v0.31.0
This commit is contained in:
1 parent
f213d853e2
commit
76b8bb7ee2
78 files changed
+7750
-5204
No files matched your search
@@ -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.30.1.
|
compatibility. Current source version: 0.31.0.
|
||||||
|
|
||||||
## Delivery
|
## Delivery
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,21 @@
|
|||||||
> test-only changes and packaging implementation notes were moved to
|
> test-only changes and packaging implementation notes were moved to
|
||||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||||
|
|
||||||
|
## 0.31.0
|
||||||
|
|
||||||
|
- Repair the reported HTTP cache-maintenance Rust E0716 temporary-borrow compilation error. Harden BAN/PURGE with scheme/host/vhost isolation, unambiguous headers and bounded background cache-maintenance workers (shared with authenticated admin purge/invalidation); audit BAN-rule removals before mutation. Add a localized curl-examples tooltip to Settings → Cache. See the [0.31.0 API and panel security review](docs/SECURITY_AUDIT_0.31.0.md) for assessed controls and remaining runtime security gates.
|
||||||
|
|
||||||
|
- Add browser-negotiated Zstd/Brotli/gzip compression for panel HTML and static assets, configurable under Settings → Administration without an extra environment variable; cached representations and `Vary: Accept-Encoding` work with or without a reverse proxy (subject to forwarded headers).
|
||||||
|
- Add strictly opt-in, IP/CIDR-gated HTTP `PURGE` (exact URL) and `BAN` (lazy URL prefix) on public vhost listeners, using the existing invalidation engine, ACL/rate limits, audit and CIDR validation. Empty allowlist keeps the feature disabled; no DB or cache-format migration.
|
||||||
|
- Align the System information manual Refresh control directly next to the API badge.
|
||||||
|
- Make the authenticated panel visible during initialization: replace the previous fully hidden body with an accessible loading indicator, a delayed reload action and an explicit startup-failure state. Paint the Overview shell before slow preferences/config/status requests complete.
|
||||||
|
- Place **Refresh now** beside the Overview title at the far right, including on narrow mobile screens. Serve panel HTML/assets using the new configurable negotiated codec policy, with an uncompressed fallback. No new endpoint, API change or proxy traffic change.
|
||||||
|
- Add per-administrator Refresh preferences under Settings → Appearance, including Live enable/disable and supported intervals for status/SSE (1/2/5/10 s), Traffic/Cache (0/3/5/10/30 s), and system information (0/15/30/60/300 s). Reset and manual refresh remain available, even when automatic updates are off.
|
||||||
|
- Replace duplicated dashboard timers with one coordinator for SSE, fallback polling, Traffic, Cache, Transform, Docker, ACME, tunnel diagnostics and editor-draft presentation. Poll only active views, prevent overlapping requests, abort stale views, pause when hidden and stop on logout.
|
||||||
|
- Keep the full authenticated status API for compatibility; add a compact live-only status view and a separate authenticated `/api/system-info`. SSE snapshots exclude full configuration and expensive system diagnostics. SSE interval is per client and does not affect other sessions.
|
||||||
|
- Update English and Polish UI labels, OpenAPI, documentation and release checks. No database schema, cache format or proxy data-plane changes.
|
||||||
|
- The Rust compiler and real protocol/load acceptance remain mandatory where the toolchain is unavailable.
|
||||||
|
|
||||||
## 0.30.1
|
## 0.30.1
|
||||||
|
|
||||||
- Fix protocol chart legend dots for HTTP and WebSocket (H1/H2/H3) so their colors always follow the configured line palette.
|
- Fix protocol chart legend dots for HTTP and WebSocket (H1/H2/H3) so their colors always follow the configured line palette.
|
||||||
|
|||||||
Generated
+1
-1
@@ -1716,7 +1716,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-proxy"
|
name = "rust-proxy"
|
||||||
version = "0.30.1"
|
version = "0.31.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rust-proxy"
|
name = "rust-proxy"
|
||||||
version = "0.30.1"
|
version = "0.31.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
description = "RustProxy - independent reverse proxy with local administration"
|
description = "RustProxy - independent reverse proxy with local administration"
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
# RustProxy 0.30.1
|
# RustProxy 0.31.0
|
||||||
|
|
||||||
|
**0.31.0 Axum E0277 follow-up:** the public BAN/PURGE async handler now takes ownership of `Request<Body>` and drops the body before its audit/cache awaits. This avoids a non-`Send` borrow through Axum 0.8 `Router::fallback(forward)`; the rest of the request pipeline is unchanged. A source regression guard checks the ownership boundary; real `cargo check --locked --all-targets` is still required.
|
||||||
|
|
||||||
|
**0.31.0 security/compilation follow-up:** resolve reported E0716 lifetime error in cache maintenance; bound concurrent BAN/PURGE/admin invalidations, preserve HTTP/HTTPS and vhost cache isolation, reject ambiguous maintenance headers and add an EN/PL curl-examples tooltip under Settings → Cache. [Security review and runtime test plan](docs/SECURITY_AUDIT_0.31.0.md). Rust compilation is not verified in this source snapshot.
|
||||||
|
|
||||||
|
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
|
||||||
|
|
||||||
Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration,
|
Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration,
|
||||||
streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools.
|
streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools.
|
||||||
|
|
||||||
**0.30.1 release source.**
|
**0.31.0 release source.**
|
||||||
|
|
||||||
## Layer-4 streams
|
## Layer-4 streams
|
||||||
|
|
||||||
@@ -11,7 +17,16 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a
|
|||||||
|
|
||||||
Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
|
Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
|
||||||
|
|
||||||
## What changed in 0.30.1
|
## What changed in 0.31.0
|
||||||
|
|
||||||
|
- **Panel startup/mobile follow-up:** replace the former white loading state with a RustProxy spinner, an automatic delayed reload suggestion and an error screen with reload link. Paint the shell before waiting for preferences/configuration/initial status; the header's **Refresh now** action sits beside the Overview title without adding a row. Negotiate Zstd/Brotli/gzip for fixed panel HTML and assets, with one-time encoding per resource; preserve public login and identity clients.
|
||||||
|
- Refresh Overview through a shared, authenticated SSE channel with per-administrator live pacing (1, 2, 5 or 10 seconds); fallback status/log polling is active only after SSE failure. Full configuration and system diagnostics are not serialized on every live event.
|
||||||
|
- **Settings → Appearance → Data refresh** now stores per-account Live enable/disable and independent Traffic, Cache and System information intervals. Manual mode (`0`) and the global Live switch disable automatic requests while preserving manual refresh, charts, filters and pagination.
|
||||||
|
- Poll only the current Traffic/Cache view, and fetch system diagnostics independently from `/api/system-info` when Overview or the relevant Settings section needs them. The previous full `/api/status` response remains supported, while `?view=live` provides compact fallback.
|
||||||
|
- Centralize dashboard polling with cancellation/in-flight guards, hidden-tab pause and logout cleanup; retain SQLite schema 9, previous preferences, proxy traffic and the existing authentication model.
|
||||||
|
- Rust compilation and live wire/performance acceptance remain required on an equipped target; see [verification report](docs/TEST_REPORT.md).
|
||||||
|
|
||||||
|
## Previously in 0.30.1
|
||||||
|
|
||||||
- Match H1/H2/H3 legend markers to the corresponding protocol-chart line colors across chart palettes.
|
- Match H1/H2/H3 legend markers to the corresponding protocol-chart line colors across chart palettes.
|
||||||
- Keep **About** focused on the product, author, license and source/package links. Move Python/ACME diagnostics out of the About dialog.
|
- Keep **About** focused on the product, author, license and source/package links. Move Python/ACME diagnostics out of the About dialog.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# RustProxy roadmap
|
# RustProxy roadmap
|
||||||
|
|
||||||
|
## 0.31.0 - Panel refresh orchestration
|
||||||
|
|
||||||
|
- [x] Centralize authenticated polling with source IDs, view lifecycle, hidden-tab pause, abort, in-flight protection and cleanup.
|
||||||
|
- [x] Separate compact live telemetry from infrequent cached system diagnostics; allow per-client SSE pacing and reconnect-only fallback.
|
||||||
|
- [x] Add per-account refresh preferences, reset/manual actions, language packs, endpoint contracts and regression tests.
|
||||||
|
- [ ] Compile/test on Rust toolchain and perform real multi-session SSE, latency and long-running browser acceptance before production release.
|
||||||
|
|
||||||
## 0.30.1 - Protocol legend and system information
|
## 0.30.1 - Protocol legend and system information
|
||||||
|
|
||||||
- [x] Color the H1/H2/H3 legend dots consistently with their themed chart strokes.
|
- [x] Color the H1/H2/H3 legend dots consistently with their themed chart strokes.
|
||||||
|
|||||||
+76
-56
@@ -1,18 +1,18 @@
|
|||||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||||
2af2dd8a13e8e1f16639fbca5b72ad0d82f6abf3a2e3ac146341a6a007547904 AGENTS.md
|
5ef98bbda0c22bd928ad4e11a1b6a8d3de117d3b492228c4a707efe2ecf861f0 AGENTS.md
|
||||||
64179555a0e536fa58e4e265ad98cdd09e3809b81cdf19ad0ba8609c35d28ddc CHANGELOG.md
|
e1652d430065765a610eb413c89b8711586a495db7e6de1545262a2670d96453 CHANGELOG.md
|
||||||
e0409a737959eb4b8122efa279401eda5b3b78155a6e9d2a4fdc891bfb4a1754 Cargo.lock
|
0afa3c54b9fa6790295b93ddd4afb957d9043da2621dc224188f916fdddd8cbc Cargo.lock
|
||||||
125a8a3c3f785675190518b398f64aee12fb0c82120d29be61012865391b3636 Cargo.toml
|
b1b58b9500b20de1e3c97e2cbb4cafac8c0cbca159059df7b5f28591f18527fd Cargo.toml
|
||||||
75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile
|
75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile
|
||||||
b8aca9e4be342f43b64a2eb04522c6582243edbfb764457afdf58ee894131480 README.md
|
af4f275c91d8783c496438078fd8e22515c0248c26fba8af803aa085accf6a56 README.md
|
||||||
40f21bcecdf85e951d07059fe5bd5a49b844a9f22e943410534a0d8b308a26ac ROADMAP.md
|
9e14f638490fd2e4a11abf24a27c38f939850c0cb63a2b748ce214ccc0a927e9 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
|
||||||
550197e512e5a3b3c1dec66f69ab31368e82a317dfac653ecd60b11dac45195e dev.sh
|
b665af2aade88286628fce98fb61b48c37fef9823fbd2eb05e8898fada6c58d2 dev.sh
|
||||||
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
||||||
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
||||||
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
||||||
@@ -27,10 +27,10 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
|
|||||||
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
|
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
|
||||||
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
|
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
|
||||||
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
|
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
|
||||||
248bd040dcbda23e223a439e17232cd681deed3a77f273615a6ffa22ddfe51e4 docs/API.md
|
8ded74f422426606238fa9876b49277a7bc56ddd96062a836cdd6ddbf78bd335 docs/API.md
|
||||||
f054206a09bed81018708608cdeecbfd09018ee7cec9d3abc038f8a37d47b815 docs/API_ACCESS.md
|
f054206a09bed81018708608cdeecbfd09018ee7cec9d3abc038f8a37d47b815 docs/API_ACCESS.md
|
||||||
4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md
|
4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md
|
||||||
598298303036912c19e0a4be1431acc7b7d3df7333f3e756e8268ac0e8c16921 docs/CACHE.md
|
e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md
|
||||||
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
|
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
|
||||||
29cc1237ab205fdbe1cc0afc0e905c211048d9b4de2c349d1e4a8427bd53c001 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
|
29cc1237ab205fdbe1cc0afc0e905c211048d9b4de2c349d1e4a8427bd53c001 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
|
||||||
c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md
|
c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md
|
||||||
@@ -39,6 +39,7 @@ befa50579e3475b22589f26380d85b3345b80e4fa947d49649a3b91a8dd8b8c2 docs/CUSTOM_LO
|
|||||||
5324b215075214a5bb9b388bc82d6491eacfc507df95856d1aecedd184e25b80 docs/ERROR_LOGGING.md
|
5324b215075214a5bb9b388bc82d6491eacfc507df95856d1aecedd184e25b80 docs/ERROR_LOGGING.md
|
||||||
35b7784afa9a2f7d805f7cd1bd188f93050fc0ebaea61b30152075fc7a2549bb docs/FILE_LOGS.md
|
35b7784afa9a2f7d805f7cd1bd188f93050fc0ebaea61b30152075fc7a2549bb docs/FILE_LOGS.md
|
||||||
8dfc89a8b3512103b87864bd7c24a3dc3de4465edf8f9874e6bed1f4634dd930 docs/HTTP3.md
|
8dfc89a8b3512103b87864bd7c24a3dc3de4465edf8f9874e6bed1f4634dd930 docs/HTTP3.md
|
||||||
|
5301e321611d494fd97b04ab648df1f66a6a0d1f1f4c3475eab485e138d5605b docs/IMPLEMENTATION_0.31.0.md
|
||||||
a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md
|
a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md
|
||||||
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
|
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
|
||||||
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
|
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
|
||||||
@@ -52,7 +53,7 @@ e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND
|
|||||||
e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md
|
e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md
|
||||||
bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md
|
bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md
|
||||||
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
|
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
|
||||||
f0786a65c93ba00830604fcc52004566199e757941ccc9481eb7b7b4232472e1 docs/README.md
|
c496cb868d9838376daa0d900c3a6d60e1772129669b183848d46715986f3b21 docs/README.md
|
||||||
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
|
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
|
||||||
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
|
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
|
||||||
d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md
|
d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md
|
||||||
@@ -60,9 +61,10 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H
|
|||||||
759bf2987f2b602488829d1856fa33d57c2a3b23bc681ef5a931b9633af3a533 docs/SECRET_STORE_BACKUP.md
|
759bf2987f2b602488829d1856fa33d57c2a3b23bc681ef5a931b9633af3a533 docs/SECRET_STORE_BACKUP.md
|
||||||
5dbdf45fddba198a28f511dbbd2f12bf7d2b88c41c3cbedbc20dfea3858779d3 docs/SECURITY.md
|
5dbdf45fddba198a28f511dbbd2f12bf7d2b88c41c3cbedbc20dfea3858779d3 docs/SECURITY.md
|
||||||
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
||||||
4c584a657606f0423d40670b72c8c95c814368be4f6635b4d6adc8c729780d0f docs/SETTINGS.md
|
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
||||||
|
2cd9dadc49a5474e4ab06d5c01fa38c27cab436578cb9c9f327a37b3406d2e13 docs/SETTINGS.md
|
||||||
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
|
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
|
||||||
ff24c09a3bde9607001fdc49acbb71b413a1c6479c8a59252da821492d7593f5 docs/TEST_REPORT.md
|
87b1e631bfec6a6b33d7b8718e06b50019586974c0714b1f1e902e4682c9fb8b docs/TEST_REPORT.md
|
||||||
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
||||||
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
||||||
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
|
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
|
||||||
@@ -134,8 +136,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
|
|||||||
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
|
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
|
||||||
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
||||||
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
|
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
|
||||||
86b95d536c1e81883f9e4002c7e72f19976436d5ece285cb103432b195ac648f langs/en_US.json
|
70c85a656bac197b7dcf7048188ec2d3320c3da39908f79477a6484b968f6901 langs/en_US.json
|
||||||
6a4e4fd967d29c38ee7e9a373004db85400bb8a1ecb51e1842f2da93b6247353 langs/pl_pl.json
|
0352d8d3c65ba5f60870a2fd3e945a79333e69a94d3349799ef285b22c3a9476 langs/pl_pl.json
|
||||||
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
||||||
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
|
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
|
||||||
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
|
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
|
||||||
@@ -178,43 +180,53 @@ e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests
|
|||||||
bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs
|
bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs
|
||||||
019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
|
019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
|
||||||
1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs
|
1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs
|
||||||
f84cdf7dc40553b6c5086446bafd5c700496f048230ebd95a01839858adff071 src/admin/documentation/documents.rs
|
ff158db7bed945d69a6e78e00ec84692114af035854628e39cf31c01acf52fc0 src/admin/documentation/documents.rs
|
||||||
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
|
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
|
||||||
002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs
|
002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs
|
||||||
2ebaf24e9d0d87383d2eea073ee2d3ac37962eeb503a0f1ee2c2de4f3f6d1b7a src/admin/file_logs.rs
|
2ebaf24e9d0d87383d2eea073ee2d3ac37962eeb503a0f1ee2c2de4f3f6d1b7a src/admin/file_logs.rs
|
||||||
d7aa68a78a4cb5a61342d92c1f3209af230d0f9ca3fea1115f67078372e6f37c src/admin/file_logs_io.rs
|
d7aa68a78a4cb5a61342d92c1f3209af230d0f9ca3fea1115f67078372e6f37c src/admin/file_logs_io.rs
|
||||||
b3e2c11dbc11bb8930117afdac64779111acc2ca7b2857f6e359a5108fc9ced8 src/admin/logging_api.rs
|
899e87a73bfe46c4078a46f9e1eb1c17b384472e8a30d2bab5486872b43fc809 src/admin/logging_api.rs
|
||||||
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
|
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
|
||||||
53347ce9fcb8ce297ff3565ac3d2af5060a56eb6fff51eeac4791c0bd9f5b296 src/admin/openapi.json
|
916d2121a6719f82b86cd59ad3c62d3d41f70812decae21998a93ff89710e43a src/admin/openapi.json
|
||||||
6c2b9a0ecb2f4172f110a363c22a6be32895dbcff9605fe7aa94efe91e0a536a src/admin/operations.rs
|
0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs
|
||||||
5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs
|
2b0ec6df0ed9e1bdc5c9e4ff1758b59dd14abc150f98365ac3edcf4672908e48 src/admin/panel_assets.rs
|
||||||
31bd90ee5f5ec2984920225ff6b57e07db3e0758092fb55d7c230e3590c1b015 src/admin/public.rs
|
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
||||||
9edf51cfbeb51ec5062fa24803247f6117a45dfe9ac731215470c3d034296020 src/admin/router.rs
|
7c113cd9d8642a808c73ebee24e695f7a318995e39dc2b81e747d2b2246e277f src/admin/preferences.rs
|
||||||
|
d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/preferences_columns.rs
|
||||||
|
9baf3c0ec4c543bda51605930ac57d6425c5e03e06d29ff755a7ce3ff611aeaf src/admin/public.rs
|
||||||
|
5135f743f628103deda9f50629065ca343f7a5bf872dcd3cc0033ac2971e361b src/admin/public/tests.rs
|
||||||
|
6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs
|
||||||
|
d45fbec28e2e3aa6e045ce7783545da319ad04d086ea22350609f1ab777f3096 src/admin/router.rs
|
||||||
8708427263cd4caf8454b9115e89acb09172483a090c6b0bd43811dd9e52c7f6 src/admin/session.rs
|
8708427263cd4caf8454b9115e89acb09172483a090c6b0bd43811dd9e52c7f6 src/admin/session.rs
|
||||||
c7dd05f83efccb8df5a8e4ea22bda072643e9d9cd52610d4c8a68f072270903b src/admin/status.rs
|
6fa2e586feb335ea9ce2c4dc523e422e4b2aa52c823ba8f8851f9e0f37cd2fef src/admin/status.rs
|
||||||
|
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs
|
||||||
|
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
|
||||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||||
5a8e2c4d0065c98f4686105c6f00362dd515f70f1ed662187f943ba4b76c54ad src/admin/tests.rs
|
3277a08915eb2e272ad4c0f3aa1ffa0c7fed4e6a1d1fb66d4bfd8a4a4ea515aa src/admin/tests.rs
|
||||||
5df98246c46135a176b4b8b705c92fd328719e82eb7342f088256abb976d5ae7 src/admin/tests/part_01.rs
|
5df98246c46135a176b4b8b705c92fd328719e82eb7342f088256abb976d5ae7 src/admin/tests/part_01.rs
|
||||||
6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs
|
6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs
|
||||||
34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs
|
34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs
|
||||||
46c6d04fb6a125ebb00f7295d3ad3c16071477fc40f2b377455ed7a6eb1370d2 src/admin/tests/part_04.rs
|
a1e14bfad1ba42045421fb761e0cc490cc30530409b22416db7e727548c06b93 src/admin/tests/part_04.rs
|
||||||
5f0062abc42a3b81c79088dc0824ab4c81a22c6ea765e77d2527d07d13bffd58 src/admin/tests/part_05.rs
|
5f0062abc42a3b81c79088dc0824ab4c81a22c6ea765e77d2527d07d13bffd58 src/admin/tests/part_05.rs
|
||||||
311c4ad16b3ad5b295a339241afd4ca8ea0d3c7f54f609c27518b4de1b4a4b71 src/admin/tests/part_06.rs
|
311c4ad16b3ad5b295a339241afd4ca8ea0d3c7f54f609c27518b4de1b4a4b71 src/admin/tests/part_06.rs
|
||||||
5fa006413fd93529ac9aaa4ffb28bf6b0b88d0a55a0bf0c49576db4a06fe9fc3 src/admin/tests/part_07.rs
|
5fa006413fd93529ac9aaa4ffb28bf6b0b88d0a55a0bf0c49576db4a06fe9fc3 src/admin/tests/part_07.rs
|
||||||
54ed2130e85239ffa4576417dd68fbb9349e396210b75f5da862897137d7e876 src/admin/tests/part_08.rs
|
54ed2130e85239ffa4576417dd68fbb9349e396210b75f5da862897137d7e876 src/admin/tests/part_08.rs
|
||||||
2ae5bc857dd62882f16d1cfcee9691c9ba8afc149f4c0435addd48b564487517 src/admin/web/acme.js
|
225a29e2e560e42e06e19c3d41c0196c4a42e1e84caa7c83446cab62e3ac30dc src/admin/tests/part_09.rs
|
||||||
|
bffa6611f3776e97d0065b510b63f083aabf7b3429fcc5cb26b1883fdb7c47dd src/admin/tests/part_10.rs
|
||||||
|
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
|
||||||
446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js
|
446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js
|
||||||
bd55186a8b02507cffe9fbd2dfb3cc5d3c54509a2a13853b5d93c5f6ec76e688 src/admin/web/app/core.js
|
8afd3356ba99843d28c107346b0f2f86fbbdf57d846554455fd374c4899bf2f2 src/admin/web/app/core.js
|
||||||
cb5c4c2dd4964795b61393b05537cc1528c72d7c1390953f4d119a5e55c65c7c src/admin/web/app/preferences.js
|
23721bfbac850c4f77b116cc32bb9d2db6361ac4dd7e310a8c8070ab906997f6 src/admin/web/app/preferences.js
|
||||||
de17987bda89dfab54b6751007474ff9e7cc8da35f7e9c46a2eff922a50fb085 src/admin/web/app/runtime.js
|
344faee969e9d3b8c2f59a443d8ab1090c556ceff325a109e745663baa5e644e src/admin/web/app/refresh.js
|
||||||
887e34c7e4b3e197e54151f4bb48932e4f7b9f8e64e3d3e8c9c7ee0835fbe4b0 src/admin/web/app/settings.js
|
31063816894c8efca08ff1c162a7fa2af71268c6deb8063146def2d0baabb17a src/admin/web/app/runtime.js
|
||||||
74777adaba5a448f3a9abf3a46d91efc6eb81ff6f3b5441e5b99a9e1d6c09af0 src/admin/web/cache-panel.js
|
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
|
||||||
|
4fec1344be3cd540d1e6d0b87fb0aa94e1bef2968c4f86366a62c56d4085f452 src/admin/web/cache-panel.js
|
||||||
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
|
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
|
||||||
720e201a29d4b3d2e487aa9b857ddcfe785f6f26190827ae5c265ef8c308a86a src/admin/web/docker.js
|
40cbf3a19827adbff179009b9189263a58a469319db25cf4adf0e4dc441c6532 src/admin/web/docker.js
|
||||||
d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css
|
d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css
|
||||||
60a4944a0a24b889296991b8af44b467f776fc85ec3597eb2e2940ef1f6fc713 src/admin/web/docs.html
|
60a4944a0a24b889296991b8af44b467f776fc85ec3597eb2e2940ef1f6fc713 src/admin/web/docs.html
|
||||||
6adb291fcc98d516fd9745d9673db16e408854a32cbf41335ae956bda11d46de src/admin/web/docs.js
|
6adb291fcc98d516fd9745d9673db16e408854a32cbf41335ae956bda11d46de src/admin/web/docs.js
|
||||||
4356ef9c862bd1095ab396b644aee227be51a2a3c960cc3fa1a02eafbc712429 src/admin/web/drafts.js
|
c52c430b9a6e584012e6014709e1e16ae7e775b2a5aedff8eb767fa867c1cbdd src/admin/web/drafts.js
|
||||||
232b766f278ae4424705af351910677fcd7ac9a20bb8320d9a12b763c91219a2 src/admin/web/error_401.html
|
232b766f278ae4424705af351910677fcd7ac9a20bb8320d9a12b763c91219a2 src/admin/web/error_401.html
|
||||||
16293363c7848471d866092fec5e83023df087aa824989e87450cee7b7ca1263 src/admin/web/error_403.html
|
16293363c7848471d866092fec5e83023df087aa824989e87450cee7b7ca1263 src/admin/web/error_403.html
|
||||||
f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/error_404.html
|
f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/error_404.html
|
||||||
@@ -224,18 +236,18 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
|
|||||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||||
af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js
|
af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js
|
||||||
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
||||||
8a7f5ba3a7d5f0810351a540b829377b8a4a20fd40f6a955529e2d2839ffe328 src/admin/web/index.html
|
6da5aec18be66ef9f4f5b325bd235f632993a06f26bb41defa1e448881c15a2e src/admin/web/index.html
|
||||||
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
|
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
|
||||||
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
||||||
af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html
|
af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html
|
||||||
99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js
|
99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js
|
||||||
810e7e5bf1af302b51d90771c3ec3c20227eca392f8c7d91d027e11a999bc236 src/admin/web/management.js
|
810e7e5bf1af302b51d90771c3ec3c20227eca392f8c7d91d027e11a999bc236 src/admin/web/management.js
|
||||||
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
|
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
|
||||||
f5ba448163d462ab1f0d421686d1af7ae465b1e5bd78e38fc0899cf0f6f5d13f src/admin/web/settings.js
|
53cdc1d9d136bbea44a2cf9f06193e90392beb3544c687fbec0fff896565a3c2 src/admin/web/settings.js
|
||||||
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
|
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
|
||||||
b12ce34bcd2617b52bb38f9dfe9be5e1f5c11304c6c18b77a7cb379fa1cafd88 src/admin/web/style.css
|
727ad8dceae6f44ed2ebdb998fe29169bd61406700980b04390c0713c8d8b86c src/admin/web/style.css
|
||||||
a8a73a4fa849ae49391fcd6143e900c515c8b577f71b55cbae9701fa9f5bbe47 src/admin/web/traffic-panel.js
|
d01b615635c7373981b0633cc27298a34e67fe0cbe9d1384d277d46f0cafb1b4 src/admin/web/traffic-panel.js
|
||||||
e686d68fd6a368110a85dc4c5dc64e8093950d20d63e9aa44790c03dea6fc193 src/admin/web/transform-panel.js
|
a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js
|
||||||
57a2b975716bdab32c33ec1bf9427b25998550e81ce191440ee66ffb7e2a249e src/admin/web/vhosts/core.js
|
57a2b975716bdab32c33ec1bf9427b25998550e81ce191440ee66ffb7e2a249e src/admin/web/vhosts/core.js
|
||||||
7e0dc397ce6b2f90dfadf0f724688cb952b89183d06042c0e28d716301e5e7ea src/admin/web/vhosts/editor.js
|
7e0dc397ce6b2f90dfadf0f724688cb952b89183d06042c0e28d716301e5e7ea src/admin/web/vhosts/editor.js
|
||||||
30a6344e76de77b60cd7247a33c68f1cb5d972aebfde56469f2ebd44440c7bf9 src/admin/web/vhosts/health.js
|
30a6344e76de77b60cd7247a33c68f1cb5d972aebfde56469f2ebd44440c7bf9 src/admin/web/vhosts/health.js
|
||||||
@@ -282,22 +294,24 @@ bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/look
|
|||||||
1779abcee77600c13050a2882f896a5b35625b1ae53737c2554179492ce0e40d src/cache/lookup/insert.rs
|
1779abcee77600c13050a2882f896a5b35625b1ae53737c2554179492ce0e40d src/cache/lookup/insert.rs
|
||||||
f3e026a3c0e953dca3e5c9def43d864a7706afcd7cbeeaf0f21c059a335b30cc src/cache/lookup/tiered.rs
|
f3e026a3c0e953dca3e5c9def43d864a7706afcd7cbeeaf0f21c059a335b30cc src/cache/lookup/tiered.rs
|
||||||
58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs
|
58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs
|
||||||
58ede9780b70ac46fe8b35b6a85f02f56f9a2c75b045e3435062cceb3921f3aa src/cache/model.rs
|
0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs
|
||||||
ba94226addfd6cdab67d666dc15b8412c4d117111e44639e503e89a95175d0a2 src/cache/model/cache.rs
|
ba94226addfd6cdab67d666dc15b8412c4d117111e44639e503e89a95175d0a2 src/cache/model/cache.rs
|
||||||
0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs
|
0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs
|
||||||
753d0a316169847f905ecdee0ab6f445e43a3c247d891a08936e19e992f9e3e0 src/cache/model/entry.rs
|
29bfce96b2ffd102d9c848c7b717a45fedb9aa44511f62f7a1f28a53dafd063a src/cache/model/entry.rs
|
||||||
|
88d74fc1b7b9ca15a54a8f4893ad3993105952148bcfee099e9bc9727b622aa3 src/cache/model/http_invalidation.rs
|
||||||
09a4417f82984eca8c040ea28eb82b3f129c347d951d4b4fab704bb194d9b985 src/cache/model/maintenance.rs
|
09a4417f82984eca8c040ea28eb82b3f129c347d951d4b4fab704bb194d9b985 src/cache/model/maintenance.rs
|
||||||
e655c9e23069ceb553f6fcc7aa3824b08e2e5ceda5c39cd800dc79507856a94d src/cache/model/settings.rs
|
5441fa3a384add28b6fdd15300cf2648f8237dbfc211cad9117754203be76911 src/cache/model/settings.rs
|
||||||
55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs
|
55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs
|
||||||
e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs
|
e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs
|
||||||
989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs
|
989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs
|
||||||
d426d341a7678f8ec70406d86052c97d0f49804bbcd8402951103d61a735b9da src/cache/policy/response.rs
|
d426d341a7678f8ec70406d86052c97d0f49804bbcd8402951103d61a735b9da src/cache/policy/response.rs
|
||||||
7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs
|
7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs
|
||||||
efde2bd046ed073900cd3e67051f0542c802a06e8f5e38c2c20ee92604080c36 src/cache/tests.rs
|
7ad96b3baae36ec49c90d03b380e749944fb3d54abb045c90db3a35f6b89b1c1 src/cache/tests.rs
|
||||||
13dab7b7a587cfc146826a92ab94ec9e04f7e500b7a84e678d77226829844923 src/cache/tests/cold_tier_tests.rs
|
13dab7b7a587cfc146826a92ab94ec9e04f7e500b7a84e678d77226829844923 src/cache/tests/cold_tier_tests.rs
|
||||||
b4f609ee900c556b613136bbd3fce11c242a415d277ac3d25bdcb72275b66832 src/cache/tests/cold_tier_tests/part_01.rs
|
b4f609ee900c556b613136bbd3fce11c242a415d277ac3d25bdcb72275b66832 src/cache/tests/cold_tier_tests/part_01.rs
|
||||||
f43aa6c231f2c85ee3390dddab74aab2eea2a3f874acb35f145c23866266cb52 src/cache/tests/cold_tier_tests/part_02.rs
|
f43aa6c231f2c85ee3390dddab74aab2eea2a3f874acb35f145c23866266cb52 src/cache/tests/cold_tier_tests/part_02.rs
|
||||||
22cb9bf20a0d25bee30c1e2213dfe8278ad7d0d5d294275d47d4a6ff9ac646ab src/cache/tests/dashboard_tests.rs
|
22cb9bf20a0d25bee30c1e2213dfe8278ad7d0d5d294275d47d4a6ff9ac646ab src/cache/tests/dashboard_tests.rs
|
||||||
|
d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/tests/http_invalidation_tests.rs
|
||||||
1db70ea5f3cc1f1543504a68498ba23cafce5f9bb48c4616a4bf70d9a536790e src/cache/tests/scope_tests.rs
|
1db70ea5f3cc1f1543504a68498ba23cafce5f9bb48c4616a4bf70d9a536790e src/cache/tests/scope_tests.rs
|
||||||
578b18cac918ba51fad775eca188f645b228500e59eb1790f783a0b8d0245b7b src/cache/tests/tests.rs
|
578b18cac918ba51fad775eca188f645b228500e59eb1790f783a0b8d0245b7b src/cache/tests/tests.rs
|
||||||
c33013089bf9e75a07a5b43f1030acaba8aba4098796e393ed4bad784f4fff96 src/cache/tests/tests/part_01.rs
|
c33013089bf9e75a07a5b43f1030acaba8aba4098796e393ed4bad784f4fff96 src/cache/tests/tests/part_01.rs
|
||||||
@@ -308,7 +322,7 @@ cdc41a7241739b89297494323f29e0230fa06abbd74e219fedfda11df1db4334 src/cache/test
|
|||||||
15e85357c3ddf4fc2283d621f5f4cd8ca309275e06677020299d4105f0475949 src/cache/tests/v022_cache_tests/part_01.rs
|
15e85357c3ddf4fc2283d621f5f4cd8ca309275e06677020299d4105f0475949 src/cache/tests/v022_cache_tests/part_01.rs
|
||||||
f262c4e946421b06198ab73ff6e36e6826848f53ce8396eada666c64935c2e70 src/cache/tests/v022_cache_tests/part_02.rs
|
f262c4e946421b06198ab73ff6e36e6826848f53ce8396eada666c64935c2e70 src/cache/tests/v022_cache_tests/part_02.rs
|
||||||
a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tickets.rs
|
a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tickets.rs
|
||||||
3fc79b09474fe23b5b6fc98e4a376469afb02a41d6cc2dbbba59dc6091c790c8 src/compression.rs
|
28f904cf4827294f2f759e6d3624608ed90059b64e42864e55dc9213824eb655 src/compression.rs
|
||||||
e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs
|
e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs
|
||||||
7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs
|
7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs
|
||||||
7ba52d3de4d77d1f3cad1b10de418953adc33901727a0791980847ae223929aa src/config/backend.rs
|
7ba52d3de4d77d1f3cad1b10de418953adc33901727a0791980847ae223929aa src/config/backend.rs
|
||||||
@@ -379,6 +393,7 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus
|
|||||||
b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body.rs
|
b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body.rs
|
||||||
16b9d4db3b20b2ab14c47ac14fcd18d3d8ca77c3ad9a1f37866ebfa60f379862 src/proxy/body/tests.rs
|
16b9d4db3b20b2ab14c47ac14fcd18d3d8ca77c3ad9a1f37866ebfa60f379862 src/proxy/body/tests.rs
|
||||||
f0cb26ed34c35bacc74b914457acac8a611390de14cf46e2e6d3bcc848e914e8 src/proxy/cache_helpers.rs
|
f0cb26ed34c35bacc74b914457acac8a611390de14cf46e2e6d3bcc848e914e8 src/proxy/cache_helpers.rs
|
||||||
|
7ad068bca5397d1337f153fa26b9ca7ae1403abf4fa059b85362d15247717a67 src/proxy/cache_http_invalidation.rs
|
||||||
771cc1a38927f96e593b144dbab66cd64a5dc0ed42d54b1615c359845b2b7223 src/proxy/error_fallback.rs
|
771cc1a38927f96e593b144dbab66cd64a5dc0ed42d54b1615c359845b2b7223 src/proxy/error_fallback.rs
|
||||||
bf2e6bbd64fb45fceed54af0bff1c1e00da22b79c64dd8f5c23d2af0dbab0b7d src/proxy/forward/cache_lookup.rs
|
bf2e6bbd64fb45fceed54af0bff1c1e00da22b79c64dd8f5c23d2af0dbab0b7d src/proxy/forward/cache_lookup.rs
|
||||||
526592b613fbe20f532f11be5a3236acaeb8cad400f70e1d28cc1c2dfc126c52 src/proxy/forward/response.rs
|
526592b613fbe20f532f11be5a3236acaeb8cad400f70e1d28cc1c2dfc126c52 src/proxy/forward/response.rs
|
||||||
@@ -387,12 +402,13 @@ cea9e47ecb31b0e4c0bbe0eb83e0e53b5e7bdcb9930107b67a8c34eef01a3f49 src/proxy/forw
|
|||||||
902cbf8dc3c1ec8d25411bc7fe0400d301f3a7cc885afada594e4eaee3bedd08 src/proxy/forward/success_prepare.rs
|
902cbf8dc3c1ec8d25411bc7fe0400d301f3a7cc885afada594e4eaee3bedd08 src/proxy/forward/success_prepare.rs
|
||||||
23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs
|
23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs
|
||||||
8961d8d649d8f4708f443f1b8b8a73c8b5f42811d2cad8cc6f596833511933f4 src/proxy/forward/web_source.rs
|
8961d8d649d8f4708f443f1b8b8a73c8b5f42811d2cad8cc6f596833511933f4 src/proxy/forward/web_source.rs
|
||||||
c39090511e8057d43f2620175958c3dbd9b686036306a0060bb64eaab4bf6c93 src/proxy/forward_entry.rs
|
a164fb389f38b0172db38803e9a49dd1cb61a4f2db49c72e220bebd96ea631ff src/proxy/forward_entry.rs
|
||||||
c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs
|
c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs
|
||||||
5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs
|
5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs
|
||||||
f202c0fe0bc4e786a906d7337d368cd81bcac15ffdbbfd35d5b0d49126d9895a src/proxy/mod.rs
|
42155c7742da4173f71dc4ef488288e1fd665fbb295f02745421785571926295 src/proxy/mod.rs
|
||||||
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
|
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
|
||||||
cc682f0d5754e0b806a4eb5950e4cc00611aca5ce4668414fddef1d692489805 src/proxy/tests/authority_tests.rs
|
cc682f0d5754e0b806a4eb5950e4cc00611aca5ce4668414fddef1d692489805 src/proxy/tests/authority_tests.rs
|
||||||
|
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs
|
||||||
e80c7fe44abb7607c64ea02d56542753dc25290b3bcf4c4d45b75f2db6ca90d0 src/proxy/types.rs
|
e80c7fe44abb7607c64ea02d56542753dc25290b3bcf4c4d45b75f2db6ca90d0 src/proxy/types.rs
|
||||||
26afb4dcda99a54abdd85f7950ecac6d608eca78067323f695a43382d21fa2fb src/proxy/upstream_helpers.rs
|
26afb4dcda99a54abdd85f7950ecac6d608eca78067323f695a43382d21fa2fb src/proxy/upstream_helpers.rs
|
||||||
69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs
|
69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs
|
||||||
@@ -406,10 +422,10 @@ d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.r
|
|||||||
811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs
|
811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs
|
||||||
d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs
|
d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs
|
||||||
4e0b25c2b86eefddd76c64b39dc1c1cd6d243addc32cdea99e6c3ac84ed879a6 src/runtime/certificates.rs
|
4e0b25c2b86eefddd76c64b39dc1c1cd6d243addc32cdea99e6c3ac84ed879a6 src/runtime/certificates.rs
|
||||||
ad79a64f0fb31881aa93a2305fa3084d7e02e15d464c71dbba38123bd6a81fc5 src/runtime/constructors.rs
|
2752a1122f22cab7af9ccdd23cd0aa6d99a73f1d82614814217a3875927d7eef src/runtime/constructors.rs
|
||||||
31dc5cdea00efd63594dc6becd5a9103594696145cbc8e7b7594fb407214ad9d src/runtime/docker.rs
|
31dc5cdea00efd63594dc6becd5a9103594696145cbc8e7b7594fb407214ad9d src/runtime/docker.rs
|
||||||
467e07b21075fe513786aa465dc2d2307f0ad76198d0166f03e6bc5b4a19868e src/runtime/tests.rs
|
e90df3fff8a25465425b099ae358d19085809e56d948ba47e01e53f8b93d5dd0 src/runtime/tests.rs
|
||||||
9671e128bfd35179cbadf4230e23c4868f13ac44957b98644301799b8acdecee src/runtime/types.rs
|
0046525317ffbd5d3b34845656b0f58d9d31f38b3d8acd71e90d94bd7cdfb8ab src/runtime/types.rs
|
||||||
88bc546fdda20d3f1f8f5e7d7872ede1fa334b8bb5102c729bd72f56e3fb94b9 src/runtime/updates.rs
|
88bc546fdda20d3f1f8f5e7d7872ede1fa334b8bb5102c729bd72f56e3fb94b9 src/runtime/updates.rs
|
||||||
b4c17b304166592e63c81884076589fbaf725c3f57043f8924c46167b061ac5b src/secret_store.rs
|
b4c17b304166592e63c81884076589fbaf725c3f57043f8924c46167b061ac5b src/secret_store.rs
|
||||||
6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs
|
6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs
|
||||||
@@ -421,7 +437,7 @@ f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/htt
|
|||||||
71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs
|
71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs
|
||||||
e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs
|
e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs
|
||||||
048f2c7a8ef698a172ba7ed844558f4118b367b2cf340748c5c28da7723d8e85 src/server/tls_runtime.rs
|
048f2c7a8ef698a172ba7ed844558f4118b367b2cf340748c5c28da7723d8e85 src/server/tls_runtime.rs
|
||||||
392e4321e5079bd94c795e9bcc08d0abe4b40e721d0e6a36e115f9fad7e86478 src/settings.rs
|
623e1b24c17f2149c712bc9fe38ca23c503e88891f5eef1b5ceef6cbf8cf9093 src/settings.rs
|
||||||
3b3893d899b8e82969588f30c1e0aeff250449e4c94627ff5ad94d29f227ceca src/settings/tests.rs
|
3b3893d899b8e82969588f30c1e0aeff250449e4c94627ff5ad94d29f227ceca src/settings/tests.rs
|
||||||
566daf9c0e1a30da2dc18e51090541a85ced3320a0fc00ed1d1de3e2b4da5072 src/stream.rs
|
566daf9c0e1a30da2dc18e51090541a85ced3320a0fc00ed1d1de3e2b4da5072 src/stream.rs
|
||||||
46d9f63ce13a634e00f38d36ca3bdb217f2e37f04d75a4ef118e7dac9896a489 src/stream/model.rs
|
46d9f63ce13a634e00f38d36ca3bdb217f2e37f04d75a4ef118e7dac9896a489 src/stream/model.rs
|
||||||
@@ -502,30 +518,34 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies
|
|||||||
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
|
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
|
||||||
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
|
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
|
||||||
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
|
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
|
||||||
|
b9d7b622127841f7abe6f87ce6bb769c95539de7c52824cc691a47be2d6ab65f tools/checks/admin_surface_audit_0310.py
|
||||||
412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs
|
412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs
|
||||||
2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs
|
2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs
|
||||||
84c090840926fb8fc5ed74aa779e7ca028bc0c684ca5ca15f5ae105e65a8dd3b tools/checks/check_source.py
|
e3591f3aa47c595ad6d93509217734b90f829d8d3eac6c572936aeea1793a1cd tools/checks/check_source.py
|
||||||
5362f6c455cf697aacb59336d6664df0fd2a36956bedbbdcb090887ff20b7049 tools/checks/docs_check.py
|
998ae8674598b8c8c937a323303f1ea0cccd4f8df2dfe804ddc24b5f8a6d6da3 tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||||
|
8005a9098f5ae142d8a54038506d8939725bf46c74bd07d2de3ecb916e390e9d tools/checks/docs_check.py
|
||||||
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
|
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
|
||||||
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
|
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
|
||||||
f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py
|
f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py
|
||||||
c2e9b21e6c4acdff5d846843fbf7a3babeeed10b9b59ed001c519feea815d90e tools/checks/preflight.py
|
5b7901747d879e12a39ed82b6efc513bcdddd15d508b5828210d2c83398fb6a4 tools/checks/panel_cache_http_0310_ui_test.mjs
|
||||||
32ae78d788aa841e2c01b792aab9c2cf3f075a1508f8abcde4499153c5f778df tools/checks/release_smoke.py
|
4bf7e733f8a2c32c3ea857e680604af39ed26f9dc9b3659744d6ec21024e9f2f tools/checks/preflight.py
|
||||||
|
3485681d369b5f486dfc2d14660f19b4098df670bafd9a296cc9dabe68003a51 tools/checks/refresh_0310_ui_test.mjs
|
||||||
|
1f4f834761a4107422e39243e39e352fff4e73eac923b6f26eb4d8e5b3e8a5c1 tools/checks/release_smoke.py
|
||||||
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
|
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
|
||||||
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
||||||
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
||||||
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
||||||
2e125a2a807553ac81d04428f337f5042eed968cb807244487114fdd8ea6b5d9 tools/checks/source_module_contracts.py
|
2e125a2a807553ac81d04428f337f5042eed968cb807244487114fdd8ea6b5d9 tools/checks/source_module_contracts.py
|
||||||
37dafd2db24beba50fa155c302299dc4a8a8171531f86ac5b4e53bb0e269c1f7 tools/checks/system_info_0301_ui_test.mjs
|
bbd9726e11ce1324b7df6a012bac51262e22f2617bd3d36974aa151dce5c1c34 tools/checks/system_info_0301_ui_test.mjs
|
||||||
7505e10a00969ea705987dd68c6ba6f5c556e0b2d5bde9926ac4f41e72381700 tools/checks/system_info_no_subprocess_contracts.py
|
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
|
||||||
987b4c593b51a7c340d8f01f9a2eea1387043cdd997eeb932c8341bbb8c0bd64 tools/checks/version_contracts.py
|
614ed0c775e32ec5bfb4ee75aaa90206a10da0f43659747a7ec76fd93e33767a tools/checks/version_contracts.py
|
||||||
2020e171be1a044f7ca2ab83af7604a014c36fe4ff04791d0620ccbf1bf8e000 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
2020e171be1a044f7ca2ab83af7604a014c36fe4ff04791d0620ccbf1bf8e000 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||||
0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs
|
0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs
|
||||||
934dce837a13c8e9eee8e161aeb8b63dc771e2197c61639cf59e33b5ceaca833 tools/checks/web_assets.py
|
934dce837a13c8e9eee8e161aeb8b63dc771e2197c61639cf59e33b5ceaca833 tools/checks/web_assets.py
|
||||||
0398892be30fe416a00578915c588f360fd93244e3af77ece834e560e547d1c8 tools/checks/web_server_contracts.py
|
0398892be30fe416a00578915c588f360fd93244e3af77ece834e560e547d1c8 tools/checks/web_server_contracts.py
|
||||||
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
|
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
|
||||||
adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs
|
adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs
|
||||||
eb4e9a72bdc8e134ee67712a656b6e42c4bbc1f1602a1039052977055cfc6a64 tools/dev/memory_load.py
|
f16f69dabab98fe468aa90cc8f3c447c0588d7bd18163080d3d7b15c67190e4c tools/dev/memory_load.py
|
||||||
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
||||||
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
|
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
|
||||||
538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py
|
538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py
|
||||||
|
|||||||
@@ -99,12 +99,16 @@ if [[ "$mode" == "test" ]]; then
|
|||||||
python3 tools/checks/high_traffic_memory_model.py
|
python3 tools/checks/high_traffic_memory_model.py
|
||||||
python3 tools/checks/web_server_contracts.py
|
python3 tools/checks/web_server_contracts.py
|
||||||
python3 tools/checks/security_contracts.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/security_dependency_contracts.py
|
||||||
if command -v node >/dev/null 2>&1; then
|
if command -v node >/dev/null 2>&1; then
|
||||||
node tools/checks/web_service_ui_test.mjs
|
node tools/checks/web_service_ui_test.mjs
|
||||||
node tools/checks/backend_cache_0297_ui_test.mjs
|
node tools/checks/backend_cache_0297_ui_test.mjs
|
||||||
node tools/checks/backend_health_0300_ui_test.mjs
|
node tools/checks/backend_health_0300_ui_test.mjs
|
||||||
node tools/checks/system_info_0301_ui_test.mjs
|
node tools/checks/system_info_0301_ui_test.mjs
|
||||||
|
node tools/checks/refresh_0310_ui_test.mjs
|
||||||
|
node tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||||
|
node tools/checks/panel_cache_http_0310_ui_test.mjs
|
||||||
node tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
node tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||||
node tools/checks/www_redirect_ui_test.mjs
|
node tools/checks/www_redirect_ui_test.mjs
|
||||||
node tools/checks/vhost_www_ui_test.mjs
|
node tools/checks/vhost_www_ui_test.mjs
|
||||||
|
|||||||
+21
-2
@@ -7,6 +7,23 @@ certificate and persist a non-loopback bind; plain remote HTTP requires the expl
|
|||||||
startup `--allow-remote-admin` gate. Responses carry no-store, CSP and other browser
|
startup `--allow-remote-admin` gate. Responses carry no-store, CSP and other browser
|
||||||
protection headers.
|
protection headers.
|
||||||
|
|
||||||
|
## Public-vhost HTTP cache invalidation (opt-in)
|
||||||
|
|
||||||
|
`PURGE /path?query` and `BAN /prefix` operate on the normal public vhost
|
||||||
|
listener and **do not** use an admin session. They are independently denied by
|
||||||
|
default unless the administrator explicitly populates the cache's
|
||||||
|
`http_invalidation_allowed_cidrs` allowlist in **Settings → Cache**. Authorization
|
||||||
|
uses the existing trusted client-IP forwarding logic, vhost/host ACL and rate
|
||||||
|
limits. BAN is a lazy prefix rule; PURGE evicts an exact URL. Both are restricted
|
||||||
|
to the request scheme (`http` vs `https`), Host and matched vhost. A shared
|
||||||
|
two-worker maintenance guard also bounds administrator-driven invalidation;
|
||||||
|
additional requests receive 429. Ambiguous duplicate control headers and
|
||||||
|
request bodies are rejected. See
|
||||||
|
[CACHE](CACHE.md#http-ban--purge-from-authorized-client-ips) for request examples,
|
||||||
|
scoping, confirmation and reverse-proxy requirements, and
|
||||||
|
[0.31.0 security review](SECURITY_AUDIT_0.31.0.md) for audit findings. The authenticated
|
||||||
|
`/api/cache/invalidate` endpoint remains unchanged.
|
||||||
|
|
||||||
## Browser documentation and anonymous surface
|
## Browser documentation and anonymous surface
|
||||||
|
|
||||||
Open `/docs` **on the administration listener** after signing in. The default **Reader**
|
Open `/docs` **on the administration listener** after signing in. The default **Reader**
|
||||||
@@ -72,6 +89,8 @@ framework extraction failures are not guaranteed to have that JSON shape.
|
|||||||
| `GET /api/backends` | Authenticated per-vhost backend observations and active/desired frontend plans |
|
| `GET /api/backends` | Authenticated per-vhost backend observations and active/desired frontend plans |
|
||||||
| `GET /api/status` | Uptime, active revision, configured counts, listener addresses, feature flags, aggregate metrics, active/desired TCP/UDP streams with per-stream L4 counters, active/desired administration state, TLS policy/cipher inventory and structured-log sink counters |
|
| `GET /api/status` | Uptime, active revision, configured counts, listener addresses, feature flags, aggregate metrics, active/desired TCP/UDP streams with per-stream L4 counters, active/desired administration state, TLS policy/cipher inventory and structured-log sink counters |
|
||||||
|
|
||||||
|
`GET /api/status?view=live` returns only volatile telemetry suitable for SSE fallback, without the heavy full configuration/OS/runtime objects. `GET /api/system-info` returns cached system diagnostics, host/runtime metadata and memory accounting under `system`, `runtime` and `memory_accounting`. Both are protected by the same cookie/origin admin guard as full status.
|
||||||
|
|
||||||
`status.streams` contains `active`, `desired`, `restart_required` and a `metrics` map keyed by startup-active stream ID. Stream counters include active/total/completed sessions, rejects, errors, RX/TX bytes and UDP packet counts; the dashboard derives rates from byte-counter deltas.
|
`status.streams` contains `active`, `desired`, `restart_required` and a `metrics` map keyed by startup-active stream ID. Stream counters include active/total/completed sessions, rejects, errors, RX/TX bytes and UDP packet counts; the dashboard derives rates from byte-counter deltas.
|
||||||
| `GET /api/logs` | Recent bounded access entries, ring capacity and contention-drop count |
|
| `GET /api/logs` | Recent bounded access entries, ring capacity and contention-drop count |
|
||||||
| `GET /api/audit` | Last 100 audit entries; database retention is 10,000 |
|
| `GET /api/audit` | Last 100 audit entries; database retention is 10,000 |
|
||||||
@@ -83,9 +102,9 @@ Except for health and authentication/bootstrap routes, these endpoints require a
|
|||||||
session. These administrative previews are not anonymous. The separate configurable
|
session. These administrative previews are not anonymous. The separate configurable
|
||||||
exporter uses its own CIDRs and optional bearer token; see the scrape section below.
|
exporter uses its own CIDRs and optional bearer token; see the scrape section below.
|
||||||
|
|
||||||
SSE has eight viewer slots, generates a snapshot approximately every two seconds
|
SSE has eight viewer slots, generates a snapshot every 1/2/5/10 seconds per authenticated client (via `?interval=`, default 2)
|
||||||
and rechecks session validity. It does not queue an unlimited per-viewer event log
|
and rechecks session validity. It does not queue an unlimited per-viewer event log
|
||||||
or guarantee replay. The browser falls back to polling. An active dashboard renews
|
or guarantee replay. The browser falls back to compact status/log polling only after SSE fails and stops polling after its recovery. Invalid SSE intervals are rejected with 422. An active dashboard renews
|
||||||
idle activity, not the absolute session deadline.
|
idle activity, not the absolute session deadline.
|
||||||
|
|
||||||
Configuration history, introduced in SQLite schema 8 and retained in current schema 9, keeps the newest 512 successful manual/configuration revisions. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121.
|
Configuration history, introduced in SQLite schema 8 and retained in current schema 9, keeps the newest 512 successful manual/configuration revisions. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121.
|
||||||
|
|||||||
+51
-4
@@ -13,7 +13,7 @@ different vhost/config scopes internally and uses response `Vary` to select repr
|
|||||||
variants.
|
variants.
|
||||||
|
|
||||||
This is not a VCL implementation. Generic Varnish grace, ESI, public unauthenticated
|
This is not a VCL implementation. Generic Varnish grace, ESI, public unauthenticated
|
||||||
BAN/PURGE and distributed cache state are not implemented. RustProxy 0.22.1 adds bounded
|
unrestricted public BAN/PURGE and distributed cache state are not implemented. RustProxy 0.22.1 adds bounded
|
||||||
foreground request collapsing, authenticated selective PURGE/BAN and optional proactive
|
foreground request collapsing, authenticated selective PURGE/BAN and optional proactive
|
||||||
refresh while keeping directive-controlled stale delivery through RFC 5861 and request
|
refresh while keeping directive-controlled stale delivery through RFC 5861 and request
|
||||||
`max-stale`; it is not an unconditional availability/grace layer. RustProxy also keeps its
|
`max-stale`; it is not an unconditional availability/grace layer. RustProxy also keeps its
|
||||||
@@ -36,6 +36,48 @@ proxy origins, range requests and conditional requests can reuse fresh stored
|
|||||||
representations. Do not rely on stale-if-error remote-backend refresh behavior
|
representations. Do not rely on stale-if-error remote-backend refresh behavior
|
||||||
for local Web Server routes.
|
for local Web Server routes.
|
||||||
|
|
||||||
|
## HTTP BAN / PURGE from authorized client IPs
|
||||||
|
|
||||||
|
The **Settings → Cache → HTTP BAN / PURGE IP allowlist** control sets
|
||||||
|
`cache.http_invalidation_allowed_cidrs`. The default is **empty (disabled)**.
|
||||||
|
Allow individual client IPs or small CIDR ranges, one per line. Saves validate
|
||||||
|
IPv4/IPv6 CIDR syntax and reject `/0` networks. An allowed request must also
|
||||||
|
pass the vhost listener binding, global/vhost ACL, and rate limits. The
|
||||||
|
operation is restricted to the matched external HTTP/HTTPS scheme, `Host` and
|
||||||
|
vhost; it cannot choose other tenants or the opposite scheme through headers. Commands use the existing
|
||||||
|
cache invalidation engine, are audit-logged before execution and share a
|
||||||
|
two-worker maintenance limit with authenticated admin invalidation (429 when
|
||||||
|
busy). They are never forwarded upstream.
|
||||||
|
|
||||||
|
- `PURGE /path?query` immediately invalidates exact cached representations of
|
||||||
|
that URL. Returns `200` with JSON result/count. Other query variants are not
|
||||||
|
matched.
|
||||||
|
- `BAN /prefix` records a lazy prefix ban of older cached representations
|
||||||
|
for that host/vhost. Returns `202` and ban ID, normally expires after one hour.
|
||||||
|
Optional `X-RustProxy-Ban-Ttl: 30` sets 1..604800 seconds. Newly filled
|
||||||
|
representations are allowed (Varnish-like lazy BAN).
|
||||||
|
- `BAN /` requires `X-RustProxy-Confirm-Wide: 1` to prevent accidental
|
||||||
|
whole-host invalidation. Request bodies, upgrades, arbitrary selectors and
|
||||||
|
wildcard cross-vhost operations are rejected.
|
||||||
|
|
||||||
|
Examples from an allowlisted machine (replace the domain and path):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -i -X PURGE 'https://site.example/assets/app.js?v=3'
|
||||||
|
curl -i -X BAN 'https://site.example/assets/'
|
||||||
|
curl -i -X BAN 'https://site.example/' -H 'X-RustProxy-Confirm-Wide: 1'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behind a reverse proxy:** forward nonstandard `BAN`/`PURGE` methods to the
|
||||||
|
RustProxy public listener; do not let an upstream cache intercept them. Configure
|
||||||
|
RustProxy's existing `forwarding.trusted_proxies` accurately and ensure the
|
||||||
|
edge sanitizes/replaces untrusted `Forwarded`/`X-Forwarded-For` headers. When a
|
||||||
|
trusted proxy does not provide a usable real-client chain, BAN/PURGE fails
|
||||||
|
closed. Do **not** allowlist an entire proxy NAT network unless all its users
|
||||||
|
should have destructive cache access. Unlike full Varnish VCL, this interface
|
||||||
|
intentionally provides only exact PURGE and bounded URL-prefix BAN, not an
|
||||||
|
arbitrary untrusted regex/VCL expression.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
| Field | Default | Bounds / meaning |
|
| Field | Default | Bounds / meaning |
|
||||||
@@ -66,6 +108,7 @@ for local Web Server routes.
|
|||||||
| max_ttl_seconds | 86400 | 1..604800; maximum configured retention/freshness cap |
|
| max_ttl_seconds | 86400 | 1..604800; maximum configured retention/freshness cap |
|
||||||
| bypass_query | false | Bypass requests containing a query when enabled |
|
| bypass_query | false | Bypass requests containing a query when enabled |
|
||||||
| bypass_prefixes | [] | Up to 64 normalized absolute path prefixes |
|
| bypass_prefixes | [] | Up to 64 normalized absolute path prefixes |
|
||||||
|
| http_invalidation_allowed_cidrs | [] | Opt-in IP/CIDR allowlist (max 32) for HTTP BAN/PURGE. Empty blocks all HTTP methods; `/0` is invalid. |
|
||||||
| static_ignore_cookies | true | For configured static extensions only, ignore request Cookie for eligibility/keying |
|
| static_ignore_cookies | true | For configured static extensions only, ignore request Cookie for eligibility/keying |
|
||||||
| expose_status | false | Emit `X-Cache: HIT`, `MISS` or `BYPASS` |
|
| expose_status | false | Emit `X-Cache: HIT`, `MISS` or `BYPASS` |
|
||||||
| expose_hits | false | Emit per-object `X-Cache-Hits` |
|
| expose_hits | false | Emit per-object `X-Cache-Hits` |
|
||||||
@@ -175,9 +218,13 @@ Cache invalidation remains explicit:
|
|||||||
- regex rules and prefix `/` are treated as broad operations and require explicit confirmation,
|
- regex rules and prefix `/` are treated as broad operations and require explicit confirmation,
|
||||||
- only cache-compatibility changes advance the normal cache generation/revision; unrelated logging/TLS/admin/metrics/response-presentation saves retain objects, and cache request-bypass edits plus response-only `X-Cache`/`Cache-Status`/resident-age/`Via` diagnostics also apply without purging stored objects.
|
- only cache-compatibility changes advance the normal cache generation/revision; unrelated logging/TLS/admin/metrics/response-presentation saves retain objects, and cache request-bypass edits plus response-only `X-Cache`/`Cache-Status`/resident-age/`Via` diagnostics also apply without purging stored objects.
|
||||||
|
|
||||||
There is no public unauthenticated PURGE/BAN endpoint. If an application changes a GET
|
Unrestricted anonymous cache-maintenance access is not exposed: the public
|
||||||
resource after POST/PUT/PATCH/DELETE, use origin freshness/validators or the authenticated
|
HTTP BAN/PURGE interface is explicitly IP/CIDR-authorized (empty by default),
|
||||||
administration invalidation path; RustProxy does not guess the affected URLs.
|
separate from the administrator-only selective invalidation API. If an
|
||||||
|
application changes a GET resource after POST/PUT/PATCH/DELETE, use origin
|
||||||
|
freshness/validators or one of the explicit invalidation paths; RustProxy
|
||||||
|
does not guess the affected URLs. For exposure review and test scenarios, see
|
||||||
|
[0.31.0 security audit](SECURITY_AUDIT_0.31.0.md).
|
||||||
|
|
||||||
## Eligibility and freshness
|
## Eligibility and freshness
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.31.0 Axum fallback handler E0277 source correction
|
||||||
|
|
||||||
|
- Correct the reported `Handler<_, _>` error at `src/proxy/types.rs:53` after the public cache-maintenance path was added. The asynchronous `cache_http_invalidation` helper previously accepted `&Request<Body>` while awaiting database audit and cache invalidation; Axum's body is not `Sync`, so the borrowed request can make the `forward` handler future non-`Send`.
|
||||||
|
- Pass ownership of `Request<Body>` to the maintenance helper, perform the same body/header/allowlist validation, copy the HTTP method for the audit entry, and release the request body before the first asynchronous operation. The `forward` router and public API semantics are unchanged.
|
||||||
|
- Add an offline source regression asserting that the async helper owns its request. Pre-Rust `./dev.sh test` checks and Go tests pass; full Rust parsing, type checking, tests and runtime maintenance calls cannot be validated without Cargo/Rustc.
|
||||||
|
|
||||||
## 0.30.1 same-version sysinfo compiler feature correction
|
## 0.30.1 same-version sysinfo compiler feature correction
|
||||||
|
|
||||||
- Fix four compiler `E0433` errors in the system information endpoint by enabling `sysinfo`'s `system` feature alongside `network` with `default-features = false` unchanged. No dependency version or API change.
|
- Fix four compiler `E0433` errors in the system information endpoint by enabling `sysinfo`'s `system` feature alongside `network` with `default-features = false` unchanged. No dependency version or API change.
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# RustProxy 0.31.0 – implementation and verification report
|
||||||
|
|
||||||
|
### Security/compilation follow-up
|
||||||
|
|
||||||
|
- Correct the user-reported `E0716` lifetime error in the HTTP BAN/PURGE
|
||||||
|
error path by returning an owned-independent static response and logging the
|
||||||
|
detailed validation error.
|
||||||
|
- Restrict invalidation to the selected HTTP/HTTPS scheme, Host and vhost;
|
||||||
|
reject duplicate maintenance headers, invalid bodies and broad BAN without
|
||||||
|
confirmation. Execute shared cache maintenance in a bounded blocking pool
|
||||||
|
of two operations, rather than scanning on Tokio core workers. Audit
|
||||||
|
authenticated BAN-rule deletion before mutation.
|
||||||
|
- Add localized BAN/PURGE `curl` help next to the Cache allowlist and Rust
|
||||||
|
API/auth/security regression tests. Full review, exposure matrix and live
|
||||||
|
test plan: [SECURITY_AUDIT_0.31.0](SECURITY_AUDIT_0.31.0.md). Source-level
|
||||||
|
coverage is **not** Rust runtime/compile verification.
|
||||||
|
|
||||||
|
### Panel compression and cache HTTP invalidation follow-up
|
||||||
|
|
||||||
|
The 0.31.0 maintenance follow-up introduces one shared, precompressed panel
|
||||||
|
content handler for authenticated HTML, CSS, JS and language bundles (negotiated
|
||||||
|
Zstd/Brotli/gzip with identity fallback; settings under Administration), plus
|
||||||
|
optional IP/CIDR-authorized public `BAN`/`PURGE` scoped to the selected vhost.
|
||||||
|
Existing cache invalidation, revisioned settings and auth/ACL behavior are reused.
|
||||||
|
The System information Refresh button now sits next to the API badge. See
|
||||||
|
[SETTINGS](SETTINGS.md), [CACHE](CACHE.md) and [TEST_REPORT](TEST_REPORT.md) for
|
||||||
|
behavior and verification limitations. This note does not assert a Rust build.
|
||||||
|
|
||||||
|
|
||||||
|
Date: 2026-10-10. Input: user-provided `RustProxy-0.30.1.zip`, which **does not contain `.git` metadata**. This is a complete edited source snapshot, **not** a verified checkout of remote `master` or a compiled/released binary. No commit or tag was created.
|
||||||
|
|
||||||
|
## Changes implemented
|
||||||
|
|
||||||
|
1. **One dashboard coordinator:** `src/admin/web/app/refresh.js` replaces recurring independent panel timers. Every task has a stable ID, active-view guard, sequential `setTimeout` scheduling after completion, `AbortController`, request epoch/generation guards, independent error handling, and manual execution. Auto polling and SSE pause on hidden tabs and stop on logout; timers and `visibilitychange` listener are removed.
|
||||||
|
2. **SSE with fallback:** `/api/events?interval=1|2|5|10` preserves one shared live channel per browser with individually selected frequency, session checking and heartbeats. It carries lightweight status and the existing log snapshot. `/api/status?view=live` and `/api/logs` polling run only while SSE is unavailable, and are stopped on recovery. The full `/api/status` remains available to existing clients.
|
||||||
|
3. **Traffic / Cache:** retained `/api/traffic` and `/api/cache`; they poll only in their active view, while both per-view Auto and account Live switches allow it. The interval comes from per-account preferences. `0` selects manual operation without disabling refresh buttons, paging, charts, filters or operations.
|
||||||
|
4. **System diagnostics:** new authenticated `/api/system-info` sourced from cached `sysinfo`/diagnostic and existing ACME-runtime metadata; OS identification is cached. Normal live snapshots no longer include costly system information, memory accounting, certificate runtime details or full configuration. System info polls at the selected lower cadence for Overview and the relevant Settings tabs; manual refresh is always available.
|
||||||
|
5. **Preferences and UI:** existing `/api/preferences` and account-specific SQLite storage extended with defaulted `refresh` settings; strict server/browser allowlists; UI controls, reset, persistence and immediate rescheduling. English/Polish translations maintained. Old preference documents are accepted without migration.
|
||||||
|
6. **Release:** main Cargo manifest/lock package, OpenAPI/version checks and active docs identify `0.31.0`. Historical changelog entries and third-party crate version numbers remain intact. Existing SQLite schema 9, cold-cache format 3 and proxy data path are unchanged.
|
||||||
|
|
||||||
|
## Refresh defaults and boundaries
|
||||||
|
|
||||||
|
| Key | Default | Allowed values (seconds) |
|
||||||
|
|---|---:|---|
|
||||||
|
| `live_enabled` | `true` | `true`, `false` |
|
||||||
|
| `overview_seconds` | 2 | 1, 2, 5, 10 |
|
||||||
|
| `traffic_seconds` | 5 | 0, 3, 5, 10, 30 |
|
||||||
|
| `cache_seconds` | 5 | 0, 3, 5, 10, 30 |
|
||||||
|
| `system_info_seconds` | 60 | 0, 15, 30, 60, 300 |
|
||||||
|
|
||||||
|
`live_enabled=false` pauses **all automatic** fetching, including SSE and system info. Every manual refresh remains possible. Server stream cadence is client-local, not global. The existing `/api/live-logs` remains independent, in accordance with its existing logs subscription model.
|
||||||
|
|
||||||
|
## Verification executed
|
||||||
|
|
||||||
|
| Action | Result | Scope/limit |
|
||||||
|
|---|---|---|
|
||||||
|
| `./dev.sh test` | **Partial, exit 127** | All Rust-independent preflight, source/module/security/version checks, SQLite schema-9 migration/upgrade, 8 Node UI tests (including new refresh test), docs and language checks passed. **Rust toolchain check then failed**: `cargo`/`rustc` unavailable. |
|
||||||
|
| `node tools/checks/refresh_0310_ui_test.mjs` | **PASS** | Deterministic simulated browser clock: 2/3/5/0-s cadence, manual mode, Live toggle, 20 rapid tab transitions, no duplicate requests/timers, aborting obsolete work, hidden/visible, SSE error/recovery fallback, independent failures, logout/cleanup, browser preference validation. |
|
||||||
|
| `go test ./...` (in `docker-agent/`) | **PASS** | Go agent unit tests. |
|
||||||
|
| `node --check` over all `src/admin/web/**/*.js` | **PASS** | JavaScript syntax only, not execution in Chrome/Firefox. |
|
||||||
|
| `python3 tools/checks/check_source.py` | **PASS** | Static contracts (not Rust type checking). |
|
||||||
|
| `python3 tools/checks/release_smoke.py` | **PASS** | Offline SQLite/migration and release integration/source contracts. |
|
||||||
|
| `bash -n dev.sh` and Python compile checks | **PASS** | Shell and Python syntax. |
|
||||||
|
| `./dev.sh check`, `./dev.sh fmt`, `./dev.sh release` | **NOT EXECUTED (exit 127)** | Rust/Cargo/rustfmt absent. No format, typecheck, Rust unit/integration suite, linked release binary. |
|
||||||
|
| Live Rust SSE/API/Traffic/Cache + multi-user persistence | **NOT EXECUTED** | Cannot start the Rust service in this environment. Rust integration regression tests were added to source but not run. |
|
||||||
|
| Real bandwidth/serialized SSE bytes/CPU, long-running resource metrics | **NOT MEASURED** | No Rust server. The repo's 2,000 HTTP-request memory-load smoke uses a Python **peer**, not the RustProxy binary; it is not RustProxy performance evidence. |
|
||||||
|
|
||||||
|
**Important:** all Rust runtime, compile and real end-to-end security, SSE heartbeat/reconnect and production proxy regressions remain open; this source archive cannot be declared production-ready before those checks.
|
||||||
|
|
||||||
|
## Request-rate analysis (source-derived forecast, not wire measurement)
|
||||||
|
|
||||||
|
Rates below are **steady-state theoretical** at default intervals, for a single visible administrator with no manual clicks; the first fetch after entering a view is extra. They are not byte-size/CPU benchmark results.
|
||||||
|
|
||||||
|
| Mode | Previously | v0.31.0 default |
|
||||||
|
|---|---|---|
|
||||||
|
| Overview status + logs | ~30 full-status SSE snapshots/min (each contained system data), with log payload | ~30 compact SSE snapshots/min with logs; no concurrent fallback |
|
||||||
|
| Overview system diagnostics | embedded in each SSE status | ~1 GET `/api/system-info`/min |
|
||||||
|
| Traffic active | ~20 GET `/api/traffic`/min at 3 s | ~12 GET/min at 5 s |
|
||||||
|
| Cache active | ~20 GET `/api/cache`/min at 3 s | ~12 GET/min at 5 s |
|
||||||
|
| Other views / hidden tab | older per-view guard behavior; background SSE not globally paused | zero scheduled Traffic/Cache; hidden tab closes SSE and pauses all automatic polling |
|
||||||
|
| Settings not requiring diagnostics | independent legacy status data carried by SSE | no automatic GET `/api/system-info` |
|
||||||
|
|
||||||
|
Actual SSE payload lengths, encoding/cost, network traffic and memory footprint **must be measured against a running binary**. No byte reduction percentage is asserted.
|
||||||
|
|
||||||
|
## Changed files (40 edited, 8 new, 0 deleted)
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- `Cargo.lock`
|
||||||
|
- `Cargo.toml`
|
||||||
|
- `README.md`
|
||||||
|
- `ROADMAP.md`
|
||||||
|
- `SHA256SUMS`
|
||||||
|
- `dev.sh`
|
||||||
|
- `docs/API.md`
|
||||||
|
- `docs/README.md`
|
||||||
|
- `docs/SETTINGS.md`
|
||||||
|
- `docs/TEST_REPORT.md`
|
||||||
|
- `langs/en_US.json`
|
||||||
|
- `langs/pl_pl.json`
|
||||||
|
- `src/admin/documentation/documents.rs`
|
||||||
|
- `src/admin/logging_api.rs`
|
||||||
|
- `src/admin/openapi.json`
|
||||||
|
- `src/admin/preferences.rs`
|
||||||
|
- `src/admin/router.rs`
|
||||||
|
- `src/admin/status.rs`
|
||||||
|
- `src/admin/tests.rs`
|
||||||
|
- `src/admin/tests/part_04.rs`
|
||||||
|
- `src/admin/web/acme.js`
|
||||||
|
- `src/admin/web/app/core.js`
|
||||||
|
- `src/admin/web/app/preferences.js`
|
||||||
|
- `src/admin/web/app/runtime.js`
|
||||||
|
- `src/admin/web/cache-panel.js`
|
||||||
|
- `src/admin/web/docker.js`
|
||||||
|
- `src/admin/web/drafts.js`
|
||||||
|
- `src/admin/web/index.html`
|
||||||
|
- `src/admin/web/settings.js`
|
||||||
|
- `src/admin/web/traffic-panel.js`
|
||||||
|
- `src/admin/web/transform-panel.js`
|
||||||
|
- `tools/checks/check_source.py`
|
||||||
|
- `tools/checks/docs_check.py`
|
||||||
|
- `tools/checks/preflight.py`
|
||||||
|
- `tools/checks/release_smoke.py`
|
||||||
|
- `tools/checks/system_info_0301_ui_test.mjs`
|
||||||
|
- `tools/checks/system_info_no_subprocess_contracts.py`
|
||||||
|
- `tools/dev/memory_load.py`
|
||||||
|
- `docs/IMPLEMENTATION_0.31.0.md`
|
||||||
|
- `src/admin/preferences_columns.rs`
|
||||||
|
- `src/admin/refresh_preferences.rs`
|
||||||
|
- `src/admin/status_live.rs`
|
||||||
|
- `src/admin/status_system.rs`
|
||||||
|
- `src/admin/tests/part_09.rs`
|
||||||
|
- `src/admin/web/app/refresh.js`
|
||||||
|
- `tools/checks/refresh_0310_ui_test.mjs`
|
||||||
|
|
||||||
|
## Required remaining release gate
|
||||||
|
|
||||||
|
On a host with stable Rust toolchain and a configured development environment, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./dev.sh fmt
|
||||||
|
./dev.sh check
|
||||||
|
./dev.sh test
|
||||||
|
./dev.sh release
|
||||||
|
cd docker-agent && go test ./... && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
Test real session login and per-account persistence, permission denial for `/api/system-info`, SSE connect/heartbeat/reconnect/disconnect, active-vs-hidden browser polling via DevTools, real `/api/status` backwards compatibility and live/proxy/cache/traffic behavior. Measure serialized SSE bytes and generation time, CPU and request counts by Overview/Traffic/Cache/Settings/hidden tab. Correct any Rust compilation/runtime failures before deploying or creating a release tag.
|
||||||
|
|
||||||
|
## Same-version mobile startup follow-up (2026-10-10)
|
||||||
|
|
||||||
|
The authenticated dashboard replaces the previous all-body `visibility: hidden` with a visible CSS-only spinner, a 12-second stalled-start hint with reload link, and an EN/PL explicit failure screen. It reveals the shell and starts the existing refresh coordinator before awaiting noncritical preferences/configuration/status; failed configuration cannot hide the shell. `Refresh now` is now aligned with the Overview heading. The fixed authenticated HTML is gzip-compressed once into cached bytes, negotiated per request; unchanged identity responses and public login remain. Browser UI/source checks are documented in `docs/TEST_REPORT.md`; Rust compilation and real wire/mobile tests remain open.
|
||||||
+3
-1
@@ -31,6 +31,8 @@ This is the canonical index for the current project documentation. The authentic
|
|||||||
- [0.26.3 implementation note](V0.26.3_IMPLEMENTATION.md) - global interface scaling, chart themes, square draft dock and Settings navigation polish.
|
- [0.26.3 implementation note](V0.26.3_IMPLEMENTATION.md) - global interface scaling, chart themes, square draft dock and Settings navigation polish.
|
||||||
- [0.26.2 implementation note](V0.26.2_IMPLEMENTATION.md) - unified Logs navigation, field-query search, dynamic JSON columns and RAW parsing.
|
- [0.26.2 implementation note](V0.26.2_IMPLEMENTATION.md) - unified Logs navigation, field-query search, dynamic JSON columns and RAW parsing.
|
||||||
- [0.26.1 implementation note](V0.26.1_IMPLEMENTATION.md) - historical vhost-aware protocol logging and Stored Logs UX milestone.
|
- [0.26.1 implementation note](V0.26.1_IMPLEMENTATION.md) - historical vhost-aware protocol logging and Stored Logs UX milestone.
|
||||||
|
- [0.31.0 security audit](SECURITY_AUDIT_0.31.0.md) - current API/panel/BAN/PURGE attack-surface review, hardening, compile fix and mandatory runtime gates.
|
||||||
|
- [0.31.0 implementation report](IMPLEMENTATION_0.31.0.md) - consolidated changes, exact file inventory, validation results and outstanding Rust runtime acceptance.
|
||||||
- [TEST_REPORT](TEST_REPORT.md) - verification commands, results and environment limitations.
|
- [TEST_REPORT](TEST_REPORT.md) - verification commands, results and environment limitations.
|
||||||
|
|
||||||
## Functional documentation
|
## Functional documentation
|
||||||
@@ -78,7 +80,7 @@ This is the canonical index for the current project documentation. The authentic
|
|||||||
|
|
||||||
## Historical or version-labelled context
|
## Historical or version-labelled context
|
||||||
|
|
||||||
Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md); [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md) is retained as the prior redirect/health/cache/log milestone; [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) is retained as the prior certificate-hardening milestone; [V0.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md) is retained as the prior external-path milestone; [V0.28.0_IMPLEMENTATION.md](V0.28.0_IMPLEMENTATION.md) is retained as the prior GeoIP/listener/cache milestone, and [V0.27.3_IMPLEMENTATION.md](V0.27.3_IMPLEMENTATION.md) is retained as the prior HTTP/3/cache-maintenance milestone, [V0.27.2_IMPLEMENTATION.md](V0.27.2_IMPLEMENTATION.md) is retained as the prior managed-ACME repair, [V0.27.1_IMPLEMENTATION.md](V0.27.1_IMPLEMENTATION.md) as the prior managed-runtime/list-sorting milestone, and older version notes remain historical evidence. The latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); older audit files remain available for traceability.
|
Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [IMPLEMENTATION_0.31.0.md](IMPLEMENTATION_0.31.0.md); [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md) is retained as historical context, and [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md) is retained as the prior redirect/health/cache/log milestone; [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) is retained as the prior certificate-hardening milestone; [V0.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md) is retained as the prior external-path milestone; [V0.28.0_IMPLEMENTATION.md](V0.28.0_IMPLEMENTATION.md) is retained as the prior GeoIP/listener/cache milestone, and [V0.27.3_IMPLEMENTATION.md](V0.27.3_IMPLEMENTATION.md) is retained as the prior HTTP/3/cache-maintenance milestone, [V0.27.2_IMPLEMENTATION.md](V0.27.2_IMPLEMENTATION.md) is retained as the prior managed-ACME repair, [V0.27.1_IMPLEMENTATION.md](V0.27.1_IMPLEMENTATION.md) as the prior managed-runtime/list-sorting milestone, and older version notes remain historical evidence. The latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); older audit files remain available for traceability.
|
||||||
|
|
||||||
Older context documents such as [INTEGRATIONS_040](INTEGRATIONS_040.md), [MANAGEMENT](MANAGEMENT.md), [SECURITY_AUDIT](SECURITY_AUDIT.md), [ACME_RFC2136](ACME_RFC2136.md) and older version audits remain available for traceability but should not override the current functional/technical documents above.
|
Older context documents such as [INTEGRATIONS_040](INTEGRATIONS_040.md), [MANAGEMENT](MANAGEMENT.md), [SECURITY_AUDIT](SECURITY_AUDIT.md), [ACME_RFC2136](ACME_RFC2136.md) and older version audits remain available for traceability but should not override the current functional/technical documents above.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# RustProxy 0.31.0 - API, panel and HTTP cache-maintenance security review
|
||||||
|
|
||||||
|
Scope: source snapshot supplied for the v0.31.0 maintenance follow-up, reviewed
|
||||||
|
2026-10-10. This is a **code/contract review with offline regressions**, not a
|
||||||
|
penetration test or proof of production safety. The supplied archive has no
|
||||||
|
`.git` directory; the review does not establish parity with remote `master`.
|
||||||
|
|
||||||
|
## Interfaces and trust boundaries
|
||||||
|
|
||||||
|
| Surface | Exposure | Authentication and mutation rules | Assessment |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Public administration bootstrap (`/`, login JS/CSS, `/api/auth/status`, setup/login POST) | Login/public administration listener | Host/origin validation; request marker for unsafe bootstrap actions; one-time setup capability and login throttling | Source-reviewed; live browser flow pending |
|
||||||
|
| Private panel HTML, JS, documentation, OpenAPI | Administration listener | Administrator session; `Cache-Control: no-store`; CSP, frame and MIME protections | Source and anonymous-response test reviewed; runtime pending |
|
||||||
|
| `/api/status`, `/api/system-info`, `/api/traffic`, `/api/cache`, logs, config, preferences, audit | Administration listener | Administrator cookie session; sensitive reads not on anonymous allowlist | Source and regression tests added; runtime pending |
|
||||||
|
| `/api/events`, live-log SSE | Administration listener | Session on subscribe and repeated validity checks; bounded viewers; heartbeat/disconnect cleanup | Source-reviewed; reconnect/load pending |
|
||||||
|
| State-changing admin APIs (including cache/config/preferences) | Administration listener | Valid session, Origin/Host, `X-RustProxy-Request: 1`, CSRF token; audit on cache commands | Source and regression tests added; runtime pending |
|
||||||
|
| Certificate API (`/api/certificates/{id}/reload`, deploy) | Administration listener | Narrow-scoped Key ID + Bearer grant; no general read access; HTTPS required remotely; origin validation when supplied | Source-reviewed; token rotation/revocation live tests pending |
|
||||||
|
| Public `BAN`/`PURGE` | Configured public vhost listener | **No admin session**: separate explicit IP/CIDR allowlist (empty by default), matched listener/vhost/ACL and optional rate limit | Hardened below; reverse/HTTP tests pending |
|
||||||
|
| Separate Prometheus metrics exporter | Distinct optional listener | Configured IP CIDRs and bearer-token policy; not the admin session | Unchanged; deployment exposure audit pending |
|
||||||
|
|
||||||
|
## Findings addressed in this follow-up
|
||||||
|
|
||||||
|
1. **E0716 / compilation defect (high functional impact).** `error()` accepts
|
||||||
|
`&'static str`; a reference to `format!(...)` was a temporary. Invalid
|
||||||
|
selector errors now log the detailed reason server-side and return a fixed,
|
||||||
|
non-sensitive message with status 422. This removes the reported lifetime
|
||||||
|
violation; the Rust compiler must still verify the entire crate.
|
||||||
|
2. **Cache protocol-scope confusion (medium).** Path-only `BAN`/`PURGE`
|
||||||
|
selectors could affect representations of the same host/path across both
|
||||||
|
HTTP and HTTPS. Targets are now built with the trusted external scheme,
|
||||||
|
validated to `http`/`https`, and the matched Host/vhost. IPv6 literal Hosts
|
||||||
|
are bracketed for the URL parser. Other tenants, schemes and hosts remain
|
||||||
|
outside the selector.
|
||||||
|
3. **Unbounded blocking maintenance work (medium availability risk).** Cache
|
||||||
|
invalidation may scan hot/cold entries. Both public HTTP and authenticated
|
||||||
|
admin cache purge/selective invalidation share two maintenance slots and
|
||||||
|
execute scans with `spawn_blocking`. Bursts receive 429 instead of piling
|
||||||
|
up unbounded blocking work on Tokio core threads. The permit stays with the
|
||||||
|
worker after a client disconnects. This is a concurrency bound, **not** a
|
||||||
|
per-client rate quota or a hard time limit for disk work.
|
||||||
|
4. **Ambiguous maintenance input (medium).** Duplicate TTL/confirmation or
|
||||||
|
`Content-Length` headers, transfer-encoded bodies, nonempty bodies and
|
||||||
|
upgrade requests are rejected; `Content-Length: 0` remains compatible with
|
||||||
|
CLI clients. TTL is limited to 1..604800 s. Broad `BAN /` requires the
|
||||||
|
explicit confirmation header; `PURGE` cannot use BAN-only controls.
|
||||||
|
5. **Audit ordering (low/medium).** Removal of an administrator-created BAN
|
||||||
|
rule now records the audit entry before mutating state. Unavailable audit
|
||||||
|
storage blocks the mutation rather than allowing an unaudited removal.
|
||||||
|
6. **Operator safety/usability (low).** An EN/PL help popover beside the HTTP
|
||||||
|
allowlist gives exact `curl` syntax, IP-only authentication semantics and
|
||||||
|
reverse-proxy identity requirements. No new global config variable is added.
|
||||||
|
|
||||||
|
## Existing protections checked in source
|
||||||
|
|
||||||
|
- Admin router's exact anonymous method/path allowlist contains only the
|
||||||
|
expected authentication bootstrap APIs; private docs and assets remain
|
||||||
|
session-protected. Request guard rejects duplicate Host/Origin and checks
|
||||||
|
browser `Sec-Fetch-Site` cross-site signals.
|
||||||
|
- Mutation guard uses an Origin/Host match, per-session CSRF comparison and
|
||||||
|
`X-RustProxy-Request` marker; cookie sessions are HttpOnly/SameSite=Strict
|
||||||
|
and use Secure under HTTPS/trusted forwarded protocol.
|
||||||
|
- Secure response policy includes CSP, `X-Frame-Options: DENY`,
|
||||||
|
`X-Content-Type-Options: nosniff`, referrer restrictions and no-store for
|
||||||
|
private material. A static scan checked 27 first-party browser JS files for
|
||||||
|
direct unsafe HTML injection and dynamic `eval`/`Function` sinks; this scan
|
||||||
|
**cannot establish that the entire DOM is XSS-free**.
|
||||||
|
- IP trust comes from the transport peer or a validated forwarding chain only
|
||||||
|
when the peer is configured trusted. An untrusted client-supplied XFF does
|
||||||
|
not independently grant BAN/PURGE permission. The cache allowlist rejects
|
||||||
|
`/0`, invalid CIDRs, duplicates and more than 32 entries. Requests are never
|
||||||
|
forwarded upstream.
|
||||||
|
- Certificate API tokens have explicit operation/ID grants and cannot use the
|
||||||
|
general admin read API; Bearer grant operations are intentionally separate
|
||||||
|
from cookie + CSRF authentication.
|
||||||
|
|
||||||
|
## HTTP maintenance CLI (allowlisted client only)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# One URL, including the query string: 200 with JSON result.
|
||||||
|
curl -i -X PURGE 'https://site.example/assets/app.js?v=3'
|
||||||
|
|
||||||
|
# Lazily ban previously cached objects under this path: 202 with JSON result.
|
||||||
|
curl -i -X BAN 'https://site.example/assets/'
|
||||||
|
|
||||||
|
# Entire host: explicit opt-in confirmation header.
|
||||||
|
curl -i -X BAN 'https://site.example/' -H 'X-RustProxy-Confirm-Wide: 1'
|
||||||
|
|
||||||
|
# Optional BAN expiration (30 seconds).
|
||||||
|
curl -i -X BAN 'https://site.example/assets/' -H 'X-RustProxy-Ban-Ttl: 30'
|
||||||
|
```
|
||||||
|
|
||||||
|
Use **Settings -> Cache -> HTTP BAN / PURGE IP allowlist** to grant the
|
||||||
|
caller IP/CIDR. This HTTP interface does not accept admin cookies/tokens as an
|
||||||
|
alternative to the allowlist. The domain, Host header, external scheme and
|
||||||
|
listener must select the intended vhost. Behind a reverse proxy, forward the
|
||||||
|
non-standard methods untouched and **replace**, rather than append untrusted,
|
||||||
|
XFF/Forwarded headers at the edge. Never add the entire reverse-proxy subnet
|
||||||
|
as a maintenance client merely because it is trusted for forwarding.
|
||||||
|
|
||||||
|
## Release security test plan (requires compiled RustProxy)
|
||||||
|
|
||||||
|
1. On the deployment host, run `cargo fmt --all -- --check`,
|
||||||
|
`./dev.sh check`, `./dev.sh test`, and `./dev.sh release`. Address any
|
||||||
|
compiler/regression errors before installation. `cargo audit` or an
|
||||||
|
equivalent advisory scan requires an up-to-date advisory DB and was not run
|
||||||
|
in this offline environment.
|
||||||
|
2. Start a disposable instance with a populated cache under both HTTP and
|
||||||
|
HTTPS for the same vhost/URL. Confirm `PURGE` returns 200 and drops the
|
||||||
|
exact query variant only; `BAN` returns 202 and invalidates only **older**
|
||||||
|
entries on the URL prefix. Confirm the *other* scheme and vhost stay hot.
|
||||||
|
3. From a denied IP, expect 403 on `BAN` and `PURGE`. With an empty allowlist,
|
||||||
|
all clients must be denied. Send duplicate TTL/confirmation/Content-Length,
|
||||||
|
TE/body and `BAN /` without confirmation: expect 400. On a trusted-proxy
|
||||||
|
hop with missing real client identity, expect 403. Test both IPv4 and IPv6.
|
||||||
|
4. From an allowed IP, send 10 concurrent broad maintenance requests; confirm
|
||||||
|
that only two workers run simultaneously and excess return 429. Verify
|
||||||
|
server CPU/IO and audit entries while the cold tier is populated.
|
||||||
|
5. Send GET requests without admin cookies to `/api/config`, `/api/status`,
|
||||||
|
`/api/system-info`, `/api/events`, `/api/logs`, `/docs/openapi.json` and
|
||||||
|
`/assets/app.js`; expect 401 and protective headers. With valid cookie but
|
||||||
|
missing/bad Origin, marker or CSRF on PUT/POST/DELETE, expect 403. Test
|
||||||
|
logout and password/session invalidation, live SSE reconnect/expiry.
|
||||||
|
6. Test the Certificate API with no grant, a valid certificate-specific grant,
|
||||||
|
revoked token, HTTP remote peer, and misuse against `/api/config`/logs.
|
||||||
|
Check errors for token disclosure and audit coverage.
|
||||||
|
7. Test actual browser startup, CSP console, output escaping and form save
|
||||||
|
with malicious HTML strings; inspect reverse-proxy Host/Origin forwarding.
|
||||||
|
Prefer HTTPS administration with a strict `allowed_hosts` list.
|
||||||
|
|
||||||
|
## Residual risk / non-claims
|
||||||
|
|
||||||
|
- **Not executed in this environment:** Cargo format/check/tests/release,
|
||||||
|
actual Rust listener requests, HTTP/2/3, fuzzing, penetration tests,
|
||||||
|
session revocation under load, proxy deployment configuration, dependency
|
||||||
|
CVE scanning and long-running cold-cache resource measurements.
|
||||||
|
- Proxy trust configuration is security-critical. A misconfigured edge that
|
||||||
|
accepts forged forwarded client addresses can let external users impersonate
|
||||||
|
an allowlisted IP. Restrict direct listener reachability and sanitize headers.
|
||||||
|
- A public IP allowlist is not user authentication; anyone using an allowed
|
||||||
|
IP/network can issue destructive maintenance commands. Use narrow CIDRs and
|
||||||
|
optionally enforce additional controls on the edge (mTLS, firewall).
|
||||||
|
- The two-worker concurrency bound mitigates bursts, not all denial-of-service
|
||||||
|
scenarios: configured cache size and storage latency still determine scan
|
||||||
|
cost. Per-IP quotas are not provided here.
|
||||||
|
- Proxy protocol path, certificate verification, arbitrary upstream code,
|
||||||
|
all application content and third-party packages are **not certified safe**
|
||||||
|
by this audit. Production acceptance remains open until the runtime test
|
||||||
|
plan is completed.
|
||||||
+47
-3
@@ -12,9 +12,25 @@ The Settings page includes a local search field that filters category tabs using
|
|||||||
|
|
||||||
Unfinished Web Server, Vhost, Redirect and Stream forms use a session-only draft dock. Closing a dirty editor minimizes its exact browser state to the bottom-right dock so another editor can be opened independently. The dock follows the square-corner panel styling of the dashboard and intentionally avoids rounded/glass presentation. Drafts are intentionally not written to SQLite because form state can contain credentials or other secrets; they are discarded on logout/session reset.
|
Unfinished Web Server, Vhost, Redirect and Stream forms use a session-only draft dock. Closing a dirty editor minimizes its exact browser state to the bottom-right dock so another editor can be opened independently. The dock follows the square-corner panel styling of the dashboard and intentionally avoids rounded/glass presentation. Drafts are intentionally not written to SQLite because form state can contain credentials or other secrets; they are discarded on logout/session reset.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
| Preference | Default | Allowed seconds |
|
||||||
|
|---|---:|---|
|
||||||
|
| `refresh.live_enabled` | `true` | `true`, `false` |
|
||||||
|
| `refresh.overview_seconds` | 2 | 1, 2, 5, 10 |
|
||||||
|
| `refresh.traffic_seconds` | 5 | 0, 3, 5, 10, 30 |
|
||||||
|
| `refresh.cache_seconds` | 5 | 0, 3, 5, 10, 30 |
|
||||||
|
| `refresh.system_info_seconds` | 60 | 0, 15, 30, 60, 300 |
|
||||||
|
|
||||||
|
`live_enabled=false` disables automatic SSE **and all polling**, including system information. `0` disables only that source's automatic polling. Overview and System information have **Refresh now** buttons; Traffic and Cache retain their own buttons, filters, charts and pagination. The per-view Auto checkboxes for Traffic/Cache/Transform and the Docker live switch remain independent opt-outs, and none changes another administrator's preferences. **Reset section** restores refresh defaults without changing theme or unrelated preferences.
|
||||||
|
|
||||||
|
The main `/api/events?interval=N` connection (allowlisted 1/2/5/10, default 2 seconds) carries compact live status plus the existing bounded log snapshot. The server checks the session on every event and retains its heartbeat, viewer cap and normal disconnect cleanup. On an SSE error the browser polls `/api/status?view=live` and `/api/logs` until snapshots resume, not concurrently during healthy streaming. The old full `/api/status` response is kept for API clients and initial diagnostics. The separate authenticated `/api/system-info` reuses cached diagnostics and ACME metadata, never runs diagnostic subprocesses per request, and is fetched automatically only for Overview or the relevant Settings pages. A hidden browser tab pauses the channel and all automatic polling; returning revalidates its session before normal operation. All interval changes take effect immediately, without reloading or changing any server-global cadence.
|
||||||
|
|
||||||
## System information (0.30.1)
|
## System information (0.30.1)
|
||||||
|
|
||||||
**Settings -> System information** (`/settings?tab=system-info`) is a read-only view of the authenticated `/api/status` endpoint. It reports the OS name/kernel on Linux when available, executable architecture, logical CPU count, CPU and RAM telemetry, host/process uptime, RustProxy version, process RSS, thread and file-descriptor counts, and the existing statistics source and data/database locations. Values not supported by the host/platform display as unavailable, never as zero.
|
**Settings -> System information** (`/settings?tab=system-info`) is a read-only view of the authenticated `/api/system-info` endpoint (and the initial, backwards-compatible full `/api/status` response). It reports the OS name/kernel on Linux when available, executable architecture, logical CPU count, CPU and RAM telemetry, host/process uptime, RustProxy version, process RSS, thread and file-descriptor counts, and the existing statistics source and data/database locations. Values not supported by the host/platform display as unavailable, never as zero.
|
||||||
|
|
||||||
ACME diagnostics formerly shown in About and under Administration are now on this tab: mode/state, certificate environment path, and the active Python/pip/Certbot versions. Versions are probed by the ACME runtime setup with bounded subprocess output/timeouts and published from stored runtime status. They are not queried at every page refresh or status API request. Failed/unavailable probes leave the version empty. No new persistent settings or permissions are introduced.
|
ACME diagnostics formerly shown in About and under Administration are now on this tab: mode/state, certificate environment path, and the active Python/pip/Certbot versions. Versions are probed by the ACME runtime setup with bounded subprocess output/timeouts and published from stored runtime status. They are not queried at every page refresh or status API request. Failed/unavailable probes leave the version empty. No new persistent settings or permissions are introduced.
|
||||||
|
|
||||||
@@ -98,11 +114,34 @@ accepted only when `administration.tls` is true. Plain remote HTTP remains avail
|
|||||||
"tls": true,
|
"tls": true,
|
||||||
"certificate_id": "admin-example",
|
"certificate_id": "admin-example",
|
||||||
"allowed_hosts": ["admin.example.com"],
|
"allowed_hosts": ["admin.example.com"],
|
||||||
"session_days": 30
|
"session_days": 30,
|
||||||
|
"panel_compression": {"zstd": true, "br": true, "gzip": true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Administration → Panel content compression** (next to the admin listener/TLS settings)
|
||||||
|
controls Zstd, Brotli and gzip for the authenticated HTML document, JavaScript,
|
||||||
|
CSS and language bundles. Each browser request is negotiated using
|
||||||
|
`Accept-Encoding` quality values: the highest positive `q` wins, and ties
|
||||||
|
prefer **Zstd → Brotli → gzip**. Codecs can be enabled independently; the
|
||||||
|
identity representation is returned when none is acceptable or all are off.
|
||||||
|
The source is encoded only once per embedded asset and shared across visitors.
|
||||||
|
Settings persist in the existing revisioned administration document, without
|
||||||
|
additional CLI flags or a separate environment/config variable, and take effect
|
||||||
|
without restart. Each compressed response includes `Content-Encoding`,
|
||||||
|
`Vary: Accept-Encoding` and the representation-specific `Content-Length`.
|
||||||
|
The response compression of proxied websites remains independent; API responses,
|
||||||
|
SSE and WebSocket streams are not affected.
|
||||||
|
|
||||||
|
**Reverse proxy:** an upstream may remove or replace `Accept-Encoding`. RustProxy
|
||||||
|
negotiates from the header it actually receives, not the original browser request.
|
||||||
|
A reverse proxy normally passes through already encoded responses instead of
|
||||||
|
recompressing them; if the reverse should exclusively manage encoding, configure
|
||||||
|
it to strip `Accept-Encoding` on requests upstream, or turn the panel codecs off.
|
||||||
|
This provides compatible HTTP negotiation but does not guarantee identical
|
||||||
|
compression with every reverse proxy configuration.
|
||||||
|
|
||||||
`certificate_id: null` uses `data/certs/default.pem` and `default.key`, generated by
|
`certificate_id: null` uses `data/certs/default.pem` and `default.key`, generated by
|
||||||
the application. A selected inventory certificate is loaded from the same private
|
the application. A selected inventory certificate is loaded from the same private
|
||||||
certificate directory. `session_days` controls the absolute browser-session lifetime
|
certificate directory. `session_days` controls the absolute browser-session lifetime
|
||||||
@@ -169,8 +208,13 @@ Version 0.22 adds foreground fill collapsing (`collapse_enabled`,
|
|||||||
(`proactive_refresh_enabled`, `proactive_refresh_before_expiry_seconds`,
|
(`proactive_refresh_enabled`, `proactive_refresh_before_expiry_seconds`,
|
||||||
`proactive_refresh_percent`, `proactive_refresh_max_inflight`). Defaults preserve the
|
`proactive_refresh_percent`, `proactive_refresh_max_inflight`). Defaults preserve the
|
||||||
previous stale-if-error behavior; proactive refresh is disabled by default. Selective
|
previous stale-if-error behavior; proactive refresh is disabled by default. Selective
|
||||||
PURGE/BAN is an operational API/panel action rather than persisted configuration. See
|
PURGE/BAN is available in the authenticated API/panel, and HTTP BAN/PURGE has an optional, separately stored CIDR allowlist (`cache.http_invalidation_allowed_cidrs`, empty by default). See
|
||||||
[CACHE.md](CACHE.md) for request-collapse, Range and invalidation semantics.
|
[CACHE.md](CACHE.md) for request-collapse, Range and invalidation semantics.
|
||||||
|
The adjacent **info (`i`) popover** in Settings → Cache shows ready-to-copy
|
||||||
|
`curl -X PURGE` and `curl -X BAN` examples in English and Polish, including
|
||||||
|
the explicit `BAN /` confirmation. These public methods authorize by the
|
||||||
|
source IP/CIDR only; they do not accept a browser admin session as a bypass.
|
||||||
|
See [0.31.0 security audit](SECURITY_AUDIT_0.31.0.md) for reverse-proxy trust and tests.
|
||||||
|
|
||||||
## Static-response transformation
|
## Static-response transformation
|
||||||
|
|
||||||
|
|||||||
+41
-1
@@ -1,4 +1,44 @@
|
|||||||
# Verification report - RustProxy 0.30.1
|
# Verification report - RustProxy 0.31.0
|
||||||
|
|
||||||
|
## 0.31.0 Axum handler `Send` correction (2026-10-10)
|
||||||
|
|
||||||
|
- **Reported failure:** `error[E0277]` at `src/proxy/types.rs:53`, `.fallback(forward)`, on Axum 0.8.9. `forward` calls asynchronous public BAN/PURGE handling. That helper accepted `&Request<Body>` across `.await`, which requires a `Sync` body to produce the `Send` future that `Handler` expects.
|
||||||
|
- **Source correction:** move `Request<Body>` into `cache_http_invalidation`, validate it synchronously, copy the method for audit logging and explicitly drop the body before the first `.await`. No cache selectors, IP permissions, status codes, API routes or session flows were changed. Strengthen `tools/checks/admin_surface_audit_0310.py` to reject a reintroduced borrowed request in the async handler.
|
||||||
|
- **Executed:** `./dev.sh test` — all pre-Rust checks **PASS** (source/security/SQLite, UI Node, docs, language packs); command terminates **127** when Cargo is required. `./dev.sh check` — **127**, Cargo absent. `cd docker-agent && go test ./...` — **PASS**. `bash -n dev.sh`, Python source compile and manifest version probe — **PASS** (`0.31.0`).
|
||||||
|
- **Not executed:** Rust `cargo check --locked --all-targets`, `cargo test --locked --all-targets`, `cargo build --locked --release`, rustfmt or live HTTP regression. No `cargo`/`rustc` or download access is available in this environment. This source correction is not claimed as a compiler-verified release.
|
||||||
|
|
||||||
|
## 0.31.0 BAN/PURGE compilation fix, security review and UI help (2026-10-10)
|
||||||
|
|
||||||
|
- Addressed reported Rust E0716 (`format!` temporary borrowed as `&'static str`) by returning a fixed 422 response and logging the specific rejection.
|
||||||
|
- Restricted public HTTP maintenance to one external scheme and matched Host/vhost, rejected duplicate controls and invalid bodies, and added a shared two-slot limit plus `spawn_blocking` for heavy HTTP/admin cache invalidation work; audit removal before BAN-rule deletion.
|
||||||
|
- Added localized EN/PL `curl` help popover beside the Cache allowlist; added protocol/host/scheme/headers Rust regression sources, admin anonymous/CSRF test sources and an offline source audit script. The detailed findings, attack-surface table and operator acceptance checklist are in [SECURITY_AUDIT_0.31.0.md](SECURITY_AUDIT_0.31.0.md).
|
||||||
|
- **Executed this follow-up:** `./dev.sh test` passed every pre-Rust static/SQLite/security/JS/docs/translation check, then exited **127** on unavailable Cargo. `./dev.sh check` also exited **127**. `go test ./...` under `docker-agent/`, syntax checking all 27 browser JS files, OpenAPI JSON parse, shell syntax and separate source audits passed. A separate Chromium screenshot attempt timed out and does **not** count as verified UI rendering.
|
||||||
|
- **Rust compiler and runtime tests NOT RUN HERE**: `cargo`, `rustc` and `rustfmt` are absent; the only safe conclusion about E0716 is that its offending lifetime pattern was removed in source. Live BAN/PURGE over H1/H2/H3, TLS, reverse trust, performance and regression gates remain open.
|
||||||
|
|
||||||
|
## 0.31.0 negotiated panel content compression and HTTP BAN/PURGE follow-up (2026-10-10)
|
||||||
|
|
||||||
|
- `administration.panel_compression` defaults to `{zstd:true,br:true,gzip:true}` and uses the **existing Administration form/config revisions**. Authenticated panel HTML and embedded `/assets/` CSS/JS/language bundles are precompressed only once per resource; per-request browser `Accept-Encoding`/`q` selects zstd, br, gzip or identity. The public login HTML remains identity (static login assets can negotiate compression). `Vary`, `Content-Encoding`, `Content-Length` are set per response. Compression for public proxied responses is unchanged. An upstream reverse proxy can override `Accept-Encoding` and therefore the negotiated result.
|
||||||
|
- Empty-by-default `cache.http_invalidation_allowed_cidrs` restricts public-vhost `PURGE /path?query` exact removal and `BAN /prefix` lazy invalidation to allowed client IPv4/IPv6 ranges; `/0` rejected. Existing vhost/host ACL, rate limit and trusted forwarding logic remain in force. `BAN /` requires explicit confirmation; operation is audited, uses `spawn_blocking` around the existing cache engine and is never sent upstream. CIDR changes do not rotate cached objects.
|
||||||
|
- System information Refresh button is next to the API badge, without another row. EN/PL locales, OpenAPI and settings/cache/API docs were updated; SQLite schema remains 9 and Cold format remains 3.
|
||||||
|
- **PASS:** `./dev.sh test` completed all pre-Rust checks (SQLite migrations, release smoke, security, translations, Node UI, source contracts) then exited **127** because `cargo`/`rustc` are missing. `./dev.sh check` likewise exits **127**. Standalone `go test ./...` in `docker-agent/`, JavaScript syntax checks, OpenAPI JSON parse and Node form roundtrip passed. Chromium headless verified that API badge and System information Refresh button remain on the same row at viewport widths 320, 390, 1024 px.
|
||||||
|
- **Source-byte measurements (not wire captures)**: authenticated HTML after version substitution **320,756 B**; Zstd level 3 around **50,526 B**, Brotli level 5 around **43,654 B**, gzip level 6 around **47,343 B** using local utilities. The actual wire choice follows browser `q` values, not necessarily the smallest encoded representation. FCP/LCP and mobile real-browser startup were not measured on a running RustProxy.
|
||||||
|
- **Rust NOT RUN:** no Cargo/Rustc; Rust codec decoding, actual vhost BAN/PURGE authorization/HTTP/1-H2-H3, live proxy integration and exact wire metrics require a Rust-capable host. Production acceptance remains open.
|
||||||
|
|
||||||
|
## 0.31.0 mobile startup / HTML payload follow-up (2026-10-10)
|
||||||
|
|
||||||
|
- Removed `html.app-loading body { visibility: hidden }`. An initial CSS-driven overlay shows a loading spinner before the application code runs, a delayed (12 s) retry link when scripts or requests stall, and an explicit localized EN/PL failure state. No inline scripts or styles were introduced; authenticated CSP and the public login document are preserved. Reduced-motion preferences disable the spinner animation.
|
||||||
|
- Dashboard shell and live SSE start once the authenticated session and language bundle are resolved, **without blocking on** preferences, configuration, certificate inventory or initial `/api/status`. Unavailable configuration shows a visible toast and does not blank the page. This improves perceived startup but is not a measured FCP improvement.
|
||||||
|
- Moved the Overview **Refresh now** button into the title row, with no additional top-level row; the button remains hidden outside Overview.
|
||||||
|
- Private HTML is gzip-compressed and cached once in memory (no per-request recompression); `Accept-Encoding: gzip` yields `Content-Encoding: gzip`, `Vary: Accept-Encoding` and compressed `Content-Length`. Without gzip or with `gzip;q=0`, the existing identity HTML remains. Public login is not compressed/altered by this handler; API, SSE and proxy paths are not changed.
|
||||||
|
- **Measured from source bytes:** HTML source **318,905 B**, authenticated response after substituting two version placeholders **318,875 B** (previous packaged authenticated response **317,723 B**); Python gzip level 6 of the response **46,821 B** (about **85.3% less HTML transfer**). These are source-based estimates, **not HTTP wire captures from running RustProxy**, so browser-delivered Content-Length remains to be confirmed with a compiled server.
|
||||||
|
- **PASS:** deterministic Node startup/placement test, Chromium 320/390/1024 px rendering and title/button alignment, existing static checks and Node coordinator regressions. **NOT RUN:** Rust compilation or Rust tests (`cargo`/`rustc` unavailable), live HTTP gzip/identity/auth, real mobile hardware reproduction and field measurements (FCP/LCP/white-screen rate). Acceptance remains open.
|
||||||
|
|
||||||
|
## 0.31.0 dashboard refresh implementation and source verification (2026-10-10)
|
||||||
|
|
||||||
|
- Source delivery based on the supplied `RustProxy-0.30.1.zip` (no `.git`; remote `master` not verifiable). Implemented one per-browser scheduler, per-account/validated preferences, per-client SSE interval and fallback isolation, active-view Traffic/Cache, separated `/api/system-info`, compatible full `/api/status`, version/docs and English/Polish UI.
|
||||||
|
- **PASS:** source/version/module/security checks, SQLite schema-9/upgrade smoke, translations/docs, all bundled Node UI tests including deterministic refresh scheduler, Go agent unit tests, all admin JS syntax. Browser simulation checks 20 view transitions, no duplicated timers/in-flight requests, 0/manual, hidden tab, SSE failure/recovery, independent errors and logout.
|
||||||
|
- **NOT RUN:** Rust compilation, Rust tests and release build because `cargo`/`rustc`/`rustfmt` are missing (`./dev.sh test`/`check`/`fmt`/`release` exit 127 at toolchain gate). Real RustProxy API/SSE/traffic/regression tests and SSE bandwidth/CPU measurement remain unverified. The stress suite's Python HTTP test peer is **not** a RustProxy runtime test.
|
||||||
|
- See [full 0.31.0 implementation and verification report](IMPLEMENTATION_0.31.0.md). No database schema changes, git commit or tag.
|
||||||
|
|
||||||
## 0.30.1 sysinfo compiler-feature fix (2026-10-10)
|
## 0.30.1 sysinfo compiler-feature fix (2026-10-10)
|
||||||
|
|
||||||
|
|||||||
+2333
-2307
File diff suppressed because it is too large.
Load diff
+2333
-2307
File diff suppressed because it is too large.
Load diff
@@ -233,6 +233,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
|||||||
"/docs/SECURITY_AUDIT.md"
|
"/docs/SECURITY_AUDIT.md"
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
|
reader_doc!("docs/SECURITY_AUDIT_0.31.0.md", "audits"),
|
||||||
(
|
(
|
||||||
"docs/SETTINGS.md",
|
"docs/SETTINGS.md",
|
||||||
"guides",
|
"guides",
|
||||||
@@ -243,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
|||||||
"guides",
|
"guides",
|
||||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
|
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
|
||||||
),
|
),
|
||||||
|
reader_doc!("docs/IMPLEMENTATION_0.31.0.md", "technical"),
|
||||||
(
|
(
|
||||||
"docs/TEST_REPORT.md",
|
"docs/TEST_REPORT.md",
|
||||||
"guides",
|
"guides",
|
||||||
|
|||||||
@@ -294,7 +294,21 @@ async fn audit(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
|||||||
async fn metrics(State(state): State<Arc<App>>) -> Response {
|
async fn metrics(State(state): State<Arc<App>>) -> Response {
|
||||||
crate::prometheus::response(&state)
|
crate::prometheus::response(&state)
|
||||||
}
|
}
|
||||||
async fn events(State(state): State<Arc<App>>, headers: HeaderMap) -> Result<Response, ApiError> {
|
#[derive(Default, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
struct EventsQuery {
|
||||||
|
interval: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn events(
|
||||||
|
State(state): State<Arc<App>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<EventsQuery>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let interval = query.interval.unwrap_or(2);
|
||||||
|
if ![1, 2, 5, 10].contains(&interval) {
|
||||||
|
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "invalid SSE interval".into()));
|
||||||
|
}
|
||||||
let secret = auth::cookie_secret(&headers)
|
let secret = auth::cookie_secret(&headers)
|
||||||
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?;
|
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?;
|
||||||
let permit = state
|
let permit = state
|
||||||
@@ -310,10 +324,10 @@ async fn events(State(state): State<Arc<App>>, headers: HeaderMap) -> Result<Res
|
|||||||
// One bounded snapshot, produced only when the consumer polls. No per-client queue.
|
// One bounded snapshot, produced only when the consumer polls. No per-client queue.
|
||||||
// Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions.
|
// Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions.
|
||||||
let stream = futures_util::stream::unfold(
|
let stream = futures_util::stream::unfold(
|
||||||
(state, secret, permit, false),
|
(state, secret, permit, false, interval),
|
||||||
|(state, secret, permit, delayed)| async move {
|
|(state, secret, permit, delayed, interval)| async move {
|
||||||
if delayed {
|
if delayed {
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||||
}
|
}
|
||||||
let session_seconds = state
|
let session_seconds = state
|
||||||
.config
|
.config
|
||||||
@@ -331,11 +345,11 @@ async fn events(State(state): State<Arc<App>>, headers: HeaderMap) -> Result<Res
|
|||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
let payload =
|
let payload =
|
||||||
json!({"status":status_payload(&state), "logs":logs_payload(&state)}).to_string();
|
json!({"status":live_status_payload(&state), "logs":logs_payload(&state)}).to_string();
|
||||||
let event = Event::default().event("snapshot").data(payload);
|
let event = Event::default().event("snapshot").data(payload);
|
||||||
Some((
|
Some((
|
||||||
Ok::<Event, Infallible>(event),
|
Ok::<Event, Infallible>(event),
|
||||||
(state, secret, permit, true),
|
(state, secret, permit, true, interval),
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+167
-3
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "RustProxy Administration API",
|
"title": "RustProxy Administration API",
|
||||||
"version": "0.30.1",
|
"version": "0.31.0",
|
||||||
"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": [
|
||||||
@@ -772,6 +772,25 @@
|
|||||||
"maximum": 30,
|
"maximum": 30,
|
||||||
"default": 30,
|
"default": 30,
|
||||||
"description": "Absolute browser administrator-session lifetime in days. Applies immediately and does not require restart."
|
"description": "Absolute browser administrator-session lifetime in days. Applies immediately and does not require restart."
|
||||||
|
},
|
||||||
|
"panel_compression": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Negotiated compression for authenticated panel HTML and embedded CSS/JS/translation assets; takes effect without restart. At equal client q: zstd > br > gzip. Disable all for identity. Independent from public proxy response compression.",
|
||||||
|
"properties": {
|
||||||
|
"zstd": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"br": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"gzip": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -1859,6 +1878,16 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Suppress RustProxy cache diagnostic headers on MISS/BYPASS. Cache HIT diagnostics remain governed by expose_* flags. The independently configured Via header is unaffected."
|
"description": "Suppress RustProxy cache diagnostic headers on MISS/BYPASS. Cache HIT diagnostics remain governed by expose_* flags. The independently configured Via header is unaffected."
|
||||||
|
},
|
||||||
|
"http_invalidation_allowed_cidrs": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Optional IP/CIDR allowlist for HTTP BAN and PURGE on matched public vhosts. Empty disables all HTTP invalidation. Max 32 entries; /0 networks forbidden. Trusted reverse proxies must provide an authenticated forwarding chain.",
|
||||||
|
"maxItems": 32,
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -3527,6 +3556,60 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Account-scoped sort direction for the Streams list."
|
"description": "Account-scoped sort direction for the Streams list."
|
||||||
|
},
|
||||||
|
"refresh": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Account-scoped polling preferences; omitted fields use defaults. Zero disables only that source automatically.",
|
||||||
|
"properties": {
|
||||||
|
"live_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"overview_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"default": 2
|
||||||
|
},
|
||||||
|
"traffic_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"cache_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"system_info_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [
|
||||||
|
0,
|
||||||
|
15,
|
||||||
|
30,
|
||||||
|
60,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"default": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -5777,6 +5860,27 @@
|
|||||||
"$ref": "#/components/schemas/WebService/properties/php"
|
"$ref": "#/components/schemas/WebService/properties/php"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SystemInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"system": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cached system and process metrics",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cached platform and ACME runtime metadata",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"memory_accounting": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cached process/cache and transform memory accounting",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6691,7 +6795,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "view",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"description": "Omit for the backward-compatible full status; use live for the compact dynamic-only fallback.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"live"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Without view, returns the historical full status including runtime and system diagnostics. view=live returns compact live-only telemetry; the browser fetches system diagnostics separately."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/backends": {
|
"/api/backends": {
|
||||||
@@ -8228,7 +8347,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Bounded snapshot every approximately 2 seconds. Session/revocation rechecked before every snapshot. Not executed in Swagger because it is an unbounded stream."
|
"description": "SSE snapshot events contain compact volatile status and bounded logs. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/5/10 second pacing; invalid values return 422.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "interval",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"description": "Per-client snapshot interval (seconds), never a global SSE rate change.",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"default": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/metrics": {
|
"/metrics": {
|
||||||
@@ -10727,6 +10864,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/system-info": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Cached system and runtime information for the administration panel",
|
||||||
|
"tags": [
|
||||||
|
"Monitoring"
|
||||||
|
],
|
||||||
|
"operationId": "get_api_system_info",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Read-only cached system info; no full configuration or secrets",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SystemInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Invalid origin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+25
-9
@@ -79,6 +79,8 @@ async fn cache_purge(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
|
||||||
|
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
|
||||||
let detail = format!(
|
let detail = format!(
|
||||||
"scope={}; in-flight fills invalidated",
|
"scope={}; in-flight fills invalidated",
|
||||||
scope.vhost.as_deref().unwrap_or("all")
|
scope.vhost.as_deref().unwrap_or("all")
|
||||||
@@ -88,10 +90,15 @@ async fn cache_purge(
|
|||||||
.audit(&session.username, "cache.purge", &detail)
|
.audit(&session.username, "cache.purge", &detail)
|
||||||
.await
|
.await
|
||||||
.map_err(auth::internal)?;
|
.map_err(auth::internal)?;
|
||||||
let purged = match &scope.vhost {
|
let cache = state.cache.clone();
|
||||||
Some(id) => state.cache.purge_scope(id),
|
let vhost = scope.vhost.clone();
|
||||||
None => state.cache.purge(None),
|
let purged = tokio::task::spawn_blocking(move || {
|
||||||
};
|
let _permit = permit;
|
||||||
|
match &vhost {
|
||||||
|
Some(id) => cache.purge_scope(id),
|
||||||
|
None => cache.purge(None),
|
||||||
|
}
|
||||||
|
}).await.map_err(auth::internal)?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}),
|
json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}),
|
||||||
))
|
))
|
||||||
@@ -119,6 +126,8 @@ async fn cache_invalidate(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
|
||||||
|
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
|
||||||
let detail = format!(
|
let detail = format!(
|
||||||
"operation={:?}; selector={:?}; vhost={}; host={}; value={:?}",
|
"operation={:?}; selector={:?}; vhost={}; host={}; value={:?}",
|
||||||
request.operation,
|
request.operation,
|
||||||
@@ -132,10 +141,12 @@ async fn cache_invalidate(
|
|||||||
.audit(&session.username, "cache.invalidate", &detail)
|
.audit(&session.username, "cache.invalidate", &detail)
|
||||||
.await
|
.await
|
||||||
.map_err(auth::internal)?;
|
.map_err(auth::internal)?;
|
||||||
let result = state
|
let cache = state.cache.clone();
|
||||||
.cache
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
.invalidate(request)
|
let _permit = permit;
|
||||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
|
cache.invalidate(request)
|
||||||
|
}).await.map_err(auth::internal)?
|
||||||
|
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"result": result,
|
"result": result,
|
||||||
"metrics": state.cache.view(),
|
"metrics": state.cache.view(),
|
||||||
@@ -152,7 +163,9 @@ async fn cache_ban_delete(
|
|||||||
Extension(session): Extension<Session>,
|
Extension(session): Extension<Session>,
|
||||||
Path(id): Path<u64>,
|
Path(id): Path<u64>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
if !state.cache.remove_ban(id) {
|
// This is a destructive operation: fail closed if the audit store is
|
||||||
|
// unavailable, rather than removing a ban and only then trying to log it.
|
||||||
|
if !state.cache.bans().iter().any(|rule| rule.id == id) {
|
||||||
return Err(ApiError(
|
return Err(ApiError(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"unknown cache BAN rule".into(),
|
"unknown cache BAN rule".into(),
|
||||||
@@ -167,6 +180,9 @@ async fn cache_ban_delete(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(auth::internal)?;
|
.map_err(auth::internal)?;
|
||||||
|
if !state.cache.remove_ban(id) {
|
||||||
|
return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into()));
|
||||||
|
}
|
||||||
Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
|
Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Fixed private admin assets, shared by all admins. The existing route/access
|
||||||
|
// allowlist decides which paths are reachable. Do not expand routes dynamically.
|
||||||
|
use super::panel_content::PanelContent;
|
||||||
|
use crate::runtime::App;
|
||||||
|
use axum::{extract::{Request, State}, response::{IntoResponse, Response}};
|
||||||
|
use http::StatusCode;
|
||||||
|
use std::{collections::HashMap, sync::{Arc, LazyLock, OnceLock}};
|
||||||
|
|
||||||
|
struct Asset {
|
||||||
|
mime: &'static str,
|
||||||
|
raw: &'static str,
|
||||||
|
content: OnceLock<PanelContent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const JS: &str = "text/javascript; charset=utf-8";
|
||||||
|
const JSON: &str = "application/json; charset=utf-8";
|
||||||
|
const CSS: &str = "text/css; charset=utf-8";
|
||||||
|
const SVG: &str = "image/svg+xml";
|
||||||
|
|
||||||
|
static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
|
||||||
|
let entries: [(&'static str, &'static str, &'static str); 22] = [
|
||||||
|
("/assets/login.js", JS, include_str!("web/login.js")),
|
||||||
|
("/assets/login-langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/login-langs.json"))),
|
||||||
|
("/assets/langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/langs.json"))),
|
||||||
|
("/assets/app.js", JS, super::admin_app_javascript()),
|
||||||
|
("/assets/management.js", JS, include_str!("web/management.js")),
|
||||||
|
("/assets/drafts.js", JS, include_str!("web/drafts.js")),
|
||||||
|
("/assets/integrations.js", JS, include_str!("web/integrations.js")),
|
||||||
|
("/assets/traffic-panel.js", JS, include_str!("web/traffic-panel.js")),
|
||||||
|
("/assets/cache-panel.js", JS, include_str!("web/cache-panel.js")),
|
||||||
|
("/assets/transform-panel.js", JS, include_str!("web/transform-panel.js")),
|
||||||
|
("/assets/file-logs.js", JS, include_str!("web/file-logs.js")),
|
||||||
|
("/assets/settings.js", JS, include_str!("web/settings.js")),
|
||||||
|
("/assets/acme.js", JS, include_str!("web/acme.js")),
|
||||||
|
("/assets/vhosts.js", JS, super::admin_vhosts_javascript()),
|
||||||
|
("/assets/web-server-profiles.js", JS, include_str!("web/web-server-profiles.js")),
|
||||||
|
("/assets/web-server.js", JS, include_str!("web/web-server.js")),
|
||||||
|
("/assets/docker.js", JS, include_str!("web/docker.js")),
|
||||||
|
("/assets/streams.js", JS, include_str!("web/streams.js")),
|
||||||
|
("/assets/redirects.js", JS, include_str!("web/redirects.js")),
|
||||||
|
("/assets/host404.js", JS, include_str!("web/host404.js")),
|
||||||
|
("/assets/style.css", CSS, include_str!("web/style.css")),
|
||||||
|
("/favicon.svg", SVG, include_str!("web/favicon.svg")),
|
||||||
|
];
|
||||||
|
entries.into_iter().map(|(path, mime, raw)| (path, Asset { mime, raw, content: OnceLock::new() }))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Response {
|
||||||
|
let Some(asset) = ASSETS.get(request.uri().path()) else {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
};
|
||||||
|
let snapshot = state.config.load();
|
||||||
|
let codecs = &snapshot.envelope.config.administration.panel_compression;
|
||||||
|
asset.content.get_or_init(|| PanelContent::new(asset.raw))
|
||||||
|
.response(request.headers(), codecs, asset.mime)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Shared immutable precompressed responses for private panel HTML and assets.
|
||||||
|
// Each embedded document is encoded once on first request, never per visitor.
|
||||||
|
use crate::settings::PanelCompression;
|
||||||
|
use axum::{body::Body, response::Response};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use flate2::{write::GzEncoder, Compression};
|
||||||
|
use http::{header, HeaderMap, HeaderValue};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
pub(super) struct PanelContent {
|
||||||
|
plain: Bytes,
|
||||||
|
gzip: Bytes,
|
||||||
|
brotli: Bytes,
|
||||||
|
zstd: Bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelContent {
|
||||||
|
pub(super) fn new(raw: &'static str) -> Self {
|
||||||
|
let mut gzip = GzEncoder::new(Vec::new(), Compression::default());
|
||||||
|
gzip.write_all(raw.as_bytes()).expect("compress panel gzip");
|
||||||
|
let gzip = Bytes::from(gzip.finish().expect("finish panel gzip"));
|
||||||
|
let mut br = Vec::new();
|
||||||
|
{
|
||||||
|
let mut writer = brotli::CompressorWriter::new(&mut br, 4096, 5, 22);
|
||||||
|
writer.write_all(raw.as_bytes()).expect("compress panel Brotli");
|
||||||
|
}
|
||||||
|
let zstd = zstd::stream::encode_all(raw.as_bytes(), 3).expect("compress panel zstd");
|
||||||
|
Self { plain: Bytes::from_static(raw.as_bytes()), gzip, brotli: Bytes::from(br), zstd: Bytes::from(zstd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn response(&self, headers: &HeaderMap, codecs: &PanelCompression, mime: &'static str) -> Response {
|
||||||
|
let settings = crate::compression::Settings {
|
||||||
|
gzip: codecs.gzip, br: codecs.br, zstd: codecs.zstd,
|
||||||
|
deflate: false, min_bytes: 1024,
|
||||||
|
};
|
||||||
|
let encoding = if crate::compression::no_transform(headers) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
crate::compression::preferred_encoding(headers, &settings)
|
||||||
|
};
|
||||||
|
let (body, name) = match encoding.map(|codec| codec.as_str()) {
|
||||||
|
Some("zstd") => (self.zstd.clone(), Some("zstd")),
|
||||||
|
Some("br") => (self.brotli.clone(), Some("br")),
|
||||||
|
Some("gzip") => (self.gzip.clone(), Some("gzip")),
|
||||||
|
_ => (self.plain.clone(), None),
|
||||||
|
};
|
||||||
|
let mut response = Response::new(Body::from(body.clone()));
|
||||||
|
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
|
||||||
|
response.headers_mut().insert(header::CONTENT_LENGTH, HeaderValue::from_str(&body.len().to_string()).expect("valid length"));
|
||||||
|
if let Some(name) = name {
|
||||||
|
response.headers_mut().insert(header::CONTENT_ENCODING, HeaderValue::from_static(name));
|
||||||
|
}
|
||||||
|
response.headers_mut().insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,94 +1,4 @@
|
|||||||
const LIVE_LOG_COLUMNS: &[&str] = &[
|
include!("preferences_columns.rs");
|
||||||
"time",
|
|
||||||
"request",
|
|
||||||
"host",
|
|
||||||
"source_ip",
|
|
||||||
"vhost",
|
|
||||||
"backend",
|
|
||||||
"status",
|
|
||||||
"duration",
|
|
||||||
"cache",
|
|
||||||
"outcome",
|
|
||||||
"transport",
|
|
||||||
"websocket",
|
|
||||||
"upstream_error",
|
|
||||||
];
|
|
||||||
const FILE_LOG_COLUMN_MAX: usize = 64;
|
|
||||||
const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
|
||||||
"vhost",
|
|
||||||
"backend",
|
|
||||||
"status",
|
|
||||||
"active",
|
|
||||||
"requests",
|
|
||||||
"errors",
|
|
||||||
"latency",
|
|
||||||
"traffic",
|
|
||||||
"generation",
|
|
||||||
"generationRequests",
|
|
||||||
"generationAge",
|
|
||||||
"rotations",
|
|
||||||
"lastFailure",
|
|
||||||
"lastRecovery",
|
|
||||||
];
|
|
||||||
const VHOST_LIST_SORT_COLUMNS: &[&str] = &[
|
|
||||||
"id",
|
|
||||||
"hostname",
|
|
||||||
"status",
|
|
||||||
"listeners",
|
|
||||||
"certificate",
|
|
||||||
"backends",
|
|
||||||
];
|
|
||||||
const DOCKER_VHOST_SORT_COLUMNS: &[&str] = &[
|
|
||||||
"service",
|
|
||||||
"hostname",
|
|
||||||
"status",
|
|
||||||
"backend",
|
|
||||||
"scheme",
|
|
||||||
"published",
|
|
||||||
];
|
|
||||||
const REDIRECT_LIST_SORT_COLUMNS: &[&str] = &[
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"hostname",
|
|
||||||
"status",
|
|
||||||
"target",
|
|
||||||
"rules",
|
|
||||||
"listeners",
|
|
||||||
"certificate",
|
|
||||||
];
|
|
||||||
const NOTFOUND_LIST_SORT_COLUMNS: &[&str] = &[
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"hostname",
|
|
||||||
"status",
|
|
||||||
"mode",
|
|
||||||
"listeners",
|
|
||||||
"certificate",
|
|
||||||
];
|
|
||||||
const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
|
|
||||||
"name",
|
|
||||||
"id",
|
|
||||||
"protocol",
|
|
||||||
"listen",
|
|
||||||
"upstream",
|
|
||||||
"status",
|
|
||||||
"maxConnections",
|
|
||||||
"idleTimeout",
|
|
||||||
];
|
|
||||||
const DASHBOARD_WIDGETS: &[&str] = &[
|
|
||||||
"requests",
|
|
||||||
"active",
|
|
||||||
"bandwidth",
|
|
||||||
"errors",
|
|
||||||
"cache",
|
|
||||||
"memory",
|
|
||||||
"websocket",
|
|
||||||
"http2",
|
|
||||||
"http3",
|
|
||||||
"tls",
|
|
||||||
"http_protocol",
|
|
||||||
"websocket_protocol",
|
|
||||||
];
|
|
||||||
|
|
||||||
fn default_admin_theme() -> String {
|
fn default_admin_theme() -> String {
|
||||||
"dark".to_owned()
|
"dark".to_owned()
|
||||||
@@ -159,9 +69,13 @@ fn default_stream_list_sort() -> String {
|
|||||||
"name".to_owned()
|
"name".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
include!("refresh_preferences.rs");
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct AdminPreferences {
|
struct AdminPreferences {
|
||||||
|
#[serde(default)]
|
||||||
|
refresh: RefreshPreferences,
|
||||||
|
#[serde(default = "default_live_log_columns")]
|
||||||
live_log_columns: Vec<String>,
|
live_log_columns: Vec<String>,
|
||||||
#[serde(default = "default_admin_theme")]
|
#[serde(default = "default_admin_theme")]
|
||||||
theme: String,
|
theme: String,
|
||||||
@@ -216,6 +130,7 @@ struct AdminPreferences {
|
|||||||
impl Default for AdminPreferences {
|
impl Default for AdminPreferences {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
refresh: RefreshPreferences::default(),
|
||||||
live_log_columns: LIVE_LOG_COLUMNS
|
live_log_columns: LIVE_LOG_COLUMNS
|
||||||
.iter()
|
.iter()
|
||||||
.map(|value| (*value).to_owned())
|
.map(|value| (*value).to_owned())
|
||||||
@@ -249,6 +164,7 @@ impl Default for AdminPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
|
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
|
||||||
|
validate_refresh_preferences(&preferences.refresh)?;
|
||||||
if preferences.live_log_columns.is_empty()
|
if preferences.live_log_columns.is_empty()
|
||||||
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
|
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
|
||||||
{
|
{
|
||||||
@@ -490,3 +406,4 @@ async fn update_preferences(
|
|||||||
}
|
}
|
||||||
Ok(Json(preferences))
|
Ok(Json(preferences))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const LIVE_LOG_COLUMNS: &[&str] = &[
|
||||||
|
"time",
|
||||||
|
"request",
|
||||||
|
"host",
|
||||||
|
"source_ip",
|
||||||
|
"vhost",
|
||||||
|
"backend",
|
||||||
|
"status",
|
||||||
|
"duration",
|
||||||
|
"cache",
|
||||||
|
"outcome",
|
||||||
|
"transport",
|
||||||
|
"websocket",
|
||||||
|
"upstream_error",
|
||||||
|
];
|
||||||
|
const FILE_LOG_COLUMN_MAX: usize = 64;
|
||||||
|
const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
||||||
|
"vhost",
|
||||||
|
"backend",
|
||||||
|
"status",
|
||||||
|
"active",
|
||||||
|
"requests",
|
||||||
|
"errors",
|
||||||
|
"latency",
|
||||||
|
"traffic",
|
||||||
|
"generation",
|
||||||
|
"generationRequests",
|
||||||
|
"generationAge",
|
||||||
|
"rotations",
|
||||||
|
"lastFailure",
|
||||||
|
"lastRecovery",
|
||||||
|
];
|
||||||
|
const VHOST_LIST_SORT_COLUMNS: &[&str] = &[
|
||||||
|
"id",
|
||||||
|
"hostname",
|
||||||
|
"status",
|
||||||
|
"listeners",
|
||||||
|
"certificate",
|
||||||
|
"backends",
|
||||||
|
];
|
||||||
|
const DOCKER_VHOST_SORT_COLUMNS: &[&str] = &[
|
||||||
|
"service",
|
||||||
|
"hostname",
|
||||||
|
"status",
|
||||||
|
"backend",
|
||||||
|
"scheme",
|
||||||
|
"published",
|
||||||
|
];
|
||||||
|
const REDIRECT_LIST_SORT_COLUMNS: &[&str] = &[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"hostname",
|
||||||
|
"status",
|
||||||
|
"target",
|
||||||
|
"rules",
|
||||||
|
"listeners",
|
||||||
|
"certificate",
|
||||||
|
];
|
||||||
|
const NOTFOUND_LIST_SORT_COLUMNS: &[&str] = &[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"hostname",
|
||||||
|
"status",
|
||||||
|
"mode",
|
||||||
|
"listeners",
|
||||||
|
"certificate",
|
||||||
|
];
|
||||||
|
const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
|
||||||
|
"name",
|
||||||
|
"id",
|
||||||
|
"protocol",
|
||||||
|
"listen",
|
||||||
|
"upstream",
|
||||||
|
"status",
|
||||||
|
"maxConnections",
|
||||||
|
"idleTimeout",
|
||||||
|
];
|
||||||
|
const DASHBOARD_WIDGETS: &[&str] = &[
|
||||||
|
"requests",
|
||||||
|
"active",
|
||||||
|
"bandwidth",
|
||||||
|
"errors",
|
||||||
|
"cache",
|
||||||
|
"memory",
|
||||||
|
"websocket",
|
||||||
|
"http2",
|
||||||
|
"http3",
|
||||||
|
"tls",
|
||||||
|
"http_protocol",
|
||||||
|
"websocket_protocol",
|
||||||
|
];
|
||||||
|
|
||||||
+31
-14
@@ -1,21 +1,35 @@
|
|||||||
//! Deliberately small anonymous surface. Operational templates/assets are private.
|
//! Deliberately small anonymous surface. Operational templates/assets are private.
|
||||||
use crate::{auth::Session, runtime::ApiError};
|
use crate::{auth::Session, runtime::{ApiError, App}, settings::PanelCompression};
|
||||||
use axum::{extract::rejection::JsonRejection, response::Html, Extension};
|
use axum::{extract::{rejection::JsonRejection, State}, response::{Html, IntoResponse, Response}, Extension};
|
||||||
use http::StatusCode;
|
use http::{HeaderMap, StatusCode};
|
||||||
use std::sync::OnceLock;
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
static ADMIN_INDEX: OnceLock<String> = OnceLock::new();
|
use super::panel_content::PanelContent;
|
||||||
|
|
||||||
pub(super) async fn index(session: Option<Extension<Session>>) -> Html<&'static str> {
|
static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
|
||||||
if session.is_some() {
|
|
||||||
Html(ADMIN_INDEX.get_or_init(|| {
|
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
|
||||||
include_str!("web/index.html")
|
let content = ADMIN_INDEX.get_or_init(|| {
|
||||||
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"))
|
let html = include_str!("web/index.html")
|
||||||
}).as_str())
|
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"));
|
||||||
} else {
|
// Exactly one static panel document shared between all administrator accounts.
|
||||||
Html(include_str!("web/login.html"))
|
PanelContent::new(Box::leak(html.into_boxed_str()))
|
||||||
}
|
});
|
||||||
|
content.response(headers, compression, "text/html; charset=utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn index(
|
||||||
|
State(state): State<Arc<App>>,
|
||||||
|
session: Option<Extension<Session>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
if session.is_none() {
|
||||||
|
return Html(include_str!("web/login.html")).into_response();
|
||||||
|
}
|
||||||
|
let snapshot = state.config.load();
|
||||||
|
panel_response(&headers, &snapshot.envelope.config.administration.panel_compression)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
|
pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
|
||||||
// Keep useful protocol statuses without returning serde's input excerpts.
|
// Keep useful protocol statuses without returning serde's input excerpts.
|
||||||
let status = match error.status() {
|
let status = match error.status() {
|
||||||
@@ -25,3 +39,6 @@ pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
|
|||||||
};
|
};
|
||||||
ApiError(status, "auth.invalid_request".into())
|
ApiError(status, "auth.invalid_request".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use super::*;
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
fn headers(value: &str) -> HeaderMap {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert(header::ACCEPT_ENCODING, value.parse().unwrap());
|
||||||
|
h
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn panel_chooses_zstd_brotli_gzip_by_quality_and_support() {
|
||||||
|
let all = PanelCompression::default();
|
||||||
|
let plain = panel_response(&headers("identity"), &all);
|
||||||
|
assert!(!plain.headers().contains_key(header::CONTENT_ENCODING));
|
||||||
|
let source = plain.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
assert!(std::str::from_utf8(&source).unwrap().contains("id=\"app-startup\""));
|
||||||
|
assert!(std::str::from_utf8(&source).unwrap().contains(env!("CARGO_PKG_VERSION")));
|
||||||
|
for (accept, selected) in [
|
||||||
|
("gzip, br, zstd", "zstd"),
|
||||||
|
("gzip, br", "br"),
|
||||||
|
("gzip", "gzip"),
|
||||||
|
("zstd;q=0.1, br;q=0.9, gzip;q=0.2", "br"),
|
||||||
|
("zstd;q=0, br;q=0, gzip;q=1", "gzip"),
|
||||||
|
("*;q=0.5", "zstd"),
|
||||||
|
] {
|
||||||
|
let response = panel_response(&headers(accept), &all);
|
||||||
|
assert_eq!(response.headers()[header::CONTENT_ENCODING], selected, "{accept}");
|
||||||
|
assert_eq!(response.headers()[header::VARY], "Accept-Encoding");
|
||||||
|
let size: usize = response.headers()[header::CONTENT_LENGTH].to_str().unwrap().parse().unwrap();
|
||||||
|
let encoded = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
assert_eq!(size, encoded.len());
|
||||||
|
let decoded = match selected {
|
||||||
|
"gzip" => { let mut bytes = Vec::new(); GzDecoder::new(encoded.as_ref()).read_to_end(&mut bytes).unwrap(); bytes },
|
||||||
|
"br" => { let mut bytes = Vec::new(); brotli::Decompressor::new(encoded.as_ref(), 4096).read_to_end(&mut bytes).unwrap(); bytes },
|
||||||
|
_ => zstd::stream::decode_all(encoded.as_ref()).unwrap(),
|
||||||
|
};
|
||||||
|
assert_eq!(decoded.as_slice(), source.as_ref());
|
||||||
|
}
|
||||||
|
let disabled = PanelCompression { gzip: false, br: false, zstd: false };
|
||||||
|
let response = panel_response(&headers("zstd, br, gzip"), &disabled);
|
||||||
|
assert!(!response.headers().contains_key(header::CONTENT_ENCODING));
|
||||||
|
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), source);
|
||||||
|
let only_gzip = PanelCompression { gzip: true, br: false, zstd: false };
|
||||||
|
assert_eq!(panel_response(&headers("zstd, br, gzip"), &only_gzip).headers()[header::CONTENT_ENCODING], "gzip");
|
||||||
|
let mut no_transform = headers("zstd, br, gzip");
|
||||||
|
no_transform.insert("cache-control", "no-transform".parse().unwrap());
|
||||||
|
assert!(!panel_response(&no_transform, &all).headers().contains_key(header::CONTENT_ENCODING));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn administration_codec_defaults_keep_older_configuration_compatible() {
|
||||||
|
let admin: crate::settings::Administration = serde_json::from_str("{\"session_days\":30}").unwrap();
|
||||||
|
assert_eq!(admin.panel_compression, PanelCompression::default());
|
||||||
|
let saved = serde_json::to_value(&admin).unwrap();
|
||||||
|
assert_eq!(saved["panel_compression"]["zstd"], true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Stored inside the existing per-account administration preferences document.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
struct RefreshPreferences {
|
||||||
|
live_enabled: bool,
|
||||||
|
overview_seconds: u16,
|
||||||
|
traffic_seconds: u16,
|
||||||
|
cache_seconds: u16,
|
||||||
|
system_info_seconds: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RefreshPreferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
live_enabled: true,
|
||||||
|
overview_seconds: 2,
|
||||||
|
traffic_seconds: 5,
|
||||||
|
cache_seconds: 5,
|
||||||
|
system_info_seconds: 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiError> {
|
||||||
|
if ![1, 2, 5, 10].contains(&refresh.overview_seconds)
|
||||||
|
|| ![0, 3, 5, 10, 30].contains(&refresh.traffic_seconds)
|
||||||
|
|| ![0, 3, 5, 10, 30].contains(&refresh.cache_seconds)
|
||||||
|
|| ![0, 15, 30, 60, 300].contains(&refresh.system_info_seconds)
|
||||||
|
{
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"invalid administration refresh interval".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_live_log_columns() -> Vec<String> {
|
||||||
|
LIVE_LOG_COLUMNS.iter().map(|column| (*column).to_owned()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
+26
-194
@@ -1,5 +1,7 @@
|
|||||||
mod documentation;
|
mod documentation;
|
||||||
mod public;
|
mod public;
|
||||||
|
mod panel_content;
|
||||||
|
mod panel_assets;
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{self, Session},
|
auth::{self, Session},
|
||||||
config::Update,
|
config::Update,
|
||||||
@@ -33,6 +35,7 @@ fn admin_app_javascript() -> &'static str {
|
|||||||
static ASSET: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
static ASSET: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||||
[
|
[
|
||||||
include_str!("web/app/core.js"),
|
include_str!("web/app/core.js"),
|
||||||
|
include_str!("web/app/refresh.js"),
|
||||||
include_str!("web/app/preferences.js"),
|
include_str!("web/app/preferences.js"),
|
||||||
include_str!("web/app/config-history.js"),
|
include_str!("web/app/config-history.js"),
|
||||||
include_str!("web/app/runtime.js"),
|
include_str!("web/app/runtime.js"),
|
||||||
@@ -87,24 +90,8 @@ pub fn router(state: Arc<App>) -> Router {
|
|||||||
.route("/logging", get(public::index))
|
.route("/logging", get(public::index))
|
||||||
.route("/logs", get(public::index))
|
.route("/logs", get(public::index))
|
||||||
.route("/settings", get(public::index))
|
.route("/settings", get(public::index))
|
||||||
.route(
|
.route("/assets/login.js", get(panel_assets::serve))
|
||||||
"/assets/login.js",
|
.route("/assets/login-langs.json", get(panel_assets::serve))
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/login.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/login-langs.json",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "application/json; charset=utf-8")],
|
|
||||||
include_str!(concat!(env!("OUT_DIR"), "/login-langs.json")),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route("/docs", get(documentation::index))
|
.route("/docs", get(documentation::index))
|
||||||
.route("/docs/", get(documentation::index))
|
.route("/docs/", get(documentation::index))
|
||||||
.route("/docs/openapi.json", get(documentation::schema))
|
.route("/docs/openapi.json", get(documentation::schema))
|
||||||
@@ -123,183 +110,29 @@ pub fn router(state: Arc<App>) -> Router {
|
|||||||
"/docs/licenses.txt",
|
"/docs/licenses.txt",
|
||||||
get(|| async { documentation::asset("licenses.txt") }),
|
get(|| async { documentation::asset("licenses.txt") }),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/assets/langs.json", get(panel_assets::serve))
|
||||||
"/assets/langs.json",
|
.route("/assets/app.js", get(panel_assets::serve))
|
||||||
get(|| async {
|
.route("/assets/management.js", get(panel_assets::serve))
|
||||||
(
|
.route("/assets/drafts.js", get(panel_assets::serve))
|
||||||
[("content-type", "application/json; charset=utf-8")],
|
.route("/assets/integrations.js", get(panel_assets::serve))
|
||||||
include_str!(concat!(env!("OUT_DIR"), "/langs.json")),
|
.route("/assets/traffic-panel.js", get(panel_assets::serve))
|
||||||
)
|
.route("/assets/cache-panel.js", get(panel_assets::serve))
|
||||||
}),
|
.route("/assets/transform-panel.js", get(panel_assets::serve))
|
||||||
)
|
.route("/assets/file-logs.js", get(panel_assets::serve))
|
||||||
.route(
|
.route("/assets/settings.js", get(panel_assets::serve))
|
||||||
"/assets/app.js",
|
.route("/assets/acme.js", get(panel_assets::serve))
|
||||||
get(|| async {
|
.route("/assets/vhosts.js", get(panel_assets::serve))
|
||||||
(
|
.route("/assets/web-server-profiles.js", get(panel_assets::serve))
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
.route("/assets/web-server.js", get(panel_assets::serve))
|
||||||
admin_app_javascript(),
|
.route("/assets/docker.js", get(panel_assets::serve))
|
||||||
)
|
.route("/assets/streams.js", get(panel_assets::serve))
|
||||||
}),
|
.route("/assets/redirects.js", get(panel_assets::serve))
|
||||||
)
|
.route("/assets/host404.js", get(panel_assets::serve))
|
||||||
.route(
|
.route("/assets/style.css", get(panel_assets::serve))
|
||||||
"/assets/management.js",
|
.route("/favicon.svg", get(panel_assets::serve))
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/management.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/drafts.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/drafts.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/integrations.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/integrations.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/traffic-panel.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/traffic-panel.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/cache-panel.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/cache-panel.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/transform-panel.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/transform-panel.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/file-logs.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/file-logs.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/settings.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/settings.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/acme.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/acme.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/vhosts.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
admin_vhosts_javascript(),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route("/assets/web-server-profiles.js", get(|| async {
|
|
||||||
([ ("content-type", "text/javascript; charset=utf-8") ],
|
|
||||||
include_str!("web/web-server-profiles.js"))
|
|
||||||
}))
|
|
||||||
.route(
|
|
||||||
"/assets/web-server.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/web-server.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/docker.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/docker.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/streams.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/streams.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/redirects.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/redirects.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/host404.js",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/javascript; charset=utf-8")],
|
|
||||||
include_str!("web/host404.js"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/assets/style.css",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "text/css; charset=utf-8")],
|
|
||||||
include_str!("web/style.css"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/favicon.svg",
|
|
||||||
get(|| async {
|
|
||||||
(
|
|
||||||
[("content-type", "image/svg+xml")],
|
|
||||||
include_str!("web/favicon.svg"),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route("/healthz", get(|| async { Json(json!({"status":"ok"})) }))
|
.route("/healthz", get(|| async { Json(json!({"status":"ok"})) }))
|
||||||
.route("/api/status", get(status))
|
.route("/api/status", get(status))
|
||||||
|
.route("/api/system-info", get(system_info))
|
||||||
.route("/api/backends", get(backend_status))
|
.route("/api/backends", get(backend_status))
|
||||||
.route("/api/docker", get(docker_status))
|
.route("/api/docker", get(docker_status))
|
||||||
.route("/api/docker/refresh", post(docker_refresh))
|
.route("/api/docker/refresh", post(docker_refresh))
|
||||||
@@ -481,7 +314,6 @@ enum CertificateApiOperation {
|
|||||||
ReloadAll,
|
ReloadAll,
|
||||||
ReloadOne(String),
|
ReloadOne(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn certificate_api_operation(method: &Method, path: &str) -> Option<CertificateApiOperation> {
|
fn certificate_api_operation(method: &Method, path: &str) -> Option<CertificateApiOperation> {
|
||||||
if method != Method::POST {
|
if method != Method::POST {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
+8
-64
@@ -1,32 +1,4 @@
|
|||||||
// Reuse sysinfo's native OS APIs on Linux, BSD, macOS and Windows. The
|
include!("status_system.rs");
|
||||||
// diagnostics endpoint must never spawn system commands or parse platform files.
|
|
||||||
// Host identification is cached because it changes only after a reboot.
|
|
||||||
fn valid_host_text(value: String) -> Option<String> {
|
|
||||||
let value = value.trim();
|
|
||||||
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
|
|
||||||
.then(|| value.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn os_release() -> Option<&'static str> {
|
|
||||||
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
|
||||||
VALUE.get_or_init(|| {
|
|
||||||
sysinfo::System::long_os_version()
|
|
||||||
.or_else(|| {
|
|
||||||
let name = sysinfo::System::name()?;
|
|
||||||
Some(match sysinfo::System::os_version() {
|
|
||||||
Some(version) if !version.is_empty() => format!("{name} {version}"),
|
|
||||||
_ => name,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.and_then(valid_host_text)
|
|
||||||
}).as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kernel_release() -> Option<&'static str> {
|
|
||||||
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
|
||||||
VALUE.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
|
|
||||||
.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_payload(state: &App) -> Value {
|
fn status_payload(state: &App) -> Value {
|
||||||
let snapshot = state.config.load_full();
|
let snapshot = state.config.load_full();
|
||||||
@@ -187,38 +159,10 @@ fn status_payload(state: &App) -> Value {
|
|||||||
payload["cache"] = json!(&cache_view);
|
payload["cache"] = json!(&cache_view);
|
||||||
payload["transform_resources"] = json!(&transform_resources);
|
payload["transform_resources"] = json!(&transform_resources);
|
||||||
payload["geoip"] = json!(state.geoip.load().view());
|
payload["geoip"] = json!(state.geoip.load().view());
|
||||||
let system = state.diagnostics.view();
|
let system_info = system_info_payload(state);
|
||||||
let cache_ram_bytes =
|
for field in ["system", "runtime", "memory_accounting"] {
|
||||||
(cache_view.hot_allocated_bytes as u64).saturating_add(cache_view.transient_bytes);
|
payload[field] = system_info[field].clone();
|
||||||
let non_cache_rss_bytes = system
|
}
|
||||||
.process_rss_bytes
|
|
||||||
.map(|rss| rss.saturating_sub(cache_ram_bytes));
|
|
||||||
payload["memory_accounting"] = json!({
|
|
||||||
"process_rss_bytes": system.process_rss_bytes,
|
|
||||||
"cache_hot_bytes": cache_view.hot_allocated_bytes,
|
|
||||||
"cache_hot_limit_bytes": cache_view.hot_limit_bytes,
|
|
||||||
"cache_transient_bytes": cache_view.transient_bytes,
|
|
||||||
"cache_transient_limit_bytes": cache_view.transient_limit_bytes,
|
|
||||||
"cache_total_bytes": cache_ram_bytes,
|
|
||||||
"transform_reserved_bytes": transform_resources.memory_reserved_bytes,
|
|
||||||
"transform_budget_bytes": transform_resources.memory_budget_bytes,
|
|
||||||
"transform_inflight": transform_resources.inflight,
|
|
||||||
"transform_max_inflight": transform_resources.max_inflight,
|
|
||||||
"non_cache_rss_bytes": non_cache_rss_bytes,
|
|
||||||
"non_cache_rss_includes_transform": true,
|
|
||||||
"method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate"
|
|
||||||
});
|
|
||||||
payload["runtime"] = json!({
|
|
||||||
"platform": system.platform,
|
|
||||||
"operating_system": os_release(),
|
|
||||||
"kernel_version": kernel_release(),
|
|
||||||
"architecture": std::env::consts::ARCH,
|
|
||||||
"statistics_source": system.source,
|
|
||||||
"data_directory": state.policy.data_dir.as_ref().map(|path| path.display().to_string()),
|
|
||||||
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string()),
|
|
||||||
"certificate_runtime": state.acme.runtime_view()
|
|
||||||
});
|
|
||||||
payload["system"] = json!(system);
|
|
||||||
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
|
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
|
||||||
payload["vhost_metrics"] = json!(snapshot
|
payload["vhost_metrics"] = json!(snapshot
|
||||||
.vhost_metrics
|
.vhost_metrics
|
||||||
@@ -281,9 +225,9 @@ fn status_payload(state: &App) -> Value {
|
|||||||
});
|
});
|
||||||
payload
|
payload
|
||||||
}
|
}
|
||||||
async fn status(State(state): State<Arc<App>>) -> Json<Value> {
|
// SSE and emergency polling use only volatile telemetry. Never serialize the
|
||||||
Json(status_payload(&state))
|
// whole configuration, OS diagnostics or certificate metadata on each tick.
|
||||||
}
|
include!("status_live.rs");
|
||||||
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
|
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||||
let snapshot = state.config.load_full();
|
let snapshot = state.config.load_full();
|
||||||
Json(
|
Json(
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
fn live_status_payload(state: &App) -> Value {
|
||||||
|
let snapshot = state.config.load_full();
|
||||||
|
let stream_metrics = state.active_streams.iter().filter_map(|stream| {
|
||||||
|
state.stream_metrics.get(&stream.id).map(|metrics| (stream.id.clone(), metrics.view()))
|
||||||
|
}).collect::<std::collections::BTreeMap<_, _>>();
|
||||||
|
json!({
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||||
|
"revision": snapshot.envelope.revision,
|
||||||
|
"listeners": {
|
||||||
|
"active_endpoints": state.active_listeners,
|
||||||
|
"desired_endpoints": snapshot.listeners,
|
||||||
|
"restart_required": snapshot.listeners != state.active_listeners
|
||||||
|
},
|
||||||
|
"metrics": state.metrics.view(),
|
||||||
|
"cache": state.cache.view(),
|
||||||
|
"transform_metrics": state.transform_metrics.total(),
|
||||||
|
"logs_dropped": state.logs.dropped.load(Ordering::Relaxed),
|
||||||
|
"backends": snapshot.envelope.config.vhosts.iter().map(|v| v.backends.len()).sum::<usize>(),
|
||||||
|
"backend_pools": snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>(),
|
||||||
|
"vhost_metrics": snapshot.vhost_metrics.iter().map(|(id, metrics)| (id.clone(), metrics.view())).collect::<std::collections::BTreeMap<_, _>>(),
|
||||||
|
"streams": {"metrics": stream_metrics},
|
||||||
|
"logging": {"sinks": state.logs.stats(), "error_file": crate::logging::error_log_stats()}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
struct StatusQuery {
|
||||||
|
view: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status(State(state): State<Arc<App>>, Query(query): Query<StatusQuery>) -> Result<Json<Value>, ApiError> {
|
||||||
|
match query.view.as_deref() {
|
||||||
|
None => Ok(Json(status_payload(&state))),
|
||||||
|
Some("live") => Ok(Json(live_status_payload(&state))),
|
||||||
|
_ => Err(ApiError(StatusCode::BAD_REQUEST, "invalid status view".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn system_info(State(state): State<Arc<App>>) -> Json<Value> {
|
||||||
|
Json(system_info_payload(&state))
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Reuse sysinfo's native OS APIs on Linux, BSD, macOS and Windows. The
|
||||||
|
// diagnostics endpoint must never spawn system commands or parse platform files.
|
||||||
|
// Host identification is cached because it changes only after a reboot.
|
||||||
|
fn valid_host_text(value: String) -> Option<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
|
||||||
|
.then(|| value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn os_release() -> Option<&'static str> {
|
||||||
|
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||||
|
VALUE.get_or_init(|| {
|
||||||
|
sysinfo::System::long_os_version()
|
||||||
|
.or_else(|| {
|
||||||
|
let name = sysinfo::System::name()?;
|
||||||
|
Some(match sysinfo::System::os_version() {
|
||||||
|
Some(version) if !version.is_empty() => format!("{name} {version}"),
|
||||||
|
_ => name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.and_then(valid_host_text)
|
||||||
|
}).as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kernel_release() -> Option<&'static str> {
|
||||||
|
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||||
|
VALUE.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
|
||||||
|
.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached diagnostics and ACME metadata are requested separately from high-rate SSE.
|
||||||
|
// Preserve the legacy full /api/status shape for existing API clients.
|
||||||
|
fn system_info_payload(state: &App) -> Value {
|
||||||
|
let cache_view = state.cache.view();
|
||||||
|
let transform_resources = crate::transform::resource_usage(&state.config.load().envelope.config.transform);
|
||||||
|
let mut payload = json!({});
|
||||||
|
let system = state.diagnostics.view();
|
||||||
|
let cache_ram_bytes =
|
||||||
|
(cache_view.hot_allocated_bytes as u64).saturating_add(cache_view.transient_bytes);
|
||||||
|
let non_cache_rss_bytes = system
|
||||||
|
.process_rss_bytes
|
||||||
|
.map(|rss| rss.saturating_sub(cache_ram_bytes));
|
||||||
|
payload["memory_accounting"] = json!({
|
||||||
|
"process_rss_bytes": system.process_rss_bytes,
|
||||||
|
"cache_hot_bytes": cache_view.hot_allocated_bytes,
|
||||||
|
"cache_hot_limit_bytes": cache_view.hot_limit_bytes,
|
||||||
|
"cache_transient_bytes": cache_view.transient_bytes,
|
||||||
|
"cache_transient_limit_bytes": cache_view.transient_limit_bytes,
|
||||||
|
"cache_total_bytes": cache_ram_bytes,
|
||||||
|
"transform_reserved_bytes": transform_resources.memory_reserved_bytes,
|
||||||
|
"transform_budget_bytes": transform_resources.memory_budget_bytes,
|
||||||
|
"transform_inflight": transform_resources.inflight,
|
||||||
|
"transform_max_inflight": transform_resources.max_inflight,
|
||||||
|
"non_cache_rss_bytes": non_cache_rss_bytes,
|
||||||
|
"non_cache_rss_includes_transform": true,
|
||||||
|
"method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate"
|
||||||
|
});
|
||||||
|
payload["runtime"] = json!({
|
||||||
|
"platform": system.platform,
|
||||||
|
"operating_system": os_release(),
|
||||||
|
"kernel_version": kernel_release(),
|
||||||
|
"architecture": std::env::consts::ARCH,
|
||||||
|
"statistics_source": system.source,
|
||||||
|
"data_directory": state.policy.data_dir.as_ref().map(|path| path.display().to_string()),
|
||||||
|
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string()),
|
||||||
|
"certificate_runtime": state.acme.runtime_view()
|
||||||
|
});
|
||||||
|
payload["system"] = json!(system);
|
||||||
|
payload
|
||||||
|
}
|
||||||
@@ -7,4 +7,6 @@ mod tests {
|
|||||||
include!("tests/part_06.rs");
|
include!("tests/part_06.rs");
|
||||||
include!("tests/part_07.rs");
|
include!("tests/part_07.rs");
|
||||||
include!("tests/part_08.rs");
|
include!("tests/part_08.rs");
|
||||||
|
include!("tests/part_09.rs");
|
||||||
|
include!("tests/part_10.rs");
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let initial = json_body(response).await;
|
let initial = json_body(response).await;
|
||||||
assert_eq!(initial["live_log_columns"].as_array().unwrap().len(), LIVE_LOG_COLUMNS.len());
|
assert_eq!(initial["live_log_columns"].as_array().unwrap().len(), LIVE_LOG_COLUMNS.len());
|
||||||
|
assert_eq!(initial["refresh"], json!({
|
||||||
|
"live_enabled":true, "overview_seconds":2, "traffic_seconds":5,
|
||||||
|
"cache_seconds":5, "system_info_seconds":60
|
||||||
|
}));
|
||||||
assert_eq!(initial["theme"], "dark");
|
assert_eq!(initial["theme"], "dark");
|
||||||
assert_eq!(initial["language"], "auto");
|
assert_eq!(initial["language"], "auto");
|
||||||
assert_eq!(initial["ui_scale"], 100);
|
assert_eq!(initial["ui_scale"], 100);
|
||||||
@@ -50,6 +54,7 @@
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let legacy = json_body(response).await;
|
let legacy = json_body(response).await;
|
||||||
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
|
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
|
||||||
|
assert_eq!(legacy["refresh"], initial["refresh"]);
|
||||||
assert_eq!(legacy["theme"], "dark");
|
assert_eq!(legacy["theme"], "dark");
|
||||||
assert_eq!(legacy["language"], "auto");
|
assert_eq!(legacy["language"], "auto");
|
||||||
assert_eq!(legacy["ui_scale"], 100);
|
assert_eq!(legacy["ui_scale"], 100);
|
||||||
@@ -65,6 +70,7 @@
|
|||||||
assert_eq!(legacy["backend_table_descending"], false);
|
assert_eq!(legacy["backend_table_descending"], false);
|
||||||
|
|
||||||
let selected = json!({
|
let selected = json!({
|
||||||
|
"refresh":{"live_enabled":false,"overview_seconds":10,"traffic_seconds":0,"cache_seconds":3,"system_info_seconds":300},
|
||||||
"live_log_columns":["time","request","status","upstream_error"],
|
"live_log_columns":["time","request","status","upstream_error"],
|
||||||
"theme":"light",
|
"theme":"light",
|
||||||
"language":"pl_pl",
|
"language":"pl_pl",
|
||||||
@@ -103,6 +109,20 @@
|
|||||||
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
|
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(saved, selected);
|
assert_eq!(saved, selected);
|
||||||
|
// Preferences must also survive a subsequent authenticated read.
|
||||||
|
let persisted = router.clone().oneshot(request("/api/preferences")
|
||||||
|
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||||
|
assert_eq!(json_body(persisted).await, selected);
|
||||||
|
// Wrong allowlist value is rejected even if the rest is valid.
|
||||||
|
let mut invalid = selected.clone();
|
||||||
|
invalid["refresh"]["traffic_seconds"] = json!(4);
|
||||||
|
let rejected = router.clone().oneshot(request("/api/preferences")
|
||||||
|
.method("PUT").header("cookie", &cookie)
|
||||||
|
.header("origin", "http://127.0.0.1:9090")
|
||||||
|
.header("x-rustproxy-request", "1").header("x-csrf-token", &csrf)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(invalid.to_string())).unwrap()).await.unwrap();
|
||||||
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
let response = router
|
let response = router
|
||||||
.clone()
|
.clone()
|
||||||
@@ -327,6 +347,7 @@
|
|||||||
let (router, store, _dir, _, _) = fixture().await;
|
let (router, store, _dir, _, _) = fixture().await;
|
||||||
for path in [
|
for path in [
|
||||||
"/api/status",
|
"/api/status",
|
||||||
|
"/api/system-info",
|
||||||
"/api/config",
|
"/api/config",
|
||||||
"/api/config/history",
|
"/api/config/history",
|
||||||
"/api/backends",
|
"/api/backends",
|
||||||
@@ -446,3 +467,7 @@
|
|||||||
}
|
}
|
||||||
store.close().await;
|
store.close().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#[tokio::test]
|
||||||
|
async fn system_info_and_compact_live_status_reuse_existing_admin_session() {
|
||||||
|
let (router, store, _dir, secret, _) = fixture().await;
|
||||||
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||||
|
let info = router.clone().oneshot(request("/api/system-info")
|
||||||
|
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||||
|
assert_eq!(info.status(), StatusCode::OK);
|
||||||
|
let info = json_body(info).await;
|
||||||
|
assert!(info.get("system").is_some());
|
||||||
|
assert!(info.get("runtime").is_some());
|
||||||
|
assert!(info.get("memory_accounting").is_some());
|
||||||
|
assert!(info.get("config").is_none());
|
||||||
|
let live = router.clone().oneshot(request("/api/status?view=live")
|
||||||
|
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||||
|
assert_eq!(live.status(), StatusCode::OK);
|
||||||
|
let live = json_body(live).await;
|
||||||
|
assert!(live.get("metrics").is_some());
|
||||||
|
assert!(live.get("backend_pools").is_some());
|
||||||
|
assert!(live["listeners"].get("active_endpoints").is_some());
|
||||||
|
assert!(live.get("system").is_none());
|
||||||
|
assert!(live.get("memory_accounting").is_none());
|
||||||
|
assert!(live.get("administration").is_none());
|
||||||
|
let complete = router.clone().oneshot(request("/api/status")
|
||||||
|
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||||
|
assert_eq!(complete.status(), StatusCode::OK);
|
||||||
|
assert!(json_body(complete).await.get("system").is_some());
|
||||||
|
let invalid = router.clone().oneshot(request("/api/status?view=unknown")
|
||||||
|
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||||
|
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let bad_sse = router.oneshot(request("/api/events?interval=4")
|
||||||
|
.header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap();
|
||||||
|
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
store.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_and_partial_legacy_refresh_documents() {
|
||||||
|
let previous = serde_json::to_value(AdminPreferences::default()).unwrap();
|
||||||
|
let mut legacy = previous.as_object().unwrap().clone();
|
||||||
|
legacy.remove("refresh");
|
||||||
|
let loaded: AdminPreferences = serde_json::from_value(Value::Object(legacy)).unwrap();
|
||||||
|
assert_eq!(loaded.refresh, RefreshPreferences::default());
|
||||||
|
let partial: RefreshPreferences = serde_json::from_str(r#"{"traffic_seconds":0}"#).unwrap();
|
||||||
|
assert_eq!(partial.traffic_seconds, 0);
|
||||||
|
assert_eq!(partial.system_info_seconds, 60);
|
||||||
|
assert!(validate_admin_preferences(&loaded).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_values_are_allowlisted() {
|
||||||
|
let mut value = RefreshPreferences::default();
|
||||||
|
for seconds in [0, 4, 11, 65000] {
|
||||||
|
value.overview_seconds = seconds;
|
||||||
|
assert!(validate_refresh_preferences(&value).is_err());
|
||||||
|
}
|
||||||
|
value.overview_seconds = 1;
|
||||||
|
for seconds in [1, 2, 4, 31] {
|
||||||
|
value.traffic_seconds = seconds;
|
||||||
|
assert!(validate_refresh_preferences(&value).is_err());
|
||||||
|
}
|
||||||
|
value.traffic_seconds = 0;
|
||||||
|
value.cache_seconds = 0;
|
||||||
|
value.system_info_seconds = 0;
|
||||||
|
assert!(validate_refresh_preferences(&value).is_ok());
|
||||||
|
value.system_info_seconds = 16;
|
||||||
|
assert!(validate_refresh_preferences(&value).is_err());
|
||||||
|
assert!(serde_json::from_str::<RefreshPreferences>(r#"{"unexpected":1}"#).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#[tokio::test]
|
||||||
|
async fn cache_system_events_and_static_private_assets_reject_anonymous_requests() {
|
||||||
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||||
|
for path in [
|
||||||
|
"/api/status", "/api/status?view=live", "/api/system-info", "/api/config",
|
||||||
|
"/api/preferences", "/api/cache", "/api/cache/bans", "/api/audit",
|
||||||
|
"/api/logs", "/api/traffic", "/api/events", "/api/live-logs",
|
||||||
|
"/api/file-logs", "/api/certificate-api", "/docs/openapi.json",
|
||||||
|
"/assets/app.js", "/assets/cache-panel.js", "/assets/langs.json",
|
||||||
|
] {
|
||||||
|
let response = router.clone().oneshot(request(path).body(Body::empty()).unwrap())
|
||||||
|
.await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "anonymous GET {path}");
|
||||||
|
assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY");
|
||||||
|
assert!(response.headers().get("content-security-policy").is_some());
|
||||||
|
}
|
||||||
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||||
|
for (method, path) in [
|
||||||
|
("POST", "/api/cache/purge"),
|
||||||
|
("POST", "/api/cache/invalidate"),
|
||||||
|
("DELETE", "/api/cache/bans/1"),
|
||||||
|
("PUT", "/api/config/cache"),
|
||||||
|
("PUT", "/api/preferences"),
|
||||||
|
("POST", "/api/auth/logout-all"),
|
||||||
|
] {
|
||||||
|
let no_session = request(path).method(method).body(Body::empty()).unwrap();
|
||||||
|
let response = router.clone().oneshot(no_session).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "anonymous {method} {path}");
|
||||||
|
let without_csrf = request(path).method(method)
|
||||||
|
.header("cookie", &cookie)
|
||||||
|
.header("origin", "http://127.0.0.1:9090")
|
||||||
|
.header("x-rustproxy-request", "1")
|
||||||
|
.body(Body::empty()).unwrap();
|
||||||
|
let response = router.clone().oneshot(without_csrf).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN, "CSRF {method} {path}");
|
||||||
|
}
|
||||||
|
// The valid session and CSRF continue to work for a benign mutation,
|
||||||
|
// proving the failed requests above did not revoke the cookie.
|
||||||
|
let response = router.oneshot(request("/api/auth/logout")
|
||||||
|
.method("POST")
|
||||||
|
.header("cookie", &cookie)
|
||||||
|
.header("origin", "http://127.0.0.1:9090")
|
||||||
|
.header("x-rustproxy-request", "1")
|
||||||
|
.header("x-csrf-token", &csrf)
|
||||||
|
.body(Body::empty()).unwrap()).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
store.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_origin_host_and_fetch_metadata_are_checked_even_with_valid_cookie() {
|
||||||
|
let (router, store, _dir, secret, _csrf) = fixture().await;
|
||||||
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||||
|
let evil_origin = request("/api/status").header("cookie", &cookie)
|
||||||
|
.header("origin", "https://another.example")
|
||||||
|
.body(Body::empty()).unwrap();
|
||||||
|
let response = router.clone().oneshot(evil_origin).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let mut repeated_host = request("/api/status").header("cookie", &cookie)
|
||||||
|
.body(Body::empty()).unwrap();
|
||||||
|
repeated_host.headers_mut().append("host", "other.example".parse().unwrap());
|
||||||
|
let response = router.clone().oneshot(repeated_host).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let cross_site = request("/api/status").header("cookie", &cookie)
|
||||||
|
.header("sec-fetch-site", "cross-site").body(Body::empty()).unwrap();
|
||||||
|
let response = router.clone().oneshot(cross_site).await.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
store.close().await;
|
||||||
|
}
|
||||||
+14
-6
@@ -155,15 +155,21 @@
|
|||||||
if (!list.children.length) list.append(el("p", t("renewalNone"), "empty"));
|
if (!list.children.length) list.append(el("p", t("renewalNone"), "empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function loadAcme({ signal, isCurrent }) {
|
||||||
if (!authenticated || currentPanel !== "tls" || loading) return; loading = true; const epoch = authEpoch;
|
if (!authenticated || currentPanel !== "tls" || loading) return;
|
||||||
|
loading = true;
|
||||||
|
const epoch = authEpoch;
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/acme");
|
const data = await api("/api/acme", { signal });
|
||||||
if (authenticated && epoch === authEpoch) { renderRuntime(data.runtime); render(data.job); renderRenewals(data.renewals, data.log_directories); }
|
if (isCurrent() && epoch === authEpoch) { renderRuntime(data.runtime); render(data.job); renderRenewals(data.renewals, data.log_directories); }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (epoch === authEpoch) { toast(error.message, true); if (error.status === 401) lock(); }
|
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||||
|
toast(error.message, true);
|
||||||
|
if (error.status === 401) lock();
|
||||||
|
}
|
||||||
} finally { loading = false; }
|
} finally { loading = false; }
|
||||||
}
|
}
|
||||||
|
function refresh() { return rustProxyRefresh.manual("acme"); }
|
||||||
$("acme-environment").addEventListener("change", () => { $("acme-confirm-production").required = $("acme-environment").value === "production"; });
|
$("acme-environment").addEventListener("change", () => { $("acme-confirm-production").required = $("acme-environment").value === "production"; });
|
||||||
$("acme-form").addEventListener("submit", async event => {
|
$("acme-form").addEventListener("submit", async event => {
|
||||||
event.preventDefault(); if (!authenticated || pending || job?.status === "running" || !runtimeAvailable) return;
|
event.preventDefault(); if (!authenticated || pending || job?.status === "running" || !runtimeAvailable) return;
|
||||||
@@ -197,7 +203,9 @@
|
|||||||
else next.tls.certificates.push(cert);
|
else next.tls.certificates.push(cert);
|
||||||
if (await saveSection("tls", next.tls || {})) { toast(t("acmeImported")); $("acme-import").hidden = true; selectTab("installed"); }
|
if (await saveSection("tls", next.tls || {})) { toast(t("acmeImported")); $("acme-import").hidden = true; selectTab("installed"); }
|
||||||
});
|
});
|
||||||
setInterval(() => { if (!document.hidden) void refresh(); }, 3000);
|
rustProxyRefresh.register("acme", {
|
||||||
|
interval: () => 3, active: () => currentPanel === "tls", run: loadAcme
|
||||||
|
});
|
||||||
window.rustProxyAcme = {
|
window.rustProxyAcme = {
|
||||||
refresh,
|
refresh,
|
||||||
select: selectTab,
|
select: selectTab,
|
||||||
|
|||||||
+107
-29
@@ -23,8 +23,8 @@ function clearInvalidOriginRecovery() {
|
|||||||
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", filelogs: "/logs", settings: "/settings" };
|
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", filelogs: "/logs", settings: "/settings" };
|
||||||
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
|
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
|
||||||
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
||||||
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
|
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null;
|
||||||
let liveEvents = null, liveConnected = false, authEpoch = 0, pendingAuthDestination = null;
|
let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null;
|
||||||
let lastApiLatencyMs = null;
|
let lastApiLatencyMs = null;
|
||||||
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
|
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
|
||||||
// Language content is discovered by build.rs from langs/*.json, never compiled
|
// Language content is discovered by build.rs from langs/*.json, never compiled
|
||||||
@@ -152,14 +152,16 @@ function setPanel(name, navigate = true) {
|
|||||||
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
|
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
|
||||||
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
|
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
|
||||||
currentPanel = name;
|
currentPanel = name;
|
||||||
|
$("overview-refresh").hidden = name !== "overview";
|
||||||
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
|
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
|
||||||
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
|
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
|
||||||
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
|
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
|
||||||
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
|
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
|
||||||
if (name === "settings") window.rustProxySettings?.syncLocation?.();
|
if (name === "settings") window.rustProxySettings?.syncLocation?.();
|
||||||
|
window.rustProxyRefresh?.setPanel(name);
|
||||||
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
|
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
|
||||||
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
|
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
|
||||||
if (name === "traffic") void window.rustProxyTrafficPanel?.refresh(); if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
|
if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); });
|
||||||
}
|
}
|
||||||
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
|
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
|
||||||
function uptime(value) {
|
function uptime(value) {
|
||||||
@@ -192,8 +194,14 @@ function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
|
|||||||
}
|
}
|
||||||
window.rustProxyEnableControl = enhanceEnabledControl;
|
window.rustProxyEnableControl = enhanceEnabledControl;
|
||||||
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
|
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
|
||||||
function renderStatus(s) {
|
function renderStatus(s, systemSample = false) {
|
||||||
const firstStatus = latestStatus === null; latestStatus = s; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
const firstStatus = latestStatus === null;
|
||||||
|
latestStatus = { ...(latestStatus || {}), ...s,
|
||||||
|
streams: s.streams ? { ...(latestStatus?.streams || {}), ...s.streams } : latestStatus?.streams,
|
||||||
|
listeners: s.listeners ? { ...(latestStatus?.listeners || {}), ...s.listeners } : latestStatus?.listeners,
|
||||||
|
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
|
||||||
|
};
|
||||||
|
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
|
||||||
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
|
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
|
||||||
$("metric-ws").textContent = m.websocket_active || 0;
|
$("metric-ws").textContent = m.websocket_active || 0;
|
||||||
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
|
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
|
||||||
@@ -250,7 +258,7 @@ function renderStatus(s) {
|
|||||||
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
|
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
|
||||||
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
|
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
|
||||||
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
|
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
|
||||||
renderFooter(s); renderDiagnostics(s); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
|
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
|
||||||
}
|
}
|
||||||
function renderFooter(s) {
|
function renderFooter(s) {
|
||||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||||
@@ -273,9 +281,40 @@ function renderFooter(s) {
|
|||||||
}
|
}
|
||||||
let lastLogs = null;
|
let lastLogs = null;
|
||||||
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
|
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
|
||||||
async function refresh(force = false) { if (!authenticated || polling || (!force && liveConnected)) return; polling = true; const epoch = authEpoch; try { const [status, logs] = await Promise.all([api("/api/status"), api("/api/logs")]); if (!authenticated || epoch !== authEpoch) return; renderStatus(status); renderLogs(logs); } catch (e) { connected = false; $("connection-label").textContent = t("disconnected"); if (e.status === 401) lock(); } finally { polling = false; } }
|
async function refresh(force = false) {
|
||||||
function stopLive() { if (liveEvents) liveEvents.close(); liveEvents = null; liveConnected = false; }
|
if (!authenticated) return;
|
||||||
function startLive() { stopLive(); if (!authenticated || document.hidden || typeof EventSource === "undefined") return; const epoch = authEpoch; const stream = new EventSource("/api/events"); liveEvents = stream; stream.addEventListener("snapshot", event => { if (!authenticated || epoch !== authEpoch) return; try { const payload = JSON.parse(event.data); liveConnected = true; renderStatus(payload.status); renderLogs(payload.logs); } catch { liveConnected = false; } }); stream.onerror = () => { if (epoch !== authEpoch) return; liveConnected = false; void refresh(); }; }
|
if (force) return Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback")]);
|
||||||
|
return rustProxyRefresh.manual("status-fallback");
|
||||||
|
}
|
||||||
|
function renderLiveStatus(value) { renderStatus(value); }
|
||||||
|
function stopLive() {
|
||||||
|
if (liveEvents) { liveEvents.close(); liveEvents = null; }
|
||||||
|
liveConnected = false; liveFallback = false; liveInterval = null;
|
||||||
|
}
|
||||||
|
function startLive() {
|
||||||
|
if (!authenticated || document.hidden || typeof EventSource === "undefined") return;
|
||||||
|
const seconds = window.rustProxyRefresh?.current().overview_seconds || 2;
|
||||||
|
if (liveEvents && liveInterval === seconds) return;
|
||||||
|
stopLive();
|
||||||
|
liveInterval = seconds;
|
||||||
|
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}`);
|
||||||
|
liveEvents = stream;
|
||||||
|
stream.addEventListener("snapshot", event => {
|
||||||
|
if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
if (!payload.status || !payload.logs) throw new Error("Invalid SSE payload");
|
||||||
|
liveConnected = true; liveFallback = false;
|
||||||
|
renderLiveStatus(payload.status); renderLogs(payload.logs);
|
||||||
|
window.rustProxyRefresh?.sync();
|
||||||
|
} catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); }
|
||||||
|
});
|
||||||
|
stream.onerror = () => {
|
||||||
|
if (epoch !== authEpoch || stream !== liveEvents) return;
|
||||||
|
liveConnected = false; liveFallback = true;
|
||||||
|
window.rustProxyRefresh?.sync();
|
||||||
|
};
|
||||||
|
}
|
||||||
async function refreshCertificateInventory() {
|
async function refreshCertificateInventory() {
|
||||||
if (!authenticated) return;
|
if (!authenticated) return;
|
||||||
const epoch = authEpoch;
|
const epoch = authEpoch;
|
||||||
@@ -322,6 +361,7 @@ function authReturnDestination() {
|
|||||||
return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings";
|
return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings";
|
||||||
}
|
}
|
||||||
function lock(notify = true) {
|
function lock(notify = true) {
|
||||||
|
window.rustProxyRefresh?.stop();
|
||||||
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
||||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||||
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
||||||
@@ -334,16 +374,52 @@ function lock(notify = true) {
|
|||||||
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-menu").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); window.rustProxyDocker?.applyPreferences(preferences); window.rustProxyRedirects?.applyPreferences(preferences); window.rustProxyNotFound?.applyPreferences(preferences); window.rustProxyStreams?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
|
async function activate(info) {
|
||||||
|
pendingAuthDestination = null; authMessage("");
|
||||||
|
for (const id of ["api-docs", "logs-menu", "logs-open", "file-logs-open"]) $(id).hidden = false;
|
||||||
|
const epoch = ++authEpoch;
|
||||||
|
sessionInfo = info; csrf = info.csrf_token; authenticated = true;
|
||||||
|
for (const id of ["password", "password-confirm", "setup-token"]) $(id).value = "";
|
||||||
|
$("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false;
|
||||||
|
renderSession();
|
||||||
|
// Paint a usable shell immediately. Preferences, configuration and initial
|
||||||
|
// telemetry are network requests and must not block the first content.
|
||||||
|
document.documentElement.classList.remove("app-loading");
|
||||||
|
document.documentElement.classList.remove("app-startup-failed");
|
||||||
|
rustProxyRefresh.start();
|
||||||
|
const preferences = await loadAdminPreferences();
|
||||||
|
if (!authenticated || epoch !== authEpoch) return;
|
||||||
|
window.rustProxyLogs?.applyPreferences(preferences);
|
||||||
|
window.rustProxyFileLogs?.applyPreferences(preferences);
|
||||||
|
window.rustProxyVhosts?.applyPreferences(preferences);
|
||||||
|
window.rustProxyDocker?.applyPreferences(preferences);
|
||||||
|
window.rustProxyRedirects?.applyPreferences(preferences);
|
||||||
|
window.rustProxyNotFound?.applyPreferences(preferences);
|
||||||
|
window.rustProxyStreams?.applyPreferences(preferences);
|
||||||
|
// The live SSE stream is already running. Configuration can load behind the
|
||||||
|
// visible shell; it does not belong in the live refresh cycle.
|
||||||
|
try { await loadConfig(); }
|
||||||
|
catch (error) {
|
||||||
|
if (authenticated && epoch === authEpoch) toast(error.message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!authenticated || epoch !== authEpoch) return;
|
||||||
|
// A single full snapshot seeds non-live diagnostics; subsequent updates
|
||||||
|
// continue over SSE rather than polling full status on a timer.
|
||||||
|
void refresh(true).catch(error => {
|
||||||
|
if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
|
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
|
||||||
let tunnelRefreshTimer = null;
|
|
||||||
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
|
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
|
||||||
async function loadTunnels() {
|
let tunnelsLoading = false;
|
||||||
if (!authenticated) return;
|
async function loadTunnels(context = null) {
|
||||||
|
if (!authenticated || tunnelsLoading) return;
|
||||||
|
tunnelsLoading = true;
|
||||||
const epoch = authEpoch;
|
const epoch = authEpoch;
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/tunnels");
|
const data = await api("/api/tunnels", context?.signal ? { signal: context.signal } : {});
|
||||||
if (!authenticated || epoch !== authEpoch) return;
|
if (!authenticated || epoch !== authEpoch || !$("tunnels-dialog")?.open || (context?.isCurrent && !context.isCurrent())) return;
|
||||||
const rows = (data.tunnels || []).map(item => {
|
const rows = (data.tunnels || []).map(item => {
|
||||||
const tr = el("tr");
|
const tr = el("tr");
|
||||||
for (const value of [item.id, item.client, item.host, item.upstream, item.protocol, `${fmt((Number(item.duration_ms) || 0) / 1000, 1)} s`, fmtBytes(item.rx_bytes), fmtBytes(item.tx_bytes)]) {
|
for (const value of [item.id, item.client, item.host, item.upstream, item.protocol, `${fmt((Number(item.duration_ms) || 0) / 1000, 1)} s`, fmtBytes(item.rx_bytes), fmtBytes(item.tx_bytes)]) {
|
||||||
@@ -365,10 +441,11 @@ async function loadTunnels() {
|
|||||||
actions.append(kill); tr.append(actions); return tr;
|
actions.append(kill); tr.append(actions); return tr;
|
||||||
});
|
});
|
||||||
$("tunnels-rows").replaceChildren(...rows); $("tunnels-empty").hidden = rows.length > 0;
|
$("tunnels-rows").replaceChildren(...rows); $("tunnels-empty").hidden = rows.length > 0;
|
||||||
} catch (error) { if (authenticated && epoch === authEpoch) tunnelMessage(error.message, true); }
|
} catch (error) { if (error?.name !== "AbortError" && authenticated && epoch === authEpoch && $("tunnels-dialog")?.open) tunnelMessage(error.message, true); }
|
||||||
|
finally { tunnelsLoading = false; }
|
||||||
}
|
}
|
||||||
function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefreshTimer); tunnelRefreshTimer = null; }
|
function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||||
function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); }
|
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
|
||||||
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
||||||
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
||||||
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||||
@@ -390,9 +467,9 @@ $("revoke-sessions").addEventListener("click", async () => { if (!(await appConf
|
|||||||
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
|
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
|
||||||
$("audit-dialog-close").addEventListener("click", () => $("audit-dialog").close());
|
$("audit-dialog-close").addEventListener("click", () => $("audit-dialog").close());
|
||||||
$("reload-audit").addEventListener("click", () => void loadAudit());
|
$("reload-audit").addEventListener("click", () => void loadAudit());
|
||||||
$("tunnels-open").addEventListener("click", () => { const dialog = $("tunnels-dialog"); tunnelMessage(); if (!dialog.open) dialog.showModal(); void loadTunnels(); startTunnelRefresh(); });
|
$("tunnels-open").addEventListener("click", () => { const dialog = $("tunnels-dialog"); tunnelMessage(); if (!dialog.open) dialog.showModal(); void rustProxyRefresh.manual("tunnels"); startTunnelRefresh(); });
|
||||||
$("tunnels-close").addEventListener("click", () => { stopTunnelRefresh(); $("tunnels-dialog").close(); });
|
$("tunnels-close").addEventListener("click", () => { $("tunnels-dialog").close(); stopTunnelRefresh(); });
|
||||||
$("tunnels-reload").addEventListener("click", () => void loadTunnels());
|
$("tunnels-reload").addEventListener("click", () => void rustProxyRefresh.manual("tunnels"));
|
||||||
$("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
$("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
||||||
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
||||||
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
||||||
@@ -404,18 +481,19 @@ async function revalidateSession() {
|
|||||||
clearInvalidOriginRecovery();
|
clearInvalidOriginRecovery();
|
||||||
if (epoch !== authEpoch) return;
|
if (epoch !== authEpoch) return;
|
||||||
if (!authenticated) { await activate(info); return; }
|
if (!authenticated) { await activate(info); return; }
|
||||||
sessionInfo = info; csrf = info.csrf_token; renderSession(); await refresh(true); startLive();
|
sessionInfo = info; csrf = info.csrf_token; renderSession(); window.rustProxyRefresh?.sync();
|
||||||
} catch (error) { if (recoverInvalidOrigin(error)) return; if (error.status !== 401) toast(error.message, true); }
|
} catch (error) { if (recoverInvalidOrigin(error)) return; if (error.status !== 401) toast(error.message, true); }
|
||||||
}
|
}
|
||||||
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
|
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
|
||||||
document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } });
|
|
||||||
setInterval(() => { if (!document.hidden) void refresh(); }, 5000);
|
|
||||||
function showStartupFailure(error) {
|
function showStartupFailure(error) {
|
||||||
// index.html is served only after the server has accepted the browser session.
|
// Never expose a blank page or an incomplete workspace after startup fails.
|
||||||
// A module/configuration error must therefore never masquerade as a logout.
|
// The authenticated shell keeps its own retry link; 401 still redirects to login.
|
||||||
$("login").hidden = true;
|
$("login").hidden = true;
|
||||||
$("workspace").hidden = false;
|
$("workspace").hidden = true;
|
||||||
$("logout").hidden = !authenticated;
|
$("logout").hidden = !authenticated;
|
||||||
document.documentElement.classList.remove("app-loading");
|
document.documentElement.classList.add("app-loading", "app-startup-failed");
|
||||||
toast(error?.message || String(error), true);
|
$("app-startup-title").textContent = languagePacks.size ? t("startupFailed") : "Could not load RustProxy";
|
||||||
|
const detail = $("app-startup-detail");
|
||||||
|
detail.textContent = error?.message || String(error);
|
||||||
|
detail.hidden = false;
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@ const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
|
|||||||
button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [],
|
button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [],
|
||||||
});
|
});
|
||||||
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
|
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
|
||||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve();
|
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
||||||
|
|
||||||
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
||||||
function normalizeAdminLanguage(value) { return value === "auto" || /^[A-Za-z0-9_-]{1,64}$/.test(value || "") ? value : "auto"; }
|
function normalizeAdminLanguage(value) { return value === "auto" || /^[A-Za-z0-9_-]{1,64}$/.test(value || "") ? value : "auto"; }
|
||||||
@@ -122,7 +122,7 @@ function refreshChartStylePalette(active = adminPreferences?.chart_style || "def
|
|||||||
});
|
});
|
||||||
target.replaceChildren(...buttons);
|
target.replaceChildren(...buttons);
|
||||||
}
|
}
|
||||||
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); window.rustProxySettings?.refreshSearch?.(); }
|
function refreshAppearanceUi() { refreshChartStylePalette(); renderDashboardWidgetSettings(); renderRefreshPreferences(adminPreferences?.refresh); window.rustProxySettings?.refreshSearch?.(); }
|
||||||
function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
function formatDateTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
||||||
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
||||||
function applyAdminPreferences(value, cache = true) {
|
function applyAdminPreferences(value, cache = true) {
|
||||||
@@ -130,11 +130,14 @@ function applyAdminPreferences(value, cache = true) {
|
|||||||
const dashboard = applyDashboardLayout(value.dashboard_widgets, value.dashboard_hidden_widgets);
|
const dashboard = applyDashboardLayout(value.dashboard_widgets, value.dashboard_hidden_widgets);
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...value,
|
...value,
|
||||||
|
refresh: normalizeRefreshPreferences(value.refresh),
|
||||||
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
|
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
|
||||||
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), table_density: applyAdminTableDensity(value.table_density),
|
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), button_style: applyAdminButtonStyle(value.button_style), table_density: applyAdminTableDensity(value.table_density),
|
||||||
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: dashboard.order, dashboard_hidden_widgets: dashboard.hidden,
|
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: dashboard.order, dashboard_hidden_widgets: dashboard.hidden,
|
||||||
};
|
};
|
||||||
adminPreferences = normalized;
|
adminPreferences = normalized;
|
||||||
|
window.rustProxyRefresh?.update(normalized.refresh);
|
||||||
|
renderRefreshPreferences(normalized.refresh);
|
||||||
window.rustProxyVhosts?.applyPreferences(normalized);
|
window.rustProxyVhosts?.applyPreferences(normalized);
|
||||||
window.rustProxyDocker?.applyPreferences(normalized);
|
window.rustProxyDocker?.applyPreferences(normalized);
|
||||||
window.rustProxyRedirects?.applyPreferences(normalized);
|
window.rustProxyRedirects?.applyPreferences(normalized);
|
||||||
@@ -152,9 +155,18 @@ async function loadAdminPreferences(force = false) {
|
|||||||
if (adminPreferencesLoading) return adminPreferencesLoading;
|
if (adminPreferencesLoading) return adminPreferencesLoading;
|
||||||
const epoch = authEpoch;
|
const epoch = authEpoch;
|
||||||
adminPreferencesLoading = (async () => {
|
adminPreferencesLoading = (async () => {
|
||||||
const value = await api("/api/preferences");
|
try {
|
||||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
const value = await api("/api/preferences");
|
||||||
return applyAdminPreferences(value);
|
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||||
|
adminPreferencesUnavailable = false;
|
||||||
|
return applyAdminPreferences(value);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 401 || !authenticated || epoch !== authEpoch) throw error;
|
||||||
|
// Preferences are optional for startup. Keep UI and live telemetry usable.
|
||||||
|
console.warn("Preferences unavailable; using defaults", error);
|
||||||
|
adminPreferencesUnavailable = true;
|
||||||
|
return applyAdminPreferences({ ...ADMIN_APPEARANCE_DEFAULTS, refresh: REFRESH_DEFAULTS });
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; }
|
try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; }
|
||||||
}
|
}
|
||||||
@@ -162,9 +174,11 @@ function updateAdminPreferences(patch) {
|
|||||||
const epoch = authEpoch;
|
const epoch = authEpoch;
|
||||||
const task = adminPreferencesWrite.then(async () => {
|
const task = adminPreferencesWrite.then(async () => {
|
||||||
if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||||
const current = await loadAdminPreferences();
|
const current = await loadAdminPreferences(adminPreferencesUnavailable);
|
||||||
|
if (adminPreferencesUnavailable) throw new Error("Cannot persist preferences while the preference service is unavailable");
|
||||||
if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||||
const next = { ...current, ...patch };
|
const next = { ...current, ...patch };
|
||||||
|
next.refresh = normalizeRefreshPreferences(next.refresh);
|
||||||
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
|
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
|
||||||
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.table_density = normalizeAdminDensity(next.table_density);
|
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.button_style = normalizeAdminButtonStyle(next.button_style); next.table_density = normalizeAdminDensity(next.table_density);
|
||||||
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets);
|
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets);
|
||||||
@@ -174,7 +188,44 @@ function updateAdminPreferences(patch) {
|
|||||||
});
|
});
|
||||||
adminPreferencesWrite = task.catch(() => undefined); return task;
|
adminPreferencesWrite = task.catch(() => undefined); return task;
|
||||||
}
|
}
|
||||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
|
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
|
||||||
|
function renderRefreshPreferences(value = REFRESH_DEFAULTS) {
|
||||||
|
const refresh = normalizeRefreshPreferences(value);
|
||||||
|
const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled;
|
||||||
|
for (const field of Object.keys(REFRESH_CHOICES)) {
|
||||||
|
const input = $(`refresh-${field.replaceAll("_", "-")}`);
|
||||||
|
if (input) input.value = String(refresh[field]);
|
||||||
|
}
|
||||||
|
const manual = refresh.system_info_seconds === 0 || !refresh.live_enabled;
|
||||||
|
const hint = $("system-info-refresh-note");
|
||||||
|
if (hint) hint.textContent = t(manual ? "refreshManualHint" : "refreshSystemHint");
|
||||||
|
}
|
||||||
|
for (const control of document.querySelectorAll("[data-refresh-preference]")) {
|
||||||
|
control.addEventListener("change", () => {
|
||||||
|
const key = control.dataset.refreshPreference;
|
||||||
|
const previous = normalizeRefreshPreferences(adminPreferences?.refresh);
|
||||||
|
const value = key === "live_enabled" ? control.checked : Number(control.value);
|
||||||
|
if (key !== "live_enabled" && !REFRESH_CHOICES[key]?.includes(value)) {
|
||||||
|
renderRefreshPreferences(previous); return;
|
||||||
|
}
|
||||||
|
const next = { ...previous, [key]: value };
|
||||||
|
if (adminPreferences) adminPreferences.refresh = next;
|
||||||
|
window.rustProxyRefresh?.update(next); renderRefreshPreferences(next);
|
||||||
|
void saveAppearancePreference({ refresh: next }, control, () => {
|
||||||
|
if (adminPreferences) adminPreferences.refresh = previous;
|
||||||
|
window.rustProxyRefresh?.update(previous); renderRefreshPreferences(previous);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("refresh-reset")?.addEventListener("click", () => {
|
||||||
|
const previous = normalizeRefreshPreferences(adminPreferences?.refresh), next = { ...REFRESH_DEFAULTS };
|
||||||
|
if (adminPreferences) adminPreferences.refresh = next;
|
||||||
|
window.rustProxyRefresh?.update(next); renderRefreshPreferences(next);
|
||||||
|
void saveAppearancePreference({ refresh: next }, $("refresh-reset"), () => {
|
||||||
|
if (adminPreferences) adminPreferences.refresh = previous;
|
||||||
|
window.rustProxyRefresh?.update(previous); renderRefreshPreferences(previous);
|
||||||
|
});
|
||||||
|
});
|
||||||
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme, refreshAppearanceUi };
|
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme, refreshAppearanceUi };
|
||||||
|
|
||||||
async function saveAppearancePreference(patch, control, rollback) {
|
async function saveAppearancePreference(patch, control, rollback) {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Single scheduler for all authenticated dashboard polling. SSE is controlled here
|
||||||
|
// but remains one native EventSource with its own browser reconnect behavior.
|
||||||
|
const REFRESH_DEFAULTS = Object.freeze({
|
||||||
|
live_enabled: true, overview_seconds: 2, traffic_seconds: 5,
|
||||||
|
cache_seconds: 5, system_info_seconds: 60
|
||||||
|
});
|
||||||
|
const REFRESH_CHOICES = Object.freeze({
|
||||||
|
overview_seconds: [1, 2, 5, 10], traffic_seconds: [0, 3, 5, 10, 30],
|
||||||
|
cache_seconds: [0, 3, 5, 10, 30], system_info_seconds: [0, 15, 30, 60, 300]
|
||||||
|
});
|
||||||
|
function normalizeRefreshPreferences(input = {}) {
|
||||||
|
const value = { ...REFRESH_DEFAULTS };
|
||||||
|
if (typeof input.live_enabled === "boolean") value.live_enabled = input.live_enabled;
|
||||||
|
for (const [key, allowed] of Object.entries(REFRESH_CHOICES)) {
|
||||||
|
if (allowed.includes(input[key])) value[key] = input[key];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rustProxyRefresh = (() => {
|
||||||
|
const tasks = new Map();
|
||||||
|
let ready = false, options = { ...REFRESH_DEFAULTS }, panel = currentPanel;
|
||||||
|
let settingsTab = "listeners", watchingVisibility = false;
|
||||||
|
const visible = () => !document.hidden;
|
||||||
|
function allowed(task) {
|
||||||
|
return ready && authenticated && visible() && options.live_enabled &&
|
||||||
|
task.interval() > 0 && (!task.active || task.active());
|
||||||
|
}
|
||||||
|
function clear(task, abort = false) {
|
||||||
|
if (task.timer !== null) clearTimeout(task.timer);
|
||||||
|
task.timer = null;
|
||||||
|
if (abort) {
|
||||||
|
++task.generation;
|
||||||
|
task.controller?.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function schedule(task, delay) {
|
||||||
|
if (task.timer !== null || task.inFlight || !allowed(task)) return;
|
||||||
|
task.timer = setTimeout(() => { task.timer = null; void execute(task, false); }, delay);
|
||||||
|
}
|
||||||
|
function execute(task, manual) {
|
||||||
|
if (!authenticated || (!manual && !allowed(task))) return Promise.resolve();
|
||||||
|
if (task.inFlight) {
|
||||||
|
if (manual) task.queued = true;
|
||||||
|
return task.inFlight;
|
||||||
|
}
|
||||||
|
clear(task);
|
||||||
|
const controller = new AbortController(), generation = task.generation, epoch = authEpoch;
|
||||||
|
const panelAtStart = panel, tabAtStart = settingsTab;
|
||||||
|
task.controller = controller;
|
||||||
|
// A manual request remains valid with Live switched off, but not in a stale view.
|
||||||
|
const isCurrent = () => authenticated && epoch === authEpoch && !controller.signal.aborted &&
|
||||||
|
generation === task.generation && (manual ? panel === panelAtStart && settingsTab === tabAtStart : allowed(task));
|
||||||
|
const promise = Promise.resolve().then(() => task.run({ signal: controller.signal, isCurrent }));
|
||||||
|
task.inFlight = promise.catch(error => {
|
||||||
|
if (error?.name !== "AbortError" && isCurrent()) {
|
||||||
|
// A failure is isolated to this source; other timers continue.
|
||||||
|
if (task.onError) task.onError(error);
|
||||||
|
else console.warn(`Refresh ${task.id} failed:`, error);
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
task.inFlight = null;
|
||||||
|
if (task.controller === controller) task.controller = null;
|
||||||
|
if (task.queued && authenticated) {
|
||||||
|
task.queued = false;
|
||||||
|
void execute(task, true);
|
||||||
|
} else {
|
||||||
|
task.queued = false;
|
||||||
|
schedule(task, task.interval() * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return task.inFlight;
|
||||||
|
}
|
||||||
|
function register(id, spec) {
|
||||||
|
if (tasks.has(id)) throw new Error(`Duplicate refresh task: ${id}`);
|
||||||
|
if (typeof spec.run !== "function" || typeof spec.interval !== "function") throw new Error(`Invalid refresh task: ${id}`);
|
||||||
|
const task = { ...spec, id, timer: null, inFlight: null, controller: null, generation: 0, queued: false, enabled: false, seconds: null };
|
||||||
|
tasks.set(id, task);
|
||||||
|
sync();
|
||||||
|
return () => { clear(task, true); tasks.delete(id); };
|
||||||
|
}
|
||||||
|
function sync() {
|
||||||
|
for (const task of tasks.values()) {
|
||||||
|
const shouldRun = allowed(task), seconds = task.interval();
|
||||||
|
if (!shouldRun) {
|
||||||
|
if (task.enabled) clear(task, true);
|
||||||
|
task.enabled = false;
|
||||||
|
} else if (!task.enabled || task.seconds !== seconds) {
|
||||||
|
clear(task);
|
||||||
|
task.enabled = true;
|
||||||
|
schedule(task, 0);
|
||||||
|
}
|
||||||
|
task.seconds = seconds;
|
||||||
|
}
|
||||||
|
if (ready && authenticated && visible() && options.live_enabled) startLive();
|
||||||
|
else stopLive();
|
||||||
|
}
|
||||||
|
function onVisibility() { sync(); if (visible() && ready && authenticated) void revalidateSession(); }
|
||||||
|
function start() {
|
||||||
|
ready = true; panel = currentPanel;
|
||||||
|
settingsTab = window.rustProxySettings?.current?.() || "listeners";
|
||||||
|
if (!watchingVisibility) { document.addEventListener("visibilitychange", onVisibility); watchingVisibility = true; }
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
ready = false;
|
||||||
|
for (const task of tasks.values()) {
|
||||||
|
clear(task, true); task.queued = false; task.enabled = false;
|
||||||
|
}
|
||||||
|
if (watchingVisibility) { document.removeEventListener("visibilitychange", onVisibility); watchingVisibility = false; }
|
||||||
|
stopLive();
|
||||||
|
}
|
||||||
|
function update(value) { options = normalizeRefreshPreferences(value); sync(); }
|
||||||
|
function setPanel(value) {
|
||||||
|
panel = value; settingsTab = window.rustProxySettings?.current?.() || settingsTab;
|
||||||
|
for (const task of tasks.values()) if (task.active && !task.active()) task.queued = false;
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
function setSettingsTab(value) {
|
||||||
|
settingsTab = value;
|
||||||
|
for (const task of tasks.values()) if (task.active && !task.active()) task.queued = false;
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
function manual(id) {
|
||||||
|
const task = tasks.get(id);
|
||||||
|
if (!task) return Promise.reject(new Error(`Unknown refresh task: ${id}`));
|
||||||
|
return execute(task, true);
|
||||||
|
}
|
||||||
|
register("status-full", { interval: () => 0, run: async ({ signal, isCurrent }) => {
|
||||||
|
const value = await api("/api/status", { signal });
|
||||||
|
if (isCurrent()) renderStatus(value, true);
|
||||||
|
}});
|
||||||
|
register("status-fallback", { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||||
|
const value = await api("/api/status?view=live", { signal });
|
||||||
|
if (isCurrent()) renderLiveStatus(value);
|
||||||
|
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }});
|
||||||
|
register("logs-fallback", { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||||
|
const value = await api("/api/logs", { signal });
|
||||||
|
if (isCurrent()) renderLogs(value);
|
||||||
|
}});
|
||||||
|
register("system-info", { interval: () => options.system_info_seconds,
|
||||||
|
active: () => panel === "overview" || (panel === "settings" && ["system-info", "cache"].includes(settingsTab)),
|
||||||
|
run: async ({ signal, isCurrent }) => {
|
||||||
|
const value = await api("/api/system-info", { signal });
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
latestStatus = { ...(latestStatus || {}), ...value };
|
||||||
|
if (latestStatus.metrics) renderDiagnostics(latestStatus, false, true);
|
||||||
|
window.rustProxySettings?.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
register("tunnels", { interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
|
||||||
|
run: async context => { await loadTunnels(context); } });
|
||||||
|
return { register, manual, start, stop, sync, update, setPanel, setSettingsTab,
|
||||||
|
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null })) };
|
||||||
|
})();
|
||||||
|
window.rustProxyRefresh = rustProxyRefresh;
|
||||||
|
|
||||||
|
$("overview-refresh")?.addEventListener("click", () => {
|
||||||
|
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback"), rustProxyRefresh.manual("system-info")]);
|
||||||
|
});
|
||||||
|
$("system-info-refresh")?.addEventListener("click", () => void rustProxyRefresh.manual("system-info"));
|
||||||
@@ -242,7 +242,7 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
|
|||||||
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty"));
|
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function renderDiagnostics(status, appendSample = true) {
|
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
|
||||||
const d = status.system || {}, c = status.cache || {}, m = status.metrics || {};
|
const d = status.system || {}, c = status.cache || {}, m = status.metrics || {};
|
||||||
$("metric-h3").textContent = fmt(m.http3_requests);
|
$("metric-h3").textContent = fmt(m.http3_requests);
|
||||||
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`;
|
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`;
|
||||||
@@ -257,7 +257,7 @@ function renderDiagnostics(status, appendSample = true) {
|
|||||||
const sourceSupported = !!d.source && d.source !== "unsupported";
|
const sourceSupported = !!d.source && d.source !== "unsupported";
|
||||||
$("system-source").hidden = !sourceSupported;
|
$("system-source").hidden = !sourceSupported;
|
||||||
if (sourceSupported) $("system-source").textContent = t("realSample");
|
if (sourceSupported) $("system-source").textContent = t("realSample");
|
||||||
if (appendSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); }
|
if (systemSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); }
|
||||||
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
|
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
|
||||||
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
|
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
|
||||||
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
|
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
|
||||||
@@ -294,6 +294,8 @@ function renderDiagnostics(status, appendSample = true) {
|
|||||||
cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60);
|
cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60);
|
||||||
lastCacheCounters = currentCounters;
|
lastCacheCounters = currentCounters;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (systemSample) {
|
||||||
if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); }
|
if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); }
|
||||||
if (d.process_rss_bytes != null) { processRssSamples.push(Number(d.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); }
|
if (d.process_rss_bytes != null) { processRssSamples.push(Number(d.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function renderSettings() {
|
|||||||
$("cache-sie-timeout").checked = c.stale_if_error_timeout !== false; $("cache-sie-connection").checked = c.stale_if_error_connection !== false; $("cache-sie-dns").checked = c.stale_if_error_dns !== false; $("cache-sie-tls").checked = c.stale_if_error_tls !== false; $("cache-sie-statuses").value = (c.stale_if_error_statuses || [500, 502, 503, 504]).join(", ");
|
$("cache-sie-timeout").checked = c.stale_if_error_timeout !== false; $("cache-sie-connection").checked = c.stale_if_error_connection !== false; $("cache-sie-dns").checked = c.stale_if_error_dns !== false; $("cache-sie-tls").checked = c.stale_if_error_tls !== false; $("cache-sie-statuses").value = (c.stale_if_error_statuses || [500, 502, 503, 504]).join(", ");
|
||||||
$("cache-proactive-enabled").checked = c.proactive_refresh_enabled === true; $("cache-proactive-before").value = c.proactive_refresh_before_expiry_seconds ?? 15; $("cache-proactive-percent").value = c.proactive_refresh_percent ?? 10; $("cache-proactive-inflight").value = c.proactive_refresh_max_inflight ?? 4;
|
$("cache-proactive-enabled").checked = c.proactive_refresh_enabled === true; $("cache-proactive-before").value = c.proactive_refresh_before_expiry_seconds ?? 15; $("cache-proactive-percent").value = c.proactive_refresh_percent ?? 10; $("cache-proactive-inflight").value = c.proactive_refresh_max_inflight ?? 4;
|
||||||
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
|
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
|
||||||
|
$("cache-http-invalidation-cidrs").value = (c.http_invalidation_allowed_cidrs || []).join("\n");
|
||||||
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
|
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
|
||||||
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age; $("cache-only-hits").checked = !!c.expose_only_hits;
|
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age; $("cache-only-hits").checked = !!c.expose_only_hits;
|
||||||
const viaHeader = c.via_header === undefined ? "RustProxy" : c.via_header;
|
const viaHeader = c.via_header === undefined ? "RustProxy" : c.via_header;
|
||||||
@@ -285,7 +286,7 @@ bindSettingsForm("response-form", next => {
|
|||||||
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
|
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
|
||||||
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
|
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
|
||||||
bindSettingsForm("cache-form", next => {
|
bindSettingsForm("cache-form", next => {
|
||||||
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
|
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
|
||||||
}, "cache");
|
}, "cache");
|
||||||
bindSettingsForm("transform-form", next => {
|
bindSettingsForm("transform-form", next => {
|
||||||
next.transform = {
|
next.transform = {
|
||||||
|
|||||||
@@ -217,13 +217,24 @@
|
|||||||
generation++; await refresh();
|
generation++; await refresh();
|
||||||
} catch (error) { operationMessage("cache-bans-status", error.message, true); }
|
} catch (error) { operationMessage("cache-bans-status", error.message, true); }
|
||||||
}
|
}
|
||||||
async function refresh() {
|
async function loadCache({ signal, isCurrent }) {
|
||||||
if (!authenticated || !envelope || currentPanel !== "cache" || loading) return;
|
if (!authenticated || !envelope || currentPanel !== "cache" || loading) return;
|
||||||
loading = true; const epoch = authEpoch, token = generation; const query = new URLSearchParams({ offset: String(offset), limit: "50" }); if (scope() !== "*") query.set("vhost", scope());
|
loading = true;
|
||||||
try { const data = await api(`/api/cache?${query}`); if (epoch !== authEpoch || token !== generation || !authenticated) return; render(data); }
|
const epoch = authEpoch, token = generation;
|
||||||
catch (error) { if (epoch === authEpoch) { $("cache-panel-state").textContent = error.message; if (error.status === 401) lock(); } }
|
const query = new URLSearchParams({ offset: String(offset), limit: "50" });
|
||||||
finally { loading = false; if (token !== generation && authenticated && currentPanel === "cache") void refresh(); }
|
if (scope() !== "*") query.set("vhost", scope());
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/cache?${query}`, { signal });
|
||||||
|
if (isCurrent() && token === generation) render(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||||
|
$("cache-panel-state").textContent = error.message;
|
||||||
|
if (error.status === 401) lock();
|
||||||
|
}
|
||||||
|
} finally { loading = false; }
|
||||||
}
|
}
|
||||||
|
function refresh() { return rustProxyRefresh.manual("cache"); }
|
||||||
|
|
||||||
initializeLiveChartLimit();
|
initializeLiveChartLimit();
|
||||||
$("cache-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
|
$("cache-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
|
||||||
$("cache-scope").addEventListener("change", () => { offset = 0; resetLiveChart(); generation++; void refresh(); });
|
$("cache-scope").addEventListener("change", () => { offset = 0; resetLiveChart(); generation++; void refresh(); });
|
||||||
@@ -319,7 +330,12 @@
|
|||||||
offset = 0; generation++; await refresh();
|
offset = 0; generation++; await refresh();
|
||||||
} catch (error) { if (epoch === authEpoch) operationMessage("cache-toolbar-operation-status", error.message, true); } finally { button.disabled = false; }
|
} catch (error) { if (epoch === authEpoch) operationMessage("cache-toolbar-operation-status", error.message, true); } finally { button.disabled = false; }
|
||||||
});
|
});
|
||||||
setInterval(() => { if (!document.hidden && $("cache-live").checked) void refresh(); }, 3000);
|
rustProxyRefresh.register("cache", {
|
||||||
|
interval: () => rustProxyRefresh.current().cache_seconds,
|
||||||
|
active: () => currentPanel === "cache" && !!$("cache-live")?.checked,
|
||||||
|
run: loadCache
|
||||||
|
});
|
||||||
|
$("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
||||||
window.rustProxyCachePanel = {
|
window.rustProxyCachePanel = {
|
||||||
refresh, configure, reason, reset() {
|
refresh, configure, reason, reset() {
|
||||||
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans();
|
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans();
|
||||||
|
|||||||
+35
-6
@@ -363,14 +363,39 @@
|
|||||||
}
|
}
|
||||||
function syncLiveSwitch() { const control = $("docker-live-refresh"); if (control) control.checked = liveEnabled(); }
|
function syncLiveSwitch() { const control = $("docker-live-refresh"); if (control) control.checked = liveEnabled(); }
|
||||||
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); }
|
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); }
|
||||||
async function refresh(force = false) {
|
let dockerLoading = null, dockerForcePending = false;
|
||||||
if (!authenticated) return; const button = $("docker-refresh"); if (button && force) button.disabled = true;
|
async function refresh(force = false, context = null) {
|
||||||
try { const data = await api(force ? "/api/docker/refresh" : "/api/docker", force ? { method: "POST", body: "{}" } : {}); observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || []; poolIndex = new Map(pools.map(pool => [pool.id, pool])); renderSettings(); if (force || canLiveRender()) renderDiscovery(); }
|
if (!authenticated) return;
|
||||||
catch (error) { if (force) toast(error.message, true); } finally { if (button) button.disabled = false; }
|
if (dockerLoading) {
|
||||||
|
if (force) dockerForcePending = true;
|
||||||
|
return dockerLoading;
|
||||||
|
}
|
||||||
|
dockerLoading = loadDocker(force, context);
|
||||||
|
try { return await dockerLoading; }
|
||||||
|
finally {
|
||||||
|
dockerLoading = null;
|
||||||
|
if (dockerForcePending && authenticated) { dockerForcePending = false; void refresh(true); }
|
||||||
|
else dockerForcePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadDocker(force = false, context = null) {
|
||||||
|
const button = $("docker-refresh"), epoch = authEpoch;
|
||||||
|
if (button && force) button.disabled = true;
|
||||||
|
const options = force ? { method: "POST", body: "{}" } : {};
|
||||||
|
if (context?.signal) options.signal = context.signal;
|
||||||
|
try {
|
||||||
|
const data = await api(force ? "/api/docker/refresh" : "/api/docker", options);
|
||||||
|
if (!authenticated || epoch !== authEpoch || (context?.isCurrent && !context.isCurrent())) return;
|
||||||
|
observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || [];
|
||||||
|
poolIndex = new Map(pools.map(pool => [pool.id, pool]));
|
||||||
|
renderSettings(); if (force || canLiveRender()) renderDiscovery();
|
||||||
|
} catch (error) {
|
||||||
|
if (force && error?.name !== "AbortError" && authenticated && epoch === authEpoch) toast(error.message, true);
|
||||||
|
} finally { if (button) button.disabled = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
$("docker-refresh")?.addEventListener("click", () => void refresh(true));
|
$("docker-refresh")?.addEventListener("click", () => void refresh(true));
|
||||||
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); if (event.target.checked) void refresh(false); });
|
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); rustProxyRefresh.sync(); });
|
||||||
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
|
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
|
||||||
$("docker-search")?.addEventListener("input", applyViewFilters);
|
$("docker-search")?.addEventListener("input", applyViewFilters);
|
||||||
$("docker-vhost-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); applyViewFilters(); });
|
$("docker-vhost-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); applyViewFilters(); });
|
||||||
@@ -379,6 +404,10 @@
|
|||||||
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
|
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
|
||||||
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
|
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
|
||||||
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
|
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
|
||||||
setInterval(() => { if (authenticated && liveEnabled() && typeof currentPanel !== "undefined" && currentPanel === "docker-vhosts" && canLiveRender()) void refresh(false); }, 5000);
|
rustProxyRefresh.register("docker", {
|
||||||
|
interval: () => 5,
|
||||||
|
active: () => currentPanel === "docker-vhosts" && liveEnabled() && canLiveRender(),
|
||||||
|
run: async context => refresh(false, context)
|
||||||
|
});
|
||||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||||
})();
|
})();
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
const remove = el("button", "×", "draft-dock-remove"); remove.type = "button"; remove.setAttribute("aria-label", t("draftDiscard")); remove.addEventListener("click", () => void discard(draft.id));
|
const remove = el("button", "×", "draft-dock-remove"); remove.type = "button"; remove.setAttribute("aria-label", t("draftDiscard")); remove.addEventListener("click", () => void discard(draft.id));
|
||||||
row.append(open, remove); list.append(row);
|
row.append(open, remove); list.append(row);
|
||||||
}
|
}
|
||||||
|
window.rustProxyRefresh?.sync();
|
||||||
}
|
}
|
||||||
toggle.addEventListener("click", () => { collapsed = !collapsed; render(); });
|
toggle.addEventListener("click", () => { collapsed = !collapsed; render(); });
|
||||||
function register(type, api) { editors.set(type, api); render(); }
|
function register(type, api) { editors.set(type, api); render(); }
|
||||||
@@ -92,7 +93,10 @@
|
|||||||
}
|
}
|
||||||
const takeChildren = node => { const children = [...node.childNodes]; node.replaceChildren(); return children; };
|
const takeChildren = node => { const children = [...node.childNodes]; node.replaceChildren(); return children; };
|
||||||
const restoreChildren = (node, children) => node.replaceChildren(...(children || []));
|
const restoreChildren = (node, children) => node.replaceChildren(...(children || []));
|
||||||
if (typeof setInterval === "function") setInterval(() => { if (drafts.length && !collapsed) render(); }, 60000);
|
window.rustProxyRefresh?.register("drafts", {
|
||||||
|
interval: () => 60, active: () => drafts.length > 0 && !collapsed,
|
||||||
|
run: async () => render()
|
||||||
|
});
|
||||||
|
|
||||||
window.rustProxyDrafts = { register, dock: dockEditor, prepare, reset, refresh: render, captureControls, restoreControls, takeChildren, restoreChildren };
|
window.rustProxyDrafts = { register, dock: dockEditor, prepare, reset, refresh: render, captureControls, restoreControls, takeChildren, restoreChildren };
|
||||||
})();
|
})();
|
||||||
@@ -28,6 +28,21 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Visible even when the dashboard JavaScript fails to initialize. -->
|
||||||
|
<div class="app-startup" id="app-startup" role="status" aria-live="polite">
|
||||||
|
<div class="app-startup-card">
|
||||||
|
<div class="app-startup-brand" aria-hidden="true">r<span>/</span>p</div>
|
||||||
|
<div class="app-startup-spinner" aria-hidden="true"></div>
|
||||||
|
<h2 id="app-startup-title" data-i18n="startupLoading">Loading RustProxy…</h2>
|
||||||
|
<p class="app-startup-message" data-i18n="startupPreparing">Connecting to the administration panel.</p>
|
||||||
|
<div class="app-startup-recovery">
|
||||||
|
<p data-i18n="startupSlow">The panel is taking longer than expected. Check your connection and try again.</p>
|
||||||
|
<p id="app-startup-detail" class="app-startup-detail" hidden></p>
|
||||||
|
<a class="outline" href="" data-i18n="startupRetry">Reload panel</a>
|
||||||
|
</div>
|
||||||
|
<noscript>JavaScript is required to use the administration panel.</noscript>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<a class="brand" href="/" aria-label="RustProxy"><span
|
<a class="brand" href="/" aria-label="RustProxy"><span
|
||||||
@@ -111,10 +126,13 @@
|
|||||||
</section>
|
</section>
|
||||||
<div hidden="" id="workspace">
|
<div hidden="" id="workspace">
|
||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div class="page-intro-copy">
|
||||||
<div class="eyebrow"><i class="signal" id="signal"></i><span id="connection-label">CONNECTED TO
|
<div class="eyebrow"><i class="signal" id="signal"></i><span id="connection-label">CONNECTED TO
|
||||||
LOCAL CORE</span></div>
|
LOCAL CORE</span></div>
|
||||||
<h1 id="page-title">Traffic, in focus.</h1>
|
<div class="page-intro-title-row">
|
||||||
|
<h1 id="page-title">Traffic, in focus.</h1>
|
||||||
|
<button class="quiet overview-refresh" data-i18n="refreshNow" id="overview-refresh" type="button" hidden>Refresh now</button>
|
||||||
|
</div>
|
||||||
<p class="lead" id="page-description"></p>
|
<p class="lead" id="page-description"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1221,7 +1239,7 @@
|
|||||||
role="status"></span>
|
role="status"></span>
|
||||||
<label class="check cache-live-toggle"><input checked="" id="traffic-live"
|
<label class="check cache-live-toggle"><input checked="" id="traffic-live"
|
||||||
type="checkbox" />
|
type="checkbox" />
|
||||||
<span data-i18n="cacheLive">Live (3 s)</span></label>
|
<span data-i18n="refreshViewLive">Auto in this view</span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label="Traffic analytics" class="settings-tabs sub-tabs traffic-tabs" id="traffic-tabs"
|
<nav aria-label="Traffic analytics" class="settings-tabs sub-tabs traffic-tabs" id="traffic-tabs"
|
||||||
@@ -1375,8 +1393,7 @@
|
|||||||
type="button">Active BANs</button>
|
type="button">Active BANs</button>
|
||||||
<div class="cache-toolbar-status"><span class="fine" id="cache-panel-state"
|
<div class="cache-toolbar-status"><span class="fine" id="cache-panel-state"
|
||||||
role="status"></span><label class="check cache-live-toggle"><input checked=""
|
role="status"></span><label class="check cache-live-toggle"><input checked=""
|
||||||
id="cache-live" type="checkbox" /><span data-i18n="cacheLive">Live (3
|
id="cache-live" type="checkbox" /><span data-i18n="refreshViewLive">Auto in this view</span></label></div>
|
||||||
s)</span></label></div>
|
|
||||||
<p class="operation-message cache-toolbar-message" id="cache-toolbar-operation-status"
|
<p class="operation-message cache-toolbar-message" id="cache-toolbar-operation-status"
|
||||||
role="status" aria-live="polite"></p>
|
role="status" aria-live="polite"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1530,8 +1547,7 @@
|
|||||||
id="transform-panel-settings" type="button">Settings</button>
|
id="transform-panel-settings" type="button">Settings</button>
|
||||||
<div class="cache-toolbar-status"><span class="fine" id="transform-panel-state"
|
<div class="cache-toolbar-status"><span class="fine" id="transform-panel-state"
|
||||||
role="status"></span><label class="check cache-live-toggle"><input checked=""
|
role="status"></span><label class="check cache-live-toggle"><input checked=""
|
||||||
id="transform-live" type="checkbox" /><span data-i18n="cacheLive">Live (3
|
id="transform-live" type="checkbox" /><span data-i18n="refreshViewLive">Auto in this view</span></label></div>
|
||||||
s)</span></label></div>
|
|
||||||
</div>
|
</div>
|
||||||
<details class="card function-live-chart" id="transform-live-chart">
|
<details class="card function-live-chart" id="transform-live-chart">
|
||||||
<summary class="function-live-chart-summary">
|
<summary class="function-live-chart-summary">
|
||||||
@@ -1912,6 +1928,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="appearance-section" id="refresh-preferences">
|
||||||
|
<div class="appearance-section-heading"><div>
|
||||||
|
<div class="small-label" data-i18n="refreshSection">REFRESH</div>
|
||||||
|
<h3 data-i18n="refreshTitle">Data refresh</h3>
|
||||||
|
</div><button class="quiet appearance-section-reset" id="refresh-reset" type="button" data-i18n="resetSection">Reset section</button></div>
|
||||||
|
<p class="fine" data-i18n="refreshHelp">Per-account refresh intervals. Off pauses all automatic sources; manual refresh remains available.</p>
|
||||||
|
<div class="appearance-settings-grid">
|
||||||
|
<label class="check"><input type="checkbox" checked id="refresh-live-enabled" data-refresh-preference="live_enabled" />
|
||||||
|
<span data-i18n="refreshLiveEnabled">Automatic refresh enabled</span></label>
|
||||||
|
<label><span data-i18n="refreshOverview">Live overview / SSE interval</span>
|
||||||
|
<select id="refresh-overview-seconds" data-refresh-preference="overview_seconds">
|
||||||
|
<option value="1">1 s</option><option selected value="2">2 s</option><option value="5">5 s</option><option value="10">10 s</option>
|
||||||
|
</select></label>
|
||||||
|
<label><span data-i18n="refreshTraffic">Traffic interval</span>
|
||||||
|
<select id="refresh-traffic-seconds" data-refresh-preference="traffic_seconds">
|
||||||
|
<option value="0" data-i18n="refreshManual">Manual</option><option value="3">3 s</option><option selected value="5">5 s</option><option value="10">10 s</option><option value="30">30 s</option>
|
||||||
|
</select></label>
|
||||||
|
<label><span data-i18n="refreshCache">Cache interval</span>
|
||||||
|
<select id="refresh-cache-seconds" data-refresh-preference="cache_seconds">
|
||||||
|
<option value="0" data-i18n="refreshManual">Manual</option><option value="3">3 s</option><option selected value="5">5 s</option><option value="10">10 s</option><option value="30">30 s</option>
|
||||||
|
</select></label>
|
||||||
|
<label><span data-i18n="refreshSystemInfo">System information interval</span>
|
||||||
|
<select id="refresh-system-info-seconds" data-refresh-preference="system_info_seconds">
|
||||||
|
<option value="0" data-i18n="refreshManual">Manual</option><option value="15">15 s</option><option value="30">30 s</option><option selected value="60">60 s</option><option value="300">300 s</option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="appearance-actions"><button class="outline" data-i18n="appearanceReset"
|
<div class="appearance-actions"><button class="outline" data-i18n="appearanceReset"
|
||||||
id="appearance-reset" type="button">Reset appearance</button></div>
|
id="appearance-reset" type="button">Reset appearance</button></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2290,6 +2333,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="fine" data-i18n="cacheViaHelp">Defaults to Via: RustProxy. Changing this
|
<p class="fine" data-i18n="cacheViaHelp">Defaults to Via: RustProxy. Changing this
|
||||||
response-only header does not purge cached objects.</p>
|
response-only header does not purge cached objects.</p>
|
||||||
|
<div class="cache-maintenance-help">
|
||||||
|
<h3 class="subheading" data-i18n="cacheHttpInvalidationTitle">HTTP BAN / PURGE IP allowlist</h3>
|
||||||
|
<details class="metric-info cache-maintenance-info">
|
||||||
|
<summary aria-label="curl examples for HTTP BAN and PURGE" data-i18n-aria="cacheHttpInvalidationCurlLabel">i</summary>
|
||||||
|
<div class="metric-info-popover" role="note">
|
||||||
|
<p class="fine" data-i18n="cacheHttpInvalidationCurlIntro">Run curl from an allowlisted IP against a public vhost:</p>
|
||||||
|
<code>curl -i -X PURGE 'https://site.example/assets/app.js?v=1'</code>
|
||||||
|
<code>curl -i -X BAN 'https://site.example/assets/'</code>
|
||||||
|
<code>curl -i -X BAN 'https://site.example/' -H 'X-RustProxy-Confirm-Wide: 1'</code>
|
||||||
|
<p class="fine" data-i18n="cacheHttpInvalidationCurlNote">PURGE removes an exact URL; BAN invalidates a path prefix for 1 hour. No admin cookie is needed, but your IP must be allowed. A reverse proxy must forward these methods and trusted client IP information.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<label><span data-i18n="cacheHttpInvalidationAddresses">Allowed client IP addresses or CIDRs (one per line)</span>
|
||||||
|
<textarea id="cache-http-invalidation-cidrs" rows="4" spellcheck="false" placeholder="192.0.2.10 2001:db8:10::/64"></textarea></label>
|
||||||
|
<p class="fine" data-i18n="cacheHttpInvalidationHelp">Empty = disabled. PURGE /path?query removes exact matching entries; BAN /path lazily blocks cached URLs starting with the prefix (TTL 1 hour, optional X-RustProxy-Ban-Ttl). BAN / requires X-RustProxy-Confirm-Wide: 1. Only the matched vhost and Host are affected. Trusted reverse proxies must sanitize forwarding headers.</p>
|
||||||
<p class="fine" data-i18n="cacheAgeHelp">Age is sent on cache hits unless an
|
<p class="fine" data-i18n="cacheAgeHelp">Age is sent on cache hits unless an
|
||||||
explicit response-header rule removes or replaces it. Such Age rules are
|
explicit response-header rule removes or replaces it. Such Age rules are
|
||||||
wire-only and do not change cache freshness.</p>
|
wire-only and do not change cache freshness.</p>
|
||||||
@@ -2697,6 +2756,13 @@
|
|||||||
type="number" required="" /></label>
|
type="number" required="" /></label>
|
||||||
<p class="fine" data-i18n="administrationSessionDaysHelp">Browser sessions use an
|
<p class="fine" data-i18n="administrationSessionDaysHelp">Browser sessions use an
|
||||||
absolute 7–30 day lifetime.</p>
|
absolute 7–30 day lifetime.</p>
|
||||||
|
<h3 class="subheading" data-i18n="panelCompressionTitle">Panel content compression</h3>
|
||||||
|
<div class="switch-grid">
|
||||||
|
<label class="check"><input id="admin-compression-zstd" type="checkbox" checked /><span>Zstd</span></label>
|
||||||
|
<label class="check"><input id="admin-compression-br" type="checkbox" checked /><span>Brotli (br)</span></label>
|
||||||
|
<label class="check"><input id="admin-compression-gzip" type="checkbox" checked /><span>gzip</span></label>
|
||||||
|
</div>
|
||||||
|
<p class="fine" data-i18n="panelCompressionHelp">The browser negotiates the best supported encoding (equal quality: Zstd → Brotli → gzip). Disable all to send uncompressed panel assets. Applies immediately; a reverse proxy may forward or re-encode the response.</p>
|
||||||
<p class="fine" id="admin-status" role="status"></p>
|
<p class="fine" id="admin-status" role="status"></p>
|
||||||
<div class="form-actions"><button class="primary" data-i18n="apply"
|
<div class="form-actions"><button class="primary" data-i18n="apply"
|
||||||
type="submit">Validate, save and apply</button></div>
|
type="submit">Validate, save and apply</button></div>
|
||||||
@@ -2706,9 +2772,10 @@
|
|||||||
data-settings-page="system-info" hidden="" id="settings-page-system-info" role="tabpanel">
|
data-settings-page="system-info" hidden="" id="settings-page-system-info" role="tabpanel">
|
||||||
<article class="card edit-form runtime-information-card">
|
<article class="card edit-form runtime-information-card">
|
||||||
<div class="card-heading">
|
<div class="card-heading">
|
||||||
<h2 data-i18n="systemInformation">System information</h2><span class="pill">API</span>
|
<h2 data-i18n="systemInformation">System information</h2><div class="system-info-heading-actions"><span class="pill">API</span><button class="quiet" data-i18n="refreshNow" id="system-info-refresh" type="button">Refresh now</button></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="fine" data-i18n="systemInformationHelp">Live, read-only information from the authenticated RustProxy status API. Unavailable metrics are not estimated.</p>
|
<p class="fine" data-i18n="systemInformationHelp">Read-only information from the authenticated system-info API. Unavailable metrics are not estimated.</p>
|
||||||
|
<p class="fine" id="system-info-refresh-note" role="status"></p>
|
||||||
<h3 data-i18n="systemHostHeading">Operating system and hardware</h3>
|
<h3 data-i18n="systemHostHeading">Operating system and hardware</h3>
|
||||||
<div class="runtime-information-grid">
|
<div class="runtime-information-grid">
|
||||||
<div><span class="small-label" data-i18n="runtimePlatform">Platform</span><code id="runtime-platform">-</code></div>
|
<div><span class="small-label" data-i18n="runtimePlatform">Platform</span><code id="runtime-platform">-</code></div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
const target = buttons.find(button => button.dataset.settingsTab === name); if (!target) return;
|
const target = buttons.find(button => button.dataset.settingsTab === name); if (!target) return;
|
||||||
if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
|
if (target.hidden && search?.value) { search.value = ""; applySettingsSearch(); }
|
||||||
active = name;
|
active = name;
|
||||||
|
if (typeof window !== "undefined") window.rustProxyRefresh?.setSettingsTab(name);
|
||||||
for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); }
|
for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); }
|
||||||
document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name);
|
document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name);
|
||||||
if (persist) syncLocation();
|
if (persist) syncLocation();
|
||||||
@@ -213,6 +214,7 @@ curl --fail --show-error --request POST \
|
|||||||
$("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current;
|
$("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current;
|
||||||
$("admin-hosts").value = (admin.allowed_hosts || []).join("\n");
|
$("admin-hosts").value = (admin.allowed_hosts || []).join("\n");
|
||||||
$("admin-session-days").value = Number(admin.session_days || 30);
|
$("admin-session-days").value = Number(admin.session_days || 30);
|
||||||
|
for (const codec of ["zstd", "br", "gzip"]) $( `admin-compression-${codec}` ).checked = admin.panel_compression?.[codec] !== false;
|
||||||
}
|
}
|
||||||
const listenLocked = !!adminStatus?.listen_locked;
|
const listenLocked = !!adminStatus?.listen_locked;
|
||||||
$("admin-listen").disabled = listenLocked; $("admin-listen-lock-note").hidden = !listenLocked;
|
$("admin-listen").disabled = listenLocked; $("admin-listen-lock-note").hidden = !listenLocked;
|
||||||
@@ -298,7 +300,7 @@ curl --fail --show-error --request POST \
|
|||||||
}, "prometheus");
|
}, "prometheus");
|
||||||
bindSettingsForm("administration-form", next => {
|
bindSettingsForm("administration-form", next => {
|
||||||
const locked = !!latestStatus?.administration?.listen_locked;
|
const locked = !!latestStatus?.administration?.listen_locked;
|
||||||
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value) };
|
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value), panel_compression: Object.fromEntries(["zstd", "br", "gzip"].map(codec => [codec, $( `admin-compression-${codec}` ).checked])) };
|
||||||
}, "administration");
|
}, "administration");
|
||||||
bindSettingsForm("tls-policy-form", next => {
|
bindSettingsForm("tls-policy-form", next => {
|
||||||
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
|
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
|
||||||
|
|||||||
+160
-2
@@ -165,8 +165,102 @@ html.app-loading {
|
|||||||
background: var(--bg)
|
background: var(--bg)
|
||||||
}
|
}
|
||||||
|
|
||||||
html.app-loading body {
|
/* The initial shell may be unavailable; never hide the boot screen itself. */
|
||||||
visibility: hidden
|
html.app-loading .shell {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg, #10151b);
|
||||||
|
color: var(--text, #edf2f7);
|
||||||
|
text-align: center
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.app-loading) .app-startup {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup-card {
|
||||||
|
width: min(100%, 360px)
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup-brand {
|
||||||
|
font: 700 29px var(--mono, monospace);
|
||||||
|
letter-spacing: -2px;
|
||||||
|
margin-bottom: 24px
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup-brand span {
|
||||||
|
color: var(--accent, #65cfa2)
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup-spinner {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0 auto 22px;
|
||||||
|
border: 3px solid var(--line, #374352);
|
||||||
|
border-top-color: var(--accent, #65cfa2);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: app-startup-spin .8s linear infinite
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup-message,
|
||||||
|
.app-startup-recovery p {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--muted, #b5c0cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup-recovery {
|
||||||
|
visibility: hidden;
|
||||||
|
animation: app-startup-reveal 0s linear 12s forwards
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup-recovery .outline {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: inline-flex
|
||||||
|
}
|
||||||
|
|
||||||
|
html.app-startup-failed .app-startup-spinner,
|
||||||
|
html.app-startup-failed .app-startup-message {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
html.app-startup-failed .app-startup-recovery {
|
||||||
|
visibility: visible;
|
||||||
|
animation: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-startup-detail {
|
||||||
|
overflow-wrap: anywhere
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes app-startup-spin {
|
||||||
|
to { transform: rotate(360deg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes app-startup-reveal {
|
||||||
|
to { visibility: visible }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-startup-spinner { animation: none }
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -9966,3 +10060,67 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
.php-origin-errors > label { max-width: 430px; }
|
.php-origin-errors > label { max-width: 430px; }
|
||||||
.php-origin-errors > .check { margin: 0 !important; }
|
.php-origin-errors > .check { margin: 0 !important; }
|
||||||
.php-origin-errors .fine { margin: 0; }
|
.php-origin-errors .fine { margin: 0; }
|
||||||
|
|
||||||
|
/* Keep the Overview refresh action on the same row as the page title. */
|
||||||
|
.page-intro-copy,
|
||||||
|
.page-intro-title-row {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro-title-row h1 {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro-title-row .overview-refresh {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.page-intro-title-row h1 {
|
||||||
|
font-size: clamp(18px, 5vw, 24px);
|
||||||
|
letter-spacing: -.6px
|
||||||
|
}
|
||||||
|
.page-intro-title-row .overview-refresh {
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 11px
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the system API indicator next to its action, aligned as one unit. */
|
||||||
|
.system-info-heading-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }
|
||||||
|
.system-info-heading-actions .pill { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Contextual, keyboard-accessible curl examples next to the cache allowlist. */
|
||||||
|
.cache-maintenance-help {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cache-maintenance-help .subheading { margin: 0; }
|
||||||
|
.cache-maintenance-info { position: static; flex-shrink: 0; }
|
||||||
|
.cache-maintenance-info .metric-info-popover {
|
||||||
|
max-height: min(60vh, 420px);
|
||||||
|
overflow-y: auto;
|
||||||
|
width: min(440px, calc(100vw - 56px));
|
||||||
|
}
|
||||||
|
.cache-maintenance-info .metric-info-popover code {
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: 7px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.cache-maintenance-info .metric-info-popover .fine + code { margin-top: 6px; }
|
||||||
|
.cache-maintenance-info .metric-info-popover code + .fine { margin-top: 8px; }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
(() => {
|
(() => {
|
||||||
let loading = false, timer = null, lastData = null, activeTab = "overview";
|
let loading = false, lastData = null, activeTab = "overview";
|
||||||
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
|
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
|
||||||
const scope = () => $("traffic-scope")?.value || "*";
|
const scope = () => $("traffic-scope")?.value || "*";
|
||||||
const number = value => fmt(Number(value) || 0);
|
const number = value => fmt(Number(value) || 0);
|
||||||
@@ -101,12 +101,17 @@
|
|||||||
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${formatTime(new Date())}`;
|
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${formatTime(new Date())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function loadTraffic({ signal, isCurrent }) {
|
||||||
if (!authenticated || loading) return; loading = true;
|
if (!authenticated || currentPanel !== "traffic" || loading) return;
|
||||||
try { render(await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`)); }
|
loading = true;
|
||||||
catch (error) { $("traffic-panel-state").textContent = error.message; }
|
try {
|
||||||
finally { loading = false; }
|
const data = await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`, { signal });
|
||||||
|
if (isCurrent()) render(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name !== "AbortError" && isCurrent()) $("traffic-panel-state").textContent = error.message;
|
||||||
|
} finally { loading = false; }
|
||||||
}
|
}
|
||||||
|
function refresh() { return rustProxyRefresh.manual("traffic"); }
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
||||||
@@ -121,7 +126,12 @@
|
|||||||
document.querySelectorAll("[data-traffic-tab]").forEach(button => button.addEventListener("click", () => selectTab(button.dataset.trafficTab)));
|
document.querySelectorAll("[data-traffic-tab]").forEach(button => button.addEventListener("click", () => selectTab(button.dataset.trafficTab)));
|
||||||
$("traffic-refresh")?.addEventListener("click", () => void refresh()); $("traffic-scope")?.addEventListener("change", () => void refresh());
|
$("traffic-refresh")?.addEventListener("click", () => void refresh()); $("traffic-scope")?.addEventListener("change", () => void refresh());
|
||||||
$("traffic-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("logging"); });
|
$("traffic-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("logging"); });
|
||||||
timer = setInterval(() => { if (authenticated && $("traffic-live")?.checked && !$("panel-traffic")?.hidden) void refresh(); }, 3000);
|
rustProxyRefresh.register("traffic", {
|
||||||
|
interval: () => rustProxyRefresh.current().traffic_seconds,
|
||||||
|
active: () => currentPanel === "traffic" && !!$("traffic-live")?.checked,
|
||||||
|
run: loadTraffic
|
||||||
|
});
|
||||||
|
$("traffic-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
||||||
selectTab(activeTab);
|
selectTab(activeTab);
|
||||||
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData), select: selectTab };
|
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData), select: selectTab };
|
||||||
})();
|
})();
|
||||||
@@ -144,26 +144,32 @@
|
|||||||
renderTypes(counters); renderReasons(counters); renderVhosts(data);
|
renderTypes(counters); renderReasons(counters); renderVhosts(data);
|
||||||
$("transform-panel-state").textContent = `${t("cacheUpdated")}: ${formatTime(new Date())}`;
|
$("transform-panel-state").textContent = `${t("cacheUpdated")}: ${formatTime(new Date())}`;
|
||||||
}
|
}
|
||||||
async function refresh() {
|
async function loadTransform({ signal, isCurrent }) {
|
||||||
if (!authenticated || currentPanel !== "transform" || loading) return;
|
if (!authenticated || currentPanel !== "transform" || loading) return;
|
||||||
loading = true; const epoch = authEpoch, token = generation;
|
loading = true;
|
||||||
|
const epoch = authEpoch, token = generation;
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/transform");
|
const data = await api("/api/transform", { signal });
|
||||||
if (epoch !== authEpoch || token !== generation || !authenticated) return;
|
if (isCurrent() && token === generation) render(data);
|
||||||
render(data);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (epoch === authEpoch) { $("transform-panel-state").textContent = error.message; if (error.status === 401) lock(); }
|
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
|
||||||
} finally {
|
$("transform-panel-state").textContent = error.message;
|
||||||
loading = false;
|
if (error.status === 401) lock();
|
||||||
if (token !== generation && authenticated && currentPanel === "transform") void refresh();
|
}
|
||||||
}
|
} finally { loading = false; }
|
||||||
}
|
}
|
||||||
|
function refresh() { return rustProxyRefresh.manual("transform"); }
|
||||||
initializeLiveChartLimit();
|
initializeLiveChartLimit();
|
||||||
$("transform-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
|
$("transform-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
|
||||||
$("transform-scope")?.addEventListener("change", () => { resetLiveChart(); if (lastData) render(lastData); });
|
$("transform-scope")?.addEventListener("change", () => { resetLiveChart(); if (lastData) render(lastData); });
|
||||||
$("transform-refresh")?.addEventListener("click", () => { generation++; void refresh(); });
|
$("transform-refresh")?.addEventListener("click", () => { generation++; void refresh(); });
|
||||||
$("transform-panel-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("transform"); $("transform-form")?.scrollIntoView({ block: "start" }); });
|
$("transform-panel-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("transform"); $("transform-form")?.scrollIntoView({ block: "start" }); });
|
||||||
setInterval(() => { if (!document.hidden && $("transform-live")?.checked) void refresh(); }, 3000);
|
rustProxyRefresh.register("transform", {
|
||||||
|
interval: () => 3,
|
||||||
|
active: () => currentPanel === "transform" && !!$("transform-live")?.checked,
|
||||||
|
run: loadTransform
|
||||||
|
});
|
||||||
|
$("transform-live")?.addEventListener("change", () => rustProxyRefresh.sync());
|
||||||
window.rustProxyTransformPanel = {
|
window.rustProxyTransformPanel = {
|
||||||
refresh,
|
refresh,
|
||||||
reset() {
|
reset() {
|
||||||
|
|||||||
Vendored
+1
@@ -1,4 +1,5 @@
|
|||||||
include!("model/settings.rs");
|
include!("model/settings.rs");
|
||||||
|
include!("model/http_invalidation.rs");
|
||||||
include!("model/entry.rs");
|
include!("model/entry.rs");
|
||||||
include!("model/cache.rs");
|
include!("model/cache.rs");
|
||||||
include!("model/maintenance.rs");
|
include!("model/maintenance.rs");
|
||||||
|
|||||||
Vendored
+1
@@ -78,6 +78,7 @@ impl Settings {
|
|||||||
"cache bypass prefixes must be normalized absolute paths"
|
"cache bypass prefixes must be normalized absolute paths"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
self.validate_http_invalidation_allowlist()?;
|
||||||
ensure!(
|
ensure!(
|
||||||
self.static_extensions.len() <= 64,
|
self.static_extensions.len() <= 64,
|
||||||
"at most 64 static cache extensions"
|
"at most 64 static cache extensions"
|
||||||
|
|||||||
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
// A strict, human-editable IP/CIDR policy. The caller supplies only a client IP
|
||||||
|
// produced by the existing forwarding trust boundary, never an arbitrary header.
|
||||||
|
fn parse_invalidation_cidr(value: &str) -> anyhow::Result<ipnet::IpNet> {
|
||||||
|
let value = value.trim();
|
||||||
|
if let Ok(ip) = value.parse::<std::net::IpAddr>() {
|
||||||
|
return Ok(ipnet::IpNet::from(ip));
|
||||||
|
}
|
||||||
|
value.parse::<ipnet::IpNet>().map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}"))
|
||||||
|
}
|
||||||
|
impl Settings {
|
||||||
|
fn validate_http_invalidation_allowlist(&self) -> anyhow::Result<()> {
|
||||||
|
ensure!(
|
||||||
|
self.http_invalidation_allowed_cidrs.len() <= 32,
|
||||||
|
"cache.http_invalidation_allowed_cidrs allows at most 32 entries"
|
||||||
|
);
|
||||||
|
let mut networks = std::collections::HashSet::new();
|
||||||
|
for input in &self.http_invalidation_allowed_cidrs {
|
||||||
|
ensure!(input.len() <= 64, "HTTP invalidation address too long");
|
||||||
|
let net = parse_invalidation_cidr(input)?;
|
||||||
|
ensure!(net.prefix_len() > 0, "HTTP invalidation cannot authorize the entire Internet");
|
||||||
|
ensure!(networks.insert(net), "duplicate HTTP invalidation IP/CIDR");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn permits_http_invalidation(&self, client: std::net::IpAddr) -> bool {
|
||||||
|
let client = crate::vhost::canonical_ip(client);
|
||||||
|
self.http_invalidation_allowed_cidrs.iter().any(|value| {
|
||||||
|
parse_invalidation_cidr(value).ok().is_some_and(|network| network.contains(&client))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+4
@@ -149,6 +149,9 @@ pub struct Settings {
|
|||||||
pub max_ttl_seconds: u64,
|
pub max_ttl_seconds: u64,
|
||||||
pub bypass_query: bool,
|
pub bypass_query: bool,
|
||||||
pub bypass_prefixes: Vec<String>,
|
pub bypass_prefixes: Vec<String>,
|
||||||
|
/// Actual client IP/CIDR allowlist for HTTP BAN/PURGE. Empty disables these
|
||||||
|
/// public-listener methods (independent of authenticated admin API).
|
||||||
|
pub http_invalidation_allowed_cidrs: Vec<String>,
|
||||||
/// For explicitly selected static extensions, ignore request Cookie when
|
/// For explicitly selected static extensions, ignore request Cookie when
|
||||||
/// deciding cache eligibility and when building the shared cache key.
|
/// deciding cache eligibility and when building the shared cache key.
|
||||||
pub static_ignore_cookies: bool,
|
pub static_ignore_cookies: bool,
|
||||||
@@ -210,6 +213,7 @@ impl Default for Settings {
|
|||||||
max_ttl_seconds: 86_400,
|
max_ttl_seconds: 86_400,
|
||||||
bypass_query: false,
|
bypass_query: false,
|
||||||
bypass_prefixes: Vec::new(),
|
bypass_prefixes: Vec::new(),
|
||||||
|
http_invalidation_allowed_cidrs: Vec::new(),
|
||||||
// Static assets are normally representation-identical for logged-in and
|
// Static assets are normally representation-identical for logged-in and
|
||||||
// anonymous visitors. Keep this enabled by default once the shared cache
|
// anonymous visitors. Keep this enabled by default once the shared cache
|
||||||
// itself is enabled; response-side privacy guards still reject Set-Cookie,
|
// itself is enabled; response-side privacy guards still reject Set-Cookie,
|
||||||
|
|||||||
Vendored
+3
@@ -16,3 +16,6 @@ include!("tests/v022_cache_tests.rs");
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
include!("tests/cold_tier_tests.rs");
|
include!("tests/cold_tier_tests.rs");
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
include!("tests/http_invalidation_tests.rs");
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
mod http_invalidation_tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn default_deny_and_strict_ipv4_ipv6_cidr_validation() {
|
||||||
|
let mut cache = Settings::default();
|
||||||
|
assert!(!cache.permits_http_invalidation("127.0.0.1".parse().unwrap()));
|
||||||
|
cache.http_invalidation_allowed_cidrs = vec!["192.0.2.42".into(), "2001:db8:10::/64".into()];
|
||||||
|
cache.validate().unwrap();
|
||||||
|
assert!(cache.permits_http_invalidation("192.0.2.42".parse().unwrap()));
|
||||||
|
assert!(!cache.permits_http_invalidation("192.0.2.43".parse().unwrap()));
|
||||||
|
assert!(cache.permits_http_invalidation("2001:db8:10::1".parse().unwrap()));
|
||||||
|
assert!(!cache.permits_http_invalidation("2001:db8:11::1".parse().unwrap()));
|
||||||
|
for invalid in ["0.0.0.0/0", "::/0", "127.0.0.1; 8.8.8.8", "not-an-ip", "10.0.0.0/33"] {
|
||||||
|
cache.http_invalidation_allowed_cidrs = vec![invalid.into()];
|
||||||
|
assert!(cache.validate().is_err(), "must reject {invalid}");
|
||||||
|
}
|
||||||
|
cache.http_invalidation_allowed_cidrs = vec!["192.0.2.42".into(), "192.0.2.42/32".into()];
|
||||||
|
assert!(cache.validate().is_err(), "duplicate ranges");
|
||||||
|
let old: Settings = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||||
|
assert!(old.http_invalidation_allowed_cidrs.is_empty());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn url_qualified_invalidation_matches_only_the_requested_scheme_and_host() {
|
||||||
|
let requested = InvalidationRequest {
|
||||||
|
operation: InvalidationOperation::Purge,
|
||||||
|
selector: InvalidationSelector::Exact,
|
||||||
|
value: "https://a.example/a?v=1".into(),
|
||||||
|
host: Some("a.example".into()),
|
||||||
|
vhost: Some("frontend-1".into()),
|
||||||
|
ttl_seconds: None,
|
||||||
|
confirm_wide: false,
|
||||||
|
};
|
||||||
|
let matcher = normalized_matcher(&requested).unwrap();
|
||||||
|
assert_eq!(matcher.value, "/a?v=1");
|
||||||
|
assert_eq!(matcher.scheme.as_deref(), Some("https"));
|
||||||
|
assert!(host_matches(&matcher, "https", "a.example", "a.example"));
|
||||||
|
assert!(!host_matches(&matcher, "http", "a.example", "a.example"));
|
||||||
|
assert!(!host_matches(&matcher, "https", "b.example", "b.example"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+2
-2
@@ -126,7 +126,7 @@ pub enum AppliedEncoding {
|
|||||||
impl AppliedEncoding {
|
impl AppliedEncoding {
|
||||||
const PREFERENCE: [Self; 4] = [Self::Zstd, Self::Brotli, Self::Gzip, Self::Deflate];
|
const PREFERENCE: [Self; 4] = [Self::Zstd, Self::Brotli, Self::Gzip, Self::Deflate];
|
||||||
|
|
||||||
fn as_str(self) -> &'static str {
|
pub(crate) fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Gzip => "gzip",
|
Self::Gzip => "gzip",
|
||||||
Self::Brotli => "br",
|
Self::Brotli => "br",
|
||||||
@@ -215,7 +215,7 @@ fn quality(raw: &str) -> Option<u16> {
|
|||||||
Some((value * 1000.0).round() as u16)
|
Some((value * 1000.0).round() as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preferred_encoding(headers: &HeaderMap, settings: &Settings) -> Option<AppliedEncoding> {
|
pub(crate) fn preferred_encoding(headers: &HeaderMap, settings: &Settings) -> Option<AppliedEncoding> {
|
||||||
let mut explicit = [None; 4];
|
let mut explicit = [None; 4];
|
||||||
let mut wildcard = None;
|
let mut wildcard = None;
|
||||||
for line in headers.get_all("accept-encoding").iter() {
|
for line in headers.get_all("accept-encoding").iter() {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Varnish-like HTTP BAN/PURGE on public vhost listeners. No new admin
|
||||||
|
// endpoint or session token: security rests on a strict, opt-in client IP/CIDR
|
||||||
|
// allowlist plus normal frontend/host ACLs and rate limits. Never forward.
|
||||||
|
fn cache_http_invalidation_method(method: &Method) -> bool {
|
||||||
|
method.as_str() == "BAN" || method.as_str() == "PURGE"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_http_invalidation_request(
|
||||||
|
method: &Method,
|
||||||
|
host: &str,
|
||||||
|
vhost: &str,
|
||||||
|
scheme: &str,
|
||||||
|
request: &Request,
|
||||||
|
) -> Result<crate::cache::InvalidationRequest, &'static str> {
|
||||||
|
// Allow a single explicit Content-Length: 0 (used by some PURGE clients),
|
||||||
|
// but reject ambiguous lengths and all transfer-encoded bodies.
|
||||||
|
let mut lengths = request.headers().get_all(http::header::CONTENT_LENGTH).iter();
|
||||||
|
if let Some(length) = lengths.next() {
|
||||||
|
if lengths.next().is_some() || length.as_bytes() != b"0" {
|
||||||
|
return Err("BAN/PURGE must not contain a request body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.headers().contains_key("upgrade")
|
||||||
|
|| request.headers().contains_key(http::header::TRANSFER_ENCODING)
|
||||||
|
|| !request.body().is_end_stream()
|
||||||
|
{
|
||||||
|
return Err("BAN/PURGE must not contain a request body or upgrade");
|
||||||
|
}
|
||||||
|
// Only this vhost and Host are permitted; no client-supplied cross-vhost
|
||||||
|
// selectors, regex, or arbitrary Cache-Tag from the public listener.
|
||||||
|
let target = request.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||||
|
if target.len() > 4096 || !target.starts_with('/') {
|
||||||
|
return Err("invalid BAN/PURGE target");
|
||||||
|
}
|
||||||
|
let operation = if method.as_str() == "BAN" {
|
||||||
|
crate::cache::InvalidationOperation::Ban
|
||||||
|
} else {
|
||||||
|
crate::cache::InvalidationOperation::Purge
|
||||||
|
};
|
||||||
|
let selector = if method.as_str() == "BAN" {
|
||||||
|
crate::cache::InvalidationSelector::Prefix
|
||||||
|
} else {
|
||||||
|
crate::cache::InvalidationSelector::Exact
|
||||||
|
};
|
||||||
|
// Never resolve ambiguous duplicate maintenance headers differently from
|
||||||
|
// intermediaries or audit logs. Reject them before selecting a target.
|
||||||
|
if request.headers().get_all("x-rustproxy-ban-ttl").iter().count() > 1
|
||||||
|
|| request.headers().get_all("x-rustproxy-confirm-wide").iter().count() > 1
|
||||||
|
{
|
||||||
|
return Err("duplicate BAN/PURGE control header");
|
||||||
|
}
|
||||||
|
let mut ttl_seconds = None;
|
||||||
|
if method.as_str() == "BAN" {
|
||||||
|
if let Some(value) = request.headers().get("x-rustproxy-ban-ttl") {
|
||||||
|
let seconds = value.to_str().ok().and_then(|v| v.parse::<u64>().ok())
|
||||||
|
.ok_or("invalid BAN TTL")?;
|
||||||
|
if !(1..=604_800).contains(&seconds) { return Err("BAN TTL must be 1..604800"); }
|
||||||
|
ttl_seconds = Some(seconds);
|
||||||
|
}
|
||||||
|
} else if request.headers().contains_key("x-rustproxy-ban-ttl") {
|
||||||
|
return Err("BAN TTL is only supported for BAN");
|
||||||
|
}
|
||||||
|
let confirm_wide = request.headers().get("x-rustproxy-confirm-wide")
|
||||||
|
.is_some_and(|value| value == "1");
|
||||||
|
if target == "/" && method.as_str() == "BAN" && !confirm_wide {
|
||||||
|
return Err("BAN / requires X-RustProxy-Confirm-Wide: 1");
|
||||||
|
}
|
||||||
|
if request.headers().contains_key("x-rustproxy-confirm-wide") && !confirm_wide {
|
||||||
|
return Err("invalid wide confirmation header");
|
||||||
|
}
|
||||||
|
if method.as_str() != "BAN" && request.headers().contains_key("x-rustproxy-confirm-wide") {
|
||||||
|
return Err("wide confirmation is only supported for BAN");
|
||||||
|
}
|
||||||
|
// Explicitly scope maintenance to the externally visible scheme. The
|
||||||
|
// ordinary /path matcher would also invalidate the opposite scheme on
|
||||||
|
// a vhost shared between HTTP and HTTPS.
|
||||||
|
if !matches!(scheme, "http" | "https") {
|
||||||
|
return Err("invalid request scheme");
|
||||||
|
}
|
||||||
|
let authority = if host.parse::<std::net::Ipv6Addr>().is_ok() {
|
||||||
|
format!("[{host}]")
|
||||||
|
} else {
|
||||||
|
host.to_owned()
|
||||||
|
};
|
||||||
|
Ok(crate::cache::InvalidationRequest {
|
||||||
|
operation, selector, value: format!("{scheme}://{authority}{target}"),
|
||||||
|
host: Some(host.to_owned()), vhost: Some(vhost.to_owned()),
|
||||||
|
ttl_seconds, confirm_wide,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cache_http_invalidation(
|
||||||
|
state: &Arc<App>,
|
||||||
|
snapshot: &crate::config::Snapshot,
|
||||||
|
frontend: Option<&Arc<crate::config::CompiledFrontend>>,
|
||||||
|
host: &str,
|
||||||
|
request: Request,
|
||||||
|
client_ip: std::net::IpAddr,
|
||||||
|
peer_ip: std::net::IpAddr,
|
||||||
|
scheme: &str,
|
||||||
|
) -> Response {
|
||||||
|
// Fail closed even when the vhost cache is disabled; never pass commands
|
||||||
|
// through to the backend, and never trust arbitrary X-Forwarded-For.
|
||||||
|
let Some(frontend) = frontend else {
|
||||||
|
return error(StatusCode::NOT_FOUND, "RustProxy: no matching vhost for cache invalidation\n");
|
||||||
|
};
|
||||||
|
// A trusted proxy missing a usable client chain must not inherit its own
|
||||||
|
// allowlist permission for every external visitor.
|
||||||
|
if snapshot.forwarding.trusted(peer_ip)
|
||||||
|
&& crate::vhost::canonical_ip(peer_ip) == client_ip
|
||||||
|
{
|
||||||
|
return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity\n");
|
||||||
|
}
|
||||||
|
if !snapshot.envelope.config.cache.permits_http_invalidation(client_ip) {
|
||||||
|
return error(StatusCode::FORBIDDEN, "RustProxy: BAN/PURGE client IP is not allowed\n");
|
||||||
|
}
|
||||||
|
let requested = match cache_http_invalidation_request(request.method(), host, &frontend.id, scheme, &request) {
|
||||||
|
Ok(requested) => requested,
|
||||||
|
Err(reason) => return error(StatusCode::BAD_REQUEST, reason),
|
||||||
|
};
|
||||||
|
// Axum's Request<Body> is Send but its Body is not Sync. Never keep
|
||||||
|
// a shared `&Request` inside the handler future across an .await.
|
||||||
|
// Release the unused body before the asynchronous audit/cache work.
|
||||||
|
let method = request.method().clone();
|
||||||
|
drop(request);
|
||||||
|
// Allocate the independent maintenance slot before even writing an audit
|
||||||
|
// row, so a burst from an allowed IP cannot pile up DB writes and workers.
|
||||||
|
let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else {
|
||||||
|
return error(StatusCode::TOO_MANY_REQUESTS, "RustProxy: cache maintenance busy\n");
|
||||||
|
};
|
||||||
|
let detail = format!(
|
||||||
|
"client_ip={client_ip}; method={}; host={host}; vhost={}; target={:?}",
|
||||||
|
method, frontend.id, requested.value
|
||||||
|
);
|
||||||
|
if let Err(audit_error) = state.store.audit(&format!("http:{client_ip}"), "cache.http_invalidate", &detail).await {
|
||||||
|
tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed");
|
||||||
|
return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: cache maintenance unavailable\n");
|
||||||
|
}
|
||||||
|
// Rate-limit the expensive maintenance path independently of the optional
|
||||||
|
// per-vhost traffic limiter. The permit lives inside the blocking worker
|
||||||
|
// even if a client disconnects while the cache scan is running.
|
||||||
|
let cache = state.cache.clone();
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
let _permit = permit;
|
||||||
|
cache.invalidate(requested)
|
||||||
|
}).await;
|
||||||
|
let result = match result {
|
||||||
|
Ok(Ok(result)) => result,
|
||||||
|
Ok(Err(invalid)) => {
|
||||||
|
tracing::warn!(reason = %invalid, "cache HTTP invalidation rejected");
|
||||||
|
return error(StatusCode::UNPROCESSABLE_ENTITY, "RustProxy: invalid cache maintenance request\n");
|
||||||
|
},
|
||||||
|
Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "RustProxy: cache maintenance failed\n"),
|
||||||
|
};
|
||||||
|
let code = if result.operation == crate::cache::InvalidationOperation::Ban {
|
||||||
|
StatusCode::ACCEPTED
|
||||||
|
} else { StatusCode::OK };
|
||||||
|
let mut response = (code, axum::Json(serde_json::json!({"result": result}))).into_response();
|
||||||
|
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
+18
-13
@@ -154,22 +154,24 @@ async fn forward_request(
|
|||||||
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
|
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(response) = frontend
|
if !cache_http_invalidation_method(request.method()) {
|
||||||
.and_then(|frontend| method_policy_denial(&frontend.method_policy, request.method()))
|
if let Some(response) = frontend
|
||||||
{
|
.and_then(|frontend| method_policy_denial(&frontend.method_policy, request.method()))
|
||||||
return response;
|
|
||||||
}
|
|
||||||
if let Some(response) = frontend.and_then(|frontend| {
|
|
||||||
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
|
|
||||||
}) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
if !tls {
|
|
||||||
if let Some(response) =
|
|
||||||
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
|
|
||||||
{
|
{
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
if let Some(response) = frontend.and_then(|frontend| {
|
||||||
|
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
|
||||||
|
}) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if !tls {
|
||||||
|
if let Some(response) = frontend.and_then(|frontend| {
|
||||||
|
force_ssl_redirect(frontend, &host, request.uri())
|
||||||
|
}) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let mut context = match snapshot.forwarding.context(
|
let mut context = match snapshot.forwarding.context(
|
||||||
request.headers(),
|
request.headers(),
|
||||||
@@ -267,6 +269,9 @@ async fn forward_request(
|
|||||||
"RustProxy: denied by access policy\n",
|
"RustProxy: denied by access policy\n",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if cache_http_invalidation_method(request.method()) {
|
||||||
|
return cache_http_invalidation(&state, &snapshot, frontend, &host, request, context.client_ip, peer.0.ip(), context.scheme).await;
|
||||||
|
}
|
||||||
if let Some((_redirect, rule)) = redirect {
|
if let Some((_redirect, rule)) = redirect {
|
||||||
let authority = original_host.to_str().unwrap_or(&host);
|
let authority = original_host.to_str().unwrap_or(&host);
|
||||||
let Some(mut response) = rule.response(authority, tls, request.uri()) else {
|
let Some(mut response) = rule.response(authority, tls, request.uri()) else {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
include!("types.rs");
|
include!("types.rs");
|
||||||
include!("error_fallback.rs");
|
include!("error_fallback.rs");
|
||||||
include!("cache_helpers.rs");
|
include!("cache_helpers.rs");
|
||||||
|
include!("cache_http_invalidation.rs");
|
||||||
include!("upstream_helpers.rs");
|
include!("upstream_helpers.rs");
|
||||||
include!("redirect_helpers.rs");
|
include!("redirect_helpers.rs");
|
||||||
include!("forward_entry.rs");
|
include!("forward_entry.rs");
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
include!("tests/authority_tests.rs");
|
include!("tests/authority_tests.rs");
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
include!("tests/http_invalidation_tests.rs");
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
mod http_invalidation_request_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn request(method: &str, target: &str) -> Request {
|
||||||
|
Request::builder().method(method).uri(target).body(Body::empty()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(method: &str, host: &str, scheme: &str, request: &Request)
|
||||||
|
-> Result<crate::cache::InvalidationRequest, &'static str>
|
||||||
|
{
|
||||||
|
cache_http_invalidation_request(
|
||||||
|
&Method::from_bytes(method.as_bytes()).unwrap(), host, "frontend-1", scheme, request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_purge_and_prefix_ban_are_scoped_to_current_host_vhost_and_scheme() {
|
||||||
|
let purge = request("PURGE", "/a?v=1");
|
||||||
|
let parsed = parse("PURGE", "a.example", "https", &purge).unwrap();
|
||||||
|
assert_eq!(parsed.selector, crate::cache::InvalidationSelector::Exact);
|
||||||
|
assert_eq!(parsed.host.as_deref(), Some("a.example"));
|
||||||
|
assert_eq!(parsed.vhost.as_deref(), Some("frontend-1"));
|
||||||
|
assert_eq!(parsed.value, "https://a.example/a?v=1");
|
||||||
|
|
||||||
|
let ban = parse("BAN", "a.example", "http", &request("BAN", "/assets/")).unwrap();
|
||||||
|
assert_eq!(ban.selector, crate::cache::InvalidationSelector::Prefix);
|
||||||
|
assert_eq!(ban.value, "http://a.example/assets/");
|
||||||
|
let ipv6 = parse("PURGE", "2001:db8::1", "https", &purge).unwrap();
|
||||||
|
assert_eq!(ipv6.value, "https://[2001:db8::1]/a?v=1");
|
||||||
|
assert!(parse("BAN", "a.example", "ftp", &request("BAN", "/assets/")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wide_ban_must_be_confirmed_and_ttl_is_bounded() {
|
||||||
|
assert!(parse("BAN", "a.example", "https", &request("BAN", "/")).is_err());
|
||||||
|
let mut wide = request("BAN", "/");
|
||||||
|
wide.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
|
||||||
|
assert!(parse("BAN", "a.example", "https", &wide).unwrap().confirm_wide);
|
||||||
|
let mut ttl = request("BAN", "/foo");
|
||||||
|
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "604801".parse().unwrap());
|
||||||
|
assert!(parse("BAN", "a.example", "https", &ttl).is_err());
|
||||||
|
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "30".parse().unwrap());
|
||||||
|
assert_eq!(parse("BAN", "a.example", "https", &ttl).unwrap().ttl_seconds, Some(30));
|
||||||
|
ttl.headers_mut().append("x-rustproxy-ban-ttl", "60".parse().unwrap());
|
||||||
|
assert!(parse("BAN", "a.example", "https", &ttl).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maintenance_rejects_bodies_and_ambiguous_or_unsupported_controls() {
|
||||||
|
let mut body = request("PURGE", "/foo");
|
||||||
|
body.headers_mut().insert(http::header::CONTENT_LENGTH, "99".parse().unwrap());
|
||||||
|
assert!(parse("PURGE", "a.example", "https", &body).is_err());
|
||||||
|
body.headers_mut().insert(http::header::CONTENT_LENGTH, "0".parse().unwrap());
|
||||||
|
assert!(parse("PURGE", "a.example", "https", &body).is_ok());
|
||||||
|
body.headers_mut().append(http::header::CONTENT_LENGTH, "0".parse().unwrap());
|
||||||
|
assert!(parse("PURGE", "a.example", "https", &body).is_err());
|
||||||
|
let mut transfer = request("PURGE", "/foo");
|
||||||
|
transfer.headers_mut().insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap());
|
||||||
|
assert!(parse("PURGE", "a.example", "https", &transfer).is_err());
|
||||||
|
let mut unexpected = request("PURGE", "/foo");
|
||||||
|
unexpected.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
|
||||||
|
assert!(parse("PURGE", "a.example", "https", &unexpected).is_err());
|
||||||
|
unexpected = request("BAN", "/foo");
|
||||||
|
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap());
|
||||||
|
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap());
|
||||||
|
assert!(parse("BAN", "a.example", "https", &unexpected).is_err());
|
||||||
|
assert!(parse("PURGE", "a.example", "https", &request("PURGE", &format!("/{}", "a".repeat(4097)))).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,7 @@ impl App {
|
|||||||
auth,
|
auth,
|
||||||
viewer_slots: Arc::new(Semaphore::new(8)),
|
viewer_slots: Arc::new(Semaphore::new(8)),
|
||||||
certificate_slots: Arc::new(Semaphore::new(2)),
|
certificate_slots: Arc::new(Semaphore::new(2)),
|
||||||
|
cache_invalidation_slots: Arc::new(Semaphore::new(2)),
|
||||||
metrics: Arc::new(Metrics::default()),
|
metrics: Arc::new(Metrics::default()),
|
||||||
transform_metrics: Arc::new(crate::transform::Metrics::default()),
|
transform_metrics: Arc::new(crate::transform::Metrics::default()),
|
||||||
traffic,
|
traffic,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ mod cache_revision_tests {
|
|||||||
wire_only.cache.expose_resident_age = !wire_only.cache.expose_resident_age;
|
wire_only.cache.expose_resident_age = !wire_only.cache.expose_resident_age;
|
||||||
wire_only.cache.expose_only_hits = !wire_only.cache.expose_only_hits;
|
wire_only.cache.expose_only_hits = !wire_only.cache.expose_only_hits;
|
||||||
wire_only.cache.via_header = Some("RustProxy edge".into());
|
wire_only.cache.via_header = Some("RustProxy edge".into());
|
||||||
|
wire_only.cache.http_invalidation_allowed_cidrs = vec!["192.0.2.42".into()];
|
||||||
assert_eq!(cache_revision(&wire_only), identity);
|
assert_eq!(cache_revision(&wire_only), identity);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
cache_invalidation(&base, &wire_only),
|
cache_invalidation(&base, &wire_only),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub struct App {
|
|||||||
pub auth: Arc<crate::auth::Auth>,
|
pub auth: Arc<crate::auth::Auth>,
|
||||||
pub viewer_slots: Arc<Semaphore>,
|
pub viewer_slots: Arc<Semaphore>,
|
||||||
pub certificate_slots: Arc<Semaphore>,
|
pub certificate_slots: Arc<Semaphore>,
|
||||||
|
pub cache_invalidation_slots: Arc<Semaphore>,
|
||||||
pub metrics: Arc<Metrics>,
|
pub metrics: Arc<Metrics>,
|
||||||
pub transform_metrics: Arc<crate::transform::Metrics>,
|
pub transform_metrics: Arc<crate::transform::Metrics>,
|
||||||
pub traffic: Arc<crate::traffic::Analytics>,
|
pub traffic: Arc<crate::traffic::Analytics>,
|
||||||
@@ -95,6 +96,8 @@ fn cache_revision(config: &config::Config) -> u64 {
|
|||||||
// edits; the new behavior applies immediately on the next request.
|
// edits; the new behavior applies immediately on the next request.
|
||||||
cache.bypass_query = false;
|
cache.bypass_query = false;
|
||||||
cache.bypass_prefixes.clear();
|
cache.bypass_prefixes.clear();
|
||||||
|
// Remote maintenance permissions cannot change stored object representations.
|
||||||
|
cache.http_invalidation_allowed_cidrs.clear();
|
||||||
cache.expose_status = false;
|
cache.expose_status = false;
|
||||||
cache.expose_hits = false;
|
cache.expose_hits = false;
|
||||||
cache.expose_cache_status = false;
|
cache.expose_cache_status = false;
|
||||||
|
|||||||
@@ -172,6 +172,20 @@ impl Http2Connections {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allowed encodings for the authenticated panel document. Codec preference is
|
||||||
|
/// zstd, then Brotli, then gzip for equal Accept-Encoding quality. These
|
||||||
|
/// settings are independent from downstream proxy response compression.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct PanelCompression {
|
||||||
|
pub zstd: bool,
|
||||||
|
pub br: bool,
|
||||||
|
pub gzip: bool,
|
||||||
|
}
|
||||||
|
impl Default for PanelCompression {
|
||||||
|
fn default() -> Self { Self { zstd: true, br: true, gzip: true } }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct Administration {
|
pub struct Administration {
|
||||||
@@ -185,6 +199,7 @@ pub struct Administration {
|
|||||||
pub allowed_hosts: Vec<String>,
|
pub allowed_hosts: Vec<String>,
|
||||||
/// Absolute browser-session lifetime. Existing sessions are bounded by the current value.
|
/// Absolute browser-session lifetime. Existing sessions are bounded by the current value.
|
||||||
pub session_days: u16,
|
pub session_days: u16,
|
||||||
|
pub panel_compression: PanelCompression,
|
||||||
}
|
}
|
||||||
impl Default for Administration {
|
impl Default for Administration {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -194,6 +209,7 @@ impl Default for Administration {
|
|||||||
certificate_id: None,
|
certificate_id: None,
|
||||||
allowed_hosts: Vec::new(),
|
allowed_hosts: Vec::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
|
panel_compression: PanelCompression::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/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: 0.31.0 admin/HTTP maintenance security source contracts; scanned {len(js_files)} first-party JS files. Actual Rust/wire behavior not tested.')
|
||||||
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
|
|||||||
|
|
||||||
preflight(root)
|
preflight(root)
|
||||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||||
assert manifest["package"]["version"] == "0.30.1"
|
assert manifest["package"]["version"] == "0.31.0"
|
||||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||||
h3dep = manifest["dependencies"]["h3"]
|
h3dep = manifest["dependencies"]["h3"]
|
||||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"]
|
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"]
|
||||||
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
|||||||
lock_path = root / "Cargo.lock"
|
lock_path = root / "Cargo.lock"
|
||||||
if lock_path.exists():
|
if lock_path.exists():
|
||||||
lock = lock_path.read_text()
|
lock = lock_path.read_text()
|
||||||
assert 'name = "rust-proxy"\nversion = "0.30.1"' in lock
|
assert 'name = "rust-proxy"\nversion = "0.31.0"' in lock
|
||||||
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
||||||
for release_doc, marker in {
|
for release_doc, marker in {
|
||||||
"README.md": "# RustProxy 0.30.1",
|
"README.md": "# RustProxy 0.31.0",
|
||||||
"CHANGELOG.md": "## 0.30.1",
|
"CHANGELOG.md": "## 0.31.0",
|
||||||
"ROADMAP.md": "## 0.30.1",
|
"ROADMAP.md": "## 0.31.0",
|
||||||
"AGENTS.md": "Current source version: 0.30.1.",
|
"AGENTS.md": "Current source version: 0.31.0.",
|
||||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.30.1",
|
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.0",
|
||||||
}.items():
|
}.items():
|
||||||
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
||||||
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
||||||
@@ -162,7 +162,7 @@ assert 'class="vhost-dialog logging-format-dialog"' in html
|
|||||||
assert 'data-panel="streams"' in html and 'id="panel-streams"' in html
|
assert 'data-panel="streams"' in html and 'id="panel-streams"' in html
|
||||||
for marker in ['id="vhost-sort"', 'id="vhost-sort-direction"', 'id="docker-vhost-sort"', 'id="docker-vhost-sort-direction"', 'id="redirect-sort"', 'id="redirect-sort-direction"', 'id="notfound-sort"', 'id="notfound-sort-direction"', 'id="stream-sort"', 'id="stream-sort-direction"']:
|
for marker in ['id="vhost-sort"', 'id="vhost-sort-direction"', 'id="docker-vhost-sort"', 'id="docker-vhost-sort-direction"', 'id="redirect-sort"', 'id="redirect-sort-direction"', 'id="notfound-sort"', 'id="notfound-sort-direction"', 'id="stream-sort"', 'id="stream-sort-direction"']:
|
||||||
assert marker in html, marker
|
assert marker in html, marker
|
||||||
preferences_source = (root / "src/admin/preferences.rs").read_text(encoding="utf-8")
|
preferences_source = rust_source(root, "src/admin/preferences.rs")
|
||||||
for marker in ['vhost_list_sort', 'docker_vhost_sort', 'redirect_list_sort', 'notfound_list_sort', 'stream_list_sort', 'VHOST_LIST_SORT_COLUMNS', 'DOCKER_VHOST_SORT_COLUMNS', 'REDIRECT_LIST_SORT_COLUMNS', 'NOTFOUND_LIST_SORT_COLUMNS', 'STREAM_LIST_SORT_COLUMNS']:
|
for marker in ['vhost_list_sort', 'docker_vhost_sort', 'redirect_list_sort', 'notfound_list_sort', 'stream_list_sort', 'VHOST_LIST_SORT_COLUMNS', 'DOCKER_VHOST_SORT_COLUMNS', 'REDIRECT_LIST_SORT_COLUMNS', 'NOTFOUND_LIST_SORT_COLUMNS', 'STREAM_LIST_SORT_COLUMNS']:
|
||||||
assert marker in preferences_source, marker
|
assert marker in preferences_source, marker
|
||||||
for source_name, markers in {
|
for source_name, markers in {
|
||||||
@@ -251,7 +251,7 @@ for marker in ['vhBackendDiagnostics', 'backend_header: yes("backend-header")',
|
|||||||
assert 'cache: { enabled: false }' in vhosts_js, "new vhost drafts must default cache off"
|
assert 'cache: { enabled: false }' in vhosts_js, "new vhost drafts must default cache off"
|
||||||
docker_js = (root / "src/admin/web/docker.js").read_text()
|
docker_js = (root / "src/admin/web/docker.js").read_text()
|
||||||
assert 'preserve.checked = assignment?.preserve_host !== false' in docker_js, "new Docker backends must preserve public Host by default"
|
assert 'preserve.checked = assignment?.preserve_host !== false' in docker_js, "new Docker backends must preserve public Host by default"
|
||||||
for marker in ['saveSection("docker-agents"', '/api/docker/refresh', 'docker-publish', 'docker-hostname', 'host-chip-editor docker-alias-editor', 'docker-target-ip', 'default_target_ip', 'docker-agent-fingerprint', 'docker-agent-certificate', 'docker-health-mode', 'docker-passive-enabled', 'docker-passive-5xx', 'docker-search', 'docker-show-hidden', 'dockerHideVhost', 'pool_routes', 'setInterval']:
|
for marker in ['saveSection("docker-agents"', '/api/docker/refresh', 'docker-publish', 'docker-hostname', 'host-chip-editor docker-alias-editor', 'docker-target-ip', 'default_target_ip', 'docker-agent-fingerprint', 'docker-agent-certificate', 'docker-health-mode', 'docker-passive-enabled', 'docker-passive-5xx', 'docker-search', 'docker-show-hidden', 'dockerHideVhost', 'pool_routes', 'rustProxyRefresh.register("docker"']:
|
||||||
assert marker in docker_js, marker
|
assert marker in docker_js, marker
|
||||||
docker_source = rust_source(root, "src/docker.rs")
|
docker_source = rust_source(root, "src/docker.rs")
|
||||||
for marker in ['pub struct Settings', 'pub struct Observation', 'pub struct HostAddress', 'pub struct Binding', 'aliases: Vec<String>', 'target_ip: Option<IpAddr>', 'tls_fingerprint_sha256', 'certificate_id: Option<String>', 'health_check: crate::balance::HealthCheck', 'passive_health: crate::balance::PassiveHealth', 'PinnedCertificateVerifier', 'fingerprint mismatch', 'effective_config', 'poll_loop', 'address_allowlist']:
|
for marker in ['pub struct Settings', 'pub struct Observation', 'pub struct HostAddress', 'pub struct Binding', 'aliases: Vec<String>', 'target_ip: Option<IpAddr>', 'tls_fingerprint_sha256', 'certificate_id: Option<String>', 'health_check: crate::balance::HealthCheck', 'passive_health: crate::balance::PassiveHealth', 'PinnedCertificateVerifier', 'fingerprint mismatch', 'effective_config', 'poll_loop', 'address_allowlist']:
|
||||||
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
|
|||||||
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
||||||
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
||||||
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
||||||
for marker in ['"version": "0.30.1"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
for marker in ['"version": "0.31.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||||
assert marker in openapi, marker
|
assert marker in openapi, marker
|
||||||
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||||
assert marker in openapi, marker
|
assert marker in openapi, marker
|
||||||
@@ -1150,7 +1150,7 @@ assert 'transport-action' not in html and '.transport-grid .transport-action' no
|
|||||||
assert '#panel-cache .cache-copy-cell' in style_source and 'cursor: copy' in style_source
|
assert '#panel-cache .cache-copy-cell' in style_source and 'cursor: copy' in style_source
|
||||||
assert '.operation-message' in style_source and '.operation-message.error' in style_source
|
assert '.operation-message' in style_source and '.operation-message.error' in style_source
|
||||||
for marker_0221 in [
|
for marker_0221 in [
|
||||||
'api("/api/tunnels")', 'tunnel-kill-reason', 'tunnelKillReasonDefault',
|
'api("/api/tunnels", context?.signal ? { signal: context.signal } : {})', 'tunnel-kill-reason', 'tunnelKillReasonDefault',
|
||||||
'api(`/api/tunnels/${encodeURIComponent(item.id)}/kill`', 'startTunnelRefresh()',
|
'api(`/api/tunnels/${encodeURIComponent(item.id)}/kill`', 'startTunnelRefresh()',
|
||||||
]:
|
]:
|
||||||
assert marker_0221 in app_js, marker_0221
|
assert marker_0221 in app_js, marker_0221
|
||||||
@@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
|
|||||||
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
||||||
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
||||||
assert 'location.pathname === "/logging"' in settings_js_0241
|
assert 'location.pathname === "/logging"' in settings_js_0241
|
||||||
assert '"RuntimeInformation"' in openapi and '"version": "0.30.1"' in openapi
|
assert '"RuntimeInformation"' in openapi and '"version": "0.31.0"' in openapi
|
||||||
|
|
||||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||||
tls_0241 = rust_source(root, "src/tls.rs")
|
tls_0241 = rust_source(root, "src/tls.rs")
|
||||||
@@ -1635,5 +1635,5 @@ for marker_0282 in [
|
|||||||
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
|
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
|
||||||
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text()
|
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text()
|
||||||
|
|
||||||
print("PASS: 0.30.1 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
|
print("PASS: 0.31.0 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
|
||||||
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
|
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Network-independent 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');
|
||||||
|
|
||||||
|
assert.match(html, /<html class="app-loading"/);
|
||||||
|
assert.match(html, /id="app-startup"[^>]*role="status"/);
|
||||||
|
assert.match(html, /id="app-startup-title"/);
|
||||||
|
assert.match(html, /class="app-startup-spinner"/);
|
||||||
|
assert.match(html, /class="app-startup-recovery"/);
|
||||||
|
assert.match(html, /class="outline" href="" data-i18n="startupRetry"/);
|
||||||
|
assert.match(html, /<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 = core.slice(core.indexOf('function showStartupFailure(error)'));
|
||||||
|
assert(activateSource.startsWith('async function activate(info)'));
|
||||||
|
assert(failureSource.startsWith('function showStartupFailure(error)'));
|
||||||
|
|
||||||
|
const nodes = new Map();
|
||||||
|
const node = key => {
|
||||||
|
if (!nodes.has(key)) nodes.set(key, { hidden: true, value: 'x', textContent: '' });
|
||||||
|
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); },
|
||||||
|
} } },
|
||||||
|
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: {}, 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.');
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Rust-independent documentation/Reader consistency checks for the 0.30.1 release."""
|
"""Rust-independent documentation/Reader consistency checks for the 0.31.0 release."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
from rust_source import rust_source
|
from rust_source import rust_source
|
||||||
@@ -29,6 +29,7 @@ required = [
|
|||||||
DOCS / "V0.25.2_AUDIT.md",
|
DOCS / "V0.25.2_AUDIT.md",
|
||||||
DOCS / "V0.25.1_AUDIT.md",
|
DOCS / "V0.25.1_AUDIT.md",
|
||||||
DOCS / "V0.25.0_AUDIT.md",
|
DOCS / "V0.25.0_AUDIT.md",
|
||||||
|
DOCS / "IMPLEMENTATION_0.31.0.md",
|
||||||
DOCS / "TEST_REPORT.md",
|
DOCS / "TEST_REPORT.md",
|
||||||
DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md",
|
DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md",
|
||||||
DOCS / "ROADMAP_HISTORY.md",
|
DOCS / "ROADMAP_HISTORY.md",
|
||||||
@@ -64,7 +65,7 @@ assert '"/docs/content.json"' in admin
|
|||||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||||
assert readme.startswith("# RustProxy 0.30.1")
|
assert readme.startswith("# RustProxy 0.31.0")
|
||||||
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
||||||
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
||||||
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/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']) {
|
||||||
|
assert(router.includes(`"/assets/${path}", get(panel_assets::serve)`), 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);
|
||||||
|
runInNewContext(administration.slice(adminStart, adminEnd), {
|
||||||
|
$: field, bindSettingsForm, latestStatus: { administration: { listen_locked: false } },
|
||||||
|
});
|
||||||
|
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-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');
|
||||||
@@ -75,10 +75,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
|||||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
||||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||||
if not (root / path).is_file():
|
if not (root / path).is_file():
|
||||||
raise ValueError(f"Incomplete RustProxy 0.30.1 source tree: missing {path}")
|
raise ValueError(f"Incomplete RustProxy 0.31.0 source tree: missing {path}")
|
||||||
text = (root / "src/lib.rs").read_text(encoding="utf-8")
|
text = (root / "src/lib.rs").read_text(encoding="utf-8")
|
||||||
if '#[path = "auth/mod.rs"]' not in text:
|
if '#[path = "auth/mod.rs"]' not in text:
|
||||||
raise ValueError("src/lib.rs is not the 0.30.1 entry point; extract the complete package, not individual files.")
|
raise ValueError("src/lib.rs is not the 0.31.0 entry point; extract the complete package, not individual files.")
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#!/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(), 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 } },
|
||||||
|
$: 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 () => undefined,
|
||||||
|
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, 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, 2);
|
||||||
|
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=2");
|
||||||
|
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();
|
||||||
|
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.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, 2);
|
||||||
|
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.");
|
||||||
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
|||||||
assert backup.is_dir()
|
assert backup.is_dir()
|
||||||
assert (backup / "src/obsolete.rs").is_file()
|
assert (backup / "src/obsolete.rs").is_file()
|
||||||
assert not (target / "src/obsolete.rs").exists()
|
assert not (target / "src/obsolete.rs").exists()
|
||||||
assert 'version = "0.30.1"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
assert 'version = "0.31.0"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||||
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
||||||
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
||||||
assert (target / "langs/site.json").is_file()
|
assert (target / "langs/site.json").is_file()
|
||||||
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
|||||||
assert (target / "docker-agent/certificate.go").is_file()
|
assert (target / "docker-agent/certificate.go").is_file()
|
||||||
assert (target / "docker-agent/server.go").is_file()
|
assert (target / "docker-agent/server.go").is_file()
|
||||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||||
assert manifest["to_version"] == "0.30.1"
|
assert manifest["to_version"] == "0.31.0"
|
||||||
assert manifest["removed_obsolete_runtime_state"] is True
|
assert manifest["removed_obsolete_runtime_state"] is True
|
||||||
assert not legacy.exists()
|
assert not legacy.exists()
|
||||||
assert not (backup / "legacy-data").exists()
|
assert not (backup / "legacy-data").exists()
|
||||||
@@ -357,7 +357,7 @@ def ui_contract_smoke() -> None:
|
|||||||
assert '/api/traffic' in traffic and 'rustProxyTrafficPanel' in traffic
|
assert '/api/traffic' in traffic and 'rustProxyTrafficPanel' in traffic
|
||||||
for marker in ['function selectTab(', 'function renderDonut(', 'function renderBars(', 'renderBars("client-ips"', 'renderBars("user-agents"', 'renderBars("paths"', 'renderBars("routes"', 'renderBars("volume"']:
|
for marker in ['function selectTab(', 'function renderDonut(', 'function renderBars(', 'renderBars("client-ips"', 'renderBars("user-agents"', 'renderBars("paths"', 'renderBars("routes"', 'renderBars("volume"']:
|
||||||
assert marker in traffic, marker
|
assert marker in traffic, marker
|
||||||
assert 'traffic: "/traffic"' in app and 'window.rustProxyTrafficPanel?.refresh()' in app
|
assert 'traffic: "/traffic"' in app and 'window.rustProxyRefresh?.setPanel(name)' in app
|
||||||
for marker in ['id="settings-tab-manual-config"', 'id="settings-page-manual-config"', 'id="config-editor"', 'id="save-json"', 'id="export-config"']:
|
for marker in ['id="settings-tab-manual-config"', 'id="settings-page-manual-config"', 'id="config-editor"', 'id="save-json"', 'id="export-config"']:
|
||||||
assert marker in html, marker
|
assert marker in html, marker
|
||||||
assert 'data-panel="configuration"' not in html and 'id="panel-configuration"' not in html
|
assert 'data-panel="configuration"' not in html and 'id="panel-configuration"' not in html
|
||||||
@@ -619,7 +619,7 @@ def source_contract_smoke() -> None:
|
|||||||
assert marker in admin, marker
|
assert marker in admin, marker
|
||||||
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
||||||
assert '"/api/status"' not in anonymous_block
|
assert '"/api/status"' not in anonymous_block
|
||||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.30.1"' in openapi_0241
|
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.0"' in openapi_0241
|
||||||
assert 'id="panel-logging"' not in html
|
assert 'id="panel-logging"' not in html
|
||||||
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
||||||
assert marker in html, marker
|
assert marker in html, marker
|
||||||
@@ -726,7 +726,7 @@ def source_contract_smoke() -> None:
|
|||||||
assert 'values.length ? values : [{ id: "root"' not in vhosts_js
|
assert 'values.length ? values : [{ id: "root"' not in vhosts_js
|
||||||
for marker in ["PinnedCertificateVerifier", "tls_fingerprint_sha256", "health_check: crate::balance::HealthCheck", "certificate fingerprint mismatch", "generated_id"]:
|
for marker in ["PinnedCertificateVerifier", "tls_fingerprint_sha256", "health_check: crate::balance::HealthCheck", "certificate fingerprint mismatch", "generated_id"]:
|
||||||
assert marker in docker_source, marker
|
assert marker in docker_source, marker
|
||||||
for marker in ["docker-agent-fingerprint", "docker-agent-certificate", "docker-health-mode", "docker-search", "docker-show-hidden", "pool_routes", "dockerBackendUp", "setInterval"]:
|
for marker in ["docker-agent-fingerprint", "docker-agent-certificate", "docker-health-mode", "docker-search", "docker-show-hidden", "pool_routes", "dockerBackendUp", 'rustProxyRefresh.register("docker"']:
|
||||||
assert marker in docker_js, marker
|
assert marker in docker_js, marker
|
||||||
assert 'function checkboxField(key, control)' in docker_js
|
assert 'function checkboxField(key, control)' in docker_js
|
||||||
assert 'checkboxField("dockerPreserveHost", preserve)' in docker_js and 'checkboxField("dockerAclInline", inline)' in docker_js
|
assert 'checkboxField("dockerPreserveHost", preserve)' in docker_js and 'checkboxField("dockerAclInline", inline)' in docker_js
|
||||||
@@ -972,8 +972,9 @@ def source_contract_smoke() -> None:
|
|||||||
assert marker in proxy, marker
|
assert marker in proxy, marker
|
||||||
assert proxy.index("RustProxy: denied by access policy") < proxy.index("if let Some(spec) = &route.response") < proxy.index("let (backend, backend_lease, affinity_cookie)")
|
assert proxy.index("RustProxy: denied by access policy") < proxy.index("if let Some(spec) = &route.response") < proxy.index("let (backend, backend_lease, affinity_cookie)")
|
||||||
assert "pub client_ip: Option<String>" in logging and "client_ip" in logging
|
assert "pub client_ip: Option<String>" in logging and "client_ip" in logging
|
||||||
assert '"metrics": stream_metrics' in admin and '"/assets/streams.js"' in admin and 'include_str!("web/streams.js")' in admin
|
admin_assets = (ROOT / "src/admin/panel_assets.rs").read_text(encoding="utf-8")
|
||||||
assert '.route("/404-hosts", get(public::index))' in admin and '"/assets/host404.js"' in admin and 'include_str!("web/host404.js")' in admin and '"not_found_hosts"' in admin
|
assert '"metrics": stream_metrics' in admin and '"/assets/streams.js"' in admin and 'include_str!("web/streams.js")' in admin_assets
|
||||||
|
assert '.route("/404-hosts", get(public::index))' in admin and '"/assets/host404.js"' in admin and 'include_str!("web/host404.js")' in admin_assets and '"not_found_hosts"' in admin
|
||||||
assert "session_at_with_browser_lifetime" in admin and "session_lifetime_seconds" in admin
|
assert "session_at_with_browser_lifetime" in admin and "session_lifetime_seconds" in admin
|
||||||
assert '/api/preferences' in admin and 'live_log_columns' in admin
|
assert '/api/preferences' in admin and 'live_log_columns' in admin
|
||||||
assert 'default_admin_theme' in admin and '"dark" | "light"' in admin
|
assert 'default_admin_theme' in admin and '"dark" | "light"' in admin
|
||||||
@@ -990,7 +991,7 @@ def source_contract_smoke() -> None:
|
|||||||
tls_source = rust_source(ROOT, "src/tls.rs")
|
tls_source = rust_source(ROOT, "src/tls.rs")
|
||||||
for marker in ["CertificateMetadata", "not_before_unix", "not_after_unix", "days_remaining", "expiry_status"]:
|
for marker in ["CertificateMetadata", "not_before_unix", "not_after_unix", "days_remaining", "expiry_status"]:
|
||||||
assert marker in tls_source, marker
|
assert marker in tls_source, marker
|
||||||
assert '"/assets/redirects.js"' in admin and 'include_str!("web/redirects.js")' in admin and '.route("/redirects", get(public::index))' in admin
|
assert '"/assets/redirects.js"' in admin and 'include_str!("web/redirects.js")' in admin_assets and '.route("/redirects", get(public::index))' in admin
|
||||||
assert '/Ubuntu%2014.04%20LTS/' in acl and 'repeated_slashes_are_normalized_for_routing' in acl and 'percent_encoded_spaces_are_valid_request_paths' in policies and 'repeated_slashes_route_normally_but_reach_upstream_unchanged' in policies and 'local_location_response_uses_response_rules_without_opening_backend' in policies and 'local_location_response_still_respects_route_acl' in policies
|
assert '/Ubuntu%2014.04%20LTS/' in acl and 'repeated_slashes_are_normalized_for_routing' in acl and 'percent_encoded_spaces_are_valid_request_paths' in policies and 'repeated_slashes_route_normally_but_reach_upstream_unchanged' in policies and 'local_location_response_uses_response_rules_without_opening_backend' in policies and 'local_location_response_still_respects_route_acl' in policies
|
||||||
db_source = rust_source(ROOT, "src/db.rs")
|
db_source = rust_source(ROOT, "src/db.rs")
|
||||||
acme_source = rust_source(ROOT, "src/acme.rs")
|
acme_source = rust_source(ROOT, "src/acme.rs")
|
||||||
@@ -1109,7 +1110,7 @@ def source_contract_smoke() -> None:
|
|||||||
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
||||||
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
||||||
assert '"/api/config/history/restore"' in openapi
|
assert '"/api/config/history/restore"' in openapi
|
||||||
for marker in ['"version": "0.30.1"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
for marker in ['"version": "0.31.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||||
assert marker in openapi, marker
|
assert marker in openapi, marker
|
||||||
assert '"software_update"' in openapi and '"update_available"' in openapi
|
assert '"software_update"' in openapi and '"update_available"' in openapi
|
||||||
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
||||||
@@ -1314,7 +1315,7 @@ def main() -> None:
|
|||||||
source_contract_smoke()
|
source_contract_smoke()
|
||||||
container_contract_smoke()
|
container_contract_smoke()
|
||||||
grafana_contract_smoke()
|
grafana_contract_smoke()
|
||||||
print("PASS: 0.30.1 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
print("PASS: 0.31.0 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {
|
|||||||
const elements = new Map();
|
const elements = new Map();
|
||||||
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
|
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
|
||||||
const status = {
|
const status = {
|
||||||
version: '0.30.1', uptime_seconds: 4201,
|
version: '0.31.0', uptime_seconds: 4201,
|
||||||
runtime: {
|
runtime: {
|
||||||
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
|
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
|
||||||
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
|
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
|
||||||
@@ -63,7 +63,7 @@ const context = {
|
|||||||
vm.runInNewContext(code, context);
|
vm.runInNewContext(code, context);
|
||||||
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
|
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
|
||||||
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
|
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
|
||||||
assert.equal(dollar('runtime-version').textContent, '0.30.1');
|
assert.equal(dollar('runtime-version').textContent, '0.31.0');
|
||||||
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
|
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
|
||||||
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
|
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
|
||||||
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
|
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
|
||||||
@@ -124,4 +124,4 @@ for (const key of ['python_version','pip_version','certbot_version']) {
|
|||||||
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
|
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
|
||||||
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
|
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
|
||||||
}
|
}
|
||||||
console.log('PASS: 0.30.1 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
console.log('PASS: 0.31.0 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"""Offline source guard for subprocess-free system information; not a Rust runtime test."""
|
"""Offline source guard for subprocess-free system information; not a Rust runtime test."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tomllib
|
import tomllib
|
||||||
|
from rust_source import rust_source
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
||||||
@@ -13,7 +14,7 @@ assert {'network', 'system'} <= set(sysinfo_features), (
|
|||||||
)
|
)
|
||||||
assert any('systemstat' in target.get('dependencies', {}) for target in manifest.get('target', {}).values())
|
assert any('systemstat' in target.get('dependencies', {}) for target in manifest.get('target', {}).values())
|
||||||
|
|
||||||
status = (ROOT / 'src/admin/status.rs').read_text()
|
status = rust_source(ROOT, 'src/admin/status.rs')
|
||||||
assert 'sysinfo::System::long_os_version()' in status
|
assert 'sysinfo::System::long_os_version()' in status
|
||||||
assert 'sysinfo::System::kernel_version()' in status
|
assert 'sysinfo::System::kernel_version()' in status
|
||||||
assert 'std::sync::OnceLock' in status
|
assert 'std::sync::OnceLock' in status
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ for name in ('src/main/bootstrap.rs','src/main/tasks.rs','src/web_server/fastcgi
|
|||||||
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/fastcgi/protocol.rs').read_text()
|
||||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/response/file.rs').read_text()
|
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/web_server/response/file.rs').read_text()
|
||||||
assert '__RUSTPROXY_VERSION__' in (root/'src/admin/web/index.html').read_text()
|
assert '__RUSTPROXY_VERSION__' in (root/'src/admin/web/index.html').read_text()
|
||||||
assert 'OnceLock<String>' in (root/'src/admin/public.rs').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 'include_str!("web/index.html")' in (root/'src/admin/public.rs').read_text()
|
||||||
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/admin/public.rs').read_text()
|
assert 'env!("CARGO_PKG_VERSION")' in (root/'src/admin/public.rs').read_text()
|
||||||
assert 'admin_index.html' not in (root/'build.rs').read_text()
|
assert 'admin_index.html' not in (root/'build.rs').read_text()
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def run_load(
|
|||||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
tls = ssl._create_unverified_context() if insecure else ssl.create_default_context()
|
tls = ssl._create_unverified_context() if insecure else ssl.create_default_context()
|
||||||
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
||||||
headers = {"user-agent": "RustProxy-Memory-Stress/0.30.0", "accept": "*/*"}
|
headers = {"user-agent": "RustProxy-Memory-Stress/0.31.0", "accept": "*/*"}
|
||||||
if host_header:
|
if host_header:
|
||||||
headers["host"] = host_header
|
headers["host"] = host_header
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user