This commit is contained in:
Mateusz Gruszczyński committed 2026-10-14 21:36:19 +02:00
1 parent c41d1e00a2
commit 6987b3ce33
98 files changed
+146 -10827

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.32.7. compatibility. Current source version: 0.32.8.
## Delivery ## Delivery
+7
View File
@@ -3,6 +3,13 @@
> Operator-facing release history. Historical engineering notes are kept in > Operator-facing release history. Historical engineering notes are kept in
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.32.8
- Keep the Overview title on the left with actions aligned to the right, wrapping naturally on narrow screens instead of forcing three equally sized button columns.
- Use existing quiet, outline and primary button styles in the Overview actions and visual editor; remove redundant Overview-specific button classes and obsolete mobile button overrides without changing the shared presets.
- Remove the complete `tools/checks/` directory from the distributed source tree. Keep the source/upgrade preflight in `scripts/preflight.py` and run Rust tests directly from `dev.sh`.
- No changes to saved layout preferences, Overview JavaScript event handlers, proxy routing or monitoring behavior.
## 0.32.7 development follow-up (UI density and H2 diagnostics) ## 0.32.7 development follow-up (UI density and H2 diagnostics)
- Remove per-location button size overrides for Overview refresh/arrangement/unlock and Appearance visual editor/show-all; defer to shared Normal/Compact button sizing. - Remove per-location button size overrides for Overview refresh/arrangement/unlock and Appearance visual editor/show-all; defer to shared Normal/Compact button sizing.
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]] [[package]]
name = "rust-proxy" name = "rust-proxy"
version = "0.32.7" version = "0.32.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rust-proxy" name = "rust-proxy"
version = "0.32.7" version = "0.32.8"
edition = "2021" edition = "2021"
rust-version = "1.99" rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration" description = "RustProxy - independent reverse proxy with local administration"
+14 -8
View File
@@ -1,4 +1,4 @@
# RustProxy 0.32.7 # RustProxy 0.32.8
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md). **0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
@@ -7,6 +7,12 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos
**0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.** **0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.**
## 0.32.8: Overview action layout
- The Overview heading stays left-aligned while the refresh, arrangement and layout-lock actions flow to the right; small screens wrap buttons without squeezing them into three equal columns.
- The inline layout actions and visual editor keep their existing IDs and behavior while sharing the standard button styles (including Compact mode).
- The `tools/checks/` source-check directory is no longer shipped. `./dev.sh doctor` and `./dev.sh repair` use `scripts/preflight.py`; `./dev.sh test` runs Cargo checks and Rust tests.
## 0.32.7 development follow-up: Appearance and H2 diagnostics ## 0.32.7 development follow-up: Appearance and H2 diagnostics
- Overview **Refresh now**, **Arrange Overview**, **Unlock layout**, and Appearance **Visual Overview editor** / **Show all cards** use the common button sizing, including the global **Compact** preset on mobile. - Overview **Refresh now**, **Arrange Overview**, **Unlock layout**, and Appearance **Visual Overview editor** / **Show all cards** use the common button sizing, including the global **Compact** preset on mobile.
@@ -735,7 +741,7 @@ well as the old source, not just the binary.
`script` responsibilities are intentionally separated: `script` responsibilities are intentionally separated:
- `scripts/runtime`, `scripts/build`, `scripts/deploy`, `scripts/migration` are coupled to application/build/deployment behavior; - `scripts/runtime`, `scripts/build`, `scripts/deploy`, `scripts/migration` are coupled to application/build/deployment behavior;
- `tools/checks`, `tools/dev`, `tools/audit`, `tools/legacy` are auxiliary tools and are not needed by the running proxy; - `tools/dev`, `tools/audit`, `tools/legacy` are optional operator/development helpers; the removed `tools/checks` suite is not shipped;
- `integrations/` contains external platform helpers such as OPNsense ACME and RouterOS OCI tooling. - `integrations/` contains external platform helpers such as OPNsense ACME and RouterOS OCI tooling.
See `scripts/README.md`, `tools/README.md` and `integrations/README.md`. See `scripts/README.md`, `tools/README.md` and `integrations/README.md`.
@@ -748,13 +754,13 @@ See `scripts/README.md`, `tools/README.md` and `integrations/README.md`.
[roadmap](ROADMAP.md), [changelog](CHANGELOG.md), [verification](docs/TEST_REPORT.md). [roadmap](ROADMAP.md), [changelog](CHANGELOG.md), [verification](docs/TEST_REPORT.md).
```bash ```bash
# Rust-independent checks: # Source preflight without Rust:
python3 tools/checks/check_source.py ./dev.sh doctor
python3 tools/checks/release_smoke.py # Cargo validation and the Rust test suite (Rust required):
# Runs those checks first, then the Rust suite when Cargo is available: ./dev.sh check
bash dev.sh test ./dev.sh test
# Source-only package, with no Git checkout required: # Source-only package, with no Git checkout required:
python3 make_zip.py /tmp/RustProxy-0.28.1.zip python3 make_zip.py /tmp/RustProxy-0.32.8.zip
``` ```
## New management and test-origin guide ## New management and test-origin guide
+7
View File
@@ -1,5 +1,12 @@
# RustProxy roadmap # RustProxy roadmap
## 0.32.8 - Overview action alignment (runtime acceptance pending)
- [x] Replace mobile three-column Overview actions with natural wrapping: keep the heading at left, actions to the right, without shrinking buttons.
- [x] Remove redundant button-only classes and outdated small-screen overrides while preserving existing common button variants, layout controls and editor handlers.
- [x] Remove the complete `tools/checks/` directory and update affected scripts and docs; retain the upgrade-safety preflight.
- [ ] Build and exercise RustProxy against a live backend and real administration session in a Rust-enabled environment.
## 0.32.7 development follow-up: Appearance and H2-close diagnostics ## 0.32.7 development follow-up: Appearance and H2-close diagnostics
- [x] Use common button sizing for Overview quick actions and Appearance visual editor/show-all; preserve the normal and compact global button styles. - [x] Use common button sizing for Overview quick actions and Appearance visual editor/show-all; preserve the normal and compact global button styles.
+19 -96
View File
@@ -1,18 +1,18 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
c38904ea10c12fea702f54dbbcad75096a4e89148532dea040a346ecc18c3b8c AGENTS.md 238101ec7b2518840b166ef6e778a3e6ed835dd493b3a36e200bcdc6e6aa22a7 AGENTS.md
56df1fa7123e95ca19e67ca11933ec34336216a0f46083121051bac7a1719dbc CHANGELOG.md ef8f6cbd5adf52cbd5f0c58263b4ea36e9ef0212a9579f43f4ad45d2c478be2f CHANGELOG.md
5d8c6feb5585b8880f7d016dec83f2a8f6cdcbaa7eb5ccc840a6064385d22133 Cargo.lock 1aa14f02650d326337f2acb5953cd2caaae963ac463db2c9e0d0f8d0689b0ae2 Cargo.lock
931e433a8f2e2582bdd76180c60c6c40978a3bb29f66bbd5219a45587a052d95 Cargo.toml daef03750129b52867850bd75bccef198ca45194c42b9d91d17e039e2e114a35 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
d4f497119527aba2681637caef3f4e1dd8615decc1f691d51e8857108e4cb00d README.md 0460e1ace69d57c8d1ec79634dfb55c8744e3ac01f158fadfcf6c0d9cb6d9f65 README.md
186faafa487edc7a23a1200c8ef8641b00ada263fe5bf994eff2aa76aa94984f ROADMAP.md 9297fa45ba5df2490c2ec38da8fb6e659571e2b16363f3c9933c08c08d76c6e2 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
8397a12bd76b6abb251a9599f7b9be92e4e5a9addaa40ef35ef6942c3ba9994a dev.sh c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
@@ -29,7 +29,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
627f1d8727321ef8961b153aabab7448f239fa383177bb9ef115cfeef48452da docs/API.md 627f1d8727321ef8961b153aabab7448f239fa383177bb9ef115cfeef48452da docs/API.md
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md d30551a52ffd34c5963094d2826ba39795210a4e6a4e4d7333066dae7456fc50 docs/ARCHITECTURE.md
fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md 9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md 3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
@@ -43,8 +43,8 @@ a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.m
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md 3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md 4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md 7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
633ed36201bf8c0c72d21596183aac9f28113c0ad2d9154abd532a8cb7c91543 docs/MEMORY_STRESS.md d8829cc68d982c933d04207cf7bc78cc918044c72e024e7ecad5220953bb3a79 docs/MEMORY_STRESS.md
ea42422dc6c0e2b49a1f0aad3ca6a7d7dd1faa6250b61109445b64210bef9e8a docs/MIGRATION.md e36247774051aa9c83c1a2fda91c1f7857991dcaa4fa90320ca5b492e31dbcf8 docs/MIGRATION.md
5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md 5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md 1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md
@@ -61,9 +61,9 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
3ab9fa2a537c00c13f294a09d0109e6a1c2e10f105902b84104bb9b613e61993 docs/SETTINGS.md 4dcf3243a13d9ae1827b5cefdbb9e27bd6310321c50d4d5934527d44437df6ef docs/SETTINGS.md
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/TEST_REPORT.md 81e8d3c8fe409a1a4fa01fc3eedda97b5d9288721c3b7189c29f865616389d2a docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
@@ -94,7 +94,7 @@ c73b814a42bb4ff703441dbd177ee625055eeb115f9b5bb0c2150aede573a159 integrations/o
cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/routeros-oci-build/README.md cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/routeros-oci-build/README.md
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
a1f873bfe87d090bac46962598aeb90ddfa62ae257b3b76ec1af011c74197bac langs/en_US.json a1f873bfe87d090bac46962598aeb90ddfa62ae257b3b76ec1af011c74197bac langs/en_US.json
ca95ffe6919c07699155d1c49fa711916f91f0fbe928e5a36be23f05519ffbbb langs/pl_pl.json ca95ffe6919c07699155d1c49fa711916f91f0fbe928e5a36be23f05519ffbbb langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
@@ -122,7 +122,8 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
f4bc093128ab0d8091f9e4e47d61bfaeacbebb0cf6e3b523cf7ddfdf5996aed7 scripts/resolve_dependencies_0312.sh 63ed91f4cd77450cfe04f4bdb6e5f866b7868afa10207f78041e6fcd86d96fb0 scripts/preflight.py
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh 174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
@@ -156,7 +157,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs 74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs 33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
ba3fe90521efd976dfeed3c353fed05e335a35e1c38842a31e7429802a22ae23 src/admin/openapi.json 83490218da65fe4354052dcccce48b59e00869b16012c4cc7753d66f29426eba src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -213,7 +214,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
1335cf75c5da63bdb2b9978aa79ec3ad37fb66e6c3cb38647375227c37c2442f src/admin/web/index.html 16c453dca88bb8ce2c722d6b9694c18d51ced22f8948ce99112781c644da1419 src/admin/web/index.html
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js 03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
@@ -223,7 +224,7 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/
c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
57081d31d7a7b7842e45725848fa4a4e6595e0cf769c7969797202ca99be439f src/admin/web/style.css 12670b09844212757ff6ed101906fa5251502f0b07de41da4994c5c0971eec1f src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
69f412bd897e45f4889b71e9982cfff56df9ef20ba2da109b454713a5ecd7c74 src/admin/web/transform-panel.js 69f412bd897e45f4889b71e9982cfff56df9ef20ba2da109b454713a5ecd7c74 src/admin/web/transform-panel.js
d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
@@ -492,86 +493,8 @@ f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs 347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs 728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py 358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
1a7ff9b28ade57b99d054c703496b51949dd1a8e7ed67bc5630f94a7df258cb5 tools/checks/admin_panel_config_0314_ui_test.mjs
e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/admin_public_origin_0314_config_sim.mjs
0060f8217640403c6af2d5091fdc9087d0fb2fc30cd545a3c58277a396d8121c tools/checks/admin_public_origin_0314_sim.mjs
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
c9d0bef4b8ed7a2f49b0fad3e116cace62d3e6e1ca31e02a3073d1fdde5157d8 tools/checks/admin_themes_0323_ui_test.mjs
e6462ce0fee8adb96ba22e275eb8cc1662c3bdf2e5ff53e7e0c8a55c8ce936ab tools/checks/appearance_density_buttons_0327.py
b305998e8314bf79fe087ccd37676dd6f7139b88559e7c1132acdb646cb3631a tools/checks/appearance_density_buttons_0327_browser.py
5b7d146a4558b2435fb4e8813db9e20e446988003fff884b3e132b28d926fa3c tools/checks/appearance_ux_0323_browser.py
8b6e1a355cae58901118b399fa2d165f7e3fd8465c89cac82776906b8b23f0a6 tools/checks/appearance_ux_0323_ui_test.mjs
f3dba206c60cd2e6fcc5f4a2c8d652ccf47362694448312cc1f8dc4112d93e7e tools/checks/backend_cache_0297_ui_test.mjs
e82765ae946461952e68369f0b16f0a99f01e625bd61d2551513504f7b2f4d46 tools/checks/backend_dashboard_0323_ui_test.mjs
45cd21192248ffe6f66c20bab03da1a5ce299f040883764d09a640859e19924d tools/checks/backend_health_0300_ui_test.mjs
a135c9456143975279f18233caab44b8c64f5025ef4ceeb1f2ad22090b588019 tools/checks/backend_protocol_0326_ui_test.mjs
72e20f15a029d1a3596c7d05979354eb78dacd1de979f6933aed0994cfe76179 tools/checks/certificate_api_0319.py
526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs
941eacd99d652916901ef3230e6e3c8489121a1676646413283e59093f8ad9c0 tools/checks/check_source.py
a1ac2ab24958c6e8527c7797f224f94e765abd7173d4e5085a64362b193112c1 tools/checks/dashboard_appearance_ribbon_0326_browser.py
3729e4e4332ebb1b943c502e432845d6ae852ca971cdd04bc204813c9c81e016 tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs
dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
7508f232a9e48049002dd4c9f34f9737bc92d304c51219f50430578dbc721855 tools/checks/dashboard_inline_layout_0323_ui_test.mjs
dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/dashboard_layout_editor_0323_ui_test.mjs
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
4be5f99949f3fb51fcaadf04be36a75da1f94b8aa6e081a29fd9c0ba6c4682ea tools/checks/dependency_upgrade_0312.py
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
d56f40095c4f51c57ef7456dd15c63c134c46a79f6ab9835848a05422b484eb3 tools/checks/docs_check.py
da9e0128adfb45d4f18bc9f691ebc42c4c2f449a5b3f6728bb83f65d7d3c5fd8 tools/checks/dynamic_acl_0325.py
9c77764561b6ebfeecbc228f43ea29a0dd9f44121ad773c2e4299b92758e45c2 tools/checks/dynamic_acl_startup_0325_ui_test.mjs
5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs
72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs
5159c1a2ee8610f9646e425e5f1a5d093842fe58859aa87fe94e5c2def9c5042 tools/checks/h3_navigation_0316.py
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
fb8619444edf81afa604194f750dc76335394a73c74324eda59e392874f29ee8 tools/checks/ios_scroll_0326_browser.py
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
c56a7763250f8cf1ac011feb0c90a06309bd680aeb6da6e1d1f825fbb2a573aa tools/checks/live_diagnostics_0326_browser.py
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py
ce4fb1793eeb76ccb888af48bf92312638ea7b056250d3a9328f47919a0958e7 tools/checks/mobile_protocol_0326.py
db40b3940989dd6e32459574b4a5ba3219ebec8e2e68dea1f71ec4a9e53e78b0 tools/checks/mobile_protocol_0326_browser.py
af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs
0387a9e29244b58cf37a6673a88ae2b7fea3aa965c032423f3783a84eab4ba05 tools/checks/overview_0321_layout_regression.mjs
7fda61ae66a6e3f1ada1304440c09e1f43d0e577b3a5eac8331afe86fc5eebcb tools/checks/overview_extra_metrics_0323_ui_test.mjs
48dd1e13b83dc897c735028c93e17200cf4ed9ce175475dc5641ec3236569c38 tools/checks/overview_grid_spacing_0323_browser.py
0575423efc24465d276226f45e3b9ef4d2dd1ae0bfbb58e257a10b2184a17d2a tools/checks/overview_gutters_0324_browser.py
b565aef4594265350240c24067a554f91fd605d0e920c03eef0b5b62c3934aad tools/checks/overview_registry_0323_ui_test.mjs
f450e97dccf75c24e228e696f21ed4ae826a823ea4edde2429894a6b6b36eaab tools/checks/overview_uniform_grid_0323_ui_test.mjs
644ca805fd7072a3087cfb8c4a41fc53eefe0e902e5d1524d8f892657970493f tools/checks/overview_wide_masonry_0323_ui_test.mjs
1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs
8ff6d76ba2465794135b532358ec6fe448928293adcc64fffa3cd917f5c11662 tools/checks/passive_h2_0327.py
eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/preflight.py
964253df6a6143a0a6bfeeba05bbb57f8eaedf7746125c9e230e265403ac1983 tools/checks/preserve_host_0324.py
3352941648add6d8a8044c0b5cad6bf264546d38c884a5beed64cd95addd850c tools/checks/preserve_host_0324_ui_test.mjs
64984e55d93617ac61c83f19b91a2f70402057a47a1962dcbcfd0b7d904f6098 tools/checks/rate_pause_0326_ui_test.mjs
599e0fa12fe95c7087a25801a26a590bbb473f24f038e0be157449daecec5593 tools/checks/refresh_0310_ui_test.mjs
c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs
333d4974a08c8d86155522a24037bdb9a21e578b32770cf0fcff2f3192209775 tools/checks/release_0318.py
e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs
ebfd4ced216bd0eac26bcb47fade5590759520b821af928df196c06ac1d2c7f3 tools/checks/release_smoke.py
31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 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
b7ac88b96fae3b897576554e1a330b38cf3dbdcae54d4d239a207a240fd19eb4 tools/checks/security_logs_0315_ui_test.mjs
011bbe65dae072999a3951aba3d74a520926abf836b784cdb2395023d2942867 tools/checks/source_module_contracts.py
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
be78df1e438d76be83e15398b310c9843ae5965b0ec6b41279b3a9650c313d8a tools/checks/stream_0320_ui_test.mjs
6c7d20fa2abb55de997c19abd07ec1afa8d5121c91e07f231976bcdfbfbba778 tools/checks/stream_0320_visibility_regression.mjs
8542f5c668155aa9f70de11e6fdaf64c5a80629a15ecc5f1d8c40f3d655f9b55 tools/checks/system_info_0301_ui_test.mjs
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
9aa7bc1018291613209f529fbf1a303481efa1df68abb77c6deee31c84a36ce0 tools/checks/upstream_protocols_0323.py
a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
bdc20ccb7ee8c67eab6ef22b2011a99079d69fea49d03ab634da7de1b1e7b909 tools/checks/vhost_protocol_badge_group_0326_browser.py
ea98e8ce2bdc841814e486ea4c13f94e284b2c50a5887598b7a288f75a14deaf tools/checks/vhost_www_ui_test.mjs
2df48af5b438eb811a5babd6401db830c6fa77ed5bb03bf80f689da8440c93e1 tools/checks/web_assets.py
8d2e1837f94302d09a704aca452cc367e6892f289aca3dc2d82c086a2e7de26d tools/checks/web_cache_revalidation_0322.py
8ec055a16198701dd4fa35eac42a71b666d2b9bbe90edb1c797c7ed759615487 tools/checks/web_server_contracts.py
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
4c541640a7edcdb69319770d6359e7715617df8f5443f82b29f867a5e820a08e tools/checks/www_redirect_ui_test.mjs
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py 7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py 2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
+3 -74
View File
@@ -50,7 +50,7 @@ RustProxy ${RUSTPROXY_VERSION}
Start a LOOPBACK-ONLY fake Tinyauth peer (never production) Start a LOOPBACK-ONLY fake Tinyauth peer (never production)
./dev.sh acme --doctor Check local Certbot availability without network access ./dev.sh acme --doctor Check local Certbot availability without network access
./dev.sh acme --cert-dir DIR Read provider request JSON from stdin; stage PEM files ./dev.sh acme --cert-dir DIR Read provider request JSON from stdin; stage PEM files
./dev.sh test Run offline release checks and all Rust tests ./dev.sh test Check all Rust targets and run Rust tests
./dev.sh build Build the development binary only ./dev.sh build Build the development binary only
./dev.sh release Build an optimized binary (target/release/rust-proxy) ./dev.sh release Build an optimized binary (target/release/rust-proxy)
./dev.sh fmt Format Rust sources with rustfmt ./dev.sh fmt Format Rust sources with rustfmt
@@ -75,7 +75,7 @@ if ! command -v python3 >/dev/null 2>&1; then
fi fi
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else "Python 3.10+ is required.")' python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else "Python 3.10+ is required.")'
if [[ "$mode" == "doctor" ]]; then if [[ "$mode" == "doctor" ]]; then
python3 tools/checks/preflight.py python3 scripts/preflight.py
printf 'Source layout: OK\n' printf 'Source layout: OK\n'
for tool in cargo rustc cc python3; do for tool in cargo rustc cc python3; do
printf '%s: ' "$tool" printf '%s: ' "$tool"
@@ -86,79 +86,8 @@ fi
if [[ "$mode" == "acme" ]]; then exec python3 scripts/runtime/acme_provider.py "$@"; fi if [[ "$mode" == "acme" ]]; then exec python3 scripts/runtime/acme_provider.py "$@"; fi
if [[ "$mode" == "mock-auth" ]]; then exec python3 tools/dev/mock_auth.py "$@"; fi if [[ "$mode" == "mock-auth" ]]; then exec python3 tools/dev/mock_auth.py "$@"; fi
if [[ "$mode" == "mock" ]]; then exec python3 tools/dev/mock_server.py "$@"; fi if [[ "$mode" == "mock" ]]; then exec python3 tools/dev/mock_server.py "$@"; fi
python3 tools/checks/preflight.py --repair-known python3 scripts/preflight.py --repair-known
if [[ "$mode" == "repair" ]]; then printf 'Source preflight: OK\n'; exit 0; fi if [[ "$mode" == "repair" ]]; then printf 'Source preflight: OK\n'; exit 0; fi
if [[ "$mode" == "test" ]]; then
python3 tools/checks/rust_module_contracts.py
python3 tools/checks/h3_navigation_0316.py
python3 tools/checks/release_0317.py
python3 tools/checks/release_0318.py
python3 tools/checks/certificate_api_0319.py
python3 tools/checks/source_pruning_0317.py
python3 tools/checks/system_info_no_subprocess_contracts.py
python3 tools/checks/source_module_contracts.py
python3 tools/checks/version_contracts.py
python3 tools/checks/upstream_protocols_0323.py
python3 tools/checks/preserve_host_0324.py
python3 tools/checks/dynamic_acl_0325.py
python3 tools/checks/appearance_density_buttons_0327.py
python3 tools/checks/mobile_protocol_0326.py
python3 tools/checks/check_source.py
python3 tools/checks/release_smoke.py
python3 tools/checks/memory_contracts.py
python3 tools/checks/high_traffic_memory_model.py
python3 tools/checks/web_server_contracts.py
python3 tools/checks/web_cache_revalidation_0322.py
python3 tools/checks/security_contracts.py
python3 tools/checks/admin_surface_audit_0310.py
python3 tools/checks/security_dependency_contracts.py
python3 tools/checks/dependency_upgrade_0312.py
if command -v node >/dev/null 2>&1; then
node tools/checks/web_service_ui_test.mjs
node tools/checks/backend_cache_0297_ui_test.mjs
node tools/checks/backend_health_0300_ui_test.mjs
node tools/checks/system_info_0301_ui_test.mjs
node tools/checks/refresh_0310_ui_test.mjs
node tools/checks/stream_0320_ui_test.mjs
node tools/checks/stream_0320_visibility_regression.mjs
node tools/checks/overview_registry_0323_ui_test.mjs
node tools/checks/dynamic_acl_startup_0325_ui_test.mjs
node tools/checks/preserve_host_0324_ui_test.mjs
node tools/checks/backend_dashboard_0323_ui_test.mjs
node tools/checks/dashboard_layout_editor_0323_ui_test.mjs
node tools/checks/dashboard_inline_layout_0323_ui_test.mjs
node tools/checks/admin_themes_0323_ui_test.mjs
node tools/checks/appearance_ux_0323_ui_test.mjs
node tools/checks/dashboard_appearance_ribbon_0326_ui_test.mjs
node tools/checks/backend_protocol_0326_ui_test.mjs
node tools/checks/overview_extra_metrics_0323_ui_test.mjs
node tools/checks/rate_pause_0326_ui_test.mjs
node tools/checks/overview_wide_masonry_0323_ui_test.mjs
node tools/checks/overview_uniform_grid_0323_ui_test.mjs
node tools/checks/overview_0321_layout_regression.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/admin_public_origin_0314_sim.mjs
node tools/checks/admin_public_origin_0314_config_sim.mjs
node tools/checks/admin_panel_config_0314_ui_test.mjs
node tools/checks/security_logs_0315_ui_test.mjs
node tools/checks/file_logs_tabs_0315_ui_test.mjs
node tools/checks/release_0317_ui_test.mjs
node tools/checks/release_0318_ui_test.mjs
node tools/checks/docker_startup_0318_ui_test.mjs
node tools/checks/certificate_api_0319_ui_test.mjs
node tools/checks/h3_cache_mobile_0315_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
node tools/checks/vhost_www_ui_test.mjs
else
printf 'Node.js not found; skipping Web Service UI behavior test.\n' >&2
fi
python3 tools/checks/docs_check.py
python3 tools/checks/langs_check.py
fi
if ! command -v cargo >/dev/null 2>&1; then if ! command -v cargo >/dev/null 2>&1; then
cat >&2 <<'MISSING' cat >&2 <<'MISSING'
Rust/Cargo is not installed or not in PATH. Rust/Cargo is not installed or not in PATH.
+4 -4
View File
@@ -42,7 +42,7 @@ committed document at startup; this is not one cross-memory/SQLite transaction.
### Rust source organization ### Rust source organization
Production Rust modules are kept as small responsibility-oriented files, with a Production Rust modules are kept as small responsibility-oriented files, with a
**500-line limit** enforced by `tools/checks/rust_module_contracts.py`. Unit and **500-line engineering limit** for production modules. Unit and
integration test files are explicitly exempt: related tests may live together in integration test files are explicitly exempt: related tests may live together in
large `tests.rs` files rather than arbitrary numbered fragments. Large logical modules use a thin facade (`config.rs`, `docker.rs`, large `tests.rs` files rather than arbitrary numbered fragments. Large logical modules use a thin facade (`config.rs`, `docker.rs`,
`server.rs`, `runtime.rs`, `balance.rs`, `prometheus.rs`, and similar) plus files in a `server.rs`, `runtime.rs`, `balance.rs`, `prometheus.rs`, and similar) plus files in a
@@ -52,9 +52,9 @@ submodules for configuration, filesystem confinement, responses, routing, FastCG
Unit-test implementations are not embedded in production source files. They live in Unit-test implementations are not embedded in production source files. They live in
`tests.rs` or `tests/` files and are included or declared only behind `#[cfg(test)]`. `tests.rs` or `tests/` files and are included or declared only behind `#[cfg(test)]`.
Source-level CI also checks `include!` targets, balanced split fragments and dangling Keep `include!` targets, split fragments and attributes/doc comments alongside
attributes/doc comments. This prevents a file boundary from separating `#[derive]`, the items they describe. After removal of the old source-check scripts, review
`#[test]` or documentation from the Rust item it belongs to. these invariants in code review and verify compilability with `./dev.sh check`.
Docker discovery is an optional provider boundary implemented through the standalone Docker discovery is an optional provider boundary implemented through the standalone
`docker-agent/`; the RustProxy process never opens the Docker socket. Cloudflare Tunnel `docker-agent/`; the RustProxy process never opens the Docker socket. Cloudflare Tunnel
+5 -10
View File
@@ -28,20 +28,15 @@ with real-runtime measurements rather than assuming either explanation.
## Source/synthetic verification ## Source/synthetic verification
```sh ```sh
python3 tools/checks/memory_contracts.py
python3 tools/checks/high_traffic_memory_model.py --http-requests 10000
./dev.sh check ./dev.sh check
./dev.sh test ./dev.sh test
# Perform separate real-runtime load and RSS measurements on staging.
``` ```
The offline test replays 300,000 distinct PURGE identities over six periods. The old Python cardinality model and protocol-peer load driver were removed
It verifies the old cumulative model retains 50k/100k/150k/200k/250k/300k with `tools/checks/`. They were synthetic tests, not proof of RustProxy memory
metadata keys whereas the revised epoch-based model holds one key after each bounds. `./dev.sh test` now runs only the Rust suite; runtime memory behavior
operation. This is a **cardinality model**, not a Rust heap measurement. It also must still be verified by sustained staging traffic and RSS tracking.
sends 10,000 HTTP GETs across 24 concurrent persistent connections and 1,000
paths to a **Python test peer** to validate the load driver (the default quick
`./dev.sh test` run sends 2,000 of those requests). The Rust regression
for 512 distinct purges is executed only if `cargo test` runs successfully.
## Real RustProxy staging traffic and RSS ## Real RustProxy staging traffic and RSS
+1 -1
View File
@@ -414,7 +414,7 @@ Managed paths now include build.rs, langs, deploy and make_zip.py. Extra *.json
language packs from the installed langs directory are copied into the new tree language packs from the installed langs directory are copied into the new tree
when the new distribution does not contain the same filename. Shipped packs are when the new distribution does not contain the same filename. Shipped packs are
updated, with old bytes preserved in the source backup. Invalid custom packs can updated, with old bytes preserved in the source backup. Invalid custom packs can
correctly block a build; review them with tools/checks/langs_check.py. No symlinked correctly block a build; review their JSON syntax and metadata before building. No symlinked
language pack is followed. The updater never copies a database from the package. language pack is followed. The updater never copies a database from the package.
The old lockfile is preserved in the managed source backup and a new dependency The old lockfile is preserved in the managed source backup and a new dependency
+8
View File
@@ -52,6 +52,14 @@ change does not alter server polling, proxy routing or backend metrics.
**Refresh now**, **Arrange Overview**, **Unlock layout**, **Visual Overview editor** and **Show all cards** inherit the same Normal/Compact button size rules as other buttons. Responsive placement still adapts to narrow screens, but no independent font-size, padding or height override applies. Dashboard density more clearly adjusts numeric tile sizing, inline chart height and the padding/title size of non-table Overview panels; the common tile gutters are unchanged. Button style and dashboard density remain independent per-account preferences. **Refresh now**, **Arrange Overview**, **Unlock layout**, **Visual Overview editor** and **Show all cards** inherit the same Normal/Compact button size rules as other buttons. Responsive placement still adapts to narrow screens, but no independent font-size, padding or height override applies. Dashboard density more clearly adjusts numeric tile sizing, inline chart height and the padding/title size of non-table Overview panels; the common tile gutters are unchanged. Button style and dashboard density remain independent per-account preferences.
### Overview header actions (0.32.8)
On the Overview page, **Traffic, in focus.** remains on the left and actions
align to the right on the same row when space permits. Buttons wrap to the right
on very narrow displays instead of being compressed into equal-width columns.
The existing Normal/Compact button preferences, editor confirmation actions,
layout locking and account-specific saved card layout are unchanged.
## Refresh preferences (0.31.0) ## Refresh preferences (0.31.0)
**Settings -> Appearance -> Data refresh** saves an additional `refresh` object in the existing `admin_preferences` document for the signed-in account. It does not modify proxy configuration or create any migration. Older documents, including partially populated `refresh` objects, use defaults. Browser and server enforce exact allowed values: **Settings -> Appearance -> Data refresh** saves an additional `refresh` object in the existing `admin_preferences` document for the signed-in account. It does not modify proxy configuration or create any migration. Older documents, including partially populated `refresh` objects, use defaults. Browser and server enforce exact allowed values:
+51
View File
@@ -0,0 +1,51 @@
# RustProxy 0.32.8 - verification report
## Scope
UI-only Overview header/editor button alignment, deletion of the complete
`tools/checks/` source directory, retained source-upgrade preflight and the
0.32.8 version bump. No proxy request path, backend behavior, preferences
schema or dashboard JavaScript event handlers were changed.
## Checks performed
- **PASS** - Chromium headless checked the actual Overview HTML and CSS at
viewport widths 320, 360, 375, 390, 430, 480, 600, 700, 768, 1024 and
1440 px, with Normal and Compact button styles and four visibility/editing
scenarios: 88 combinations. Title stays left, available actions align right;
no overlapping buttons or page/header horizontal overflow was observed.
This is a layout fixture, not a running API/backend acceptance test.
- **PASS** - All Overview and editor controls use the original semantic
`quiet`, `outline` and `primary` classes. No CSS class names were added.
Removed obsolete dedicated button selectors. The Overview/core/refresh/layout
JavaScript files are byte-identical to 0.32.7, so existing event handling,
save/cancel and editor locking implementations were not changed.
- **PASS** - Before removing the old QA directory, the 0.32.7 UI simulation
suites `dashboard_layout_editor_0323_ui_test.mjs` and
`dashboard_inline_layout_0323_ui_test.mjs` passed. The original
`appearance_density_buttons_0327.py` source check also passed. These tests
exercised simulated UI behavior, not a live RustProxy server.
- **PASS** - `python3 scripts/preflight.py`, `./dev.sh doctor`,
`./dev.sh repair`, shell syntax of `dev.sh` and the dependency-resolution
script, all bundled language JSON files, version consistency and source
class/handler comparisons.
- **PASS** - Source-upgrade rehearsal from the supplied 0.32.7 release tree
to 0.32.8 removed `tools/checks/` from the installed working tree, retained
the previous sources under `.upgrade-backup/`, preserved `.env` and left
`./dev.sh doctor` working. No live server/database was used in this rehearsal.
- **PASS** - All eight bundled SQL migration scripts applied twice in an
in-memory SQLite database; `PRAGMA integrity_check` returned `ok`.
## Limits and outstanding acceptance
- Rust/Cargo was **not available** in the verification environment, so
`./dev.sh check`, `./dev.sh test`, binary build and live proxy/UI regression
checks were not run. These remain required before production acceptance.
- An unrelated legacy `dashboard_startup_0310_ui_test.mjs` assertion was already
failing on the supplied 0.32.7 tree due to its expected
`prefers-reduced-motion: reduce` CSS rule being absent. That test was removed
with `tools/checks/`, not claimed as passing or fixed by this patch.
- The intentionally removed `tools/checks/` suites are no longer executed by
`dev.sh`; `./dev.sh test` now runs Cargo validation and the Rust test suite.
Independent API, stress, browser and real-runtime acceptance tests must be
run externally when required.
+4 -2
View File
@@ -34,7 +34,9 @@ triggers a Cargo rebuild. Duplicate locales, invalid metadata, non-string values
and oversized packs cause a build error. Limits: 128 packs, 256 KiB per pack, and oversized packs cause a build error. Limits: 128 packs, 256 KiB per pack,
4096 keys per pack, 8 MiB total bundle. 4096 keys per pack, 8 MiB total bundle.
Run `python3 tools/checks/langs_check.py` before a build. The checker also validates Validate pack JSON locally (`python3 -m json.tool langs/en_US.json > /dev/null`,
that every static UI translation key has an English fallback. Extra JSON packs survive the managed updater when the new release does not contain repeating for custom packs). The Rust build validates translation metadata and
bundle limits. Ensure static UI keys have an English fallback. Extra JSON packs
survive the managed updater when the new release does not contain
the same filename. Keep a separate backup too; shipped filenames are replaced and the same filename. Keep a separate backup too; shipped filenames are replaced and
the previous bytes are retained in the source backup. the previous bytes are retained in the source backup.
@@ -74,7 +74,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
flat = module.parent.with_suffix(".rs") flat = module.parent.with_suffix(".rs")
if flat.is_file(): if flat.is_file():
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.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/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/rates.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/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file(): if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}") raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
# The Reader uses compile-time include_str! for its bundled Markdown. A # The Reader uses compile-time include_str! for its bundled Markdown. A
@@ -97,7 +97,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--repair-known", action="store_true") parser.add_argument("--repair-known", action="store_true")
args = parser.parse_args() args = parser.parse_args()
try: try:
+1 -2
View File
@@ -7,14 +7,13 @@ if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; the
printf 'Rust 1.99+ and Cargo are required; no lockfile was regenerated.\n' >&2 printf 'Rust 1.99+ and Cargo are required; no lockfile was regenerated.\n' >&2
exit 127 exit 127
fi fi
python3 tools/checks/preflight.py python3 scripts/preflight.py
if [[ "$(python3 scripts/version.py)" != '0.31.8' ]]; then if [[ "$(python3 scripts/version.py)" != '0.31.8' ]]; then
printf 'Expected RustProxy 0.31.8 sources.\n' >&2 printf 'Expected RustProxy 0.31.8 sources.\n' >&2
exit 1 exit 1
fi fi
printf 'Toolchain: '; rustc --version printf 'Toolchain: '; rustc --version
cargo update cargo update
python3 tools/checks/dependency_upgrade_0312.py --require-lock
cargo metadata --locked --format-version 1 --no-deps >/dev/null cargo metadata --locked --format-version 1 --no-deps >/dev/null
cargo fmt --all -- --check cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings cargo clippy --locked --all-targets -- -D warnings
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
"title": "RustProxy Administration API", "title": "RustProxy Administration API",
"version": "0.32.7", "version": "0.32.8",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
}, },
"servers": [ "servers": [
+5 -5
View File
@@ -117,10 +117,10 @@
LOCAL CORE</span></div> LOCAL CORE</span></div>
<div class="page-intro-title-row"> <div class="page-intro-title-row">
<h1 id="page-title">Traffic, in focus.</h1> <h1 id="page-title">Traffic, in focus.</h1>
<button class="quiet overview-refresh" data-i18n="refreshNow" id="overview-refresh" <button class="quiet" data-i18n="refreshNow" id="overview-refresh" type="button"
type="button" hidden>Refresh now</button> hidden>Refresh now</button>
<button class="quiet overview-layout-open" data-i18n="overviewLayoutEditorOpen" <button class="quiet" data-i18n="overviewLayoutEditorOpen" id="overview-layout-open"
id="overview-layout-open" type="button" hidden>Arrange cards visually</button> type="button" hidden>Arrange cards visually</button>
<button class="outline" id="overview-layout-unlock" type="button" hidden <button class="outline" id="overview-layout-unlock" type="button" hidden
data-i18n="overviewLayoutUnlock" aria-pressed="false">Unlock layout</button> data-i18n="overviewLayoutUnlock" aria-pressed="false">Unlock layout</button>
<div class="overview-inline-actions" id="overview-inline-actions" hidden> <div class="overview-inline-actions" id="overview-inline-actions" hidden>
@@ -2496,7 +2496,7 @@
the backend table.</small> the backend table.</small>
</div> </div>
<div class="dashboard-widget-settings-controls"> <div class="dashboard-widget-settings-controls">
<button class="outline dashboard-layout-open-settings" type="button" <button class="outline" type="button"
id="overview-layout-open-settings" id="overview-layout-open-settings"
data-i18n="overviewLayoutEditorOpenSettings">Visual layout data-i18n="overviewLayoutEditorOpenSettings">Visual layout
editor</button> editor</button>
+9 -55
View File
@@ -1290,10 +1290,6 @@ button.is-saving {
padding-top: 25px padding-top: 25px
} }
.page-intro .outline {
margin-top: 19px
}
.eyebrow { .eyebrow {
font-size: 9px; font-size: 9px;
letter-spacing: 1.2px letter-spacing: 1.2px
@@ -2201,13 +2197,6 @@ td {
font-size: 11px font-size: 11px
} }
.page-intro .outline {
font-size: 10px;
padding: 7px;
white-space: normal;
max-width: 95px
}
.page-intro .eyebrow { .page-intro .eyebrow {
letter-spacing: .5px; letter-spacing: .5px;
font-size: 8px font-size: 8px
@@ -9518,55 +9507,27 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
.page-intro-copy, .page-intro-copy,
.page-intro-title-row { .page-intro-title-row {
min-width: 0; min-width: 0;
width: 100% width: 100%;
} }
.page-intro-title-row { .page-intro-title-row {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 10px justify-content: flex-end;
gap: 10px;
} }
.page-intro-title-row h1 { .page-intro-title-row h1 {
min-width: 0; min-width: 0;
flex: 1 1 auto flex: 1 1 auto;
} margin-right: auto;
.page-intro-title-row .overview-refresh {
flex: 0 0 auto;
margin-left: auto;
white-space: nowrap
}
.page-intro-title-row>:is(.quiet, .outline) {
display: inline-flex;
align-items: center;
justify-content: center;
} }
@media(max-width:700px) { @media(max-width:700px) {
.page-intro-title-row { .page-intro-title-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: 8px; gap: 8px;
} }
.page-intro-title-row .overview-inline-actions,
.page-intro-title-row h1 {
grid-column: 1/-1;
}
.page-intro-title-row .overview-refresh {
margin-left: 0;
}
.page-intro-title-row>:is(.quiet, .outline) {
width: 100%;
min-width: 0;
white-space: normal;
text-align: center;
}
} }
@media(max-width:480px) { @media(max-width:480px) {
@@ -10398,11 +10359,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.dashboard-layout-open-settings {
margin: 5px 0;
justify-self: start;
}
@media(max-width:850px) { @media(max-width:850px) {
.overview-layout-grid, .overview-layout-grid,
@@ -10594,7 +10550,10 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
display: inline-flex; display: inline-flex;
gap: 7px; gap: 7px;
align-items: center; align-items: center;
justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
max-width: 100%;
margin-left: auto;
} }
.overview-inline-actions[hidden], .overview-inline-actions[hidden],
@@ -11137,11 +11096,6 @@ html[data-dashboard-density=ultra] #panel-overview .dashboard-overview-grid>.car
min-width: 30px; min-width: 30px;
} }
.dashboard-widget-settings-controls .dashboard-layout-open-settings {
margin: 0;
justify-self: auto;
}
.dashboard-appearance-grid .dashboard-widget-list { .dashboard-appearance-grid .dashboard-widget-list {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
+1
View File
@@ -343,6 +343,7 @@ impl BackendState {
} }
} }
#[cfg(test)]
fn observe_passive( fn observe_passive(
&self, &self,
active: &HealthCheck, active: &HealthCheck,
+1 -2
View File
@@ -2,9 +2,8 @@
These files are not required by the running RustProxy proxy process. These files are not required by the running RustProxy proxy process.
- `checks/` - compiler-independent source/release/documentation contracts and UI checks.
- `dev/` - local mock services and diagnostic traffic/compression helpers. - `dev/` - local mock services and diagnostic traffic/compression helpers.
- `audit/` - explicit operator audit/probe tools. - `audit/` - explicit operator audit/probe tools.
- `legacy/` - retained compatibility/release-test helpers; not the supported production workflow. - `legacy/` - retained compatibility/release-test helpers; not the supported production workflow.
Production packages do not need this tree unless a packaging/test workflow intentionally ships it. The removed `checks/` tree is not shipped. Use `./dev.sh check` and `./dev.sh test` for Rust validation; source/upgrade preflight lives in `scripts/preflight.py`.
@@ -1,52 +0,0 @@
// Test the real admin settings normalization helpers and visible form wiring;
// source-level UI test only, not a Rust or browser integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const js = fs.readFileSync('src/admin/web/settings.js','utf8');
const html = fs.readFileSync('src/admin/web/index.html','utf8');
const en = JSON.parse(fs.readFileSync('langs/en_US.json','utf8'));
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json','utf8'));
const api = JSON.parse(fs.readFileSync('src/admin/openapi.json','utf8'));
const begin = js.indexOf(' function pinnedAdminHostname(value) {');
const end = js.indexOf(' const normalizedRequestedTab', begin);
assert(begin >= 0 && end > begin, 'real admin form normalization helpers must exist');
const sandbox = vm.createContext({URL});
vm.runInContext(js.slice(begin,end),sandbox);
const normalize = (hosts, pin) => Array.from(vm.runInContext(
`additionalAdminHosts(${JSON.stringify(hosts)},${JSON.stringify(pin)})`,sandbox));
const cases = [
{name:'empty form',hosts:[],pin:null,expected:[]},
{name:'simple single origin',hosts:[],pin:'https://panel.example.test',expected:[]},
{name:'0.31.4 repeated host',hosts:['panel.example.test'],pin:'https://panel.example.test',expected:[]},
{name:'case and trailing dot',hosts:['PANEL.Example.Test.',' other.example.test '],pin:'https://panel.example.test:8443',expected:['other.example.test']},
{name:'multiple additional names',hosts:['foo.example.test','panel.example.test','bar.example.test'],pin:'https://panel.example.test',expected:['foo.example.test','bar.example.test']},
{name:'pin changed',hosts:['other.example.test'],pin:'https://new.example.test',expected:['other.example.test']},
{name:'no pin means independent allowlist',hosts:['panel.example.test'],pin:'',expected:['panel.example.test']},
{name:'invalid URL delegates to server validation',hosts:['other.example.test'],pin:'not a url',expected:['other.example.test']},
{name:'blank host lines removed',hosts:['',' panel.example.test ',' '],pin:null,expected:['panel.example.test']},
];
for (const item of cases) {
assert.deepEqual(normalize(item.hosts,item.pin),item.expected,item.name);
}
const admin = html.split('id="settings-page-administration"')[1].split('id="settings-page-system-info"')[0];
for (const id of ['admin-public-origin','admin-extra-hosts-details','admin-hosts'])
assert(admin.includes(`id="${id}"`),`missing ${id}`);
assert(admin.indexOf('id="admin-public-origin"') < admin.indexOf('id="admin-extra-hosts-details"'));
assert(admin.includes('<details id="admin-extra-hosts-details">'));
assert(js.includes('$("admin-extra-hosts-details").open = extraHosts.length > 0'));
assert(js.includes('additionalAdminHosts(admin.allowed_hosts || [], admin.public_origin)'));
assert(js.includes('allowed_hosts: additionalAdminHosts($("admin-hosts").value.split('));
assert(!js.includes('allowed_hosts: $("admin-hosts").value.split('));
for (const key of ['administrationHosts','administrationHostsAdvanced','administrationHostsHelp','administrationPublicOriginHelp']) {
assert.equal(typeof en.translations[key],'string'); assert.equal(typeof pl.translations[key],'string');
assert(en.translations[key].length > 15 && pl.translations[key].length > 15,key);
}
// Config contract remains compatible; the pin allows a host implicitly.
const spec = api.components?.schemas || api.definitions;
const specText=JSON.stringify(spec);
assert(specText.includes('implicitly allowed'));
assert(specText.includes('automatically allowed'));
console.log(`PASS: ${cases.length} real settings-helper scenarios, HTML/wiring, locale parity and OpenAPI descriptions`);
@@ -1,210 +0,0 @@
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
// Real-source anchors guard against accidental model/source drift. This is NOT
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
const source = {
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
};
for (const [part, tokens] of Object.entries({
settings: ['pub public_origin: Option<String>', 'fn normalize_public_admin_origin(public_origin: &str)', 'fn include_public_origin_host(&mut self)', 'fn explicitly_allowed_panel_host(&self, host: &str)', 'self.public_origin != active.public_origin'],
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
forwarding: ['self.trust_proto && self.trusted(ip)'],
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
automation:false};
const normal = x => x.toLowerCase();
function authority(value) {
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
try {
const parsed = new URL(`https://${value}/`);
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
const host = parsed.hostname.toLowerCase();
return {host, port:parsed.port ? Number(parsed.port) : null};
} catch {return null;}
}
function sameAuthority(a,b,scheme) {
const left=authority(a), right=authority(b);
if (!left || !right) return false;
const defaultPort = scheme === 'https' ? 443 : 80;
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
}
function hostAllowed(value, tls, cfg) {
const actual=authority(value);
if (!actual) return false;
const port=actual.port ?? (tls ? 443 : 80);
// Effective runtime/desired allowlist also includes the public origin host;
// only extra names are persisted in allowed_hosts.
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|| cfg.allowedHosts.includes(actual.host)
|| actual.host===pinnedHost;
}
const map = raw => {
const headers=new Map();
for (let i=0;i<raw.length;i+=2) {
const name=normal(raw[i]);
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
}
return headers;
};
const all = (headers,name) => headers.get(name)||[];
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
function protoCompatible(headers) {
let hops=0;
for (const value of all(headers,'x-forwarded-proto')) {
for (const part of value.split(',')) {
hops++;
const scheme=part.trim();
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
}
}
return true;
}
function originMatches(value,scheme,external) {
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
try {
const uri=new URL(value);
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
return sameAuthority(uri.host, external, scheme);
} catch {return false;}
}
function guard(headers,cfg) {
const rawHost=single(headers,'host');
if (!rawHost) return {status:403,reason:'invalid_host'};
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
let pinned;
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
return {status:403,reason:'pinned_origin_mismatch'};
const xfh=all(headers,'x-forwarded-host');
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
return {status:403,reason:'pinned_origin_mismatch'};
scheme='https'; external=pinned.host;
} else {
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
const proto=single(headers,'x-forwarded-proto');
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
scheme=proto;
}
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
external=single(headers,'x-forwarded-host');
if (!hostAllowed(external,scheme==='https',cfg))
return {status:403,reason:'invalid_host'};
}
const originValues=all(headers,'origin');
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
return {status:403,reason:'origin_mismatch'};
if (all(headers,'sec-fetch-site').includes('cross-site'))
return {status:403,reason:'cross_site_fetch'};
return {status:200,reason:'pass',scheme};
}
const cases=[];
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
add('legacy normal same-origin GET', {},{},'pass');
add('no pin and no DNS allowlist rejects proxied host', {},{allowedHosts:[]},'invalid_host');
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
add('legacy explicit DNS host without pin', {'host':'other.example.test','x-forwarded-host':'other.example.test'},
{allowedHosts:['other.example.test']}, 'pass');
add('legacy extra host without pin still denies unknown', {'host':'evil.example.test','x-forwarded-host':'evil.example.test'},
{allowedHosts:['other.example.test']}, 'invalid_host');
add('pin: normal GET, persisted allowed_hosts empty', {},PIN,'pass');
add('pin: public Host implicitly allowed', {'host':'panel.example.test'},PIN,'pass');
add('pin: unknown Host still rejected', {'host':'unknown.example.test'},PIN,'pinned_origin_mismatch');
add('pin: old 0.31.4 explicit hostname remains compatible', {}, {...PIN,allowedHosts:['panel.example.test']},'pass');
add('pin: additional DNS host accepted only with matching forwarded public Host',
{'host':'other.example.test'}, {...PIN,allowedHosts:['other.example.test']},'pass');
add('pin: changing public origin does not allow previous hostname',
{'host':'panel.example.test','x-forwarded-host':'new.example.test'},
{...PIN,publicOrigin:'https://new.example.test'},'pinned_origin_mismatch');
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
function encodedHeaders(patch) {
const merged={...defaults,...patch};
const result={};
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
return result;
}
const pending=[];
const server=http.createServer((req,res)=>{
const item=pending.shift();
if (!item) return res.writeHead(500).end('no scenario');
const result=guard(map(req.rawHeaders),{...context,...item.opts});
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
async function request(item) {
pending.push(item);
return await new Promise((resolve,reject)=>{
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
agent:false,headers:encodedHeaders(item.patch)},res=>{
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
res.on('end',()=>resolve(JSON.parse(data)));
});
req.on('error',reject);req.end();
});
}
try {
for (const item of cases) {
const result=await request(item);
assert.equal(result.reason,item.expected,item.name);
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
}
const repeats=[];
for (const configured of [false,true]) {
for (let attempt=1;attempt<=12;attempt++) {
const malformed=attempt%4===0;
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
opts:configured? PIN : {}});
assert.equal(result.status,configured?200:(malformed?403:200));
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
}
}
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
} finally {server.close();}
@@ -1,196 +0,0 @@
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
// Real-source anchors guard against accidental model/source drift. This is NOT
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
const source = {
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
};
for (const [part, tokens] of Object.entries({
settings: ['pub public_origin: Option<String>', 'fn include_public_origin_host(&mut self)', 'self.public_origin != active.public_origin'],
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
forwarding: ['self.trust_proto && self.trusted(ip)'],
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
automation:false};
const normal = x => x.toLowerCase();
function authority(value) {
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
try {
const parsed = new URL(`https://${value}/`);
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
const host = parsed.hostname.toLowerCase();
return {host, port:parsed.port ? Number(parsed.port) : null};
} catch {return null;}
}
function sameAuthority(a,b,scheme) {
const left=authority(a), right=authority(b);
if (!left || !right) return false;
const defaultPort = scheme === 'https' ? 443 : 80;
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
}
function hostAllowed(value, tls, cfg) {
const actual=authority(value);
if (!actual) return false;
const port=actual.port ?? (tls ? 443 : 80);
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
// The validated public panel hostname is derived at runtime.
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|| cfg.allowedHosts.includes(actual.host)
|| actual.host===pinnedHost;
}
const map = raw => {
const headers=new Map();
for (let i=0;i<raw.length;i+=2) {
const name=normal(raw[i]);
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
}
return headers;
};
const all = (headers,name) => headers.get(name)||[];
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
function protoCompatible(headers) {
let hops=0;
for (const value of all(headers,'x-forwarded-proto')) {
for (const part of value.split(',')) {
hops++;
const scheme=part.trim();
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
}
}
return true;
}
function originMatches(value,scheme,external) {
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
try {
const uri=new URL(value);
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
return sameAuthority(uri.host, external, scheme);
} catch {return false;}
}
function guard(headers,cfg) {
const rawHost=single(headers,'host');
if (!rawHost) return {status:403,reason:'invalid_host'};
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
let pinned;
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
return {status:403,reason:'pinned_origin_mismatch'};
const xfh=all(headers,'x-forwarded-host');
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
return {status:403,reason:'pinned_origin_mismatch'};
scheme='https'; external=pinned.host;
} else {
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
const proto=single(headers,'x-forwarded-proto');
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
scheme=proto;
}
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
external=single(headers,'x-forwarded-host');
if (!hostAllowed(external,scheme==='https',cfg))
return {status:403,reason:'invalid_host'};
}
const originValues=all(headers,'origin');
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
return {status:403,reason:'origin_mismatch'};
if (all(headers,'sec-fetch-site').includes('cross-site'))
return {status:403,reason:'cross_site_fetch'};
return {status:200,reason:'pass',scheme};
}
const cases=[];
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
add('legacy normal same-origin GET', {},{},'pass');
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
add('pin: normal GET', {},PIN,'pass');
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
function encodedHeaders(patch) {
const merged={...defaults,...patch};
const result={};
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
return result;
}
const pending=[];
const server=http.createServer((req,res)=>{
const item=pending.shift();
if (!item) return res.writeHead(500).end('no scenario');
const result=guard(map(req.rawHeaders),{...context,...item.opts});
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
async function request(item) {
pending.push(item);
return await new Promise((resolve,reject)=>{
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
agent:false,headers:encodedHeaders(item.patch)},res=>{
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
res.on('end',()=>resolve(JSON.parse(data)));
});
req.on('error',reject);req.end();
});
}
try {
for (const item of cases) {
const result=await request(item);
assert.equal(result.reason,item.expected,item.name);
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
}
const repeats=[];
for (const configured of [false,true]) {
for (let attempt=1;attempt<=12;attempt++) {
const malformed=attempt%4===0;
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
opts:configured? PIN : {}});
assert.equal(result.status,configured?200:(malformed?403:200));
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
}
}
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
} finally {server.close();}
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""Fail-closed source regression checks. Not a replacement for running Rust tests."""
from pathlib import Path
import re
root = Path(__file__).resolve().parents[2]
def read(name):
return (root / name).read_text(encoding="utf-8")
def must(source, *text):
for item in text:
if item not in source:
raise AssertionError(f"missing security invariant: {item}")
router = read('src/admin/router.rs')
access = read('src/admin/access.rs')
session = read('src/admin/session.rs')
proxy = read('src/proxy/forward_entry.rs')
maintenance = read('src/proxy/cache_http_invalidation.rs')
operations = read('src/admin/operations.rs')
cache_policy = read('src/cache/model/http_invalidation.rs')
app = read('src/runtime/types.rs') + read('src/runtime/constructors.rs')
must(router,
'fn anonymous_allowed', 'panel_document_path(path)',
'/api/auth/status', '/api/auth/setup', '/api/auth/login',
'.layer(middleware::from_fn_with_state(state.clone(), guard))',
'"content-security-policy"', '"x-frame-options"',
'"x-content-type-options"', '"no-referrer"',
'"/api/system-info", get(system_info)',
'"/api/events", get(events)',
'"/api/cache/invalidate"',
'"/api/config", get(configuration).merge(put(update))')
must(session,
'if !public && session.is_none()',
'if unsafe_method && !external_certificate_api',
'single_header(headers, "origin")',
'single_header(headers, "x-rustproxy-request") != Some("1")',
'single_header(headers, "x-csrf-token")',
'auth::equal_secret',
'session_at_with_browser_lifetime',
'secure(next.run(request).await)',
'h == "cross-site"')
must(access, 'admin_request_authority', 'admin_origin_matches',
'trusted_admin_proxy', 'single_header(headers, "x-forwarded-host")',
'certificate_api_credentials', 'valid_certificate_api_token')
must(session, 'verify_certificate_api_credential')
# Only the intended bootstrap/discovery APIs may appear inside the anonymous allowlist.
allowlist = router.split('fn anonymous_allowed(', 1)[1].split('\n}', 1)[0]
allowed_api_paths = set(re.findall(r'"(/api/[^" ]+)"', allowlist))
assert allowed_api_paths == {'/api/auth/status', '/api/auth/setup', '/api/auth/login'}, allowed_api_paths
must(maintenance,
'permits_http_invalidation(client_ip)',
'snapshot.forwarding.trusted(peer_ip)',
'return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity',
'cache_http_invalidation_request(request.method()',
'duplicate BAN/PURGE control header',
'request.body().is_end_stream()',
'get_all(http::header::CONTENT_LENGTH)',
'http::header::TRANSFER_ENCODING',
'X-RustProxy-Confirm-Wide: 1',
'"{scheme}://{authority}{target}"',
'cache_invalidation_slots.clone().try_acquire_owned()',
'state.store.audit(',
'tokio::task::spawn_blocking',
'let _permit = permit;',
'StatusCode::UNPROCESSABLE_ENTITY',
'StatusCode::TOO_MANY_REQUESTS')
# Axum 0.8 requires the `forward` fallback future to be Send. Its Body is
# not Sync, so an async helper must own Request<Body> rather than retain
# &Request<Body> across the audit/cache await points.
async_maintenance = maintenance.split('async fn cache_http_invalidation(', 1)[1].split(') -> Response {', 1)[0]
assert 'request: Request,' in async_maintenance, 'cache maintenance must own Request<Body> in its async future'
assert 'request: &Request' not in async_maintenance, 'borrowed Request<Body> makes fallback handler non-Send'
must(maintenance, 'let method = request.method().clone();', 'drop(request);')
assert maintenance.index('drop(request);') < maintenance.index('state.store.audit('), 'drop body before asynchronous audit'
assert 'frontend, &host, request, context.client_ip' in proxy, 'forward must transfer request ownership'
must(proxy, 'snapshot.host_allowed(&host, context.client_ip)',
'crate::acl::allows_with_country(',
'context.client_ip, peer.0.ip(), context.scheme',
'return cache_http_invalidation(')
assert proxy.find('snapshot.host_allowed(&host, context.client_ip)') < proxy.find('return cache_http_invalidation(')
assert proxy.find('if cache_http_invalidation_method(request.method())') < proxy.find('return cache_http_invalidation(')
must(cache_policy, 'http_invalidation_allowed_cidrs.len() <= 32',
'net.prefix_len() > 0', 'pub fn permits_http_invalidation')
must(app, 'cache_invalidation_slots: Arc<Semaphore>',
'cache_invalidation_slots: Arc::new(Semaphore::new(2))')
must(operations, 'cache_invalidation_slots.clone().try_acquire_owned()',
'tokio::task::spawn_blocking(move ||',
'"cache.invalidate"', '"cache.purge"',
'"cache.ban.remove"')
start = operations.index('async fn cache_ban_delete(')
end = operations.index('async fn active_tunnels(', start)
assert operations[start:end].index('.audit(') < operations[start:end].index('remove_ban(id)'), 'BAN deletion must audit before mutation'
# Frontend: no HTML-string DOM injection or dynamic code execution in first-party scripts.
js_files = list((root/'src/admin/web').rglob('*.js'))
for path in js_files:
text = path.read_text(encoding='utf-8')
dangerous = (r'\.innerHTML\s*=', r'\.outerHTML\s*=', r'\.insertAdjacentHTML\s*\(',
r'\beval\s*\(', r'\bnew\s+Function\s*\(')
for pattern in dangerous:
if re.search(pattern, text):
raise AssertionError(f'unsafe DOM/code sink: {path.relative_to(root)} matches {pattern}')
html = read('src/admin/web/index.html')
for fragment in ['class="metric-info cache-maintenance-info"',
'cacheHttpInvalidationCurlLabel',
'curl -i -X PURGE', 'curl -i -X BAN']:
must(html, fragment)
print(f'PASS: admin/HTTP maintenance security source contracts; scanned {len(js_files)} first-party JS files. Actual Rust/wire behavior not tested.')
@@ -1,77 +0,0 @@
// Offline UI/theme contract: selectors, cached appearance, and CSS readability.
// Does not substitute for a browser session or Rust API integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const base = 'src/admin/web/';
const css = fs.readFileSync(`${base}style.css`, 'utf8');
const html = fs.readFileSync(`${base}index.html`, 'utf8');
const login = fs.readFileSync(`${base}login.js`, 'utf8');
const app = fs.readFileSync(`${base}app/preferences.js`, 'utf8');
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
const themes = ['dark', 'ultra_dark', 'light', 'cream'];
const declaration = app.match(/^const ADMIN_THEMES = .*;$/m)?.[0];
const normalize = app.match(/^function normalizeAdminTheme\(value\) \{[^\n]*\}$/m)?.[0];
const apply = app.split('function applyAdminTheme(value, cache = true) {')[1]
?.split('function applyAdminLanguage(')[0];
assert(declaration && normalize && apply);
const stored = new Map();
const select = { value: '' };
const root = { dataset: {} };
const ctx = {
document: { documentElement: root },
$: id => id === 'ui-theme' ? select : null,
localStorage: { setItem: (k, v) => stored.set(k, v), getItem: k => stored.get(k) },
};
vm.createContext(ctx);
vm.runInContext(`${declaration}\n${normalize}\nfunction applyAdminTheme(value, cache = true) {${apply}`, ctx);
for (const theme of themes) {
assert(html.includes(`value="${theme}"`), `option ${theme}`);
assert(login.includes('"ultra_dark", "light", "cream"'), 'cached theme also applies at login');
assert(rust.includes('"dark" | "ultra_dark" | "light" | "cream"'));
assert.equal(vm.runInContext(`normalizeAdminTheme("${theme}")`, ctx), theme);
assert.equal(vm.runInContext(`applyAdminTheme("${theme}")`, ctx), theme);
assert.equal(root.dataset.theme, theme);
assert.equal(select.value, theme);
assert.equal(stored.get('rp-theme'), theme);
}
assert.equal(vm.runInContext('normalizeAdminTheme("unknown")', ctx), 'dark');
vm.runInContext('applyAdminTheme("ultra_dark", false)', ctx);
assert.equal(stored.get('rp-theme'), 'cream', 'preview without caching preserves selected preference');
assert(en.themeUltraDark && en.themeCream && pl.themeUltraDark && pl.themeCream);
assert.equal(schema.components.schemas.AdminPreferences.properties.theme.enum.length, 4);
function block(selector) {
const start = css.indexOf(`${selector} {`);
assert(start >= 0, selector);
return css.slice(start, css.indexOf('\n}', start) + 2);
}
function token(section, key) {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = section.match(new RegExp(`--${escaped}:\\s*(#[0-9a-fA-F]{6});`));
assert(match, `missing ${key}`);
return match[1];
}
function luminance(hex) {
const rgb = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
const x = luminance(a), y = luminance(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
for (const theme of ['ultra_dark', 'cream']) {
const section = block(`html[data-theme="${theme}"]`);
const bg = token(section, 'bg'), panel = token(section, 'panel'), panel2 = token(section, 'panel2');
const text = token(section, 'text'), muted = token(section, 'muted'), accent = token(section, 'accent');
for (const [name, foreground, surface] of [
['text on background', text, bg], ['text on panel', text, panel],
['muted on card', muted, panel2], ['accent on background', accent, bg],
]) assert(contrast(foreground, surface) >= 4.5, `${theme}: ${name}`);
}
console.log('PASS: four account themes, persisted and cached selection, server API contract, and accessible black/cream palette contrast');
@@ -1,24 +0,0 @@
#!/usr/bin/env python3
"""Static regression for 0.32.7 follow-up; not a browser or Rust runtime test."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
css = (ROOT / 'src/admin/web/style.css').read_text()
html = (ROOT / 'src/admin/web/index.html').read_text()
response = (ROOT / 'src/proxy/forward/response.rs').read_text()
helper = (ROOT / 'src/proxy/h2_graceful.rs').read_text()
for button_id in ['overview-refresh', 'overview-layout-open', 'overview-layout-unlock',
'overview-layout-open-settings', 'dashboard-widget-expand']:
assert f'id="{button_id}"' in html
assert '.page-intro-title-row>:is(.quiet, .outline) {\n min-height:' not in css
assert 'dashboard-widget-settings-controls>button {\n min-height:' not in css
assert 'html[data-button-style=compact] body' in css
for mode in ['normal', 'compact', 'ultra']:
assert f'html[data-dashboard-density={mode}] #panel-overview .dashboard-overview-grid>.card:not(.pool-card)' in css
assert 'upstream_health_error_class(&cause, connect_failure, &active_backend)' in response
assert 'let graceful_h2_close = matches!(' in response
assert 'tracing::trace!(' in response
assert 'StatusCode::BAD_GATEWAY' in response
assert 'Some(detail.clone())' in response
assert 'recently_saw_graceful_goaway()' in helper
print('PASS: shared button CSS, density variants, guarded graceful-H2 logging (source only)')
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""Optional Chromium regression: shared button sizing and Dashboard density.
Uses the production HTML button markup and stylesheet without a running API.
Run: python3 tools/checks/appearance_density_buttons_0327_browser.py
"""
from pathlib import Path
import re
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
index = (ROOT / 'src/admin/web/index.html').read_text()
css = (ROOT / 'src/admin/web/style.css').read_text()
def button(button_id):
match = re.search(r'<button\b(?=[^>]*\bid="' + re.escape(button_id) + r'")[\s\S]*?</button>', index)
assert match, button_id
return match.group(0).replace(' hidden', '')
html = f'''<!doctype html><html data-theme="dark"><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>{css}</style></head>
<body><main style="max-width:100%;padding:10px;box-sizing:border-box">
<div class="page-intro-title-row"><h1>Overview</h1>
{button('overview-refresh')}{button('overview-layout-open')}{button('overview-layout-unlock')}</div>
<div class="appearance-settings-grid dashboard-appearance-grid"><div class="dashboard-widget-settings"><div class="dashboard-widget-settings-controls">
{button('overview-layout-open-settings')}{button('dashboard-widget-expand')}
</div></div></div>
<div>{'<button class="quiet" id="reference-quiet">Reference</button><button class="outline" id="reference-outline">Reference</button>'}</div>
<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned">
<article class="stat stat-chart" data-dashboard-widget="http_protocol"><span>Requests</span><strong>100</strong><small>Rate</small><svg viewBox="0 0 100 25"></svg></article>
<article class="card" data-dashboard-widget="diagnostics"><div class="card-heading"><h2>Diagnostics</h2></div><p>Metrics</p></article>
</div></section></main></body></html>'''
with sync_playwright() as p:
browser = p.chromium.launch(executable_path=shutil.which('chromium') or shutil.which('chromium-browser'), args=['--no-sandbox'])
for width in [320, 393, 1280]:
page = browser.new_page(viewport={'width': width, 'height': 900})
page.set_content(html)
for style in ['normal', 'compact']:
page.evaluate('(value) => document.documentElement.dataset.buttonStyle = value', style)
computed = page.evaluate('''() => {
const values = id => {
const s = getComputedStyle(document.getElementById(id));
return [s.fontSize, s.minHeight, s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft];
};
return Object.fromEntries(['overview-refresh', 'overview-layout-open', 'overview-layout-unlock',
'overview-layout-open-settings', 'dashboard-widget-expand', 'reference-quiet', 'reference-outline']
.map(id => [id, values(id)]));
}''')
for target, reference in [
('overview-refresh', 'reference-quiet'),
('overview-layout-open', 'reference-quiet'),
('overview-layout-unlock', 'reference-outline'),
('overview-layout-open-settings', 'reference-outline'),
('dashboard-widget-expand', 'reference-quiet'),
]:
assert computed[target] == computed[reference], (width, style, target, computed[target], computed[reference])
print('PASS buttons', width, style, computed['overview-refresh'])
measures = {}
for density in ['normal', 'compact', 'ultra']:
page.evaluate('(value) => document.documentElement.dataset.dashboardDensity = value', density)
measures[density] = page.evaluate('''() => {
const stat = document.querySelector('#panel-overview .stat');
const value = stat.querySelector('strong');
const svg = stat.querySelector('svg');
const card = document.querySelector('#panel-overview .card');
const title = card.querySelector('h2');
return {statPadding: parseFloat(getComputedStyle(stat).paddingTop),
valueSize: parseFloat(getComputedStyle(value).fontSize),
chartHeight: parseFloat(getComputedStyle(svg).height),
cardPadding: parseFloat(getComputedStyle(card).paddingTop),
titleSize: parseFloat(getComputedStyle(title).fontSize),
overflow: document.documentElement.scrollWidth > window.innerWidth};
}''')
assert not measures[density]['overflow'], (width, density)
for key in ['statPadding', 'valueSize', 'chartHeight', 'cardPadding', 'titleSize']:
values = [measures[d][key] for d in ['normal', 'compact', 'ultra']]
assert values[0] > values[1] > values[2], (width, key, values)
print('PASS densities', width, measures)
page.close()
browser.close()
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""Optional Chromium checks for Appearance controls and global corner geometry.
Uses shipped CSS and the real Appearance list-rendering function with mocked
preferences/translation state. Not a test of the authenticated Rust service.
"""
from pathlib import Path
import json
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
css = (WEB / 'style.css').read_text()
prefs = (WEB / 'app/preferences.js').read_text()
widgets = json.loads((WEB / 'dashboard-widgets.json').read_text())
translations = json.loads((ROOT / 'langs/en_US.json').read_text())['translations']
functions = prefs[:prefs.index('function browserLanguage()')] + '\n' + prefs[prefs.index('function dashboardWidgetLabel'):prefs.index('function refreshChartStylePalette')]
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="default"><head><style>{css}</style></head><body>'
'<div id="settings-page-appearance" class="settings-page" style="max-width:100%;padding:12px">'
'<div class="appearance-section"><div class="dashboard-widget-list" id="dashboard-widget-list"></div></div>'
'<div style="display:flex;gap:10px;margin-top:12px">'
'<div class="card" id="test-card">Card</div><div class="stat" id="test-stat">Stat</div>'
'<button class="outline" id="test-action">Action</button><input id="test-input" value="value"></div>'
'<a class="brand" href="#"><span class="brand-mark" id="test-logo">r<span>/</span>p</span>RustProxy</a></div>'
'<div class="overview-widget-type" id="editor-type-badge">Chart</div></body></html>')
js = f'''const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({json.dumps(widgets)});
const translations = {json.dumps(translations)};
function t(key) {{ return translations[key] || key; }}
function $(id) {{ return document.getElementById(id); }}
function el(tag, text, klass) {{ const node = document.createElement(tag); if (text != null) node.textContent = text; if (klass) node.className = klass; return node; }}
{functions}
renderDashboardWidgetSettings();'''
with sync_playwright() as playwright:
binary = shutil.which('chromium') or shutil.which('chromium-browser')
browser = playwright.chromium.launch(headless=True, executable_path=binary, args=['--no-sandbox'])
page = browser.new_page(viewport={'width': 1320, 'height': 950})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=js)
assert page.locator('#dashboard-widget-list .dashboard-widget-setting').count() == 62
assert page.locator('#settings-page-appearance .overview-widget-type').count() == 0
assert page.locator('#editor-type-badge').is_visible()
for width in (1320, 790, 450, 345):
page.set_viewport_size({'width': width, 'height': 950})
results = page.locator('.dashboard-widget-setting').evaluate_all('''rows => rows.map(row => {
const label = row.querySelector('.dashboard-widget-toggle').getBoundingClientRect();
const actions = row.querySelector('.dashboard-widget-setting-actions').getBoundingClientRect();
const box = row.getBoundingClientRect();
return { labelRight: label.right, actionsLeft: actions.left, actionsRight: actions.right, right: box.right };
})''')
for i, result in enumerate(results):
assert result['actionsLeft'] >= result['labelRight'] - 1, (width, i, result)
assert result['actionsRight'] <= result['right'] + 1, (width, i, result)
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth + 2'), width
for theme in ('dark', 'light', 'ultra_dark', 'cream'):
page.evaluate('theme => { document.documentElement.dataset.theme = theme; document.documentElement.dataset.cornerStyle = "default"; }', theme)
stat = page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
card = page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius')
assert stat == card == '0px', (theme, stat, card)
for style, expected_surface, expected_control in (('square', '0px', '0px'), ('subtle','5px','4px'), ('rounded','12px','8px'),('extra','18px','12px')):
page.evaluate('style => document.documentElement.dataset.cornerStyle = style', style)
assert page.locator('#test-stat').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'stat')
assert page.locator('#test-card').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_surface, (theme, style, 'card')
assert page.locator('#test-action').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'button')
assert page.locator('#test-input').evaluate('(n) => getComputedStyle(n).borderTopLeftRadius') == expected_control, (theme, style, 'input')
page.evaluate('document.documentElement.dataset.theme = "dark"; document.documentElement.dataset.cornerStyle = "default"')
# The last glyph must have room before the outlined logo's right edge,
# including the 34px mobile variant; the slash-to-p spacing is unchanged.
for width in (1320, 790, 450, 345):
page.set_viewport_size({'width': width, 'height': 950})
inset = page.locator('#test-logo').evaluate('''mark => {
const p = [...mark.childNodes].find(node => node.nodeType === 3 && node.textContent.trim() === 'p');
const range = document.createRange();
range.selectNodeContents(p);
return mark.getBoundingClientRect().right - range.getBoundingClientRect().right;
}''')
assert inset >= 4, (width, inset)
page.set_viewport_size({'width': 1320, 'height': 950})
original_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
page.evaluate('document.documentElement.dataset.theme = "ultra_dark"')
black_logo = page.locator('#test-logo').evaluate('(n) => getComputedStyle(n).borderTopColor')
assert black_logo == original_logo == 'rgb(246, 155, 116)', (black_logo, original_logo)
out = ROOT.parent / 'appearance_ux_0323_preview.png'
page.screenshot(path=str(out))
browser.close()
print('PASS: 62 badge-free Appearance rows, right-pinned buttons at 4 widths, theme-independent corner geometry, original black-theme logo accent')
@@ -1,96 +0,0 @@
// Regression contract: Appearance list is badge-free, corners are account-scoped,
// and Settings opens Appearance/last active tab unless a valid URL tab wins.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const prefs = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const settings = fs.readFileSync('src/admin/web/settings.js', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const login = fs.readFileSync('src/admin/web/login.js', 'utf8');
const rust = fs.readFileSync('src/admin/preferences.rs', 'utf8');
const tests = fs.readFileSync('src/admin/preferences_tests.rs', 'utf8');
const schema = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
const styles = ['default', 'square', 'subtle', 'rounded', 'extra'];
const appearance = prefs.slice(prefs.indexOf('function renderDashboardWidgetSettings()'), prefs.indexOf('function refreshChartStylePalette'));
assert(appearance.includes('dashboard-widget-setting-actions'));
assert(!appearance.includes('dashboardWidgetTypeBadge('), 'Appearance rows must not render type badges');
assert(prefs.includes('function dashboardWidgetTypeBadge('), 'visual editors retain type labels');
assert(html.includes('id="ui-corner-style"'));
assert(html.includes('id="dashboard-widget-list"'));
const contract = schema.components.schemas.AdminPreferences.properties.corner_style;
assert.deepEqual(contract.enum, styles);
assert.equal(contract.default, 'default');
assert(rust.includes('default_admin_corner_style'));
assert(rust.includes('"default" | "square" | "subtle" | "rounded" | "extra"'));
assert(tests.includes('fn corner_style_defaults_and_rejects_unknown_presets()'));
for (const key of ['cornerStyle', 'cornerStyleHelp', 'cornerStyleDefault', 'cornerStyleSquare', 'cornerStyleSubtle', 'cornerStyleRounded', 'cornerStyleExtra']) {
assert(en[key] && pl[key], `missing translation ${key}`);
}
assert(!/html:is\(\[data-theme="ultra_dark"\], \[data-theme="cream"\]\) :is\(\.card, \.stat, dialog\) \{/.test(css), 'theme may not override corner geometry');
assert(css.includes('--logo-accent: #f69b74;'));
assert(css.includes('--logo-secondary: #cfe0ac;'));
assert(css.includes('var(--logo-accent, var(--accent))'));
assert(login.includes('rp-corner-style'), 'cached corners must work on login too');
const decl = prefs.match(/^const ADMIN_CORNER_STYLES = .*;$/m)?.[0];
const normalize = prefs.match(/^function normalizeAdminCornerStyle\(value\) \{[^\n]*\}$/m)?.[0];
const body = prefs.split('function applyAdminCornerStyle(value, cache = true) {')[1]?.split('function applyAdminTableDensity(')[0];
assert(decl && normalize && body);
const storage = new Map();
const root = { dataset: {} }, input = { value: '' };
const ctx = { document: { documentElement: root }, $: id => id === 'ui-corner-style' ? input : null,
localStorage: { setItem: (key, value) => storage.set(key, value) } };
vm.createContext(ctx);
vm.runInContext(`${decl}\n${normalize}\nfunction applyAdminCornerStyle(value, cache = true) {${body}`, ctx);
for (const style of styles) {
assert.equal(vm.runInContext(`applyAdminCornerStyle('${style}')`, ctx), style);
assert.equal(root.dataset.cornerStyle, style);
assert.equal(storage.get('rp-corner-style'), style);
assert.equal(input.value, style);
}
vm.runInContext("applyAdminCornerStyle('invalid')", ctx);
assert.equal(root.dataset.cornerStyle, 'default');
assert(prefs.includes('corner_style: "default"'), 'old account defaults preserve original geometry');
assert(prefs.includes('corner_style: next'), 'choice persisted via API');
assert(prefs.includes('applyAdminCornerStyle(previous)'), 'failed update rolls back');
const setup = settings.slice(settings.indexOf(' const buttons ='), settings.indexOf(' const policyTabs ='));
assert(setup.length > 1000);
function boot(path, cached, query = '') {
const cache = new Map(cached ? [['rp-settings-last-tab', cached]] : []);
const names = ['appearance', 'listeners', 'cache', 'security', 'manual-config', 'logging'];
const buttons = names.map(name => ({ dataset: { settingsTab: name }, hidden: false,
textContent: name, addEventListener() {}, setAttribute() {}, focus() {} }));
const pages = names.map(name => ({ dataset: { settingsPage: name }, hidden: true }));
const url = new URL(`https://dashboard.example${path}${query}`);
const location = { href: url.href, pathname: url.pathname, search: url.search, hash: '', protocol: url.protocol };
const context = { location, URL, URLSearchParams, CSS: { escape: value => value },
localStorage: { getItem: key => cache.get(key) || null, setItem: (key, value) => cache.set(key, value) },
document: { querySelectorAll: selector => selector === '[data-settings-tab]' ? buttons : pages,
querySelector: () => null }, $: () => null,
history: { state: {}, replaceState(_state, _title, href) { location.href = 'https://dashboard.example' + href; } },
window: { rustProxyRefresh: { setSettingsTab() {} } }, t: key => key,
};
const vmctx = vm.createContext(context);
const result = vm.runInContext(`(() => { ${setup}\nreturn { current: () => active, select }; })()`, vmctx);
return { ...result, cache };
}
assert.equal(boot('/settings').current(), 'appearance', 'first Settings visit opens Appearance');
assert.equal(boot('/settings', 'cache').current(), 'cache', 'last Settings tab is restored');
assert.equal(boot('/settings', 'unknown').current(), 'appearance', 'invalid cached tab is ignored');
assert.equal(boot('/settings', 'cache', '?tab=security').current(), 'security', 'URL tab takes precedence');
assert.equal(boot('/configuration', 'cache').current(), 'manual-config', 'legacy /configuration keeps intent');
assert.equal(boot('/logging', 'cache').current(), 'logging', 'legacy /logging keeps intent');
const active = boot('/settings');
active.select('cache');
assert.equal(active.cache.get('rp-settings-last-tab'), 'cache');
active.select('security');
assert.equal(active.cache.get('rp-settings-last-tab'), 'security');
active.select('appearance', false, false);
assert.equal(active.cache.get('rp-settings-last-tab'), 'security', 'temporary selection does not override remembered tab');
console.log('PASS: badge-free Appearance, 5 global corner presets, logo colors, account/API defaults, and last Settings tab navigation');
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env node
// Exercise the shipped 0.29.7 backend monitor editor with a minimal DOM double.
// This checks browser form behavior, not health probing or compiled Rust code.
import fs from 'node:fs';
import vm from 'node:vm';
const root = process.cwd();
const core = fs.readFileSync(`${root}/src/admin/web/vhosts/core.js`, 'utf8');
const protocol = fs.readFileSync(`${root}/src/admin/web/vhosts/protocol.js`, 'utf8');
const editor = fs.readFileSync(`${root}/src/admin/web/vhosts/editor.js`, 'utf8');
const settings = fs.readFileSync(`${root}/src/admin/web/app/settings.js`, 'utf8');
const html = fs.readFileSync(`${root}/src/admin/web/index.html`, 'utf8');
const start = core.indexOf('function memberRow(');
const end = core.indexOf('function countryRateEditor(', start);
if (start < 0 || end <= start) throw new Error('Backend editor code not found');
const check = (condition, reason) => { if (!condition) throw new Error(reason); };
class Element {
constructor(tag = 'div', value = '', css = '') {
this.tag = tag;
this.value = value == null ? '' : String(value);
this.checked = false;
this.dataset = {};
this.hidden = false;
this.disabled = false;
this.name = '';
this.children = [];
this.listeners = new Map();
this.classes = new Set(css.split(/\s+/).filter(Boolean));
this.textContent = this.value;
}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
addEventListener(type, handler) { this.listeners.set(type, handler); }
dispatch(type) { this.listeners.get(type)?.(); }
querySelectorAll() {
const found = [];
const walk = node => { for (const child of node.children) {
if (child.tag === 'input' || child.tag === 'select') found.push(child);
walk(child);
}};
walk(this);
return found;
}
}
const el = (tag, value, css) => new Element(tag, value, css);
const text = (tag, label, css) => el(tag, label, css);
const field = (parent, name, label, value, type = 'text') => {
const wrapper = el('label');
const input = el(type === 'select' ? 'select' : 'input', value);
input.name = name;
input.checked = !!value;
wrapper.append(input);
parent.append(wrapper);
return input;
};
const button = (label, handler) => { const node = el('button', label); node.addEventListener('click', handler); return node; };
const note = (parent, label) => { const node = text('span', label); parent.append(node); return node; };
const parent = el('div');
const value = {
id: 'error_404', url: 'http://127.0.0.1:8282', weight: 1, enabled: true,
pool_eligible: false, health_check_enabled: false, preserve_host: true,
health_check: { mode: 'http', path: '/ready', host: 'errors.example.test',
interval_ms: 10000, timeout_ms: 1000, slow_response_ms: 500, fall: 2,
rise: 1, expected_status_min: 200, expected_status_max: 399 }
};
vm.runInNewContext(`${protocol}\n${core.slice(start, end)}\nmemberRow(parent, value);`, {
parent, value, el, text, field, button, note,
t: key => key, toast: () => {}, changed: () => {},
appConfirm: async () => true, adminListenerBackendCandidate: () => false, backendHealthEditorControl: null
});
const row = parent.children[0];
const input = name => (name.startsWith('member-health-') && name !== 'member-health-enabled' ? row.memberHealthPanel : row).querySelectorAll().find(item => item.name === name);
const fields = [];
const visit = node => { if (node.classes.has('vh-backend-health-fields')) fields.push(node); for (const child of node.children) visit(child); };
visit(row.memberHealthPanel);
check(fields.length === 1, 'Custom backend health panel not created');
check(input('member-pool').checked === false, 'Internal 404 backend accidentally joined balanced pool');
check(input('member-enabled').checked === true, 'Backend was disabled when monitoring was disabled');
check(input('member-health-enabled').checked === false, 'Explicit monitoring off not restored');
check(input('member-health-custom').checked === true, 'Custom health not restored');
check(input('member-health-mode').value === 'http', 'Member health mode lost');
check(input('member-health-path').value === '/ready', 'Member health path lost');
check(input('member-health-interval').value === '10000', 'Member check interval lost');
check(fields[0].hidden === false && input('member-health-path').disabled, 'Unmonitored backend settings should be retained but disabled');
input('member-health-custom').checked = false;
input('member-health-custom').dispatch('change');
check(fields[0].hidden && input('member-health-path').disabled, 'Inherit switch did not hide/disable overrides');
input('member-health-custom').checked = true;
input('member-health-custom').dispatch('change');
check(!fields[0].hidden && input('member-health-path').disabled, 'Unmonitored backend unexpectedly became editable');
input('member-health-enabled').checked = true;
input('member-health-enabled').dispatch('change');
check(!input('member-health-path').disabled, 'Monitored custom fields failed to re-enable');
check(input('member-pool').checked === false, 'Monitoring switch changed balanced-pool eligibility');
for (const part of [
'health_check_enabled: yes("member-health-enabled", row)',
'health_check: yes("member-health-custom", row.memberHealthPanel)',
'expose_only_hits: tri("vh-cache-only-hits")',
]) check((editor + core).includes(part), `Missing saved field: ${part}`);
check(editor.includes('tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits)'), 'Vhost cache override does not roundtrip');
check(html.includes('id="cache-only-hits"') && settings.includes('expose_only_hits: $("cache-only-hits").checked'), 'Global cache checkbox does not save');
check(settings.includes('$("cache-only-hits").checked = !!c.expose_only_hits'), 'Global cache checkbox does not load');
console.log('PASS: 0.29.7 backend monitor and custom policy form state, independent pool status, global/vhost cache diagnostics toggles.');
@@ -1,173 +0,0 @@
// HTTP backend Overview widgets: offline simulated status feed, no Rust execution.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const names = ['backend_health', 'backend_activity', 'backend_errors', 'backend_latency', 'backend_traffic', 'backend_protocols', 'backend_requests', 'backend_capacity', 'backend_http5xx', 'backend_timeouts', 'backend_network', 'backend_retries', 'backend_queue', 'backend_rotations'];
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
for (const name of names) assert(registry.some(widget => widget.id === name && widget.default_hidden && widget.data_source === 'backend_status'));
const source = fs.readFileSync('src/admin/web/app/backend-dashboard.js', 'utf8');
assert(!/\b(fetch|api)\s*\(/.test(source), 'Overview must not fetch per-widget metrics');
const cards = new Map(names.map(name => [name, { hidden: true }]));
const nodes = new Map();
const get = id => {
if (!nodes.has(id)) nodes.set(id, { textContent: '-', style: {}, attrs: {}, children: [], setAttribute(k,v) { this.attrs[k] = v; } });
return nodes.get(id);
};
for (const type of ['h1', 'auto', 'compat', 'h2']) {
const node = get('backend-mode-' + type);
node.parentElement = get('backend-mode-bar');
node.parentElement.children.push(node);
}
let now = 1000;
const window = { rustProxyPreferences: { current: () => null } };
const ctx = {
window,
ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: names },
document: { querySelector(selector) { return cards.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null; } },
$: get, t: key => key, fmt: (value, precision = 0) => Number(value).toFixed(precision),
bytes: v => `${v} B`, currentPanel: 'overview', performance: { now: () => now },
};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx);
vm.runInContext(source, ctx);
const module = window.rustProxyBackendDashboard;
const data = {
revision: 1,
backend_pools: [{ id: 'pool1', members: [
{ id: 'alpha', url: 'https://example.invalid', protocol: 'auto', status: 'up', active: 2, max_concurrent_requests: 4, capacity_rejections_total: 1, requests: 10, errors: 1, bytes_in: 1000, bytes_out: 2000, health_mode: 'tcp', last_probe_ms: 1234, latency_ms: 10, http_5xx_total: 2, timeouts_total: 1, connect_errors_total: 1, dns_errors_total: 1, tls_errors_total: 1, io_errors_total: 1, retry_attempts_total: 2, retry_successes_total: 1, queue_timeouts_total: 1, upstream_client_rotations_total: 2, upstream_client_generation: 3, upstream_client_generation_age_seconds: 500 },
{ id: 'beta', url: 'http://example.invalid', protocol: 'http2', status: 'down', active: 0, requests: 5, errors: 2, bytes_in: 100, bytes_out: 400, health_mode: 'http', last_probe_ms: 0, latency_ms: 0 },
{ id: 'gamma', url: 'http://example.invalid', protocol: 'http1', status: 'checking', active: 1, max_concurrent_requests: 2, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'tcp', last_probe_ms: 0, latency_ms: 0 },
{ id: 'delta', url: 'http://example.invalid', status: 'draining', active: 0, requests: 0, errors: 0, bytes_in: 0, bytes_out: 0, health_mode: 'off', last_probe_ms: 0, latency_ms: 0 },
] }],
};
const text = id => get(`backend-dashboard-${id}`).textContent;
module.render(data); // Hidden by default: should not start collecting samples.
assert(names.every(name => cards.get(name).hidden));
assert.equal(text('health'), '-');
module.render(data, []);
assert(names.every(name => !cards.get(name).hidden));
assert.equal(text('health'), '1 / 3');
assert.match(text('health-detail'), /backendWidgetDown: 1/);
assert.equal(text('activity'), '3');
assert.equal(text('errors'), '3');
assert.equal(text('latency'), '10.0 ms');
assert.equal(text('traffic'), '0 B/s');
assert.equal(text('protocols'), '4');
assert.match(text('protocols-detail'), /H1 1.*Auto 1.*backendWidgetModeCompat 1.*backendWidgetModeStrict 0.*backendWidgetH2c: 1.*backendWidgetModeUnknown: 1/);
assert.equal(get('backend-mode-compat').style.flexGrow, '1');
assert.equal(get('backend-mode-h2').style.flexGrow, '0');
assert.equal(text('requests'), '0.0/s');
assert.match(text('requests-detail'), /backendWidgetRequestsTotal: 15/);
assert.equal(text('capacity'), '50.0%');
assert.match(text('capacity-detail'), /3\/6.*backendWidgetCapacityRejects: 1/);
assert.equal(text('http5xx'), '2');
assert.equal(text('timeouts'), '1');
assert.equal(text('network'), '4');
assert.equal(text('retries'), '2');
assert.equal(text('queue'), '2');
assert.equal(text('rotations'), '2');
assert.match(text('rotations-detail'), /backendWidgetPoolGeneration: 3.*backendWidgetPoolAge: 500s/);
now = 3000;
const next = structuredClone(data);
next.backend_pools[0].members[0].requests = 30;
next.backend_pools[0].members[0].errors = 3;
next.backend_pools[0].members[0].bytes_in = 3000;
next.backend_pools[0].members[0].bytes_out = 3000;
next.backend_pools[0].members[0].active = 5;
next.backend_pools[0].members[0].latency_ms = 22;
next.backend_pools[0].members[0].http_5xx_total = 4;
next.backend_pools[0].members[0].timeouts_total = 5;
next.backend_pools[0].members[0].connect_errors_total = 3;
next.backend_pools[0].members[0].retry_attempts_total = 4;
next.backend_pools[0].members[0].retry_successes_total = 2;
next.backend_pools[0].members[0].queue_timeouts_total = 3;
// New member starts with huge absolute counters: must NOT appear as a spike.
next.backend_pools[0].members.push({ id: 'new', url:'https://new.invalid', protocol: 'http1', status:'up', requests: 100000, errors: 1000, bytes_in: 10000000, bytes_out: 99999999, active: 0, health_mode: 'off', last_probe_ms: 0 });
module.render(next, []);
assert.equal(text('errors'), '1005');
assert.match(text('errors-detail'), /1\.0\/s/);
assert.match(text('errors-detail'), /10\.0%/);
assert.equal(text('traffic'), '1500 B/s');
assert.equal(get('backend-dashboard-in-rate').textContent, '1000 B/s');
assert.equal(get('backend-dashboard-out-rate').textContent, '500 B/s');
assert.equal(text('activity'), '6');
assert.equal(text('requests'), '10.0/s');
assert.equal(text('capacity'), '100.0%');
assert.equal(text('latency'), '22.0 ms');
assert.match(text('http5xx-detail'), /1\.0\/s/);
assert.match(text('timeouts-detail'), /2\.0\/s/);
assert.match(text('network-detail'), /1\.0\/s/);
assert.match(text('retries-detail'), /1\.0\/s.*backendWidgetRetriesSuccess: 2/);
assert.match(text('queue-detail'), /1\.0\/s.*backendWidgetQueueRejected: 1/);
// Repaint on toggles/translation must not produce additional rate samples.
now = 3500;
module.render(next, [], false);
assert.match(text('errors-detail'), /1\.0\/s/);
// Reset of a member's counters should never be treated as an error burst.
now = 5000;
const restarted = structuredClone(next);
restarted.backend_pools[0].members[0].requests = 2;
restarted.backend_pools[0].members[0].errors = 0;
restarted.backend_pools[0].members[0].bytes_in = 999999;
module.render(restarted, []);
assert.match(text('errors-detail'), /0\.0\/s/);
assert.equal(text('traffic'), '0 B/s');
assert.equal(text('requests'), '0.0/s');
// Reconfiguration invalidates prior generations, first sample is a baseline.
now = 8000;
restarted.revision = 2;
module.render(restarted, []);
assert.match(text('errors-detail'), /0\.0\/s/);
// Missing origin data must show neutral state and not previously cached values.
module.render({ revision: 3, backend_pools: [] }, []);
assert.equal(text('health'), '-');
assert.equal(text('traffic'), '-');
assert.equal(text('protocols-detail'), 'backendWidgetUnavailable');
assert.equal(text('requests'), '-');
assert.equal(text('capacity'), '-');
for (const name of ['http5xx', 'timeouts', 'network', 'retries', 'queue', 'rotations'])
assert.equal(text(name), '-', `neutral empty state for ${name}`);
module.render(data, [...names]);
assert(names.every(name => cards.get(name).hidden));
// Origin protocol editor must preserve legacy HTTPS fallback, expose strict
// H2 as a separate new option, and distinguish cleartext h2c.
const protocolHelp = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
const element = () => ({ dataset: {}, textContent: '', hidden: false });
const originCtx = { el: element, t: key => key };
vm.createContext(originCtx);
vm.runInContext(protocolHelp, originCtx);
const mounted = [];
const panel = { append(...items) { mounted.push(...items); } };
const select = { value: 'http2', events: {}, addEventListener(type, handler) { this.events[type] = handler; } };
const url = { value: 'https://origin.example', events: {}, addEventListener(type, handler) { this.events[type] = handler; } };
originCtx.attachOriginProtocolDetails(panel, select, url);
assert.equal(mounted.length, 2);
assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpsDetails');
assert.equal(mounted[1].hidden, false, 'Saved HTTPS H2 needs a compatibility warning');
select.value = 'auto'; select.events.change();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpsDetails');
assert.equal(mounted[1].hidden, true);
url.value = 'http://origin.example'; url.events.input();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolAutoHttpDetails');
select.value = 'http2'; select.events.change();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolLegacyHttp2HttpDetails');
assert.equal(mounted[1].hidden, true);
url.value = 'https://origin.example'; url.events.input();
assert.equal(mounted[1].hidden, false);
select.value = 'h2'; select.events.change();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpsDetails');
assert.equal(mounted[1].hidden, true);
url.value = 'http://origin.example'; url.events.input();
assert.equal(mounted[0].dataset.i18n, 'vhProtocolH2HttpDetails');
// Strict H2 is tracked independently from the backwards-compatible value.
const strict = structuredClone(data);
strict.backend_pools[0].members[2].protocol = 'h2';
module.render(strict, [], false);
assert.match(text('protocols-detail'), /backendWidgetModeCompat 1.*backendWidgetModeStrict 1.*backendWidgetH2c: 2/);
assert.equal(get('backend-mode-h2').style.flexGrow, '1');
console.log('PASS: 14 opt-in HTTP backend cards, ALPN mode distinction, capacity, request rates, fallback UI');
@@ -1,129 +0,0 @@
#!/usr/bin/env node
// Simulated vhost DOM: selector eligibility, independent edits and lifecycle.
// This is not a browser rendering test or a Rust health-probe test.
import fs from 'node:fs';
import vm from 'node:vm';
const core = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
const protocol = fs.readFileSync('src/admin/web/vhosts/protocol.js', 'utf8');
const health = fs.readFileSync('src/admin/web/vhosts/health.js', 'utf8');
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
const render = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
const first = core.indexOf('function memberRow(');
const last = core.indexOf('function countryRateEditor(', first);
const assert = (condition, message) => { if (!condition) throw new Error(message); };
assert(first >= 0 && last > first, 'Vhost member editor missing');
class Element {
constructor(tag = 'div', value = '', css = '') {
this.tag = tag; this.value = String(value ?? ''); this.textContent = this.value;
this.children = []; this.parent = null; this.name = ''; this.checked = false; this.dataset = {};
this.disabled = false; this.hidden = false; this.listeners = new Map();
this.classes = new Set(css.split(/\s+/).filter(Boolean));
}
append(...children) {
for (const child of children) {
if (child.parent) child.remove();
this.children.push(child); child.parent = this;
}
}
replaceChildren(...children) { for (const child of [...this.children]) child.remove(); this.append(...children); }
remove() {
if (!this.parent) return;
this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null;
}
addEventListener(type, callback) { this.listeners.set(type, callback); }
dispatch(type) { this.listeners.get(type)?.(); }
querySelectorAll(selector) {
const result = [];
const walk = parent => { for (const child of parent.children) {
if ((selector === 'input,select' && ['input', 'select'].includes(child.tag)) ||
(selector === '*' || selector === undefined)) result.push(child);
walk(child);
}};
walk(this); return result;
}
querySelector(selector) {
const match = /^\[name="([^"]+)"\]$/.exec(selector);
return match ? this.querySelectorAll('*').find(node => node.name === match[1]) : null;
}
}
const el = (tag, value, css) => new Element(tag, value, css);
const text = (tag, key, css) => el(tag, key, css);
const field = (parent, name, key, value, type = 'text') => {
const wrapper = el('label');
const input = el(type === 'select' ? 'select' : 'input', value); input.name = name;
input.checked = type === 'checkbox' && !!value;
wrapper.append(input); parent.append(wrapper); return input;
};
const button = (key, callback) => { const node = el('button', key); node.addEventListener('click', callback); return node; };
const note = (parent, key) => { const node = text('p', key); parent.append(node); return node; };
const context = {
el, text, field, button, note, t: key => key,
str: (name, scope) => scope.querySelector(`[name="${name}"]`).value.trim(),
toast: () => { throw Error('Unexpected error toast'); }, changed: () => {},
appConfirm: async () => true, adminListenerBackendCandidate: () => false,
backendHealthEditorControl: null,
read: (name, scope) => scope ? scope.querySelector(`[name="${name}"]`) :
({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
"status-min": "201", "status-max": "398" }[name] != null ?
{ value: ({ "health-mode": "http", "health-path": "/shared", "health-host": "shared.example",
interval: "8500", timeout: "1800", slow: "600", fall: "4", rise: "3",
"status-min": "201", "status-max": "398" })[name] } : null),
};
const exports = vm.runInNewContext(`${protocol}\n${core.slice(first, last)}\n${health}\n({ memberRow, backendHealthEditor })`, context);
const members = el('div');
const probe = (mode, path) => ({ mode, path, host: null, interval_ms: 5000, timeout_ms: 1000, slow_response_ms: 500, fall: 2, rise: 1, expected_status_min: 200, expected_status_max: 399 });
const a = { id: 'alpha', url: 'http://192.0.2.1:9000', health_check_enabled: true, health_check: probe('http', '/a') };
const b = { id: 'disabled', url: 'http://192.0.2.2:9000', health_check_enabled: false, health_check: probe('http', '/b') };
const c = { id: 'charlie', url: 'http://192.0.2.3:9000', health_check_enabled: true, health_check: null };
for (const backend of [a, b, c]) exports.memberRow(members, { weight: 1, ...backend });
const [rowA, rowB, rowC] = members.children;
const wrapper = el('div'); context.backendHealthEditorControl = exports.backendHealthEditor(wrapper, members);
const select = wrapper.querySelector('[name="health-backend-select"]');
const control = (row, name) => row.memberHealthPanel.querySelector(`[name="${name}"]`);
assert(select.children.length === 2 && select.children[0].textContent.includes('alpha') && select.children[1].textContent.includes('charlie'), 'Unmonitored backend listed in selector');
assert(!rowA.memberHealthPanel.hidden && rowB.memberHealthPanel.hidden && rowC.memberHealthPanel.hidden, 'Selected panel visibility incorrect');
assert(control(rowB, 'member-health-path').disabled, 'Unmonitored override must be non-editable');
assert(!control(rowA, 'member-health-path').disabled, 'Monitored override unexpectedly disabled');
assert(control(rowA, 'member-health-path').value === '/a', 'Existing per-member override not loaded');
select.value = '1'; select.dispatch('change');
assert(rowA.memberHealthPanel.hidden && !rowC.memberHealthPanel.hidden, 'Switch did not change selected panel');
const customC = control(rowC, 'member-health-custom');
customC.checked = true; customC.dispatch('change');
assert(control(rowC, 'member-health-mode').value === 'http' &&
control(rowC, 'member-health-interval').value === '8500' &&
control(rowC, 'member-health-path').value === '/shared', 'First override did not inherit shared settings');
const pathC = control(rowC, 'member-health-path'); pathC.value = '/charlie-ready';
customC.checked = false; customC.dispatch('change');
customC.checked = true; customC.dispatch('change');
assert(pathC.value === '/charlie-ready', 'Toggling custom override discarded edits');
assert(!pathC.disabled, 'New custom override should become editable');
select.value = '0'; select.dispatch('change');
assert(control(rowA, 'member-health-path').value === '/a' && pathC.value === '/charlie-ready', 'Switch discarded unsaved member edits');
select.value = '1'; select.dispatch('change');
assert(pathC.value === '/charlie-ready' && !rowC.memberHealthPanel.hidden, 'Returning to backend lost its edits');
rowC.querySelector('[name="member-id"]').value = 'renamed';
rowC.querySelector('[name="member-id"]').dispatch('input');
assert(select.children[1].textContent.includes('renamed'), 'Backend rename not reflected in selector');
const monitorA = rowA.querySelector('[name="member-health-enabled"]');
monitorA.checked = false; monitorA.dispatch('change');
assert(select.children.length === 1 && select.children[0].textContent.includes('renamed'), 'Disabled monitor remains in selector');
assert(control(rowA, 'member-health-path').value === '/a' && control(rowA, 'member-health-path').disabled, 'Disabling monitor lost override or left active controls');
monitorA.checked = true; monitorA.dispatch('change');
assert(select.children.length === 2 && pathC.value === '/charlie-ready', 'Re-enabling monitor did not restore choice/edits');
const removeC = rowC.children[0].children[1]; // backend-card header's Remove button
removeC.dispatch('click');
assert(members.children.length === 2 && select.children.length === 1 && !rowA.memberHealthPanel.hidden, 'Deleting selected backend did not refresh selector');
assert(wrapper.querySelectorAll('*').includes(rowA.memberHealthPanel) && !wrapper.querySelectorAll('*').includes(rowC.memberHealthPanel), 'Removed backend panel remained retained in DOM');
monitorA.checked = false; monitorA.dispatch('change');
assert(select.disabled && select.children.length === 0 && rowB.memberHealthPanel.hidden, 'Empty monitored backend list not handled');
assert(editor.includes('health_check: yes("member-health-custom", row.memberHealthPanel)'), 'Per-member health settings not collected from selected panels');
assert(editor.includes('backendHealthEditorControl = backendHealthEditor(g, members)'), 'Selector not wired to Health tab');
assert(render.includes('backendHealthEditorControl?.reveal('), 'Invalid hidden member settings cannot be revealed');
assert(core.includes('backendHealthEditorControl = state.backendHealthEditorControl || null;') && core.includes('pagesEditor, backendHealthEditorControl,'), 'Draft switching lost selected backend editor');
assert(fs.readFileSync('src/admin/router.rs','utf8').includes('include_str!("web/vhosts/health.js")'), 'Bundled health editor missing');
console.log('PASS: 0.30.0 monitored-only backend selector, independent unsaved edits, monitor toggle, rename/removal, empty list and validation routing.');
@@ -1,54 +0,0 @@
// Verifies real-status-to-badge mapping without guessing a configured Auto result.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8');
const block = source.slice(source.indexOf('const VHOST_PROTOCOL_FRESH_MS = '), source.indexOf('function render() {'));
assert(block.includes('function renderUpstreamProtocolBadges(status)'));
const rustClient = fs.readFileSync('src/upstream/client.rs', 'utf8');
const rustPool = fs.readFileSync('src/balance/pool_view.rs', 'utf8');
assert(rustClient.includes('self.inner.observe_protocol(value.version())'));
assert(rustClient.includes('let response = client.request(request).await;'));
for (const name of ['upstream_protocol_observed','upstream_protocol_last_seen_ms','upstream_protocol_http1_total','upstream_protocol_http2_total']) {
assert(rustPool.includes(`"${name}"`), name);
}
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
for(const key of ['vhBackendProtocolRecent','vhBackendProtocolUnobserved']) assert(en[key] && pl[key]);
const badge = (vhost, backend) => ({dataset:{upstreamProtocolVhost:vhost,upstreamProtocolBackend:backend},textContent:'',title:''});
const visible = [badge('api','auto-backend'),badge('api','h1-backend'),badge('other','auto-backend')];
const envelope = {config:{vhosts:[
{id:'api',backends:[{id:'auto-backend',protocol:'auto'},{id:'h1-backend',protocol:'http1'}]},
{id:'other',backends:[{id:'auto-backend',protocol:'auto'}]},
]}};
const ctx = {Date, Number, String, Map, envelope, document:{querySelectorAll:()=>visible},t:key=>en[key]};
vm.createContext(ctx);
vm.runInContext(block, ctx);
const recent = Date.now() - 2000;
let status = {backend_pools:[{id:'api',members:[
{id:'auto-backend',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:recent,upstream_protocol_http1_total:5,upstream_protocol_http2_total:9},
{id:'h1-backend',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:recent,upstream_protocol_http1_total:12},
]}]};
ctx.renderUpstreamProtocolBadges(status);
assert.equal(visible[0].textContent,'auto-backend · AUTO → H2');
assert.equal(visible[0].dataset.observed,'true');
assert(visible[0].title.includes('H1: 5; H2: 9'));
assert.equal(visible[1].textContent,'h1-backend · H1 → H1');
assert.equal(visible[2].textContent,'auto-backend · AUTO → ?');
assert.equal(visible[2].dataset.observed,'false');
// Old/stale response must not masquerade as a recent ALPN observation.
status.backend_pools[0].members[0].upstream_protocol_last_seen_ms = Date.now() - 3600000;
ctx.renderUpstreamProtocolBadges(status);
assert.equal(visible[0].textContent,'auto-backend · AUTO → ?');
assert.equal(visible[0].dataset.observed,'false');
assert(visible[0].title.startsWith(en.vhBackendProtocolUnobserved));
// Fresh data for a similarly named backend from another vhost must not leak.
ctx.renderUpstreamProtocolBadges({backend_pools:[{id:'other',members:[
{id:'auto-backend',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:recent}
]}]});
assert.equal(visible[0].textContent,'auto-backend · AUTO → ?');
assert.equal(visible[2].textContent,'auto-backend · AUTO → H1');
ctx.renderUpstreamProtocolBadges({backend_pools:[]});
assert.equal(visible[2].textContent,'auto-backend · AUTO → ?');
console.log('PASS: observed Auto/H1/H2 badges, freshness, cross-vhost isolation and unknown data');
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env python3
"""0.31.9 Certificate API source/OpenAPI/UI contracts; no Rust runtime claims."""
import json
from pathlib import Path
import tomllib
root = Path(__file__).resolve().parents[2]
read = lambda path: (root / path).read_text(encoding='utf-8')
version = tomllib.loads(read('Cargo.toml'))['package']['version']
assert version == '0.32.7', version
spec = json.loads(read('src/admin/openapi.json'))
assert spec['info']['version'] == version
paths = spec['paths']
assert set(paths['/api/certificate-api/{key_id}']) == {'get', 'patch', 'delete'}
assert set(paths['/api/certificate-api/{key_id}/regenerate']) == {'post'}
for method, path in [('get', '/api/certificate-api/{key_id}'), ('patch', '/api/certificate-api/{key_id}'),
('post', '/api/certificate-api/{key_id}/regenerate')]:
operation = paths[path][method]
assert operation['security'] == ([{'AdminSession': []}] if method == 'get' else [{'AdminSession': [], 'CsrfToken': []}])
assert operation['parameters'][0]['name'] == 'key_id'
assert '401' in operation['responses'] and '404' in operation['responses']
edit = spec['components']['schemas']['CertificateApiEdit']
assert edit['additionalProperties'] is False
assert set(edit['required']) == {'comment', 'certificate_ids', 'allow_deploy'}
assert not {'token', 'token_hash', 'key_id'} & edit['properties'].keys()
assert set(spec['components']['schemas']['CertificateApiDetailResult']['properties']) == {'credential'}
assert set(spec['components']['schemas']['CertificateApiRegenerateResult']['properties']) == {'credential', 'token'}
assert 'readOnly' in spec['components']['schemas']['CertificateApiRegenerateResult']['properties']['token']
backend = read('src/admin/certificate_api.rs')
router = read('src/admin/router.rs')
db = read('src/db/certificate_api.rs')
assert 'include!("certificate_api.rs")' in read('src/admin/mod.rs')
for symbol in ('certificate_api_detail', 'certificate_api_edit', 'certificate_api_regenerate'):
assert f'async fn {symbol}(' in backend
assert symbol in router
assert 'deny_unknown_fields' in backend
read_source = backend.split('async fn certificate_api_detail(')[1].split('async fn certificate_api_edit(')[0]
edit_source = backend.split('async fn certificate_api_edit(')[1].split('async fn certificate_api_regenerate(')[0]
regenerate_source = backend.split('async fn certificate_api_regenerate(')[1].split('async fn certificate_api_revoke(')[0]
assert '"token"' not in read_source
assert '"token"' not in edit_source and '"token_hash"' not in edit_source
assert '"token": token' in regenerate_source
assert 'auth::random_secret()' in regenerate_source
assert '"certificate_api.invalid_certificate_ids"' in edit_source
update_source = db.split('pub async fn edit_certificate_api_credential(')[1].split('pub async fn regenerate_certificate_api_token(')[0]
rotate_source = db.split('pub async fn regenerate_certificate_api_token(')[1].split('pub async fn revoke_certificate_api_credential(')[0]
assert 'SET comment = ?' in update_source
assert 'SET token_hash' not in update_source
assert 'SET token_hash = ?, created_at = ?, last_used_at = NULL' in rotate_source
assert 'certificate_api.edit' in update_source and 'certificate_api.regenerate' in rotate_source
assert 'WHERE key_id = ?' in rotate_source
assert 'certificate_api_operation(method: &Method' in router or 'fn certificate_api_operation(method: &Method' in read('src/admin/access.rs') + router
assert 'if method != Method::POST' in router
panel = read('src/admin/web/certificate-api.js')
assert 'include_str!("web/certificate-api.js")' in router
for marker in ('function certificateApiDetailNode(', 'beginCertificateApiEdit(', 'certificateApiEditingKey',
'method: "PATCH"', '/regenerate', 'showCertificateApiSecret(data.token || "")',
'clearCertificateApiSecret()', 'token-field', 'certificateApiTokenHidden'):
assert marker in panel, marker
for item in ('certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-secret', 'certificate-api-token-field'):
assert f'id="{item}"' in read('src/admin/web/index.html')
for language in ('en_US', 'pl_pl'):
strings = json.loads(read(f'langs/{language}.json'))['translations']
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
assert strings.get(key), (language, key)
assert '0.32.7' in read('docs/TEST_REPORT.md')
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
@@ -1,169 +0,0 @@
#!/usr/bin/env node
// Execute the real 0.31.9 Certificate API panel code against a simulated DOM/API.
// This is not a compiled Rust proxy test.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync('src/admin/web/certificate-api.js', 'utf8');
const html = readFileSync('src/admin/web/index.html', 'utf8');
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
for (const pack of [english, polish]) {
for (const key of ['certificateApiView', 'certificateApiHide', 'certificateApiEdit',
'certificateApiSave', 'certificateApiCancelEdit', 'certificateApiRegenerate',
'certificateApiRegenerateConfirm', 'certificateApiRegenerated', 'certificateApiTokenHidden']) {
assert.ok(pack[key], key);
}
}
for (const id of ['certificate-api-save', 'certificate-api-cancel-edit', 'certificate-api-token-field',
'certificate-api-secret', 'certificate-api-credentials', 'certificate-api-edit-note']) {
assert.ok(html.includes(`id="${id}"`), id);
}
class FakeNode {
constructor(tag = 'div', content = '', cls = '') {
this.tagName = tag; this.value = ''; this.content = String(content ?? '');
this.className = cls; this.children = []; this.events = {};
this.dataset = {}; this.hidden = false; this.disabled = false;
this.checked = false; this.readOnly = false;
this.classList = { add: cls => { this.className += ` ${cls}`; } };
}
get textContent() { return this.content + this.children.map(child => child.textContent).join(''); }
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
addEventListener(type, callback) { this.events[type] = callback; }
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; this.content = ''; }
scrollIntoView() { }
focus() { }
async fire(type, event = {}) { assert.ok(this.events[type], `${this.tagName} missing ${type}`); await this.events[type](event); }
findByText(value) { return [this, ...this.children.flatMap(child => child.findAll())].find(child => child.content === value); }
findAll() { return [this, ...this.children.flatMap(child => child.findAll())]; }
}
const fields = new Map();
const dollar = id => {
if (!fields.has(id)) fields.set(id, new FakeNode(id));
return fields.get(id);
};
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
let persisted = [{ key_id: 'renew-prod', comment: 'Old owner', certificate_ids: ['first-cert'],
allow_deploy: false, created_at: 1690000000, last_used_at: 1700000000 }];
let activeToken = 'original-secret-not-readable';
const requests = [];
const makeStatus = () => ({ configured: persisted.length > 0, credentials: structuredClone(persisted),
max_credentials: 32, scopes: ['certificates:reload', 'certificates:deploy'] });
async function api(path, options = {}) {
const method = options.method || 'GET';
const data = options.body ? JSON.parse(options.body) : {};
requests.push({ path, method, data });
if (path === '/api/certificate-api' && method === 'GET') return makeStatus();
if (path === '/api/certificate-api' && method === 'DELETE') { persisted = []; return { revoked: 1 }; }
if (path === '/api/certificate-api' && method === 'PUT') {
activeToken = data.token || 'created-once-token';
const entry = { key_id: data.key_id || 'generated-id', comment: data.comment,
certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy,
created_at: 1800000000, last_used_at: null };
persisted.push(entry);
return { ...makeStatus(), credential: entry, token: activeToken, generated: !data.token };
}
const match = path.match(/^\/api\/certificate-api\/([^/]+)(\/regenerate)?$/);
assert.ok(match, `Unexpected ${method} ${path}`);
const credential = persisted.find(entry => entry.key_id === match[1]);
assert.ok(credential);
if (method === 'GET') return { credential: structuredClone(credential) };
if (method === 'PATCH') {
assert.deepEqual(Object.keys(data).sort(), ['allow_deploy', 'certificate_ids', 'comment']);
assert.ok(!('token' in data) && !('token_hash' in data));
Object.assign(credential, { comment: data.comment, certificate_ids: data.certificate_ids, allow_deploy: data.allow_deploy });
return { credential: structuredClone(credential) };
}
if (method === 'POST' && match[2] === '/regenerate') {
activeToken = 'replacement-secret-once';
credential.last_used_at = null;
credential.created_at = 1800000001;
return { credential: structuredClone(credential), token: activeToken };
}
if (method === 'DELETE') {
persisted = persisted.filter(entry => entry.key_id !== credential.key_id);
return { revoked: true };
}
throw new Error(`Unsupported ${method} ${path}`);
}
const toasts = [];
const ctx = {
document: {}, window: {}, $: dollar, el: node,
action: (text, cb) => { const result = node('button', text, 'quiet'); result.addEventListener('click', cb); result.type = 'button'; return result; },
t: key => english[key] || key,
location: { origin: 'https://admin.example.test' },
authenticated: true, api, appConfirm: async () => true,
formatDateTime: timestamp => new Date(timestamp).toISOString(),
toast: value => toasts.push(value),
fieldError: (_, message) => new Error(message), showFieldError: () => {},
};
vm.createContext(ctx);
vm.runInContext(source, ctx, { filename: 'src/admin/web/certificate-api.js' });
await new Promise(resolve => setImmediate(resolve));
const list = dollar('certificate-api-credentials');
const actionsFor = (rowIndex = 0) => list.children[rowIndex].children[0].children[1].children;
const click = async (label, rowIndex = 0) => {
const btn = actionsFor(rowIndex).find(item => item.content === label);
assert.ok(btn, `Missing button: ${label}`);
await btn.fire('click');
};
assert.equal(list.children.length, 1);
assert.ok(requests.some(request => request.path === '/api/certificate-api' && request.method === 'GET'));
await click(english.certificateApiView);
assert.equal(list.children[0].children.length, 2, 'details expanded');
const previewText = list.children[0].children[1].textContent;
assert.match(previewText, /renew-prod/);
assert.match(previewText, /Old owner/);
assert.match(previewText, /first-cert/);
assert.match(previewText, /Unavailable/);
assert.ok(!previewText.includes(activeToken), 'never show existing bearer token');
assert.ok(requests.some(request => request.path === '/api/certificate-api/renew-prod' && request.method === 'GET'));
await click(english.certificateApiEdit);
assert.equal(dollar('certificate-api-key-id').value, 'renew-prod');
assert.equal(dollar('certificate-api-key-id').readOnly, true);
assert.equal(dollar('certificate-api-comment').value, 'Old owner');
assert.equal(dollar('certificate-api-token-field').hidden, true);
assert.equal(dollar('certificate-api-save').content, english.certificateApiSave);
assert.equal(dollar('certificate-api-cancel-edit').hidden, false);
dollar('certificate-api-comment').value = 'New owner';
dollar('certificate-api-certificate-ids').value = 'second-cert,third-cert';
dollar('certificate-api-allow-deploy').checked = true;
await dollar('certificate-api-form').fire('submit', { preventDefault() {} });
assert.equal(persisted[0].comment, 'New owner');
assert.deepEqual(persisted[0].certificate_ids, ['second-cert', 'third-cert']);
assert.equal(persisted[0].allow_deploy, true);
assert.equal(persisted[0].created_at, 1690000000, 'editing must not rotate');
assert.equal(activeToken, 'original-secret-not-readable');
assert.equal(dollar('certificate-api-token-field').hidden, false);
assert.equal(dollar('certificate-api-save').content, english.certificateApiRotate);
assert.equal(dollar('certificate-api-secret-wrap').hidden, true);
await click(english.certificateApiRegenerate);
assert.equal(activeToken, 'replacement-secret-once');
assert.equal(dollar('certificate-api-secret').textContent, activeToken);
assert.equal(dollar('certificate-api-secret-wrap').hidden, false);
assert.equal(persisted[0].comment, 'New owner', 'metadata unchanged by regeneration');
assert.equal(persisted[0].allow_deploy, true);
assert.equal(persisted[0].last_used_at, null);
assert.equal(list.textContent.includes(activeToken), false, 'token not in persistent credential DOM');
await click(english.certificateApiHide);
assert.equal(dollar('certificate-api-secret').textContent, 'replacement-secret-once', 'collapsing is not a new API secret read');
await click(english.certificateApiView);
assert.equal(dollar('certificate-api-secret-wrap').hidden, true, 'reading settings does not re-expose the token');
assert.equal(dollar('certificate-api-secret').textContent, '');
assert.ok(!list.textContent.includes(activeToken));
ctx.window.rustProxyCertificateApi.reset();
assert.equal(dollar('certificate-api-secret').textContent, '');
assert.equal(list.children.length, 0);
await ctx.window.rustProxyCertificateApi.load();
assert.equal(list.children.length, 1);
assert.ok(!list.textContent.includes(activeToken));
assert.equal(requests.filter(item => item.path.endsWith('/regenerate')).length, 1);
assert.ok(toasts.includes(english.certificateApiSaved));
assert.ok(toasts.includes(english.certificateApiRegenerated));
console.log('PASS: 0.31.9 Certificate API view/edit/regenerate/one-time secret UI (real module, simulated DOM/API).');
File diff suppressed because it is too large. Load diff
@@ -1,91 +0,0 @@
#!/usr/bin/env python3
"""Optional real-CSS/JS Chromium regression for the horizontal Overview card ribbon.
Mocks authenticated preferences; no server or physical iOS device involved.
Run with: python tools/checks/dashboard_appearance_ribbon_0326_browser.py
"""
from pathlib import Path
from playwright.sync_api import sync_playwright
import shutil
import json
root=Path(__file__).resolve().parents[2]
html=(root/'src/admin/web/index.html').read_text()
css=(root/'src/admin/web/style.css').read_text()
prefs=(root/'src/admin/web/app/preferences.js').read_text()
registry=json.loads((root/'src/admin/web/dashboard-widgets.json').read_text())
translations=json.loads((root/'langs/pl_pl.json').read_text())['translations']
heading=html.index('data-i18n="dashboardLayout"')
section_start=html.rfind('<section class="appearance-section">',0,heading)
section_end=html.index('</section>',heading)+len('</section>')
assert section_start >= 0
source=html[section_start:section_end]
start=prefs.index('function renderDashboardWidgetSettings() {')
end=prefs.index('function refreshChartStylePalette(',start)
code=prefs[start:end]
stub='''
const $ = id => document.getElementById(id);
const el = (tag, txt, classes) => { let node=document.createElement(tag); if (txt!==undefined) node.textContent=String(txt); if (classes) node.className=classes; return node; };
const labels = __TRANSLATIONS__;
const registry = __REGISTRY__;
const ADMIN_DASHBOARD_BY_ID = new Map(registry.map(def=>[def.id,def]));
const order = registry.map(def=>def.id);
let adminPreferences = {dashboard_widgets:order, dashboard_hidden_widgets:registry.filter(def=>def.default_hidden).map(def=>def.id)};
const ADMIN_APPEARANCE_DEFAULTS = adminPreferences;
function t(key){ return labels[key] || key; }
function dashboardWidgetLabel(id) { return t(ADMIN_DASHBOARD_BY_ID.get(id)?.label_key || id); }
function normalizeDashboardWidgets(value){return Array.isArray(value)?value:order;}
function normalizeHiddenDashboardWidgets(value){return Array.isArray(value)?value:[];}
function applyDashboardLayout(order,hidden){ window.__state={order,hidden}; }
async function saveAppearancePreference(next){ Object.assign(adminPreferences,next);return {}; }
'''.replace('__TRANSLATIONS__',json.dumps(translations,ensure_ascii=False)).replace('__REGISTRY__',json.dumps(registry))
with sync_playwright() as p:
browser=p.chromium.launch(executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),args=['--no-sandbox'],headless=True)
for width,height in [(1500,950),(393,850)]:
page=browser.new_page(viewport={'width':width,'height':height},device_scale_factor=1)
page.set_content('<!doctype html><html data-theme="dark" lang="pl"><head><style>'+css+'</style></head><body><main style="margin:0; padding:28px 16px; width:100%; max-width:100%; box-sizing:border-box">'+source+'</main></body></html>',wait_until='domcontentloaded')
page.add_script_tag(content=stub+code+'\nrenderDashboardWidgetSettings();')
page.wait_for_timeout(90)
metrics=page.evaluate('''() => {
const list=document.getElementById('dashboard-widget-list'), section=document.querySelector('.appearance-section');
const parent=list.closest('.dashboard-widget-settings');
return { total: list.children.length, listHeight:list.getBoundingClientRect().height,
listWidth:list.getBoundingClientRect().width, contentWidth:list.scrollWidth,
sectionHeight:section.getBoundingClientRect().height,
overflowX:getComputedStyle(list).overflowX,
firstWidth:list.firstElementChild.getBoundingClientRect().width,
density: document.querySelector('.dashboard-density-setting').getBoundingClientRect().width,
widget:parent.getBoundingClientRect().width,
viewportOverflow:document.documentElement.scrollWidth>window.innerWidth,
nextDisabled:document.getElementById('dashboard-widget-scroll-next').disabled};
}''')
print(width,'COMPACT',metrics)
assert metrics['total']==len(registry)
assert metrics['listHeight']<66,(width,metrics)
assert metrics['contentWidth']>metrics['listWidth'],(width,metrics)
assert metrics['sectionHeight'] < (320 if width>700 else 450), (width,metrics)
assert not metrics['viewportOverflow']
page.locator('#dashboard-widget-scroll-next').click()
page.wait_for_timeout(450)
pos=page.evaluate('document.getElementById("dashboard-widget-list").scrollLeft')
print(width,'scroll after next:',pos)
assert pos>10
page.locator('.dashboard-widget-setting').nth(7).locator('button.dashboard-widget-toggle').click(force=True)
page.wait_for_timeout(60)
state=page.evaluate('window.__state')
assert state and len(state['order'])==len(registry)
retained=page.evaluate('document.getElementById("dashboard-widget-list").scrollLeft')
assert retained>=pos-15,(pos,retained)
page.locator('#dashboard-widget-expand').click()
page.wait_for_timeout(60)
expanded=page.evaluate('''() => { const list = document.getElementById("dashboard-widget-list"); return { height:list.getBoundingClientRect().height, mode:getComputedStyle(list).display, count:list.children.length, expanded:document.getElementById('dashboard-widget-expand').getAttribute('aria-expanded'), prevHidden:document.getElementById('dashboard-widget-scroll-prev').hidden } }''')
print(width,'EXPANDED',expanded)
assert expanded['mode']=='grid'
assert expanded['expanded']=='true' and expanded['prevHidden']
assert expanded['height']>metrics['listHeight']*3
page.locator('#dashboard-widget-expand').click()
assert page.locator('#dashboard-widget-expand').get_attribute('aria-expanded')=='false'
outfile=str(root.parent / f'dashboard_appearance_0326_preview_{width}.png')
page.locator('.appearance-section').screenshot(path=outfile)
print('screenshot',outfile)
page.close()
browser.close()
@@ -1,36 +0,0 @@
// Appearance ribbon: compact by default, with full-width expansion and the same
// persisted visibility/reorder controls. No browser or Rust service required.
import assert from 'node:assert/strict';
import fs from 'node:fs';
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const js = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const locales = ['en_US', 'pl_pl'].map(id => JSON.parse(fs.readFileSync(`langs/${id}.json`, 'utf8')).translations);
for (const id of ['dashboard-widget-list', 'dashboard-widget-expand', 'dashboard-widget-scroll-prev',
'dashboard-widget-scroll-next', 'overview-layout-open-settings', 'ui-dashboard-density']) {
assert(html.includes(`id="${id}"`), `missing ${id}`);
}
assert.match(html, /class="appearance-settings-grid dashboard-appearance-grid"/);
assert.match(css, /\.appearance-settings-grid\.dashboard-appearance-grid\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/s,
'dashboard setting layout must use the full available width');
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*overflow-x:\s*auto;/s,
'the default card selector must be a horizontal ribbon, not a tall two-column list');
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\.is-expanded\s*\{[^}]*display:\s*grid;/s);
assert.match(css, /\.dashboard-widget-settings-controls\s*>\s*\.dashboard-widget-scroll-button/);
assert(js.includes('list.scrollLeft = previousScroll'), 'rebuilding settings must preserve horizontal scroll position');
assert(js.includes('focusedId') && js.includes('preventScroll: true'), 'keyboard focus must survive updates');
assert(js.includes('saveAppearancePreference({ dashboard_hidden_widgets:') &&
js.includes('saveAppearancePreference({ dashboard_widgets:'), 'both APIs must retain persistence');
assert(js.includes('const up = el("button", "←"') && js.includes('const down = el("button", "→"'),
'horizontal order controls must align with the ribbon');
assert.equal(registry.length, 62, 'all configured Overview cards must remain available');
for (const translation of locales) {
for (const key of ['dashboardWidgetsShowAll', 'dashboardWidgetsCompact', 'dashboardWidgetsScrollBack',
'dashboardWidgetsScrollNext', 'dashboardWidgetsScrollHelp',
'dashboardWidgetsMoveEarlier', 'dashboardWidgetsMoveLater']) {
assert(translation[key], `missing ${key}`);
}
}
console.log('PASS: full-width compact Appearance ribbon with expandable 62-card layout, keyboard focus and preference persistence');
@@ -1,89 +0,0 @@
#!/usr/bin/env node
// Release-independent, real-source startup ordering and recovery contract.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { resolve, dirname } 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 html = read('src/admin/web/index.html');
const runtime = read('src/admin/web/app/runtime.js');
const core = read('src/admin/web/app/core.js');
const router = read('src/admin/router.rs');
const bundle = read('tools/checks/web_assets.py');
const version = read('Cargo.toml').match(/^version\s*=\s*"([\d.]+)"/m)?.[1];
assert(version);
assert.equal((html.match(/<script defer="" src="\/assets\//g) || []).length, 1, 'one atomic dashboard script');
assert.match(html, /src="\/assets\/app___RUSTPROXY_ASSET_ID__\.js"/);
assert.match(html, /href="\/assets\/style___RUSTPROXY_ASSET_ID__\.css"/);
assert.match(router, /admin_vhosts_javascript\(\)/);
assert.match(bundle, /"integrations\.js", "vhosts\.js"/);
assert(router.indexOf('include_str!("web/integrations.js")') < router.indexOf('admin_vhosts_javascript(),'));
assert(router.indexOf('admin_vhosts_javascript(),') < router.indexOf('include_str!("web/host404.js")'));
const prefix = runtime.slice(0, runtime.indexOf('function certificateUsesExternalFiles'));
assert(prefix.includes('document.addEventListener("DOMContentLoaded", startAdminPanel'));
function simulate(complete) {
let bootstrap, calls = 0, activated = 0, failed = null;
const modules = {};
for (const [name, action] of [
['rustProxyIntegrations','bindings'], ['rustProxyVhosts','render'],
['rustProxySettings','render'], ['rustProxyCachePanel','refresh'],
['rustProxyTrafficPanel','refresh'], ['rustProxyPreferences','load'],
['rustProxyRefresh','start'],
]) if (complete || name !== 'rustProxyIntegrations') modules[name] = { [action]() {} };
const ctx = {
window: modules, document: { readyState: 'loading', addEventListener(name, fn) {
assert.equal(name, 'DOMContentLoaded'); bootstrap = fn;
} },
api: async () => { calls++; return { csrf_token: 'abc' }; },
clearInvalidOriginRecovery() {}, loadLanguages: async () => {},
translate() {}, syncAuthForm() {}, activate: async () => { activated++; },
recoverInvalidOrigin: () => false, showStartupFailure: e => { failed = e; },
location: { pathname: '/overview', search: '', replace() { throw new Error('unexpected redirect'); } },
};
runInNewContext(prefix, ctx);
assert.equal(calls, 0, 'zero API calls before all defer/module readiness');
assert.equal(activated, 0);
assert(bootstrap, 'registered DOMContentLoaded bootstrap');
return { bootstrap, state: () => ({ calls, activated, failed }) };
}
const success = simulate(true);
success.bootstrap();
for (let i = 0; i < 8; i++) await Promise.resolve();
assert.equal(success.state().calls, 1);
assert.equal(success.state().activated, 1);
assert.equal(success.state().failed, null);
const missing = simulate(false);
missing.bootstrap();
for (let i = 0; i < 8; i++) await Promise.resolve();
assert.equal(missing.state().calls, 0, 'do not initialize partially loaded modules');
assert.match(missing.state().failed.message, /rustProxyIntegrations/);
// Exercise real activate on temporary config API errors and on persistent failure.
const activation = core.slice(core.indexOf('async function activate(info) {'), core.indexOf('async function loadAudit()'));
assert(activation.startsWith('async function activate(info)'));
async function configRetry(failures) {
const nodes = new Map();
const node = key => { if (!nodes.has(key)) nodes.set(key, { hidden: true, value: '' }); return nodes.get(key); };
let config = 0, fallback = 0, status = 0;
const ctx = {
$: node, authenticated: false, csrf: '', authEpoch: 0, sessionInfo: null, pendingAuthDestination: null,
window: {}, document: { documentElement: { classList: { remove() {} } } },
authMessage() {}, renderSession() {}, rustProxyRefresh: { start() {} },
loadAdminPreferences: async () => ({}),
loadConfig: async () => { config++; if (config <= failures) throw new Error('Temporary proxy failure'); },
refresh: async () => { status++; },
setTimeout: fn => { fn(); return 1; },
showStartupFailure: () => { fallback++; },
toast() { throw new Error('A transient failure must not be hidden in a toast'); },
};
runInNewContext(activation, ctx);
await ctx.activate({ username: 'owner', csrf_token: 'token' });
for (let i=0;i<4;i++) await Promise.resolve();
return { config, fallback, status };
}
assert.deepEqual(await configRetry(2), { config: 3, fallback: 0, status: 1 });
assert.deepEqual(await configRetry(3), { config: 3, fallback: 1, status: 0 });
console.log(`PASS: ${version} one build-fingerprinted JS payload, DOM/module-gated startup, retries and visible failure`);
@@ -1,235 +0,0 @@
// Offline simulation of direct Overview arrangement. No authenticated browser
// or Rust networking is exercised by this test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const widgets = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = widgets.map(w => w.id);
const hiddenDefault = widgets.filter(w => w.default_hidden).map(w => w.id);
const source = fs.readFileSync('src/admin/web/app/dashboard-inline-layout.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
for (const id of ['overview-layout-unlock', 'overview-inline-actions', 'overview-inline-save',
'overview-inline-cancel', 'overview-inline-hint', 'dashboard-overview-grid'])
assert(html.includes(`id="${id}"`), `missing ${id}`);
class Node {
constructor(tag = 'div', value = '', cls = '') {
this.tag = tag; this.textContent = value ?? ''; this.classes = new Set(cls.split(' ').filter(Boolean));
this.classList = { add: (...names) => names.forEach(name => this.classes.add(name)),
remove: (...names) => names.forEach(name => this.classes.delete(name)) };
this.children = []; this.listeners = new Map(); this.dataset = {};
this.attributes = {}; this.hidden = false; this.disabled = false; this.parent = null;
}
append(...children) {
for (const child of children) {
child.remove(); this.children.push(child); child.parent = this;
}
}
replaceChildren(...children) {
for (const child of this.children) child.parent = null;
this.children = [];
this.append(...children);
}
remove() {
if (this.parent) { const at = this.parent.children.indexOf(this); if (at >= 0) this.parent.children.splice(at, 1); }
this.parent = null;
}
closest(selector) {
for (let cur = this; cur; cur = cur.parent) {
if (selector === '[data-dashboard-widget]' && cur.dataset.dashboardWidget) return cur;
if (selector === '[data-overview-drag-handle]' && cur.dataset.overviewDragHandle) return cur;
}
return null;
}
contains(other) { return !!other && (other === this || this.children.some(child => child.contains(other))); }
querySelector(selector) {
for (const child of this.children) {
if (child.classes.has(selector.slice(1)) || child.tag === selector) return child;
const nested = child.querySelector(selector);
if (nested) return nested;
}
return null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getBoundingClientRect() { return { left: 0, width: 100 }; }
addEventListener(name, cb) { this.listeners.set(name, [...(this.listeners.get(name) || []), cb]); }
fire(name, event = {}) {
const e = { target: this, clientX: 0, prevented: false, preventDefault() { this.prevented = true; }, ...event };
for (const cb of this.listeners.get(name) || []) cb(e);
return e;
}
focus() { this.focused = true; }
}
const controls = new Map(['dashboard-overview-grid', 'overview-layout-unlock',
'overview-inline-actions', 'overview-inline-save', 'overview-inline-cancel',
'overview-inline-hint', 'overview-inline-hidden', 'overview-inline-hidden-list',
'overview-inline-hidden-count', 'overview-layout-open', 'overview-layout-dialog'].map(id => [id, new Node()]));
const grid = controls.get('dashboard-overview-grid');
controls.get('overview-inline-hidden').append(new Node('summary', 'Hidden blocks'));
const cards = new Map(ids.map(id => {
const node = new Node('article'); node.dataset.dashboardWidget = id;
node.hidden = hiddenDefault.includes(id);
node.setAttribute('id', `real-${id}`);
node.append(new Node('strong', id));
grid.append(node);
return [id, node];
}));
let current = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault], theme: 'light' };
const writes = [], notifications = [];
let reject = false;
const window = {
rustProxyPreferences: {
load: async () => current,
current: () => current,
update: async patch => {
if (reject) throw Error('server refused');
writes.push(patch);
current = { ...current, ...patch };
applyDashboardLayout(current.dashboard_widgets, current.dashboard_hidden_widgets);
return current;
},
},
};
function applyDashboardLayout(order, hidden) {
for (const id of order) { cards.get(id).hidden = hidden.includes(id); grid.append(cards.get(id)); }
}
const $ = id => controls.get(id);
const ctx = {
window, $, el: (tag, value, cls) => new Node(tag, value, cls),
t: key => `loc:${key}`, toast: msg => notifications.push(msg),
dashboardWidgetLabel: id => `label:${id}`,
dashboardWidgetTypeBadge: id => new Node('span', `kind:${widgets.find(w => w.id === id).display_type}`, 'overview-widget-type'),
applyDashboardLayout,
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault] },
normalizeDashboardWidgets: order => [...new Set([...(Array.isArray(order) ? order.filter(id => ids.includes(id)) : []), ...ids])],
normalizeHiddenDashboardWidgets: hidden => [...new Set((hidden || []).filter(id => ids.includes(id)))],
};
vm.createContext(ctx);
vm.runInContext(source, ctx);
const editor = window.rustProxyInlineLayout;
const refresh = () => new Promise(resolve => setImmediate(resolve));
const visible = () => grid.children.filter(node => !node.hidden).map(node => node.dataset.dashboardWidget);
const toolbarCount = () => grid.children.filter(node => node.querySelector('.overview-inline-toolbar')).length;
const button = (id, cls) => cards.get(id).querySelector(cls);
const grip = id => button(id, '.overview-inline-grip');
const dataTransfer = () => ({ setData(k, value) { this[k] = value; }, effectAllowed: '', dropEffect: '' });
// Opening does not clone cards or trigger network writes. Only the handle drags.
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(editor.isEditing());
assert.equal(controls.get('overview-inline-actions').hidden, false);
assert.equal(toolbarCount(), 62);
assert.equal(cards.get('requests').attributes.id, 'real-requests');
assert.equal(grip('requests').dataset.overviewDragHandle, 'requests');
const accidental = grid.fire('dragstart', { target: cards.get('requests'), dataTransfer: dataTransfer() });
assert(accidental.prevented);
assert.equal(writes.length, 0);
// Hide removes the real block from the current view, not the registry; restore
// works immediately without an API call, and Cancel reverts an uncommitted hide.
button('requests', '.overview-inline-hide').fire('click');
assert(cards.get('requests').hidden);
assert(controls.get('overview-inline-hidden').open);
assert.equal(controls.get('overview-inline-hidden').hidden, false);
assert(controls.get('overview-inline-hidden-list').children.length > hiddenDefault.length);
assert.equal(writes.length, 0);
controls.get('overview-inline-hidden-list').children
.find(item => item.children.some(child => child.textContent === 'label:requests'))
.querySelector('.overview-inline-restore').fire('click');
assert(!cards.get('requests').hidden, 'restore makes the block visible in the same draft');
button('requests', '.overview-inline-hide').fire('click');
assert(cards.get('requests').hidden);
controls.get('overview-inline-cancel').fire('click');
assert(!cards.get('requests').hidden, 'cancel restores the original visibility');
assert.equal(controls.get('overview-inline-hidden').hidden, true);
assert.equal(writes.length, 0);
controls.get('overview-layout-unlock').fire('click'); await refresh();
const transfer = dataTransfer();
grid.fire('dragstart', { target: grip('requests'), dataTransfer: transfer });
assert.equal(transfer['text/plain'], 'requests');
grid.fire('dragover', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
grid.fire('drop', { target: cards.get('active'), clientX: 70, dataTransfer: transfer });
assert.deepEqual(visible().slice(0, 3), ['active','requests','bandwidth']);
assert.equal(writes.length, 0);
button('requests', '.overview-inline-up').fire('click');
assert.deepEqual(visible().slice(0, 3), ['requests','active','bandwidth']);
button('traffic_chart', '.overview-inline-up').fire('click');
assert.equal(writes.length, 0);
controls.get('overview-inline-cancel').fire('click');
assert(!editor.isEditing());
assert.deepEqual(visible(), ids.filter(id => !hiddenDefault.includes(id)));
assert.equal(toolbarCount(), 0);
assert.equal(writes.length, 0);
// Saving writes exactly one ordered/hidden patch and retains other preferences.
controls.get('overview-layout-unlock').fire('click'); await refresh();
grid.fire('dragstart', { target: grip('requests'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('active'), clientX: 90 });
reject = true;
controls.get('overview-inline-save').fire('click'); await refresh();
assert(editor.isEditing(), 'failed PUT preserves the draft');
assert.equal(controls.get('overview-inline-hint').textContent, 'server refused');
assert.equal(writes.length, 0);
reject = false;
controls.get('overview-inline-save').fire('click'); await refresh();
assert(!editor.isEditing());
assert.equal(toolbarCount(), 0);
assert.equal(writes.length, 1);
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
assert.deepEqual([...current.dashboard_hidden_widgets], hiddenDefault);
assert.equal(current.theme, 'light');
assert.equal(notifications.length, 1);
// Locking without changes does not write again. Navigation discard and dialog guard.
controls.get('overview-layout-unlock').fire('click'); await refresh();
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(!editor.isEditing());
assert.equal(writes.length, 1);
controls.get('overview-layout-dialog').open = true;
controls.get('overview-layout-unlock').fire('click'); await refresh();
assert(!editor.isEditing(), 'do not edit actual cards underneath the open modal');
controls.get('overview-layout-dialog').open = false;
controls.get('overview-layout-unlock').fire('click'); await refresh();
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('requests'), clientX: 0 });
assert.notDeepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
editor.cancel();
assert.deepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id)));
assert.equal(writes.length, 1);
console.log('PASS: direct Overview unlock, handles, move arrows, cancel, one save, retry, lock, navigation and modal guard');
// Hide persists across unlock sessions and the list allows recovery without
// touching unrelated account settings. The same API path handles a large panel.
controls.get('overview-layout-unlock').fire('click'); await refresh();
button('backend_table', '.overview-inline-hide').fire('click');
assert(cards.get('backend_table').hidden);
assert.equal(writes.length, 1);
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 2);
assert(current.dashboard_hidden_widgets.includes('backend_table'));
assert(cards.get('backend_table').hidden);
assert.equal(current.theme, 'light');
controls.get('overview-layout-unlock').fire('click'); await refresh();
controls.get('overview-inline-hidden-list').children
.find(item => item.children.some(child => child.textContent === 'label:backend_table'))
.querySelector('.overview-inline-restore').fire('click');
assert(!cards.get('backend_table').hidden);
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 3);
assert(!current.dashboard_hidden_widgets.includes('backend_table'));
assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']);
console.log('PASS: inline hide, unsaved cancel, hidden shelf restore and one-request persistence');
// In the real browser the row controller groups cards by footprint. A cross-
// group drop should not appear to move a card into an incompatible-height row.
window.rustProxyMasonry = {groupFor: id => id === 'traffic_chart' ? 1 : 0};
const beforeGroups = [...current.dashboard_widgets];
controls.get('overview-layout-unlock').fire('click'); await refresh();
const beforeCrossDrop = visible();
grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() });
grid.fire('drop', { target: cards.get('requests'), clientX: 50 });
assert.deepEqual(visible(), beforeCrossDrop, 'different-size cards must not switch groups');
controls.get('overview-inline-save').fire('click'); await refresh();
assert.equal(writes.length, 3, 'blocked cross-group move must not write');
assert.deepEqual([...current.dashboard_widgets], beforeGroups);
console.log('PASS: cross-size drops cannot break symmetric grid groups');
@@ -1,213 +0,0 @@
// Browser-independent UI simulation of the actual visual Overview modal.
// This does not replace an authenticated DOM/browser acceptance test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = registry.map(widget => widget.id);
const initialHidden = registry.filter(widget => widget.default_hidden).map(widget => widget.id);
const source = fs.readFileSync('src/admin/web/app/dashboard-layout-editor.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
for (const id of ['overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden', 'overview-layout-open', 'overview-layout-open-settings', 'overview-layout-save', 'overview-layout-cancel', 'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel'])
assert(html.includes(`id="${id}"`), id);
class FakeNode {
constructor(tag = 'div', content = '', classes = '') {
this.tag = tag; this.textContent = String(content ?? ''); this.classes = new Set(classes.split(/\s+/).filter(Boolean));
this.classList = { add: (...args) => args.forEach(x => this.classes.add(x)), remove: (...args) => args.forEach(x => this.classes.delete(x)) };
this.children = []; this.attrs = {}; this.dataset = {}; this.listeners = new Map();
this.hidden = false; this.open = false; this.disabled = false; this.value = ''; this.draggable = false;
}
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
addEventListener(name, cb) { const list = this.listeners.get(name) || []; list.push(cb); this.listeners.set(name, list); }
fire(name, event = {}) { for (const cb of this.listeners.get(name) || []) cb({ preventDefault() {}, target: this, ...event }); }
setAttribute(k, v) { this.attrs[k] = String(v); }
removeAttribute(k) { delete this.attrs[k]; }
focus() { this.focused = true; }
showModal() { this.open = true; }
close() { this.open = false; this.fire('close'); }
querySelectorAll(selector) {
const nodes = [];
function visit(node) {
for (const child of node.children) {
if (selector === '[id]' && child.attrs.id) nodes.push(child);
else if (selector === '[aria-labelledby], [aria-describedby]' && (child.attrs['aria-labelledby'] || child.attrs['aria-describedby'])) nodes.push(child);
else if (selector === '.overview-layout-tile' && child.classes.has('overview-layout-tile')) nodes.push(child);
visit(child);
}
}
visit(this); return nodes;
}
querySelector(selector) {
return this.querySelectorAll('.overview-layout-tile').find(node => node.classes.has(selector.slice(1))) ||
(() => { const visited = [this]; while (visited.length) { const node = visited.shift(); if (node.classes.has(selector.slice(1))) return node; visited.push(...node.children); } return null; })();
}
cloneNode(deep = false) {
const copy = new FakeNode(this.tag, this.textContent, [...this.classes].join(' '));
copy.hidden = this.hidden; copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
if (deep) copy.children = this.children.map(node => node.cloneNode(true));
return copy;
}
}
const controls = Object.fromEntries([
'overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden',
'overview-layout-save', 'overview-layout-cancel', 'overview-layout-search', 'overview-layout-type-filter',
'overview-layout-message', 'overview-layout-open', 'overview-layout-open-settings',
'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel',
].map(id => [id, new FakeNode()]));
const originals = new Map(ids.map(id => {
const node = new FakeNode('article', 'metric value', 'stat');
node.attrs.id = `real-${id}`; node.dataset.dashboardWidget = id;
node.append(new FakeNode('strong', '42'));
node.children[0].attrs.id = `metric-${id}`;
node.hidden = initialHidden.includes(id);
return [id, node];
}));
let saved = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden], theme: 'light' };
const writes = [], notifications = [];
let failSave = false;
const window = { rustProxyPreferences: {
current: () => saved,
load: async () => saved,
update: async patch => {
if (failSave) throw Error('server refused');
writes.push(patch); saved = { ...saved, ...patch };
for (const id of ids) originals.get(id).hidden = patch.dashboard_hidden_widgets.includes(id);
return saved;
},
} };
const $ = id => controls[id];
const ctx = {
window, $, t: text => `loc:${text}`, toast: text => notifications.push(text),
el: (tag, content, cls) => new FakeNode(tag, content, cls),
ADMIN_DASHBOARD_BY_ID: new Map(registry.map(widget => [widget.id, widget])),
ADMIN_DASHBOARD_WIDGETS: [...ids],
ADMIN_DEFAULT_HIDDEN_WIDGETS: [...initialHidden],
ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden] },
normalizeDashboardWidgets: value => [...new Set([...(Array.isArray(value) ? value.filter(id => ids.includes(id)) : []), ...ids])],
normalizeHiddenDashboardWidgets: value => [...new Set((Array.isArray(value) ? value : []).filter(id => ids.includes(id)))],
dashboardWidgetType: id => registry.find(widget => widget.id === id)?.display_type,
dashboardWidgetTypeLabel: id => `loc:overviewBlockType${({
value: 'Value', value_chart: 'ValueChart', chart: 'Chart', panel: 'Panel', table: 'Table',
})[registry.find(widget => widget.id === id)?.display_type]}`,
dashboardWidgetTypeBadge: id => new FakeNode('span', `type:${registry.find(widget => widget.id === id)?.display_type}`, 'overview-widget-type'),
document: { querySelector: selector => originals.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
};
vm.createContext(ctx); vm.runInContext(source, ctx);
const module = window.rustProxyDashboardLayout;
const dialog = controls['overview-layout-dialog'];
const visible = controls['overview-layout-visible'];
const hidden = controls['overview-layout-hidden'];
const refresh = () => new Promise(resolve => setImmediate(resolve));
const previewIds = () => visible.children.filter(node => node.dataset.previewWidget).map(node => node.dataset.previewWidget);
// Cancel changes, no PUT or change of real dashboard.
controls['overview-layout-open'].fire('click'); await refresh();
assert.equal(dialog.open, true);
assert.equal(previewIds().length, ids.length - initialHidden.length);
assert.equal(hidden.children.length, initialHidden.length);
assert.equal(visible.children[0].children[0].children[1].textContent, 'type:value');
assert.equal(hidden.children[0].children[1].textContent, `type:${registry.find(w => w.id === initialHidden[0]).display_type}`);
controls['overview-layout-type-filter'].value = 'chart';
controls['overview-layout-type-filter'].fire('change');
assert.equal(hidden.children.filter(item => item.classes.has('overview-layout-hidden-item')).length, registry.filter(w => w.default_hidden && w.display_type === 'chart').length, 'type filter shows only hidden charts');
controls['overview-layout-type-filter'].value = 'all';
controls['overview-layout-type-filter'].fire('change');
assert.equal(hidden.children.length, initialHidden.length);
const previewCopy = visible.children[0].children[1];
assert.equal(previewCopy.attrs.id, undefined, 'preview clones must not duplicate real IDs');
assert.equal(previewCopy.children[0].attrs.id, undefined);
assert.equal(previewCopy.attrs['aria-hidden'], 'true');
const first = previewIds()[0];
visible.children[0].children[0].children[2].children[2].fire('click');
assert(!previewIds().includes(first));
assert.equal(originals.get(first).hidden, false, 'draft hide must not touch live cards');
assert.equal(writes.length, 0);
controls['overview-layout-cancel'].fire('click');
assert.equal(dialog.open, false);
assert.equal(writes.length, 0);
// Settings and Overview entry points are wired to the same editor.
controls['overview-layout-open-settings'].fire('click'); await refresh();
assert.equal(dialog.open, true);
const originalVisible = previewIds();
const from = visible.children[0], to = visible.children[2];
const dataTransfer = { setData(type, value) { this[type] = value; }, effectAllowed: '', dropEffect: '' };
from.fire('dragstart', { target: from, dataTransfer });
to.fire('dragover', { dataTransfer });
to.fire('drop', { dataTransfer });
assert.equal(previewIds()[2], originalVisible[0], 'dropping moves a tile after the drop target');
assert.equal(previewIds()[0], originalVisible[1]);
// Keyboard buttons move cards back, without rebuilding saved prefs prematurely.
visible.children[2].children[0].children[2].children[0].fire('click');
assert.equal(previewIds()[1], originalVisible[0]);
const firstHiddenId = initialHidden[0];
const hiddenItem = hidden.children.find(node => node.children[0].textContent === `loc:${registry.find(x => x.id === firstHiddenId).label_key}`);
hiddenItem.children[2].fire('click');
assert(previewIds().includes(firstHiddenId));
assert.equal(writes.length, 0);
controls['overview-layout-save'].fire('click'); await refresh();
assert.equal(writes.length, 1, 'one atomic preferences update');
assert.equal(dialog.open, false);
assert.equal(saved.theme, 'light', 'unrelated preferences are untouched');
assert.equal(new Set(saved.dashboard_widgets).size, ids.length);
assert.equal(saved.dashboard_widgets.length, ids.length);
assert(!saved.dashboard_hidden_widgets.includes(firstHiddenId));
assert.equal(notifications.length, 1);
// Failed save keeps the dialog open and the unsaved draft available for retry.
controls['overview-layout-open'].fire('click'); await refresh();
failSave = true;
const writeCount = writes.length;
controls['overview-layout-save'].fire('click'); await refresh();
assert.equal(dialog.open, true);
assert.equal(controls['overview-layout-message'].textContent, 'server refused');
assert.equal(writes.length, writeCount);
failSave = false;
controls['overview-layout-cancel'].fire('click');
assert.equal(dialog.open, false);
// Default view is only a draft until Save: no implicit writes.
controls['overview-layout-open'].fire('click'); await refresh();
const writesBeforeDefault = writes.length;
controls['overview-layout-default'].fire('click');
assert.deepEqual(previewIds(), ids.filter(id => !initialHidden.includes(id)));
assert(controls['overview-layout-message'].textContent.includes('overviewLayoutDefaultLoaded'));
assert.equal(writes.length, writesBeforeDefault);
assert(previewIds().includes('backend_table'), 'all seven full-width Overview panels belong to the editor');
assert.equal(visible.children.find(node => node.dataset.previewWidget === 'backend_table').classes.has('is-panel'), true);
// Reset first prompts for confirmation, cancellation must not change preferences.
controls['overview-layout-reset'].fire('click');
assert.equal(controls['overview-reset-confirm'].open, true);
assert.equal(writes.length, writesBeforeDefault);
controls['overview-reset-cancel'].fire('click');
assert.equal(controls['overview-reset-confirm'].open, false);
assert.equal(writes.length, writesBeforeDefault);
// A failed reset keeps the confirmation open and never claims success.
controls['overview-layout-reset'].fire('click'); failSave = true;
controls['overview-reset-apply'].fire('click'); await refresh();
assert.equal(controls['overview-reset-confirm'].open, true);
assert.equal(writes.length, writesBeforeDefault);
failSave = false;
controls['overview-reset-apply'].fire('click'); await refresh();
assert.equal(controls['overview-reset-confirm'].open, false);
assert.equal(dialog.open, false);
assert.equal(writes.length, writesBeforeDefault + 1);
assert.deepEqual([...saved.dashboard_widgets], ids);
assert.deepEqual([...saved.dashboard_hidden_widgets], initialHidden);
assert.equal(saved.theme, 'light', 'reset must not affect unrelated account preferences');
console.log('PASS: full Overview editor, drag/keyboard, legacy settings, default preview, confirmed reset, cancel, atomic save and failures');
// Group boundaries are a UX constraint in the real browser: the modal must
// not let a small tile replace the position of a tall diagnostic panel.
window.rustProxyMasonry = { groupFor: id => id === 'traffic_chart' ? 1 : id.endsWith('_diagnostics') ? 3 : 0 };
controls['overview-layout-open'].fire('click'); await refresh();
const beforeGroupMove = previewIds();
const small = visible.children.find(node => node.dataset.previewWidget === 'requests');
const big = visible.children.find(node => node.dataset.previewWidget === 'traffic_chart');
small.fire('dragstart', { target: small, dataTransfer: {setData() {},effectAllowed:''} });
big.fire('drop');
assert.deepEqual(previewIds(), beforeGroupMove, 'the modal must not mix incompatible tile groups');
controls['overview-layout-cancel'].fire('click');
console.log('PASS: visual editor respects symmetric height groups');
@@ -1,95 +0,0 @@
#!/usr/bin/env node
// Browser bootstrap contract: visible recovery and early shell.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { resolve, dirname } 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 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" 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/);
assert.match(css, /app-startup-reveal 0s linear 12s forwards/);
assert.match(css, /prefers-reduced-motion: reduce/);
assert.match(runtime, /showStartupFailure\(e\)/);
const titleRow = html.match(/<div class="page-intro-title-row">([\s\S]*?)<\/div>/)?.[1];
assert(titleRow, 'title row exists');
assert.match(titleRow, /id="page-title"/);
assert.match(titleRow, /id="overview-refresh"[^>]*hidden/);
assert.equal((html.match(/id="overview-refresh"/g) || []).length, 1);
assert.match(core, /\$\("overview-refresh"\)\.hidden = name !== "overview"/);
assert.match(css, /\.page-intro-title-row\s*\{\s*display:\s*flex/);
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 = resume.slice(resume.indexOf('function displayStartupError(error, sessionResume = false)'));
assert(activateSource.startsWith('async function activate(info)'));
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: '', addEventListener() {} });
return nodes.get(key);
};
const classes = new Set(['app-loading']);
let startCount = 0, configCount = 0, statusCount = 0, prefResolve, configResolve;
const preferencesPromise = new Promise(resolve => { prefResolve = resolve; });
const configPromise = new Promise(resolve => { configResolve = resolve; });
const context = {
$: node,
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() {},
authEpoch: 0, authenticated: false, csrf: '', sessionInfo: null,
renderSession() {},
rustProxyRefresh: { start() { startCount++; } },
loadAdminPreferences: () => preferencesPromise,
loadConfig: () => { configCount++; return configPromise; },
refresh: async () => { statusCount++; },
toast() { throw new Error('Unexpected toast'); },
window: { rustProxyRefresh: { stop() {} } }, languagePacks: new Map(), t: key => key,
};
runInNewContext(`${activateSource}\n${failureSource}`, context);
const activating = context.activate({ username: 'admin', csrf_token: 'CSRF' });
assert.equal(startCount, 1, 'SSE/polling starts without waiting for preferences');
assert.equal(context.authenticated, true);
assert.equal(node('workspace').hidden, false, 'shell is shown immediately');
assert(!classes.has('app-loading'), 'spinner is cleared before preferences/config/status');
assert.equal(configCount, 0, 'preferences request is still pending');
assert.equal(statusCount, 0);
prefResolve({ refresh: {} });
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(configCount, 1);
assert.equal(node('workspace').hidden, false, 'slow configuration does not hide shell');
configResolve({});
await activating;
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(statusCount, 1);
context.showStartupFailure(new Error('Offline'));
assert(classes.has('app-loading') && classes.has('app-startup-failed'));
assert.equal(node('app-startup-detail').textContent, 'Offline');
assert.equal(node('app-startup-detail').hidden, false);
assert.equal(node('workspace').hidden, true, 'failed shell cannot obscure retry UI');
console.log('PASS: startup spinner/retry, shell before slow API, error recovery and compact Overview title row.');
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""Check the 0.31.9 dependency/source migration; optionally require a resolved lock.
This is not a substitute for rustc, cargo metadata or runtime tests.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
import tomllib
ROOT = Path(__file__).resolve().parents[2]
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.32.7'
assert manifest['package']['rust-version'] == '1.99'
deps = manifest['dependencies']
expected_requirements = {
'argon2': '0.6', 'rand': '0.10', 'sqlx': '0.9', 'rcgen': '0.14.10',
'tower-http': '0.7.1', 'brotli': '9', 'sysinfo': '0.39',
'h3': '=2.5.20', 'tokio': '1.53.2', 'hyper': '1.12.0',
}
for name, version in expected_requirements.items():
entry = deps[name]
found = entry if isinstance(entry, str) else entry['version']
assert found == version, f'{name} requirement changed: {found!r} != {version!r}'
assert deps['h3']['package'] == 'hpx-h3'
assert deps['h3']['features'] == ['quinn']
assert deps['sqlx']['default-features'] is False
assert deps['sqlx']['features'] == ['runtime-tokio', 'sqlite']
assert set(deps['sysinfo']['features']) == {'network','system'}
assert set(deps['rcgen']['features']) == {'crypto', 'pem', 'ring'}
assert 'tls12' in deps['rustls']['features'] and 'ring' in deps['rustls']['features']
assert 'tls12' in deps['tokio-rustls']['features']
for target in ('Dockerfile', 'integrations/routeros-oci-build/Dockerfile.routeros'):
assert 'FROM rust:1.99-trixie AS build' in (ROOT / target).read_text(), target
files = [p for p in (ROOT / 'src').rglob('*.rs')]
sources = '\n'.join(p.read_text() for p in files)
for old in ('rngs::OsRng', 'rand::thread_rng()', 'SaltString::generate(',
'distributions::Alphanumeric', '.key_pair.serialize_pem()',
'hash_password(password.as_bytes(), &salt)'):
assert old not in sources, f'obsolete dependency API: {old}'
for new in ('SysRng.try_fill_bytes(', 'phc::PasswordHash',
'hash_password(password.as_bytes())', 'verify_password(password.as_bytes()',
'signing_key.serialize_pem()', 'rand::rng()'):
assert new in sources, f'missing dependency migration: {new}'
assert 'VERSION = "5.33.1"' in (ROOT / 'scripts/build/swagger_assets.py').read_text()
lock = tomllib.loads((ROOT / 'Cargo.lock').read_text())
installed: dict[str,set[str]] = {}
for item in lock['package']:
installed.setdefault(item['name'], set()).add(item['version'])
latest_required = {
'argon2': '0.6.0', 'rand': '0.10.3', 'sqlx': '0.9.0',
'rcgen': '0.14.10', 'tower-http': '0.7.1', 'brotli': '9.0.0',
'sysinfo': '0.39.6', 'hpx-h3': '2.5.20', 'hyper': '1.12.0',
'tokio': '1.53.2',
}
def compatible_update(package: str, installed_version: str, minimum: str) -> bool:
candidate = tuple(int(x) for x in installed_version.split('.')[:3])
baseline = tuple(int(x) for x in minimum.split('.')[:3])
if package == 'hpx-h3':
return candidate == baseline # Intentionally exact-pinned for the QUIC integration.
if candidate[0] != baseline[0]:
return False
if baseline[0] == 0 and candidate[1] != baseline[1]:
return False
return candidate >= baseline
missing = {name: version for name,version in latest_required.items()
if not any(compatible_update(name, candidate, version)
for candidate in installed.get(name, set()))}
if missing:
details = ', '.join(f'{name} {version}' for name,version in missing.items())
if args.require_lock:
raise SystemExit(f'FAIL: Cargo.lock not resolved to current deps: {details}. Run cargo update first.')
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.9 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
@@ -1,167 +0,0 @@
#!/usr/bin/env node
// Runs the real Docker VHosts dashboard module against a minimal DOM + API fixture.
// This is a JS-only regression, not a compiled RustProxy or live agent test.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const dockerSource = readFileSync('src/admin/web/docker.js', 'utf8');
const coreSource = readFileSync('src/admin/web/app/core.js', 'utf8');
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
for (const translations of [english, polish]) {
assert.ok(translations.dockerNoObservation);
assert.ok(translations.dockerAgentConnectionFailed);
assert.ok(translations.dockerUnknownPollError);
assert.doesNotMatch(translations.dockerNoObservation.toLowerCase(), /refresh|odśwież/);
}
class FakeNode {
constructor(tag, text = '', cls = '') {
this.tagName = tag;
this.content = String(text ?? '');
this.className = cls;
this.children = [];
this.dataset = {};
this.attrs = {};
this.events = {};
this.hidden = false;
this.open = false;
this.classList = {
toggle: (name, value) => {
const parts = new Set(this.className.split(' ').filter(Boolean));
if (value) parts.add(name); else parts.delete(name);
this.className = [...parts].join(' ');
},
add: name => { this.className += ` ${name}`; },
remove: name => { this.className = this.className.split(' ').filter(part => part !== name).join(' '); },
};
}
get textContent() { return this.content + this.children.map(child => child.textContent || '').join(''); }
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
append(...nodes) { this.children.push(...nodes.filter(Boolean)); }
replaceChildren(...nodes) { this.children = nodes.filter(Boolean); }
setAttribute(name, value) { this.attrs[name] = value; }
addEventListener(name, fn) { this.events[name] = fn; }
descendants() { return this.children.flatMap(child => [child, ...child.descendants()]); }
querySelectorAll(selector) {
const part = selector.split(' ').at(-1);
if (part.startsWith('.')) return this.descendants().filter(child => child.className.split(' ').includes(part.slice(1)));
return [];
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
}
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
const destination = node('div');
const elements = new Map([
['docker-discovery-list', destination],
['overview-refresh', node('button')],
['page-label', node('span')],
['page-title', node('h1')],
['page-description', node('p')]
]);
const agent = {
id: 'docker1', name: 'Docker One', enabled: true,
base_domain: 'apps.example.test', endpoint: 'https://192.0.2.2:9876',
tls_fingerprint_sha256: 'A'.repeat(64), poll_interval_seconds: 30,
assignments: []
};
const config = { docker_agents: { agents: [agent] }, listeners: { endpoints: [] }, tls: { certificates: [] } };
const storage = new Map();
const requests = [];
let nextResult = { observations: [], pools: [], pool_routes: [] };
let refreshSpec;
let syncs = 0, manuals = 0;
const coordinator = {
register(id, spec) { assert.equal(id, 'docker'); refreshSpec = spec; },
sync() { syncs++; },
manual(id) { assert.equal(id, 'docker'); manuals++; return Promise.resolve(); },
setPanel(name) { this.panel = name; },
};
const ctx = {
el: node,
$: name => elements.get(name) || null,
t: name => english[name] || name,
document: {
activeElement: null,
querySelectorAll(selector) {
if (selector === '#docker-discovery-list .docker-agent-card') return destination.children;
return [];
}
},
window: { rustProxyRefresh: coordinator, rustProxySettings: {} },
rustProxyRefresh: coordinator,
localStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value) },
authenticated: true, authEpoch: 1,
currentPanel: 'docker-vhosts', envelope: { config },
settingsDirty: new Set(), busy: false,
api: async (path, args = {}) => { requests.push({ path, args }); return nextResult; },
toast(message) { throw new Error(`Unexpected toast: ${message}`); },
console,
};
vm.createContext(ctx);
vm.runInContext(dockerSource, ctx, { filename: 'src/admin/web/docker.js' });
assert(refreshSpec, 'register Docker refresh with central scheduler');
assert.equal(refreshSpec.interval(), 5);
ctx.window.rustProxyDocker.render();
assert.equal(manuals, 1, 'deep-link bootstrap fetches a snapshot after configuration loads');
assert.equal(syncs, 1, 'deep-link bootstrap syncs waiting-state scheduler');
assert.equal(refreshSpec.active(), true, 'bootstrap retries cached status without requiring local Live view');
assert.match(destination.textContent, /WAITING/);
assert.match(destination.textContent, /Waiting for the first agent check/);
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
assert.doesNotMatch(destination.textContent, /Agent connection failed/);
assert.equal(destination.querySelector('.docker-agent-error'), null, 'pending must not be styled as a failure');
let tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
assert.ok(tlsPill);
assert.ok(!tlsPill.className.split(' ').includes('live'), 'configured fingerprint does not prove successful handshake');
await ctx.window.rustProxyDocker.refresh(false);
assert.equal(requests.at(-1).path, '/api/docker');
assert.notEqual(requests.at(-1).args.method, 'POST', 'automatic status fetch never polls the agent');
assert.equal(refreshSpec.active(), true, 'retry while the initial server-side poll is not complete');
nextResult = { observations: [{ agent_id: 'docker1', ok: false, error: 'connection refused', checked_at_unix_ms: 1, inventory: null }], pools: [], pool_routes: [] };
await ctx.window.rustProxyDocker.refresh(false);
assert.match(destination.textContent, /Last Docker agent check failed/);
assert.match(destination.textContent, /connection refused/);
assert.doesNotMatch(destination.textContent, /TLS certificate SHA-256:/, 'pinned fingerprint is configuration, not the reason for an arbitrary failure');
assert.ok(destination.querySelector('.docker-agent-error'), 'a genuine failed observation must be visible');
assert.equal(refreshSpec.active(), false, 'local Live-off stops automatic refresh after first status, even if it is a failure');
nextResult = { observations: [{ agent_id: 'docker1', ok: true, error: null, checked_at_unix_ms: Date.now(), inventory: {
agent_version: '0.22.3', hostname: 'docker1', addresses: [], services: [], public_ip: '192.0.2.2', certificate_sha256: 'A'.repeat(64)
} }], pools: [], pool_routes: [] };
await ctx.window.rustProxyDocker.refresh(false);
assert.match(destination.textContent, /CONNECTED/);
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
assert.equal(refreshSpec.active(), false, 'local Live-off remains off with a successful observation');
tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
assert.ok(tlsPill.className.split(' ').includes('live'), 'pin may be marked verified after a successful poll');
storage.set('rustproxy.docker.live-view', '1');
assert.equal(refreshSpec.active(), true, 'Live view still polls normally');
storage.delete('rustproxy.docker.live-view');
// Execute the real panel-navigation function with side effects disabled.
const from = coreSource.indexOf('function setPanel(name, navigate = true) {');
const to = coreSource.indexOf('function bytes(value)', from);
assert(from >= 0 && to > from);
ctx.PANEL_PATHS = { overview: '/', 'docker-vhosts': '/docker-vhosts' };
vm.runInContext(coreSource.slice(from, to), ctx);
ctx.currentPanel = 'overview';
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 2, 'opening Docker VHosts requests a new cached snapshot even with Live view off');
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 2, 'rendering the same panel does not duplicate one-shot requests');
ctx.setPanel('overview', false);
ctx.setPanel('docker-vhosts', false);
assert.equal(manuals, 3, 're-entering the page reads the newest cached status');
ctx.currentPanel = 'overview';
assert.equal(refreshSpec.active(), false, 'no bootstrap activity outside Docker VHosts');
ctx.currentPanel = 'docker-vhosts';
config.docker_agents.agents = [{ ...agent, enabled: false }];
ctx.window.rustProxyDocker.render();
assert.equal(refreshSpec.active(), false, 'disabled agents do not cause polling');
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
console.log('PASS: 0.31.9 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.31.9 release."""
from pathlib import Path
import re
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[2]
DOCS = ROOT / "docs"
required = [
ROOT / "README.md",
ROOT / "ROADMAP.md",
ROOT / "CHANGELOG.md",
DOCS / "README.md",
DOCS / "WEB_SERVER.md",
DOCS / "TODO.md",
DOCS / "TEST_REPORT.md",
DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md",
DOCS / "ROADMAP_HISTORY.md",
]
for path in required:
assert path.is_file(), f"missing required documentation: {path.relative_to(ROOT)}"
index = (DOCS / "README.md").read_text(encoding="utf-8")
for marker in ["Functional documentation", "Development and testing", "Technical documentation", "Historical or version-labelled context", "TODO.md", "SECURITY_AUDIT_0.31.0.md", "CHANGELOG_TECHNICAL_ARCHIVE.md", "ROADMAP_HISTORY.md", "/docs"]:
assert marker in index, marker
for document in sorted(DOCS.glob("*.md")):
if document.name == "README.md":
continue
assert document.name in index, f"documentation index does not classify {document.name}"
reader_html = (ROOT / "src/admin/web/docs.html").read_text(encoding="utf-8")
reader_js = (ROOT / "src/admin/web/docs.js").read_text(encoding="utf-8")
build = (ROOT / "build.rs").read_text(encoding="utf-8")
admin = rust_source(ROOT, "src/admin/mod.rs")
for marker in ["docs-tab-reader", "docs-reader-list", "docs-reader-content", "docs-tab-api"]:
assert marker in reader_html, marker
for marker in ["renderMarkdown", "selectDocument", 'get("/docs/content.json")', "docsByPath", "docsCategoryHistorical"]:
assert marker in reader_js, marker
documentation_rs = rust_source(ROOT, "src/admin/documentation.rs") + (ROOT / "src/admin/documentation/documents.rs").read_text(encoding="utf-8")
assert "READER_DOCUMENTS" in documentation_rs and "reader_bundle" in documentation_rs
assert 'include_str!(concat!(env!("CARGO_MANIFEST_DIR")' in documentation_rs
assert 'docs-content.json' not in documentation_rs and 'documentation_bundle' not in build
for document in sorted(DOCS.glob("*.md")):
marker = f'"{document.relative_to(ROOT).as_posix()}"'
assert marker in documentation_rs, f"Reader does not embed {document.relative_to(ROOT)}"
for path in set(re.findall(r'"(docs/[A-Za-z0-9_.-]+\.md)"', documentation_rs)):
assert (ROOT / path).is_file(), f"Reader embeds missing document: {path}"
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.32.7")
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
assert roadmap.startswith("# RustProxy roadmap") and "## 0.30.0" and "## 0.29.1" in roadmap and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
assert "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap
assert changelog.startswith("# Changelog\n")
for release in ["0.29.1", "0.29.0", "0.28.2", "0.28.1", "0.28.0", "0.27.3", "0.27.2", "0.27.1", "0.27.0", "0.26.7", "0.26.6", "0.26.5", "0.26.4", "0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
assert f"## {release}" in changelog, release
assert changelog.index("## 0.29.1") < changelog.index("## 0.29.0") < changelog.index("## 0.28.2") < changelog.index("## 0.28.1") < changelog.index("## 0.28.0") < changelog.index("## 0.27.3") < changelog.index("## 0.27.2") < changelog.index("## 0.27.1") < changelog.index("## 0.27.0") < changelog.index("## 0.26.7") < changelog.index("## 0.26.6") < changelog.index("## 0.26.5") < changelog.index("## 0.26.4") < changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
assert "CHANGELOG_TECHNICAL_ARCHIVE.md" in changelog
for developer_only in ["Clean the remaining compiler warnings", "Update package/source/UI/OpenAPI/release identity", "source regressions and update release identity"]:
assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}"
link_re = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
paths = [ROOT / "README.md", ROOT / "ROADMAP.md", ROOT / "CHANGELOG.md", *sorted(DOCS.glob("*.md"))]
for source in paths:
text = source.read_text(encoding="utf-8")
assert "Run compiler-enabled `cargo fmt --all`" not in text, f"obsolete release-host checklist remains in {source.relative_to(ROOT)}"
for raw in link_re.findall(text):
target = raw.strip().split()[0].strip("<>")
if not target or target.startswith(("http://", "https://", "mailto:", "#", "/")):
continue
target = target.split("#", 1)[0].split("?", 1)[0]
if not target:
continue
resolved = (source.parent / target).resolve()
assert resolved.exists(), f"broken documentation link: {source.relative_to(ROOT)} -> {target}"
install = (DOCS / "INSTALL.md").read_text(encoding="utf-8")
for marker in ["https://rustproxy.linuxiarz.pl/", "https://rustproxy-opn.linuxiarz.pl/", "Optional: Debian 13+ one-line source install/update"]:
assert marker in install, marker
print("PASS: documentation index, Reader/API split, release docs and local Markdown links are consistent.")
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env python3
"""Static source contract for DDNS ACL 0.32.7; NOT a Rust test."""
from pathlib import Path
import json
import tomllib
ROOT = Path(__file__).resolve().parents[2]
read = lambda p: (ROOT / p).read_text(encoding="utf-8")
acl = read('src/acl.rs')
dns = read('src/acl/dynamic.rs')
js = read('src/admin/web/management.js')
checks = {
'keep static IP/CIDR checks': 'RuleMatch::Ip(net) => net.contains(&peer)' in acl,
'dynamic DNS only allows explicit allow rules': 'dynamic DNS ACL rules only support allow' in acl,
'deny precedence retained': 'return Some(Action::Deny)' in acl,
'strict hostname validation': 'pub(crate) fn hostname(' in dns,
'scope scan covers vhosts, locations, profiles, host policies and docker': all(f in dns for f in [
'config.host_policies', 'config.acl_profiles', 'config.vhosts', 'vhost.routes', 'config.docker_agents.agents', 'agent.assignments'
]),
'bounded asynchronous DNS work': all(x in dns for x in ['REFRESH_SECONDS: u64 = 60', 'MAX_AGE_SECONDS: u64 = 180', 'MAX_NAMES: usize = 128', 'MAX_ADDRESSES: usize = 32', 'PARALLEL_LOOKUPS: usize = 8', 'LOOKUP_SECONDS: u64 = 4', 'buffer_unordered(PARALLEL_LOOKUPS)']),
'lookup on refresh, not on request': 'lookup_host' in dns and 'lookup_host' not in acl and 'dynamic::matches' in acl,
'resolver owns the cache key independently of the DNS future (E0505)': 'let lookup_name = name.clone();' in dns and 'lookup_host((lookup_name.as_str(), 0u16))' in dns and 'lookup_host((name.as_str(), 0u16))' not in dns,
'fail closed on empty or failed resolution': 'CACHE.store(Arc::new(results))' in dns and 'return (name, None);' in dns and 'entry.valid_until > Instant::now()' in dns,
'update after configuration commit': all('crate::acl::dynamic::config_changed()' in read(p) for p in ['src/runtime/updates.rs', 'src/runtime/docker.rs', 'src/runtime/certificates.rs']),
'startup background refresh': 'state.start_dns_acl_updates();' in read('src/main/admin_runtime.rs'),
'visual selector and disabled DNS deny': '.acl-profile-rule-type' in js and 'aclDnsAllowOnly' in js,
'old network field persisted': 'network: normalizeNetwork(' in js,
'PL/EN translations': all(k in json.loads(read(f'langs/{loc}.json'))['translations'] for loc in ('en_US','pl_pl') for k in ('aclRuleType','aclRuleDns','aclDnsHelp','aclDnsAllowOnly')),
'package version': tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7',
}
for name, ok in checks.items():
if not ok: raise AssertionError(name)
print(f'PASS: {len(checks)} DNS ACL 0.32.7 static source checks (not compiled or network-tested)')
@@ -1,51 +0,0 @@
#!/usr/bin/env node
// Execute the real ACL editor initializer, not just parse it: an uninitialized
// i18n helper used to stop the entire fingerprinted dashboard bundle at load.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
const source = readFileSync('src/admin/web/management.js', 'utf8');
const start = source.lastIndexOf('(() => {');
assert(start > 0 && source.slice(start).includes('window.rustProxyAclProfiles ='), 'ACL editor IIFE not found');
function element(tag = 'div') {
return {
tag, children: [], dataset: {}, value: '', checked: false, hidden: false,
classList: { add() {}, remove() {}, toggle() {} },
append(...nodes) { this.children.push(...nodes); },
after(...nodes) { this.insertedAfter = nodes; },
replaceChildren(...nodes) { this.children = nodes; },
addEventListener() {},
reset() {},
querySelector() { return element(); },
querySelectorAll() { return []; },
};
}
const nodes = new Map();
const get = id => {
if (!nodes.has(id)) nodes.set(id, element());
return nodes.get(id);
};
const modes = ['allow_exceptions', 'block_exceptions', 'custom'].map((value, i) => ({ value, checked: i === 0 }));
get('acl-profile-form').querySelectorAll = selector => selector === 'input[name="acl-profile-mode"]' ? modes : [];
const window = {};
const context = {
$: get, window, envelope: null,
t: key => `localized:${key}`,
el: (tag, text, cls) => {
const node = element(tag);
if (text !== undefined) node.textContent = text;
if (cls) node.className = cls;
return node;
},
};
runInNewContext(source.slice(start), context, { filename: 'src/admin/web/management.js' });
assert.equal(typeof window.rustProxyAclProfiles?.render, 'function', 'ACL panel initializer must finish');
assert.equal(typeof window.rustProxyAclProfiles?.refreshTranslations, 'function');
const hint = get('acl-profile-ip-help').insertedAfter?.[0];
assert.equal(hint?.tag, 'p', 'DDNS hint must be rendered');
assert.equal(hint?.dataset.i18n, 'aclDnsHelp', 'DDNS hint must remain translatable');
assert.equal(hint?.textContent, 'localized:aclDnsHelp');
window.rustProxyAclProfiles.reset();
console.log('PASS: ACL/DDNS real initializer runs without i18n temporal dead zone');
@@ -1,28 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
const html = read('src/admin/web/index.html');
const js = read('src/admin/web/file-logs.js');
const css = read('src/admin/web/style.css');
const en = JSON.parse(read('langs/en_US.json')).translations;
const pl = JSON.parse(read('langs/pl_pl.json')).translations;
const header = html.indexOf('class="file-logs-heading-actions"');
const filter = html.indexOf('id="file-logs-filter"');
const toggle = html.indexOf('id="file-log-consolidate-vhosts"');
assert.ok(header >= 0 && header < toggle && toggle < filter, 'compact consolidation switch belongs in header');
assert.equal((html.match(/id="file-log-consolidate-vhosts"/g) || []).length, 1);
for (const view of ['entries','counts']) {
assert.match(html, new RegExp(`id="file-log-tab-${view}"[^>]+role="tab"`));
assert.match(html, new RegExp(`id="file-log-view-${view}"[^>]+role="tabpanel"`));
}
assert.match(html, /id="file-log-view-counts"[^>]+hidden/);
assert.ok(html.indexOf('id="file-log-aggregate-form"') > html.indexOf('id="file-log-view-counts"'));
assert.ok(html.indexOf('id="file-log-aggregate-form"') < html.indexOf('id="file-log-view-entries"'));
assert.match(js, /function setView\(view\)/);
assert.match(js, /aria-selected/);
assert.match(js, /\$\("file-log-view-" \+ name\)\.hidden = !selected/);
assert.match(js, /activeView === "counts" && aggregateField/);
assert.match(js, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
assert.match(css, /#file-log-view-counts\[hidden\]\s*,\s*#file-log-view-entries\[hidden\]/);
for (const strings of [en, pl]) for (const key of ['fileLogsConsolidateShort', 'fileLogsEntriesTab','fileLogsCountsTab']) assert.ok(strings[key], key);
console.log('PASS: Stored Logs separate tabs, shared filter, compact persisted consolidation toggle, localized UI.');
@@ -1,78 +0,0 @@
// Source contract tests only: no Rust compilation, live H3 peer or UI browser.
// Keep these checks aligned with Rust unit tests in src/http3/tests.rs and
// src/runtime/tests.rs; do not interpret source checks as wire acceptance.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
const h3 = read('src/http3.rs');
const identity = read('src/http3/identity.rs');
const body = read('src/http3/body.rs');
const h3Tests = read('src/http3/tests.rs');
const cache = read('src/runtime/types.rs');
const cacheTests = read('src/runtime/tests.rs');
const css = read('src/admin/web/style.css');
const html = read('src/admin/web/index.html');
const logs = read('src/admin/web/file-logs.js');
const en = JSON.parse(read('langs/en_US.json')).translations;
const pl = JSON.parse(read('langs/pl_pl.json')).translations;
// A peer-initiated RESET_STREAM or STOP_SENDING must not be logged as a
// fabricated HTTP 400 when the browser aborts a prior navigation on F5.
assert.match(identity, /fn client_cancelled_code\(code: Code\)/);
assert.match(identity, /code == Code::H3_REQUEST_CANCELLED \|\| code == Code::H3_NO_ERROR/);
assert.match(identity, /StreamError::RemoteTerminate \{ code, \.\. \} if client_cancelled_code\(\*code\)/);
assert.match(h3, /Ok\(Err\(error\)\)\s*=>\s*\{\s*if client_cancelled_request\(&error\)/);
assert.match(h3, /\} else \{\s*log_protocol_error\(\s*&state, "http3_request_error", error, peer, local, 400/);
assert.match(h3, /"http3_invalid_headers"[^\n]*\n\s*peer, local, 400/);
assert.match(h3, /error\.downcast_ref::<h3::error::StreamError>\(\)\s*\.is_some_and\(client_cancelled_request\)/);
assert.match(h3, /if !cancelled \{[\s\S]*?send\.stop_stream\(Code::H3_INTERNAL_ERROR\)/);
const bodyDrop = body.slice(body.indexOf('impl Drop for ReceiveBody'));
assert.ok(!bodyDrop.includes('stop_sending('), 'Do not reintroduce a receive-stream Drop panic');
assert.match(h3Tests, /client_cancelled_code\(Code::H3_MESSAGE_ERROR\)/);
// H2/H3 client transport selection is not a cached representation property.
const revision = cache.slice(cache.indexOf('fn cache_revision('), cache.indexOf('fn cache_invalidation('));
assert.match(revision, /for vhost in &mut vhosts/);
assert.match(revision, /vhost\.http2_enabled = true;/);
assert.match(revision, /vhost\.http3_enabled = true;/);
assert.ok(revision.indexOf('vhost.http3_enabled = true;') < revision.indexOf('serde_json::to_vec('));
assert.match(cacheTests, /cache_invalidation\(&base, &protocol_only\), CacheInvalidation::None/);
assert.match(cacheTests, /cache_invalidation\(&protocol_only, &base\), CacheInvalidation::None/);
assert.match(cacheTests, /cache_revision\(&both_protocols\), identity/);
assert.match(cacheTests, /CacheInvalidation::All\(value\) if value == cache_identity/);
// Every row is still keyboard-expandable and all selected columns are
// represented on the mobile card rather than removed by display:none.
assert.match(html, /id="panel-filelogs"/);
assert.match(logs, /row\.tabIndex = 0/);
assert.match(logs, /row\.setAttribute\("aria-expanded"/);
assert.match(logs, /event\.key === "Enter" \|\| event\.key === " "/);
assert.match(logs, /cell\.dataset\.mobileColumn = id/);
assert.match(logs, /cell\.dataset\.mobileLabel = label/);
assert.match(logs, /if \(selectedIndex === index\) nodes\.push\(detailRow\(entry\)\)/);
assert.match(css, /@media \(max-width: 700px\)/);
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\) \{\s*display: grid/);
assert.match(css, /#panel-filelogs \.file-logs-table tbody tr:not\(\.file-log-detail-row\)\s*>\s*td \{\s*display: block/);
assert.match(css, /overflow-wrap: anywhere/);
assert.match(css, /#panel-filelogs \.file-logs-table,\s*#panel-filelogs \.file-log-aggregate-table \{[^}]*overflow-x: auto/s);
assert.match(css, /#panel-filelogs \.file-log-inline-detail pre \{[^}]*max-width:\s*100%;[^}]*overflow-x:\s*auto;[^}]*\}/s);
// Exercise the actual browser code's entry-summary function with a logged
// H3 cancellation and a normal access event; this is a JS unit simulation.
const start = logs.indexOf(' function entrySummary(entry) {');
const end = logs.indexOf(' function nestedValue(', start);
assert.ok(start >= 0 && end > start, 'entrySummary function exists');
const context = vm.createContext({});
vm.runInContext(logs.slice(start, end), context);
assert.equal(vm.runInContext('entrySummary({reason:"http3_request_error",error:"Remote reset: H3_REQUEST_CANCELLED"})', context), 'http3_request_error');
assert.equal(vm.runInContext('entrySummary({method:"GET",host:"rp.example.test",path:"/admin"})', context), 'GET rp.example.test/admin');
for (const [language, pack] of [['en_US', en], ['pl_pl', pl]]) {
assert.match(pack.administrationPublicOriginHelp, /HTTPS/i, `${language}: origin scheme`);
assert.match(pack.administrationPublicOriginHelp, /Origin\/CSRF/i, `${language}: security purpose`);
assert.ok(pack.administrationHostsAdvanced.length > 10, `${language}: advanced DNS hosts`);
}
console.log('PASS: H3 cancellation classification, cache stability, mobile log-card/keyboard contracts and origin help.');
console.log('NOT VERIFIED: Rust build, actual QUIC navigation or full authenticated mobile UI.');
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
"""Compiler-independent guard for the 0.31.7 H3 navigation patch."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
html = (ROOT / 'src/admin/web/index.html').read_text()
assert '0.32.7' in (ROOT / "Cargo.toml").read_text()
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
assert 'let navigation_method = method == Method::GET || method == Method::HEAD;' in body
assert body.count('std::time::Duration::from_millis(35)') >= 2
assert 'let bounded_h3_navigation = request.version() == Version::HTTP_3' in body
assert 'Body::new(body::ReplayBody::new(frame, tail))' in body
assert '|| method == Method::GET' not in body.split('async fn normalize_h3_h1_unknown_body',1)[1].split('// Ignore a small number',1)[0]
h3 = (ROOT / 'src/http3.rs').read_text()
assert '"http3_peer_cancelled_before_headers"' in h3
assert '"http3_peer_cancelled_after_headers"' in h3
assert '"http3_response_write_error"' in h3
assert 'stream_id={stream_id}' in h3 and 'body_bytes_sent={sent_body_bytes}' in h3
diagnostics = (ROOT / 'src/http3/diagnostics.rs').read_text()
assert 'http3_navigation_complete' in diagnostics and 'http3_navigation_started' in diagnostics
assert 'Err(error)=>{requests.abort_all();return Err(error.into());}' not in h3
rust_tests = (ROOT / 'src/proxy/tests/h3_navigation_tests.rs').read_text()
for marker in ('h3_bodyless_get_and_head_are_normalized_for_http1_navigation',
'h3_get_with_payload_retains_payload_for_http1_upstream',
'h3_stalled_empty_get_does_not_block_navigation',
'h3_stalled_cache_probe_bypasses_without_blocking_navigation',
'h3_cache_probe_replays_nonempty_get_without_dropping_bytes'):
assert marker in rust_tests, marker
print('PASS: RustProxy 0.31.7 original logo, H3 empty-GET receive lifecycle, bounded probe, per-stream diagnostics and drain invariants')
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env python3
"""Offline high-cardinality invalidation replay + HTTP load-driver smoke test.
The replay models bookkeeping cardinality, not a running RustProxy heap. The HTTP
requests target a Python test peer solely to validate the staging load harness.
"""
from __future__ import annotations
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import os
from pathlib import Path
import sys
import threading
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
from memory_load import run_load # noqa: E402
def purge_replay(operations: int = 300_000, windows: int = 6) -> list[tuple[int, int]]:
old_generations = {}
new_generations = {}
counts = []
assert operations % windows == 0
for i in range(operations):
key = i.to_bytes(32, "big")
# Legacy: each already-removed key keeps a generation slot forever.
old_generations[key] = 1
# Same-version fix: the global generation barrier cancels all older
# tickets, so only the current purge's per-key metadata is needed.
new_generations.clear()
new_generations[key] = 1
if (i + 1) % (operations // windows) == 0:
counts.append((len(old_generations), len(new_generations)))
assert counts == [(operations // windows * i, 1) for i in range(1, windows + 1)]
return counts
class TestPeer(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self) -> None:
body = b"HTTP-load-driver-ok"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_: object) -> None:
pass
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--http-requests", type=int, default=2_000, help="HTTP requests against the local Python test peer (default: 2000)")
args = parser.parse_args()
if not 1 <= args.http_requests <= 1_000_000:
parser.error("--http-requests must be 1..1000000")
checkpoints = purge_replay()
print("PASS: 300,000 simulated distinct PURGEs; cumulative vs reset per-key generation slots:", checkpoints)
peer = ThreadingHTTPServer(("127.0.0.1", 0), TestPeer)
runner = threading.Thread(target=peer.serve_forever, daemon=True)
runner.start()
try:
url = f"http://127.0.0.1:{peer.server_port}/"
report = run_load(url=url, requests=args.http_requests, concurrency=24, unique_paths=min(1000, args.http_requests),
timeout=5, pid=os.getpid(), sample_interval=0.05, cooldown=0.15)
assert report["requests_sent"] == args.http_requests, report
assert report["statuses"] == {"200": args.http_requests}, report
assert report["response_bytes_read"] == args.http_requests * len(b"HTTP-load-driver-ok"), report
assert report["rss_peak_mib"] is not None, report
print(f"PASS: load tool generated {args.http_requests:,} real HTTP requests with 24 connections and {min(1000, args.http_requests):,} paths against a Python test peer")
print("NOTE: peer/load-tool RSS is NOT RustProxy RSS; live RustProxy profiling remains mandatory")
finally:
peer.shutdown()
peer.server_close()
runner.join(timeout=5)
if __name__ == "__main__":
main()
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
"""Regression for live Network diagnostic DOM stability beside Process.
Runs the actual renderer and the shipped dashboard CSS with mocked sample data
in Chromium touch emulation. This does NOT certify Safari/iOS 27 behavior.
"""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
process = soup.select_one('[data-dashboard-widget="process_diagnostics"]')
network = soup.select_one('[data-dashboard-widget="network_diagnostics"]')
assert process and network
source = (WEB / 'app/runtime.js').read_text()
start = source.index('function renderNetworkInterfaceCharts(interfaces, appendSample) {')
start = source.rfind('let latestNetworkInterfaceSnapshot', 0, start)
assert start >= 0
renderer = source[start:source.index('function renderDiagnostics(', start)]
helper = source[source.index('function setChartText('):start]
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
'<head><meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>' + (WEB / 'style.css').read_text() + '</style></head>'
'<body style="margin:0"><div style="height:1020px">Overview metrics</div>'
'<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned" '
'id="dashboard-overview-grid">' + str(process) + str(network) +
'</div></section><div style="height:160px"></div></body></html>')
stubs = '''
const networkInterfaceSamples = new Map();
const hiddenNetworkInterfaces = new Set();
const $ = id => document.getElementById(id);
const t = s => ({networkInterfacesVisibleCount:'{visible} / {total}',
networkInterfaceRate:'Rate', networkInterfacesHistory:'Traffic',
hideMetric:'Hide', showMetric:'Show',hiddenInterfaces:'Hidden',
networkNoInterfaces:'No interfaces',networkNoVisibleInterfaces:'None visible'})[s] || s;
const fmtBytes = n => `${n} B`;
const el = (tag, text, cls) => {const node=document.createElement(tag);
if(text !== undefined) node.textContent=text;if(cls) node.className=cls;return node;};
const action = (label, fn) => {const node=el('button',label);node.addEventListener('click',fn);return node;};
const svgNode = (tag, attrs={}) => {const node=document.createElementNS('http://www.w3.org/2000/svg',tag);
Object.entries(attrs).forEach(([key,value])=>node.setAttribute(key,value));return node;};
const sparklinePoints = (values,width=300,height=60,padding=5,peakOverride=null) => {
const peak=Math.max(1,peakOverride??Math.max(0,...values));
return values.map((value,index)=>`${index*width/Math.max(1,values.length-1)},${height-padding-Math.max(0,value)/peak*(height-padding*2)}`).join(' ');
};
const saveHiddenNetworkInterfaces = () => {};
'''
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
context = browser.new_context(viewport={'width':393,'height':844},
device_scale_factor=3, is_mobile=True, has_touch=True)
page = context.new_page()
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=stubs + helper + renderer)
baseline = page.evaluate('''() => {
const items = [{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:20},
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}];
renderNetworkInterfaceCharts(items,true);
const panel = document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]');
const target = document.getElementById('network-interface-charts');
window.testNodes = {first:target.firstElementChild, svg:target.querySelector('svg'),
path:target.querySelector('.network-rx-line')};
window.testInterfaces = items;
window.scrollTo(0,Math.round(network.getBoundingClientRect().top+window.scrollY+180));
return {processHeight:panel.getBoundingClientRect().height, y:scrollY,
network:network.getBoundingClientRect().top, count:target.children.length,
scrollHeight:document.documentElement.scrollHeight};
}''')
assert baseline['y'] > 0 and baseline['count'] == 2, baseline
count = page.evaluate('''() => {
const target = document.getElementById('network-interface-charts');
let mutations=0;
const obs = new MutationObserver(list=>mutations+=list.filter(item=>item.type==='childList').length);
obs.observe(target,{childList:true,subtree:true});
const beforeY=scrollY;
const beforeHeight=document.documentElement.scrollHeight;
const beforeProcess=document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height;
for(let i=0;i<45;i++){
renderNetworkInterfaceCharts([{name:'ens18',rx_bytes_per_second:i*109,tx_bytes_per_second:12},
{name:'lo',rx_bytes_per_second:i*2,tx_bytes_per_second:3}],true);
if(target.firstElementChild!==window.testNodes.first ||
target.querySelector('svg')!==window.testNodes.svg ||
target.querySelector('.network-rx-line')!==window.testNodes.path) throw Error('Network DOM replaced');
}
obs.takeRecords().forEach(r=> {if(r.type==='childList')mutations++});obs.disconnect();
return {mutations, y:scrollY, beforeY, count:target.children.length,
scrollHeight:document.documentElement.scrollHeight, beforeHeight,
processHeight:document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height, beforeProcess,
changed:target.querySelector('.network-interface-rate').textContent.includes('4796')};
}''')
assert count['mutations'] == 0, count
assert abs(count['y'] - count['beforeY']) < 2, count
assert count['count'] == 2 and count['changed'], count
assert abs(count['scrollHeight'] - count['beforeHeight']) < 2, count
assert abs(count['processHeight'] - count['beforeProcess']) < 2, count
print('PASS: 45 updates preserve SVG nodes, Process height, document height and scroll')
# Interface charts advance at every Overview live tick like the aggregate
# network chart, repeating the last observed rate until system-info updates.
# A system-info-only refresh or Hide/Show redraw never adds history points.
history = page.evaluate('''() => {
networkInterfaceSamples.clear();
const one=[{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200}];
renderNetworkInterfaceCharts(one,true);
const before=networkInterfaceSamples.get('ens18').rx.length;
for(let i=0;i<45;i++) renderNetworkInterfaceCharts(one,true);
const live=networkInterfaceSamples.get('ens18').rx.length;
const measured=[{name:'ens18',rx_bytes_per_second:500,tx_bytes_per_second:250}];
renderNetworkInterfaceCharts(measured,false); // only new system-info data
const afterSystem=networkInterfaceSamples.get('ens18').rx.length;
const label=document.querySelector('.network-interface-rate').textContent;
renderNetworkInterfaceCharts(measured,true); // next live tick
const afterLive=networkInterfaceSamples.get('ens18').rx.length;
const newest=networkInterfaceSamples.get('ens18').rx.at(-1);
return {before,live,afterSystem,afterLive,label,newest};
}''')
assert history['before'] == 1 and history['live'] == 46, history
assert history['afterSystem'] == 46 and history['afterLive'] == 47, history
assert history['newest'] == 500 and '500 B' in history['label'], history
assert 'renderNetworkInterfaceCharts(live.network_interfaces || [], appendSample);' in source
refresh = (WEB / 'app/refresh.js').read_text()
assert 'interval: () => options.system_info_seconds' in refresh
assert 'renderDiagnostics(latestStatus, false, true);' in refresh
print('PASS: frequent interface chart history, configured system-info cadence, no extra samples on system-only redraw')
page.evaluate('''() => renderNetworkInterfaceCharts([
{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200},
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}],false)''')
# The Hide / Show controls retain the newest sample on click, not the array
# captured when a chart node was created.
page.locator('.network-interface-hide').first.click()
assert page.locator('.network-interface-row').count() == 1
assert page.locator('.network-interface-hidden button').count() == 1
page.locator('.network-interface-hidden button').click()
assert page.locator('.network-interface-row').count() == 2
assert page.locator('.network-interface-hidden').count() == 0
page.evaluate('''() => renderNetworkInterfaceCharts([{name:'eth0',rx_bytes_per_second:9,tx_bytes_per_second:11}],true)''')
assert page.locator('.network-interface-row').count() == 1
assert page.locator('.network-interface-row').first.get_attribute('data-interface-name') == 'eth0'
page.evaluate('''() => renderNetworkInterfaceCharts([],true)''')
assert page.locator('.network-interface-empty').count() == 1
print('PASS: Hide/Show actions, hotplug/removal and empty state still work')
browser.close()
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
"""Validate language-pack content and emit the same-shaped bundle used by UI QA.
Rust build.rs performs the actual compile-time discovery and embedding.
"""
from pathlib import Path
import argparse
import json
import re
ROOT = Path(__file__).resolve().parents[2]
def bundle(root=ROOT):
languages=[];locales=set()
for path in sorted((root/'langs').glob('*.json')):
assert re.fullmatch(r'[A-Za-z0-9_-]{1,64}',path.stem),path
assert path.stat().st_size<=262144,path
pack=json.loads(path.read_text(encoding='utf-8'));assert set(pack)=={'meta','translations'},f'unexpected top-level language-pack keys: {set(pack)-{"meta","translations"}} in {path}';meta=pack['meta'];translations=pack['translations']
assert all(isinstance(meta.get(key),str) and 0<len(meta[key].encode())<=128 for key in ['locale','name','native_name','direction']),path
assert meta['direction'] in ('ltr','rtl'),path
assert re.fullmatch(r'[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*',meta['locale']),path
assert meta['locale'].lower() not in locales, 'duplicate locale'
locales.add(meta['locale'].lower())
assert isinstance(translations,dict) and len(translations)<=4096,path
assert all(isinstance(v,str) and 0<len(k.encode())<=128 and len(v.encode())<=16384 for k,v in translations.items()),path
languages.append({'id':path.stem,**pack})
assert 1<=len(languages)<=128
fallback=next(pack for pack in languages if pack['id']=='en_US')['translations']
html=(root/'src/admin/web/index.html').read_text()
keys=set(re.findall(r'data-i18n(?:-placeholder|-aria)?="([^"]+)"',html))
js='\n'.join(p.read_text() for p in (root/'src/admin/web').glob('**/*.js'))
keys.update(re.findall(r'\bt\("([^"\n]+)"\)',js))
keys.update(re.findall(r'"(vh[A-Z][A-Za-z]+)"',js))
assert keys<=fallback.keys(),f'Missing fallback keys: {keys-fallback.keys()}'
for language in languages:
translations=language['translations']
assert keys<=translations.keys(),f"Missing UI keys in {language['id']}: {keys-translations.keys()}"
result={'default':'en_US','languages':languages}
assert len(json.dumps(result,ensure_ascii=False).encode())<=8388608
return result
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--output',type=Path);args=parser.parse_args()
data=bundle()
if args.output:args.output.write_text(json.dumps(data,ensure_ascii=False)+'\n')
print(f"PASS: {len(data['languages'])} language packs, metadata, fallback UI keys and bounds.")
@@ -1,133 +0,0 @@
#!/usr/bin/env python3
"""Real diagnostic renderer/CSS in Chromium mobile emulation (not iOS Safari)."""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
source = (WEB / 'app/runtime.js').read_text()
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
process = soup.select_one('[data-dashboard-widget="process_diagnostics"]')
network = soup.select_one('[data-dashboard-widget="network_diagnostics"]')
assert process and network
# Run the real renderDiagnostics and interface component as shipped, without
# creating a fake SSE server or depending on Rust/Cargo in this test environment.
start = source.index('let systemSamples =')
end = source.index('function renderHeaderRules(', start)
renderer = source[start:end]
html = ('<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded" '
'data-dashboard-density="compact"><head><meta name="viewport" '
'content="width=device-width, initial-scale=1"><style>'
+ (WEB / 'style.css').read_text() + '</style></head>'
'<body style="margin:0"><div style="height:1100px">Earlier dashboard cards</div>'
'<section id="panel-overview"><div id="dashboard-overview-grid" '
'class="stats-grid dashboard-overview-grid is-aligned">'
+ str(process) + str(network) + '</div></section>'
'<div class="vhost-policy-pills"><small class="pill">H1</small>'
'<small class="vhost-backend-protocol-badge" data-observed="true">AUTO → H2</small></div>'
'<div style="height:900px">Footer</div></body></html>')
stubs = '''
const dummy = new Map();
const $ = id => document.getElementById(id) || (() => {
if (!dummy.has(id)) dummy.set(id, { textContent:'-', value:'', hidden:false,
classList:{ toggle(){} }, setAttribute(){}, removeAttribute(){}, append(){},
replaceChildren(){}, closest(){ return {hidden:false}; } });
return dummy.get(id);
})();
const envelope = {config:{cache:{enabled:true,cold_enabled:false}}};
const t = key => ({networkInterfacesVisibleCount:'{visible} / {total}',
networkInterfaceRate:'Rate',networkInterfacesHistory:'Traffic',hideMetric:'Hide',
showMetric:'Show',hiddenInterfaces:'Hidden',networkNoInterfaces:'None',
networkNoVisibleInterfaces:'None visible',notEnabled:'Disabled',
activeListeners:'Listeners',restartPending:'Restart',realSample:'Sampled',
threads:'threads',unavailable:'Unavailable',unlimited:'Unlimited'})[key] || key;
const bytes = value => `${Number(value)} B`;
const locale = () => 'en-US';
const uptime = seconds => `${seconds}s`;
const formatDateTime = seconds => String(seconds);
const action = (text, fn) => { const node=document.createElement('button'); node.textContent=text;
node.addEventListener('click',fn); return node; };
const el = (tag, text, cls) => {const node=document.createElement(tag);
if(text!==undefined) node.textContent=text; if(cls) node.className=cls; return node;};
const updateCacheMemorySizing = () => {};
const rustProxyRateSampling = {cadence:()=>3,difference:(previous,values,at)=>({next:{values,at},
deltas:previous?values.map((v,i)=>v-previous.values[i]):null,seconds:3})};
'''
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
ctx = browser.new_context(viewport={'width':393,'height':844}, device_scale_factor=3,
is_mobile=True, has_touch=True)
page = ctx.new_page()
page.set_content(html, wait_until='domcontentloaded')
page.on('pageerror', lambda error: print('BROWSER ERROR:', error))
page.add_script_tag(content=stubs + renderer)
result = page.evaluate('''() => {
const system = {source:'sysinfo', platform:'Linux', process_threads:5,
process_cpu_percent:5,process_rss_bytes:100,cpu_percent:15,
network_rx_bytes_per_second:20, network_tx_bytes_per_second:10,
sampled_at_ms:Date.now(),network_interfaces:[
{name:'eth0',rx_bytes_per_second:10,tx_bytes_per_second:5}]};
const s = {system,cache:{hits:50,misses:30,bypasses:10,resident_bytes:4096},
metrics:{http3_requests:2,mean_duration_ms:0.5},uptime_seconds:100,
listeners:{active_endpoints:[]},proxy_listen:null,tls_listen:null};
renderDiagnostics(s,true,true);
const process = document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]');
const target = document.getElementById('network-interface-charts');
const firstRow = target.firstElementChild;
const initialDetail = document.getElementById('process-cpu').textContent;
const initialHistory = processCpuSamples.length;
window.scrollTo(0, Math.round(network.getBoundingClientRect().top + window.scrollY + 125));
const y = window.scrollY, height = process.getBoundingClientRect().height;
for (let i=0; i<35; i++) {
s.live_system = {sampled_at_ms:Date.now(),process_cpu_percent:7+i,
process_rss_bytes:150+i, cpu_percent:22+i,
network_rx_bytes_per_second:100+i*20,network_tx_bytes_per_second:10+i,
network_interfaces:[{name:'eth0',rx_bytes_per_second:100+i*20,
tx_bytes_per_second:10+i}]};
renderDiagnostics(s,true,false);
}
const unchangedDetails = document.getElementById('process-cpu').textContent === initialDetail;
const recentChart = document.getElementById('process-cpu-chart-value').textContent;
const iface = target.querySelector('.network-interface-rate').textContent;
const beforeSystem = processCpuSamples.length;
const y2 = window.scrollY;
s.system = {...system, process_cpu_percent:93,process_rss_bytes:3000,
network_rx_bytes_per_second:9000, network_tx_bytes_per_second:900};
renderDiagnostics(s,false,true);
const slowlyUpdated = document.getElementById('process-cpu').textContent;
const pill = document.querySelector('.pill');
const origin = document.querySelector('.vhost-backend-protocol-badge');
const roundedOrigin = getComputedStyle(origin).borderTopLeftRadius;
document.documentElement.dataset.cornerStyle = 'default';
const defaultPillRadius = getComputedStyle(pill).borderTopLeftRadius;
const defaultOriginRadius = getComputedStyle(origin).borderTopLeftRadius;
document.documentElement.dataset.cornerStyle = 'rounded';
return {initialDetail,initialHistory,unchangedDetails,recentChart,iface,
beforeSystem,afterSystem:processCpuSamples.length,slowlyUpdated,
rowStable:target.firstElementChild===firstRow,
heightDelta:process.getBoundingClientRect().height-height,
scrollDelta:y2-y,postSystemScrollDelta:window.scrollY-y,
nestedScroll:getComputedStyle(target).overflowY,
nestedMaxHeight:getComputedStyle(target).maxHeight,
pillRadius:getComputedStyle(pill).borderTopLeftRadius,
originRadius:roundedOrigin,defaultPillRadius,defaultOriginRadius,
originColor:getComputedStyle(origin).color};
}''')
assert result['initialDetail'] == '5%', result
assert result['initialHistory'] == 1, result
assert result['unchangedDetails'] and result['recentChart'] == '41%', result
assert result['beforeSystem'] == 36 == result['afterSystem'], result
assert result['slowlyUpdated'] == '93%', result
assert result['rowStable'] and '780 B' in result['iface'], result
assert abs(result['heightDelta']) < 2 and abs(result['scrollDelta']) < 2, result
assert abs(result['postSystemScrollDelta']) < 2, result
assert result['nestedScroll'] == 'visible' and result['nestedMaxHeight'] == 'none', result
assert result['pillRadius'] == '8px' and result['originRadius'] == '8px', result
assert result['defaultPillRadius'] == result['defaultOriginRadius'], result
assert result['originColor'] != '', result
print('PASS: 35 live CPU/RSS/network samples, slow Process metadata, stable mobile layout/scroll, badge radii', result)
browser.close()
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env python3
"""Static regression checks for bounded-memory/background-worker contracts.
This is intentionally Rust-toolchain independent. It does not replace runtime RSS/
heap profiling; it prevents the source-level bounds audited for this release from
silently disappearing before compiler-enabled acceptance runs.
"""
from __future__ import annotations
from pathlib import Path
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
target = ROOT / path
return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8")
def require(path: str, *markers: str) -> None:
text = read(path)
for marker in markers:
assert marker in text, f"{path}: missing memory contract: {marker}"
def forbid(path: str, *markers: str) -> None:
text = read(path)
for marker in markers:
assert marker not in text, f"{path}: forbidden memory pattern returned: {marker}"
# Transform path: hard aggregate/decode/concurrency budgets and no JSON DOM tree.
require(
"src/transform/mod.rs",
"DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024",
"pub memory_budget_bytes: usize",
"pub max_inflight: usize",
"try_reserve_transform_memory(required_memory, settings.memory_budget_bytes)",
"try_reserve_transform_slot(settings.max_inflight)",
"TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize",
"TRANSFORM_INFLIGHT: AtomicUsize",
"pub struct ResourceUsage",
"pub fn resource_usage(settings: &Settings)",
"memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load",
"Limited::new(body, settings.max_body_bytes)",
".try_reserve_exact(read)",
'"memory_pressure"',
'"concurrency_pressure"',
"aggregation_is_bounded_even_when_content_length_is_false",
"transform_memory_reservation_obeys_runtime_budget",
"transform_inflight_reservation_obeys_runtime_limit",
)
require("src/transform/json.rs", "IgnoredAny::deserialize", "Vec::with_capacity(body.len())")
forbid("src/transform/json.rs", "use serde_json::Value", "from_slice::<Value>")
require("src/transform/css.rs", "let mut last_char: Option<char> = None")
require("src/transform/html.rs", "enum RawElement", "fn raw_tag(tag: &str)")
# Cache background work: all previously high-retention paths have hard bounds.
require(
"src/cache/model.rs",
"COLD_DELETE_FALLBACKS: usize = 2",
"COLD_CLEANUP_SLOTS: usize = 1",
"BACKGROUND_REVALIDATION_LIMIT: usize = 8",
"pub cold_limit_bytes: u64",
"pub cold_limit_entries: usize",
"pub hot_auto_trim_enabled: bool",
"pub hot_auto_trim_after_seconds: u64",
"pub hot_auto_trim_target_percent: u8",
)
require(
"src/admin/mod.rs",
'payload["transform_resources"]',
'payload["memory_accounting"]',
'"non_cache_rss_includes_transform": true',
)
require(
"src/cache/cold.rs",
"BinaryHeap::with_capacity(requested_limit.saturating_add(1))",
"self.cold_delete_fallbacks.clone().try_acquire_owned()",
"self.cleanup_slots.clone().try_acquire_owned()",
"std::mem::take(&mut delete)",
)
require(
"src/cache/tickets.rs",
"active.len() >= BACKGROUND_REVALIDATION_LIMIT",
)
require(
"src/cache/collapse.rs",
".saturating_mul(64)",
".clamp(64, 256)",
"settings.proactive_refresh_max_inflight as u64",
)
require(
"src/cache/lifecycle.rs",
"mpsc::channel(COLD_QUEUE)",
"cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS))",
"cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS))",
"cache.trim_hot_idle();",
"fn trim_hot_idle(&self)",
)
# COLD bodies stay disk-backed and the long-lived index must not retain full
# response header/validator allocations for every object.
cache_model = read("src/cache/model/cache.rs")
cold_entry = cache_model.split("struct ColdEntry {", 1)[1].split("#[derive(Default)]", 1)[0]
assert "headers: HeaderMap" not in cold_entry, "COLD index retains full response headers"
assert "origin_etag: Option<HeaderValue>" not in cold_entry, "COLD index retains ETag HeaderValue"
assert "origin_last_modified: Option<HeaderValue>" not in cold_entry, "COLD index retains Last-Modified HeaderValue"
assert "body_sha256: Key" in cold_entry, "COLD digest must use the compact 32-byte representation"
assert "has_validator: bool" in cold_entry, "COLD index must retain validator presence without full values"
require(
"src/cache/model/cold_metadata.rs",
"tokio::fs::read(&entry.meta_path).await",
"pairs_to_headers(&file.headers)",
"body_sha256 != entry.body_sha256",
)
# Rate-limit client state stores a vhost allocation once and reclaims expired
# fixed-window buckets without waiting for the global cardinality ceiling.
require(
"src/rate_limit.rs",
"buckets: HashMap<String, HashMap<IpAddr, Bucket>>",
"fn sweep_expired(&mut self, now: Instant, force: bool)",
"now.duration_since(bucket.started) < Duration::from_secs(bucket.window_seconds)",
"state.sweep_expired(now, false)",
"state.sweep_expired(now, true)",
"clients.shrink_to_fit()",
"self.buckets.shrink_to_fit()",
"pub fn maintenance(&self)",
"MAX_BUCKETS: usize = 65_536",
)
require(
"src/runtime/constructors.rs",
"app.rate_limiter.maintenance()",
"spawn_blocking(crate::diagnostics::trim_allocator)",
)
# Compression remains response-streaming; no complete-body collection is added.
require(
"src/compression.rs",
"No full-body aggregation is introduced.",
"CompressionLevel::Fastest",
"Compression::new(service)",
)
# One JSONL buffer is reused across local file and Logstash sends (Loki-only
# sinks should not allocate the JSONL line). Both output queues remain bounded.
logging_sink = read("src/logging/runtime.rs")
assert logging_sink.count("serde_json::to_vec(&payload)") == 1
assert "if config.file.enabled || config.logstash.enabled" in logging_sink
assert "opened.write_all(&jsonl_line)" in logging_sink
assert "state.stream.write_all(&jsonl_line)" in logging_sink
assert "PER_VHOST_OPEN_FILES: usize = 64" in logging_sink
# Stored Logs file discovery must also be cardinality bounded before sort:
# rotated files and per-vhost directories may contain arbitrary historical files.
require("src/admin/file_logs.rs", "newest_log_files(std::fs::read_dir(&directory)", ", 256);")
require("src/admin/file_logs_io.rs", "BinaryHeap::with_capacity(limit)", "newest_log_files(std::fs::read_dir(parent)", "), 64);")
# A per-key invalidation generation is useful only inside the current PURGE
# publication epoch; retaining each purged URL after eviction is an unbounded
# lifetime-memory leak on high-cardinality cache invalidation workloads.
invalidation = read("src/cache/invalidation/invalidate.rs")
start = invalidation.index("InvalidationOperation::Purge =>")
barrier = invalidation.index("store.generation = store.generation.wrapping_add(1);", start)
reset = invalidation.index("store.key_generations.clear();", barrier)
assert "store.key_generations.capacity() > 256" in invalidation[barrier:reset]
matcher = invalidation.index("let affected = self.purge_matcher(&matcher);", reset)
assert start < barrier < reset < matcher
require("src/cache/tests/v022_cache_tests.rs", "many_distinct_selective_purges_do_not_retain_tombstones_forever")
# Repeated hot-path analytics labels should use borrowed lookups instead of
# allocating a String for every request in an already populated top table.
require("src/traffic.rs", "fn truncate(value: &str, max_bytes: usize) -> &str", "self.values.get_mut(value)", "self.values.insert(value.to_owned(), weight)")
forbid("src/web_server/serve.rs", "Body::from(listing.clone())")
require("src/web_server/serve.rs", "entries.retain(|entry|", "let listing_bytes = listing.len();", "Body::from(listing)")
require("src/logging/runtime.rs", "String::from_utf8_lossy(&jsonl_line[..jsonl_line.len() - 1])")
# Other long-lived/background facilities: queue/task/worker cardinality is bounded
# or owned by a cancellation-aware supervisor/TaskTracker.
require(
"src/logging.rs",
"HISTORY_CAPACITY: usize = 4096",
"LIVE_STREAM_CAPACITY: usize = 4096",
"mpsc::channel(SINK_QUEUE)",
"self.sink.try_send(entry)",
"pub capture_user_agent: bool",
"pub capture_country: bool",
)
require(
"src/traffic.rs",
"QUEUE_CAPACITY: usize = 2048",
"TOP_CAPACITY: usize = 32",
"MAX_VHOSTS: usize = 64",
"self.tx.try_send(sample)",
"client_ips: TopTable",
)
require(
"src/diagnostics.rs",
"tokio::task::spawn_blocking(move || system_stats::sample(previous)).await",
"stop.cancelled()",
"pub fn trim_allocator() -> bool",
)
require(
"src/runtime.rs",
"Duration::from_secs(60)",
"spawn_blocking(crate::diagnostics::trim_allocator)",
)
require(
"src/upstream.rs",
"Semaphore::new(16)",
".try_acquire_owned()",
"tokio::time::timeout(DNS_TIMEOUT, lookup)",
)
require(
"src/upstream/client.rs",
"pool_recycle_requests",
"pool_recycle_seconds",
"http1_max_buf_size(keepalive.http1_max_buffer_bytes)",
"ArcSwap<RawProxyClient>",
"self.current.store(Arc::new(client))",
'"recycled upstream client pool"',
)
require(
"src/balance.rs",
"if checks.len() >= 16 { break; }",
"checks.abort_all()",
)
require(
"src/stream.rs",
"Semaphore::new(host.max_connections)",
"mpsc::channel(64)",
"JoinSet::new()",
)
require(
"src/server.rs",
"Semaphore::new(max_connections)",
"let mut tasks = JoinSet::new()",
)
require(
"src/runtime.rs",
"tokio_util::task::TaskTracker::new()",
"update_slots: Arc::new(Semaphore::new(8))",
"inflight: Arc::new(Semaphore::new(max_inflight))",
)
require("src/acme.rs", "slot: Arc::new(Semaphore::new(1))", ".try_acquire_owned()")
require("src/docker.rs", "shutdown.cancelled()", "Duration::from_secs(5)")
require("src/update_check.rs", "shutdown.cancelled()", "tokio::time::sleep(interval)")
print("PASS: bounded-memory transform/cache contracts and background-worker limits are present.")
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
"""Source-only mobile/observed-backend UI invariants, no browser required."""
from pathlib import Path
root=Path(__file__).resolve().parents[2]
css=(root/'src/admin/web/style.css').read_text()
preferences=(root/'src/admin/web/app/preferences.js').read_text()
masonry=(root/'src/admin/web/app/overview-masonry.js').read_text()
html=(root/'src/admin/web/index.html').read_text()
render=(root/'src/admin/web/vhosts/render.js').read_text()
client=(root/'src/upstream/client.rs').read_text()
pool=(root/'src/balance/pool_view.rs').read_text()
assert 'touch-action: pan-y pinch-zoom' in css
assert 'overflow-anchor: none' in css
assert 'grid-template-columns: repeat(2, minmax(0, 1fr))' in css
assert '@container overview-canvas (max-width: 700px)' in css
assert 'overview-canvas (max-width: 440px)' not in css
assert '.vhost-backend-protocol-badge' in css
assert '.vhost-card' in css[css.index('html:is([data-corner-style=square]'):]
assert '.draft-dock,' not in css[css.index('html:is([data-corner-style=square]'):]
assert 'grid.append(...orderedNodes)' in preferences
assert 'orderedNodes.some((node, index) => node !== currentNodes[index])' in preferences
assert 'if (ordered.some((node, i) => node !== previous[i])) grid.append(...ordered);' in masonry
assert html.count('data-dashboard-widget=') >= 62
assert 'function renderUpstreamProtocolBadges(status)' in render
assert 'const VHOST_PROTOCOL_FRESH_MS = 5 * 60 * 1000' in render
# Protocol badges live in the existing Vhost policy-pill group, not above the
# live traffic metrics. The class and observed-color state remain unchanged.
policy_start=render.index('const policy = el("div", undefined, "vhost-policy-pills");')
policy_end=render.index('if (policy.children.length) box.append(policy);', policy_start)
policy_block=render[policy_start:policy_end]
assert 'policy.append(badge);' in policy_block
assert 'vhost-backend-protocol-badge' in policy_block
assert 'box.append(badges)' not in render
assert '.vhost-backend-protocol-badge[data-observed=true]' in css
assert 'color: var(--green)' in css[css.index('.vhost-backend-protocol-badge[data-observed=true]'):]
assert 'self.inner.observe_protocol(value.version())' in client
assert '"upstream_protocol_observed"' in pool
print('PASS: mobile grid, shape, stable reorder and observed backend protocol source contracts')
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""Mobile Dashboard scroll/layout and appearance browser regression.
Uses the shipped static web assets in a Chromium touch-device emulation, not
physical Safari, the authenticated server or a running Rust backend.
"""
from pathlib import Path
import json
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
widgets = soup.select('#dashboard-overview-grid > [data-dashboard-widget]')
assert len(widgets) == 62
widget_ids = [widget['data-dashboard-widget'] for widget in widgets]
css = (WEB / 'style.css').read_text()
source = (WEB / 'app/preferences.js').read_text()
layout_code = source.split('function applyDashboardLayout(order, hidden) {')[1].split('function dashboardWidgetLabel(')[0]
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact" '
'data-corner-style="default"><head><meta name="viewport" '
'content="width=device-width,initial-scale=1"><style>' + css +
'</style></head><body style="margin:0">'
'<div style="height:80px">Mobile navigation</div>'
'<section id="panel-overview" style="width:auto;padding:0 10px;box-sizing:border-box">'
'<div id="dashboard-overview-grid" class="stats-grid dashboard-overview-grid">' +
''.join(map(str, widgets)) + '</div></section>'
'<div id="radii" style="padding:10px"><article class="vhost-card" id="vhost-test">Vhost</article>'
'<div class="entity" id="entity-test">Entity</div>'
'<span class="vhost-backend-protocol-badge" id="badge-test">AUTO → H2</span></div>'
'</body></html>')
with sync_playwright() as browser_tool:
chromium = browser_tool.chromium.launch(headless=True,
executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),
args=['--no-sandbox'])
context = chromium.new_context(viewport={'width': 393, 'height': 844},
device_scale_factor=3, is_mobile=True, has_touch=True)
page = context.new_page()
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=(WEB / 'app/overview-masonry.js').read_text())
preference_fixture = (
'const ADMIN_DASHBOARD_WIDGETS=' + json.dumps(widget_ids) + ';' +
'const ADMIN_DEFAULT_HIDDEN_WIDGETS=[];' +
'const latestStatus=null; const currentPanel="overview";' +
'const normalizeDashboardWidgets=(ids)=>ids;' +
'const normalizeHiddenDashboardWidgets=(ids)=>ids;' +
'function applyDashboardLayout(order, hidden) {' + layout_code
)
page.add_script_tag(content=preference_fixture)
for width in (320, 375, 393, 402, 430, 550):
page.set_viewport_size({'width': width, 'height': 844})
snapshot = page.evaluate('''() => {
const grid=document.getElementById('dashboard-overview-grid');
const cards=[...grid.querySelectorAll('[data-dashboard-widget].stat')]
.filter(node => !node.hidden).slice(0,6).map(node => {
const r=node.getBoundingClientRect();
return {left:r.left,top:r.top,right:r.right,bottom:r.bottom,width:r.width};
});
return {columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length,
cards,gridWidth:grid.clientWidth,gridScroll:grid.scrollWidth,
screenScroll:document.documentElement.scrollWidth,
screen:innerWidth,
touch:getComputedStyle(grid.querySelector('.stat')).touchAction};
}''')
a,b,c,d,e,f = snapshot['cards']
assert snapshot['columns'] == 2, (width, snapshot)
assert abs(a['top']-b['top']) < 1 and abs(c['top']-d['top']) < 1
assert abs(e['top']-f['top']) < 1 and c['top'] > a['top'] and e['top'] > c['top']
assert b['left'] >= a['right'] and d['left'] >= c['right']
assert a['width'] > 50 and b['width'] > 50
assert snapshot['gridScroll'] <= snapshot['gridWidth']+2, (width,snapshot)
assert snapshot['screenScroll'] <= snapshot['screen']+2, (width,snapshot)
assert 'pan-y' in snapshot['touch']
action = page.evaluate('''() => {
const tile=document.querySelector('.stat-with-action');
const label=tile.querySelector(':scope > span').getBoundingClientRect();
const button=tile.querySelector('.stat-corner-action').getBoundingClientRect();
const value=tile.querySelector('strong').getBoundingClientRect();
const card=tile.getBoundingClientRect();
return {labelBottom:label.bottom,buttonTop:button.top,
buttonBottom:button.bottom,valueTop:value.top,
buttonRight:button.right,cardRight:card.right};
}''')
assert action['buttonTop'] >= action['labelBottom'] - 2, (width,action)
assert action['valueTop'] >= action['buttonBottom'] - 2, (width,action)
assert action['buttonRight'] <= action['cardRight'] + 1, (width,action)
page.evaluate('window.scrollTo(0,350)')
before = page.evaluate('window.scrollY')
assert before > 0, width
page.evaluate('''() => {
const grid=document.getElementById('dashboard-overview-grid');
window.rustProxyMasonry.refresh();
for(const [index,node] of [...grid.querySelectorAll('.stat strong')].entries()) {
node.textContent=index%2 ? '26216' : '49,750';
}
}''')
after = page.evaluate('window.scrollY')
assert abs(after - before) <= 2, (width, before, after)
# Reapplying an unchanged preference must NOT reparent the grid children.
moves = page.evaluate('''() => {
const grid=document.getElementById('dashboard-overview-grid');
const order=[...grid.children].map(n=>n.dataset.dashboardWidget);
let moves=0;
const observer=new MutationObserver(items => { moves+=items.filter(i=>i.type==='childList').length; });
observer.observe(grid,{childList:true});
applyDashboardLayout(order,[]);
observer.takeRecords().forEach(i=>{if(i.type==='childList')moves++});
observer.disconnect();
return moves;
}''')
assert moves == 0, (width, moves)
assert abs(page.evaluate('window.scrollY')-before) <= 2
print(f'PASS: {width}px touch emulation, two metric columns, no overflow or scroll jumps')
for style,surface,control in (('square','0px','0px'), ('subtle','5px','4px'),
('rounded','12px','8px'), ('extra','18px','12px')):
page.evaluate('(s) => document.documentElement.dataset.cornerStyle=s', style)
for selector in ('#vhost-test','#entity-test','.stat'):
radius = page.locator(selector).first.evaluate('node=>getComputedStyle(node).borderTopLeftRadius')
assert radius == surface, (style,selector,radius)
radius = page.locator('#badge-test').evaluate('node=>getComputedStyle(node).borderTopLeftRadius')
assert radius == control, (style,'origin protocol badge',radius)
print('PASS: vhost, entity, badge/control and metric corner presets')
chromium.close()
-207
View File
@@ -1,207 +0,0 @@
#!/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');
@@ -1,56 +0,0 @@
// 0.32.1 regression: restore pre-0.32.0 content-driven tile heights and
// align inter-tile gaps with the same spacing as other Dashboard sections.
// These are source/CSS contracts, not a browser or authenticated-UI test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
function rule(selector) {
// Target precise blocks rather than accepting an unrelated matching rule.
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`${escaped}\\s*\\{([^{}]*)\\}`));
assert(match, `Missing CSS rule: ${selector}`);
return match[1];
}
for (const density of ['normal', 'compact', 'ultra']) {
const declarations = rule(`html[data-dashboard-density="${density}"] #panel-overview .stats-grid`);
assert.match(declarations, /gap:\s*var\(--ui-gap\)\s*;/,
`${density} tiles must use the same density-aware gap as Dashboard sections`);
}
const sharedGap = rule('.overview-grid,\n.diagnostics-grid,\n.settings-grid,\n.config-columns');
assert.match(sharedGap, /gap:\s*var\(--ui-gap\)/);
for (const density of ['dense', 'comfortable', 'compact']) {
assert.match(css, new RegExp(`body\\[data-density="${density}"\\]\\s*\\{[^}]*--ui-gap:`),
`UI density ${density} must provide the shared gap variable`);
}
assert.match(css, /@media \(min-width: 1201px\)\s*\{\s*#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/,
'six desktop columns are preserved');
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
'desktop cards should grow naturally to their legend/content height');
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
'card descriptions must remain visible, not hover-only');
const heading = rule('html[data-dashboard-density="compact"] #panel-overview .stat>span');
const metric = rule('html[data-dashboard-density="compact"] #panel-overview .stat strong');
const caption = rule('html[data-dashboard-density="compact"] #panel-overview .stat small');
const payload = rule('html[data-dashboard-density="compact"] #panel-overview .payload-split');
assert.match(heading, /font-size:\s*11px/);
assert.match(metric, /font-size:\s*25px/);
assert.match(caption, /font-size:\s*10px/);
assert.match(payload, /font-size:\s*10px\s*!important/);
assert.match(css, /#panel-overview \.stats-grid \.stat-chart>\.stat-chart-legend\s*\{[^}]*flex-wrap:\s*wrap/s);
const overview = html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"'));
const widgets = [...overview.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(match => match[1]);
assert.equal(widgets.length, 62, '49 existing widgets plus 6 small and 7 large opt-in charts');
assert.equal(widgets.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_') && !id.startsWith('wide_') && !['traffic_chart','system_chart','connection_chart','cache_diagnostics','process_diagnostics','network_diagnostics','backend_table'].includes(id)).length, 16,
'all 16 pre-existing metric/Stream cards must be retained');
assert.match(overview, /id="metric-bytes-split"/);
assert.match(overview, /id="http-protocol-chart"/);
assert.match(overview, /id="ws-protocol-chart"/);
assert.match(overview, /id="dashboard-overview-grid"/);
assert.doesNotMatch(overview, /class="overview-grid"|class="diagnostics-grid"/, 'all Overview widgets must share one arrangement grid');
console.log('PASS: 0.32.1 Overview six-column content height, 90% compact typography, shared tile spacing and inline legends.');
@@ -1,96 +0,0 @@
// Existing status snapshots only: no API calls, bounded histories and honest no-data.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/app/overview-extra-metrics.js', 'utf8');
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8'));
const ids = registry.filter(widget => widget.category === 'performance').map(widget => widget.id);
assert.equal(ids.length, 18);
const widgets = new Map(ids.map(id => [id, { hidden: false }]));
const fields = new Map();
const field = id => {
if (!fields.has(id)) fields.set(id, { textContent: '-', points: '', setAttribute(name, value) { this[name] = value; } });
return fields.get(id);
};
let now = 1000;
const context = {
window: {}, currentPanel: 'overview', performance: { now: () => now },
t: key => key, fmt: (v, places = 0) => Number(v).toFixed(places),
fmtBytes: v => `${v} B`, $: field,
document: { querySelector: selector => widgets.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null },
};
vm.createContext(context);
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
vm.runInContext(source, context);
const render = context.window.rustProxyExtraOverview.render;
const text = id => field(`overview-extra-${id}`).textContent;
const points = id => field(`overview-extra-${id}-line`).points;
const start = {
metrics: { requests: 100, http1_requests: 70, http2_requests: 30, http3_requests: 0, tls_handshake_errors: 3, errors: 10, active_connections: 5, active: 2, websocket_active: 1, mean_duration_ms: 25 },
system: { process_cpu_percent: 12, process_rss_bytes: 1000, open_fds: 10, max_fds: 500 },
cache: { hits: 10, misses: 10, resident_bytes: 10, hot_limit_bytes: 100 },
backend_pools: [{ members: [{status: 'up'}, {status: 'down'}] }],
};
render(start);
assert.equal(text('extra_process_cpu'), '12.0%');
assert.equal(text('extra_process_rss'), '1000 B');
assert.equal(text('extra_backend_unhealthy'), '1');
for (const id of ['extra_tls_error_rate','extra_request_error_rate','extra_cache_hit_rate','extra_h2_share'])
assert.equal(text(id), '-', `initial ${id} delta must not be invented`);
now = 3000;
const next = structuredClone(start);
next.metrics = { ...start.metrics, requests: 130, http1_requests: 84, http2_requests: 45, http3_requests: 1, tls_handshake_errors: 5, errors: 16, active_connections: 6, active: 4, websocket_active: 3, mean_duration_ms: 40 };
next.system = { ...start.system, process_cpu_percent: 22, process_rss_bytes: 2000, open_fds: 20 };
next.cache = { ...start.cache, hits: 20, misses: 15, resident_bytes: 25 };
next.backend_pools[0].members[0].status = 'down';
render(next);
assert.equal(text('extra_tls_error_rate'), '1.0/s');
assert.equal(text('extra_request_error_rate'), '3.0/s');
assert.equal(text('extra_cache_hit_rate'), '66.7%');
assert.equal(text('extra_h2_share'), '50.0%');
assert.equal(text('extra_cache_memory'), '25.0%');
assert.equal(text('extra_response_latency'), '40.0 ms');
assert.equal(text('extra_backend_unhealthy'), '2');
assert.equal(text('extra_total_rps'), '15.0/s');
assert.equal(text('extra_active_exchanges'), '4');
assert.equal(text('extra_h1_share'), '46.7%');
assert.equal(text('extra_h3_share'), '3.3%');
assert.equal(text('extra_cache_miss_rate'), '2.5/s');
assert.equal(text('extra_backend_active'), '0');
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 2);
// Hiding a chart stops sampling; stale cumulative counters are never used as an instant rate.
widgets.get('extra_tls_error_rate').hidden = true;
now = 5000;
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 50 } });
assert.equal(text('extra_tls_error_rate'), '1.0/s');
widgets.get('extra_tls_error_rate').hidden = false;
now = 6000;
render({ ...next, metrics: { ...next.metrics, tls_handshake_errors: 60 } });
assert.equal(text('extra_tls_error_rate'), '-', 'first fresh visible sample establishes a new baseline');
// After a reset, don't return a negative rate or denominator percentage.
now = 7000;
render({ ...next, metrics: { ...next.metrics, requests: 1, http2_requests: 0, tls_handshake_errors: 0, errors: 0 }, cache: { ...next.cache, hits: 0, misses: 0 } });
assert.equal(text('extra_h2_share'), '-');
assert.equal(text('extra_request_error_rate'), '-');
assert.equal(text('extra_tls_error_rate'), '-');
// Explicit unavailability is not represented as a measured zero.
now = 8000;
render({ metrics: {}, system: {}, cache: {} });
assert.equal(text('extra_process_cpu'), '-');
assert.equal(text('extra_websocket_active'), '-');
assert.equal(text('extra_backend_unhealthy'), '-');
context.window.rustProxyExtraOverview.reset();
now = 9000;
for (let index = 0; index < 50; index++) {
now += 1000;
render({ ...next, system: { ...next.system, process_cpu_percent: index } });
}
assert.equal(points('extra_process_cpu').trim().split(/\s+/).length, 30, 'bounded history');
context.currentPanel = 'vhosts';
const pointBefore = points('extra_process_cpu');
now += 1000;
render(next);
assert.equal(points('extra_process_cpu'), pointBefore, 'no extra sampling outside Overview');
assert.match(source, /if \(!tile \|\| tile.hidden\)/);
console.log('PASS: 18 extra metric series, time-based deltas, no-data, counter resets, hidden opt-ins and 30-sample bounds');
@@ -1,206 +0,0 @@
#!/usr/bin/env python3
"""Overview row symmetry regression using the shipped CSS/JS and simulated status.
Browser DOM geometry only; does not exercise the running RustProxy backend.
"""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2] / 'src/admin/web'
soup = BeautifulSoup((ROOT / 'index.html').read_text(), 'html.parser')
widgets = list(soup.select('#dashboard-overview-grid > [data-dashboard-widget]'))
assert len(widgets) == 62
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
'<head><meta charset="utf-8"><style>' + (ROOT / 'style.css').read_text() +
'</style></head><body style="margin:0"><div id="test-shell" '
'style="padding:8px;box-sizing:border-box"><div class="brand">'
'<div class="brand-mark" id="logo-test">R<span>P</span></div></div>'
'<section id="panel-overview"><div id="dashboard-overview-grid" '
'class="stats-grid dashboard-overview-grid">' +
''.join(str(widget) for widget in widgets) + '</div></section></div></body></html>')
SNAPSHOT = """() => {
const grid = document.getElementById('dashboard-overview-grid');
const style = getComputedStyle(grid), outer = grid.getBoundingClientRect();
const group = window.rustProxyMasonry.groupFor;
return {
packed: grid.classList.contains('is-packed'),
gap: parseFloat(style.columnGap),
columns: style.gridTemplateColumns.trim().split(/\\s+/).length,
grid: {left:outer.left,right:outer.right,width:outer.width},
scroll: grid.scrollWidth, client: grid.clientWidth,
cards: [...grid.children].filter(node=>node.dataset.dashboardWidget && !node.hidden).map(node=>{
const r=node.getBoundingClientRect();return {id:node.dataset.dashboardWidget,
group:group(node.dataset.dashboardWidget),x:r.left,y:r.top,right:r.right,
bottom:r.bottom,width:r.width,height:r.height};
})
};
}"""
def validate(data, zoom, width):
"""Peers in each row must have one top edge and one bottom edge."""
cards = data['cards']; gap = data['gap'] * zoom
assert cards and not data['packed'], (width, 'absolute/masonry mode is forbidden')
assert data['scroll'] <= data['client'] + 2, (width, 'horizontal overflow')
seen_groups = []
rows = {}
for card in cards:
assert card['width'] > 40 and card['height'] > 26, (width, card)
assert card['x'] >= data['grid']['left'] - 1, (width, card)
assert card['right'] <= data['grid']['right'] + 1, (width, card)
if not seen_groups or seen_groups[-1] != card['group']:
assert card['group'] not in seen_groups, (width, 'noncontiguous size groups')
seen_groups.append(card['group'])
row = round(card['y'], 1)
rows.setdefault(row, []).append(card)
for members in rows.values():
assert len({m['group'] for m in members}) == 1, (width, 'mixed sizes in a row', members)
assert max(m['bottom'] for m in members) - min(m['bottom'] for m in members) <= 1.5, (width, 'ragged bottom', members)
assert max(m['height'] for m in members) - min(m['height'] for m in members) <= 1.5, (width, 'uneven heights', members)
for a,b in zip(members,members[1:]):
assert abs(b['x']-a['right']-gap) <= 2.0, (width,'wrong horizontal gutter',a,b,gap)
line_groups = list(rows.values())
for above, below in zip(line_groups,line_groups[1:]):
a = above[0]; b=below[0]
assert b['y'] >= max(v['bottom'] for v in above) + gap - 2, (width,'vertical gap',a,b)
return rows
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True,
executable_path=shutil.which('chromium') or shutil.which('chromium-browser'),
args=['--no-sandbox'])
page = browser.new_page(viewport={'width':1720,'height':950})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=(ROOT / 'app/overview-masonry.js').read_text())
page.evaluate("""() => window._overviewOriginal = [...document.querySelectorAll(
'#dashboard-overview-grid > [data-dashboard-widget]')].map(n=>[n.dataset.dashboardWidget,n.hidden])""")
data=page.evaluate(SNAPSHOT)
assert data['columns']==12
assert [card['id'] for card in data['cards'][:6]] == [
'requests','active','bandwidth','errors','cache','memory']
validate(data,1,1720)
assert len({round(v['height']) for v in data['cards'][:6]}) == 1
page.locator('#dashboard-overview-grid').screenshot(
path=str(Path(__file__).parents[3] / 'overview_uniform_default_preview.png'))
# Screenshot regression: custom ordering previously produced 3 distinctly
# sized tiles on the same horizontal band (a metric + two tall charts).
ids=['backend_connection_errors','process_diagnostics','network_diagnostics','cache_diagnostics',
'connection_chart','traffic_chart','system_chart','backend_table',
'requests','active','http_protocol','websocket_protocol',
'wide_cpu','wide_memory']
page.evaluate("""ids => {
const grid=document.getElementById('dashboard-overview-grid');
for(const node of grid.children) if(node.dataset.dashboardWidget)
node.hidden=!ids.includes(node.dataset.dashboardWidget);
grid.prepend(...ids.map(id=>grid.querySelector(`[data-dashboard-widget="${id}"]`)));
window.rustProxyMasonry.refresh();
}""", ids)
changed=page.evaluate(SNAPSHOT)
validate(changed,1,1720)
assert [card['group'] for card in changed['cards']] == sorted(card['group'] for card in changed['cards'])
assert not any(card['id']=='backend_connection_errors' and card['y'] == next(v['y'] for v in changed['cards'] if v['id']=='process_diagnostics') for card in changed['cards'])
# 3 uniform diagnostic panels per desktop row, 2 large charts per row.
panels=[card for card in changed['cards'] if card['group']==3]
assert len({round(card['y']) for card in panels[:3]}) == 1
assert len({round(card['height']) for card in panels[:3]}) == 1
wides=[card for card in changed['cards'] if card['group']==2]
assert len(wides)==2 and abs(wides[0]['height']-wides[1]['height']) < 2
page.screenshot(path=str(Path(__file__).parents[3] / 'overview_uniform_preview.png'), full_page=True)
# Runtime content growth automatically expands all peers in its Grid row,
# without ResizeObserver, manual coordinates or animation loops.
old_height=panels[0]['height']
page.evaluate("""() => {
const panel=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const child=document.createElement('div');child.id='expanded';child.style.height='175px';panel.append(child);
}""")
updated=page.evaluate(SNAPSHOT)
validate(updated,1,1720)
assert next(card for card in updated['cards'] if card['id']=='process_diagnostics')['height'] > old_height + 80
page.evaluate("() => document.getElementById('expanded').remove()")
validate(page.evaluate(SNAPSHOT),1,1720)
radii=page.evaluate("""() => {
const node=document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const bar=document.createElement('div');bar.className='overview-inline-toolbar';
bar.innerHTML='<button class="quiet overview-inline-up">←</button><button class="quiet overview-inline-hide">×</button>';
node.append(bar);const out=[];
for(const preset of ['default','square','subtle','rounded','extra']) {
document.documentElement.dataset.cornerStyle=preset;
out.push([getComputedStyle(document.getElementById('logo-test')).borderTopLeftRadius,
getComputedStyle(bar.querySelector('.overview-inline-up')).borderTopLeftRadius,
getComputedStyle(bar.querySelector('.overview-inline-hide')).borderTopLeftRadius]);
}bar.remove();return out;
}""")
assert len({item[0] for item in radii})==1
assert all(item[1]==item[2] for item in radii)
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
for(const node of grid.children) if(node.dataset.dashboardWidget) node.hidden=false;
window.rustProxyMasonry.refresh();
}""")
cases=[(1720,0,1,'normal',12),(1350,180,1,'compact',12),
(1200,260,1,'normal',6),(900,0,1,'compact',6),
(900,190,1,'normal',6),(720,190,1,'ultra',2),
(620,0,1,'compact',2),(430,0,1,'ultra',2),
(1140,200,.9,'compact',6),(1140,200,1.1,'normal',6),
(1140,200,1.3,'normal',6)]
for width,sidebar,zoom,density,max_columns in cases:
page.set_viewport_size({'width':width,'height':950})
page.evaluate("""({sidebar,zoom,density})=>{
const shell=document.getElementById('test-shell');
shell.style.width=`${(innerWidth-sidebar)/zoom}px`;
shell.style.marginLeft=`${sidebar}px`;shell.style.zoom=String(zoom);
document.documentElement.dataset.dashboardDensity=density;
window.rustProxyMasonry.refresh();
}""", {'sidebar':sidebar,'zoom':zoom,'density':density})
result=page.evaluate(SNAPSHOT)
assert len(result['cards'])==62
assert result['columns']<=max_columns, (width, result['columns'], max_columns)
validate(result,zoom,width)
print(f"PASS: {width}px, zoom={zoom}, groups={len(set(c['group'] for c in result['cards']))}, "
f"columns={result['columns']}, gap={result['gap']}px")
# Unlock mode adds editing toolbars/padding; rows must remain uniform.
page.set_viewport_size({'width':900,'height':950})
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
const shell=document.getElementById('test-shell');
shell.style.width='auto';shell.style.zoom='1';shell.style.marginLeft='0';
grid.classList.add('is-unlocked');
for(const node of grid.children) if(node.dataset.dashboardWidget) {
const toolbar=document.createElement('div');toolbar.className='overview-inline-toolbar';
toolbar.innerHTML='<button class="quiet overview-inline-grip">⠿</button>'+
'<span class="overview-widget-type">Wykres</span>'+
'<button class="quiet overview-inline-up">←</button>'+
'<button class="quiet overview-inline-down">→</button>'+
'<button class="quiet overview-inline-hide">×</button>';
node.append(toolbar);
}
}""")
validate(page.evaluate(SNAPSHOT),1,900)
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid');
grid.classList.remove('is-unlocked');
grid.querySelectorAll('.overview-inline-toolbar').forEach(toolbar=>toolbar.remove());
}""")
page.set_viewport_size({'width':1720,'height':950})
page.evaluate("""() => {
const grid=document.getElementById('dashboard-overview-grid'),shell=document.getElementById('test-shell');
shell.style.width='auto';shell.style.marginLeft='0';shell.style.zoom='1';
document.documentElement.dataset.dashboardDensity='compact';
grid.append(...window._overviewOriginal.map(([id,hidden])=>{
const node=grid.querySelector(`[data-dashboard-widget="${id}"]`);
node.hidden=hidden;return node;
}));window.rustProxyMasonry.refresh();
}""")
final=page.evaluate(SNAPSHOT)
validate(final,1,1720)
assert [node['id'] for node in final['cards'][:6]]==['requests','active','bandwidth','errors','cache','memory']
browser.close()
print('PASS: 62 cards in aligned rows; no floating heights, safe resize and zoom')
@@ -1,82 +0,0 @@
#!/usr/bin/env python3
"""Browser regression: compact, matching Overview gutters in both directions.
Fixture uses live CSS and the real grid grouping script, but mocked content.
"""
from pathlib import Path
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
root = Path(__file__).resolve().parents[2] / "src/admin/web"
soup = BeautifulSoup((root / "index.html").read_text(), "html.parser")
widgets = soup.select("#dashboard-overview-grid > [data-dashboard-widget]")
assert len(widgets) == 62, len(widgets)
css = (root / "style.css").read_text()
html = ("<!DOCTYPE html><html data-theme='dark' data-dashboard-density='normal'>"
"<head><meta charset='utf-8'><style>" + css + "</style></head>"
"<body><section id='panel-overview' style='width:1680px'>"
"<div id='dashboard-overview-grid' class='stats-grid dashboard-overview-grid'>"
+ "".join(map(str, widgets)) + "</div></section></body></html>")
snapshot = """() => {
const grid = document.getElementById('dashboard-overview-grid');
const style = getComputedStyle(grid);
const parentGap = parseFloat(getComputedStyle(document.body).getPropertyValue('--ui-gap'));
const rows = [];
for (const node of grid.children) {
if (!node.dataset.dashboardWidget || node.hidden) continue;
const rect = node.getBoundingClientRect();
const group = window.rustProxyMasonry.groupFor(node.dataset.dashboardWidget);
let row = rows.find(r => Math.abs(r.top - rect.top) < 0.75);
if (!row) {
row = {top: rect.top, bottom: rect.bottom, group, cards: []};
rows.push(row);
}
row.bottom = Math.max(row.bottom, rect.bottom);
row.cards.push({left: rect.left, right: rect.right, bottom: rect.bottom});
}
return {
rowGap: parseFloat(style.rowGap), columnGap: parseFloat(style.columnGap),
parentGap, margin: style.marginBottom,
rows: rows.sort((a, b) => a.top - b.top)
};
}"""
with sync_playwright() as playwright:
browser = playwright.chromium.launch(
headless=True, executable_path="/usr/bin/chromium", args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1800, "height": 1100})
page.set_content(html)
page.add_script_tag(content=(root / "app/overview-masonry.js").read_text())
checks = 0
for width in (900, 1220, 1680):
page.evaluate("w => document.getElementById('panel-overview').style.width = w + 'px'", width)
for ui_density, old_gap in (("normal", 5), ("dense", 4), ("comfortable", 13)):
page.evaluate("d => {document.body.dataset.density = d === 'normal' ? '' : d;}", ui_density)
for card_density in ("normal", "compact", "ultra"):
page.evaluate("d => document.documentElement.dataset.dashboardDensity = d", card_density)
data = page.evaluate(snapshot)
expected = old_gap
assert abs(data["parentGap"] - old_gap) < 0.01, data
assert abs(data["rowGap"] - expected) < 0.01, data
assert abs(data["columnGap"] - expected) < 0.01, data
assert data["margin"] in ("0px", "0"), data
rows = data["rows"]
assert len(rows) >= 4, (width, ui_density, card_density)
for before, after in zip(rows[:10], rows[1:11]):
distance = after["top"] - before["bottom"]
assert abs(distance - expected) < 1.05, (width, ui_density, card_density, distance, expected)
for row in rows[:10]:
cards = sorted(row["cards"], key=lambda card: card["left"])
assert max(c["bottom"] for c in cards) - min(c["bottom"] for c in cards) < 1.1
for a, b in zip(cards, cards[1:]):
assert abs(b["left"] - a["right"] - expected) < 1.1, (
width, ui_density, card_density, a, b, expected)
first_large = next((i for i, row in enumerate(rows) if row["group"] != 0), None)
assert first_large is not None and first_large > 0
transition = rows[first_large]["top"] - rows[first_large-1]["bottom"]
assert abs(transition - expected) < 1.05, (width, ui_density, card_density, transition)
checks += 1
print(f"PASS: {width}px/{ui_density}: uniform {expected}px gutters, all Dashboard densities")
browser.close()
print(f"PASS: {checks} Chromium layout checks including small-to-large transitions")
@@ -1,72 +0,0 @@
// Regression for shared server/browser Overview Cards metadata and future opt-in widgets.
import fs from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
const prefs = fs.readFileSync('src/admin/web/app/preferences.js','utf8');
const constants = prefs.slice(prefs.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefs.indexOf('const CHART_STYLE_KEYS'));
const normalize = prefs.slice(prefs.indexOf('function normalizeDashboardWidgets'),prefs.indexOf('function browserLanguage'));
const label = prefs.slice(prefs.indexOf('function dashboardWidgetLabel'),prefs.indexOf('function renderDashboardWidgetSettings'));
assert(constants.startsWith('const ADMIN_DASHBOARD_WIDGETS'));
assert(normalize.startsWith('function normalizeDashboardWidgets'));
assert(label.startsWith('function dashboardWidgetLabel'));
function client(definitions) {
const context = {t: key => `loc:${key}`};
vm.createContext(context);
vm.runInContext(`const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${JSON.stringify(definitions)});\n${constants}\n${normalize}\n${label}\n`,context);
return {
order: value => [...vm.runInContext(`normalizeDashboardWidgets(${JSON.stringify(value)})`,context)],
hidden: (value,saved) => [...vm.runInContext(`normalizeHiddenDashboardWidgets(${JSON.stringify(value)},${JSON.stringify(saved)})`,context)],
label: id => vm.runInContext(`dashboardWidgetLabel(${JSON.stringify(id)})`,context),
};
}
const original = client(registry);
const previous = registry.filter(widget => widget.category === 'overview').map(widget => widget.id);
assert.deepEqual(original.order(previous),registry.map(widget => widget.id));
assert.deepEqual(original.hidden([],previous),registry.filter(widget=>widget.default_hidden).map(widget=>widget.id));
assert.equal(original.label('stream_overview'),'loc:streamWidgetOverview');
assert.equal(original.label('backend_health'),'loc:backendWidgetHealth');
assert.equal(original.label('backend_table'),'loc:vhHealthTitle');
assert.equal(registry.length, 62);
const types = new Set(['value', 'value_chart', 'chart', 'panel', 'table']);
assert(registry.every(widget => types.has(widget.display_type)), 'Every Overview block needs explicit type metadata');
assert.equal(registry.find(w => w.id === 'requests').display_type, 'value');
assert.equal(registry.find(w => w.id === 'backend_requests').display_type, 'value_chart');
assert.equal(registry.find(w => w.id === 'traffic_chart').display_type, 'chart');
assert.equal(registry.find(w => w.id === 'cache_diagnostics').display_type, 'panel');
assert.equal(registry.find(w => w.id === 'backend_table').display_type, 'table');
// Upgrading from the previous 30-widget 0.32.3 archive keeps its exact
// visible/order preferences: only twelve new metrics are opt-in, while
// seven existing large panels remain visible in the factory layout.
const previouslySaved = registry.slice(0, 30).map(widget => widget.id);
const migratedOrder = original.order(previouslySaved);
const migratedHidden = original.hidden(registry.slice(0, 30).filter(w => w.default_hidden).map(w => w.id), previouslySaved);
assert.deepEqual(migratedOrder.slice(0, 30), previouslySaved);
assert.deepEqual(migratedOrder.slice(30), registry.slice(30).map(widget => widget.id));
for (const widget of registry.filter(widget => widget.category === 'performance'))
assert(migratedHidden.includes(widget.id), `${widget.id} must be opt-in after upgrade`);
for (const widget of registry.filter(widget => widget.category === 'panels' && !widget.default_hidden))
assert(!migratedHidden.includes(widget.id), `${widget.id} must retain default visibility`);
for (const widget of registry.filter(widget => widget.id.startsWith('wide_')))
assert(migratedHidden.includes(widget.id), `${widget.id} is a new opt-in chart`);
assert.equal(registry.filter(widget => widget.category === 'panels').length, 14);
for (const widget of registry.filter(widget => widget.category === 'performance')) assert(widget.default_hidden);
for (const id of ['backend_health','backend_activity','backend_errors','backend_latency','backend_traffic','backend_protocols','backend_requests','backend_capacity','backend_http5xx','backend_timeouts','backend_network','backend_retries','backend_queue','backend_rotations'])
assert(original.hidden([],previous).includes(id), `${id} must remain opt-in for older preferences`);
assert.deepEqual(original.order(['errors','unknown-0.33.0','requests','errors']).slice(0,2),['errors','requests']);
assert(original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('http3'));
assert(!original.hidden(['unknown-0.33.0','http3'],registry.map(widget=>widget.id)).includes('unknown-0.33.0'));
// Adding a new registry card, with an existing UI element and translation key,
// automatically offers it in the ordered preference list and hides it by default.
const extra={...registry[0], id:'future_upstream', label_key:'futureUpstreamProtocol',
description_key:null, element_selector:'[data-dashboard-widget="future_upstream"]', default_hidden:true};
const expanded=client([...registry,extra]);
assert.equal(expanded.order(previous).at(-1),'future_upstream');
assert(expanded.hidden([],previous).includes('future_upstream'));
assert.equal(expanded.label('future_upstream'),'loc:futureUpstreamProtocol');
assert(!expanded.hidden([],registry.map(widget=>widget.id).concat('future_upstream')).includes('future_upstream'),
'explicitly opted-in widget must not become hidden after a later release');
console.log(`PASS: shared widget registry UI preserves order, visibility and ${registry.length} existing cards`);
@@ -1,15 +0,0 @@
// Source-level guard against reintroducing broken absolute/masonry layout.
import fs from 'node:fs';
import assert from 'node:assert/strict';
const source = fs.readFileSync('src/admin/web/app/overview-masonry.js','utf8');
const css = fs.readFileSync('src/admin/web/style.css','utf8');
assert.match(source,/\.sort\(\(a, b\) => group\(a\.node\) - group\(b\.node\)/);
assert.match(source,/grid\.classList\.remove\("is-packed"\)/);
assert.match(source,/node\.classList\.add\("overview-row-start"\)/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
assert(!/style\.position\s*=\s*["']absolute["']|requestAnimationFrame\(|new ResizeObserver\(/.test(source),
'No absolute positioning, animation loop or ResizeObserver should manage Overview geometry');
assert(!/\.dashboard-overview-grid\.is-aligned\.is-packed\s*\{/.test(css));
assert(!source.includes('fetch(') && !source.includes('setInterval('));
console.log('PASS: regular symmetric CSS Grid for all 62 Overview blocks');
@@ -1,78 +0,0 @@
// Optional large Overview charts: real status schema, counter resets, no polling.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/app/overview-wide-metrics.js','utf8');
const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json','utf8'));
const ids = registry.filter(w => w.id.startsWith('wide_')).map(w => w.id);
assert.equal(ids.length, 7);
assert(ids.every(id => registry.find(w => w.id === id).default_hidden));
const nodes = new Map(ids.map(id => [id,{ hidden: false }]));
const fields = new Map();
function $(id) {
if (!fields.has(id)) fields.set(id,{ textContent:'-', d:'', setAttribute(name,value) {this[name]=value;} });
return fields.get(id);
}
let now = 1000;
const ctx = {window:{},currentPanel:'overview',performance:{now:()=>now},fmt:(n,places=0)=>Number(n).toFixed(places),fmtBytes:n=>`${n} B`,$,
document:{querySelector:selector=>nodes.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1])||null}};
vm.createContext(ctx);vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), ctx);
vm.runInContext(source,ctx);
const render=ctx.window.rustProxyWideOverview.render;
const text=id=>$(id).textContent;
const baseline = { revision:1,
metrics:{ http1_requests:50,http2_requests:30,http3_requests:20,active_connections:9,websocket_active:3,mean_duration_ms:15 },
system:{cpu_percent:45,process_cpu_percent:16,process_rss_bytes:1500},
cache:{hits:20,misses:10,resident_bytes:220},
backend_pools:[{members:[{status:'up',active:1,last_probe_ms:100,latency_ms:12,health_mode:'active'},{status:'down',active:0}]}]};
render(baseline);
assert.equal(text('overview-wide-wide_protocol_rates-summary'),'-');
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%');
assert.equal(text('overview-wide-wide_memory-process-value'),'1500 B');
assert.equal(text('overview-wide-wide_backend_health-down-value'),'1');
assert.equal(text('overview-wide-wide_latency-probe-value'),'12.0 ms');
now = 3000;
const second=structuredClone(baseline);
second.metrics.http1_requests=70; second.metrics.http2_requests=40; second.metrics.http3_requests=22;
second.cache.hits=30; second.cache.misses=18;
render(second);
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'10.0/s');
assert.equal(text('overview-wide-wide_protocol_rates-h2-value'),'5.0/s');
assert.equal(text('overview-wide-wide_protocol_rates-h3-value'),'1.0/s');
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'5.0/s');
assert.equal(text('overview-wide-wide_cache_activity-misses-value'),'4.0/s');
assert.equal(text('overview-wide-wide_connections-open-value'),'9');
now = 4000;
render({...second,metrics:{...second.metrics,http1_requests:0,http2_requests:0,http3_requests:0},cache:{...second.cache,hits:0,misses:0}});
assert.equal(text('overview-wide-wide_protocol_rates-h1-value'),'-', 'counter resets must not create negative traffic');
assert.equal(text('overview-wide-wide_cache_activity-hits-value'),'-');
nodes.get('wide_cpu').hidden=true;
now=5000;render({...second, system:{}});
assert.equal(text('overview-wide-wide_cpu-system-value'),'45.0%', 'hidden chart should not re-render');
nodes.get('wide_cpu').hidden=false;
now=6000;render({metrics:{},system:{},cache:{}});
assert.equal(text('overview-wide-wide_cpu-system-value'),'-', 'unavailable metrics are not zero');
assert.equal(text('overview-wide-wide_backend_health-up-value'),'-', 'missing backend array is no data');
ctx.window.rustProxyWideOverview.reset();
for(let i=0;i<75;i++){now+=1000;render({...baseline,system:{...baseline.system,cpu_percent:i}});}
assert(source.includes('const limit = 60'));
assert(source.includes('if (!node || node.hidden)'));
assert(!/fetch\s*\(|setInterval\s*\(/.test(source),'no extra API calls or polling');
const css=fs.readFileSync('src/admin/web/style.css','utf8');
const aligned = fs.readFileSync('src/admin/web/app/overview-masonry.js', 'utf8');
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-rows:\s*auto;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*grid-auto-flow:\s*row;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*--overview-gap:\s*max\(3px, calc\(var\(--ui-gap\) - 1px\)\);/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*gap:\s*var\(--overview-gap\);/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\.stats-grid\s*\{[^}]*gap:\s*var\(--overview-gap\);/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*\{[^}]*align-items:\s*stretch;/);
assert.match(css, /#panel-overview\s*\{\s*container:\s*overview-canvas \/ inline-size;/);
assert.match(css, /@container overview-canvas \(max-width:\s*700px\)/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\s*\{[^}]*align-self:\s*stretch;/);
assert.match(css, /\.dashboard-overview-grid\.is-aligned\s*>\s*\[data-dashboard-widget\]\.overview-row-start\s*\{[^}]*grid-column-start:\s*1;/);
assert.match(aligned, /node\.classList\.remove\("overview-row-start"\)/);
assert.match(aligned, /node\.classList\.add\("overview-row-start"\)/);
assert.match(aligned, /grid\.classList\.remove\("is-packed"\)/);
assert(!/style\.position\s*=\s*["']absolute["']/.test(aligned));
assert(!aligned.includes('fetch(') && !aligned.includes('setInterval('));
console.log('PASS: 7 optional wide charts, regular rows, no uneven masonry');
@@ -1,121 +0,0 @@
#!/usr/bin/env node
// Real browser form callbacks with fake DOM; protocol/security checks are
// source-level assertions only. Rust unit and live HTTP tests need Cargo.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const read = path => readFileSync(resolve(root, path), 'utf8');
const html = read('src/admin/web/index.html');
const css = read('src/admin/web/style.css');
const administration = read('src/admin/web/settings.js');
const cache = read('src/admin/web/app/settings.js');
const router = read('src/admin/router.rs');
const assets = read('src/admin/panel_assets.rs');
const compression = read('src/admin/panel_content.rs');
const ip = read('src/cache/model/http_invalidation.rs');
const cacheMethods = read('src/proxy/cache_http_invalidation.rs');
for (const codec of ['zstd', 'br', 'gzip']) {
assert.match(html, new RegExp(`id="admin-compression-${codec}"[^>]*checked`));
assert(assets.includes('"/assets/app.js"'));
}
for (const path of ['app.js','management.js','vhosts.js','settings.js','cache-panel.js','traffic-panel.js','style.css','langs.json']) {
if (path === "langs.json") assert(router.includes(`"/assets/${path}", get(panel_assets::serve)`), path);
else assert(router.includes('"/assets/{fingerprinted}"'), path);
assert(assets.includes(`"/assets/${path}"`), path);
}
assert(compression.includes('preferred_encoding(headers, &settings)'));
assert(compression.includes('HeaderValue::from_static("Accept-Encoding")'));
assert(compression.includes('header::CONTENT_LENGTH'));
assert(compression.includes('header::CONTENT_ENCODING'));
assert(compression.includes('crate::compression::no_transform(headers)'));
assert(html.includes('id="cache-http-invalidation-cidrs"'));
assert(html.includes('class="metric-info cache-maintenance-info"'));
assert(html.includes('data-i18n-aria="cacheHttpInvalidationCurlLabel"'));
for (const snippet of ["curl -i -X PURGE", "curl -i -X BAN", "X-RustProxy-Confirm-Wide: 1"]) {
assert(html.includes(snippet), snippet);
}
assert(css.includes('.cache-maintenance-info .metric-info-popover'));
assert(cache.includes('http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs")'));
assert(ip.includes('http_invalidation_allowed_cidrs.len() <= 32'));
assert(ip.includes('net.prefix_len() > 0'));
assert(cacheMethods.includes('snapshot.envelope.config.cache.permits_http_invalidation(client_ip)'));
assert(cacheMethods.includes('state.store.audit('));
assert(cacheMethods.includes('spawn_blocking'));
assert(cacheMethods.includes('x-rustproxy-confirm-wide'));
assert(cacheMethods.includes('InvalidationSelector::Exact'));
assert(cacheMethods.includes('InvalidationSelector::Prefix'));
assert(read('src/proxy/forward_entry.rs').includes('return cache_http_invalidation('));
const heading = html.lastIndexOf('data-i18n="systemInformation"');
assert(heading > 0);
const headerArea = html.slice(heading, heading + 600);
assert(headerArea.includes('<span class="pill">API</span>'));
assert(headerArea.indexOf('<span class="pill">API</span>') < headerArea.indexOf('id="system-info-refresh"'));
assert.match(css, /\.system-info-heading-actions\s*\{[^}]*display:\s*flex/);
for (const lang of ['en_US','pl_pl']) {
const strings = JSON.parse(read(`langs/${lang}.json`));
const t = strings.translations;
assert(t.panelCompressionTitle && t.panelCompressionHelp && t.cacheHttpInvalidationHelp, lang);
assert(t.cacheHttpInvalidationCurlLabel && t.cacheHttpInvalidationCurlIntro && t.cacheHttpInvalidationCurlNote, lang);
}
// Execute the actual callbacks captured from the shipped frontend settings JS.
const fields = new Map();
const field = name => {
if (!fields.has(name)) fields.set(name, { checked: false, value: '' });
return fields.get(name);
};
for (const name of ['zstd', 'br', 'gzip']) field(`admin-compression-${name}`).checked = true;
field('admin-listen').value = '127.0.0.1:9090';
field('admin-session-days').value = '30';
let handlers = new Map();
const bindSettingsForm = (id, handler) => handlers.set(id, handler);
const adminStart = administration.indexOf('bindSettingsForm("administration-form", next => {');
const adminEnd = administration.indexOf('bindSettingsForm("tls-policy-form",', adminStart);
assert(adminStart > 0 && adminEnd > adminStart);
// Use the shipped helper in the extracted callback environment as well.
const hostHelperStart = administration.indexOf(' function pinnedAdminHostname(value) {');
const hostHelperEnd = administration.indexOf(' const normalizedRequestedTab', hostHelperStart);
assert(hostHelperStart > 0 && hostHelperEnd > hostHelperStart);
runInNewContext(administration.slice(hostHelperStart, hostHelperEnd) + administration.slice(adminStart, adminEnd), {
$: field, bindSettingsForm, latestStatus: { administration: { listen_locked: false } }, URL,
});
const adminNext = {};
handlers.get('administration-form')(adminNext);
assert.deepEqual(JSON.parse(JSON.stringify(adminNext.administration.panel_compression)),
{ zstd: true, br: true, gzip: true });
field('admin-public-origin').value = 'https://panel.example.test';
field('admin-hosts').value = 'panel.example.test\nother.example.test';
const pinnedNext = {};
handlers.get('administration-form')(pinnedNext);
assert.deepEqual(JSON.parse(JSON.stringify(pinnedNext.administration.allowed_hosts)), ['other.example.test']);
field('admin-compression-br').checked = false;
field('admin-compression-zstd').checked = false;
const offNext = {};
handlers.get('administration-form')(offNext);
assert.deepEqual(JSON.parse(JSON.stringify(offNext.administration.panel_compression)),
{ zstd: false, br: false, gzip: true });
const cacheStart = cache.indexOf('bindSettingsForm("cache-form", next => {');
const cacheEnd = cache.indexOf('bindSettingsForm("transform-form",', cacheStart);
assert(cacheStart > 0 && cacheEnd > cacheStart);
handlers = new Map();
field('cache-http-invalidation-cidrs').value = ' 192.0.2.50\n 2001:db8:2::/64, 198.51.100.9 \n';
field('cache-sie-statuses').value = '500,502,503,504';
runInNewContext(cache.slice(cacheStart, cacheEnd), {
$: field, bindSettingsForm, selectedCacheMemoryPreset: null, selectedCacheColdPreset: null,
});
const cacheNext = { cache: { max_old_setting: 'kept' } };
handlers.get('cache-form')(cacheNext);
assert.deepEqual(JSON.parse(JSON.stringify(cacheNext.cache.http_invalidation_allowed_cidrs)),
['192.0.2.50', '2001:db8:2::/64', '198.51.100.9']);
assert.equal(cacheNext.cache.max_old_setting, 'kept');
field('cache-http-invalidation-cidrs').value = '\n';
handlers.get('cache-form')(cacheNext);
assert.deepEqual(JSON.parse(JSON.stringify(cacheNext.cache.http_invalidation_allowed_cidrs)), []);
console.log('PASS: cache CIDR/administration codec UI roundtrip, headers, security source wiring, API badge layout');
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
"""Source-only regression assertions; NOT a compiled Rust or live H2 test."""
from pathlib import Path
import tomllib
root = Path(__file__).resolve().parents[2]
read = lambda name: (root / name).read_text(encoding='utf-8')
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7'
client = read('src/upstream/client.rs')
forward = read('src/proxy/forward/upstream.rs')
helper = read('src/proxy/h2_graceful.rs')
lease = read('src/balance/lease_runtime.rs')
health = read('src/balance/health.rs')
tests = read('src/balance/tests.rs')
for marker in ('GoAway(', 'NO_ERROR', 'Remote', 'note_graceful_goaway(generation)', 'is_graceful_h2_goaway(error)'):
assert marker in client, marker
for marker in ('graceful_retry_done', 'retry_safe_method(&retry_method)', 'request_body_empty', 'handshake.is_none()', 'retry_request(', 'graceful_retry_done = true', 'continue;'):
assert marker in forward, marker
for marker in ('is_related_h2_close', 'recently_saw_graceful_goaway()', 'h2_graceful_goaway', 'h2_graceful_close'):
assert marker in helper, marker
for marker in ('first_error_detail', 'first_error_at_ms', 'trigger_error_detail', 'backend excluded from traffic', 'tracing::warn!(', 'h2_graceful_goaway'):
assert marker in lease, marker
for marker in ('passive_first_failure', 'active_first_failure', 'passive_probe_successes', 'passive_ejected_at', 'Duration::from_millis(250)', 'fn clear_passive_ejection', 'pub fn healthy'):
assert marker in health, marker
for marker in ('passive_ejection_remembers_first_real_error_and_trigger', 'passive_io_burst_is_one_observation', 'passive_only_quarantine_expires', 'active_probe_successes_recover'):
assert marker in tests, marker
print('PASS: version, H2 graceful recycle/retry, passive health, first-error WARN source contracts (NOT compiled)')
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""Verify the security-sensitive transport split without claiming a Rust execution."""
from pathlib import Path
import json
import re
root=Path(__file__).resolve().parents[2]
read=lambda file:(root/file).read_text(encoding='utf-8')
backend=read('src/config/backend.rs')
forward=read('src/proxy/forward/upstream.rs')
proxy=read('src/proxy/types.rs')
client=read('src/upstream/client.rs')
conn=read('src/upstream.rs')
assert not re.search(r'ensure!\([^;]*!backend\.preserve_host',backend)
assert 'preserve_host: backend.preserve_host' in backend
assert 'fn upstream_request_target(' in proxy
assert 'parts.authority = Some(authority)' in proxy
assert 'if strict_h2_request' in proxy and 'headers.remove(HOST)' in proxy
assert 'let Some(upstream_target) = upstream_request_target(' in forward
assert 'retry_headers = request.headers().clone()' in forward
assert 'upstream_request_target(&uri, request.headers_mut(), backend, false, false)' in proxy
assert 'let destination: http::Uri' in client
assert 'let uri = self.destination.clone()' in conn
assert 'fn call(&mut self, _request_uri: Uri)' in conn
assert 'connect_host' in client and 'connect_host' in conn
assert 'policy.check(ip)' in conn
assert '.retry_canceled_requests(false)' in client
assert 'preserve_host_is_valid_with_every_upstream_protocol' in read('src/config/tests.rs')
for test in (
'preserve_host_sets_http2_authority_without_changing_network_origin',
'retry_prepares_authority_for_the_selected_backend_not_the_previous_one',
'invalid_outgoing_host_is_rejected_instead_of_used_for_dialing',
):
assert test in read('src/proxy/tests/authority_tests.rs'),test
for test in (
'strict_h2_preserves_public_authority_while_tcp_and_sni_use_private_origin',
'auto_h1_fallback_preserves_public_host_without_resolving_it',
'auto_h2_alpn_retains_same_public_host_and_pinned_tls_origin',
'cleartext_h2c_preserves_public_authority_and_pins_socket',
):
assert test in read('src/upstream/protocol_tests.rs'),test
css=read('src/admin/web/style.css')
match=re.search(r'#panel-overview \.dashboard-overview-grid\.is-aligned\s*\{([^}]*)\}',css)
assert match
rules=match[1]
for marker in ['margin: 0;', '--overview-gap: var(--ui-gap);',
'row-gap: var(--overview-gap);', 'column-gap: var(--overview-gap);',
'gap: var(--overview-gap);']:
assert marker in rules, marker
# Regression guard for E0382: the retry builder must not consume the URI
# before upstream_request_target() borrows it to prepare HTTP/2 authority.
retry = re.search(r'fn retry_request\([\s\S]*?\n}\n', proxy)
assert retry and '.uri(uri.clone())' in retry[0]
assert 'upstream_request_target(&uri, request.headers_mut()' in retry[0]
assert json.loads(read('src/admin/openapi.json'))['info']['version']=='0.32.7'
assert 'version = "0.32.7"' in read('Cargo.toml')
print('PASS: 0.32.7 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)')
@@ -1,42 +0,0 @@
import fs from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';
const ui = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8');
const head = 'function debugOriginIssues(members) {';
const tail = '\nfunction aclEditor(';
const start = ui.indexOf(head);
const end = ui.indexOf(tail, start);
assert(start >= 0 && end > start, 'debug origin verification helper is present');
const warn = vm.runInNewContext('(' + ui.slice(start, end).trim() + ')', { URL });
function member(protocol, more={}) { return {
url:'https://10.87.20.2:443',protocol,preserve_host:true,
pool_eligible:true,allow_private:true,tls_verify:false,...more,
}; }
for (const protocol of ['http1', 'auto', 'http2', 'h2']) {
assert.equal(warn([member(protocol)]).length, 0,
`${protocol} is accepted without forcing H1 or disabling preserved Host`);
assert.equal(warn([member(protocol, {tls_server_name:'unitraklub.pl',tls_verify:true})]).length, 0,
`${protocol} supports explicitly verified certificate DNS names`);
}
assert.equal(warn([member('h2',{pool_eligible:false,allow_private:false})]).length,0,
'pool and private-origin flags must not be mistaken for protocol requirements');
assert.equal(warn([member('auto',{tls_verify:true})]).length,1,
'HTTPS-by-IP without TLS identity receives a nonblocking warning');
assert.equal(warn([{...member('h2',{tls_verify:true}),url:'https://site.example'}]).length,0,
'DNS HTTPS origin needs no IP-certificate warning');
assert.equal(warn([{...member('h2',{tls_verify:true}),url:'http://10.87.20.2:80'}]).length,0);
assert(!/preserve\.disabled\s*=|preserve\.checked\s*=\s*false/.test(ui),
'Protocol selector may not disable or clear Preserve Host');
assert(ui.includes('value.preserve_host === true'), 'Existing backend preference remains visible');
assert(ui.includes('protocol.value = "http1"; preserve.checked = true'),
'Optional explicit H1 troubleshooting preset is retained');
assert(!ui.includes('syncProtocol()'), 'Do not call removed protocol-only Host limiter');
assert(ui.includes('protocol.dispatchEvent(new Event("change"))'), 'Preset refreshes protocol help text');
for (const lang of ['en_US','pl_pl']) {
const t=JSON.parse(fs.readFileSync(`langs/${lang}.json`,'utf8')).translations;
for (const key of ['vhPreserveHost','vhDebugOriginPreset','vhOriginPresetHelp','vhDebugOriginMismatch'])
assert(t[key] && t[key].length>30,`${lang}.${key}`);
}
console.log('PASS: Preserve Host is available for four modes; IP/TLS hint is conditional and optional');
-126
View File
@@ -1,126 +0,0 @@
// Counter-derived chart regression: pauses must not become fake 0/s samples.
// Real Cache / Transform chart functions run against simulated DOM and clock.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const samplingSource = fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8');
const panels = {
cache: {
path: 'src/admin/web/cache-panel.js', stop: ' function configure()',
counters: (hit, miss) => ({ hits: hit, misses: miss, bypasses: 0 }),
graph: 'cache-live-hit-line', rate: 'cache-live-hit-rate',
keys: ['hits', 'misses', 'bypasses'], seconds: 5,
},
transform: {
path: 'src/admin/web/transform-panel.js', stop: ' function countersFor(data)',
counters: (hit, miss) => ({ applied: hit, cache_hits: miss, skipped: 0, errors: 0 }),
graph: 'transform-live-applied-line', rate: 'transform-live-applied-rate',
keys: ['applied', 'cache_hits', 'skipped', 'errors'], seconds: 3,
},
};
function fixture(name, config) {
const source = fs.readFileSync(config.path, 'utf8');
const end = source.indexOf(config.stop);
assert(end > 0, `unable to isolate ${name} live chart`);
const documentListeners = new Map(), windowListeners = new Map(), nodes = new Map();
let now = 1000;
const node = id => {
if (!nodes.has(id)) nodes.set(id, {
value: id.endsWith('-scope') ? '*' : '', textContent: '',
attrs: {}, setAttribute(key, value) { this.attrs[key] = value; },
});
return nodes.get(id);
};
const document = {
hidden: false,
addEventListener(name, listener) { documentListeners.set(name, listener); },
};
const window = {
addEventListener(name, listener) { windowListeners.set(name, listener); },
rustProxyRefresh: { current: () => ({ overview_seconds: 2, cache_seconds: config.seconds }) },
};
const ctx = {
document, window, $: node, performance: { now: () => now },
sparklinePoints: samples => samples.join(','),
};
vm.createContext(ctx);
vm.runInContext(samplingSource, ctx);
vm.runInContext(source.slice(0, end) + '\nwindow.liveChartTest = { renderLiveChart, resetLiveChart, liveChartSamples };\n})();', ctx);
const chart = window.liveChartTest;
const line = () => node(config.graph).attrs.points;
const rate = () => node(config.rate).textContent;
const length = () => chart.liveChartSamples[config.keys[0]].length;
const sample = (stamp, hits, misses) => {
now = stamp;
chart.renderLiveChart(config.counters(hits, misses));
};
return { ctx, chart, line, rate, length, sample, document, documentListeners, windowListeners };
}
for (const [name, config] of Object.entries(panels)) {
const f = fixture(name, config);
f.sample(1000, 100, 50); // no deltas exist before the first observation
assert.equal(f.length(), 0, `${name}: first reading is a baseline, not 0/s`);
assert.equal(f.rate(), '-');
f.sample(1000 + config.seconds * 1000, 130, 65);
assert.equal(f.rate(), `${(30 / config.seconds).toFixed(1)}/s`);
assert.equal(f.length(), 1);
f.sample(1000 + config.seconds * 2000, 130, 65);
assert.equal(f.rate(), '0.0/s', `${name}: real idle interval is still zero`);
assert.equal(f.length(), 2);
const graphBefore = f.line();
const delayed = 1000 + config.seconds * 2000 + 120000;
f.sample(delayed, 500, 200);
assert.equal(f.rate(), '-', `${name}: long pause cannot be averaged into a misleading rate`);
assert.equal(f.length(), 2, `${name}: do not append fake drop`);
assert.equal(f.line(), graphBefore, `${name}: keep old chart path`);
f.sample(delayed + config.seconds * 1000, 530, 215);
assert.equal(f.rate(), `${(30 / config.seconds).toFixed(1)}/s`, `${name}: next fresh interval resumes`);
assert.equal(f.length(), 3);
f.document.hidden = true;
f.documentListeners.get('visibilitychange')?.();
f.document.hidden = false;
f.documentListeners.get('visibilitychange')?.();
const afterBackground = delayed + config.seconds * 1000 + 200;
f.sample(afterBackground, 533, 219);
assert.equal(f.length(), 3, `${name}: even a short background hide must not append a rate`);
assert.equal(f.rate(), '-');
f.sample(afterBackground + config.seconds * 1000, 533, 219);
assert.equal(f.rate(), '0.0/s', `${name}: genuine zero after resume`);
assert.equal(f.length(), 4);
f.windowListeners.get('pagehide')?.();
f.sample(afterBackground + config.seconds * 2000, 540, 220);
assert.equal(f.length(), 4, `${name}: pagehide / BFCache must rebase`);
f.sample(afterBackground + config.seconds * 3000, 550, 230);
assert.equal(f.length(), 5);
f.sample(afterBackground + config.seconds * 4000, 1, 1);
assert.equal(f.length(), 5, `${name}: counters reset must not be interpreted as 0/s`);
f.sample(afterBackground + config.seconds * 5000, 3, 3);
assert.equal(f.length(), 6, `${name}: counter reset recovery`);
}
// All remaining rate charts share this exact continuity check, including
// configured 30-second Cache refresh (not falsely treated as a pause).
{
const listeners = new Map(), document = { hidden: false, addEventListener(key, fn) { listeners.set(key, fn); } };
const context = { window: {}, document };
vm.createContext(context);
vm.runInContext(samplingSource, context);
const sample = vm.runInContext('rustProxyRateSampling', context);
let baseline = sample.difference(null, [100], 1000, 30).next;
let value = sample.difference(baseline, [130], 31000, 30);
assert.equal(value.deltas[0], 30, '30-second chart interval is valid');
baseline = value.next;
value = sample.difference(baseline, [200], 124000, 30);
assert.equal(value.deltas, null, 'large delay is a gap');
document.hidden = true;
listeners.get('visibilitychange')?.();
document.hidden = false;
assert.equal(sample.continuous(value.next, 124001, 30), false, 'hide invalidates even a short elapsed period');
}
console.log('PASS: Cache/Transform history survives hidden tabs, long pauses, BFCache and counter resets; real 0/s and configured cadence remain intact.');
-238
View File
@@ -1,238 +0,0 @@
#!/usr/bin/env node
// Deterministic browser-coordinator regression without a production browser or network.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const scheduler = readFileSync(resolve(root, "src/admin/web/app/refresh.js"), "utf8");
const core = readFileSync(resolve(root, "src/admin/web/app/core.js"), "utf8");
const bridge = core.slice(core.indexOf("function renderLiveStatus(value)"), core.indexOf("async function refreshCertificateInventory()"));
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(), 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); }
addEventListener(type, callback) { this.handlers.set(type, callback); }
close() { this.closed = true; }
emit(type, value) { this.handlers.get(type)?.({ data: JSON.stringify(value) }); }
fail() { this.onerror?.({ type: "error" }); }
}
const nodes = { "connection-label": { textContent: "" } };
const document = {
hidden: false,
addEventListener(type, callback) { assert(!listeners.has(type)); listeners.set(type, callback); },
removeEventListener(type, callback) { assert.equal(listeners.get(type), callback); listeners.delete(type); }
};
const ctx = {
document, AbortController, URLSearchParams,
EventSource: withoutSse ? undefined : FakeEventSource,
setTimeout(fn, ms) { const id = nextId++; timers.set(id, { at: time + ms, fn }); return id; },
clearTimeout(id) { timers.delete(id); },
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 },
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); },
renderStatus(value) { renderCount++; rendered.push(value); },
renderLogs() { logCount++; },
renderDiagnostics() { diagCount++; },
renderLiveStatus() { throw new Error("bridge not loaded"); },
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" });
const refresh = ctx.window.rustProxyRefresh;
const settled = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
async function advance(ms = 0) {
const until = time + ms;
for (let i = 0; i < 1000; i++) {
const due = [...timers.entries()].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!due) { time = until; await settled(); return; }
const [id, timer] = due; time = timer.at; timers.delete(id); timer.fn(); await settled();
}
throw new Error("unbounded polling timer loop");
}
function switchPanel(name) { ctx.currentPanel = name; refresh.setPanel(name); }
function changeVisibility(hidden) { document.hidden = hidden; listeners.get("visibilitychange")?.(); }
function count(url) { return requests.filter(item => item.url.startsWith(url)).length; }
function registerViews() {
for (const name of ["traffic", "cache"]) {
refresh.register(name, {
interval: () => refresh.current()[`${name}_seconds`],
active: () => ctx.currentPanel === name,
run: async ({ signal, isCurrent }) => {
const value = await ctx.api(`/api/${name}`, { signal });
if (isCurrent()) rendered.push({ name, value });
}
});
}
}
return {
ctx, refresh, requests, rendered, streams, listeners, windowListeners, timers, failures, advance, switchPanel, count,
registerViews, changeVisibility,
setResponse(fn) { response = fn; },
get renderCount() { return renderCount; },
get logCount() { return logCount; },
get diagCount() { return diagCount; },
};
}
const h = fixture();
h.registerViews();
assert.equal(h.refresh.current().overview_seconds, 3);
assert.equal(h.refresh.current().system_info_seconds, 60);
assert.equal(h.refresh.current().traffic_seconds, 5);
assert.equal(h.refresh.current().cache_seconds, 5);
assert.equal(h.refresh.current().live_enabled, true);
assert.equal(h.streams.length, 0);
h.refresh.start(); await h.advance();
assert.equal(h.streams.length, 1, "one shared SSE connection");
assert.equal(h.streams[0].url, "/api/events?interval=3");
assert.equal(h.count("/api/traffic"), 0, "no Traffic on Overview");
assert.equal(h.count("/api/cache"), 0, "no Cache on Overview");
assert.equal(h.count("/api/system-info"), 1, "Overview uses slow diagnostics");
assert.equal(h.count("/api/status"), 0, "no duplicate status polling while SSE connects");
assert.equal(h.count("/api/logs"), 0);
await h.advance(10_000);
assert.equal(h.count("/api/system-info"), 1, "diagnostics not collected at live interval");
h.switchPanel("traffic"); await h.advance();
assert.equal(h.count("/api/traffic"), 1);
assert.equal(h.count("/api/cache"), 0);
for (let i = 0; i < 20; i++) { h.switchPanel("overview"); h.switchPanel("traffic"); }
assert.equal(h.refresh.diagnostics().filter(row => row.id === "traffic").length, 1);
await h.advance(0);
assert.equal(h.count("/api/traffic"), 2, "tab switches do not multiply immediate requests");
await h.advance(15_000);
assert.equal(h.count("/api/traffic"), 5, "one request each 5 seconds");
assert.equal(h.count("/api/cache"), 0);
h.refresh.update({ traffic_seconds: 3, cache_seconds: 0, system_info_seconds: 0, overview_seconds: 5 });
assert(h.streams[0].closed, "SSE is replaced when the administrator updates their interval");
assert.equal(h.streams.at(-1).url, "/api/events?interval=5");
await h.advance();
assert.equal(h.count("/api/traffic"), 6);
await h.advance(6_000);
assert.equal(h.count("/api/traffic"), 8);
h.switchPanel("cache"); await h.advance(20_000);
assert.equal(h.count("/api/cache"), 0, "Cache manual mode cancels polling");
await h.refresh.manual("cache");
assert.equal(h.count("/api/cache"), 1, "manual Cache works with interval=0");
await h.advance(40_000);
assert.equal(h.count("/api/cache"), 1);
assert.equal(h.count("/api/traffic"), 8, "Traffic is stopped in Cache view");
// SSE failures activate both fallbacks and a recovered snapshot switches them off.
h.streams.at(-1).fail(); await h.advance();
assert.equal(h.count("/api/status?view=live"), 1);
assert.equal(h.count("/api/logs"), 1);
await h.advance(5_000);
assert.equal(h.count("/api/status?view=live"), 2);
h.streams.at(-1).emit("snapshot", { status: { metrics: { requests: 5 } }, logs: { entries: [] } });
assert.equal(h.ctx.liveConnected, true);
await h.advance(20_000);
assert.equal(h.count("/api/status?view=live"), 2, "SSE recovery stops fallback polling");
assert.equal(h.count("/api/logs"), 2);
assert.equal(h.renderCount, 3, "two fallback status renders followed by recovered SSE");
// Hidden browser tabs pause even previously active sources, then resume cleanly.
h.refresh.update({ traffic_seconds: 3, cache_seconds: 3, system_info_seconds: 15 });
h.switchPanel("traffic"); await h.advance();
const beforeHidden = h.count("/api/traffic");
h.changeVisibility(true);
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(); await h.advance();
assert.equal(h.count("/api/traffic"), beforeHidden + 1);
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
// Off pauses all scheduled work, but retains explicit manual requests.
h.refresh.update({ live_enabled: false, traffic_seconds: 3 });
const requestsBeforeOff = h.requests.length;
await h.advance(120_000);
assert.equal(h.requests.length, requestsBeforeOff);
await h.refresh.manual("traffic");
assert.equal(h.count("/api/traffic"), beforeHidden + 2);
assert.equal(h.streams.filter(stream => !stream.closed).length, 0);
h.refresh.update({ live_enabled: true, traffic_seconds: 3 }); await h.advance();
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
// Aborting an old view prevents the obsolete response from modifying UI.
let resolveDelayed;
h.setResponse(url => url === "/api/traffic" ? new Promise(resolve => { resolveDelayed = resolve; }) : Promise.resolve({ ok: true }));
await h.advance(3_000);
const staleCount = h.rendered.length;
const request = h.requests.at(-1);
assert.equal(request.url, "/api/traffic");
h.switchPanel("cache");
assert.equal(request.args.signal.aborted, true);
resolveDelayed({ stale: true }); await h.advance();
assert.equal(h.rendered.filter(v => v.name === "traffic").length, h.rendered.slice(0, staleCount).filter(v => v.name === "traffic").length);
assert.equal(h.count("/api/cache"), 2);
// Manual diagnostics still work when their own automatic interval is zero.
h.refresh.update({ live_enabled: false, system_info_seconds: 0 });
const diagnosticRequests = h.count("/api/system-info");
await h.refresh.manual("system-info");
assert.equal(h.count("/api/system-info"), diagnosticRequests + 1);
h.refresh.update({ live_enabled: true });
// A failed source does not stop other source registrations.
const e = fixture({ withoutSse: true });
e.registerViews();
e.setResponse(url => url === "/api/traffic" ? Promise.reject(new Error("Traffic disconnected")) : Promise.resolve({ ok: true }));
e.refresh.start(); e.switchPanel("traffic"); await e.advance();
assert.equal(e.count("/api/traffic"), 1);
assert.equal(e.count("/api/status?view=live"), 1, "browsers without EventSource use status polling");
assert.equal(e.count("/api/logs"), 1, "browsers without EventSource use logs polling");
e.switchPanel("cache"); await e.advance();
assert.equal(e.count("/api/cache"), 1);
assert.equal(e.failures.length, 1);
await e.advance(5_000);
assert.equal(e.count("/api/cache"), 2);
// Multiple registrations with identical IDs must be rejected.
assert.throws(() => h.refresh.register("traffic", { interval: () => 5, run: async () => {} }), /Duplicate refresh task/);
// 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;
h.ctx.authenticated = false;
await h.advance(600_000);
assert.equal(h.requests.length, afterLogout);
await h.refresh.manual("traffic");
assert.equal(h.requests.length, afterLogout);
// Validate incoming preferences in the browser, including malformed partial records.
const invalid = runInNewContext(scheduler + ";normalizeRefreshPreferences({ live_enabled: 1, overview_seconds: 0, traffic_seconds: 17, cache_seconds: 0, system_info_seconds: 300 });", {
document: { hidden: true, addEventListener() {}, removeEventListener() {} },
AbortController, window: {}, authenticated: false, authEpoch: 0, currentPanel: "overview", liveConnected: false,
liveFallback: false, stopLive() {}, startLive() {}, $: () => null
});
assert.equal(invalid.live_enabled, true);
assert.equal(invalid.overview_seconds, 3);
assert.equal(invalid.traffic_seconds, 5);
assert.equal(invalid.cache_seconds, 0);
assert.equal(invalid.system_info_seconds, 300);
console.log("PASS: per-account refresh validation, SSE/fallback, 5/3/0-s scheduling, tab/visibility/logout lifecycle, isolation, no duplicate/inflight/stale requests, manual mode.");
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""0.31.7 offline update-retention regression without systemd or root privileges."""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[2]
UPDATER = ROOT / "update.sh"
def mkdir_age(path: Path, days: int) -> None:
path.mkdir(parents=True)
stamp = time.time() - days * 86400
os.utime(path, (stamp, stamp))
def call(update_prefix: Path, cargo_target: Path, mockbin: Path, *, fail: bool = False, plan: bool = False) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["PATH"] = f"{mockbin}:{env.get('PATH', '')}"
env["CARGO_TARGET_DIR"] = str(cargo_target)
env["RP_TEST_UPDATE_FAIL"] = "1" if fail else "0"
args = ["bash", str(UPDATER), "--prefix", str(update_prefix)]
if plan:
args.append("--plan")
return subprocess.run(args, env=env, capture_output=True, text=True, timeout=15, check=False)
def main() -> None:
subprocess.run(["bash", "-n", str(UPDATER)], check=True)
with tempfile.TemporaryDirectory(prefix="rp-release-0317-") as tmp:
root = Path(tmp)
prefix = root / "installed rustproxy"
releases = prefix / "releases"
backups = prefix / "backups"
releases.mkdir(parents=True)
backups.mkdir()
for name, age in [("r-new", 0), ("r-second", 2), ("r-live", 8), ("r-ancient", 30), ("r-older extra", 40)]:
mkdir_age(releases / name, age)
(prefix / "current").symlink_to(releases / "r-live", target_is_directory=True)
for days in range(1, 6):
mkdir_age(backups / f"before-{days}", days)
target = root / "target"
for profile in ["release", "debug"]:
(target / profile / "deps").mkdir(parents=True)
(target / profile / ".fingerprint").mkdir()
(target / profile / "build").mkdir()
(target / profile / "rust-proxy").write_text("compiled binary")
mkdir_age(target / profile / "incremental" / "old", 50)
mkdir_age(target / profile / "incremental" / "new", 1)
mockbin = root / "bin"
mockbin.mkdir()
stub = mockbin / "python3"
stub.write_text("#!/bin/sh\n[ \"${RP_TEST_UPDATE_FAIL:-0}\" != 1 ] || exit 17\nexit 0\n")
stub.chmod(0o755)
result = call(prefix, target, mockbin, fail=True)
assert result.returncode == 17, result.stderr
assert (releases / "r-older extra").exists(), "failed update cleaned releases"
assert (target / "release" / "incremental" / "old").exists(), "failed update cleaned target"
result = call(prefix, target, mockbin, plan=True)
assert result.returncode == 0, result.stderr
assert (releases / "r-older extra").exists(), "planned update cleaned releases"
result = call(prefix, target, mockbin)
assert result.returncode == 0, (result.stdout, result.stderr)
assert sorted(path.name for path in releases.iterdir()) == ["r-live", "r-new"], result.stdout
assert sorted(path.name for path in backups.iterdir()) == ["before-1", "before-2", "before-3"], result.stdout
assert (prefix / "current").resolve() == releases / "r-live", "current symlink changed"
for profile in ["debug", "release"]:
assert not (target / profile / "incremental" / "old").exists(), "old incremental retained"
assert (target / profile / "incremental" / "new").exists(), "recent incremental removed"
for artifact in ["deps", ".fingerprint", "build", "rust-proxy", ".cargo-lock"]:
assert (target / profile / artifact).exists(), f"removed Cargo artifact: {profile}/{artifact}"
# Safety: do not touch target if a parent directory is a symlink.
target_symlink_root = root / "symlink"
target_symlink_root.symlink_to(target, target_is_directory=True)
mkdir_age(target / "release" / "incremental" / "symlink-test", 50)
result = call(prefix, target_symlink_root, mockbin)
assert result.returncode == 0, result.stderr
assert (target / "release" / "incremental" / "symlink-test").exists(), "followed symlinked target"
# Static source contracts for the managed-update cache handoff.
deploy = (ROOT / "scripts/deploy/deploy_runtime.py").read_text()
runtime = (ROOT / "src/main/admin_runtime.rs").read_text()
demote = (ROOT / "src/cache/cold/demotion.rs").read_text()
assert "stop_for_managed_update(data)" in deploy
assert "run(['systemctl', 'stop', SERVICE])" in deploy
assert "data_dir.join(\".managed-update\").is_file()" in runtime
assert "state.cache.flush_hot_to_cold_for_update()" in runtime
assert "self.flush_hot_to_cold_inner(true).await" in demote
print("PASS: 0.31.7 failed/plan/successful update, two releases + three backups, Cargo safety/symlink guards, managed-update flush wiring.")
if __name__ == "__main__":
main()
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env node
// 0.31.7 DOM/source + executable revision-diff tests (no Rust runtime).
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const read = path => fs.readFileSync(path, 'utf8');
const html = read('src/admin/web/index.html');
const css = read('src/admin/web/style.css');
const settings = read('src/admin/web/settings.js');
const hist = read('src/admin/web/app/config-history.js');
const management = read('src/admin/web/management.js');
const listener = read('src/admin/web/integrations.js');
const router = read('src/admin/router.rs');
const core = read('src/admin/web/vhosts/core.js');
const editor = read('src/admin/web/vhosts/editor.js');
const rates = read('src/admin/web/vhosts/rates.js');
const model = read('src/rate_limit.rs');
const api = JSON.parse(read('src/admin/openapi.json'));
assert.match(html, /data-settings-tab="policies"/);
for (const name of ['user-agent', 'headers', 'response']) {
assert.match(html, new RegExp(`data-policy-settings-tab="${name}"`));
assert.match(html, new RegExp(`data-policy-settings-page="${name}"`));
}
for (const form of ['global-ua-block-form', 'headers-form', 'response-form']) {
assert.equal(html.split(`id="${form}"`).length, 2, `${form} must be unique`);
}
assert.match(settings, /selectPolicyTab\("user-agent"\)/);
assert.match(html, /id="config-history-json-dialog"/);
assert.match(html, /id="config-history-compare-open"/);
const actionStart = html.indexOf('<div class="config-history-detail-actions">');
const actionEnd = html.indexOf('</div>', actionStart);
const restorePos = html.indexOf('id="config-history-restore"', actionStart);
const comparePos = html.indexOf('id="config-history-compare-open"', actionStart);
assert.ok(actionStart > -1 && restorePos > actionStart && comparePos > restorePos && comparePos < actionEnd,
'JSON compare button must appear directly under restore inside the same action group');
assert.equal(html.split('id="config-history-compare-open"').length, 2);
assert.match(html, /id="config-json-compare-title"[\s\S]*?<div class="window-tools">[\s\S]*?id="config-history-json-close"/);
assert.match(css, /dialog\.config-history-dialog,\s*dialog\.config-history-json-dialog\s*\{/);
assert.match(css, /\.config-history-dialog\[open\]\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/);
assert.match(css, /\.config-history-diff\s*\{[^}]*overflow-y:\s*auto/);
assert.match(css, /\.config-history-layout\s*\{[^}]*overflow:\s*hidden/);
assert.match(css, /@media \(max-width:\s*860px\)\s*\{[\s\S]*?\.config-history-layout\s*\{[^}]*overflow-y:\s*auto/);
assert.match(css, /\.config-history-json-columns pre\s*\{[^}]*border:\s*0/);
assert.match(hist, /"config-history-json-before"\)\.textContent/);
assert.match(hist, /"config-history-json-after"\)\.textContent/);
assert.match(css, /\.listener-protocol-settings-actions\s*\{[^}]*gap:\s*14px/);
assert.match(listener, /listener-protocol-settings-actions/);
assert.match(management, /el\("details", undefined, "acl-profile-attached"\)/);
assert.doesNotMatch(management, /attached\.open\s*=\s*true/);
assert.match(router, /include_str!\("web\/vhosts\/rates\.js"\)/);
assert.match(editor, /user_agent_limits: uaRatesReader\(\)/);
assert.match(editor, /userAgentRateEditor\(g, rate\.user_agent_limits \|\| \[\]\)/);
assert.match(core, /uaRatesReader/);
assert.match(rates, /function userAgentRateEditor/);
assert.match(model, /pub user_agent_limits: Vec<UserAgentLimit>/);
assert.ok(api.components.schemas.VhostRateLimit.properties.user_agent_limits);
const start = hist.indexOf('function configDiff('), end = hist.indexOf('function renderConfigRevisionDetail(', start);
assert.ok(start > 0 && end > start, 'executable diff function must exist');
const context = { before: null, after: null };
vm.createContext(context);
vm.runInContext(`${hist.slice(start, end)}\nglobalThis.diffResult = () => configDiff(before, after);`, context);
const diff = (before, after) => { context.before=before; context.after=after; return JSON.parse(JSON.stringify(context.diffResult())); };
let result = diff({port:80, x:{a:true, b:2}, vhosts:[{id:'v1', host:'a'},{id:'v2', host:'b'}]},
{port:443, x:{a:true, b:3}, vhosts:[{id:'v2', host:'b'},{id:'v1', host:'a'}]});
assert.deepEqual(result.map(x => x.path), ['$["port"]','$["x"]["b"]']);
result = diff({a:1, headers:[]}, {a:1, headers:[{id:'one',value:'ok'}], policy:true});
assert.deepEqual(result.map(x => x.path), ['$["headers"]["one"]["id"]','$["headers"]["one"]["value"]','$["policy"]']);
result = diff({items: [{id:'one', active: true}]}, {items: [{id:'one',active:false}]});
assert.equal(result.length,1);
assert.equal(result[0].path,'$["items"]["one"]["active"]');
assert.ok(diff({}, Object.fromEntries(Array.from({length:350},(_,i) => [i, i]))).length <= 200);
console.log('PASS: 0.31.7 Settings sections, vhost UA rate editor/API, ACL/listener layout, changed-field revision diff, bounded scrolling, conventional full JSON modal.');
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env python3
"""0.31.9 source-only regression; does NOT claim Rust compilation or memory profiling."""
from pathlib import Path
import json
import re
import tomllib
from html.parser import HTMLParser
ROOT = Path(__file__).resolve().parents[2]
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.7'
assert 'name = "rust-proxy"\nversion = "0.32.7"' in read('Cargo.lock')
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.7'
pages = read('src/pages.rs')
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
file = f'src/admin/web/error_{code}.html'
markup = read(file)
assert markup.lstrip().startswith('<!doctype html>'), file
assert 'background: #131615' in markup and 'max-width: 620px' in markup, file
assert re.search(rf'<h1>{code} - [^<]+</h1>', markup), file
assert f'include_str!("admin/web/error_{code}.html")' in pages, file
assert 'status_200.html' in pages and '<h1>200 - OK</h1>' in read('src/admin/web/status_200.html')
assert 'RustProxy generated this response.' in pages and 'background: #131615' in pages
assert 'matches!(status, StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED)' in pages
geoip = read('src/geoip.rs')
assert 'ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]>' in geoip
assert 'ipv4.into_boxed_slice()' in geoip and 'ipv6.into_boxed_slice()' in geoip
assert 'start: [u8; 16]' in geoip and 'end: [u8; 16]' in geoip
assert 'u128::from(ip).to_be_bytes()' in geoip and 'ip <= item.end' in geoip
assert 'size_of::<V6Range>(), 34' in read('src/geoip/tests.rs')
cached = read('src/cache/fill.rs') + read('src/cache/lookup/tiered.rs')
for marker in ('bytes.try_into_mut()', 'mutable.capacity()', 'candidate.shared = Some(bytes.clone())',
'shared_allocated', 'candidate.fill_gauge.grow(allocation', 'if transformed { 0 }',
'candidate.data.extend_from_slice(data)'):
assert marker in cached, marker
q = read('src/admin/file_logs.rs') + read('src/admin/file_logs_query.rs')
for marker in ('sort_by: Option<String>', 'sort_descending: Option<bool>', 'valid_raw_field_key(field)',
'fn sort_file_log_records(', 'stored_log_sort_key(&record, field)',
'file_log_field(record, field)', 'None, Some(_)', 'sort_file_log_records(&mut records'):
assert marker in q, marker
assert q.index('sort_file_log_records(&mut records') < q.index('let page = records')
print('PASS: 0.31.9 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).')
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env node
// JS-only regression of the Stored Logs sorting interaction (no Rust server).
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const read = name => fs.readFileSync(name, 'utf8');
const ui = read('src/admin/web/file-logs.js');
const css = read('src/admin/web/style.css');
const api = JSON.parse(read('src/admin/openapi.json'));
const params = api.paths['/api/file-logs/query'].get.parameters.map(param => param.name);
assert.ok(params.includes('sort_by') && params.includes('sort_descending'));
assert.ok(api.components.schemas.AdminPreferences.properties.file_log_sort);
assert.ok(api.components.schemas.AdminPreferences.properties.file_log_descending);
assert.match(ui, /params\.set\("sort_by", sortBy\)/);
assert.match(ui, /params\.set\("sort_descending", String\(sortDescending\)\)/);
assert.match(css, /file-log-sort-button/);
class FakeNode {
constructor(tag, text = '', cls = '') { this.tag = tag; this.textContent = text; this.className = cls; this.children = []; this.dataset = {}; this.attrs = {}; this.events = {}; }
setAttribute(name, value) { this.attrs[name] = value; }
append(...nodes) { this.children.push(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
addEventListener(name, handler) { this.events[name] = handler; }
click() { this.events.click?.(); }
}
const row = new FakeNode('tr');
const sort = ui.slice(ui.indexOf(' function syncHeaders() {'), ui.indexOf(' function syncColumnPicker()'));
assert.ok(sort.startsWith(' function syncHeaders() {') && sort.includes('file-log-sort-button'));
const ctx = {
el: (tag, text, cls) => new FakeNode(tag, text, cls),
$: () => ({querySelector: () => row}),
orderedColumns: () => ['time', 'status'],
visibleColumns: new Set(['time', 'status']),
specialLabels: new Map([['time', 'time'], ['status', 'status']]),
columnLabel: id => id,
t: key => key,
sortBy: 'time', sortDescending: true, selectedIndex: 3, offset: 100,
saved: 0, refreshed: 0,
savePreferences() { this.saved++; },
refresh() { this.refreshed++; },
};
// Methods capture explicit counters rather than relying on vm's `this` binding.
ctx.savePreferences = () => { ctx.saved++; };
ctx.refresh = reset => { if (reset) ctx.refreshed++; };
vm.createContext(ctx);
vm.runInContext(`${sort}\nglobalThis.draw = syncHeaders;`, ctx);
ctx.draw();
assert.equal(row.children.length, 2);
assert.equal(row.children[0].attrs['aria-sort'], 'descending');
assert.equal(row.children[1].attrs['aria-sort'], 'none');
row.children[1].children[0].click();
assert.equal(ctx.sortBy, 'status');
assert.equal(ctx.sortDescending, false);
assert.equal(ctx.offset, 0);
assert.equal(ctx.saved, 1);
assert.equal(ctx.refreshed, 1);
assert.equal(row.children[1].attrs['aria-sort'], 'ascending');
row.children[1].children[0].click();
assert.equal(ctx.sortDescending, true);
assert.equal(row.children[1].attrs['aria-sort'], 'descending');
assert.equal(ctx.saved, 2);
assert.equal(ctx.refreshed, 2);
console.log('PASS: 0.31.9 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.');
File diff suppressed because it is too large. Load diff
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""Static structure contracts for the split Rust source tree.
These checks are intentionally compiler-independent. They prevent the module split
from regressing into large implementation monoliths, allow full-sized test files,
and catch the class of dangling-attribute/broken-include mistakes that can otherwise
surface only at cargo check time.
"""
from __future__ import annotations
from pathlib import Path
import re
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "src"
MAX_LINES = 500
def is_test_source(path: Path) -> bool:
relative = path.relative_to(ROOT)
if relative.parts[0] == "tests":
return True
return relative.parts[0] == "src" and (
path.name == "tests.rs" or path.stem.endswith("_tests") or "tests" in relative.parts[1:]
)
include_re = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)')
test_attr_re = re.compile(r'(?m)^\s*#\[(?:tokio::)?test(?:\([^\]]*\))?\]')
dangling_doc_before_close_re = re.compile(r'(?ms)(?:^\s*///[^\n]*\n){1,8}\s*}')
dangling_attr_before_close_re = re.compile(r'(?ms)^\s*#\[[^\n]+\]\s*\n\s*}')
def delimiters_balanced(text: str) -> bool:
stack: list[str] = []
pairs = {"}": "{", ")": "(", "]": "["}
i = 0
state = "code"
block_depth = 0
raw_hashes = 0
while i < len(text):
char = text[i]
nxt = text[i + 1] if i + 1 < len(text) else ""
if state == "code":
if char == "/" and nxt == "/":
state = "line"; i += 2; continue
if char == "/" and nxt == "*":
state = "block"; block_depth = 1; i += 2; continue
if char == '"':
state = "string"; i += 1; continue
if char == "r":
raw = re.match(r'r(#{0,16})"', text[i:])
if raw:
raw_hashes = len(raw.group(1)); state = "raw"; i += len(raw.group(0)); continue
if char == "'":
# Lifetimes are identifiers; only enter char mode when a nearby closing quote exists.
cursor = i + 1
closing = False
while cursor < min(len(text), i + 8):
if text[cursor] == "\\": cursor += 2; continue
if text[cursor] == "'": closing = True; break
if text[cursor] in "\r\n": break
cursor += 1
if closing:
state = "char"; i += 1; continue
if char in "({[": stack.append(char)
elif char in "})]":
if not stack or stack[-1] != pairs[char]: return False
stack.pop()
i += 1
elif state == "line":
if char == "\n": state = "code"
i += 1
elif state == "block":
if char == "/" and nxt == "*": block_depth += 1; i += 2
elif char == "*" and nxt == "/":
block_depth -= 1; i += 2
if block_depth == 0: state = "code"
else: i += 1
elif state == "string":
if char == "\\": i += 2
elif char == '"': state = "code"; i += 1
else: i += 1
elif state == "char":
if char == "\\": i += 2
elif char == "'": state = "code"; i += 1
else: i += 1
elif state == "raw":
terminator = '"' + "#" * raw_hashes
if text.startswith(terminator, i): i += len(terminator); state = "code"
else: i += 1
return not stack and state in {"code", "line"}
# The 500-line rule applies to production Rust sources, not unit/integration tests.
for path in sorted(ROOT.rglob("*.rs")):
if any(part in {"target", ".git"} for part in path.parts):
continue
if is_test_source(path):
continue
lines = path.read_text(encoding="utf-8").splitlines()
assert len(lines) <= MAX_LINES, f"{path.relative_to(ROOT)} has {len(lines)} lines (max {MAX_LINES})"
for path in sorted(SRC.rglob("*.rs")):
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
assert delimiters_balanced(text), f"unbalanced delimiters in split Rust source: {path.relative_to(ROOT)}"
nonblank = [line.strip() for line in lines if line.strip()]
if nonblank:
assert not nonblank[-1].startswith(("#[", "///", "//!")), f"dangling Rust attribute/doc comment at EOF: {path.relative_to(ROOT)}"
assert not dangling_doc_before_close_re.search(text), f"doc comment before closing brace does not document an item: {path.relative_to(ROOT)}"
assert not dangling_attr_before_close_re.search(text), f"attribute before closing brace does not decorate an item: {path.relative_to(ROOT)}"
for target in include_re.findall(text):
included = path.parent / target
assert included.is_file(), f"broken include! in {path.relative_to(ROOT)}: {target}"
included_text = included.read_text(encoding="utf-8")
# include! expands into the caller's existing module/item scope. Inner module docs
# (`//!`) in a split fragment are therefore invalid once another item precedes them.
assert not re.search(r'(?m)^\s*//!', included_text), (
f"inner module doc comment in include! fragment: {included.relative_to(ROOT)}"
)
if not is_test_source(path):
assert not test_attr_re.search(text), f"unit test remains in implementation source: {path.relative_to(ROOT)}"
# Test-only implementation must live in a separate file and be pulled in explicitly.
for match in re.finditer(r'(?m)^\s*#\[cfg\(test\)\]\s*$', text):
tail = text[match.end():].lstrip()
assert tail.startswith("include!(") or tail.startswith("mod tests;"), (
f"test cfg in implementation must target a separate test file: {path.relative_to(ROOT)}"
)
# Unit tests are intentionally consolidated rather than mechanically numbered.
for folder in (
"admin/tests", "balance/tests", "config/tests", "web_server/tests",
"cache/tests/tests", "cache/tests/v022_cache_tests", "cache/tests/cold_tier_tests",
):
assert not (SRC / folder).exists(), f"obsolete split-test directory: {folder}"
for relative in (
"admin/tests.rs", "balance/tests.rs", "config/tests.rs", "web_server/tests.rs",
"cache/tests/tests.rs", "cache/tests/v022_cache_tests.rs", "cache/tests/cold_tier_tests.rs",
):
assert (SRC / relative).is_file(), f"missing consolidated test file: {relative}"
assert 'include_str!("web/login-keys.json")' in (SRC / "admin/tests.rs").read_text(), (
"admin test asset path must be relative to the consolidated test file"
)
assert (SRC / "admin/web/login-keys.json").is_file()
# Explicit regressions from the first Web Server split.
response = (SRC / "web_server/response.rs").read_text(encoding="utf-8")
assert not response.rstrip().endswith("#[derive(Debug, Clone)]"), "dangling derive returned in Web Server response module"
fastcgi = rust_source(ROOT, "src/web_server/fastcgi.rs")
request = (SRC / "web_server/request.rs").read_text(encoding="utf-8")
assert "use anyhow::{Context" in fastcgi or "Context," in fastcgi or ", Context" in fastcgi
assert "htaccess," in fastcgi
assert "request::server_protocol" in fastcgi
assert "pub(super) fn server_protocol" in request
headers = (SRC / "web_server/response/headers.rs").read_text(encoding="utf-8")
assert "htaccess," in headers, "Web Server response headers must import sibling htaccess after module split"
assert "ACCEPT_ENCODING," not in headers, "stale ACCEPT_ENCODING import reintroduced in response headers"
# Regressions exposed by a real `cargo check --locked --all-targets` after the 0.25.1 split.
assert "pub(super) trait FastCgiIo" in fastcgi, "FastCgiIo visibility must match sibling-visible FastCGI helpers"
for relative in ("src/proxy/websocket/tests.rs", "tests/http3.rs"):
text = (ROOT / relative).read_text(encoding="utf-8")
assert 'parse::<h3::ext::Protocol>().unwrap()' not in text, (
f"{relative} uses Result::unwrap() for h3::ext::Protocol even though InvalidProtocol has no Debug"
)
server_tests = (SRC / "server/serve/tests.rs").read_text(encoding="utf-8")
assert server_tests.count("serve_inner(") == server_tests.count("Http2Connections::default()"), (
"direct serve_inner test calls must pass the Http2Connections argument"
)
cache_policy_tests = (SRC / "cache/tests/tests.rs").read_text(encoding="utf-8")
# Older cache tests legitimately use `let plan = plan(...)`. The regression
# applies to the Vary/revalidation group formerly stored in part_03.rs.
revalidation_tests = cache_policy_tests.split(
"async fn vary_selects_variants_without_fragmenting_the_primary_hash", 1
)[1]
assert "let plan = plan(" not in revalidation_tests, "cache policy test shadows the plan() function"
cold_tests = (SRC / "cache/tests/cold_tier_tests.rs").read_text(encoding="utf-8")
for field in ("scheme:", "authority:", "target:", "tags:", "created_at:"):
assert field in cold_tests, f"ColdEntry test fixture is missing current field {field}"
admin_tests = (SRC / "admin/tests.rs").read_text(encoding="utf-8")
assert "let configuration = router\n .clone()\n .oneshot(" in admin_tests, (
"admin persistence test must retain Router before Tower::oneshot consumes a clone"
)
web_tests_1 = (SRC / "web_server/tests.rs").read_text(encoding="utf-8")
assert "ETAG, EXPIRES," not in web_tests_1, "unused EXPIRES import returned in Web Server tests"
print(f"PASS: production Rust modules are <= {MAX_LINES} lines (tests exempt), numbered test fragments are absent, include! targets resolve, and module regressions are guarded.")
-43
View File
@@ -1,43 +0,0 @@
"""Helpers for source-level contracts over split Rust modules.
The project intentionally keeps Rust source files small and composes several modules
with include!("..."). Static contracts must inspect the logical source, not only the
thin facade file.
"""
from __future__ import annotations
from pathlib import Path
import re
_INCLUDE = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)\s*;?')
def expanded_source(path: Path, _stack: tuple[Path, ...] = ()) -> str:
path = path.resolve()
if path in _stack:
chain = " -> ".join(str(item) for item in (*_stack, path))
raise RuntimeError(f"cyclic Rust include!: {chain}")
text = path.read_text(encoding="utf-8")
def replace(match: re.Match[str]) -> str:
child = (path.parent / match.group(1)).resolve()
if not child.is_file():
raise FileNotFoundError(f"{path}: include target does not exist: {match.group(1)}")
return expanded_source(child, (*_stack, path))
return _INCLUDE.sub(replace, text)
def rust_source(root: Path, relative: str) -> str:
"""Read one logical Rust source file, recursively expanding include! fragments."""
return expanded_source(root / relative)
def rust_tree(root: Path, relative_dir: str) -> str:
"""Read every Rust file beneath a module directory (including mod-declared children)."""
directory = root / relative_dir
return "\n".join(
expanded_source(path)
for path in sorted(directory.rglob("*.rs"))
if path.is_file()
)
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env python3
"""Source-level security invariants that must hold even before Rust runtime tests."""
from __future__ import annotations
from pathlib import Path
from rust_source import rust_source, rust_tree
ROOT = Path(__file__).resolve().parents[2]
def read(path: str) -> str:
target = ROOT / path
return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8")
def need(text: str, *needles: str) -> None:
for needle in needles:
if needle not in text:
raise AssertionError(f"missing security invariant: {needle}")
web = rust_tree(ROOT, "src/web_server")
htaccess = read("src/web_server/htaccess.rs")
acl = read("src/acl.rs")
admin = read("src/admin/mod.rs")
auth = read("src/auth/mod.rs")
web_ui = read("src/admin/web/web-server.js")
# Preset regression: selecting a preset must actually materialize normal config fields.
need(web_ui,
"function presetChanged()",
'if ($("web-service-preset").value !== "custom") applyPreset();',
'$("web-service-preset").addEventListener("change", presetChanged);')
# Request target/path canonicalization: reject double decoding, encoded separators and dot traversal.
need(acl,
"ambiguous encoded path character",
"ambiguous path segments",
"!matches!(decoded, b'/' | b'\\\\' | b'%' | b'?' | b'#' | b';')",
"path.len() <= 8192")
# FastCGI must not inherit client proxy settings (httpoxy) and must not control HTTP framing.
need(web,
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
'if name.as_str() == "proxy"',
'"content-length"',
'"transfer-encoding"',
'"connection"',
'"upgrade"',
'"proxy-authenticate"',
'"proxy-authorization"',
"(200..=599).contains(&code)",
"unsupported informational/invalid status",
"reserved_fastcgi_param",
'"PHP_ADMIN_VALUE"',
"pass_authorization",
"max_request_body_bytes",
"StatusCode::PAYLOAD_TOO_LARGE",
"max_response_header_bytes",
"stderr_log_bytes")
# File-system confinement and .htaccess loading must not follow attacker-controlled symlinks.
need(web, "libc::openat", "libc::O_NOFOLLOW", "secure filesystem confinement")
need(htaccess,
"read_htaccess_nofollow",
"libc::O_NOFOLLOW",
"fs::symlink_metadata",
".htaccess scan escaped the Web Service document root",
"must not be a symlink",
"MAX_HTACCESS_FILES",
"MAX_HTACCESS_TOTAL_BYTES")
need(web, "htaccess_configuration_file_cannot_be_a_symlink")
# PHP source must fail closed instead of becoming a static download when FPM is unavailable.
need(web,
"php_source_extensions_are_not_served_without_php_fpm",
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
"fastcgi_never_forwards_proxy_header_as_http_proxy",
"fastcgi_response_cannot_control_downstream_framing_or_hop_headers",
"fastcgi_response_rejects_informational_status_codes",
"php_custom_fastcgi_params_cannot_override_core_or_php_admin_values",
"php_request_body_limit_returns_413_before_fastcgi_connect")
# Admin plane: exact anonymous allowlist + cookie/CSRF/origin/browser marker + CSP/frame denial.
need(admin,
"fn anonymous_allowed",
'single_header(headers, "origin")',
'single_header(headers, "x-rustproxy-request") != Some("1")',
'single_header(headers, "x-csrf-token")',
'"auth.csrf"',
"frame-ancestors 'none'",
"object-src 'none'",
"anonymous_operational_paths_fail_closed_before_extractors",
"duplicate_csrf_or_marker_is_rejected_and_session_survives")
need(auth,
"HttpOnly; SameSite=Strict",
'if secure { "; Secure" } else { "" }',
"Argon2id",
"(12..=128).contains(&value.len())")
# Web serving/admin code must not execute shell commands as part of request/config handling.
for path, text in {
"src/web_server/*.rs": web,
"src/web_server/htaccess.rs": htaccess,
"src/admin/mod.rs": admin,
}.items():
if "Command::new" in text or "std::process::Command" in text:
raise AssertionError(f"unexpected process execution primitive in {path}")
print("PASS: production security source invariants (paths, FastCGI, htaccess, admin auth/CSRF, no shell execution) are present.")
@@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""Offline minimum-version gates for network-facing RustSec advisories reviewed 2026-10-04.
This is deliberately not a replacement for `cargo audit`: it prevents known-fixed
packages from being accidentally downgraded when the lockfile changes.
"""
from __future__ import annotations
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[2]
LOCK = (ROOT / "Cargo.lock").read_text(encoding="utf-8")
def versions() -> dict[str, list[str]]:
found: dict[str, list[str]] = {}
for block in LOCK.split("[[package]]")[1:]:
match = re.search(r'\n?name = "([^"]+)"\nversion = "([^"]+)"', block)
if match:
found.setdefault(match.group(1), []).append(match.group(2))
return found
def triplet(value: str) -> tuple[int, int, int]:
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", value)
if not match:
raise AssertionError(f"unsupported version syntax: {value}")
return tuple(map(int, match.groups()))
packages = versions()
minimums = {
# RUSTSEC-2026-0285
"rustls": (0, 23, 45),
# RUSTSEC-2026-0185 and RUSTSEC-2026-0037
"quinn-proto": (0, 11, 15),
# RUSTSEC-2026-0104 (also covers 0049/0098/0099)
"rustls-webpki": (0, 103, 13),
# RUSTSEC-2025-0009
"ring": (0, 17, 12),
# RUSTSEC-2024-0363
"sqlx": (0, 8, 1),
# RUSTSEC-2026-0258
"h2": (0, 4, 16),
# RUSTSEC-2026-0007
"bytes": (1, 11, 1),
# RUSTSEC-2026-0009
"time": (0, 3, 47),
}
for package, minimum in minimums.items():
locked = packages.get(package)
if not locked:
raise AssertionError(f"expected security-critical dependency {package} in Cargo.lock")
for value in locked:
if triplet(value) < minimum:
raise AssertionError(f"{package} {value} is below reviewed patched minimum {'.'.join(map(str, minimum))}")
# 2026 malicious-crate campaign snapshot reviewed against RustSec on 2026-10-04.
# Keep the exact arrayref version gate because earlier arrayref releases are legitimate.
for value in packages.get("arrayref", []):
if value == "0.3.10":
raise AssertionError("Cargo.lock contains compromised arrayref 0.3.10")
malicious_packages = {
"greentic-setup", "greentic-setup-dev", "append-only-vec", "proc-macro1",
"internment", "proc-macro-en", "onering", "logflux", "exploration",
"sui-execution-cut", "mysten-metrics", "microsoftsystem64",
"pretty-changelog-logger", "safe-agent-rs", "logtrace", "jfrog_quotes",
"tree-sitter-pkl", "custom-req-on-workers", "sophosfirewall-python",
"statsrelay-protobuf", "time_calibrator", "tracings", "tracing_checks",
"rpc-check", "tracing-check", "clob-sdk", "polymarkets-rs-clob-client",
"polymarkets-client-sdk", "polymarket-client-sdks", "finch-rst",
}
present = sorted(malicious_packages.intersection(packages))
if present:
raise AssertionError(f"Cargo.lock contains RustSec-listed malicious package(s): {', '.join(present)}")
print("PASS: Cargo.lock stays above reviewed RustSec minimums and excludes the reviewed 2026 malicious-crate set (snapshot 2026-10-04).")
@@ -1,71 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const read = path => fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8');
const json = path => JSON.parse(read(path));
const manifest = read('Cargo.toml');
const lib = read('src/lib.rs');
const model = read('src/config/model.rs');
const vhost = read('src/vhost/model.rs');
const forward = read('src/proxy/forward_entry.rs');
const loggingApi = read('src/admin/logging_api.rs');
const ua = read('src/user_agent.rs');
const stored = read('src/admin/file_logs.rs') + read('src/admin/file_logs_query.rs') + read('src/admin/file_log_search.rs');
const html = read('src/admin/web/index.html');
const core = read('src/admin/web/app/core.js');
const editor = read('src/admin/web/vhosts/editor.js');
const traffic = read('src/admin/web/traffic-panel.js');
const fileLogs = read('src/admin/web/file-logs.js');
const openapi = json('src/admin/openapi.json');
const en = json('langs/en_US.json').translations;
const pl = json('langs/pl_pl.json').translations;
assert.match(manifest, /version = "0\.32\.6"/);
assert.match(lib, /pub mod user_agent;/);
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
assert.match(loggingApi, /"user-agent-blocking"/);
assert.match(ua, /MAX_PATTERNS: usize = 128/);
assert.match(ua, /candidate\.contains\(pattern\)/);
assert.match(ua, /pattern\.contains\('\*'\)/);
const denyPos = forward.indexOf('denied by User-Agent policy');
const redirectPos = forward.indexOf('let redirect = snapshot.redirect_on');
assert.ok(denyPos > 0 && redirectPos > denyPos, 'UA policy must run before redirect/origin processing');
assert.match(html, /id="global-ua-block-form"/);
assert.match(core, /saveSection\("user-agent-blocking"/);
assert.match(editor, /"ua-block-patterns"/);
assert.match(traffic, /traffic-copy-value/);
assert.match(traffic, /navigator\.clipboard\?\.writeText/);
assert.match(html, /id="file-log-consolidate-vhosts"/);
assert.match(html, /id="file-log-aggregate-form"/);
assert.match(stored, /group_id/);
assert.match(stored, /consolidate_vhosts/);
assert.match(stored, /aggregate_field/);
assert.match(stored, /FILE_LOG_MAX_AGGREGATE_VALUES: usize = 4096/);
assert.match(fileLogs, /member_count/);
assert.match(fileLogs, /rp-file-logs-consolidate-vhosts/);
assert.match(fileLogs, /localStorage\.getItem\(CONSOLIDATION_STORAGE_KEY\)/);
assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
assert.match(fileLogs, /aggregate_min_count/);
assert.match(fileLogs, /file-log-aggregate-copy/);
assert.equal(openapi.info.version, '0.32.7');
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
const params = openapi.paths['/api/file-logs/query'].get.parameters.map(item => item.name);
for (const name of ['consolidate_vhosts', 'aggregate_field', 'aggregate_min_count', 'aggregate_limit']) assert.ok(params.includes(name), name);
const sections = openapi.paths['/api/config/{section}'].put.parameters.find(item => item.name === 'section').schema.enum;
assert.ok(sections.includes('user-agent-blocking'));
for (const pack of [en, pl]) {
for (const key of ['globalUaBlockingTitle', 'vhUaBlockEnabled', 'fileLogsConsolidateVhosts', 'fileLogsAdvancedParser', 'copied', 'copyValue']) {
assert.equal(typeof pack[key], 'string', key);
assert.ok(pack[key].length > 0, key);
}
}
console.log('PASS: 0.31.7 UA blocking, copy controls, repeated-value parser, vhost log consolidation, OpenAPI and locales.');
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env python3
"""Compiler-independent source-size and test-isolation contracts for non-Rust modules."""
from __future__ import annotations
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
MAX_LINES = 500
def lines(path: Path) -> int:
return len(path.read_text(encoding="utf-8").splitlines())
# Browser implementation modules are bounded. QA harnesses under tools/checks are checked separately.
for path in sorted((ROOT / "src/admin/web").rglob("*.js")):
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
# Docker Agent implementation and Go tests are separate files and all remain bounded.
for path in sorted((ROOT / "docker-agent").glob("*.go")):
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
if not path.name.endswith("_test.go"):
text = path.read_text(encoding="utf-8")
assert not re.search(r"(?m)^func\s+Test[A-Z]", text), f"Go test remains in implementation file: {path.relative_to(ROOT)}"
# Application-coupled Python entry points are split from external QA/dev tooling.
for directory in ("scripts/runtime", "scripts/build", "scripts/deploy", "scripts/migration"):
for path in sorted((ROOT / directory).glob("*.py")):
assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})"
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/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/protocol.js", "src/admin/web/vhosts/rates.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",
"scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py",
"scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py",
):
assert (ROOT / required).is_file(), f"missing split source module: {required}"
# When Node is available, parse every standalone browser module and both assembled public bundles.
node = shutil.which("node")
if node:
env = dict(os.environ, TERM=os.environ.get("TERM", "xterm"))
standalone = list((ROOT / "src/admin/web").rglob("*.js"))
for path in sorted(standalone):
subprocess.run([node, "--check", str(path)], check=True, env=env, stdout=subprocess.DEVNULL)
from web_assets import read_app_js, read_vhosts_js
browser_markup = (ROOT / "src/admin/web/index.html").read_text(encoding="utf-8")
assert browser_markup.count('<script defer="" src="/assets/') == 1, "one private JS bundle prevents module race"
assert '/assets/app___RUSTPROXY_ASSET_ID__.js' in browser_markup
with tempfile.TemporaryDirectory(prefix="rustproxy-js-contract-") as directory:
temporary = Path(directory)
for name, content in (("app.js", read_app_js(ROOT)), ("vhosts.js", read_vhosts_js(ROOT))):
target = temporary / name
target.write_text(content, encoding="utf-8")
subprocess.run([node, "--check", str(target)], check=True, env=env, stdout=subprocess.DEVNULL)
# The public vhosts asset wraps three individually valid source modules in one private IIFE.
# Every source part is parsed above; this additional check keeps the assembled browser asset valid.
print(f"PASS: browser, Docker Agent and application-coupled Python modules are <= {MAX_LINES} lines, tooling is separated, tests are isolated, and browser source boundaries parse cleanly.")
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env python3
"""Verify removed history is optional, but live embedded documents remain required."""
from __future__ import annotations
from pathlib import Path
import shutil
import sys
from tempfile import TemporaryDirectory
from preflight import check
ROOT = Path(__file__).resolve().parents[2]
# A compact 0.31.7 source tree may omit historical version-labelled documents.
# It must still pass the same preflight used by every ./dev.sh command.
assert check(ROOT) == []
with TemporaryDirectory(prefix="rustproxy-pruned-source-") as tmp:
stripped = Path(tmp) / "RustProxy"
shutil.copytree(
ROOT, stripped,
ignore=shutil.ignore_patterns(".git", "target", "data", ".upgrade-backup", "__pycache__"),
)
# This functional document is in the Reader registry, but intentionally not
# in the legacy hand-maintained required-file list. It must still be caught.
reader_doc = stripped / "docs/REFERENCES.md"
assert reader_doc.is_file(), "Missing test fixture: docs/REFERENCES.md"
reader_doc.unlink()
try:
check(stripped)
except ValueError as error:
assert "Missing embedded Reader document: docs/REFERENCES.md" in str(error), error
else:
raise AssertionError("Preflight accepted missing compile-time embedded document")
shutil.copy2(ROOT / "docs/REFERENCES.md", reader_doc)
required_module = stripped / "src/cache/mod.rs"
required_module.unlink()
try:
check(stripped)
except ValueError as error:
assert "missing src/cache/mod.rs" in str(error), error
else:
raise AssertionError("Preflight accepted missing runtime source")
print("PASS: pruned historical documents are optional; embedded Reader files and runtime sources are mandatory.")
-103
View File
@@ -1,103 +0,0 @@
// Verify the real opt-in Stream Dashboard component against a lightweight DOM.
// No server, browser layout engine or Rust executable is exercised here.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const src = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
const preferencesSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
assert(!/\b(setInterval|setTimeout|fetch|XMLHttpRequest)\s*\(/.test(src), 'widgets must not create timers/fetches');
const cards = new Map(ids.map(id => [id, { hidden: true }]));
const elements = new Map();
for (const name of ['overview', 'overview-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'traffic', 'traffic-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
elements.set(`stream-dashboard-${name}`, { textContent: '-', attrs: new Map(), setAttribute(k, v) { this.attrs.set(k, v); } });
}
const preferences = { dashboard_hidden_widgets: [...ids] };
const window = { rustProxyPreferences: { current: () => preferences } };
let clock = 1000;
const scriptContext = { window, document: { querySelector(selector) {
const match = selector.match(/^\[data-dashboard-widget="([^\"]+)"\]$/);
return match ? cards.get(match[1]) || null : null;
} }, $: name => elements.get(name), ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: ids },
envelope: { config: { streams: [
{ id: 'tcp', enabled: true, protocol: 'tcp' },
{ id: 'udp', enabled: true, protocol: 'udp' }
] } }, latestStatus: null, currentPanel: 'overview',
performance: { now: () => clock }, t: key => key, fmt: n => `${n}`, bytes: n => `${n} B`, Math,
};
vm.runInNewContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8') + '\n' + src, scriptContext);
const widget = window.rustProxyStreamDashboard;
const status = { streams: { active: [
{ id: 'tcp', enabled: true, protocol: 'tcp' },
{ id: 'udp', enabled: true, protocol: 'udp' }
], metrics: {
tcp: { active: 3, bytes_in: 500, bytes_out: 1000, backends: [
{ id: 'a', enabled: true, drain: false, status: 'healthy' },
{ id: 'b', enabled: true, drain: false, status: 'unhealthy' }
] },
udp: { active: 2, bytes_in: 100, bytes_out: 200, backends: [
{ id: 'a', enabled: true, drain: false, status: 'unverified' }
] }
} } };
scriptContext.latestStatus = status;
widget.render(status);
assert(ids.every(id => cards.get(id).hidden), 'new widgets are hidden by default');
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'hidden chart must not sample');
preferences.dashboard_hidden_widgets = [];
widget.render(status);
assert(ids.every(id => !cards.get(id).hidden));
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5');
assert.equal(elements.get('stream-dashboard-overview').textContent, '2 / 2');
assert.equal(elements.get('stream-dashboard-health').textContent, '1 / 1');
clock += 1000;
status.streams.metrics.tcp.bytes_in += 1024;
status.streams.metrics.tcp.bytes_out += 512;
widget.render(status);
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '1024 B/s');
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '512 B/s');
assert(elements.get('stream-dashboard-rx').attrs.get('points')?.includes(','));
preferences.dashboard_hidden_widgets = ['stream_traffic'];
widget.syncVisibility();
assert(cards.get('stream_traffic').hidden);
const previous = elements.get('stream-dashboard-rx').attrs.get('points');
clock += 1000;
widget.render(status);
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), previous, 'hidden chart must not update');
// Explicitly enabled cards remain visible even without active/configured streams.
// The user should see a neutral state, not a widget that ignores Appearance.
preferences.dashboard_hidden_widgets = [];
scriptContext.envelope.config.streams = [];
scriptContext.latestStatus = { streams: { active: [], metrics: {} } };
widget.render(scriptContext.latestStatus);
assert(ids.every(id => !cards.get(id).hidden), 'appearance must control Stream card visibility even with no listeners');
for (const name of ['overview', 'sessions', 'health', 'traffic']) {
assert.equal(elements.get(`stream-dashboard-${name}`).textContent, '-');
if (name !== 'traffic') assert.equal(elements.get(`stream-dashboard-${name}-detail`).textContent, 'streamWidgetUnavailable');
}
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '-');
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '-');
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), '0,55 300,55', 'empty status must clear the graph');
// A desired but not yet running Stream must still make the overview useful.
scriptContext.envelope.config.streams = [{ id: 'pending', enabled: true, protocol: 'tcp' }];
preferences.dashboard_hidden_widgets = [];
widget.render({ streams: { active: [], metrics: {} } });
assert.equal(elements.get('stream-dashboard-overview').textContent, '0 / 1');
assert(!cards.get('stream_overview').hidden, 'restart-pending listener does not suppress the card');
preferences.dashboard_hidden_widgets = [...ids];
widget.render(scriptContext.latestStatus);
assert(ids.every(id => cards.get(id).hidden), 'only the Appearance switch hides opt-in cards');
widget.reset();
// Reuse original Appearance normalization logic to protect 0.31.x saved layouts.
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
const definitions = preferencesSource.slice(preferencesSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), preferencesSource.indexOf('const CHART_STYLE_KEYS'));
const normalize = preferencesSource.slice(preferencesSource.indexOf('function normalizeDashboardWidgets'), preferencesSource.indexOf('function browserLanguage'));
const sandbox = {};
vm.runInNewContext(`${registryPrelude}\n${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
const oldOrder = ['requests', 'errors'];
const migratedOrder = [...sandbox.normalizeDashboardWidgets(oldOrder)];
const migratedHidden = [...sandbox.normalizeHiddenDashboardWidgets([], oldOrder)];
assert.deepEqual(migratedOrder.slice(0, 2), oldOrder);
assert(ids.every(id => migratedOrder.includes(id) && migratedHidden.includes(id)), 'legacy order must add cards as hidden');
console.log('PASS: opt-in Stream dashboard uses shared data with no polling, handles user visibility/empty-status states, and migrates old Appearance layouts.');
@@ -1,156 +0,0 @@
// Regression for persisted Appearance preferences and Stream widgets.
// Uses the actual JS functions with a minimal DOM, no network or timers.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const prefsSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
const streamSource = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
assert(html.includes('id="panel-overview"'));
for (const id of ids) assert(html.includes(`data-dashboard-widget="${id}"`));
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
'Overview details must never become hover/focus popovers');
assert.match(css, /#panel-overview \.stats-grid \.stat-chart\s*>\s*\.stat-chart-legend/,
'in-card chart legends must be visible, including on touch devices');
assert.match(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);\s*\}/,
'desktop Overview must retain the original six cards per row');
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\);\s*\}/,
'do not replace the original six-column Overview with a four-column layout');
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
'0.32.0 fixed height produced empty tiles; let inline metric legends size the row');
assert.match(html, /id="stream-dashboard-traffic-detail"/);
assert.match(html, /id="stream-dashboard-rx-rate"/);
assert.match(html, /id="stream-dashboard-tx-rate"/);
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('id="panel-vhosts"')), /<article[^>]*tabindex="0"/,
'metric cards must not introduce redundant keyboard stops');
const cardIds = [...html.matchAll(/<article class="stat[^"]*" data-dashboard-widget="([^"]+)"/g)].map(m => m[1]);
assert.equal(cardIds.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_')).length, 16, 'original Overview cards must be retained');
assert.equal(cardIds.length, 48, '30 existing metric/Stream/backend tiles and 18 optional small charts must be registered');
const cards = new Map(cardIds.map(id => [id, { dataset: { dashboardWidget: id }, hidden: id.startsWith('stream_') }]));
const grid = { querySelectorAll: () => [...cards.values()], append(node) { assert(cards.has(node.dataset.dashboardWidget)); } };
const elements = new Map();
for (const name of ['overview', 'overview-detail', 'traffic', 'traffic-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
elements.set('stream-dashboard-' + name, { textContent: '-', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; } });
}
const status = { streams: {
active: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
desired: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
metrics: { alpha: { active: 5, bytes_in: 1024, bytes_out: 2048, backends: [{ id: 'a', enabled: true, status: 'healthy' }] } }
} };
const window = { rustProxyRefresh: { update() {} } };
const context = {
window, latestStatus: status, envelope: { config: { streams: status.streams.desired } },
currentPanel: 'overview', authenticated: true, languagePacks: { size: 0 },
$: id => elements.get(id), document: { querySelector(selector) {
if (selector === '#panel-overview .stats-grid') return grid;
const match = selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/);
return match ? cards.get(match[1]) : null;
} }, performance: { now: () => 1000 }, t: key => key,
fmt: v => String(v), bytes: v => v + ' B', Math,
normalizeRefreshPreferences: v => v || {}, renderRefreshPreferences() {},
applyAdminTheme: v => v, applyAdminLanguage: v => v, applyAdminUiScale: v => v,
applyAdminTimeFormat: v => v, applyAdminChartStyle: v => v,
applyAdminButtonStyle: v => v, applyAdminCornerStyle: v => v, applyAdminTableDensity: v => v,
applyAdminDashboardDensity: v => v, refreshAppearanceUi() {},
};
vm.createContext(context);
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
const constants = prefsSource.slice(prefsSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefsSource.indexOf('const CHART_STYLE_KEYS'));
const normalize = prefsSource.slice(prefsSource.indexOf('function normalizeDashboardWidgets'), prefsSource.indexOf('function browserLanguage'));
const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboardLayout'), prefsSource.indexOf('function dashboardWidgetLabel'));
const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme('));
vm.runInContext(`${registryPrelude}\n${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
window.rustProxyPreferences = { current: () => context.getPrefs() };
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
vm.runInContext(streamSource, context);
// First load (or page reload): defaults hide the new widgets.
const initial = { dashboard_widgets: cardIds, dashboard_hidden_widgets: ids };
context.applyAdminPreferencesTest(initial);
assert(ids.every(id => cards.get(id).hidden), 'opt-in cards must start hidden');
// Server responds to a previously saved opt-in; at this point the old in-memory
// prefs are still hidden. The new layout must win before adminPreferences is set.
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
assert(ids.every(id => !cards.get(id).hidden), 'server-saved opt-in must reveal cards immediately');
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5', 'no need to wait for polling');
assert.equal(elements.get('stream-dashboard-overview').textContent, '1 / 1');
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'cached repaint is not a traffic sample');
// Hide one widget and restore it, all within the same session. Normal cards stay put.
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ['stream_traffic'] });
assert(cards.get('stream_traffic').hidden);
assert(!cards.get('requests').hidden);
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
assert(!cards.get('stream_traffic').hidden);
// A disabled/absent Stream must not override an explicit Appearance opt-in.
context.envelope.config.streams = [];
context.latestStatus = { streams: { active: [], desired: [], metrics: {} } };
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
assert(ids.every(id => !cards.get(id).hidden), 'show requested cards even with no configured Stream');
assert.equal(elements.get('stream-dashboard-overview').textContent, '-');
assert.equal(elements.get('stream-dashboard-overview-detail').textContent, 'streamWidgetUnavailable');
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ids });
assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work');
assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
// Native title tooltips do not participate in layout or create horizontal scroll.
assert.doesNotMatch(css, /dashboard-widget-(?:description|setting-label-block)/, 'obsolete custom tooltip styles must be removed');
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list\s*\{[^}]*overflow-x: auto;/s,
'Appearance must use a compact horizontal ribbon instead of long half-width columns');
assert.match(css, /\.dashboard-appearance-grid \.dashboard-widget-list > \.dashboard-widget-setting\s*\{[^}]*min-width: 226px;/s,
'Appearance ribbon must retain readable labels and right-aligned reorder buttons');
assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/,
'old popover DOM and associations must be removed');
const widgetList = { children: [], replaceChildren(...children) { this.children = children; } };
const makeElement = (tag, text, className) => ({
tag, textContent: text || '', className, dataset: {}, children: [], attributes: {},
classList: { toggle() {} }, setAttribute(name, value) { this.attributes[name] = value; },
append(...children) { this.children.push(...children); }, addEventListener() {},
});
for (const card of cards.values()) card.querySelector = () => ({ textContent: card.dataset.dashboardWidget });
elements.set('dashboard-widget-list', widgetList);
context.el = makeElement;
context.CSS = { escape: value => value };
vm.runInContext(prefsSource.slice(prefsSource.indexOf('function dashboardWidgetLabel'),
prefsSource.indexOf('function refreshChartStylePalette')), context);
context.renderDashboardWidgetSettings();
assert.equal(widgetList.children.length, 62, 'all cards and full-width panels retain settings controls');
for (const id of ids) {
const row = widgetList.children.find(item => item.dataset.dashboardWidgetSetting === id);
const button = row.children[0];
const key = ({ stream_overview: 'streamWidgetOverviewDescription',
stream_traffic: 'streamWidgetTrafficDescription', stream_sessions: 'streamWidgetSessionsDescription',
stream_backend_health: 'streamWidgetHealthDescription' })[id];
assert.equal(row.title, key, `${id} must have a localized browser tooltip`);
assert.equal(button.attributes['aria-description'], key, 'the tooltip must remain accessible');
assert.equal(button.children.length, 2, 'Appearance shows only the visibility indicator and label');
assert.equal(row.children.length, 2, 'Appearance keeps badge metadata in the visual editor, not in each setting');
assert.equal(row.children[1].className, 'dashboard-widget-setting-actions', 'move actions remain on the right');
}
assert.equal(widgetList.children.filter(row => row.title).length, 50,
'Stream, backend, performance and full-panel entries have translated descriptions');
// Editor-specific regressions: avoid the redundant enable hint and label the
// two distinct inheritance layers instead of saying "Inherit" everywhere.
const editor = fs.readFileSync('src/admin/web/streams.js', 'utf8');
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
assert(editor.includes('enabledLabel?.querySelector(".vh-enable-hint")?.remove()'));
const vhostEditor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
assert.match(vhostEditor, /window\.rustProxyEnableControl\(enabledControl, "vhEnableHint"\)/);
assert.match(vhostEditor, /enabledLabel\?\.querySelector\("\.vh-enable-hint"\)\?\.remove\(\)/, 'vhost editor must not show the redundant active hint');
assert.match(vhostEditor, /enabledLabel\?\.classList\.add\("vh-enable-no-hint"\)/);
assert(editor.includes('["inherit", "streamMetricsDefaultOff"]'));
assert(editor.includes('["inherit", "streamMetricsFromStream"]'));
for (const locale of [pl, en]) {
for (const key of ['streamMetricsDefaultOff', 'streamMetricsFromStream', 'streamMetricsHelp', 'streamBackendDetailMetrics'])
assert(locale[key] && locale[key].length > 5, `missing ${key}`);
}
assert.equal(pl.streamMetricsFromStream, 'Z ustawień Stream');
assert.equal(pl.streamMetricsDefaultOff, 'Domyślnie wyłączone');
console.log('PASS: Stream visibility, inline Overview legends, scroll-safe native Appearance tooltips and Vhost enable label.');
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env node
// Simulated status and tab DOM. No Rust process or visual browser is run here.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
const js = fs.readFileSync('src/admin/web/settings.js', 'utf8');
const core = fs.readFileSync('src/admin/web/app/core.js', 'utf8');
const api = JSON.parse(fs.readFileSync('src/admin/openapi.json', 'utf8'));
const has = (haystack, needle) => assert.ok(haystack.includes(needle), `Missing ${needle}`);
// The two mini charts share the exact H1/H2/H3 palette as their SVG lines.
for (const version of ['h1', 'h2', 'h3']) {
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}"`);
}
assert.equal((html.match(/class="stat-chart-legend"/g) || []).length, 2);
const about = html.split('<dialog aria-labelledby="about-title"')[1].split('</dialog>')[0];
for (const forbidden of ['about-acme-mode', 'about-python-runtime', 'about-python-details', 'runtimeAcmeMode']) {
assert.ok(!about.includes(forbidden), `ACME details left in About: ${forbidden}`);
}
assert.ok(!core.includes('renderAboutRuntime('), 'Obsolete About runtime renderer must be removed');
for (const marker of ['aboutAuthor', 'aboutLicense', 'aboutRepository', 'aboutPackagesRepository']) has(about, marker);
has(html, 'data-settings-tab="system-info"');
has(html, 'id="settings-page-system-info"');
const tab = html.split('id="settings-page-system-info"')[1].split('id="settings-page-certificate-api"')[0];
for (const field of ['runtime-operating-system', 'runtime-kernel-version', 'runtime-architecture',
'runtime-logical-cpus', 'runtime-system-memory', 'runtime-process-memory',
'runtime-acme-mode', 'runtime-python', 'runtime-pip', 'runtime-certbot',
'runtime-acme-directory', 'runtime-data-directory', 'runtime-database-path']) has(tab, `id="${field}"`);
assert.ok(!tab.includes('<form'), 'The System information page must be read-only');
const admin = html.split('id="settings-page-administration"')[1].split('id="settings-page-system-info"')[0];
assert.ok(!admin.includes('runtime-information-card'), 'Runtime data must be relocated, not duplicated');
// Execute the real status-to-settings rendering code against a synthetic status payload.
const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {};'), js.indexOf(' const policy = envelope.config.tls?', 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.32.1', 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',
certificate_runtime: { status: 'ready', mode: 'managed', message: 'acme.runtime_ready',
python: true, pip: true, certbot: true, python_version: '3.13.5', pip_version: '25.2',
certbot_version: '5.8.0', managed_directory: '/var/lib/rustproxy/acme-python' }
},
system: { logical_cpus: 8, cpu_percent: 17.2, memory_total_bytes: 16_384, memory_used_bytes: 8_192,
process_rss_bytes: 2_048, system_uptime_seconds: 100, process_threads: 9, open_fds: 67 }
};
const context = {
latestStatus: status, $: dollar, locale: () => 'en-US',
t: key => ({ runtimeAvailable: 'Available', runtimeUnavailable: 'Unavailable',
certificateRuntimeManaged: 'App-managed Python', 'acme.runtime_ready': 'Ready', unavailable: 'N/A' })[key] || key,
bytes: value => `${value} B`, uptime: value => `${value}s`,
};
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.32.1');
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');
assert.equal(dollar('runtime-pip').textContent, 'Available · 25.2');
assert.equal(dollar('runtime-certbot').textContent, 'Available · 5.8.0');
assert.equal(dollar('runtime-acme-directory').textContent, '/var/lib/rustproxy/acme-python');
// A working ACME runtime can have no trustworthy version metadata on some OSes.
elements.clear();
vm.runInNewContext(code, { ...context,
latestStatus: { ...status, runtime: { ...status.runtime, certificate_runtime: {
...status.runtime.certificate_runtime, python_version: null, pip_version: null, certbot_version: null
} } }
});
for (const field of ['runtime-python', 'runtime-pip', 'runtime-certbot']) {
assert.equal(dollar(field).textContent, 'Available');
}
// Status fields remain safely unavailable rather than displaying fake zeros.
elements.clear();
vm.runInNewContext(code, { ...context,
latestStatus: { runtime: { certificate_runtime: { status: 'unavailable', mode: 'none',
message: 'acme.runtime_python_missing', python: false, pip: false, certbot: false } }, system: {} }
});
for (const field of ['runtime-cpu-usage', 'runtime-logical-cpus', 'runtime-system-memory', 'runtime-python']) {
assert.ok(!/NaN|0 B/.test(dollar(field).textContent));
}
// Exercise real tab switching, deep-linking and search code without a browser.
const entry = js.slice(js.indexOf(' const buttons ='), js.indexOf(' const cacheTabs ='));
const buttons = ['administration', 'system-info', 'certificate-api'].map(name => ({
dataset: {settingsTab:name}, textContent:name, hidden:false, tabIndex:-1,
listeners:{}, setAttribute(name,value){this[name]=value;}, addEventListener(name,fn){this.listeners[name]=fn;}, focus(){} }));
const pages = buttons.map(b => ({dataset:{settingsPage:b.dataset.settingsTab}, hidden:true,textContent:b.dataset.settingsTab}));
const search={value:'',listeners:{},addEventListener(name,fn){this.listeners[name]=fn;}};
const searchStatus={textContent:''};
const loc = new URL('https://localhost/settings?tab=system-info');
let lastUrl='';
vm.runInNewContext(`(() => { ${entry} return true; })()`, {
document: {querySelectorAll: sel => sel === '[data-settings-tab]' ? buttons : pages,
querySelector: sel => pages.find(p => sel.includes(p.dataset.settingsPage))},
$: id => id === 'settings-search' ? search : searchStatus,
location: loc, history: {state:null,replaceState:(_state,_unused,url) => {lastUrl=url;}},
CSS:{escape:x=>x}, URL, URLSearchParams, t:x=>x,
});
buttons[1].listeners.click();
assert.equal(pages[1].hidden,false);
assert.equal(pages[0].hidden,true);
assert.equal(buttons[1]['aria-selected'],'true');
assert.equal(lastUrl,''); // Already at canonical deep link; no unnecessary history rewrite.
buttons[0].listeners.click();
assert.equal(pages[1].hidden,true);
assert.equal(lastUrl,'/settings?tab=administration');
const runtimeSchema = api.components.schemas.RuntimeInformation;
for (const key of ['operating_system','kernel_version','architecture']) assert.ok(runtimeSchema.properties[key]);
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.9 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
@@ -1,41 +0,0 @@
#!/usr/bin/env python3
"""Offline source guard for subprocess-free system information; not a Rust runtime test."""
from pathlib import Path
import tomllib
from rust_source import rust_source
ROOT = Path(__file__).resolve().parents[2]
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
for dependency in ('sysinfo', 'metrics-process'):
assert dependency in manifest['dependencies'], dependency
sysinfo_features = manifest['dependencies']['sysinfo']['features']
assert {'network', 'system'} <= set(sysinfo_features), (
'sysinfo::System and sysinfo::Networks require both system and network features'
)
assert any('systemstat' in target.get('dependencies', {}) for target in manifest.get('target', {}).values())
status = rust_source(ROOT, 'src/admin/status.rs')
assert 'sysinfo::System::long_os_version()' in status
assert 'sysinfo::System::kernel_version()' in status
assert 'std::sync::OnceLock' in status
for forbidden in ('Command::', 'std::process::Command', '/etc/os-release', '/proc/sys/kernel/osrelease'):
assert forbidden not in status, f'Status OS identification cannot use {forbidden}'
metadata = (ROOT / 'src/acme/runtime_versions.rs').read_text()
for marker in ('pyvenv.cfg', '.dist-info', 'METADATA', 'MAX_VERSION_METADATA_BYTES', 'MAX_PACKAGES_PER_SITE', 'fn runtime_versions('):
assert marker in metadata, marker
for forbidden in ('Command::', '.spawn(', 'version_token(', '"--version"', "'-c'", '.await'):
assert forbidden not in metadata, f'Diagnostic versions must not execute: {forbidden}'
runtime = (ROOT / 'src/acme/runtime_env.rs').read_text()
assert 'include!("runtime_versions.rs")' in runtime
assert 'runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)))' in runtime
assert 'python_version: versions.python' in runtime and 'certbot_version: versions.certbot' in runtime
assert 'path.with_extension("exe")' in runtime, 'Windows executable discovery must include .exe'
regressions = (ROOT / 'src/acme/tests.rs').read_text()
for marker in ('managed_python_pip_and_certbot_versions_use_metadata_only',
'absent_or_unverified_versions_are_not_invented',
'conflicting_site_distributions_do_not_return_stale_versions'):
assert marker in regressions, marker
print('PASS: shared Rust libraries, portable OS detection, no diagnostic subprocesses, bounded metadata and source regressions.')
-147
View File
@@ -1,147 +0,0 @@
#!/usr/bin/env python3
"""Offline source contracts for RustProxy 0.32.7 upstream protocols and card registry.
These assertions supplement, rather than replace, Rust ALPN, H2C and websocket
runtime tests. No code execution against a RustProxy server is implied.
"""
import json
import re
from pathlib import Path
root=Path(__file__).resolve().parents[2]
read=lambda name:(root/name).read_text(encoding='utf-8')
registry=json.loads(read('src/admin/web/dashboard-widgets.json'))
ids=[widget['id'] for widget in registry]
assert len(ids)==len(set(ids)) and len(ids)>=16
assert all(re.fullmatch(r'[a-z0-9_]+',id) for id in ids)
html=read('src/admin/web/index.html')
html_ids=re.findall(r'<article\b[^>]*\bdata-dashboard-widget="([a-z0-9_]+)"',html)
assert len(html_ids)==len(set(html_ids)), 'duplicate Overview widget element'
assert set(ids)==set(html_ids), f'Registry diverges from rendered Overview cards: {set(ids)^set(html_ids)}'
assert ids==html_ids, 'registry should follow the default displayed card order'
for language in ['en_US','pl_pl']:
translations=json.loads(read(f'langs/{language}.json'))['translations']
assert 'Auto' in translations['vhProtocolAuto']
assert 'H2' in translations['vhProtocolHttp2']
assert 'H2' in translations['vhProtocolH2Strict']
assert all(key in translations for key in (
'vhProtocolCompatibilityWarning', 'vhProtocolAutoHttpsDetails', 'vhProtocolH2HttpDetails',
'vhProtocolLegacyHttp2HttpsDetails', 'vhProtocolLegacyHttp2HttpDetails',
'backendWidgetModeCompat', 'backendWidgetModeStrict',
'backendWidgetRequests', 'backendWidgetCapacity'))
for widget in registry:
assert widget['label_key'] in translations, (language,widget['label_key'])
if widget['description_key'] is not None:
assert widget['description_key'] in translations
for widget in registry:
assert widget['element_selector']==f'[data-dashboard-widget="{widget["id"]}"]'
assert widget['data_source'] in ('status_metrics','stream_status','backend_status','system_status','cache_status')
assert widget['no_data']=='neutral_dash'
assert isinstance(widget['default_hidden'],bool)
assert widget['category'] in ('overview','streams','backends','performance','panels')
assert {v['id'] for v in registry if v['default_hidden']}=={
'stream_overview','stream_traffic','stream_sessions','stream_backend_health',
'backend_health','backend_activity','backend_errors','backend_latency',
'backend_traffic','backend_protocols','backend_requests','backend_capacity',
'backend_http5xx','backend_timeouts','backend_network','backend_retries','backend_queue','backend_rotations',
'extra_process_cpu','extra_process_rss','extra_process_fds','extra_open_connections',
'extra_websocket_active','extra_tls_error_rate','extra_request_error_rate',
'extra_cache_hit_rate','extra_cache_memory','extra_h2_share','extra_response_latency',
'extra_backend_unhealthy',
'extra_total_rps', 'extra_active_exchanges', 'extra_h1_share', 'extra_h3_share',
'extra_cache_miss_rate', 'extra_backend_active',
'wide_protocol_rates', 'wide_cpu', 'wide_memory', 'wide_cache_activity',
'wide_connections', 'wide_backend_health', 'wide_latency'
}
backend=read('src/config/model.rs')
client=read('src/upstream/client.rs')
# Existing saved `http2` is NOT strict on HTTPS: only new `h2` is.
assert re.search(r'enum UpstreamProtocol\s*\{\s*#\[default\]\s*Http1,',backend)
for name in ['Http2,', 'Auto,', 'H2,']:
assert name in backend
assert 'is_https && backend.protocol == UpstreamProtocol::Http2' in client
assert 'let protocol = if websocket {' in client and 'UpstreamProtocol::Http1' in client
assert 'if !is_https && protocol == UpstreamProtocol::Auto' in client
assert 'UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http)' in client
assert re.search(r'UpstreamProtocol::Http2 \| UpstreamProtocol::H2 => \{?\s*builder\.enable_http2\(\)\.wrap_connector\(http\)',client)
assert 'UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http)' in client
assert re.search(r'\.http2_only\(matches!\(\s*protocol,\s*UpstreamProtocol::Http2 \| UpstreamProtocol::H2\s*\)\s*\)',client)
assert 'backend.origin.starts_with("http://")' in read('src/proxy/types.rs')
assert 'UpstreamProtocol::Http2 | crate::config::UpstreamProtocol::H2' in read('src/proxy/forward/upstream.rs')
assert '.retry_canceled_requests(false)' in client
assert '.pool_idle_timeout(' in client and '.pool_max_idle_per_host(' in client
assert 'generation_requests' in client and 'rotating' in client
assert 'include!("protocol_tests.rs");' in read('src/upstream/tests.rs')
protocol_tests=read('src/upstream/protocol_tests.rs')
for test in ['https_auto_chooses_h2_by_alpn_without_replaying_request',
'https_auto_negotiates_http1_before_sending_to_h1_only_peer',
'saved_legacy_https_http2_keeps_h1_fallback',
'saved_legacy_https_http2_still_prefers_h2',
'explicit_https_h2_requires_h2_and_refuses_h1_only_peer',
'explicit_https_h2_selects_h2_via_alpn',
'explicit_https_http1_does_not_offer_h2_to_h2_capable_peer',
'websocket_isolated_client_stays_on_http1_even_with_h2_backend',
'cleartext_auto_does_not_attempt_unsafe_h2c_probe',
'default_protocol_and_serialization_are_backward_compatible']:
assert f'fn {test}' in protocol_tests, test
assert 'fn explicit_h2c_backend_uses_http2_prior_knowledge' in read('src/upstream/tests.rs')
assert 'fn upstream_request_version(' in read('src/proxy/types.rs')
assert 'fn legacy_https_http2_version_is_h1_safe_and_strict_h2_is_required' in read('src/proxy/tests/authority_tests.rs')
server=read('src/admin/preferences_columns.rs')
api=read('src/admin/preferences.rs')
router=read('src/admin/router.rs')
js=read('src/admin/web/app/preferences.js')
assert 'include_str!("web/dashboard-widgets.json")' in server
assert 'include_str!("web/dashboard-widgets.json")' in router
assert 'const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(' in router
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.map(widget => widget.id)' in js
assert 'ADMIN_DASHBOARD_WIDGET_REGISTRY.filter(widget => widget.default_hidden)' in js
assert 'ADMIN_DASHBOARD_BY_ID.get(id)?.description_key' in js
assert 'reconcile_dashboard_widgets(&mut preferences);' in api
assert 'prefs.dashboard_widgets.retain(' in api and 'prefs.dashboard_hidden_widgets.retain(' in api
assert 'fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids' in read('src/admin/preferences_tests.rs')
assert 'include!("preferences_tests.rs");' in read('src/admin/tests.rs')
assert 'item.insert("protocol".into(), json!(m.definition.protocol));' in read('src/balance/pool_view.rs')
assert 'include_str!("web/app/backend-dashboard.js")' in router
assert 'window.rustProxyBackendDashboard?.render(s)' in read('src/admin/web/app/core.js')
assert 'window.rustProxyBackendDashboard?.syncVisibility' in js
assert 'window.rustProxyBackendDashboard?.reset()' in read('src/admin/web/app/core.js')
assert 'vhProtocolCompatibilityWarning' in read('src/admin/web/vhosts/protocol.js')
assert '["h2", "vhProtocolH2Strict"]' in read('src/admin/web/vhosts/core.js')
assert 'backendWidgetRequestsTotal' in read('src/admin/web/app/backend-dashboard.js')
assert 'backendWidgetCapacityLimits' in read('src/admin/web/app/backend-dashboard.js')
assert 'include_str!("web/app/dashboard-layout-editor.js")' in router
assert 'include_str!("web/app/dashboard-inline-layout.js")' in router
assert all(widget.get('display_type') in {'value','value_chart','chart','panel','table'} for widget in registry)
assert 'dashboardWidgetTypeBadge' in js
assert 'overview-layout-unlock' in html and 'overview-layout-type-filter' in html
assert 'dashboard_widgets: [...draft.order]' in read('src/admin/web/app/dashboard-layout-editor.js')
assert 'dashboard_hidden_widgets: [...draft.hidden]' in read('src/admin/web/app/dashboard-layout-editor.js')
assert 'overview-layout-dialog' in html and 'overview-layout-open' in html
assert 'tls_errors_total' in read('src/admin/web/app/backend-dashboard.js')
assert len(registry) == 62
assert len([w for w in registry if w['category'] == 'panels']) == 14
assert 'include_str!("web/app/overview-extra-metrics.js")' in router
assert 'include_str!("web/app/overview-wide-metrics.js")' in router
assert 'include_str!("web/app/overview-masonry.js")' in router
assert 'window.rustProxyExtraOverview?.render(s)' in read('src/admin/web/app/core.js')
assert all(f'id="{id}"' in html for id in ('dashboard-overview-grid','overview-layout-default','overview-layout-reset','overview-reset-confirm'))
openapi = json.loads(read('src/admin/openapi.json'))
assert openapi['components']['schemas']['VhostBackend']['properties']['protocol']['enum'] == ['http1', 'http2', 'auto', 'h2']
prefs_schema = openapi['components']['schemas']['AdminPreferences']['properties']
for field in ('dashboard_widgets', 'dashboard_hidden_widgets'):
schema = prefs_schema[field]
assert schema['maxItems'] == len(registry), field
assert schema['items']['enum'] == ids, field
expected = ids if field == 'dashboard_widgets' else [widget['id'] for widget in registry if widget['default_hidden']]
assert schema['default'] == expected, field
assert '"version": "0.32.7"' in read('src/admin/openapi.json')
assert 'version = "0.32.7"' in read('Cargo.toml')
assert 'version = "0.32.7"' in read('Cargo.lock')
print(f'PASS: 0.32.7 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards')
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env python3
"""Keep executable release identity derived from the Cargo package version."""
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from scripts.version import project_version
root = Path(__file__).resolve().parents[2]
version = project_version(root)
for name in ('src/main/bootstrap.rs','src/main/tasks.rs','src/web_server/fastcgi/protocol.rs','src/web_server/response/file.rs','dev.sh','scripts/deploy/upgrade.py','make_zip.py'):
text = (root/name).read_text(encoding='utf-8')
assert f'RustProxy {version}' not in text and f'RustProxy/{version}' not in text, f'hardcoded runtime version: {name}'
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/fastcgi/protocol.rs').read_text()
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/response/file.rs').read_text()
assert '__RUSTPROXY_ASSET_ID__' in (root/'src/admin/web/index.html').read_text()
assert 'OnceLock<PanelContent>' in (root/'src/admin/public.rs').read_text()
assert 'PanelContent::new' in (root/'src/admin/public.rs').read_text()
assert 'preferred_encoding' in (root/'src/admin/panel_content.rs').read_text()
assert 'include_str!("web/index.html")' in (root/'src/admin/public.rs').read_text()
assert 'super::asset_build_id()' in (root/'src/admin/public.rs').read_text()
assert 'admin_index.html' not in (root/'build.rs').read_text()
assert 'project_version' in (root/'make_zip.py').read_text()
print(f'PASS: executable version comes from Cargo.toml ({version})')
@@ -1,95 +0,0 @@
#!/usr/bin/env node
// 0.29.7 origin error-page interception form roundtrip and input validation.
// Browser DOM is simulated; this is NOT a Rust/protocol integration test.
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync('src/admin/web/management.js', 'utf8');
const start = source.indexOf(' function vhostEditor(');
const end = source.indexOf(' let global = null;', start);
if (start === -1 || end <= start) throw new Error('Missing current vhost pages form');
const codes = [400,401,403,404,408,413,429,500,502,503,504];
const check = (valid, message) => { if (!valid) throw new Error(message); };
class Element {
constructor(tag='div', text='') {
this.tag = tag;
this.textContent = text;
this.value = '';
this.checked = false;
this.disabled = false;
this.children = [];
this.listeners = new Map();
}
append(...nodes) { this.children.push(...nodes); }
addEventListener(name, callback) { this.listeners.set(name, callback); }
dispatch(name) { this.listeners.get(name)?.(); }
}
const el = (tag, text) => new Element(tag, text);
const label = (tag, key) => new Element(tag, key);
const pageEditor = (parent, label, initial) => { parent.append(el('div', label)); return {read:()=>initial||null}; };
const errorsEditor = (parent, initial) => { parent.append(el('div')); return {read:()=>initial||{}}; };
const context = {
pageEditor, errorsEditor, el, label, codes,
document: { createElement: tag => el(tag) },
t: key => key,
};
vm.createContext(context);
vm.runInContext(`${source.slice(start,end)}\nthis.makeEditor = vhostEditor;`,context);
const build = values => {
const parent = el('div');
const editor = context.makeEditor(parent, values);
const section = parent.children[2];
check(section.tag === 'section', 'Missing origin interception section');
const input = section.children[0].children[0];
const statuses = section.children[1].children[1];
return {editor, input, statuses, section};
};
const prior = build({});
check(!prior.input.checked && prior.statuses.disabled, 'Interception must default off');
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[]', 'Old config must remain unchanged');
prior.input.checked = true; prior.input.dispatch('change');
check(!prior.statuses.disabled, 'Status list must enable with checkbox');
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[404]', 'Default status must be HTTP 404');
prior.statuses.value = '404, 500, 503';
check(JSON.stringify(prior.editor.read().intercept_statuses) === '[404,500,503]', 'Explicit statuses lost');
for (const invalid of ['302', '404,404', '404.0', '0x194', 'abc', '', '200', '600']) {
prior.statuses.value = invalid;
let rejected = false;
try { prior.editor.read(); } catch { rejected = true; }
check(rejected, `Invalid interception status should be rejected: ${invalid}`);
}
prior.input.checked = false; prior.input.dispatch('change');
check(prior.statuses.disabled && prior.editor.read().intercept_statuses.length===0,'Disabling interception must clear saved statuses');
const saved = build({intercept_statuses:[401,404],errors:{404:{kind:'html',status:null,html:'local'}}});
check(saved.input.checked && !saved.statuses.disabled && saved.statuses.value==='401, 404','Existing interception settings not restored');
check(saved.editor.read().errors['404'].html==='local', 'Custom error page lost');
const rust = fs.readFileSync('src/proxy/error_fallback.rs','utf8');
check(rust.includes('if route.internal {\n return response;'), 'Internal fallback response is not protected from interception');
const web = fs.readFileSync('src/proxy/forward/web_source.rs','utf8');
const proxy = fs.readFileSync('src/proxy/forward/response.rs','utf8');
check(web.includes('let mut response = intercept_route_origin_error(response, route, &snapshot);'), 'Web Server origin interception not in response path');
const prepared = fs.readFileSync('src/proxy/forward/success_prepare.rs','utf8');
check(proxy.includes('include!("success_prepare.rs")'), 'Reverse Proxy response preparation missing');
check(!proxy.includes('let response = intercept_route_origin_error(response, route, &snapshot);'), 'Do not convert Incoming body before HTTP/2 WebSocket setup');
check(prepared.includes('response = intercept_route_origin_error(response, route, &snapshot);'), 'Origin error interception missing after body adaptation');
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(/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);');
check(interceptPosition > prepared.indexOf('Response::from_parts(parts, Body::new(body))'), 'Origin interception must operate on Response<Body>');
check(interceptPosition < prepared.indexOf('response.extensions_mut().insert(ResponseRulesApplied)'), 'Intercepted origin response must receive final response metadata');
check(prepared.includes('websocket::wrap_h2_response_body(body, session, exchange)'), 'HTTP/2 WebSocket tunnel body must remain compatible with Incoming');
const reappliedRules = prepared.indexOf('apply_response_rules(', interceptPosition);
const errorNoStore = prepared.indexOf('HeaderValue::from_static("no-store")', interceptPosition);
check(reappliedRules > interceptPosition && errorNoStore > reappliedRules, 'Intercepted pages must reapply header policy and then force no-store');
const healthLoop = fs.readFileSync('src/balance/lease_runtime.rs','utf8');
check(healthLoop.includes('checks.spawn(probe(Arc::clone(member), health.clone()))'), 'Health probe must take an owned Arc<Member>');
const html = fs.readFileSync('src/admin/web/index.html','utf8');
check(html.includes('id="php-fpm-profiles-help"') && html.includes('id="php-help-dialog"'), 'Shared PHP-FPM profiles lack help modal');
for (const key of ['phpDocsScope','phpDocsValuesText','phpDocsAdminText','phpHintPathInfo']) {
check(html.includes(`data-i18n="${key}"`), `PHP help does not explain ${key}`);
}
console.log('PASS: application error interception form default/roundtrip/validation, internal fallback protection, PHP help access.');
@@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Render real Vhost cards with mock config/status to verify badge placement/color.
Headless Chromium fixture only: does not verify authenticated Rust runtime/ALPN.
"""
from pathlib import Path
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
source = (WEB / 'vhosts/render.js').read_text()
start = source.index('const VHOST_PROTOCOL_FRESH_MS = ')
end = source.index('function backendTrafficRate(', start)
renderer = source[start:end]
css = (WEB / 'style.css').read_text()
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded">'
f'<head><meta name="viewport" content="width=device-width,initial-scale=1">'
f'<style>{css}</style></head><body><section id="panel-vhosts">'
f'<div id="backend-diagnostics-card"></div><input id="vhost-search">'
f'<div id="vhost-list"></div><span id="vhost-editing"></span>'
f'</section></body></html>')
stubs = r'''
const $ = id => document.getElementById(id);
const t = key => key;
const locale = () => 'en-US';
const el = (tag, value, cls) => {
const node=document.createElement(tag);
if (value !== undefined) node.textContent=value;
if (cls) node.className=cls;
return node;
};
const text = (tag, key, cls) => el(tag, t(key), cls);
const action = (name, fn) => {
const btn=el('button', name, 'quiet');
btn.addEventListener('click', fn);
return btn;
};
const button = (name, fn, cls) => {
const btn=action(name, fn);
if (cls) btn.className=cls;
return btn;
};
const initialized=true;
let editing=null, latestStatus=null;
const vhostListPrefs={sort:'id',descending:false};
const envelope={config:{listeners:{endpoints:[]},vhosts:[
{id:'proxy-test',enabled:true,frontend:{hosts:['proxy.example'],listener_ids:[]},
source:{type:'reverse_proxy'},force_ssl:true,websockets:true,
backends:[{id:'origin1',protocol:'auto'}, {id:'origin2',protocol:'http1'}]},
{id:'static-test',enabled:true,frontend:{hosts:['static.example'],listener_ids:[]},
source:{type:'web_server',web_service_id:'public'},
backends:[{id:'unused',protocol:'auto'}]},
]}};
'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
for width in (393, 1024):
page = browser.new_page(viewport={'width': width, 'height': 844})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=stubs + renderer + '\nrender();')
initial = page.evaluate('''() => {
const card=document.querySelector('[data-vhost-card="proxy-test"]');
const badge=card.querySelector('.vhost-backend-protocol-badge');
const group=card.querySelector('.vhost-policy-pills');
const staticCard=document.querySelector('[data-vhost-card="static-test"]');
return {badges:group.querySelectorAll('.vhost-backend-protocol-badge').length,
otherPills:group.querySelectorAll('.pill').length,
parent:badge.parentElement===group,
lastChild:card.lastElementChild===group,
separateRows:card.querySelectorAll('.vhost-backend-protocols').length,
staticBadges:staticCard.querySelectorAll('.vhost-backend-protocol-badge').length,
initialText:badge.textContent,
scroll:document.documentElement.scrollWidth,screen:innerWidth};
}''')
assert initial['badges'] == 2 and initial['otherPills'] == 2, initial
assert initial['parent'] and initial['lastChild'], initial
assert initial['separateRows'] == 0 and initial['staticBadges'] == 0, initial
assert initial['initialText'] == 'origin1 · AUTO → ?', initial
assert initial['scroll'] <= initial['screen'] + 2, initial
appearance = page.evaluate('''() => {
const badge=document.querySelector('[data-upstream-protocol-backend="origin1"]');
const expected=token=>{const n=document.createElement('span');n.style.color=`var(${token})`;
document.body.append(n);const c=getComputedStyle(n).color;n.remove();return c;};
const unknown=getComputedStyle(badge).color;
renderUpstreamProtocolBadges({backend_pools:[{id:'proxy-test',members:[
{id:'origin1',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:Date.now()-1000},
{id:'origin2',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:Date.now()-1000}
]}]});
const h2={text:badge.textContent,observed:badge.dataset.observed,
color:getComputedStyle(badge).color,expected:expected('--green')};
return {unknown, expectedUnknown:expected('--muted'), h2};
}''')
assert appearance['unknown'] == appearance['expectedUnknown'], (width, appearance)
assert appearance['h2']['color'] == appearance['h2']['expected'], (width, appearance)
assert appearance['h2']['observed'] == 'true' and appearance['h2']['text'] == 'origin1 · AUTO → H2', appearance
print(f'PASS: {width}px Vhost protocol badges share policy group and preserve green/neutral states')
page.close()
browser.close()
-103
View File
@@ -1,103 +0,0 @@
#!/usr/bin/env node
// Exercise the real vhost WWW-routing editor code with a small DOM stand-in.
// This is a browser-UI state test, not a Rust request-routing test.
import fs from 'node:fs';
import vm from 'node:vm';
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
const first = editor.indexOf('const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");');
const last = editor.indexOf('field(g, "forward-client-ip",', first);
if (first === -1 || last === -1) throw new Error('WWW editor component missing');
const source = editor.slice(first, last);
if (editor.indexOf('readHosts = hostTagEditor(g, value.frontend?.hosts') < editor.indexOf('g = group("vhFrontends")')) {
throw new Error('Host chips must be in the same Frontends tab as WWW routing');
}
class ClassList {
constructor() { this.names = new Set(); }
add(name) { this.names.add(name); }
toggle(name, active) { if (active) this.names.add(name); else this.names.delete(name); }
contains(name) { return this.names.has(name); }
}
class Element {
constructor(tag = 'div', text = '', css = '') {
this.tag = tag;
this.textContent = text || '';
this.classList = new ClassList();
if (css) for (const name of css.split(' ')) this.classList.add(name);
this.children = [];
this.hidden = false;
this.checked = false;
this.value = '';
this.attrs = {};
this.listeners = new Map();
}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
prepend(...children) { for (const child of [...children].reverse()) { child.parent = this; this.children.unshift(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
closest(tag) { let node = this.parent; while (node && node.tag !== tag) node = node.parent; return node; }
setAttribute(key, value) { this.attrs[key] = String(value); }
addEventListener(event, listener) { this.listeners.set(event, listener); }
dispatch(event) { this.listeners.get(event)?.(); }
}
const el = (tag, content, css) => new Element(tag, content, css);
const t = key => key === 'vhWwwSharedCount' ? '{count} names / one vhost' : key;
const text = (tag, key, css) => el(tag, t(key), css);
const g = new Element();
const hosts = new Element('textarea');
hosts.value = 'suba.local\nsubb.local\n*.local';
const read = name => { if (name !== 'hosts') throw new Error(name); return hosts; };
const lines = raw => raw.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
let changes = 0;
const changed = () => { changes++; };
const value = { www_redirect: false, www_redirect_target: 'subb.local' };
const document = { createElement: tag => new Element(tag) };
const field = (parent, name, key, initial, type) => {
const label = new Element('label');
const input = new Element('input');
input.name = name;
input.type = type;
input.checked = Boolean(initial);
label.append(input, text('span', key));
parent.append(label);
return input;
};
let refreshCanonical = () => {};
const context = { el, text, field, g, t, value, read, lines, changed, document, refreshCanonical };
const component = vm.runInNewContext(`(() => { ${source}\nreturn { wwwPanel, wwwRedirect, wwwTarget, wwwChoices, wwwDetails, wwwInactive, wwwOther, wwwNoHosts, wwwHostCount, syncCanonical }; })()`, context);
const check = (condition, message) => { if (!condition) throw new Error(message); };
const choice = (baseIndex, directionIndex) => component.wwwChoices.children[baseIndex].children[1].children[directionIndex];
const route = (baseIndex, directionIndex) => choice(baseIndex, directionIndex).children[1].children[1].textContent;
check(!component.wwwDetails.hidden && !component.wwwInactive.hidden, 'Direction preview should be visible even when redirect is disabled');
check(component.wwwTarget.value === 'subb.local', 'Saved canonical domain was lost');
check(component.wwwChoices.children.length === 2, 'Wildcard must not become a canonical pair');
check(component.wwwHostCount.textContent === '3 names / one vhost', 'Shared host count not updated');
check(route(0, 0) === 'www.suba.local → suba.local', 'Incorrect www -> non-www preview');
check(route(0, 1) === 'suba.local → www.suba.local', 'Incorrect non-www -> www preview');
check(route(1, 0) === 'www.subb.local → subb.local', 'Incorrect second domain preview');
check(choice(1, 0).classList.contains('is-selected'), 'Saved target not visibly selected');
component.wwwRedirect.checked = true;
component.wwwRedirect.dispatch('change');
check(!component.wwwDetails.hidden && component.wwwInactive.hidden, 'Switch did not reveal WWW routing choices');
check(component.wwwRedirect.attrs['aria-expanded'] === 'true', 'Toggle accessibility state not updated');
check(component.wwwOther.children.some(child => child.textContent === 'suba.local'), 'Non-canonical alias must remain available');
check(component.wwwOther.children.some(child => child.textContent === '*.local'), 'Wildcard alias must remain available');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.suba.local', 'WWW destination selected without a separately declared www host was lost');
check(choice(0, 1).classList.contains('is-selected'), 'Selected direction not styled');
check(changes === 2, 'Toggle and radio change did not mark editor dirty');
hosts.value = 'subb.local\nwww.subb.local\n*.local';
context.refreshCanonical();
check(component.wwwTarget.value === 'subb.local', 'Deleting selected domain did not choose a valid fallback');
check(component.wwwChoices.children.length === 1, 'Duplicate base and www domains should be one pair');
check(route(0, 1) === 'subb.local → www.subb.local', 'Wrong preview for canonical www host');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.subb.local', 'Explicit WWW target not serialized');
check(component.wwwOther.children.every(child => child.textContent !== 'www.subb.local'), 'Redirected companion must not appear as a non-redirected alias');
hosts.value = '*.local';
context.refreshCanonical();
check(component.wwwNoHosts.hidden === false && component.wwwTarget.value === '', 'Wildcard-only configurations need a useful empty state');
check(component.wwwRedirect.disabled && !component.wwwRedirect.checked, 'Wildcard-only configuration must disable redirect');
console.log('PASS: WWW-routing UI two-way choices, implicit www target, shared aliases, dynamic host edits, wildcard exclusion and empty state.');
-33
View File
@@ -1,33 +0,0 @@
from pathlib import Path
APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "rate-sampling.js", "preferences.js", "dashboard-layout-editor.js", "dashboard-inline-layout.js", "stream-dashboard.js", "backend-dashboard.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",
"redirects.js", "host404.js", "traffic-panel.js", "cache-panel.js",
"transform-panel.js", "acme.js", "settings.js", "certificate-api.js", "file-logs.js",
)
VHOST_PARTS = ("core.js", "protocol.js", "health.js", "rates.js", "editor.js", "render.js")
def _read(root: Path, folder: str, parts: tuple[str, ...]) -> str:
return "\n".join((root / "src/admin/web" / folder / part).read_text(encoding="utf-8") for part in parts)
def read_vhosts_js(root: Path) -> str:
body = _read(root, "vhosts", VHOST_PARTS)
return "(() => {\n" + body + "\n})();"
def read_app_js(root: Path) -> str:
# Mirror the Rust include_str! assembly including the server-validated
# widget registry, so QA parses the same core used by the real panel.
parts = []
for part in APP_PARTS:
if part == "preferences.js":
registry = (root / "src/admin/web/dashboard-widgets.json").read_text(encoding="utf-8")
parts.append(f"const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze({registry});")
parts.append((root / "src/admin/web/app" / part).read_text(encoding="utf-8"))
primary = "\n".join(parts)
panel = "\n".join(
read_vhosts_js(root) if name == "vhosts.js" else (root / "src/admin/web" / name).read_text(encoding="utf-8")
for name in PANEL_PARTS
)
return primary + "\n" + panel
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
"""Rust-independent structural checks; the Rust tests require Cargo."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
source = (ROOT / 'src/proxy/forward/web_source.rs').read_text()
helper = (ROOT / 'src/proxy/web_cache_revalidation.rs').read_text()
tests = (ROOT / 'src/proxy/web_cache_revalidation/tests.rs').read_text()
assert 'mod web_cache_revalidation;' in (ROOT / 'src/proxy/mod.rs').read_text()
assert 'state.cache.revalidation_tiered(ticket).await' in source
assert '!cached.reusable_for_request(&request_headers)' in source
assert 'web_origin_request_headers(' in source
assert 'revalidation_sent && response.status() == StatusCode::NOT_MODIFIED' in source
assert 'finish_web_revalidation(' in source
assert 'state.cache.discard(ticket)' in source
assert 'cache.publish_revalidated(' in helper
assert 'cache.ticket_current(ticket)' in helper
assert 'cache.discard(ticket)' in helper
assert 'origin_safe' in helper
assert 'cached.apply_validators(&mut headers)' in helper
assert 'headers.remove("if-none-match")' in helper
assert 'headers.remove("if-modified-since")' in helper
for name in (
'browser_refresh_304_preserves_web_server_hit_counter',
'browser_conditional_refresh_returns_304_without_resetting_hits',
'mismatched_browser_validator_cannot_validate_different_cached_object',
'range_and_head_requests_use_validated_cached_body',
'changed_local_file_is_not_falsely_revalidated',
'304_new_private_policy_discards_cached_representation',
):
assert f'async fn {name}()' in tests
print('PASS: RustProxy 0.32.2 Web Server revalidation wiring and Rust regression contracts (static only)')
-219
View File
@@ -1,219 +0,0 @@
#!/usr/bin/env python3
"""Rust-independent 0.25 Web Server architecture/security contract checks."""
from pathlib import Path
import json
from rust_source import rust_source, rust_tree
from web_assets import read_app_js, read_vhosts_js
ROOT = Path(__file__).resolve().parents[2]
def need(text: str, *markers: str) -> None:
for marker in markers:
assert marker in text, marker
config = rust_source(ROOT, "src/config.rs")
vhost = rust_source(ROOT, "src/vhost.rs")
web_dir = ROOT / "src/web_server"
assert not (ROOT / "src/web_server.rs").exists(), "Web Server monolith must not coexist with src/web_server/mod.rs"
for module in ["mod.rs", "config.rs", "filesystem.rs", "security.rs", "response.rs", "request.rs", "routing.rs", "fastcgi.rs", "serve.rs", "htaccess.rs", "tests.rs"]:
assert (web_dir / module).is_file(), module
web = rust_tree(ROOT, "src/web_server")
proxy = rust_source(ROOT, "src/proxy/mod.rs")
admin = rust_source(ROOT, "src/admin/mod.rs")
ui = read_vhosts_js(ROOT)
web_ui = (ROOT / "src/admin/web/web-server.js").read_text() + "\n" + (ROOT / "src/admin/web/web-server-profiles.js").read_text()
app_ui = read_app_js(ROOT)
html = (ROOT / "src/admin/web/index.html").read_text()
settings_ui = (ROOT / "src/admin/web/settings.js").read_text()
logging = rust_source(ROOT, "src/logging.rs")
metrics = rust_source(ROOT, "src/metrics.rs")
acl = rust_source(ROOT, "src/acl.rs")
openapi = json.loads((ROOT / "src/admin/openapi.json").read_text())
need(config,
"pub web_services: Vec<crate::web_server::Service>",
"pub enum CompiledSource",
"CompiledSource::WebServer",
"unknown Web Service",
"web_server_main_source_needs_no_backend_pool",
"web_server_and_reverse_proxy_can_share_one_vhost",
"one_web_service_can_be_reused_by_multiple_vhosts",
"missing_source_fields_remain_reverse_proxy_for_old_configs")
need(vhost,
"pub enum Source",
"WebServer { web_service_id: String }",
"pub source: Source",
"vhost requires at least one backend member for Reverse Proxy sources",
"Web Server location cannot pin a reverse-proxy backend")
# PATH_INFO must be dispatched before `try_files` rewrites a valid
# Roundcube /static.php/skins/... request into the index.php controller.
serve = (web_dir / "serve.rs").read_text()
assert serve.index("if let Ok(components) = path_components(&rewritten_path)") < serve.index("match resolve_try_files(")
assert "hardening_denied_path(service, &script_path)" in serve
assert "htaccess::denied(service.htaccess.as_ref(), &script_path)" in serve
need(web,
"roundcube_static_php_path_info_runs_before_front_controller_try_files",
"without_php_path_info_try_files_keeps_legacy_front_controller_fallback")
need(web,
"pub struct Service",
"pub document_root: PathBuf",
"pub index: Vec<String>",
"pub directory_listing: bool",
"pub mime_types: BTreeMap<String, String>",
"pub default_mime_type: String",
"pub cache_control: Option<String>",
"pub cache_rules: Vec<CacheRule>",
"pub response_headers: BTreeMap<String, String>",
"pub try_files: Vec<String>",
"pub rewrites: Vec<RewriteRule>",
"pub precompressed: Precompressed",
"pub download_extensions: Vec<String>",
"pub symlink_policy: SymlinkPolicy",
"pub php: Php",
"pub htaccess: HtaccessConfig",
"pub hardening: Hardening",
"hardening_blocks_common_probe_paths_and_trace",
"hardening_strips_fingerprint_headers_after_source_rules",
"HTTP_PROXY is a well-known CGI/FastCGI injection vector",
'if name.as_str() == "proxy"',
'"content-length"',
'"proxy-authenticate"',
'"proxy-authorization"',
"mod htaccess",
"htaccess::apply_rewrites",
"htaccess::protected_component",
"htaccess_wordpress_style_front_controller_and_protected_files_work",
"htaccess_directory_index_mime_headers_expires_and_nested_scope_work",
"htaccess_strict_mode_rejects_unsupported_directives",
"htaccess_security_sensitive_unsupported_directives_fail_closed",
"php_source_extensions_are_not_served_without_php_fpm",
"htaccess_php_addhandler_requires_php_fpm",
"FastCgiEndpoint",
"FCGI_BEGIN_REQUEST",
"try_acquire_owned",
"CONTENT_DISPOSITION",
"CONTENT_ENCODING",
"libc::openat",
"libc::O_NOFOLLOW",
"ReaderStream::new(file.take(body_length))",
"IF_NONE_MATCH", "IF_MODIFIED_SINCE", "IF_RANGE", "RANGE",
"StatusCode::NOT_MODIFIED", "StatusCode::PARTIAL_CONTENT", "StatusCode::RANGE_NOT_SATISFIABLE",
"listing_escapes_html_and_encodes_urls",
"root_directory_listing_is_repeatable_across_refreshes",
"index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory",
"mime_overrides_default_mime_and_cache_control_are_applied_to_files",
'HeaderValue::from_static("no-store")',
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
"empty_index_can_go_directly_to_directory_listing",
"env!(\"CARGO_PKG_VERSION\")",
"traversal_and_symlink_escape_are_not_served",
"try_files_rewrites_cache_rules_headers_and_downloads_work",
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
"php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure",
"php_fpm_executes_configured_directory_index",
"php_fpm_failure_returns_gateway_error_instead_of_php_source",
"inside_root_symlink_policy_allows_only_targets_beneath_root",
"secure filesystem confinement currently requires a Unix platform")
need(proxy,
"if let crate::config::CompiledSource::WebServer(service) = &route.source",
"let request_method = request.method().clone()",
"crate::web_server::serve(",
"let request_headers = request.headers().clone()",
"response.extensions_mut().insert(StaticSourceResponse)",
"execute_error_fallback(",
"crate::pages::replace_error",
"state.cache.wrap_with_fill(",
"crate::transform::apply(",
"apply_response_rules(",
"finish_headers(",
"crate::compression::encode(")
assert "crate::web_server::serve(service, &request" not in proxy
basic_identity_pos = proxy.index("basic_identity =")
identity_pos = proxy.index("let identity =")
web_source_pos = proxy.index("CompiledSource::WebServer(service)", basic_identity_pos)
assert basic_identity_pos < web_source_pos
assert identity_pos < web_source_pos
assert web_source_pos < proxy.index("let (backend, backend_lease, affinity_cookie)")
web_source = rust_source(ROOT, "src/proxy/forward/web_source.rs")
# The 0.32.2 local-304 path also applies response rules, before the normal
# 200-origin processing. Enforce order using the latter's distinct call site.
normal_origin_rules = "apply_response_rules(response.headers_mut(),"
assert web_source.index(normal_origin_rules) > web_source.index("let origin_headers =")
assert web_source.index("crate::transform::apply(") > web_source.index(normal_origin_rules)
need(acl, "ambiguous encoded path character", '"/%2e%2e/secret"', '"/a%2fb"', '"/a%5cb"', '"/bad%"')
need(admin, '"/web-server"', '"web-services"', '"vhost_metrics"')
need((ROOT / "src/admin/panel_assets.rs").read_text(), '"/assets/web-server.js"')
need(html, 'data-panel="web-server"', 'id="panel-web-server"', 'id="web-service-dialog"',
'id="web-service-tabs"', 'data-web-service-page="general"', 'data-web-service-page="routing"',
'data-web-service-page="mime"', 'data-web-service-page="cache"', 'data-web-service-page="delivery"',
'data-web-service-page="php"', 'data-web-service-page="security"', 'data-web-service-page="htaccess"',
'id="web-service-htaccess-enabled"', 'id="web-service-htaccess-help"', 'id="htaccess-help-dialog"', 'id="web-service-try-files"',
'id="web-service-cache-rules"', 'id="web-service-precompressed"', 'id="web-service-compression"', 'id="web-service-compression-zstd"', 'id="web-service-compression-br"', 'id="web-service-compression-gzip"', 'id="web-service-compression-deflate"', 'id="web-service-php-enabled"',
'id="web-service-php-pass-authorization"', 'id="web-service-php-profile"',
'id="web-service-php-profiles"', 'id="php-fpm-profiles-dialog"',
'id="web-service-php-body-limit"',
'id="web-service-php-header-limit"', 'id="web-service-php-stderr-limit"',
'id="web-service-php-custom-params"', 'id="web-service-php-values"', 'id="php-help-dialog"',
'id="web-service-symlink-policy"', 'id="web-service-preset"', 'id="web-service-preset-apply"',
'id="web-service-hardening-attacks"', 'id="web-service-hardening-headers"', 'data-logging-settings-tab="outputs"',
'data-logging-settings-tab="format"', 'data-logging-settings-tab="errors"')
need(settings_ui, "selectLoggingTab", 'data-logging-settings-page', 'selectLoggingTab("outputs")')
need(web_ui, 'saveSection("web-services"', "directory_listing", "web_service_id", "mime_types", "default_mime_type",
"cache_control", "cache_rules", "response_headers", "try_files", "rewrites", "precompressed", "compression",
"download_extensions", "symlink_policy", "php", "htaccess", "hardening", "presetConfig", "phpFrontController", "staticCacheRules",
"pass_authorization", "max_request_body_bytes", "max_response_header_bytes", "stderr_log_bytes", "custom_params", "php_values",
"syncHtaccess", "htaccess-help-dialog", "php-help-dialog", "parseFastCgiParams", "parsePhpValues", "validatePhpCustomData", "activateTab", "initTabs", "presetChanged",
'button = el("button", t(key), "quiet")', 'button.dataset.tab = name', 'button.setAttribute("role", "tab")', "vhost-detail",
'el("span", t(key), "small-label")', "if (envelope) render();")
need(config, "pub php_fpm_profiles: Vec<crate::web_server::PhpFpmProfile>", "duplicate PHP-FPM profile id", "unknown PHP-FPM profile")
need(web, "pub struct PhpFpmProfile", "pub php_profile_id: Option<String>", "fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests")
need(proxy, "empty_for_cache(&mut request)", 'request.headers_mut().insert(HOST, original_host.clone())')
need(web_ui, "phpProfileLocal", 'saveSection("php-fpm-profiles"', "function profileEditor(")
need(logging, '"route"', '"cache_effective_enabled"', '"transform_effective_enabled"', 'value["cache_enabled"]', 'value["transform_enabled"]')
need(metrics, "pub struct VhostMetricsView", "ewma_duration_us", "pub fn view(&self) -> VhostMetricsView")
assert 'item.append(text("span"' not in web_ui
need(app_ui, 'function showStartupFailure(error)', '$("login").hidden = true', 'const info = await api("/api/auth/session")')
assert '$("login").hidden = false; document.documentElement.classList.remove("app-loading")' not in app_ui
need(ui, 'type: "web_server"', "web_service_id", "sourceReverseProxy", "vhBackendRequired", "backend-diagnostics-card", "status.vhost_metrics", "vhostTrafficRate")
assert "WebService" in openapi.get("components", {}).get("schemas", {})
assert "PhpFpmProfile" in openapi["components"]["schemas"]
assert "php_fpm_profiles" in openapi["components"]["schemas"]["Config"]["properties"]
web_service_schema = openapi["components"]["schemas"]["WebService"]
assert "php_profile_id" in web_service_schema["properties"]
assert web_service_schema["properties"]["index"].get("minItems", 0) == 0
assert web_service_schema["properties"]["index"]["maxItems"] == 16
assert web_service_schema["properties"]["mime_types"]["maxProperties"] == 64
assert web_service_schema["properties"]["default_mime_type"]["default"] == "application/octet-stream"
assert web_service_schema["properties"]["cache_control"]["nullable"] is True
assert web_service_schema["properties"]["compression"]["$ref"].endswith("/CompressionOverrides")
compression_override_schema = openapi["components"]["schemas"]["CompressionOverrides"]
for prop in ["enabled", "zstd", "br", "gzip", "deflate"]:
assert prop in compression_override_schema["properties"], prop
for prop in ["cache_rules", "response_headers", "try_files", "rewrites", "precompressed", "compression", "download_extensions", "symlink_policy", "php", "htaccess", "hardening"]:
assert prop in web_service_schema["properties"], prop
assert web_service_schema["properties"]["symlink_policy"]["default"] == "deny"
assert web_service_schema["properties"]["php"]["properties"]["enabled"]["default"] is False
php_schema = web_service_schema["properties"]["php"]["properties"]
assert php_schema["pass_authorization"]["default"] is True
assert php_schema["max_request_body_bytes"]["default"] == 0
assert php_schema["max_response_header_bytes"]["default"] == 65536
assert php_schema["stderr_log_bytes"]["default"] == 4096
assert php_schema["custom_params"]["maxProperties"] == 64
assert php_schema["php_values"]["maxProperties"] == 64
assert web_service_schema["properties"]["htaccess"]["properties"]["enabled"]["default"] is False
assert web_service_schema["properties"]["htaccess"]["properties"]["filename"]["default"] == ".htaccess"
assert web_service_schema["properties"]["hardening"]["properties"]["block_common_attacks"]["default"] is False
assert web_service_schema["properties"]["hardening"]["properties"]["strip_unnecessary_headers"]["default"] is False
htaccess_source = rust_source(ROOT, "src/web_server/htaccess.rs")
need(htaccess_source, "MAX_HTACCESS_FILES", "MAX_HTACCESS_TOTAL_BYTES", "RewriteEngine", "RewriteCond", "RewriteRule", "DirectoryIndex", "AddType", "AddHandler", "ExpiresByType", "Require all granted/denied", "general Files/FilesMatch semantics are not supported", "security/handler directive", "apply_response", "apply_rewrites")
assert "Source" in openapi.get("components", {}).get("schemas", {})
assert "client_ips" in openapi["components"]["schemas"]["TrafficCounters"]["properties"]
assert (ROOT / "docs/WEB_SERVER.md").is_file()
assert (ROOT / "docs/PHP_FPM.md").is_file()
assert (ROOT / "docs/TODO.md").is_file()
docs_html = (ROOT / "src/admin/web/docs.html").read_text()
docs_js = (ROOT / "src/admin/web/docs.js").read_text()
need(docs_html, "docs-tab-reader", "docs-reader-content", "docs-tab-api")
need(docs_js, 'get("/docs/content.json")', "renderMarkdown", "selectDocument")
print("PASS: 0.25 Web Server separation, pipeline, filesystem, HTTP, UI/API and regression contracts are present.")
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import vm from "node:vm";
class ClassList {
constructor() { this.values = new Set(); }
toggle(name, active) { active ? this.values.add(name) : this.values.delete(name); }
}
class Element {
constructor(id = "") {
this.id = id;
this.value = "";
this.checked = false;
this.disabled = false;
this.hidden = false;
this.open = false;
this.dataset = {};
this.children = [];
this.listeners = new Map();
this.classList = new ClassList();
this.attributes = new Map();
this.tabIndex = 0;
this.textContent = "";
this.type = "";
this.readOnly = false;
this.elements = [];
}
addEventListener(name, callback) { this.listeners.set(name, callback); }
dispatch(name) { return this.listeners.get(name)?.({ target: this, preventDefault() {} }); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
append(...items) { this.children.push(...items); }
replaceChildren(...items) { this.children = [...items]; }
showModal() { this.open = true; }
close() { this.open = false; }
reset() {}
checkValidity() { return true; }
}
const ids = [
"htaccess-help-close","htaccess-help-dialog","php-help-close","php-help-dialog","web-service-cache-control","web-service-cache-rules",
"web-service-close","web-service-default-mime","web-service-dialog","web-service-download-extensions",
"web-service-editing","web-service-form","web-service-hardening-attacks","web-service-hardening-headers",
"web-service-htaccess-enabled","web-service-htaccess-filename","web-service-htaccess-help","web-service-htaccess-recursive",
"web-service-htaccess-strict","web-service-id","web-service-index","web-service-list","web-service-listing",
"web-service-mime-types","web-service-name","web-service-new","web-service-php-concurrency",
"web-service-php-connect-timeout","web-service-php-enabled","web-service-php-endpoint","web-service-php-extensions",
"web-service-php-request-timeout","web-service-php-pass-authorization","web-service-php-path-info","web-service-php-body-limit","web-service-php-header-limit",
"web-service-php-stderr-limit","web-service-php-custom-params","web-service-php-values","web-service-php-admin-values","web-service-php-help",
"web-service-compression","web-service-compression-options","web-service-compression-zstd","web-service-compression-br","web-service-compression-gzip","web-service-compression-deflate","web-service-precompressed","web-service-precompressed-br","web-service-precompressed-gzip",
"web-service-preset","web-service-preset-apply","web-service-preset-summary","web-service-reset",
"web-service-response-headers","web-service-rewrites","web-service-root","web-service-search",
"web-service-symlink-policy","web-service-tabs","web-service-try-files",
"web-service-php-profile", "web-service-php-profiles", "web-service-php-profiles-open", "php-fpm-profiles-dialog",
"php-fpm-profiles-list", "php-fpm-profiles-close", "php-fpm-profile-add", "php-fpm-profiles-help"
];
const elements = new Map(ids.map(id => [id, new Element(id)]));
const pages = ["general","routing","htaccess","mime","cache","delivery","php","security"].map(name => {
const page = new Element(); page.dataset.webServicePage = name; return page;
});
elements.get("web-service-php-endpoint").value = "unix:/run/php/php-fpm.sock";
elements.get("web-service-php-connect-timeout").value = "1000";
elements.get("web-service-php-request-timeout").value = "30000";
elements.get("web-service-php-concurrency").value = "64";
elements.get("web-service-php-pass-authorization").checked = true;
elements.get("web-service-php-path-info").checked = false;
elements.get("web-service-php-body-limit").value = "0";
elements.get("web-service-php-header-limit").value = "65536";
elements.get("web-service-php-stderr-limit").value = "4096";
elements.get("web-service-compression").value = "";
for (const name of ["zstd", "br", "gzip", "deflate"]) elements.get(`web-service-compression-${name}`).value = "";
const document = {
body: new Element("body"),
createElement: tag => { const node = new Element(); node.type = tag; return node; },
querySelectorAll(selector) {
if (selector === "#web-service-tabs button") return elements.get("web-service-tabs").children;
if (selector === "[data-web-service-page]") return pages;
return [];
}
};
let savedSection = null;
const context = {
console,
structuredClone,
document,
window: {},
envelope: null,
busy: false,
$: id => {
const value = elements.get(id);
if (!value) throw new Error(`missing fake element ${id}`);
return value;
},
t: key => key,
el: (_tag, text = "", className = "") => {
const node = new Element();
node.textContent = text ?? "";
if (className) node.classList.values.add(className);
return node;
},
toast: () => {},
saveSection: async (name, value) => { savedSection = { name, value: structuredClone(value) }; return true; },
appConfirm: async () => true,
};
vm.createContext(context);
vm.runInContext(fs.readFileSync("src/admin/web/drafts.js", "utf8"), context, { filename: "drafts.js" });
vm.runInContext(fs.readFileSync("src/admin/web/web-server-profiles.js", "utf8"), context, { filename: "web-server-profiles.js" });
vm.runInContext(fs.readFileSync("src/admin/web/web-server.js", "utf8"), context, { filename: "web-server.js" });
// Explicitly disabling scoped dynamic compression must hide and disable codec
// inheritance controls without erasing their saved selections.
const compressionMode = elements.get("web-service-compression");
const compressionOptions = elements.get("web-service-compression-options");
const compressionZstd = elements.get("web-service-compression-zstd");
compressionZstd.value = "off";
compressionMode.value = "off";
compressionMode.dispatch("change");
if (!compressionOptions.hidden) throw new Error("Disabled Web Service compression did not hide codec overrides");
for (const name of ["zstd", "br", "gzip", "deflate"]) {
if (!elements.get(`web-service-compression-${name}`).disabled) throw new Error(`Disabled Web Service compression left ${name} override active`);
}
compressionMode.value = "on";
compressionMode.dispatch("change");
if (compressionOptions.hidden) throw new Error("Re-enabled Web Service compression did not restore codec overrides");
if (compressionZstd.value !== "off") throw new Error("Compression visibility toggle erased the saved codec selection");
const preset = elements.get("web-service-preset");
preset.value = "wordpress";
preset.dispatch("change");
const expectedIndex = "index.php\nindex.html\nindex.htm";
const expectedTry = "$uri\n$uri/\n/index.php?$query_string";
if (elements.get("web-service-index").value !== expectedIndex) throw new Error("WordPress preset did not populate index files");
if (elements.get("web-service-try-files").value !== expectedTry) throw new Error("WordPress preset did not populate try_files");
if (!elements.get("web-service-php-enabled").checked) throw new Error("WordPress preset did not enable PHP-FPM");
if (!elements.get("web-service-precompressed").checked) throw new Error("WordPress preset did not enable precompressed sidecars");
if (!elements.get("web-service-cache-rules").value.includes("ext:css = public, max-age=604800")) throw new Error("WordPress preset did not populate cache rules");
preset.value = "downloads";
preset.dispatch("change");
if (elements.get("web-service-index").value !== "") throw new Error("Downloads preset should clear index files");
if (!elements.get("web-service-listing").checked) throw new Error("Downloads preset should enable directory listing");
if (elements.get("web-service-php-enabled").checked) throw new Error("Downloads preset should disable PHP-FPM");
if (elements.get("web-service-cache-control").value !== "public, max-age=3600") throw new Error("Downloads preset cache policy missing");
// The save path must serialize the values materialized by preset selection, not
// the previous/custom values that were present when the dialog opened.
context.envelope = { config: { web_services: [] } };
elements.get("web-service-id").value = "web1";
elements.get("web-service-name").value = "web1";
elements.get("web-service-root").value = "/srv/web1";
elements.get("web-service-php-pass-authorization").checked = false;
elements.get("web-service-php-path-info").checked = true;
elements.get("web-service-php-body-limit").value = "1048576";
elements.get("web-service-php-header-limit").value = "131072";
elements.get("web-service-php-stderr-limit").value = "8192";
elements.get("web-service-php-custom-params").value = "APP_ENV = production";
elements.get("web-service-php-values").value = "memory_limit = 256M";
preset.value = "wordpress";
preset.dispatch("change");
elements.get("web-service-compression").value = "on";
elements.get("web-service-compression-gzip").value = "off";
await elements.get("web-service-form").dispatch("submit");
if (savedSection?.name !== "web-services" || savedSection.value.length !== 1) throw new Error("WordPress preset save did not persist a Web Service");
const saved = savedSection.value[0];
if (saved.index.join("\n") !== expectedIndex) throw new Error("Saved WordPress preset lost index files");
if (saved.try_files.join("\n") !== expectedTry) throw new Error("Saved WordPress preset lost try_files");
if (!saved.php.enabled) throw new Error("Saved WordPress preset lost PHP-FPM enablement");
if (!saved.precompressed.enabled) throw new Error("Saved WordPress preset lost precompressed enablement");
if (saved.compression?.enabled !== true || saved.compression?.gzip !== false) throw new Error("Saved WordPress preset lost compression/codec override");
if (saved.php.pass_authorization !== false) throw new Error("Saved WordPress preset lost Authorization forwarding choice");
if (saved.php.path_info !== true) throw new Error("Saved WordPress preset lost PATH_INFO choice");
if (saved.php.max_request_body_bytes !== 1048576 || saved.php.max_response_header_bytes !== 131072 || saved.php.stderr_log_bytes !== 8192) throw new Error("Saved WordPress preset lost advanced FastCGI limits");
if (saved.php.custom_params.APP_ENV !== "production") throw new Error("Saved WordPress preset lost custom FastCGI parameters");
if (saved.php.php_values.memory_limit !== "256M") throw new Error("Saved WordPress preset lost PHP_VALUE settings");
if (!saved.cache_rules.some(rule => rule.matcher === "ext:css" && rule.cache_control.includes("604800"))) throw new Error("Saved WordPress preset lost cache rules");
console.log("PASS: Web Service preset selection materializes and persists CMS/download settings.");
// The shared editor lists profiles, saves them under the new revisioned
// section, and Web Services can attach without losing local PHP settings.
context.envelope.config.php_fpm_profiles = [{
id: "wp-pool", name: "WordPress pool", php: {
enabled: true, path_info: true, pass_authorization: true,
extensions: ["php"], endpoint: "tcp:127.0.0.1:9000",
connect_timeout_ms: 1000, request_timeout_ms: 30000,
max_concurrent_requests: 64, max_request_body_bytes: 0,
max_response_header_bytes: 65536, stderr_log_bytes: 4096,
custom_params: {}, php_values: {}, php_admin_values: {}
}
}];
// Both entry points must share the existing modal; the toolbar shortcut
// belongs next to New Web Service rather than inside the service editor.
const html = fs.readFileSync("src/admin/web/index.html", "utf8");
const profileDialog = html.split('id="php-fpm-profiles-dialog"')[1]?.split('</dialog>')[0] ?? '';
const listAt = profileDialog.indexOf('id="php-fpm-profiles-list"');
const addAt = profileDialog.indexOf('id="php-fpm-profile-add"');
const dividerAt = profileDialog.indexOf('class="php-profile-help-divider"');
const helpAt = profileDialog.indexOf('id="php-fpm-profiles-help"');
if (!(listAt >= 0 && listAt < addAt && addAt < dividerAt && dividerAt < helpAt)) {
throw new Error("PHP-FPM syntax/safety help must be at the bottom of the profile modal, separated by a rule");
}
const webPanel = html.split('id="panel-web-server"')[1]?.split("</section>")[0] ?? "";
const toolbar = webPanel.match(/<div class="vhost-list-tools">([\s\S]*?)<\/div>/)?.[1] ?? "";
const shortcutAt = toolbar.indexOf('id="web-service-php-profiles-open"');
const newAt = toolbar.indexOf('id="web-service-new"');
if (shortcutAt < 0 || newAt < 0 || shortcutAt > newAt) {
throw new Error("PHP-FPM profiles shortcut must be adjacent to New Web Service in the toolbar");
}
elements.get("web-service-php-profiles-open").dispatch("click");
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("Toolbar PHP-FPM profile shortcut did not open the modal");
elements.get("php-fpm-profiles-close").dispatch("click");
if (elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal failed to close");
elements.get("web-service-php-profiles").dispatch("click");
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal was not opened");
const profileDetail = elements.get("php-fpm-profiles-list").children[0];
if (!profileDetail || profileDetail.children[0].textContent !== "WordPress pool") throw new Error("Shared profile list missing");
const profileForm = profileDetail.children[1];
const phpRows = profileForm.children.filter(node => node.classList.values.has("php-switch-row vh-wide"));
if (phpRows.length !== 3) throw new Error("PHP-FPM / PATH_INFO / Authorization switches do not have separate aligned rows");
for (const row of phpRows) {
if (row.children.length !== 2 || row.children[0].children[0].type !== "checkbox" ||
row.children[1].children[0].textContent !== "i" || row.children[1].children[1].children.length < 1) {
throw new Error("Profile PHP toggle lacks its checkbox label or explanation popover");
}
}
if (!phpRows[0].children[0].children[0].checked || !phpRows[1].children[0].children[0].checked) {
throw new Error("PHP execution and PATH_INFO saved values were not restored");
}
phpRows[1].children[0].children[0].checked = false;
if (!phpRows[0].children[0].children[0].checked) {
throw new Error("Changing PATH_INFO also disabled PHP execution");
}
phpRows[1].children[0].children[0].checked = true;
elements.get("php-fpm-profiles-help").dispatch("click");
if (!elements.get("php-help-dialog").open) throw new Error("PHP profile help button did not open syntax documentation");
elements.get("php-help-close").dispatch("click");
if (elements.get("php-help-dialog").open) throw new Error("PHP syntax help dialog did not close");
profileForm.children[1].children[1].value = "Updated WordPress pool";
await profileForm.dispatch("submit");
if (savedSection?.name !== "php-fpm-profiles" || savedSection.value[0].name !== "Updated WordPress pool") {
throw new Error("Shared profile edit did not save to the profile configuration section");
}
const profileSelector = elements.get("web-service-php-profile");
const localEndpoint = elements.get("web-service-php-endpoint").value;
profileSelector.value = "wp-pool";
profileSelector.dispatch("change");
if (!elements.get("web-service-php-endpoint").disabled) throw new Error("Shared profile left local PHP controls editable");
elements.get("web-service-id").value = "wp-shared";
elements.get("web-service-name").value = "WordPress shared";
await elements.get("web-service-form").dispatch("submit");
if (savedSection?.name !== "web-services" || savedSection.value[0].php_profile_id !== "wp-pool") {
throw new Error("Web Service did not persist selected shared PHP-FPM profile");
}
if (savedSection.value[0].php.endpoint !== localEndpoint) {
throw new Error("Attaching a shared FPM profile changed local settings");
}
console.log("PASS: Toolbar and editor PHP-FPM shortcuts open the shared modal; profile edits and Web Service attachment work.");
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
// Exercise the actual domain-choice sync function from the vhost editor with
// DOM stand-ins. This does not replace browser or Rust runtime acceptance.
const source = fs.readFileSync("src/admin/web/vhosts/editor.js", "utf8");
const start = source.search(/^\s*const syncCanonical = \(\) => \{/m);
const end = source.indexOf("refreshCanonical = syncCanonical;", start);
assert.ok(start >= 0 && end > start, "WWW selection logic must remain discoverable");
const selectedLogic = source.slice(start, end);
class Element {
constructor(tag = "", body = "", className = "") {
this.tag = tag;
this.textContent = body ?? "";
this.className = className;
this.children = [];
this.listeners = {};
this.checked = false;
this.disabled = false;
this.hidden = false;
this.value = "";
this.attrs = new Map();
this.classes = new Set();
this.classList = {
add: name => this.classes.add(name),
toggle: (name, active) => active ? this.classes.add(name) : this.classes.delete(name),
};
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; }
setAttribute(key, value) { this.attrs.set(key, String(value)); }
addEventListener(event, callback) { this.listeners[event] = callback; }
fire(event) { this.listeners[event]?.(); }
}
function editor(hosts, previous = "", checked = false) {
const dom = Object.fromEntries([
"wwwTarget", "wwwChoices", "wwwOther", "wwwNoHosts", "wwwDetails", "wwwInactive", "wwwRedirect", "wwwPanel", "wwwHostCount"
].map(id => [id, new Element()]));
dom.wwwRedirect.checked = checked;
let changes = 0;
const bindings = {
...dom,
document: { createElement: tag => new Element(tag) },
el: (tag, content, className) => new Element(tag, content, className),
text: (_tag, key) => new Element("span", key),
t: key => key === "vhWwwSharedCount" ? "{count} names / one vhost" : key,
read: key => { assert.equal(key, "hosts"); return { value: hosts.join("\n") }; },
lines: value => value.split(/[\n,]+/).filter(Boolean),
changed: () => { changes++; },
};
const code = `let selectedCanonical = ${JSON.stringify(previous)};\n${selectedLogic}\nthis.run = () => syncCanonical();\nthis.getSelected = () => selectedCanonical;`;
const runtime = vm.createContext(bindings);
vm.runInContext(code, runtime, { filename: "editor-www-sync.js" });
runtime.run();
return { runtime, dom, countChanges: () => changes };
}
const result = editor(["suba.local", "subb.local"], "www.subb.local", false);
assert.equal(result.dom.wwwDetails.hidden, false, "destination cards must be visible while redirect is disabled");
assert.equal(result.dom.wwwTarget.value, "www.subb.local", "www target must survive editor re-opening");
assert.equal(result.dom.wwwChoices.children.length, 2, "unrelated domains must remain distinct groups");
assert.equal(result.dom.wwwHostCount.textContent, "2 names / one vhost");
const aChoices = result.dom.wwwChoices.children[0].children[1].children;
assert.equal(aChoices.length, 2, "each domain must offer both www and non-www targets");
assert.equal(aChoices[0].children[0].value, "suba.local");
assert.equal(aChoices[1].children[0].value, "www.suba.local");
assert.ok(aChoices[1].children[1].children[1].textContent.includes("suba.local"), "preview must show direction");
// Select www destination for a different host, without editing hostname chips.
aChoices[1].children[0].checked = true;
aChoices[1].children[0].fire("change");
assert.equal(result.dom.wwwTarget.value, "www.suba.local");
assert.equal(result.countChanges(), 1);
assert.ok(result.dom.wwwOther.children.some(child => child.textContent === "subb.local"), "other alias remains in vhost");
result.dom.wwwRedirect.checked = true;
result.runtime.run();
assert.equal(result.dom.wwwInactive.hidden, true);
assert.ok(result.dom.wwwPanel.classes.has("is-enabled"));
const empty = editor(["*.example.test"], "", true);
assert.equal(empty.dom.wwwRedirect.disabled, true, "wildcard-only vhost cannot enable WWW canonicalization");
assert.equal(empty.dom.wwwRedirect.checked, false);
const canonical = editor(["www.example.test"], "example.test", true);
assert.equal(canonical.dom.wwwTarget.value, "example.test", "bare target may be implicit when only www host is configured");
assert.equal(canonical.dom.wwwChoices.children.length, 1);
console.log("PASS: WWW destination cards, both directions, disabled preview, live update, aliases and wildcard guard.");