v0.32.0
This commit is contained in:
1 parent
d23fa5e154
commit
81ab39e70a
51 files changed
+4543
-680
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.31.9.
|
compatibility. Current source version: 0.32.0.
|
||||||
|
|
||||||
## Delivery
|
## Delivery
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,34 @@
|
|||||||
> Operator-facing release history. Historical engineering notes are kept in
|
> Operator-facing release history. Historical engineering notes are kept in
|
||||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||||
|
|
||||||
|
## 0.32.0
|
||||||
|
|
||||||
|
- Extend Stream TCP/UDP listeners with explicit per-host backend pools (up to 32), legacy single-upstream fallback, stable backend IDs, weight, enabled, drain, session caps and weighted Round Robin / Least Connections or Sessions selection. Keep TCP/UDP affinity for each session and release backend gauges via RAII.
|
||||||
|
- Add optional TCP Connect and conservative UDP response-matched probes with configurable interval, timeout and rise/fall, globally bounded concurrency and cancellation. Never label an unverified UDP connect as Healthy.
|
||||||
|
- Add bounded per-backend observations to existing status/SSE and opt-in detailed Prometheus metrics; preserve global `include_streams`, stream-level basic counters and traffic direction meanings.
|
||||||
|
- Extend existing Appearance dashboard card visibility/order with four opt-in Stream cards. Reuse account preferences, existing status updates, translations, style/responsive layout and in-memory browser charts. Migrate older 12-widget layouts to hidden Stream cards.
|
||||||
|
- Update Streams editor, OpenAPI, Settings documentation and regression coverage. No new public routes, scheduler or database format. Backend changes remain restart-required. **Cargo and live TCP/UDP integration gate remain open** until verified.
|
||||||
|
|
||||||
|
### 0.32.0 Overview layout and Vhosts hint correction
|
||||||
|
|
||||||
|
- Restore permanent inline secondary labels and legends on the main Overview tiles, without hover/focus tooltip behavior or extra keyboard stops. The follow-up restores the **original six desktop columns** (not four), with taller tiles for their descriptions; retain responsive grid and dashboard density options. Stream traffic uses the existing RX/TX chart with an inline colored legend.
|
||||||
|
- Keep hover/focus help in **Settings -> Appearance** unchanged; dashboard card visibility, preferences and polling are not modified.
|
||||||
|
- Remove the redundant *Controls whether this vhost accepts traffic* helper text in the Vhost add/edit form. Keep the enabled checkbox, state badge and enabled behavior unchanged.
|
||||||
|
|
||||||
|
### 0.32.0 Appearance / Stream editor hotfix
|
||||||
|
|
||||||
|
- Make the four Stream Overview card toggles authoritative even without active or configured listeners; render a neutral no-data state instead of silently hiding a user-enabled card. Preserve opt-in defaults, cached status rendering, existing polling and all legacy widgets.
|
||||||
|
- Move Appearance Stream card descriptions into hover/focus tooltips (with `aria-describedby`) without changing ordering, control behavior or keyboard accessibility.
|
||||||
|
- Remove only the redundant enable-control helper hint from the Stream editor. Label detailed metrics precisely: Stream null means default disabled; backend null uses the parent Stream setting; global Prometheus `include_streams` remains the export gate.
|
||||||
|
- Bundle `scripts/start_stream_backends.sh`, `scripts/test_stream_tcp.py` and `scripts/test_stream_udp.py` directly under `scripts/` and update test instructions.
|
||||||
|
|
||||||
|
### 0.32.0 follow-up fixes
|
||||||
|
|
||||||
|
- Correct the Appearance/Overview visibility synchronization: immediately apply the newly saved (rather than stale) hidden-widget list, repaint cached Stream totals after an opt-in and on return to Overview, without an extra request, rate sample or timer. A subsequent hotfix removes the no-Stream visibility restriction in favor of an explicit neutral state.
|
||||||
|
- Show secondary Overview-card details as focus/hover tips on pointer devices while retaining on-screen details on touch devices; keyboard focus is supported.
|
||||||
|
- Rename the atomic reserve call from deprecated `fetch_update` to `try_update` without changing the capacity logic or memory ordering.
|
||||||
|
- Include independent mock-backed TCP and UDP black-box test scripts, executable against configured RustProxy listeners, plus harness-only selftests.
|
||||||
|
|
||||||
## 0.31.9
|
## 0.31.9
|
||||||
|
|
||||||
- Add per-credential **View settings**, **Edit settings** and **Regenerate token** actions to Settings → Certificate API. Read and edit return non-secret metadata only; Key ID remains stable, and editing comment/certificate allowlist/deploy permission does not rotate the existing bearer token.
|
- Add per-credential **View settings**, **Edit settings** and **Regenerate token** actions to Settings → Certificate API. Read and edit return non-secret metadata only; Key ID remains stable, and editing comment/certificate allowlist/deploy permission does not rotate the existing bearer token.
|
||||||
|
|||||||
Generated
+1
-1
@@ -1881,7 +1881,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-proxy"
|
name = "rust-proxy"
|
||||||
version = "0.31.9"
|
version = "0.32.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.31.9"
|
version = "0.32.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.99"
|
rust-version = "1.99"
|
||||||
description = "RustProxy - independent reverse proxy with local administration"
|
description = "RustProxy - independent reverse proxy with local administration"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# RustProxy 0.31.9
|
# RustProxy 0.32.0
|
||||||
|
|
||||||
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
|
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
|
||||||
|
|
||||||
@@ -7,13 +7,24 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos
|
|||||||
|
|
||||||
**0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.**
|
**0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.**
|
||||||
|
|
||||||
|
## What changed in 0.32.0
|
||||||
|
|
||||||
|
**Dashboard follow-up:** saved opt-in cards show on Overview even without active Streams (neutral no-data state). Appearance card-setting explanations remain on hover/focus; Overview metrics and legends are permanently visible inside the original six-column desktop grid, with taller tiles and no hover popovers. Stream editor explains the detailed-metrics inheritance and omits the redundant enable hint. `scripts/start_stream_backends.sh`, `scripts/test_stream_tcp.py` and `scripts/test_stream_udp.py` provide runnable mock peers and separate black-box tests. See [STREAMS.md](docs/STREAMS.md).
|
||||||
|
|
||||||
|
- TCP and UDP Stream hosts support optional pools of up to 32 IP:port backends with weighted Round Robin and weighted Least Connections/Sessions, stable backend IDs, session caps, enabled/drain flags and per-session backend affinity (including UDP).
|
||||||
|
- Optional bounded TCP Connect checks with rise/fall thresholds, and explicit UDP probes with exact response matching. UDP connect without a verified response is **Unverified**, not Healthy. Health checks are canceled on listener shutdown and globally bounded to 16 concurrent probes.
|
||||||
|
- Existing per-Stream counters, status snapshot, Prometheus `include_streams` and RX/TX charts are retained. Opt-in backend detail counters and health observations use bounded config IDs only.
|
||||||
|
- **Settings → Appearance → Dashboard layout** adds Stream overview, traffic, sessions and backend-health cards. These use the existing preference persistence, layout, status updates and SSE/polling; all new cards are hidden by default for existing and fresh accounts. No UI action restarts the proxy.
|
||||||
|
- Old single-`upstream` configs remain valid, with an implicit backend `primary`. Backend pool edits still follow the existing Stream restart-required lifecycle. See [Streams](docs/STREAMS.md).
|
||||||
|
- **Release acceptance remains open** until Cargo check/tests and live TCP/UDP/health checks are exercised on a Rust toolchain host.
|
||||||
|
|
||||||
## Layer-4 streams
|
## Layer-4 streams
|
||||||
|
|
||||||
Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, alongside **Vhosts**, **Redirects** and **404 hosts**. Streams use the same card/modal workflow as vhosts, but each stream owns a dedicated Layer-4 listener and one upstream and intentionally bypasses HTTP ACL/cache/header/compression logic. The page exposes live per-stream connections/sessions, rejects, errors, transferred bytes and RX/TX rate charts. Listener changes require a restart. See [TCP/UDP streams](docs/STREAMS.md).
|
Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, alongside **Vhosts**, **Redirects** and **404 hosts**. Streams use the same card/modal workflow as vhosts, but each stream owns a dedicated Layer-4 listener and an optional backend pool and intentionally bypasses HTTP ACL/cache/header/compression logic. The page exposes live per-stream connections/sessions, rejects, errors, transferred bytes and RX/TX rate charts. Listener changes require a restart. See [TCP/UDP streams](docs/STREAMS.md).
|
||||||
|
|
||||||
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.31.9
|
## Previously in 0.31.9
|
||||||
|
|
||||||
- **Settings → Certificate API:** view the saved non-secret configuration of any Key ID, including comment, allowed certificate IDs, reload/deploy permission and creation/last-use timestamps. Current bearer tokens cannot be retrieved.
|
- **Settings → Certificate API:** view the saved non-secret configuration of any Key ID, including comment, allowed certificate IDs, reload/deploy permission and creation/last-use timestamps. Current bearer tokens cannot be retrieved.
|
||||||
- **Edit settings:** change a credential's comment, certificate allowlist and deploy permission without replacing its bearer token, Key ID, creation timestamp or last-use timestamp. Existing automation can continue with the current secret.
|
- **Edit settings:** change a credential's comment, certificate allowlist and deploy permission without replacing its bearer token, Key ID, creation timestamp or last-use timestamp. Existing automation can continue with the current secret.
|
||||||
|
|||||||
+8
-15
@@ -1,5 +1,12 @@
|
|||||||
# RustProxy roadmap
|
# RustProxy roadmap
|
||||||
|
|
||||||
|
## 0.32.0 - Stream backend pools and opt-in Dashboard cards (runtime acceptance pending)
|
||||||
|
|
||||||
|
- [x] Extend legacy TCP/UDP upstream with an optional validated backend pool, Round Robin/Least Sessions, session pinning, and bounded concurrency.
|
||||||
|
- [x] Add bounded TCP Connect / verified UDP Probe health monitoring with rise/fall hysteresis.
|
||||||
|
- [x] Add opt-in, bounded backend metrics and reuse the existing Prometheus stream gate and API status.
|
||||||
|
- [x] Add four translated opt-in Dashboard cards through existing Appearance preferences and common status refresh.
|
||||||
|
|
||||||
## 0.31.9 - Certificate API credential management (runtime acceptance pending)
|
## 0.31.9 - Certificate API credential management (runtime acceptance pending)
|
||||||
|
|
||||||
- [x] Inspect individual saved Certificate API credential metadata without reading its bearer secret.
|
- [x] Inspect individual saved Certificate API credential metadata without reading its bearer secret.
|
||||||
@@ -16,8 +23,6 @@
|
|||||||
- [x] Pack GeoIP IPv6 records and remove unused CSV Vec capacity without changing lookup results.
|
- [x] Pack GeoIP IPv6 records and remove unused CSV Vec capacity without changing lookup results.
|
||||||
- [x] Add bounded zero-copy transformed-cache fill where exclusive ownership and exact allocation capacity are available.
|
- [x] Add bounded zero-copy transformed-cache fill where exclusive ownership and exact allocation capacity are available.
|
||||||
- [x] Correct Docker VHosts post-restart pending/failed status, first-read synchronization and TLS pin indicator, without changing poll/TLS/SQLite protocols.
|
- [x] Correct Docker VHosts post-restart pending/failed status, first-read synchronization and TLS pin indicator, without changing poll/TLS/SQLite protocols.
|
||||||
- [ ] Compile, format, run Rust tests and check performance with Rust 1.99+, resolve/verify dependencies and run real H1/H2/H3 + browser acceptance.
|
|
||||||
- [ ] Compare stable RSS at identical HOT usage and traffic; do not claim the estimated allocation savings are measured RSS.
|
|
||||||
|
|
||||||
## 0.31.7 - UA-specific throttling and managed update housekeeping (runtime acceptance pending)
|
## 0.31.7 - UA-specific throttling and managed update housekeeping (runtime acceptance pending)
|
||||||
|
|
||||||
@@ -41,7 +46,6 @@
|
|||||||
- [x] Add repeated-value aggregation for arbitrary parsed Stored Logs fields.
|
- [x] Add repeated-value aggregation for arbitrary parsed Stored Logs fields.
|
||||||
- [x] Consolidate active and rotated gzip per-vhost logs into one logical source on demand.
|
- [x] Consolidate active and rotated gzip per-vhost logs into one logical source on demand.
|
||||||
- [x] Update OpenAPI, translations, documentation and Rust-independent regression checks.
|
- [x] Update OpenAPI, translations, documentation and Rust-independent regression checks.
|
||||||
- [ ] Resolve Cargo dependencies, compile/test Rust and perform real browser/proxy acceptance before production deployment.
|
|
||||||
|
|
||||||
## 0.31.4 - Administration-only public HTTPS Origin with one-field configuration
|
## 0.31.4 - Administration-only public HTTPS Origin with one-field configuration
|
||||||
|
|
||||||
@@ -50,20 +54,16 @@
|
|||||||
- [x] Keep additional allowed DNS hostnames in an advanced panel field and remove redundant legacy duplicates on edit/save.
|
- [x] Keep additional allowed DNS hostnames in an advanced panel field and remove redundant legacy duplicates on edit/save.
|
||||||
- [x] Preserve strict Origin, CSRF, trusted proxy, forwarded Host and HTTPS-first header checks and the opt-in self-admin session-cookie boundary.
|
- [x] Preserve strict Origin, CSRF, trusted proxy, forwarded Host and HTTPS-first header checks and the opt-in self-admin session-cookie boundary.
|
||||||
- [x] Add panel settings UI, translations, API schema, Rust source regressions and Node simulations without changing public proxy forwarding.
|
- [x] Add panel settings UI, translations, API schema, Rust source regressions and Node simulations without changing public proxy forwarding.
|
||||||
- [ ] Resolve Cargo dependencies, compile and test Rust, and perform real mobile/reverse-proxy acceptance before production deployment.
|
|
||||||
|
|
||||||
## 0.31.3 - Mobile session recovery and Origin diagnostics
|
## 0.31.3 - Mobile session recovery and Origin diagnostics
|
||||||
|
|
||||||
- [x] Reproduce the overlapping resume-session checks, reload-loop marker reset and early SSE restart using the 0.31.2 source in a deterministic JavaScript simulation.
|
- [x] Reproduce the overlapping resume-session checks, reload-loop marker reset and early SSE restart using the 0.31.2 source in a deterministic JavaScript simulation.
|
||||||
- [x] Coalesce page visibility/BFCache session verification, defer background requests until revalidated and offer a visible retry on failure without weakening Origin enforcement.
|
- [x] Coalesce page visibility/BFCache session verification, defer background requests until revalidated and offer a visible retry on failure without weakening Origin enforcement.
|
||||||
- [x] Display a safe 403 HTML message for invalid-Origin dashboard navigation and provide credential-redacted rejection diagnostics.
|
- [x] Display a safe 403 HTML message for invalid-Origin dashboard navigation and provide credential-redacted rejection diagnostics.
|
||||||
- [ ] Pass Rust/Cargo compile/clippy/test gates and live mobile Safari/Chrome reverse-proxy verification; diagnose the original 403 trigger using the logs.
|
|
||||||
|
|
||||||
## 0.31.2 - Dependency updates
|
## 0.31.2 - Dependency updates
|
||||||
|
|
||||||
- [x] Prepare stable dependency-family and code API migrations, preserving existing wire/configuration/SQLite formats.
|
- [x] Prepare stable dependency-family and code API migrations, preserving existing wire/configuration/SQLite formats.
|
||||||
- [ ] Resolve and commit an actual Cargo.lock using Rust 1.99; run format, clippy, tests and release builds.
|
|
||||||
- [ ] Confirm HTTP/3/QUIC, TLS, SQLite migration/restore, auth, compression, panel and cache BAN/PURGE in real integration tests.
|
|
||||||
|
|
||||||
## 0.31.1 - Mobile panel loading stability
|
## 0.31.1 - Mobile panel loading stability
|
||||||
|
|
||||||
@@ -75,14 +75,12 @@
|
|||||||
- [x] Centralize authenticated polling with source IDs, view lifecycle, hidden-tab pause, abort, in-flight protection and cleanup.
|
- [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] 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.
|
- [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.
|
||||||
- [x] Remove ACME/Python runtime details from About and provide a read-only System information settings tab.
|
- [x] Remove ACME/Python runtime details from About and provide a read-only System information settings tab.
|
||||||
- [x] Read available ACME Python/pip/Certbot version metadata and OS/hardware/process details with existing libraries, without subprocesses for information gathering or new configuration fields.
|
- [x] Read available ACME Python/pip/Certbot version metadata and OS/hardware/process details with existing libraries, without subprocesses for information gathering or new configuration fields.
|
||||||
- [ ] Compile and run Rust tests, verify OS/kernel and metadata-based ACME versions on Linux, BSD, macOS and Windows, and perform live UI/status checks before production acceptance.
|
|
||||||
|
|
||||||
## 0.30.0 - Per-backend Health editor and logging resource review
|
## 0.30.0 - Per-backend Health editor and logging resource review
|
||||||
|
|
||||||
@@ -96,14 +94,13 @@
|
|||||||
- [x] Expose optional global and vhost-level cache HIT-only diagnostic headers.
|
- [x] Expose optional global and vhost-level cache HIT-only diagnostic headers.
|
||||||
- [x] Identify unsafe PHP-FPM shared-socket directive differences and document dedicated pool migration for Roundcube/WordPress.
|
- [x] Identify unsafe PHP-FPM shared-socket directive differences and document dedicated pool migration for Roundcube/WordPress.
|
||||||
- [x] Preserve existing settings and schema 9/Cold format 3.
|
- [x] Preserve existing settings and schema 9/Cold format 3.
|
||||||
- [ ] Compile the Rust code, execute runtime tests, and verify WordPress/Roundcube against separate live PHP-FPM pools before production deployment.
|
|
||||||
|
|
||||||
## 0.29.6 - FastCGI compression EOF and diagnostic routing
|
## 0.29.6 - FastCGI compression EOF and diagnostic routing
|
||||||
|
|
||||||
- [x] Fuse FastCGI response streams so compressors may safely probe the end of a PHP-FPM response multiple times.
|
- [x] Fuse FastCGI response streams so compressors may safely probe the end of a PHP-FPM response multiple times.
|
||||||
- [x] Route WARN/ERROR diagnostics and panic-hook events to the configured error file, or stderr when the file is disabled/unavailable.
|
- [x] Route WARN/ERROR diagnostics and panic-hook events to the configured error file, or stderr when the file is disabled/unavailable.
|
||||||
- [x] Add source tests for repeated EOF polling, gzip output, and live error-log settings changes.
|
- [x] Add source tests for repeated EOF polling, gzip output, and live error-log settings changes.
|
||||||
- [ ] Run Cargo tests and reproduce the Roundcube gzip request against a built 0.29.6 binary before production deployment.
|
|
||||||
- [x] Retain schema 9, Cold Cache format 3 and 0.29.x saved-configuration compatibility.
|
- [x] Retain schema 9, Cold Cache format 3 and 0.29.x saved-configuration compatibility.
|
||||||
|
|
||||||
## 0.29.5 - Roundcube PHP PATH_INFO routing
|
## 0.29.5 - Roundcube PHP PATH_INFO routing
|
||||||
@@ -120,7 +117,6 @@ Current direction: 0.29.3 -> 0.29.4
|
|||||||
- [x] Apply RustProxy cache and response transformations for Web Server files and PHP-FPM responses.
|
- [x] Apply RustProxy cache and response transformations for Web Server files and PHP-FPM responses.
|
||||||
- [x] Supply FastCGI `HTTP_HOST` from request authority for HTTP/2 and HTTP/3 WordPress requests.
|
- [x] Supply FastCGI `HTTP_HOST` from request authority for HTTP/2 and HTTP/3 WordPress requests.
|
||||||
- [x] Add source/UI regression coverage for new configuration and response behavior.
|
- [x] Add source/UI regression coverage for new configuration and response behavior.
|
||||||
- [ ] Run the Rust compiler/test suite and verify PHP-FPM, cache and transformations end-to-end before production use.
|
|
||||||
- [x] Keep SQLite schema 9 and Cold Cache format 3 unchanged.
|
- [x] Keep SQLite schema 9 and Cold Cache format 3 unchanged.
|
||||||
|
|
||||||
## 0.29.3 - HTTP/2 and HTTP/3 internal fallback stabilization
|
## 0.29.3 - HTTP/2 and HTTP/3 internal fallback stabilization
|
||||||
@@ -130,7 +126,6 @@ Current direction: 0.29.2 -> 0.29.3
|
|||||||
- [x] Allow empty GET/HEAD bodies with unknown length hints to enter the internal error fallback path.
|
- [x] Allow empty GET/HEAD bodies with unknown length hints to enter the internal error fallback path.
|
||||||
- [x] Preserve the final internal fallback response, including the fallback backend's HTML on HTTP 404.
|
- [x] Preserve the final internal fallback response, including the fallback backend's HTML on HTTP 404.
|
||||||
- [x] Add Rust regression tests for fallback dispatch, request-body safety and error-page precedence.
|
- [x] Add Rust regression tests for fallback dispatch, request-body safety and error-page precedence.
|
||||||
- [ ] Run Rust unit tests, build and end-to-end HTTP/2/HTTP/3 fallback acceptance on a compiler-equipped machine.
|
|
||||||
- [x] Preserve SQLite schema 9 and Cold Cache format 3 without configuration migrations.
|
- [x] Preserve SQLite schema 9 and Cold Cache format 3 without configuration migrations.
|
||||||
|
|
||||||
## 0.29.2 - PHP-FPM operator controls and observability
|
## 0.29.2 - PHP-FPM operator controls and observability
|
||||||
@@ -138,8 +133,6 @@ Current direction: 0.29.2 -> 0.29.3
|
|||||||
- [x] Add bounded, operator-only PHP_ADMIN_VALUE INI overrides per Web Service.
|
- [x] Add bounded, operator-only PHP_ADMIN_VALUE INI overrides per Web Service.
|
||||||
- [x] Show admin overrides in the Web Service UI/API, reusing existing location-to-service routing.
|
- [x] Show admin overrides in the Web Service UI/API, reusing existing location-to-service routing.
|
||||||
- [x] Track PHP responses by status class, response-header latency and free concurrency slots in Prometheus.
|
- [x] Track PHP responses by status class, response-header latency and free concurrency slots in Prometheus.
|
||||||
- [ ] Verify FastCGI integration and full Rust test suite on a host with a compiler and PHP-FPM.
|
|
||||||
- [ ] Consider per-location PHP override profiles and PHP-FPM connect/write/read phase metrics.
|
|
||||||
|
|
||||||
## 0.29.1 - Internal error routing, multipart ranges and PHP PATH_INFO
|
## 0.29.1 - Internal error routing, multipart ranges and PHP PATH_INFO
|
||||||
|
|
||||||
|
|||||||
+48
-40
@@ -1,18 +1,18 @@
|
|||||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||||
fa9e3f1bf83ebe02e7c1b60e014e4e0d05d2a9d9a319715ebca3fff4677a6b28 AGENTS.md
|
bf752089983e44302369c9f9e49d772fd30f332fb92a825a88aa0721aa55755b AGENTS.md
|
||||||
2347fb7dedb9d9fbb9fd46d75ee8df9cac2aa6c9f63299329b4a86d6700e4d7c CHANGELOG.md
|
f883914a769deebbee14dcdd56edffab73423f63b01d775d0479780b63d37b6c CHANGELOG.md
|
||||||
951711fd4a30d9514f7872fb0a31728af9c33c1d7148e357dc96b266becc23b5 Cargo.lock
|
ac5a5bbe018e66e9b26468fa4155c576d162ea6224ae3444c86560a2ed779311 Cargo.lock
|
||||||
86002aec92d8f486e50d115fdedcaf860a8a88db60465fc49deceeadabf6b877 Cargo.toml
|
d292878efbedefe37a3ee39150a9988ba53dba18b09bc38a0045c7c30a4c21ea Cargo.toml
|
||||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||||
c92e6406addb5c9b75f93c43fef0189a501a6f64c5a4fe26e4225b49e0d86285 README.md
|
c7ece696785cb815fd8b3b30f8ad99b87501daa82d59d4d7cd156412958a4b07 README.md
|
||||||
8d136174ae003dacb0693d0f56df93eb8c17c320feb1b9890892df8cae162d43 ROADMAP.md
|
9a7bd0c457cb6b198c6e8335b0cd9a311fa5c2decc1f9c1bdccbb727af628f85 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
|
||||||
80dad45d29aac750fbc7569a73d7ca53ac497961dec32a08f94fca53a1f3a914 dev.sh
|
ce5dd515f2fdf93c27b7cd68940cea108b8a460f765cc68f26441c0958bb3a1f 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,7 +27,7 @@ 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
|
||||||
4d4c767eb13805c9213cebfa51ddce0d881c6b60ee4ed59d93476163f30b49bd docs/API.md
|
ed9edea5a28c34aa8bacadf1f6d39257ae1421b1fe907b0cc038fb0de898c67f docs/API.md
|
||||||
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
|
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
|
||||||
6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md
|
6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md
|
||||||
e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md
|
e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md
|
||||||
@@ -51,7 +51,7 @@ e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND
|
|||||||
89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md
|
89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md
|
||||||
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
|
5cd028b7b13838fcf500e1660aea5324223329467f6b717573f47d09b366f1b5 docs/PROMETHEUS.md
|
||||||
bb15713876184682cf1907d8c5edd18e657735b6de6cc5f8ca976dbdec4dd936 docs/README.md
|
bb15713876184682cf1907d8c5edd18e657735b6de6cc5f8ca976dbdec4dd936 docs/README.md
|
||||||
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
|
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
|
||||||
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
|
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
|
||||||
@@ -61,9 +61,9 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m
|
|||||||
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
|
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
|
||||||
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
|
||||||
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
||||||
07517c8bf788b1560f760d7e17b2f20027a57098156056ff4be7b1aacf2d6f9b docs/SETTINGS.md
|
70ee2ba62d21345c6ea25db2c4a3de9cd5913cb12de530159527c3efc10a5893 docs/SETTINGS.md
|
||||||
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
|
ffe962544cf09ef401465b1ef9d0666558490d664212b733a5ab205490ee3b9d docs/STREAMS.md
|
||||||
a1cc550782cb0fa488580768070c799895f401971390161d07a4f9e92a3d9e5b docs/TEST_REPORT.md
|
06f7f5981fc3a285e1316aa025ce962f9253e77e949b4a77263b55b8996a2422 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
|
||||||
@@ -95,8 +95,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
|
||||||
5c17d25127a690d925a96ae37e661763c7c9936269e0fb368000875b29b38661 langs/en_US.json
|
613b10b42885a7f91174961fe6d1b0d7a4bd29363f152ec6dd6bc53e5731ad87 langs/en_US.json
|
||||||
97aba663b64f7d38869c00170adfa097fa43ab5354f208f12a2fe9d35a6959d8 langs/pl_pl.json
|
a656e36b55370a3763893762f791ef99a444baffbc4ddec5780a4d2dd38d7efa 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
|
||||||
@@ -113,7 +113,7 @@ ee50f38c037765fab742e888d4b9fc4d874c31c785c6064d4eca3d2c4d24d944 nagios/check_r
|
|||||||
1465396cf1358067e9fe48b3d61f28fd4ac65c577b67c502950ce6b42ecbe3b3 nagios/check_rustproxy_latency
|
1465396cf1358067e9fe48b3d61f28fd4ac65c577b67c502950ce6b42ecbe3b3 nagios/check_rustproxy_latency
|
||||||
6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues
|
6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues
|
||||||
7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml
|
7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml
|
||||||
2145ad17ae7a823756371ceab6bdbeb332211df93193ffb4e486f7a80c4f29ae scripts/README.md
|
bd313cacfc1b450a90368b30ae39ab48b877c8bed3c2db8bbd4e4a1f40865c9e scripts/README.md
|
||||||
31953ddf3fda1c9b47fb5de7f85b91a23782b4ccc9555b8423a965659a0a8786 scripts/build/swagger_assets.py
|
31953ddf3fda1c9b47fb5de7f85b91a23782b4ccc9555b8423a965659a0a8786 scripts/build/swagger_assets.py
|
||||||
e4d15aa6aa864d04b9b30aca33826b833632af02ca643f8b23e219a5ea1298dd scripts/deploy/deploy.py
|
e4d15aa6aa864d04b9b30aca33826b833632af02ca643f8b23e219a5ea1298dd scripts/deploy/deploy.py
|
||||||
cc268d9d3dd125c9cbff2522fe3137a561fb4fcbd5ee461d70ef2e3a8241cdb1 scripts/deploy/deploy_core.py
|
cc268d9d3dd125c9cbff2522fe3137a561fb4fcbd5ee461d70ef2e3a8241cdb1 scripts/deploy/deploy_core.py
|
||||||
@@ -125,6 +125,9 @@ a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migrat
|
|||||||
f4bc093128ab0d8091f9e4e47d61bfaeacbebb0cf6e3b523cf7ddfdf5996aed7 scripts/resolve_dependencies_0312.sh
|
f4bc093128ab0d8091f9e4e47d61bfaeacbebb0cf6e3b523cf7ddfdf5996aed7 scripts/resolve_dependencies_0312.sh
|
||||||
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
|
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
|
||||||
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
||||||
|
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
|
||||||
|
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
|
||||||
|
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
|
||||||
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
|
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
|
||||||
ad62f73bb42906d617cc1b479f02d4648c7324cb89fc11d2c6aa6bf13774eec9 src/acl.rs
|
ad62f73bb42906d617cc1b479f02d4648c7324cb89fc11d2c6aa6bf13774eec9 src/acl.rs
|
||||||
ea272433e7f92e983509a8ab1aa54242440abef21df526c2f99944ef279e09b9 src/acl/tests.rs
|
ea272433e7f92e983509a8ab1aa54242440abef21df526c2f99944ef279e09b9 src/acl/tests.rs
|
||||||
@@ -149,30 +152,31 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
|
|||||||
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
||||||
7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs
|
7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs
|
||||||
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
|
33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs
|
||||||
1a49462c34150005759d4824264c72491f0431251af3909620f98c73a3a788bf src/admin/openapi.json
|
b7af5b8be98904231ac08b6f8e9aaf4b3ec73ae3bbf965cc3f078b27d5948cce src/admin/openapi.json
|
||||||
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
|
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
|
||||||
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
||||||
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
||||||
d8bad220bf42663be3355d0a4b8ea9cede1c425ba6c6e0d961c59cd16ff7f55a src/admin/preferences.rs
|
fce294c2af33845cd139fc7fe794f6ee7dea1b20b13fe597c5174cab0d3739d8 src/admin/preferences.rs
|
||||||
d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/preferences_columns.rs
|
78942d5e97e42c9de9379644ea1b73e595e48567d8f7820742cab6ec91851a73 src/admin/preferences_columns.rs
|
||||||
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
|
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
|
||||||
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
|
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
|
||||||
6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs
|
6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs
|
||||||
8d48e4b7818562159d5c1418e242b9425cd4074c7d4611ef36f6b096f5d92132 src/admin/router.rs
|
afa1a677dbd7caf34dd5b81cf4de98cc83b58550f05937978421b7c5dc915a68 src/admin/router.rs
|
||||||
a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
|
a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
|
||||||
e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs
|
e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs
|
||||||
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs
|
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs
|
||||||
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
|
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
|
||||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||||
f047f6eee5d219c4324422332d9e1e7749e3fab845945a864badd97badb3075e src/admin/tests.rs
|
bf403ed0db49fc7ead3b981d47604bb52d52c56b271a2e379dd878956989faf0 src/admin/tests.rs
|
||||||
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
|
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
|
||||||
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
|
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
|
||||||
d8a06511406d6ab974a6bf6cb5da35a1eeb4031144ef462c5390f9d0954fa059 src/admin/web/app/core.js
|
cb9def393b7f57a6cfecbb8edaff4b7e20e7805231d08e354ad0b7deb294a103 src/admin/web/app/core.js
|
||||||
23721bfbac850c4f77b116cc32bb9d2db6361ac4dd7e310a8c8070ab906997f6 src/admin/web/app/preferences.js
|
f38cd0381be1f0ec00101dede69bf13fc7d77e71ede0aea5ef0aa0f2bc9e419c src/admin/web/app/preferences.js
|
||||||
729ed152eb8471c67fb0e55e44fe0669c2708802ce83228df9cb4e53c14cee02 src/admin/web/app/refresh.js
|
729ed152eb8471c67fb0e55e44fe0669c2708802ce83228df9cb4e53c14cee02 src/admin/web/app/refresh.js
|
||||||
984d092961bfaaa19f982e54c7d4be474e8417b208d814ac88bda63f1ab19f4c src/admin/web/app/runtime.js
|
984d092961bfaaa19f982e54c7d4be474e8417b208d814ac88bda63f1ab19f4c src/admin/web/app/runtime.js
|
||||||
db56370db60e514a2241d231cd949bac2c7aa2f413a2c14035a877029a8d864f src/admin/web/app/session-resume.js
|
db56370db60e514a2241d231cd949bac2c7aa2f413a2c14035a877029a8d864f src/admin/web/app/session-resume.js
|
||||||
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
|
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
|
||||||
|
fc27b21bb6692efccd51351a7b9510f699bad69e51f4ecc4b7bdbf75ad35414b src/admin/web/app/stream-dashboard.js
|
||||||
4fec1344be3cd540d1e6d0b87fb0aa94e1bef2968c4f86366a62c56d4085f452 src/admin/web/cache-panel.js
|
4fec1344be3cd540d1e6d0b87fb0aa94e1bef2968c4f86366a62c56d4085f452 src/admin/web/cache-panel.js
|
||||||
02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js
|
02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js
|
||||||
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
|
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
|
||||||
@@ -196,7 +200,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
|
|||||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||||
a065af0a331a953dc51c72f0f589023cf8a917cda5baa5308dd0d8c105ec3d91 src/admin/web/file-logs.js
|
a065af0a331a953dc51c72f0f589023cf8a917cda5baa5308dd0d8c105ec3d91 src/admin/web/file-logs.js
|
||||||
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
||||||
1e736b63c322b13627ee139399016df77d9cb2754c819a76755f2d2b52fd1046 src/admin/web/index.html
|
d38d416baf581453a23ab6a8e119272fb3b743119b0bd3ed7a8cfb35f1101d0b src/admin/web/index.html
|
||||||
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
|
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
|
||||||
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
||||||
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
|
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
|
||||||
@@ -205,12 +209,12 @@ cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/
|
|||||||
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
|
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
|
||||||
2efa65f4112ea5523bbb5583c6e320c93f0cfed520a0bc5cf4c22fb51b123649 src/admin/web/settings.js
|
2efa65f4112ea5523bbb5583c6e320c93f0cfed520a0bc5cf4c22fb51b123649 src/admin/web/settings.js
|
||||||
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
||||||
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
|
6e0036e3915de17529f14a7e47fa57d52ed3e03e65a3e4c0aa78cd6486342e96 src/admin/web/streams.js
|
||||||
5dd0099b6fdbf741dcd088f93833be5f7f2b5d66f8fc884aa788c6cf9890a639 src/admin/web/style.css
|
0795e65f07dfea1ae7871198115f83e877cdac3d62171c08b1afadd77a5050e4 src/admin/web/style.css
|
||||||
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
||||||
a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js
|
a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js
|
||||||
00f59a6f656e44c364ed3c8eed33ce1df878a488908b30a9523a87b64f50f9ef src/admin/web/vhosts/core.js
|
00f59a6f656e44c364ed3c8eed33ce1df878a488908b30a9523a87b64f50f9ef src/admin/web/vhosts/core.js
|
||||||
306a7e48425c54a0b3637e7789e68fefa0c9d7630cf8715175f1690993888b69 src/admin/web/vhosts/editor.js
|
2aaeacc1b71748bb01b5f02613be343df9f5b496be644662130b184c7c195177 src/admin/web/vhosts/editor.js
|
||||||
30a6344e76de77b60cd7247a33c68f1cb5d972aebfde56469f2ebd44440c7bf9 src/admin/web/vhosts/health.js
|
30a6344e76de77b60cd7247a33c68f1cb5d972aebfde56469f2ebd44440c7bf9 src/admin/web/vhosts/health.js
|
||||||
137aeabed99cbdf9f122da6350e596e6a4e32244fa23101ccf6378305148d38d src/admin/web/vhosts/rates.js
|
137aeabed99cbdf9f122da6350e596e6a4e32244fa23101ccf6378305148d38d src/admin/web/vhosts/rates.js
|
||||||
f2689283cfe43d6aae911b0df778a69335089be230fbfc6de5dcce1801c38427 src/admin/web/vhosts/render.js
|
f2689283cfe43d6aae911b0df778a69335089be230fbfc6de5dcce1801c38427 src/admin/web/vhosts/render.js
|
||||||
@@ -336,7 +340,7 @@ d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs
|
|||||||
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
|
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
|
||||||
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs
|
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs
|
||||||
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
|
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
|
||||||
5ed3d03f9b805413544b932d0c266b0e32b830c783d01900af7988bca6bc48a3 src/prometheus/docker_misc.rs
|
902185d2abe011fda35b438621e73c0728ca230f2f0f7779640cd2136f2cd9ed src/prometheus/docker_misc.rs
|
||||||
d3c6b08088df0003144544936a55e2a3661aac85292821b594f00adfd19a2951 src/prometheus/model.rs
|
d3c6b08088df0003144544936a55e2a3661aac85292821b594f00adfd19a2951 src/prometheus/model.rs
|
||||||
064b2be7fed2f6acf526b1cce070c1bb1b943d36f08fa50bbfcbdf3cf1c9318c src/prometheus/tests.rs
|
064b2be7fed2f6acf526b1cce070c1bb1b943d36f08fa50bbfcbdf3cf1c9318c src/prometheus/tests.rs
|
||||||
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
|
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
|
||||||
@@ -393,10 +397,12 @@ e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/ser
|
|||||||
73ec453ac2850f6963f5bc33401cb8c92aa26d5123f15d68e6633860d91f265e src/settings.rs
|
73ec453ac2850f6963f5bc33401cb8c92aa26d5123f15d68e6633860d91f265e src/settings.rs
|
||||||
240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs
|
240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs
|
||||||
1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs
|
1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs
|
||||||
566daf9c0e1a30da2dc18e51090541a85ced3320a0fc00ed1d1de3e2b4da5072 src/stream.rs
|
9dbf814870b8636770f579c3d36d16f5b6afe8bc66adfad356c26d7ae7dbce45 src/stream.rs
|
||||||
46d9f63ce13a634e00f38d36ca3bdb217f2e37f04d75a4ef118e7dac9896a489 src/stream/model.rs
|
c29f19729ef93c55de5a4d9b448a0adc366e1f34655bd95471ad9100cef51ba1 src/stream/health.rs
|
||||||
9c59bdb856106c0a74d1f8c156b0dd7289a170322ea556bad19bb8de3bf13d69 src/stream/runtime.rs
|
1ad99c5f5eea1e4aff0d9ba82186d36f41a87aafd2849f082652d2270f3166b2 src/stream/model.rs
|
||||||
71c8850c9f80ec2c2eaa1ab3559670ba6b10be4fa5ad088e94047238d2d0c67b src/stream/tests.rs
|
049cc0e13cbc78ccc8f19ab97986a40f95587235c359529293d7c90b2503447d src/stream/pool.rs
|
||||||
|
6ec42b8647c95a0c2025462e0be29c71481ce1c3b1f4d4193926471f1cf06be0 src/stream/runtime.rs
|
||||||
|
2d8497f7486240c27fc0091dc22f3bfd71dc20d4ac839c10d5b57b2715a72fab src/stream/tests.rs
|
||||||
3070d78a7b88c2e47ed59960fa35e7d220a4cd7d78e48f6c6d2bcc5ff77f9620 src/system_stats/linux.rs
|
3070d78a7b88c2e47ed59960fa35e7d220a4cd7d78e48f6c6d2bcc5ff77f9620 src/system_stats/linux.rs
|
||||||
d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_stats/linux/tests.rs
|
d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_stats/linux/tests.rs
|
||||||
751d59582fb92c84e0c0cc7c0855c9d0a545805c9ba3be61826e50fb96c297ed src/system_stats/mod.rs
|
751d59582fb92c84e0c0cc7c0855c9d0a545805c9ba3be61826e50fb96c297ed src/system_stats/mod.rs
|
||||||
@@ -476,17 +482,17 @@ e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/a
|
|||||||
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
|
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 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
|
||||||
5c3d7278de6a4f3ab81908b89f1581f13fa22295714b2ba6651312c49ee9b5a7 tools/checks/certificate_api_0319.py
|
92a2992e7d60ec7edd743bd5eced22d08fd2bfd765f87cd656c97e9691543cff tools/checks/certificate_api_0319.py
|
||||||
526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs
|
526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs
|
||||||
882bbba56046d62f279d725868ad5794dbd1e0214ba4863d71083e44f923be46 tools/checks/check_source.py
|
5e88ddee66527c97b11a21b4cc3dfd73fffbaaec64061e6a9a59a3a6ff2b452e tools/checks/check_source.py
|
||||||
dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
|
dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
|
||||||
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
|
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||||
9039136a8e40537546ee3e5c1e1e1144d9903ef3e770c02df1ca6cd8c0092760 tools/checks/dependency_upgrade_0312.py
|
d3760705017644f1993c3a9adc9c308b29b54d55f523fc2dc95f8c4c19950b23 tools/checks/dependency_upgrade_0312.py
|
||||||
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
|
38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs
|
||||||
3bf519557c229285aac5078c0049d53b8dd0c6161d7eac220b0a2ac2ac93dc10 tools/checks/docs_check.py
|
aaef0dc6a56eb58eff94544f3eee20d15ae1c5b714ca5cd52ac3ec93f9dbf197 tools/checks/docs_check.py
|
||||||
6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs
|
6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs
|
||||||
83c74f0027a6d2f51f637d552593f9904e60ccd69665f6460136c48b159afb60 tools/checks/h3_cache_mobile_0315_test.mjs
|
83c74f0027a6d2f51f637d552593f9904e60ccd69665f6460136c48b159afb60 tools/checks/h3_cache_mobile_0315_test.mjs
|
||||||
d7e22227c9f159eb4129ecbeb5a036f7445f5848b0153dfca1df54aeacc6d233 tools/checks/h3_navigation_0316.py
|
4f3a892139ea81e195866215ed9d8e22449b1876bc6d6310b561b1fc06586192 tools/checks/h3_navigation_0316.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
|
||||||
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py
|
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py
|
||||||
@@ -496,17 +502,19 @@ eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/p
|
|||||||
021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs
|
021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs
|
||||||
c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
|
c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
|
||||||
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs
|
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs
|
||||||
399a926877c225b9564dd447f8889bffb6092c0a1ab9a1f187137ecdfb59f566 tools/checks/release_0318.py
|
a920b391463d69f37af8da36f3b19e991fc62b7483ae98469ab9c5a358800a37 tools/checks/release_0318.py
|
||||||
e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs
|
e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs
|
||||||
1c1ae5ac6447524a56bd3db4bd5f14457d8052a3d0dde18c5e221a632f5e645d tools/checks/release_smoke.py
|
5a23bf1561c8f8850f011fff79e08074df1d4f41041a820c28d6a4ccb4b3c9ef tools/checks/release_smoke.py
|
||||||
31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py
|
31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 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
|
||||||
32574fd9d1abdd72764a9a9a425b410a194a666fef72652fcfa78ff307ce7b3e tools/checks/security_logs_0315_ui_test.mjs
|
56ccfd5ea83f36a5afb291d4a8011ace842c8f9998184024e0803ead0053e247 tools/checks/security_logs_0315_ui_test.mjs
|
||||||
70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py
|
70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py
|
||||||
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
|
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
|
||||||
28c55ffdd4a1762217a32b6595ba67f5c23dce9261a25926854d9bbfb22b1602 tools/checks/system_info_0301_ui_test.mjs
|
e9f96a2a44712feb090ddb0eb273124ac780cb68e428292f7c7f106de11be050 tools/checks/stream_0320_ui_test.mjs
|
||||||
|
a4d4a61b34840dd0971a4b5fdef33fee89d0e8fbc7f68b7cdbefb259d480f822 tools/checks/stream_0320_visibility_regression.mjs
|
||||||
|
1218e3b847daf1850f2601fe62de63000e9f9891d2903b11b8e60b169c960d46 tools/checks/system_info_0301_ui_test.mjs
|
||||||
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
|
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
|
||||||
a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
|
a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
|
||||||
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ if [[ "$mode" == "test" ]]; then
|
|||||||
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/refresh_0310_ui_test.mjs
|
||||||
|
node tools/checks/stream_0320_ui_test.mjs
|
||||||
|
node tools/checks/stream_0320_visibility_regression.mjs
|
||||||
node tools/checks/dashboard_startup_0310_ui_test.mjs
|
node tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||||
node tools/checks/dashboard_boot_0311_ui_test.mjs
|
node tools/checks/dashboard_boot_0311_ui_test.mjs
|
||||||
node tools/checks/origin_resume_0313_ui_test.mjs
|
node tools/checks/origin_resume_0313_ui_test.mjs
|
||||||
|
|||||||
+10
@@ -615,3 +615,13 @@ The runtime system diagnostics object includes `platform`, supplied by the share
|
|||||||
## Per-vhost User-Agent rate limiting (0.31.7)
|
## Per-vhost User-Agent rate limiting (0.31.7)
|
||||||
|
|
||||||
The revisioned `/api/config` vhost rate-limit object accepts optional `user_agent_limits`, up to 64 entries with `pattern` (1..256 bytes, case-insensitive substring or `*` wildcard), `requests` (1..1000000), `window_seconds` (1..3600) and `burst` (0..1000000). The first matching rule replaces the standard and country rate for that request and uses a separate fixed-window counter for each (vhost, rule index, client IP). Requests with no matching User-Agent use the existing limiter. A User-Agent is trivially spoofable; these overrides are operational shaping, not access control. Global and vhost User-Agent blocklists continue to deny matching requests before rate limiting.
|
The revisioned `/api/config` vhost rate-limit object accepts optional `user_agent_limits`, up to 64 entries with `pattern` (1..256 bytes, case-insensitive substring or `*` wildcard), `requests` (1..1000000), `window_seconds` (1..3600) and `burst` (0..1000000). The first matching rule replaces the standard and country rate for that request and uses a separate fixed-window counter for each (vhost, rule index, client IP). Requests with no matching User-Agent use the existing limiter. A User-Agent is trivially spoofable; these overrides are operational shaping, not access control. Global and vhost User-Agent blocklists continue to deny matching requests before rate limiting.
|
||||||
|
|
||||||
|
## Stream pools and dashboard widgets (0.32.0)
|
||||||
|
|
||||||
|
`GET /api/config` and `PUT /api/config/streams` preserve the existing revisioned JSON and session/CSRF gate. A Stream host may include `backends[]` (ID, address, weight, enabled, drain, max_sessions, health, metrics_enabled), `balance` and nullable `metrics_enabled`. With no `backends`, `upstream` retains its legacy single-backend behavior. Backend edits remain desired state until controlled restart. See [STREAMS.md](STREAMS.md).
|
||||||
|
|
||||||
|
`GET /api/status` retains `streams.active`, `streams.desired`, `streams.restart_required` and `streams.metrics`. Each active Stream metric view includes `backends[]` for health/active information and optional detailed counters. `/api/status?view=live` and the existing authenticated SSE snapshots carry the same backend observations inside `streams.metrics` without a separate polling source. Backend status is `unverified`, `checking`, `healthy`, `unhealthy` or `disabled`.
|
||||||
|
|
||||||
|
`GET`/`PUT /api/preferences` continue to own `dashboard_widgets` and `dashboard_hidden_widgets` (now 16 supported IDs, adding `stream_overview`, `stream_traffic`, `stream_sessions`, `stream_backend_health`). Legacy accounts have their missing Stream widget IDs appended **hidden**. Preferences affect only Dashboard presentation, not proxy configuration or metrics export. No new endpoint or permission was introduced.
|
||||||
|
|
||||||
|
Detailed backend metrics default to off in v0.32.0; per-Stream `metrics_enabled` sets the inherited value for its backends, and explicit per-backend `metrics_enabled` overrides it. Basic per-Stream counters remain available regardless of these flags.
|
||||||
@@ -182,3 +182,9 @@ filters so multi-agent installations can be inspected independently from ordinar
|
|||||||
vhosts/backends.
|
vhosts/backends.
|
||||||
|
|
||||||
Reference: https://prometheus.io/docs/instrumenting/exposition_formats/
|
Reference: https://prometheus.io/docs/instrumenting/exposition_formats/
|
||||||
|
|
||||||
|
## Stream pool metrics (0.32.0)
|
||||||
|
|
||||||
|
The existing global `include_streams` controls all Stream Prometheus families. Each configured Stream may set nullable `metrics_enabled` (default true); each backend may set its own nullable `metrics_enabled` (inherit from Stream). Detailed backend counters are emitted only when both flags are enabled. Basic per-Stream counters, backend active/drain and numeric health-state gauges remain observable with global Stream export enabled; routing and health monitoring never depend on these flags. Backend series use only configured `stream`, `backend` and `protocol` labels, never client IP or arbitrary UDP payload. `rust_proxy_stream_backend_health_state` encodes unverified=0, checking=1, healthy=2, unhealthy=3, disabled=4. Optional detailed families include bytes/packets, assignments, errors/rejects, sessions and health successes/failures. All counters reset on process startup.
|
||||||
|
|
||||||
|
For v0.32.0 Stream pools, backend-level `metrics_enabled` overrides the Stream default; missing/null values inherit it. Detailed backend accounting defaults off, including for legacy `upstream` configurations. Global `include_streams` independently gates all Stream Prometheus export.
|
||||||
+5
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Settings -> Appearance** contains dashboard-only preferences. They are stored per administrator in the existing `admin_preferences` SQLite document and do not create a proxy configuration revision. The preferences are theme (`dark`/`light`), language (`auto` or a language-pack id), global interface scale (70-125%), timestamp format (`24h`/`12h`), chart style (`default`, `soft`, `technical`, `high_contrast`, `neon`, `monochrome`, `warm` or `cool`), button style (`normal` or `compact`), table/log density (`normal`, `compact`, `ultra`), dashboard density (`normal`, `compact`, `ultra`), Overview card order and Overview card visibility. `auto` language follows the browser language when a matching pack exists and otherwise falls back to the configured default pack.
|
**Settings -> Appearance** contains dashboard-only preferences. They are stored per administrator in the existing `admin_preferences` SQLite document and do not create a proxy configuration revision. The preferences are theme (`dark`/`light`), language (`auto` or a language-pack id), global interface scale (70-125%), timestamp format (`24h`/`12h`), chart style (`default`, `soft`, `technical`, `high_contrast`, `neon`, `monochrome`, `warm` or `cool`), button style (`normal` or `compact`), table/log density (`normal`, `compact`, `ultra`), dashboard density (`normal`, `compact`, `ultra`), Overview card order and Overview card visibility. `auto` language follows the browser language when a matching pack exists and otherwise falls back to the configured default pack.
|
||||||
|
|
||||||
The scale applies to the complete authenticated administration surface, including navigation, workspace content, dialogs, overlays and the draft dock. Text fields, selects, textareas, checkboxes and radios also receive a small proportional control-size adjustment on top of global zoom so field proportions track the selected scale more visibly. Button style is independent: **Normal** keeps the refreshed control sizing and **Compact** restores the smaller source-style sizing, including generated entity actions and tab buttons inside dialogs. Table/log and dashboard density are additional layout controls and do not change the whole-interface scale. The dashboard editor can hide/show and reorder the telemetry cards at the top of Overview; this changes presentation only, not metric collection. The selected time format is shared by logs, backend/runtime diagnostics, audit/session timestamps, certificate views and configuration-history timestamps. Chart presets change colors, surfaces, grid and line presentation only; they do not change sampled data, and the palette selector exposes the main colors for each preset. Each Appearance subsection has its own reset action, and **Reset appearance** restores all documented Appearance defaults without touching unrelated account preferences. A small local cache is retained only to avoid a theme/scale flash before authentication, while the authenticated SQLite preference remains authoritative.
|
The scale applies to the complete authenticated administration surface, including navigation, workspace content, dialogs, overlays and the draft dock. Text fields, selects, textareas, checkboxes and radios also receive a small proportional control-size adjustment on top of global zoom so field proportions track the selected scale more visibly. Button style is independent: **Normal** keeps the refreshed control sizing and **Compact** restores the smaller source-style sizing, including generated entity actions and tab buttons inside dialogs. Table/log and dashboard density are additional layout controls and do not change the whole-interface scale. The dashboard editor can hide/show and reorder the telemetry cards at the top of Overview; this changes presentation only, not metric collection. Stream card explanations appear on hover and keyboard focus instead of expanding the card list. Stream cards default hidden but, once switched on, remain present even when no Stream is active (neutral no-data state). The selected time format is shared by logs, backend/runtime diagnostics, audit/session timestamps, certificate views and configuration-history timestamps. Chart presets change colors, surfaces, grid and line presentation only; they do not change sampled data, and the palette selector exposes the main colors for each preset. Each Appearance subsection has its own reset action, and **Reset appearance** restores all documented Appearance defaults without touching unrelated account preferences. A small local cache is retained only to avoid a theme/scale flash before authentication, while the authenticated SQLite preference remains authoritative.
|
||||||
|
|
||||||
Unfinished Web Server, Vhost, Redirect and Stream editors remain session-only drafts. Their bottom-right dock uses square corners, can collapse to a single title bar, shows the age of each suspended draft and does not persist draft form values to SQLite.
|
Unfinished Web Server, Vhost, Redirect and Stream editors remain session-only drafts. Their bottom-right dock uses square corners, can collapse to a single title bar, shows the age of each suspended draft and does not persist draft form values to SQLite.
|
||||||
|
|
||||||
@@ -536,3 +536,7 @@ The Prometheus card controls enabled, bind IP/port, URL path, client CIDRs,
|
|||||||
bearer-token environment variable name and optional collectors, including the bounded
|
bearer-token environment variable name and optional collectors, including the bounded
|
||||||
Traffic analytics collector. Only the variable name is exported; supply the actual secret to the service separately. See
|
Traffic analytics collector. Only the variable name is exported; supply the actual secret to the service separately. See
|
||||||
PROMETHEUS.md for immediate revocation, restart rules and scrape configuration.
|
PROMETHEUS.md for immediate revocation, restart rules and scrape configuration.
|
||||||
|
|
||||||
|
## Stream Dashboard cards (0.32.0)
|
||||||
|
|
||||||
|
Under **Settings → Appearance → Dashboard layout**, the existing Show/Hide and Up/Down card controls now include Stream overview, traffic, sessions and backend health. They are opt-in and account-scoped, saved through the existing `/api/preferences` flow. Legacy 12-card orders migrate in memory with all four Stream cards hidden. They use the existing status/SSE stream and stop computing their charts while hidden; no backend monitoring or listener settings are changed when toggled. An explicitly enabled Stream card stays on Overview and shows a neutral no-data state when no Stream is active. Overview details and chart legends stay visible inside the cards (without hover tooltips); the appearance editor continues to show its own descriptions on hover or keyboard focus.
|
||||||
+108
-42
@@ -1,61 +1,127 @@
|
|||||||
# TCP/UDP streams
|
# TCP/UDP Stream pools (v0.32.0)
|
||||||
|
|
||||||
RustProxy 0.13.0 exposes raw Layer-4 forwarding on the main **Streams** page, alongside **Vhosts** and **Redirects**. A stream combines one dedicated TCP or UDP listener with one upstream. Stream traffic does **not** enter the HTTP pipeline: no HTTP vhost routing, ACL, cache, headers, compression, Tinyauth or TLS termination is applied.
|
Layer-4 forwarding runs on dedicated TCP/UDP listeners and never traverses HTTP Vhosts, ACL, cache, headers, compression, TLS termination or authentication. The existing **Streams** page provides a card/modal editor and per-Stream RX/TX chart. Changes to Stream listeners and backend pool configuration remain **desired state** until the next controlled RustProxy restart; saving Appearance preferences does not restart the proxy.
|
||||||
|
|
||||||
## Dashboard workflow
|
## Compatible configuration
|
||||||
|
|
||||||
Use **Streams > New stream** or **Add TCP/UDP listener**. The modal mirrors the Vhost workflow and contains Details, Listener & upstream, and Limits tabs. Existing streams are shown as searchable cards with live runtime counters and RX/TX rate charts.
|
The legacy document still works without modification:
|
||||||
|
|
||||||
A saved listener change is desired state only until RustProxy restarts. The page keeps the active startup listener visible and marks changed/new/removed stream configuration as pending restart.
|
```json
|
||||||
|
{"streams":[{"id":"ssh","name":"SSH relay","enabled":true,"protocol":"tcp","listen":"0.0.0.0:2222","upstream":"10.0.0.20:22","connect_timeout_seconds":5,"idle_timeout_seconds":300,"max_connections":512}]}
|
||||||
|
```
|
||||||
|
|
||||||
The HTTP/HTTPS listener editor remains under **Settings > Listeners**. Its bind addresses use the same chip-style interaction as vhost hostnames. Those named HTTP/HTTPS listeners are separate from stream listeners.
|
Without `backends`, `upstream` is treated as one enabled backend with ID `primary`, weight 1, no active health probe and detailed metrics disabled by default (opt in explicitly). The legacy field is retained in serialized configuration and is set to the first backend address when a pool is explicitly configured. Existing listeners continue to require restart on change.
|
||||||
|
|
||||||
## Configuration document
|
A TCP pool can be configured as follows (the same backend fields apply to UDP):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"streams": [
|
"id":"ssh", "name":"SSH pool", "enabled":true, "protocol":"tcp",
|
||||||
{
|
"listen":"0.0.0.0:2222", "upstream":"10.0.0.20:22",
|
||||||
"id": "ssh",
|
"balance":"round_robin", "metrics_enabled":true,
|
||||||
"name": "SSH relay",
|
"connect_timeout_seconds":5, "idle_timeout_seconds":300, "max_connections":512,
|
||||||
"enabled": true,
|
"backends":[
|
||||||
"protocol": "tcp",
|
{"id":"node-a","name":"Server A","address":"10.0.0.20:22","enabled":true,"drain":false,"weight":2,"max_sessions":256,"metrics_enabled":true,"health":{"mode":"tcp_connect","interval_seconds":10,"timeout_ms":1000,"rise":2,"fall":2}},
|
||||||
"listen": "0.0.0.0:2222",
|
{"id":"node-b","name":"Server B","address":"10.0.0.21:22","enabled":true,"drain":false,"weight":1,"max_sessions":256,"metrics_enabled":false,"health":{"mode":"tcp_connect","interval_seconds":10,"timeout_ms":1000,"rise":2,"fall":2}}
|
||||||
"upstream": "10.0.0.20:22",
|
|
||||||
"connect_timeout_seconds": 5,
|
|
||||||
"idle_timeout_seconds": 300,
|
|
||||||
"max_connections": 512
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "dns-udp",
|
|
||||||
"name": "DNS UDP",
|
|
||||||
"enabled": true,
|
|
||||||
"protocol": "udp",
|
|
||||||
"listen": "0.0.0.0:5353",
|
|
||||||
"upstream": "10.0.0.53:53",
|
|
||||||
"connect_timeout_seconds": 5,
|
|
||||||
"idle_timeout_seconds": 30,
|
|
||||||
"max_connections": 2048
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
TCP uses a bidirectional byte relay with a connect timeout, inactivity timeout and connection limit. UDP creates a bounded per-client session mapped to one connected upstream socket, expires it after inactivity and limits concurrent sessions.
|
`balance` is `round_robin` (default), `least_connections` (TCP), or `least_sessions` (UDP). The least-load variants use an active-assignment-to-weight ratio. Each new TCP connection selects one backend for its entire connection; a UDP client session selects once and remains pinned for its lifetime (idle timeout). `drain: true`, `enabled: false`, `Unhealthy` state, or a reached `max_sessions` prevents new assignments. In-flight TCP relays and UDP sessions retain their selected backend; a controlled process restart still follows the existing shutdown grace period and may end in-flight sessions.
|
||||||
|
|
||||||
## Runtime telemetry
|
## Health monitoring
|
||||||
|
|
||||||
The administration status snapshot exposes fixed-cardinality metrics for active startup streams. The Streams page displays:
|
Each backend has independent `enabled`, `health` and `metrics_enabled` controls. Disabled and draining backends can continue independent health monitoring if probes are explicitly configured; it does not get new sessions. Probe concurrency is globally limited to 16, at most one health task per enabled/configured backend, with lifecycle tied to the listener cancellation token. Timeouts, probe intervals and rise/fall thresholds are bounded by validation. TCP checks use a timed TCP connect without application payload.
|
||||||
|
|
||||||
- active TCP connections or UDP client sessions;
|
UDP checks cannot use a local UDP `connect()` as evidence that the remote service exists. For a validated active UDP probe define both `payload_hex` and an exact, nonempty `expect_hex` response (up to 128 bytes each), e.g.:
|
||||||
- accepted connections/sessions and completed sessions;
|
|
||||||
- capacity rejects and relay/upstream errors;
|
|
||||||
- bytes received from clients and returned from upstreams;
|
|
||||||
- UDP packet counters;
|
|
||||||
- live RX/TX rates derived from monotonic backend byte counters.
|
|
||||||
|
|
||||||
The chart history exists only in the current browser panel session. Backend counters are process-lifetime counters and reset on process restart.
|
```json
|
||||||
|
{"mode":"udp_probe","interval_seconds":10,"timeout_ms":1000,"rise":2,"fall":2,"payload_hex":"50494e47","expect_hex":"504f4e47"}
|
||||||
|
```
|
||||||
|
|
||||||
## Listener safety
|
**Do not enable UDP probing without the service owner's approval.** Unknown application protocols must leave checks `off`; `udp_probe` without an expected reply is `Unverified`, sends no datagrams and does not report Healthy. A backend without active checks is `Unverified` (routing still allowed), while probed backends show `Checking`, `Healthy`, `Unhealthy` or `Disabled`; hysteresis prevents state flapping. An Unhealthy backend stops receiving new sessions, but no ongoing TCP connection is reassigned. If the pool has no eligible backend, new sessions or datagrams are rejected and the legacy per-Stream `rejected` counter advances.
|
||||||
|
|
||||||
RustProxy validates collisions before publishing configuration. TCP stream listeners may not overlap HTTP/TLS, administration or Prometheus TCP listeners. UDP stream listeners may not overlap HTTP/3 UDP listeners. TCP and UDP may intentionally use the same numeric port because they are different transports. Direct loops into another RustProxy listener of the same transport are rejected.
|
## Telemetry and Dashboard
|
||||||
|
|
||||||
|
The existing `/api/status` response and `/api/status?view=live` SSE/fallback add a bounded `backends` array under `streams.metrics[stream_id]`. It includes stable configured IDs, status, active sessions, health counts/last errors and (if enabled) optional completed/created/assignment, bytes, packets and error/reject metrics. `bytes_in` remains client → upstream, `bytes_out` upstream → client. A UDP datagram rejected before a session is established remains counted in the original per-Stream incoming counters, not backend delivered-byte counters.
|
||||||
|
|
||||||
|
In the editor, **Stream → Detailed metrics → Default (disabled)** means the absence of a Stream override resolves to `false` (it does **not** inherit from Prometheus). At the backend level, **Use Stream setting** resolves to that Stream's value; backend **Enabled**/**Disabled** override it. Prometheus retains `include_streams` as the separate global export gate. `metrics_enabled` defaults to `false` for detailed per-backend accounting (no new overhead on legacy streams). Stream-level `true` enables the default for its backends; backend-level `true`/`false` explicitly overrides the Stream value, while `null` inherits. This choice never changes session routing or health checks. Basic per-Stream counters and backend active gauges stay available. Backend label cardinality is bounded to IDs declared in config, never client IPs. History is a limited browser-memory window only; metrics are not persisted as periodic SQLite samples.
|
||||||
|
|
||||||
|
Under **Settings → Appearance → Dashboard layout**, toggle/reorder Stream overview, traffic, sessions and backend-health cards alongside existing telemetry cards. All Stream cards default hidden for existing accounts, including migrated 0.31.x layouts. An explicitly enabled card stays visible even when no Stream is configured, enabled or running; it displays a neutral **No stream data** state in that case rather than ignoring the Appearance setting. They use the existing API status/SSE/fallback and one dashboard refresh scheduler. Hiding a card avoids its chart computations, and changing visibility does not alter Stream runtime, existing widgets or global Prometheus settings. Widget descriptions in Appearance appear on hover/keyboard focus rather than permanently beneath settings labels. On the main Overview page, secondary values and RX/TX chart legend are always inline under the metric/graph (not tooltips), with the original six-column desktop layout and taller tiles for readability.
|
||||||
|
|
||||||
|
## Safety and operational limits
|
||||||
|
|
||||||
|
At most 128 Stream hosts and 32 backends per host, weights 1–16, 1–65535 stream/session concurrency and optional per-backend session caps. Validated concrete backend IPs and nonzero ports are required. Duplicate backend IDs, local listener loops, conflicting transport health modes and invalid probes are rejected before configuration is published. UDP client queues are bounded at 64 datagrams. The existing listener restart indicator remains authoritative, and no new public administration routes or polling tasks are added.
|
||||||
|
|
||||||
|
## Standalone TCP and UDP forwarding tests
|
||||||
|
|
||||||
|
The ready-to-use launcher and individual TCP/UDP test clients are all in `scripts/`.
|
||||||
|
Start four loopback-only mock backends (TCP 9101/9102; UDP 9201/9202):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/start_stream_backends.sh
|
||||||
|
# Optional, only on an isolated test network: ./scripts/start_stream_backends.sh 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Point your corresponding Stream backend pools at these ports, restart RustProxy
|
||||||
|
(the Stream configuration's existing restart requirement), then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/test_stream_tcp.py client --port 2222 --sessions 32 --messages 5 --min-backends 2
|
||||||
|
python3 scripts/test_stream_udp.py client --port 5353 --sessions 32 --messages 5 --min-backends 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop the launcher with `Ctrl+C`. Its four children are stopped together. If you
|
||||||
|
prefer launching backend peers separately or need different ports, use the examples below.
|
||||||
|
|
||||||
|
These are **black-box clients for a real running RustProxy** plus small, intentionally
|
||||||
|
loopback-only mock services. Do not aim them at live/production backends. Use
|
||||||
|
separate terminals for each mock, and configure the Stream editor with these
|
||||||
|
backend addresses. **Restart RustProxy after modifying Stream backends** (the
|
||||||
|
existing Stream lifecycle still applies).
|
||||||
|
|
||||||
|
TCP example (Stream listener `127.0.0.1:2222`, backend pool `127.0.0.1:9121` and
|
||||||
|
`127.0.0.1:9122`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
python3 scripts/test_stream_tcp.py server --port 9121 --backend-id node-a
|
||||||
|
# Terminal 2
|
||||||
|
python3 scripts/test_stream_tcp.py server --port 9122 --backend-id node-b
|
||||||
|
# Terminal 3 - after enabling the TCP Stream listener in RustProxy
|
||||||
|
python3 scripts/test_stream_tcp.py client --port 2222 --sessions 32 --messages 5 --min-backends 2
|
||||||
|
```
|
||||||
|
|
||||||
|
UDP example (Stream listener `127.0.0.1:5353`, pool `127.0.0.1:9131` and
|
||||||
|
`127.0.0.1:9132`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
python3 scripts/test_stream_udp.py server --port 9131 --backend-id node-a
|
||||||
|
# Terminal 2
|
||||||
|
python3 scripts/test_stream_udp.py server --port 9132 --backend-id node-b
|
||||||
|
# Terminal 3 - after enabling the UDP Stream listener in RustProxy
|
||||||
|
python3 scripts/test_stream_udp.py client --port 5353 --sessions 32 --messages 5 --min-backends 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Each client verifies exact round-trip payloads and logs per-backend session
|
||||||
|
assignments. TCP verifies the selected backend stays unchanged through several
|
||||||
|
messages on one connection; UDP verifies the same over datagrams from one
|
||||||
|
client socket. Use `--min-backends 1` for a single backend, drain, or failover
|
||||||
|
case. Compare the reported assignments with Stream metrics and Prometheus.
|
||||||
|
`--parallel`, `--sessions`, `--messages`, and `--timeout` control test load.
|
||||||
|
UDP mock services also respond to the **explicit** health probe `PING`/`PONG`
|
||||||
|
(`payload_hex: 50494e47`, `expect_hex: 504f4e47`); do not apply this payload
|
||||||
|
arbitrarily to real application servers.
|
||||||
|
|
||||||
|
To check the scripts themselves *without RustProxy*, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/test_stream_tcp.py selftest
|
||||||
|
python3 scripts/test_stream_udp.py selftest
|
||||||
|
```
|
||||||
|
|
||||||
|
Selftests only exercise the Python mock peer/harness; they do not verify the
|
||||||
|
RustProxy forwarding path, balance algorithms or real health check lifecycle.
|
||||||
|
Run the `client` cases against your compiled proxy and manually repeat after
|
||||||
|
disabling, draining, or stopping one mock backend to verify failover behavior.
|
||||||
+48
-1
@@ -1,4 +1,51 @@
|
|||||||
# Verification report - RustProxy 0.31.9
|
# Verification report - RustProxy 0.32.0
|
||||||
|
|
||||||
|
## 0.32.0 — Overview original six-column layout (2026-10-12)
|
||||||
|
|
||||||
|
- **Fix:** remove the Hotfix 3 four-column desktop override; restore the existing six-column Overview grid above 1200px, retaining the original 3-column/2-column responsive breakpoints and dashboard density settings. Use taller desktop cards so secondary labels and colored graph legends remain visible inline, without hover/focus behavior. No changes to Dashboard preferences, widgets, API, refresh scheduler or Vhosts behavior.
|
||||||
|
- **PASS — regression & Chromium static fixture (not a live RustProxy panel):** `stream_0320_visibility_regression.mjs` rejects the four-column override and asserts the six-column desktop rule. Chromium computed six columns at 1280px (126px-tall compact cards), three at 1024px and two at 390px, with all 16 original+Stream cards present in fixture, captions and RX/TX legend visible, and no horizontal page overflow. This is not an authenticated application or a Rust runtime test.
|
||||||
|
|
||||||
|
## 0.32.0 — Overview inline legends and Vhosts edit-hint fix (2026-10-12)
|
||||||
|
|
||||||
|
- **Overview:** remove the hover/focus tooltip CSS previously affecting every Overview `small` caption on pointer devices; restore their permanent placement below numbers and graphs on desktop and touch. Remove `tabindex=0` from non-interactive stat articles (inner buttons remain keyboard-accessible). Keep the 0.32.0 Stream opt-in/empty-state behavior and unchanged status/SSE cadence.
|
||||||
|
- **Layout:** Overview uses four wider columns above 1200px and the original responsive grid below that breakpoint; allow chart legends to wrap. Stream RX and TX share the already collected traffic rates and existing graph and now render as a two-color inline legend (no API/timer added). **Settings -> Appearance** description popovers remain unchanged.
|
||||||
|
- **Vhosts:** the add/edit enabled checkbox still uses the shared enabled-state control, but its redundant `vhEnableHint` helper node is removed locally, as already done for Streams. This does not change how `enabled` is saved or processed.
|
||||||
|
- **PASS — full Rust-independent `./dev.sh test`:** production-module/source contracts, SQLite/schema/upgrade checks, security and documentation checks, locale parity, and all bundled Node UI regressions. Includes modified Stream visibility and in-card RX/TX legend tests and the Vhost hint source test. The command ultimately stops at **127** because Cargo/Rust is not installed; no Rust tests ran.
|
||||||
|
- **PASS — Chromium static-layout fixture (not a live authenticated panel):** desktop 1280px Overview has four columns (Stream traffic card 268px wide), labels remain visible and in place after hover, and colored RX/TX legend fits within the card. At 390px the layout has two columns with wrapped chart legends; both viewports have no horizontal overflow. Appearance help is hidden at rest and still appears on hover. Screenshots were checked manually.
|
||||||
|
- **OPEN:** Cargo format/check/tests, a rebuilt running RustProxy panel, and authenticated browser/live TCP/UDP regressions on the operator host. No change was made to Rust Stream routing, system widgets, preference persistence or refresh scheduler in this patch.
|
||||||
|
|
||||||
|
## 0.32.0 — Stream Dashboard/Appearance hotfix 2 (2026-10-12)
|
||||||
|
|
||||||
|
- **Root cause addressed:** `stream-dashboard.js` unconditionally hid opted-in Stream cards whenever no enabled/running listener appeared in the current config/status. Visibility now follows existing Appearance preferences exclusively. When there is no running/configured Stream, explicitly selected cards show the translated neutral no-data state without extra polling or history samples. Disabled cards remain entirely hidden; legacy cards and layout controls are unchanged. Regression cases include saved opt-in, toggling, disabled/no-data and restart-pending Streams.
|
||||||
|
- **Appearance UX:** descriptions of the four Stream widget controls are popovers on pointer hover or keyboard focus (`role=tooltip`, `aria-describedby`), rather than permanent second lines. The shared Overview metric tips remain unchanged. In the Stream editor, the redundant generic active-control hint is removed locally; `metrics_enabled=null` is labeled **Default (disabled)** on a Stream and **Use Stream setting** on a backend, consistent with existing runtime inheritance. The `include_streams` Prometheus export switch remains independent.
|
||||||
|
- **Tools:** moved the separate Python TCP and UDP test clients from `scripts/tests/` to `scripts/`, added `scripts/start_stream_backends.sh` alongside them, and updated command examples and README. The launcher starts/stops both TCP peers (ports 9101, 9102) and both UDP peers (9201, 9202) in one group; no service is exposed beyond loopback unless explicitly requested.
|
||||||
|
- **PASS — full Rust-independent `./dev.sh test` run:** Python/SQL/schema/security/docs/i18n checks and all bundled Node UI/regression tests passed, including both new Stream visibility cases; terminated **127** because Cargo is missing. Source line-count contract (including JS <=500 lines) passed after moving a local Stream UI change out of core.js.
|
||||||
|
- **PASS — Chromium static Appearance fixture, not authenticated RustProxy:** 1280px and 390px viewports; Stream description tooltip hidden at rest, shown on hover or focus, 34px row height and no horizontal overflow.
|
||||||
|
- **PASS — actual four-peer launcher test (mock-only):** direct Python client connections to TCP 9101/9102 and UDP 9201/9202 exchanged 4 messages per port over two sessions. Python TCP/UDP standalone selftests also passed. Neither path went through the RustProxy binary; it is not a real proxy routing/failover acceptance test.
|
||||||
|
- **PASS:** `node --check` for modified browser modules, script syntax, locale/doc checks, `GOPROXY=off GOSUMDB=off go test ./...` for the Docker agent.
|
||||||
|
- **Still pending on a Rust-equipped host:** Cargo build/check/test, actual admin session/browser UI, and TCP/UDP routing + health/failover/load tests through running RustProxy. This source hotfix is not runtime-production validated here.
|
||||||
|
|
||||||
|
## 0.32.0 — Dashboard follow-up and separate TCP/UDP test tools (2026-10-12)
|
||||||
|
|
||||||
|
- **Reported on a Rust-enabled host:** `src/stream/pool.rs` emitted one deprecated `AtomicU64::fetch_update` warning. The source now calls `AtomicU64::try_update` with the same memory orderings and session-capacity closure. The modified Rust source was **not compiled here**.
|
||||||
|
- **Fixed in source:** Appearance layout now supplies the newly selected `dashboard_hidden_widgets` to Stream visibility immediately, instead of consulting stale `adminPreferences` during an initial load or a persisted switch. Cached Stream totals repaint on enabling or returning to Overview without an extra API request, timer or synthetic traffic sample. Disabled/nonexistent Streams remain absent.
|
||||||
|
- **UX:** secondary Overview values (the `small` text below metrics) float above cards only on pointer hover/keyboard focus; card names and primary values remain visible. For touch-only devices, details retain their original inline presentation. Keyboard focus outlines are preserved.
|
||||||
|
- **PASS — actual Chromium static layout check:** desktop 1280px hover and keyboard focus changed tooltip opacity from 0 to 1, mobile touch 390px retained inline detail opacity 1 and no horizontal overflow. This is **not** an authenticated RustProxy browser test.
|
||||||
|
- **PASS — full Rust-independent `./dev.sh test` suite:** all Python/SQLite/security/docs/locale and Node UI regressions, including `stream_0320_visibility_regression.mjs`, completed. The tool terminated **127** at the expected missing-Cargo check. New navigation code was additionally checked against the existing Docker VHosts simulated regression. No Rust tests ran.
|
||||||
|
- **PASS — standalone harness-only selftests:** `scripts/test_stream_tcp.py selftest` (8 local mock TCP sessions/24 messages) and `scripts/test_stream_udp.py selftest` (8 local mock UDP client sockets/24 datagrams). Both verify echo and same-session affinity against the Python mock **directly**, not through RustProxy.
|
||||||
|
- **PASS:** `GOPROXY=off GOSUMDB=off go test ./...` for the bundled Docker Agent; `bash -n dev.sh`, `python3 -m py_compile` for the new Python scripts, Node source syntax, docs and i18n checks.
|
||||||
|
- **PENDING on operator's Rust toolchain and live proxy:** `cargo fmt --check`, `cargo check --all-targets`, `cargo test`, compilation without warnings, and running both `client` scripts through actual configured TCP/UDP Stream listeners with mock upstreams, followed by drain/health/failover verification. No claim of production-ready runtime is made here.
|
||||||
|
|
||||||
|
## 0.32.0 — Stream pools and Appearance/Dashboard integration
|
||||||
|
|
||||||
|
- **Implemented, source inspection:** legacy single-upstream compatibility, bounded TCP/UDP backend pool with RAII assignments, weighted Round Robin and least-load selection, health modes and probe hysteresis, optional per-backend counters and Prometheus labels.
|
||||||
|
- **Implemented, source inspection:** four hidden-by-default Stream Dashboard widgets and Appearance controls in the existing per-user preference mechanism, using the existing status stream/polling (no new timer or HTTP route).
|
||||||
|
- **PASS — offline source/UI regressions:** `./dev.sh test` completed all Python checks, all browser-side Node.js tests (including `tools/checks/stream_0320_ui_test.mjs`), security and documentation checks, and language-pack checks. It then stopped at the Cargo prerequisite with exit code **127** because the Rust toolchain is absent; this is **not** a passed Rust test run.
|
||||||
|
- **PASS — syntax/docs:** `node --check` on all 31 first-party browser JavaScript modules; `python3 tools/checks/docs_check.py`; `python3 tools/checks/langs_check.py`; JSON parsing for the OpenAPI specification.
|
||||||
|
- **PASS — independent Go agent:** `GOPROXY=off GOSUMDB=off go test ./...` from `docker-agent/`.
|
||||||
|
- **NEW, NOT EXECUTED (Rust):** legacy configuration parse/validation, duplicate IDs/self-loops, weighted RR/least sessions, cap/drain/lease accounting, TCP rise/fall, UDP Unverified, real TCP/UDP probe replies, real socket forwarding and UDP session pinning. These tests were added to `src/stream/tests.rs` but require the Rust 1.99+ toolchain.
|
||||||
|
- **OPEN:** `cargo fmt --check`, `cargo check --locked --all-targets`, `cargo test --locked`, staging TCP/UDP health/recovery/load tests and authenticated Dashboard browser acceptance. No production-readiness claim is made without these checks.
|
||||||
|
|
||||||
|
|
||||||
## 0.31.9 — Certificate API credential management (runtime acceptance open)
|
## 0.31.9 — Certificate API credential management (runtime acceptance open)
|
||||||
|
|
||||||
|
|||||||
+55
-2
@@ -16,7 +16,6 @@
|
|||||||
"vhUaRateHelp": "First matching pattern wins. Case-insensitive substring or * wildcard. Per-client-IP and per-vhost, independent of the default and country limits; applies even if the default rate limit is off.",
|
"vhUaRateHelp": "First matching pattern wins. Case-insensitive substring or * wildcard. Per-client-IP and per-vhost, independent of the default and country limits; applies even if the default rate limit is off.",
|
||||||
"configHistoryFullCompare": "Full JSON comparison",
|
"configHistoryFullCompare": "Full JSON comparison",
|
||||||
"configHistoryChangedFields": "Changed fields only",
|
"configHistoryChangedFields": "Changed fields only",
|
||||||
|
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"mainMenu": "Menu",
|
"mainMenu": "Menu",
|
||||||
"routing": "Legacy routes",
|
"routing": "Legacy routes",
|
||||||
@@ -1072,6 +1071,56 @@
|
|||||||
"aclProfileBulkHelp": "Paste one “allow|deny CIDR” rule per line. Invisible Unicode spacing is normalized during import and errors identify the exact line.",
|
"aclProfileBulkHelp": "Paste one “allow|deny CIDR” rule per line. Invisible Unicode spacing is normalized during import and errors identify the exact line.",
|
||||||
"aclProfileImportRules": "Import rules",
|
"aclProfileImportRules": "Import rules",
|
||||||
"aclProfileRulesImported": "ACL rules imported into the visual editor.",
|
"aclProfileRulesImported": "ACL rules imported into the visual editor.",
|
||||||
|
"streamWidgetOverview": "Stream overview",
|
||||||
|
"streamWidgetTraffic": "Stream traffic",
|
||||||
|
"streamWidgetSessions": "Stream sessions",
|
||||||
|
"streamWidgetHealth": "Stream backend health",
|
||||||
|
"streamWidgetUnavailable": "No stream data",
|
||||||
|
"streamWidgetActiveConfigured": "active / configured",
|
||||||
|
"streamWidgetAttention": "require attention",
|
||||||
|
"streamWidgetHealthyUnhealthy": "healthy / unhealthy",
|
||||||
|
"streamWidgetUnverified": "unverified",
|
||||||
|
"streamWidgetEmptyPools": "pools without backend",
|
||||||
|
"streamWidgetSettingsHelp": "Stream cards are opt-in and only change Dashboard presentation, not Stream routing or monitoring.",
|
||||||
|
"streamBackendPool": "Backend pool",
|
||||||
|
"streamBackend": "Backend",
|
||||||
|
"streamBackendId": "Backend ID",
|
||||||
|
"streamBackendName": "Backend name",
|
||||||
|
"streamBackendAddress": "Address / port",
|
||||||
|
"streamBackendWeight": "Weight (1–16)",
|
||||||
|
"streamBackendMaxSessions": "Session limit (optional)",
|
||||||
|
"streamBackendDrain": "Drain: no new sessions",
|
||||||
|
"streamBackendAdd": "Add backend",
|
||||||
|
"streamBackendDuplicate": "Backend IDs must be unique.",
|
||||||
|
"streamBackendLimit": "At most 32 backends per stream.",
|
||||||
|
"streamBackendRequired": "At least one backend is required.",
|
||||||
|
"streamBalance": "Load-balancing algorithm",
|
||||||
|
"streamRoundRobin": "Weighted Round Robin",
|
||||||
|
"streamLeastSessions": "Least connections / sessions",
|
||||||
|
"streamDetailMetrics": "Detailed metrics",
|
||||||
|
"streamBackendDetailMetrics": "Backend detailed metrics",
|
||||||
|
"streamMetricsFromStream": "Use Stream setting",
|
||||||
|
"streamMetricsDefaultOff": "Default (disabled)",
|
||||||
|
"streamMetricsHelp": "Stream defaults to detailed metrics off. Each backend can override this setting. Prometheus export also requires the separate global include_streams switch; basic counters are always available.",
|
||||||
|
"streamInherit": "Inherit",
|
||||||
|
"streamMetricsOn": "Enabled",
|
||||||
|
"streamMetricsOff": "Disabled",
|
||||||
|
"streamHealthMode": "Health check",
|
||||||
|
"streamHealthOff": "Off (unverified)",
|
||||||
|
"streamHealthTcp": "TCP Connect",
|
||||||
|
"streamHealthUdp": "UDP Probe (response required)",
|
||||||
|
"streamHealthInterval": "Probe interval (seconds)",
|
||||||
|
"streamHealthTimeout": "Probe timeout (ms)",
|
||||||
|
"streamHealthRise": "Success threshold (rise)",
|
||||||
|
"streamHealthFall": "Failure threshold (fall)",
|
||||||
|
"streamHealthPayload": "UDP payload (hex)",
|
||||||
|
"streamHealthExpected": "Expected UDP response (hex)",
|
||||||
|
"streamHealthUdpHelp": "UDP probing is disabled unless both a payload and expected exact response are set; UDP connect alone never proves health.",
|
||||||
|
"streamHealthState_healthy": "healthy",
|
||||||
|
"streamHealthState_unhealthy": "unhealthy",
|
||||||
|
"streamHealthState_checking": "checking",
|
||||||
|
"streamHealthState_disabled": "disabled",
|
||||||
|
"streamHealthState_unverified": "unverified",
|
||||||
"streamHosts": "Stream hosts",
|
"streamHosts": "Stream hosts",
|
||||||
"streamsHelp": "Raw TCP/UDP forwarding on dedicated ports. Stream traffic bypasses HTTP vhosts, ACL, cache, headers and compression.",
|
"streamsHelp": "Raw TCP/UDP forwarding on dedicated ports. Stream traffic bypasses HTTP vhosts, ACL, cache, headers and compression.",
|
||||||
"streamAdd": "Add stream host",
|
"streamAdd": "Add stream host",
|
||||||
@@ -2387,6 +2436,10 @@
|
|||||||
"copyValue": "Copy value",
|
"copyValue": "Copy value",
|
||||||
"fileLogsConsolidateShort": "Merge rotations",
|
"fileLogsConsolidateShort": "Merge rotations",
|
||||||
"fileLogsEntriesTab": "Log entries",
|
"fileLogsEntriesTab": "Log entries",
|
||||||
"fileLogsCountsTab": "Repeated values"
|
"fileLogsCountsTab": "Repeated values",
|
||||||
|
"streamWidgetOverviewDescription": "Configured and running TCP/UDP listeners, including those requiring attention.",
|
||||||
|
"streamWidgetTrafficDescription": "Total Stream receive/transmit rate from the shared live status sample.",
|
||||||
|
"streamWidgetSessionsDescription": "Current TCP connections and UDP client sessions.",
|
||||||
|
"streamWidgetHealthDescription": "Healthy, unhealthy and unverified backends and pools with no eligible target."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+55
-2
@@ -16,7 +16,6 @@
|
|||||||
"vhUaRateHelp": "Pierwszy pasujący wzorzec wygrywa. Dopasowanie bez rozróżniania wielkości liter; fragment lub *. Limity są osobne dla IP i vhosta, niezależne od limitów ogólnych i krajowych. Działają także po wyłączeniu limitu ogólnego.",
|
"vhUaRateHelp": "Pierwszy pasujący wzorzec wygrywa. Dopasowanie bez rozróżniania wielkości liter; fragment lub *. Limity są osobne dla IP i vhosta, niezależne od limitów ogólnych i krajowych. Działają także po wyłączeniu limitu ogólnego.",
|
||||||
"configHistoryFullCompare": "Pełne porównanie JSON",
|
"configHistoryFullCompare": "Pełne porównanie JSON",
|
||||||
"configHistoryChangedFields": "Tylko zmienione pola",
|
"configHistoryChangedFields": "Tylko zmienione pola",
|
||||||
|
|
||||||
"overview": "Przegląd",
|
"overview": "Przegląd",
|
||||||
"mainMenu": "Menu",
|
"mainMenu": "Menu",
|
||||||
"routing": "Starsze trasy",
|
"routing": "Starsze trasy",
|
||||||
@@ -1072,6 +1071,56 @@
|
|||||||
"aclProfileBulkHelp": "Wklej po jednej regule „allow|deny CIDR” w wierszu. Niewidoczne odstępy Unicode są normalizowane, a błąd wskazuje dokładny wiersz.",
|
"aclProfileBulkHelp": "Wklej po jednej regule „allow|deny CIDR” w wierszu. Niewidoczne odstępy Unicode są normalizowane, a błąd wskazuje dokładny wiersz.",
|
||||||
"aclProfileImportRules": "Importuj reguły",
|
"aclProfileImportRules": "Importuj reguły",
|
||||||
"aclProfileRulesImported": "Reguły ACL zaimportowano do edytora graficznego.",
|
"aclProfileRulesImported": "Reguły ACL zaimportowano do edytora graficznego.",
|
||||||
|
"streamWidgetOverview": "Przegląd Stream",
|
||||||
|
"streamWidgetTraffic": "Ruch Stream",
|
||||||
|
"streamWidgetSessions": "Sesje Stream",
|
||||||
|
"streamWidgetHealth": "Stan backendów Stream",
|
||||||
|
"streamWidgetUnavailable": "Brak danych Stream",
|
||||||
|
"streamWidgetActiveConfigured": "aktywne / skonfigurowane",
|
||||||
|
"streamWidgetAttention": "wymagają uwagi",
|
||||||
|
"streamWidgetHealthyUnhealthy": "zdrowe / niedostępne",
|
||||||
|
"streamWidgetUnverified": "niezweryfikowane",
|
||||||
|
"streamWidgetEmptyPools": "pule bez backendu",
|
||||||
|
"streamWidgetSettingsHelp": "Karty Stream są domyślnie ukryte. Zmiana widoczności nie wpływa na routing ani monitorowanie.",
|
||||||
|
"streamBackendPool": "Pula backendów",
|
||||||
|
"streamBackend": "Backend",
|
||||||
|
"streamBackendId": "ID backendu",
|
||||||
|
"streamBackendName": "Nazwa backendu",
|
||||||
|
"streamBackendAddress": "Adres / port",
|
||||||
|
"streamBackendWeight": "Waga (1–16)",
|
||||||
|
"streamBackendMaxSessions": "Limit sesji (opcjonalnie)",
|
||||||
|
"streamBackendDrain": "Drain: bez nowych sesji",
|
||||||
|
"streamBackendAdd": "Dodaj backend",
|
||||||
|
"streamBackendDuplicate": "ID backendów muszą być unikalne.",
|
||||||
|
"streamBackendLimit": "Maksymalnie 32 backendy na Stream.",
|
||||||
|
"streamBackendRequired": "Wymagany jest co najmniej jeden backend.",
|
||||||
|
"streamBalance": "Algorytm balansowania",
|
||||||
|
"streamRoundRobin": "Ważony Round Robin",
|
||||||
|
"streamLeastSessions": "Najmniej połączeń / sesji",
|
||||||
|
"streamDetailMetrics": "Szczegółowe metryki",
|
||||||
|
"streamBackendDetailMetrics": "Szczegółowe metryki backendu",
|
||||||
|
"streamMetricsFromStream": "Z ustawień Stream",
|
||||||
|
"streamMetricsDefaultOff": "Domyślnie wyłączone",
|
||||||
|
"streamMetricsHelp": "Domyślnie szczegółowe metryki Stream są wyłączone. Każdy backend może nadpisać to ustawienie. Eksport do Prometheus wymaga osobno globalnego include_streams; podstawowe liczniki działają zawsze.",
|
||||||
|
"streamInherit": "Dziedzicz",
|
||||||
|
"streamMetricsOn": "Włączone",
|
||||||
|
"streamMetricsOff": "Wyłączone",
|
||||||
|
"streamHealthMode": "Test stanu",
|
||||||
|
"streamHealthOff": "Wyłączony (niezweryfikowany)",
|
||||||
|
"streamHealthTcp": "Połączenie TCP",
|
||||||
|
"streamHealthUdp": "Sonda UDP (wymagana odpowiedź)",
|
||||||
|
"streamHealthInterval": "Interwał sondy (sekundy)",
|
||||||
|
"streamHealthTimeout": "Timeout sondy (ms)",
|
||||||
|
"streamHealthRise": "Próg sukcesów (rise)",
|
||||||
|
"streamHealthFall": "Próg błędów (fall)",
|
||||||
|
"streamHealthPayload": "Dane UDP (hex)",
|
||||||
|
"streamHealthExpected": "Oczekiwana odpowiedź UDP (hex)",
|
||||||
|
"streamHealthUdpHelp": "Sonda UDP działa tylko przy zdefiniowanych danych i oczekiwanej odpowiedzi. Samo UDP connect nie dowodzi dostępności.",
|
||||||
|
"streamHealthState_healthy": "zdrowy",
|
||||||
|
"streamHealthState_unhealthy": "niedostępny",
|
||||||
|
"streamHealthState_checking": "sprawdzanie",
|
||||||
|
"streamHealthState_disabled": "wyłączony",
|
||||||
|
"streamHealthState_unverified": "niezweryfikowany",
|
||||||
"streamHosts": "Hosty stream",
|
"streamHosts": "Hosty stream",
|
||||||
"streamsHelp": "Surowe przekazywanie TCP/UDP na osobnych portach. Ruch stream omija vhosty HTTP, ACL, cache, nagłówki i kompresję.",
|
"streamsHelp": "Surowe przekazywanie TCP/UDP na osobnych portach. Ruch stream omija vhosty HTTP, ACL, cache, nagłówki i kompresję.",
|
||||||
"streamAdd": "Dodaj host stream",
|
"streamAdd": "Dodaj host stream",
|
||||||
@@ -2387,6 +2436,10 @@
|
|||||||
"copyValue": "Kopiuj wartość",
|
"copyValue": "Kopiuj wartość",
|
||||||
"fileLogsConsolidateShort": "Scal rotacje",
|
"fileLogsConsolidateShort": "Scal rotacje",
|
||||||
"fileLogsEntriesTab": "Wpisy logów",
|
"fileLogsEntriesTab": "Wpisy logów",
|
||||||
"fileLogsCountsTab": "Powtarzające się wartości"
|
"fileLogsCountsTab": "Powtarzające się wartości",
|
||||||
|
"streamWidgetOverviewDescription": "Liczba skonfigurowanych i uruchomionych listenerów TCP/UDP oraz wymagających uwagi.",
|
||||||
|
"streamWidgetTrafficDescription": "Łączna szybkość odbioru i wysyłania na podstawie wspólnego odczytu statusu.",
|
||||||
|
"streamWidgetSessionsDescription": "Aktywne połączenia TCP i sesje klientów UDP.",
|
||||||
|
"streamWidgetHealthDescription": "Zdrowe, niedostępne i niezweryfikowane backendy oraz pule bez dostępnego celu."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,3 +9,10 @@ External development and QA helpers live under `tools/`; external platform integ
|
|||||||
- `migration/` - application migration utilities, currently the Nginx Proxy Manager importer.
|
- `migration/` - application migration utilities, currently the Nginx Proxy Manager importer.
|
||||||
|
|
||||||
Keep these paths stable because Rust source, `build.rs` and shell entry points reference them directly.
|
Keep these paths stable because Rust source, `build.rs` and shell entry points reference them directly.
|
||||||
|
|
||||||
|
## Stream TCP/UDP test helpers
|
||||||
|
|
||||||
|
- `start_stream_backends.sh` — launches two TCP echo mocks (9101, 9102) and two UDP echo/probe mocks (9201, 9202), defaulting to loopback, and terminates all on Ctrl+C.
|
||||||
|
- `test_stream_tcp.py`, `test_stream_udp.py` — independent `server`, `client` and `selftest` modes. See `docs/STREAMS.md` for RustProxy listener setup and test commands.
|
||||||
|
|
||||||
|
The selftests test only Python mock peers, **not** RustProxy forwarding.
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start two TCP and two UDP mock backends for RustProxy Stream tests.
|
||||||
|
# Usage: ./start_stream_backends.sh [bind-host]
|
||||||
|
# Defaults to loopback for safety. Use 0.0.0.0 only on a controlled test network.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
HOST="${1:-127.0.0.1}"
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
pids=()
|
||||||
|
|
||||||
|
for file in test_stream_tcp.py test_stream_udp.py; do
|
||||||
|
if [[ ! -f "$DIR/$file" ]]; then
|
||||||
|
echo "Missing $DIR/$file (place the launcher next to the two test scripts)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
if ((${#pids[@]})); then
|
||||||
|
kill "${pids[@]}" 2>/dev/null || true
|
||||||
|
wait "${pids[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
"$PYTHON" "$DIR/test_stream_tcp.py" server --host "$HOST" --port 9101 --backend-id tcp-a & pids+=("$!")
|
||||||
|
"$PYTHON" "$DIR/test_stream_tcp.py" server --host "$HOST" --port 9102 --backend-id tcp-b & pids+=("$!")
|
||||||
|
"$PYTHON" "$DIR/test_stream_udp.py" server --host "$HOST" --port 9201 --backend-id udp-a & pids+=("$!")
|
||||||
|
"$PYTHON" "$DIR/test_stream_udp.py" server --host "$HOST" --port 9202 --backend-id udp-b & pids+=("$!")
|
||||||
|
|
||||||
|
printf '\nMock backends for RustProxy (bind: %s):\n' "$HOST"
|
||||||
|
printf 'TCP: %s:9101 (tcp-a), %s:9102 (tcp-b)\n' "$HOST" "$HOST"
|
||||||
|
printf 'UDP: %s:9201 (udp-a), %s:9202 (udp-b)\n' "$HOST" "$HOST"
|
||||||
|
printf 'UDP health probe: PING => PONG\n'
|
||||||
|
printf 'Stop: Ctrl+C\n\n'
|
||||||
|
|
||||||
|
# Stop the group if any backend exits unexpectedly (e.g., port conflict).
|
||||||
|
wait -n "${pids[@]}"
|
||||||
|
echo 'A backend stopped; stopping all mock backends.' >&2
|
||||||
|
exit 1
|
||||||
Executable
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""TCP Stream black-box test: use against a running RustProxy and mock backends.
|
||||||
|
|
||||||
|
The 'selftest' mode validates this harness only; it does NOT run RustProxy.
|
||||||
|
All traffic uses loopback by default. Do not point it at a production service.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
MAX_LINE = 1024
|
||||||
|
|
||||||
|
|
||||||
|
class TCPServer(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
request_queue_size = 128
|
||||||
|
|
||||||
|
|
||||||
|
class TCPBackend(socketserver.StreamRequestHandler):
|
||||||
|
def handle(self) -> None:
|
||||||
|
self.request.settimeout(10)
|
||||||
|
backend_id = self.server.backend_id.encode('ascii')
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = self.rfile.readline(MAX_LINE + 1)
|
||||||
|
if not line or len(line) > MAX_LINE or not line.endswith(b'\n'):
|
||||||
|
return
|
||||||
|
self.wfile.write(backend_id + b'|' + line)
|
||||||
|
self.wfile.flush()
|
||||||
|
except (OSError, TimeoutError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(host: str, port: int, backend_id: str) -> None:
|
||||||
|
with TCPServer((host, port), TCPBackend) as server:
|
||||||
|
server.backend_id = backend_id
|
||||||
|
print(f'TCP mock {backend_id} listening on {server.server_address}', flush=True)
|
||||||
|
try:
|
||||||
|
server.serve_forever(poll_interval=0.1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def check_one(index: int, host: str, port: int, messages: int, timeout: float, hold_ms: float, nonce: str) -> str:
|
||||||
|
backend = None
|
||||||
|
with socket.create_connection((host, port), timeout=timeout) as connection:
|
||||||
|
connection.settimeout(timeout)
|
||||||
|
with connection.makefile('rb') as reader:
|
||||||
|
for seq in range(messages):
|
||||||
|
payload = f'rp-tcp:{nonce}:{index}:{seq}\n'.encode('ascii')
|
||||||
|
connection.sendall(payload)
|
||||||
|
response = reader.readline(MAX_LINE + 1)
|
||||||
|
try:
|
||||||
|
observed, echoed = response.split(b'|', 1)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AssertionError(f'session {index}: missing mock backend response: {response!r}') from exc
|
||||||
|
assert echoed == payload, f'session {index}: response mismatch: {response!r}'
|
||||||
|
assert observed, f'session {index}: empty backend ID'
|
||||||
|
if backend is None:
|
||||||
|
backend = observed
|
||||||
|
assert observed == backend, f'session {index}: TCP backend changed during connection'
|
||||||
|
if hold_ms and seq != messages - 1:
|
||||||
|
time.sleep(hold_ms / 1000)
|
||||||
|
return backend.decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
def run_client(host: str, port: int, sessions: int, messages: int, parallel: int, timeout: float,
|
||||||
|
hold_ms: float, min_backends: int) -> Counter:
|
||||||
|
nonce = secrets.token_hex(8)
|
||||||
|
started = time.monotonic()
|
||||||
|
counts: Counter[str] = Counter()
|
||||||
|
with ThreadPoolExecutor(max_workers=parallel) as pool:
|
||||||
|
futures = [pool.submit(check_one, i, host, port, messages, timeout, hold_ms, nonce)
|
||||||
|
for i in range(sessions)]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
counts[future.result()] += 1
|
||||||
|
assert len(counts) >= min_backends, f'observed {len(counts)} backend(s), required {min_backends}: {dict(counts)}'
|
||||||
|
print(f'TCP PASS: {sessions} sessions, {sessions * messages} messages, '
|
||||||
|
f'{len(counts)} backend(s), {time.monotonic() - started:.2f}s; assignments={dict(sorted(counts.items()))}')
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest='mode', required=True)
|
||||||
|
server = sub.add_parser('server', help='run a local TCP mock backend')
|
||||||
|
server.add_argument('--host', default='127.0.0.1')
|
||||||
|
server.add_argument('--port', type=int, required=True)
|
||||||
|
server.add_argument('--backend-id', required=True, help='unique ASCII marker (e.g. node-a)')
|
||||||
|
client = sub.add_parser('client', help='test forwarding via an already-running RustProxy listener')
|
||||||
|
client.add_argument('--host', default='127.0.0.1', help='RustProxy TCP listener host')
|
||||||
|
client.add_argument('--port', type=int, required=True, help='RustProxy TCP listener port')
|
||||||
|
client.add_argument('--sessions', type=int, default=24)
|
||||||
|
client.add_argument('--messages', type=int, default=3)
|
||||||
|
client.add_argument('--parallel', type=int, default=8)
|
||||||
|
client.add_argument('--timeout', type=float, default=4.0)
|
||||||
|
client.add_argument('--hold-ms', type=float, default=20.0, help='pause between messages to keep TCP sessions alive')
|
||||||
|
client.add_argument('--min-backends', type=int, default=1, help='minimum distinct backend IDs observed')
|
||||||
|
sub.add_parser('selftest', help='test mock peer + harness directly, WITHOUT RustProxy')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.mode == 'server':
|
||||||
|
if not args.backend_id.isascii() or not args.backend_id or '|' in args.backend_id or '\n' in args.backend_id:
|
||||||
|
parser.error('--backend-id must be nonempty ASCII without | or newline')
|
||||||
|
run_server(args.host, args.port, args.backend_id)
|
||||||
|
elif args.mode == 'client':
|
||||||
|
if min(args.port, args.sessions, args.messages, args.parallel, args.min_backends) < 1 or args.port > 65535 or args.timeout <= 0 or args.hold_ms < 0:
|
||||||
|
parser.error('invalid port, count, timeout or hold delay')
|
||||||
|
run_client(args.host, args.port, args.sessions, args.messages, args.parallel,
|
||||||
|
args.timeout, args.hold_ms, args.min_backends)
|
||||||
|
else:
|
||||||
|
with TCPServer(('127.0.0.1', 0), TCPBackend) as server:
|
||||||
|
server.backend_id = 'local-mock'
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
run_client('127.0.0.1', server.server_address[1], 8, 3, 4, 2.0, 5, 1)
|
||||||
|
print('SELFTEST PASS: tested mock TCP peer, not RustProxy')
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (OSError, AssertionError, UnicodeError) as exc:
|
||||||
|
print(f'TCP FAIL: {exc}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""UDP Stream black-box test: sends multiple packets from each stable client socket.
|
||||||
|
|
||||||
|
Start separately labeled mock backends and target an existing RustProxy UDP
|
||||||
|
listener. 'selftest' only validates this Python harness, NOT RustProxy.
|
||||||
|
By default, the server responds PONG to PING for an explicitly configured probe.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
MAX_DATAGRAM = 1024
|
||||||
|
|
||||||
|
|
||||||
|
class UDPServer(socketserver.UDPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
class UDPBackend(socketserver.BaseRequestHandler):
|
||||||
|
def handle(self) -> None:
|
||||||
|
data, connection = self.request
|
||||||
|
if data == b'PING':
|
||||||
|
reply = b'PONG'
|
||||||
|
elif data and len(data) <= MAX_DATAGRAM:
|
||||||
|
reply = self.server.backend_id.encode('ascii') + b'|' + data
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
connection.sendto(reply, self.client_address)
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(host: str, port: int, backend_id: str) -> None:
|
||||||
|
with UDPServer((host, port), UDPBackend) as server:
|
||||||
|
server.backend_id = backend_id
|
||||||
|
print(f'UDP mock {backend_id} listening on {server.server_address}; health probe PING -> PONG', flush=True)
|
||||||
|
try:
|
||||||
|
server.serve_forever(poll_interval=0.1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def check_one(index: int, host: str, port: int, messages: int, timeout: float, pause_ms: float, nonce: str) -> str:
|
||||||
|
info = socket.getaddrinfo(host, port, type=socket.SOCK_DGRAM)[0]
|
||||||
|
family, sock_type, proto, _, address = info
|
||||||
|
backend = None
|
||||||
|
# One socket = one stable client identity for the entire UDP Stream session.
|
||||||
|
with socket.socket(family, sock_type, proto) as connection:
|
||||||
|
connection.settimeout(timeout)
|
||||||
|
for seq in range(messages):
|
||||||
|
payload = f'rp-udp:{nonce}:{index}:{seq}'.encode('ascii')
|
||||||
|
connection.sendto(payload, address)
|
||||||
|
response, _ = connection.recvfrom(MAX_DATAGRAM + 128)
|
||||||
|
try:
|
||||||
|
observed, echoed = response.split(b'|', 1)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AssertionError(f'session {index}: missing mock backend response: {response!r}') from exc
|
||||||
|
assert echoed == payload, f'session {index}: response mismatch {response!r}'
|
||||||
|
assert observed, f'session {index}: empty backend ID'
|
||||||
|
if backend is None:
|
||||||
|
backend = observed
|
||||||
|
assert observed == backend, f'session {index}: UDP backend changed for the same client socket'
|
||||||
|
if pause_ms and seq != messages - 1:
|
||||||
|
time.sleep(pause_ms / 1000)
|
||||||
|
return backend.decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
def run_client(host: str, port: int, sessions: int, messages: int, parallel: int, timeout: float,
|
||||||
|
pause_ms: float, min_backends: int) -> Counter:
|
||||||
|
nonce = secrets.token_hex(8)
|
||||||
|
started = time.monotonic()
|
||||||
|
counts: Counter[str] = Counter()
|
||||||
|
with ThreadPoolExecutor(max_workers=parallel) as pool:
|
||||||
|
futures = [pool.submit(check_one, i, host, port, messages, timeout, pause_ms, nonce)
|
||||||
|
for i in range(sessions)]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
counts[future.result()] += 1
|
||||||
|
assert len(counts) >= min_backends, f'observed {len(counts)} backend(s), required {min_backends}: {dict(counts)}'
|
||||||
|
print(f'UDP PASS: {sessions} client sockets, {sessions * messages} datagrams, '
|
||||||
|
f'{len(counts)} backend(s), {time.monotonic() - started:.2f}s; assignments={dict(sorted(counts.items()))}')
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest='mode', required=True)
|
||||||
|
server = sub.add_parser('server', help='run local UDP echo and optional PING/PONG probe responder')
|
||||||
|
server.add_argument('--host', default='127.0.0.1')
|
||||||
|
server.add_argument('--port', type=int, required=True)
|
||||||
|
server.add_argument('--backend-id', required=True, help='unique ASCII marker (e.g. node-a)')
|
||||||
|
client = sub.add_parser('client', help='test forwarding via an already-running RustProxy UDP listener')
|
||||||
|
client.add_argument('--host', default='127.0.0.1', help='RustProxy UDP listener host')
|
||||||
|
client.add_argument('--port', type=int, required=True, help='RustProxy UDP listener port')
|
||||||
|
client.add_argument('--sessions', type=int, default=24)
|
||||||
|
client.add_argument('--messages', type=int, default=3)
|
||||||
|
client.add_argument('--parallel', type=int, default=8)
|
||||||
|
client.add_argument('--timeout', type=float, default=4.0)
|
||||||
|
client.add_argument('--pause-ms', type=float, default=20.0, help='pause between datagrams on each stable client socket')
|
||||||
|
client.add_argument('--min-backends', type=int, default=1, help='minimum distinct backend IDs observed')
|
||||||
|
sub.add_parser('selftest', help='test mock peer + harness directly, WITHOUT RustProxy')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.mode == 'server':
|
||||||
|
if not args.backend_id.isascii() or not args.backend_id or '|' in args.backend_id or '\n' in args.backend_id:
|
||||||
|
parser.error('--backend-id must be nonempty ASCII without | or newline')
|
||||||
|
run_server(args.host, args.port, args.backend_id)
|
||||||
|
elif args.mode == 'client':
|
||||||
|
if min(args.port, args.sessions, args.messages, args.parallel, args.min_backends) < 1 or args.port > 65535 or args.timeout <= 0 or args.pause_ms < 0:
|
||||||
|
parser.error('invalid port, count, timeout or pause delay')
|
||||||
|
run_client(args.host, args.port, args.sessions, args.messages, args.parallel,
|
||||||
|
args.timeout, args.pause_ms, args.min_backends)
|
||||||
|
else:
|
||||||
|
with UDPServer(('127.0.0.1', 0), UDPBackend) as server:
|
||||||
|
server.backend_id = 'local-mock'
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
run_client('127.0.0.1', server.server_address[1], 8, 3, 4, 2.0, 5, 1)
|
||||||
|
print('SELFTEST PASS: tested mock UDP peer, not RustProxy')
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (OSError, AssertionError, UnicodeError) as exc:
|
||||||
|
print(f'UDP FAIL: {exc}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Executable
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""TCP Stream black-box test: use against a running RustProxy and mock backends.
|
||||||
|
|
||||||
|
The 'selftest' mode validates this harness only; it does NOT run RustProxy.
|
||||||
|
All traffic uses loopback by default. Do not point it at a production service.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
MAX_LINE = 1024
|
||||||
|
|
||||||
|
|
||||||
|
class TCPServer(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
request_queue_size = 128
|
||||||
|
|
||||||
|
|
||||||
|
class TCPBackend(socketserver.StreamRequestHandler):
|
||||||
|
def handle(self) -> None:
|
||||||
|
self.request.settimeout(10)
|
||||||
|
backend_id = self.server.backend_id.encode('ascii')
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = self.rfile.readline(MAX_LINE + 1)
|
||||||
|
if not line or len(line) > MAX_LINE or not line.endswith(b'\n'):
|
||||||
|
return
|
||||||
|
self.wfile.write(backend_id + b'|' + line)
|
||||||
|
self.wfile.flush()
|
||||||
|
except (OSError, TimeoutError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(host: str, port: int, backend_id: str) -> None:
|
||||||
|
with TCPServer((host, port), TCPBackend) as server:
|
||||||
|
server.backend_id = backend_id
|
||||||
|
print(f'TCP mock {backend_id} listening on {server.server_address}', flush=True)
|
||||||
|
try:
|
||||||
|
server.serve_forever(poll_interval=0.1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def check_one(index: int, host: str, port: int, messages: int, timeout: float, hold_ms: float, nonce: str) -> str:
|
||||||
|
backend = None
|
||||||
|
with socket.create_connection((host, port), timeout=timeout) as connection:
|
||||||
|
connection.settimeout(timeout)
|
||||||
|
with connection.makefile('rb') as reader:
|
||||||
|
for seq in range(messages):
|
||||||
|
payload = f'rp-tcp:{nonce}:{index}:{seq}\n'.encode('ascii')
|
||||||
|
connection.sendall(payload)
|
||||||
|
response = reader.readline(MAX_LINE + 1)
|
||||||
|
try:
|
||||||
|
observed, echoed = response.split(b'|', 1)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AssertionError(f'session {index}: missing mock backend response: {response!r}') from exc
|
||||||
|
assert echoed == payload, f'session {index}: response mismatch: {response!r}'
|
||||||
|
assert observed, f'session {index}: empty backend ID'
|
||||||
|
if backend is None:
|
||||||
|
backend = observed
|
||||||
|
assert observed == backend, f'session {index}: TCP backend changed during connection'
|
||||||
|
if hold_ms and seq != messages - 1:
|
||||||
|
time.sleep(hold_ms / 1000)
|
||||||
|
return backend.decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
def run_client(host: str, port: int, sessions: int, messages: int, parallel: int, timeout: float,
|
||||||
|
hold_ms: float, min_backends: int) -> Counter:
|
||||||
|
nonce = secrets.token_hex(8)
|
||||||
|
started = time.monotonic()
|
||||||
|
counts: Counter[str] = Counter()
|
||||||
|
with ThreadPoolExecutor(max_workers=parallel) as pool:
|
||||||
|
futures = [pool.submit(check_one, i, host, port, messages, timeout, hold_ms, nonce)
|
||||||
|
for i in range(sessions)]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
counts[future.result()] += 1
|
||||||
|
assert len(counts) >= min_backends, f'observed {len(counts)} backend(s), required {min_backends}: {dict(counts)}'
|
||||||
|
print(f'TCP PASS: {sessions} sessions, {sessions * messages} messages, '
|
||||||
|
f'{len(counts)} backend(s), {time.monotonic() - started:.2f}s; assignments={dict(sorted(counts.items()))}')
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest='mode', required=True)
|
||||||
|
server = sub.add_parser('server', help='run a local TCP mock backend')
|
||||||
|
server.add_argument('--host', default='127.0.0.1')
|
||||||
|
server.add_argument('--port', type=int, required=True)
|
||||||
|
server.add_argument('--backend-id', required=True, help='unique ASCII marker (e.g. node-a)')
|
||||||
|
client = sub.add_parser('client', help='test forwarding via an already-running RustProxy listener')
|
||||||
|
client.add_argument('--host', default='127.0.0.1', help='RustProxy TCP listener host')
|
||||||
|
client.add_argument('--port', type=int, required=True, help='RustProxy TCP listener port')
|
||||||
|
client.add_argument('--sessions', type=int, default=24)
|
||||||
|
client.add_argument('--messages', type=int, default=3)
|
||||||
|
client.add_argument('--parallel', type=int, default=8)
|
||||||
|
client.add_argument('--timeout', type=float, default=4.0)
|
||||||
|
client.add_argument('--hold-ms', type=float, default=20.0, help='pause between messages to keep TCP sessions alive')
|
||||||
|
client.add_argument('--min-backends', type=int, default=1, help='minimum distinct backend IDs observed')
|
||||||
|
sub.add_parser('selftest', help='test mock peer + harness directly, WITHOUT RustProxy')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.mode == 'server':
|
||||||
|
if not args.backend_id.isascii() or not args.backend_id or '|' in args.backend_id or '\n' in args.backend_id:
|
||||||
|
parser.error('--backend-id must be nonempty ASCII without | or newline')
|
||||||
|
run_server(args.host, args.port, args.backend_id)
|
||||||
|
elif args.mode == 'client':
|
||||||
|
if min(args.port, args.sessions, args.messages, args.parallel, args.min_backends) < 1 or args.port > 65535 or args.timeout <= 0 or args.hold_ms < 0:
|
||||||
|
parser.error('invalid port, count, timeout or hold delay')
|
||||||
|
run_client(args.host, args.port, args.sessions, args.messages, args.parallel,
|
||||||
|
args.timeout, args.hold_ms, args.min_backends)
|
||||||
|
else:
|
||||||
|
with TCPServer(('127.0.0.1', 0), TCPBackend) as server:
|
||||||
|
server.backend_id = 'local-mock'
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
run_client('127.0.0.1', server.server_address[1], 8, 3, 4, 2.0, 5, 1)
|
||||||
|
print('SELFTEST PASS: tested mock TCP peer, not RustProxy')
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (OSError, AssertionError, UnicodeError) as exc:
|
||||||
|
print(f'TCP FAIL: {exc}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""UDP Stream black-box test: sends multiple packets from each stable client socket.
|
||||||
|
|
||||||
|
Start separately labeled mock backends and target an existing RustProxy UDP
|
||||||
|
listener. 'selftest' only validates this Python harness, NOT RustProxy.
|
||||||
|
By default, the server responds PONG to PING for an explicitly configured probe.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
MAX_DATAGRAM = 1024
|
||||||
|
|
||||||
|
|
||||||
|
class UDPServer(socketserver.UDPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
class UDPBackend(socketserver.BaseRequestHandler):
|
||||||
|
def handle(self) -> None:
|
||||||
|
data, connection = self.request
|
||||||
|
if data == b'PING':
|
||||||
|
reply = b'PONG'
|
||||||
|
elif data and len(data) <= MAX_DATAGRAM:
|
||||||
|
reply = self.server.backend_id.encode('ascii') + b'|' + data
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
connection.sendto(reply, self.client_address)
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(host: str, port: int, backend_id: str) -> None:
|
||||||
|
with UDPServer((host, port), UDPBackend) as server:
|
||||||
|
server.backend_id = backend_id
|
||||||
|
print(f'UDP mock {backend_id} listening on {server.server_address}; health probe PING -> PONG', flush=True)
|
||||||
|
try:
|
||||||
|
server.serve_forever(poll_interval=0.1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def check_one(index: int, host: str, port: int, messages: int, timeout: float, pause_ms: float, nonce: str) -> str:
|
||||||
|
info = socket.getaddrinfo(host, port, type=socket.SOCK_DGRAM)[0]
|
||||||
|
family, sock_type, proto, _, address = info
|
||||||
|
backend = None
|
||||||
|
# One socket = one stable client identity for the entire UDP Stream session.
|
||||||
|
with socket.socket(family, sock_type, proto) as connection:
|
||||||
|
connection.settimeout(timeout)
|
||||||
|
for seq in range(messages):
|
||||||
|
payload = f'rp-udp:{nonce}:{index}:{seq}'.encode('ascii')
|
||||||
|
connection.sendto(payload, address)
|
||||||
|
response, _ = connection.recvfrom(MAX_DATAGRAM + 128)
|
||||||
|
try:
|
||||||
|
observed, echoed = response.split(b'|', 1)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AssertionError(f'session {index}: missing mock backend response: {response!r}') from exc
|
||||||
|
assert echoed == payload, f'session {index}: response mismatch {response!r}'
|
||||||
|
assert observed, f'session {index}: empty backend ID'
|
||||||
|
if backend is None:
|
||||||
|
backend = observed
|
||||||
|
assert observed == backend, f'session {index}: UDP backend changed for the same client socket'
|
||||||
|
if pause_ms and seq != messages - 1:
|
||||||
|
time.sleep(pause_ms / 1000)
|
||||||
|
return backend.decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
def run_client(host: str, port: int, sessions: int, messages: int, parallel: int, timeout: float,
|
||||||
|
pause_ms: float, min_backends: int) -> Counter:
|
||||||
|
nonce = secrets.token_hex(8)
|
||||||
|
started = time.monotonic()
|
||||||
|
counts: Counter[str] = Counter()
|
||||||
|
with ThreadPoolExecutor(max_workers=parallel) as pool:
|
||||||
|
futures = [pool.submit(check_one, i, host, port, messages, timeout, pause_ms, nonce)
|
||||||
|
for i in range(sessions)]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
counts[future.result()] += 1
|
||||||
|
assert len(counts) >= min_backends, f'observed {len(counts)} backend(s), required {min_backends}: {dict(counts)}'
|
||||||
|
print(f'UDP PASS: {sessions} client sockets, {sessions * messages} datagrams, '
|
||||||
|
f'{len(counts)} backend(s), {time.monotonic() - started:.2f}s; assignments={dict(sorted(counts.items()))}')
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest='mode', required=True)
|
||||||
|
server = sub.add_parser('server', help='run local UDP echo and optional PING/PONG probe responder')
|
||||||
|
server.add_argument('--host', default='127.0.0.1')
|
||||||
|
server.add_argument('--port', type=int, required=True)
|
||||||
|
server.add_argument('--backend-id', required=True, help='unique ASCII marker (e.g. node-a)')
|
||||||
|
client = sub.add_parser('client', help='test forwarding via an already-running RustProxy UDP listener')
|
||||||
|
client.add_argument('--host', default='127.0.0.1', help='RustProxy UDP listener host')
|
||||||
|
client.add_argument('--port', type=int, required=True, help='RustProxy UDP listener port')
|
||||||
|
client.add_argument('--sessions', type=int, default=24)
|
||||||
|
client.add_argument('--messages', type=int, default=3)
|
||||||
|
client.add_argument('--parallel', type=int, default=8)
|
||||||
|
client.add_argument('--timeout', type=float, default=4.0)
|
||||||
|
client.add_argument('--pause-ms', type=float, default=20.0, help='pause between datagrams on each stable client socket')
|
||||||
|
client.add_argument('--min-backends', type=int, default=1, help='minimum distinct backend IDs observed')
|
||||||
|
sub.add_parser('selftest', help='test mock peer + harness directly, WITHOUT RustProxy')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.mode == 'server':
|
||||||
|
if not args.backend_id.isascii() or not args.backend_id or '|' in args.backend_id or '\n' in args.backend_id:
|
||||||
|
parser.error('--backend-id must be nonempty ASCII without | or newline')
|
||||||
|
run_server(args.host, args.port, args.backend_id)
|
||||||
|
elif args.mode == 'client':
|
||||||
|
if min(args.port, args.sessions, args.messages, args.parallel, args.min_backends) < 1 or args.port > 65535 or args.timeout <= 0 or args.pause_ms < 0:
|
||||||
|
parser.error('invalid port, count, timeout or pause delay')
|
||||||
|
run_client(args.host, args.port, args.sessions, args.messages, args.parallel,
|
||||||
|
args.timeout, args.pause_ms, args.min_backends)
|
||||||
|
else:
|
||||||
|
with UDPServer(('127.0.0.1', 0), UDPBackend) as server:
|
||||||
|
server.backend_id = 'local-mock'
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
run_client('127.0.0.1', server.server_address[1], 8, 3, 4, 2.0, 5, 1)
|
||||||
|
print('SELFTEST PASS: tested mock UDP peer, not RustProxy')
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (OSError, AssertionError, UnicodeError) as exc:
|
||||||
|
print(f'UDP FAIL: {exc}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
+270
-10
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "RustProxy Administration API",
|
"title": "RustProxy Administration API",
|
||||||
"version": "0.31.9",
|
"version": "0.32.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": [
|
||||||
@@ -1521,7 +1521,7 @@
|
|||||||
},
|
},
|
||||||
"upstream": {
|
"upstream": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Single raw upstream as IP:port."
|
"description": "Legacy single TCP/UDP upstream (IP:port); used only when backends is empty. An omitted field gets the legacy model default."
|
||||||
},
|
},
|
||||||
"connect_timeout_seconds": {
|
"connect_timeout_seconds": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -1537,13 +1537,34 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 65535
|
"maximum": 65535
|
||||||
|
},
|
||||||
|
"backends": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 32,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/StreamBackend"
|
||||||
|
},
|
||||||
|
"description": "Optional backend pool. Empty retains legacy upstream as primary."
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"round_robin",
|
||||||
|
"least_connections",
|
||||||
|
"least_sessions"
|
||||||
|
],
|
||||||
|
"default": "round_robin"
|
||||||
|
},
|
||||||
|
"metrics_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Optional default for per-backend detailed accounting. Null defaults off; basic Stream counters remain active and Prometheus include_streams remains authoritative."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"protocol",
|
"protocol",
|
||||||
"listen",
|
"listen"
|
||||||
"upstream"
|
|
||||||
],
|
],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Layer-4 stream host. It bypasses the HTTP vhost/ACL/cache/header/compression pipeline."
|
"description": "Layer-4 stream host. It bypasses the HTTP vhost/ACL/cache/header/compression pipeline."
|
||||||
@@ -1595,6 +1616,13 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"backends": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/StreamBackendMetrics"
|
||||||
|
},
|
||||||
|
"description": "Runtime backend states; detail metrics are null when disabled."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -3350,7 +3378,7 @@
|
|||||||
"dashboard_widgets": {
|
"dashboard_widgets": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"maxItems": 12,
|
"maxItems": 16,
|
||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -3366,7 +3394,11 @@
|
|||||||
"http3",
|
"http3",
|
||||||
"tls",
|
"tls",
|
||||||
"http_protocol",
|
"http_protocol",
|
||||||
"websocket_protocol"
|
"websocket_protocol",
|
||||||
|
"stream_overview",
|
||||||
|
"stream_traffic",
|
||||||
|
"stream_sessions",
|
||||||
|
"stream_backend_health"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"default": [
|
"default": [
|
||||||
@@ -3381,13 +3413,17 @@
|
|||||||
"http3",
|
"http3",
|
||||||
"tls",
|
"tls",
|
||||||
"http_protocol",
|
"http_protocol",
|
||||||
"websocket_protocol"
|
"websocket_protocol",
|
||||||
|
"stream_overview",
|
||||||
|
"stream_traffic",
|
||||||
|
"stream_sessions",
|
||||||
|
"stream_backend_health"
|
||||||
],
|
],
|
||||||
"description": "Account-scoped order for configurable Overview telemetry cards."
|
"description": "Account-scoped order for configurable Overview telemetry cards."
|
||||||
},
|
},
|
||||||
"dashboard_hidden_widgets": {
|
"dashboard_hidden_widgets": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"maxItems": 12,
|
"maxItems": 16,
|
||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -3403,10 +3439,19 @@
|
|||||||
"http3",
|
"http3",
|
||||||
"tls",
|
"tls",
|
||||||
"http_protocol",
|
"http_protocol",
|
||||||
"websocket_protocol"
|
"websocket_protocol",
|
||||||
|
"stream_overview",
|
||||||
|
"stream_traffic",
|
||||||
|
"stream_sessions",
|
||||||
|
"stream_backend_health"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"default": [],
|
"default": [
|
||||||
|
"stream_overview",
|
||||||
|
"stream_traffic",
|
||||||
|
"stream_sessions",
|
||||||
|
"stream_backend_health"
|
||||||
|
],
|
||||||
"description": "Account-scoped Overview telemetry cards hidden from the dashboard."
|
"description": "Account-scoped Overview telemetry cards hidden from the dashboard."
|
||||||
},
|
},
|
||||||
"backend_table_columns": {
|
"backend_table_columns": {
|
||||||
@@ -6116,6 +6161,221 @@
|
|||||||
"credential",
|
"credential",
|
||||||
"token"
|
"token"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"StreamBackend": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"address"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z0-9_-]{1,64}$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 96
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Concrete upstream IP:port"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"drain": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 16,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"max_sessions": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 65535
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"$ref": "#/components/schemas/StreamHealthCheck"
|
||||||
|
},
|
||||||
|
"metrics_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Optional detailed backend metrics. Null inherits Stream metrics_enabled (off when unset); an explicit boolean overrides it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StreamHealthCheck": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"tcp_connect",
|
||||||
|
"udp_probe"
|
||||||
|
],
|
||||||
|
"default": "off"
|
||||||
|
},
|
||||||
|
"interval_seconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 3600,
|
||||||
|
"default": 10
|
||||||
|
},
|
||||||
|
"timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 50,
|
||||||
|
"maximum": 30000,
|
||||||
|
"default": 1000
|
||||||
|
},
|
||||||
|
"rise": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10,
|
||||||
|
"default": 2
|
||||||
|
},
|
||||||
|
"fall": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10,
|
||||||
|
"default": 2
|
||||||
|
},
|
||||||
|
"payload_hex": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 256,
|
||||||
|
"description": "At most 128 bytes; sent only when exact reply expected"
|
||||||
|
},
|
||||||
|
"expect_hex": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"maxLength": 256,
|
||||||
|
"description": "Exact response; without it, UDP stays Unverified and no probe is sent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StreamBackendMetrics": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"drain": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"detail_enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"unverified",
|
||||||
|
"checking",
|
||||||
|
"healthy",
|
||||||
|
"unhealthy",
|
||||||
|
"disabled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"sessions_total": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"sessions_completed": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"assignments": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"bytes_in": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"bytes_out": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"packets_in": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"packets_out": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"last_checked_unix_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"latency_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"successful": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"last_result": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"failure"
|
||||||
|
],
|
||||||
|
"description": "Most recent active probe outcome, null if no probe has completed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,8 +30,37 @@ fn default_admin_dashboard_widgets() -> Vec<String> {
|
|||||||
.map(|value| (*value).to_owned())
|
.map(|value| (*value).to_owned())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
fn default_file_log_sort() -> String { "time".to_owned() }
|
const STREAM_DASHBOARD_WIDGETS: &[&str] = &[
|
||||||
fn default_file_log_descending() -> bool { true }
|
"stream_overview",
|
||||||
|
"stream_traffic",
|
||||||
|
"stream_sessions",
|
||||||
|
"stream_backend_health",
|
||||||
|
];
|
||||||
|
fn default_hidden_dashboard_widgets() -> Vec<String> {
|
||||||
|
STREAM_DASHBOARD_WIDGETS
|
||||||
|
.iter()
|
||||||
|
.map(|id| (*id).to_owned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
// Old accounts stored a complete 12-card order and often an explicit empty
|
||||||
|
// hidden list. Upgrade it in memory: append opt-in Stream cards as hidden.
|
||||||
|
fn migrate_dashboard_widgets(prefs: &mut AdminPreferences) {
|
||||||
|
for id in STREAM_DASHBOARD_WIDGETS {
|
||||||
|
if !prefs.dashboard_widgets.iter().any(|item| item == id) {
|
||||||
|
prefs.dashboard_widgets.push((*id).to_owned());
|
||||||
|
if !prefs.dashboard_hidden_widgets.iter().any(|item| item == id) {
|
||||||
|
prefs.dashboard_hidden_widgets.push((*id).to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_file_log_sort() -> String {
|
||||||
|
"time".to_owned()
|
||||||
|
}
|
||||||
|
fn default_file_log_descending() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
fn default_file_log_columns() -> Vec<String> {
|
fn default_file_log_columns() -> Vec<String> {
|
||||||
[
|
[
|
||||||
"time",
|
"time",
|
||||||
@@ -97,7 +126,7 @@ struct AdminPreferences {
|
|||||||
dashboard_density: String,
|
dashboard_density: String,
|
||||||
#[serde(default = "default_admin_dashboard_widgets")]
|
#[serde(default = "default_admin_dashboard_widgets")]
|
||||||
dashboard_widgets: Vec<String>,
|
dashboard_widgets: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default = "default_hidden_dashboard_widgets")]
|
||||||
dashboard_hidden_widgets: Vec<String>,
|
dashboard_hidden_widgets: Vec<String>,
|
||||||
#[serde(default = "default_file_log_columns")]
|
#[serde(default = "default_file_log_columns")]
|
||||||
file_log_columns: Vec<String>,
|
file_log_columns: Vec<String>,
|
||||||
@@ -150,7 +179,7 @@ impl Default for AdminPreferences {
|
|||||||
table_density: default_admin_table_density(),
|
table_density: default_admin_table_density(),
|
||||||
dashboard_density: default_admin_dashboard_density(),
|
dashboard_density: default_admin_dashboard_density(),
|
||||||
dashboard_widgets: default_admin_dashboard_widgets(),
|
dashboard_widgets: default_admin_dashboard_widgets(),
|
||||||
dashboard_hidden_widgets: Vec::new(),
|
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
|
||||||
file_log_columns: default_file_log_columns(),
|
file_log_columns: default_file_log_columns(),
|
||||||
file_log_sort: default_file_log_sort(),
|
file_log_sort: default_file_log_sort(),
|
||||||
file_log_descending: default_file_log_descending(),
|
file_log_descending: default_file_log_descending(),
|
||||||
@@ -388,8 +417,16 @@ async fn preferences(
|
|||||||
.map_err(auth::internal)?;
|
.map_err(auth::internal)?;
|
||||||
let preferences = match stored {
|
let preferences = match stored {
|
||||||
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
|
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
|
||||||
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
|
Ok(mut preferences) => {
|
||||||
Ok(_) | Err(_) => {
|
migrate_dashboard_widgets(&mut preferences);
|
||||||
|
if validate_admin_preferences(&preferences).is_ok() {
|
||||||
|
preferences
|
||||||
|
} else {
|
||||||
|
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
||||||
|
AdminPreferences::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
||||||
AdminPreferences::default()
|
AdminPreferences::default()
|
||||||
}
|
}
|
||||||
@@ -420,4 +457,3 @@ async fn update_preferences(
|
|||||||
}
|
}
|
||||||
Ok(Json(preferences))
|
Ok(Json(preferences))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,5 +88,8 @@ const DASHBOARD_WIDGETS: &[&str] = &[
|
|||||||
"tls",
|
"tls",
|
||||||
"http_protocol",
|
"http_protocol",
|
||||||
"websocket_protocol",
|
"websocket_protocol",
|
||||||
|
"stream_overview",
|
||||||
|
"stream_traffic",
|
||||||
|
"stream_sessions",
|
||||||
|
"stream_backend_health",
|
||||||
];
|
];
|
||||||
|
|
||||||
+13
-10
@@ -1,7 +1,7 @@
|
|||||||
mod documentation;
|
mod documentation;
|
||||||
mod public;
|
|
||||||
mod panel_content;
|
|
||||||
mod panel_assets;
|
mod panel_assets;
|
||||||
|
mod panel_content;
|
||||||
|
mod public;
|
||||||
|
|
||||||
// Embedded asset URLs change with every build, independent of Cargo package version.
|
// Embedded asset URLs change with every build, independent of Cargo package version.
|
||||||
// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404.
|
// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404.
|
||||||
@@ -11,9 +11,13 @@ fn asset_build_id() -> &'static str {
|
|||||||
fn fingerprinted_path(path: &str, prefix: &str) -> Option<String> {
|
fn fingerprinted_path(path: &str, prefix: &str) -> Option<String> {
|
||||||
let name = path.strip_prefix(prefix)?;
|
let name = path.strip_prefix(prefix)?;
|
||||||
let (stem, extension) = name.rsplit_once('.')?;
|
let (stem, extension) = name.rsplit_once('.')?;
|
||||||
if extension != "js" && extension != "css" { return None; }
|
if extension != "js" && extension != "css" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let (base, token) = stem.rsplit_once('_')?;
|
let (base, token) = stem.rsplit_once('_')?;
|
||||||
if token != asset_build_id() { return None; }
|
if token != asset_build_id() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
Some(format!("{prefix}{base}.{extension}"))
|
Some(format!("{prefix}{base}.{extension}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ fn admin_app_javascript() -> &'static str {
|
|||||||
include_str!("web/app/session-resume.js"),
|
include_str!("web/app/session-resume.js"),
|
||||||
include_str!("web/app/refresh.js"),
|
include_str!("web/app/refresh.js"),
|
||||||
include_str!("web/app/preferences.js"),
|
include_str!("web/app/preferences.js"),
|
||||||
|
include_str!("web/app/stream-dashboard.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"),
|
||||||
include_str!("web/app/settings.js"),
|
include_str!("web/app/settings.js"),
|
||||||
@@ -306,14 +311,12 @@ fn panel_document_path(path: &str) -> bool {
|
|||||||
fn anonymous_allowed(method: &Method, path: &str) -> bool {
|
fn anonymous_allowed(method: &Method, path: &str) -> bool {
|
||||||
if method == Method::GET || method == Method::HEAD {
|
if method == Method::GET || method == Method::HEAD {
|
||||||
panel_document_path(path)
|
panel_document_path(path)
|
||||||
|| fingerprinted_path(path, "/assets/")
|
|| fingerprinted_path(path, "/assets/").is_some_and(|base| {
|
||||||
.is_some_and(|base| matches!(base.as_str(), "/assets/login.js" | "/assets/style.css"))
|
matches!(base.as_str(), "/assets/login.js" | "/assets/style.css")
|
||||||
|
})
|
||||||
|| matches!(
|
|| matches!(
|
||||||
path,
|
path,
|
||||||
"/assets/login-langs.json"
|
"/assets/login-langs.json" | "/favicon.svg" | "/healthz" | "/api/auth/status"
|
||||||
| "/favicon.svg"
|
|
||||||
| "/healthz"
|
|
||||||
| "/api/auth/status"
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
method == Method::POST && matches!(path, "/api/auth/setup" | "/api/auth/login")
|
method == Method::POST && matches!(path, "/api/auth/setup" | "/api/auth/login")
|
||||||
|
|||||||
+761
-234
File diff suppressed because it is too large.
Load diff
@@ -59,7 +59,7 @@ function translate() {
|
|||||||
window.rustProxyDrafts?.refresh();
|
window.rustProxyDrafts?.refresh();
|
||||||
window.rustProxyPreferences?.refreshAppearanceUi?.();
|
window.rustProxyPreferences?.refreshAppearanceUi?.();
|
||||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||||
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); } if (lastLogs) renderLogs(lastLogs);
|
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); } if (lastLogs) renderLogs(lastLogs);
|
||||||
syncSettingsDirtyIndicators();
|
syncSettingsDirtyIndicators();
|
||||||
}
|
}
|
||||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||||
@@ -160,6 +160,10 @@ function setPanel(name, navigate = true) {
|
|||||||
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);
|
window.rustProxyRefresh?.setPanel(name);
|
||||||
|
// Paint Stream tiles immediately on return from Appearance using cached status.
|
||||||
|
// Do not create an extra sample/poll on navigation.
|
||||||
|
if (name === "overview" && previousPanel !== name && typeof latestStatus !== "undefined" && latestStatus)
|
||||||
|
window.rustProxyStreamDashboard?.render?.(latestStatus, null, false);
|
||||||
// Read the cached Docker status on each visit, independent of the Live-view toggle.
|
// Read the cached Docker status on each visit, independent of the Live-view toggle.
|
||||||
// This is GET /api/docker, not the POST that triggers an agent poll.
|
// This is GET /api/docker, not the POST that triggers an agent poll.
|
||||||
if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker");
|
if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker");
|
||||||
@@ -262,7 +266,7 @@ function renderStatus(s, systemSample = false) {
|
|||||||
$("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, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
|
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(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`;
|
||||||
@@ -370,7 +374,7 @@ function authReturnDestination() {
|
|||||||
function lock(notify = true) {
|
function lock(notify = true) {
|
||||||
window.rustProxyRefresh?.stop();
|
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(); window.rustProxyStreamDashboard?.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(); }
|
||||||
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
||||||
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]);
|
|||||||
const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]);
|
const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]);
|
||||||
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
|
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
|
||||||
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
|
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
|
||||||
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol"]);
|
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol", "stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"]);
|
||||||
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
|
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
|
||||||
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
|
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
|
||||||
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: ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"],
|
||||||
});
|
});
|
||||||
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(), adminPreferencesUnavailable = false;
|
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
||||||
@@ -24,9 +24,14 @@ function normalizeDashboardWidgets(value) {
|
|||||||
for (const id of ADMIN_DASHBOARD_WIDGETS) if (!seen.has(id)) result.push(id);
|
for (const id of ADMIN_DASHBOARD_WIDGETS) if (!seen.has(id)) result.push(id);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
function normalizeHiddenDashboardWidgets(value) {
|
function normalizeHiddenDashboardWidgets(value, savedOrder = ADMIN_DASHBOARD_WIDGETS) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
|
const hidden = (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
|
||||||
|
// Also protect preferences written by 0.31.x with an explicit empty hidden list.
|
||||||
|
for (const id of ADMIN_DASHBOARD_WIDGETS.filter(id => id.startsWith("stream_"))) {
|
||||||
|
if (!savedOrder?.includes(id) && !seen.has(id)) { hidden.push(id); seen.add(id); }
|
||||||
|
}
|
||||||
|
return hidden;
|
||||||
}
|
}
|
||||||
function browserLanguage() {
|
function browserLanguage() {
|
||||||
const packs = [...languagePacks.values()], candidates = (navigator.languages || [navigator.language]).filter(Boolean).map(value => value.replaceAll("_", "-").toLowerCase());
|
const packs = [...languagePacks.values()], candidates = (navigator.languages || [navigator.language]).filter(Boolean).map(value => value.replaceAll("_", "-").toLowerCase());
|
||||||
@@ -81,6 +86,11 @@ function applyDashboardLayout(order, hidden) {
|
|||||||
const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] };
|
const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] };
|
||||||
const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node]));
|
const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node]));
|
||||||
for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); }
|
for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); }
|
||||||
|
// Apply the newly selected visibility, not the previous saved preferences.
|
||||||
|
// During load, adminPreferences is assigned only after this function returns.
|
||||||
|
window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
|
||||||
|
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview")
|
||||||
|
window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false);
|
||||||
return { order: normalizedOrder, hidden: [...hiddenSet] };
|
return { order: normalizedOrder, hidden: [...hiddenSet] };
|
||||||
}
|
}
|
||||||
function dashboardWidgetLabel(id) {
|
function dashboardWidgetLabel(id) {
|
||||||
@@ -94,7 +104,18 @@ function renderDashboardWidgetSettings() {
|
|||||||
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
|
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
|
||||||
const visible = !hidden.has(id);
|
const visible = !hidden.has(id);
|
||||||
const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
|
const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
|
||||||
toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
|
const labelBlock = el("span", undefined, "dashboard-widget-setting-label-block");
|
||||||
|
labelBlock.append(el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
|
||||||
|
const descriptionKey = ({ stream_overview: "streamWidgetOverviewDescription", stream_traffic: "streamWidgetTrafficDescription", stream_sessions: "streamWidgetSessionsDescription", stream_backend_health: "streamWidgetHealthDescription" })[id];
|
||||||
|
if (descriptionKey) {
|
||||||
|
const description = el("small", t(descriptionKey), "dashboard-widget-description");
|
||||||
|
description.dataset.i18n = descriptionKey;
|
||||||
|
description.id = `dashboard-widget-help-${id}`;
|
||||||
|
description.setAttribute("role", "tooltip");
|
||||||
|
toggle.setAttribute("aria-describedby", description.id);
|
||||||
|
labelBlock.append(description);
|
||||||
|
}
|
||||||
|
toggle.append(el("span", undefined, "dashboard-widget-visibility"), labelBlock);
|
||||||
const actions = el("span", undefined, "dashboard-widget-setting-actions");
|
const actions = el("span", undefined, "dashboard-widget-setting-actions");
|
||||||
const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp"));
|
const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp"));
|
||||||
const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown"));
|
const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown"));
|
||||||
@@ -127,7 +148,7 @@ function formatDateTime(value) { const date = value instanceof Date ? value : ne
|
|||||||
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) {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
const dashboard = applyDashboardLayout(value.dashboard_widgets, value.dashboard_hidden_widgets);
|
const dashboard = applyDashboardLayout(value.dashboard_widgets, normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets));
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...value,
|
...value,
|
||||||
refresh: normalizeRefreshPreferences(value.refresh),
|
refresh: normalizeRefreshPreferences(value.refresh),
|
||||||
@@ -279,9 +300,9 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
|
|||||||
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
|
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
|
||||||
const patches = {
|
const patches = {
|
||||||
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
|
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
|
||||||
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] },
|
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] },
|
||||||
};
|
};
|
||||||
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
||||||
}));
|
}));
|
||||||
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] }, $("appearance-reset"), "appearanceResetDone"));
|
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] }, $("appearance-reset"), "appearanceResetDone"));
|
||||||
refreshAppearanceUi();
|
refreshAppearanceUi();
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Four opt-in cards in the EXISTING dashboard widget order and status refresh.
|
||||||
|
// No timers, fetches, or independent persistence are created here.
|
||||||
|
window.rustProxyStreamDashboard = (() => {
|
||||||
|
const ids = ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"];
|
||||||
|
const history = { last: null, rx: [], tx: [] };
|
||||||
|
let wasVisible = false;
|
||||||
|
const card = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
|
||||||
|
const set = (id, value) => { const node = $(id); if (node) node.textContent = value; };
|
||||||
|
const trafficRates = (rx, tx) => {
|
||||||
|
set("stream-dashboard-rx-rate", rx);
|
||||||
|
set("stream-dashboard-tx-rate", tx);
|
||||||
|
};
|
||||||
|
function clearTraffic() {
|
||||||
|
history.last = null; history.rx.length = 0; history.tx.length = 0;
|
||||||
|
$("stream-dashboard-rx")?.setAttribute("points", "0,55 300,55");
|
||||||
|
$("stream-dashboard-tx")?.setAttribute("points", "0,55 300,55");
|
||||||
|
}
|
||||||
|
function syncVisibility(hiddenOverride = null) {
|
||||||
|
const prefs = window.rustProxyPreferences?.current?.();
|
||||||
|
const hidden = new Set(hiddenOverride ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
|
||||||
|
// Visibility belongs exclusively to Appearance. An absent, disabled or
|
||||||
|
// restart-pending Stream must not silently override an explicit opt-in.
|
||||||
|
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
|
||||||
|
if (card("stream_traffic")?.hidden && wasVisible) clearTraffic();
|
||||||
|
wasVisible = !card("stream_traffic")?.hidden;
|
||||||
|
}
|
||||||
|
function line(points) {
|
||||||
|
const peak = Math.max(1, ...history.rx, ...history.tx);
|
||||||
|
return points.map((value, i) => `${i * 300 / Math.max(1, points.length - 1)},${55 - value / peak * 50}`).join(" ");
|
||||||
|
}
|
||||||
|
function render(status, hiddenOverride = null, recordTrafficSample = true) {
|
||||||
|
syncVisibility(hiddenOverride);
|
||||||
|
if (!status || currentPanel !== "overview" || ids.every(id => card(id)?.hidden)) return;
|
||||||
|
const configured = envelope?.config?.streams || status?.streams?.desired || [];
|
||||||
|
const active = (status?.streams?.active || []).filter(item => item.enabled !== false);
|
||||||
|
const metrics = status?.streams?.metrics || {};
|
||||||
|
if (!configured.some(item => item.enabled !== false) && !active.length) {
|
||||||
|
// A deliberately shown tile has a neutral empty state, not a
|
||||||
|
// hidden DOM node. No history is sampled without running streams.
|
||||||
|
for (const name of ["overview", "sessions", "health", "traffic"]) {
|
||||||
|
if (!card(({ overview: "stream_overview", sessions: "stream_sessions", health: "stream_backend_health", traffic: "stream_traffic" })[name])?.hidden) {
|
||||||
|
set(`stream-dashboard-${name}`, "-");
|
||||||
|
if (name === "traffic") trafficRates("-", "-");
|
||||||
|
else set(`stream-dashboard-${name}-detail`, t("streamWidgetUnavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!card("stream_traffic")?.hidden) clearTraffic();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!card("stream_overview")?.hidden) {
|
||||||
|
const desired = configured.filter(item => item.enabled !== false);
|
||||||
|
const attention = desired.filter(item => !active.some(a => a.id === item.id)).length +
|
||||||
|
active.filter(item => (metrics[item.id]?.backends || []).length &&
|
||||||
|
!(metrics[item.id].backends || []).some(b => b.enabled && !b.drain && b.status !== "unhealthy")).length;
|
||||||
|
set("stream-dashboard-overview", `${active.length} / ${desired.length}`);
|
||||||
|
set("stream-dashboard-overview-detail", `${t("streamWidgetActiveConfigured")} · ${attention} ${t("streamWidgetAttention")}`);
|
||||||
|
}
|
||||||
|
if (!card("stream_sessions")?.hidden) {
|
||||||
|
const sum = protocol => active.filter(item => item.protocol === protocol)
|
||||||
|
.reduce((count, item) => count + (Number(metrics[item.id]?.active) || 0), 0);
|
||||||
|
const tcp = sum("tcp"), udp = sum("udp");
|
||||||
|
set("stream-dashboard-sessions", fmt(tcp + udp));
|
||||||
|
set("stream-dashboard-sessions-detail", `TCP ${fmt(tcp)} · UDP ${fmt(udp)}`);
|
||||||
|
}
|
||||||
|
if (!card("stream_backend_health")?.hidden) {
|
||||||
|
const backends = active.flatMap(item => metrics[item.id]?.backends || []);
|
||||||
|
const healthy = backends.filter(b => b.status === "healthy").length;
|
||||||
|
const unhealthy = backends.filter(b => b.status === "unhealthy").length;
|
||||||
|
const unverified = backends.filter(b => b.status === "unverified" || b.status === "checking").length;
|
||||||
|
const emptyPools = active.filter(item => (metrics[item.id]?.backends || []).length > 0 &&
|
||||||
|
!(metrics[item.id].backends || []).some(b => b.enabled && !b.drain && b.status !== "unhealthy")).length;
|
||||||
|
set("stream-dashboard-health", `${healthy} / ${unhealthy}`);
|
||||||
|
set("stream-dashboard-health-detail", `${t("streamWidgetHealthyUnhealthy")} · ${unverified} ${t("streamWidgetUnverified")} · ${emptyPools} ${t("streamWidgetEmptyPools")}`);
|
||||||
|
}
|
||||||
|
if (card("stream_traffic")?.hidden) return;
|
||||||
|
const views = active.map(item => metrics[item.id]).filter(Boolean);
|
||||||
|
if (!views.length) {
|
||||||
|
set("stream-dashboard-traffic", t("streamWidgetUnavailable"));
|
||||||
|
trafficRates("-", "-");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Switching tabs or appearance preferences paints cached numbers without
|
||||||
|
// generating an extra rate sample from the same status response.
|
||||||
|
if (!recordTrafficSample) {
|
||||||
|
if (history.rx.length) {
|
||||||
|
const rx = history.rx.at(-1), tx = history.tx.at(-1);
|
||||||
|
set("stream-dashboard-traffic", `${bytes(Math.round(rx))}/s`);
|
||||||
|
trafficRates(`${bytes(Math.round(rx))}/s`, `${bytes(Math.round(tx))}/s`);
|
||||||
|
} else {
|
||||||
|
set("stream-dashboard-traffic", "-");
|
||||||
|
trafficRates("-", "-");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rxTotal = views.reduce((sum, view) => sum + (Number(view.bytes_in) || 0), 0);
|
||||||
|
const txTotal = views.reduce((sum, view) => sum + (Number(view.bytes_out) || 0), 0);
|
||||||
|
const now = performance.now();
|
||||||
|
let rx = 0, tx = 0;
|
||||||
|
if (history.last) {
|
||||||
|
const seconds = Math.max(.001, (now - history.last.when) / 1000);
|
||||||
|
if (rxTotal >= history.last.rx) rx = (rxTotal - history.last.rx) / seconds;
|
||||||
|
if (txTotal >= history.last.tx) tx = (txTotal - history.last.tx) / seconds;
|
||||||
|
}
|
||||||
|
history.last = { when: now, rx: rxTotal, tx: txTotal };
|
||||||
|
history.rx.push(rx); history.tx.push(tx);
|
||||||
|
if (history.rx.length > 60) { history.rx.shift(); history.tx.shift(); }
|
||||||
|
set("stream-dashboard-traffic", `${bytes(Math.round(rx))}/s`);
|
||||||
|
trafficRates(`${bytes(Math.round(rx))}/s`, `${bytes(Math.round(tx))}/s`);
|
||||||
|
$("stream-dashboard-rx")?.setAttribute("points", line(history.rx));
|
||||||
|
$("stream-dashboard-tx")?.setAttribute("points", line(history.tx));
|
||||||
|
}
|
||||||
|
function reset() { clearTraffic(); wasVisible = false; }
|
||||||
|
return { syncVisibility, render, reset };
|
||||||
|
})();
|
||||||
+493
-184
File diff suppressed because it is too large.
Load diff
+100
-8
@@ -53,6 +53,60 @@
|
|||||||
return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; };
|
return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backendDefaults = (index = 0, address = "127.0.0.1:10001") => ({
|
||||||
|
id: index ? `backend-${index + 1}` : "primary", name: index ? `Backend ${index + 1}` : "Primary",
|
||||||
|
address, enabled: true, drain: false, weight: 1, max_sessions: null, metrics_enabled: null,
|
||||||
|
health: { mode: "off", interval_seconds: 10, timeout_ms: 1000, rise: 2, fall: 2, payload_hex: "", expect_hex: null }
|
||||||
|
});
|
||||||
|
let backendList = null;
|
||||||
|
function renderBackendEditor(parent, backend, protocol) {
|
||||||
|
if (backendList?.children.length >= 32) { toast(t("streamBackendLimit"), true); return; }
|
||||||
|
const row = el("fieldset", undefined, "stream-backend-row vh-wide"); row.append(el("legend", backend.id || t("streamBackend")));
|
||||||
|
const controls = el("div", undefined, "vh-grid"); row.append(controls);
|
||||||
|
field(controls, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
|
||||||
|
field(controls, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
|
||||||
|
field(controls, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
|
||||||
|
field(controls, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
|
||||||
|
field(controls, "backend-max", "streamBackendMaxSessions", backend.max_sessions ?? "", "number", { min: 1, max: 65535 });
|
||||||
|
field(controls, "backend-enabled", "vhEnabled", backend.enabled !== false, "checkbox");
|
||||||
|
field(controls, "backend-drain", "streamBackendDrain", backend.drain === true, "checkbox");
|
||||||
|
const detail = backend.metrics_enabled == null ? "inherit" : String(backend.metrics_enabled);
|
||||||
|
field(controls, "backend-metrics", "streamBackendDetailMetrics", detail, "select", { values: [["inherit", "streamMetricsFromStream"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
|
||||||
|
const hc = backend.health || backendDefaults().health;
|
||||||
|
field(controls, "backend-health-mode", "streamHealthMode", hc.mode || "off", "select", { values: [["off", "streamHealthOff"], [protocol === "tcp" ? "tcp_connect" : "udp_probe", protocol === "tcp" ? "streamHealthTcp" : "streamHealthUdp"]] });
|
||||||
|
field(controls, "backend-health-interval", "streamHealthInterval", hc.interval_seconds ?? 10, "number", { min: 1, max: 3600, required: true });
|
||||||
|
field(controls, "backend-health-timeout", "streamHealthTimeout", hc.timeout_ms ?? 1000, "number", { min: 50, max: 30000, required: true });
|
||||||
|
field(controls, "backend-health-rise", "streamHealthRise", hc.rise ?? 2, "number", { min: 1, max: 10, required: true });
|
||||||
|
field(controls, "backend-health-fall", "streamHealthFall", hc.fall ?? 2, "number", { min: 1, max: 10, required: true });
|
||||||
|
if (protocol === "udp") {
|
||||||
|
field(controls, "backend-health-payload", "streamHealthPayload", hc.payload_hex || "", "text", { maxLength: 256 });
|
||||||
|
field(controls, "backend-health-expected", "streamHealthExpected", hc.expect_hex || "", "text", { maxLength: 256 });
|
||||||
|
}
|
||||||
|
const remove = button("remove", () => { if (backendList.children.length > 1) { row.remove(); changed(); } });
|
||||||
|
row.append(remove); backendList.append(row);
|
||||||
|
}
|
||||||
|
const optBool = value => value === "inherit" ? null : value === "true";
|
||||||
|
function collectBackends() {
|
||||||
|
const rows = [...backendList.querySelectorAll(".stream-backend-row")];
|
||||||
|
if (!rows.length) throw new Error(t("streamBackendRequired"));
|
||||||
|
const get = (row, name) => row.querySelector(`[name="${name}"]`);
|
||||||
|
const selected = rows.map(row => ({
|
||||||
|
id: get(row, "backend-id").value.trim(), name: get(row, "backend-name").value.trim(),
|
||||||
|
address: get(row, "backend-address").value.trim(), weight: Number(get(row, "backend-weight").value),
|
||||||
|
max_sessions: get(row, "backend-max").value === "" ? null : Number(get(row, "backend-max").value),
|
||||||
|
enabled: get(row, "backend-enabled").checked, drain: get(row, "backend-drain").checked,
|
||||||
|
metrics_enabled: optBool(get(row, "backend-metrics").value),
|
||||||
|
health: {
|
||||||
|
mode: get(row, "backend-health-mode").value, interval_seconds: Number(get(row, "backend-health-interval").value),
|
||||||
|
timeout_ms: Number(get(row, "backend-health-timeout").value), rise: Number(get(row, "backend-health-rise").value),
|
||||||
|
fall: Number(get(row, "backend-health-fall").value),
|
||||||
|
payload_hex: get(row, "backend-health-payload")?.value.trim() || "",
|
||||||
|
expect_hex: get(row, "backend-health-expected")?.value.trim() || null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (new Set(selected.map(b => b.id)).size !== selected.length) throw new Error(t("streamBackendDuplicate"));
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
let readListen = () => "";
|
let readListen = () => "";
|
||||||
function load(value = defaults(), show = true) {
|
function load(value = defaults(), show = true) {
|
||||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
||||||
@@ -63,18 +117,41 @@
|
|||||||
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
|
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
|
||||||
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||||
window.rustProxyEnableControl(enabledControl);
|
window.rustProxyEnableControl(enabledControl);
|
||||||
|
const enabledLabel = enabledControl.closest("label");
|
||||||
|
enabledLabel?.querySelector(".vh-enable-hint")?.remove();
|
||||||
|
enabledLabel?.classList.add("vh-enable-no-hint");
|
||||||
note(g, "streamsHelp");
|
note(g, "streamsHelp");
|
||||||
|
|
||||||
g = group("streamNetwork", "network");
|
g = group("streamNetwork", "network");
|
||||||
readListen = socketChipField(g, value.listen || "0.0.0.0:10000");
|
readListen = socketChipField(g, value.listen || "0.0.0.0:10000");
|
||||||
field(g, "upstream", "streamUpstream", value.upstream || "127.0.0.1:10001", "text", { required: true, maxLength: 128 });
|
field(g, "balance", "streamBalance", value.balance || "round_robin", "select", { values: [["round_robin", "streamRoundRobin"], [protocol.value === "tcp" ? "least_connections" : "least_sessions", "streamLeastSessions"]] });
|
||||||
|
field(g, "stream-metrics", "streamDetailMetrics", value.metrics_enabled == null ? "inherit" : String(value.metrics_enabled), "select", { values: [["inherit", "streamMetricsDefaultOff"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
|
||||||
|
note(g, "streamMetricsHelp");
|
||||||
|
const pool = el("div", undefined, "vh-wide stream-backend-pool"); pool.append(text("h3", "streamBackendPool"));
|
||||||
|
backendList = el("div", undefined, "stream-backend-list"); pool.append(backendList);
|
||||||
|
const initial = value.backends?.length ? value.backends : [backendDefaults(0, value.upstream || "127.0.0.1:10001")];
|
||||||
|
for (const backend of initial) renderBackendEditor(backendList, backend, protocol.value);
|
||||||
|
pool.append(button("streamBackendAdd", () => { renderBackendEditor(backendList, backendDefaults(backendList.children.length), protocol.value); changed(); }));
|
||||||
|
note(pool, "streamHealthUdpHelp"); g.append(pool);
|
||||||
note(g, "streamDedicatedListenerHelp");
|
note(g, "streamDedicatedListenerHelp");
|
||||||
|
|
||||||
g = group("streamLimits", "limits");
|
g = group("streamLimits", "limits");
|
||||||
const connectTimeout = field(g, "connect-timeout", "streamConnectTimeout", value.connect_timeout_seconds ?? 5, "number", { min: 1, max: 300, required: true });
|
const connectTimeout = field(g, "connect-timeout", "streamConnectTimeout", value.connect_timeout_seconds ?? 5, "number", { min: 1, max: 300, required: true });
|
||||||
field(g, "idle-timeout", "streamIdleTimeout", value.idle_timeout_seconds ?? 300, "number", { min: 1, max: 86400, required: true });
|
field(g, "idle-timeout", "streamIdleTimeout", value.idle_timeout_seconds ?? 300, "number", { min: 1, max: 86400, required: true });
|
||||||
field(g, "max-connections", "streamMaxConnections", value.max_connections ?? 1024, "number", { min: 1, max: 65535, required: true });
|
field(g, "max-connections", "streamMaxConnections", value.max_connections ?? 1024, "number", { min: 1, max: 65535, required: true });
|
||||||
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", sync); sync();
|
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", () => {
|
||||||
|
sync();
|
||||||
|
// Explicitly reset incompatible health modes when switching transport.
|
||||||
|
for (const row of backendList.children) {
|
||||||
|
const mode = row.querySelector('[name="backend-health-mode"]');
|
||||||
|
if (mode?.value !== "off") mode.value = "off";
|
||||||
|
}
|
||||||
|
const currentBackends = collectBackends(); backendList.replaceChildren();
|
||||||
|
currentBackends.forEach(backend => renderBackendEditor(backendList, backend, protocol.value));
|
||||||
|
const balance = read("balance"); balance.replaceChildren();
|
||||||
|
for (const [val, key] of [["round_robin", "streamRoundRobin"], [protocol.value === "tcp" ? "least_connections" : "least_sessions", "streamLeastSessions"]]) { const option = text("option", key); option.value = val; balance.append(option); }
|
||||||
|
changed();
|
||||||
|
}); sync();
|
||||||
note(g, "streamLimitsHelp");
|
note(g, "streamLimitsHelp");
|
||||||
if (show) { activateTab("details"); if (!dialog.open) dialog.showModal(); }
|
if (show) { activateTab("details"); if (!dialog.open) dialog.showModal(); }
|
||||||
$("stream-live-summary").textContent = editing ? t("streamNoLiveMetrics") : "";
|
$("stream-live-summary").textContent = editing ? t("streamNoLiveMetrics") : "";
|
||||||
@@ -83,18 +160,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collect() {
|
function collect() {
|
||||||
|
const backends = collectBackends();
|
||||||
return {
|
return {
|
||||||
id: str("id"), name: str("name"), enabled: yes("enabled"), protocol: str("protocol"),
|
id: str("id"), name: str("name"), enabled: yes("enabled"), protocol: str("protocol"),
|
||||||
listen: readListen(), upstream: str("upstream"), connect_timeout_seconds: Number(str("connect-timeout") || 5),
|
listen: readListen(), upstream: backends[0].address, backends, balance: str("balance"),
|
||||||
|
metrics_enabled: optBool(str("stream-metrics")), connect_timeout_seconds: Number(str("connect-timeout") || 5),
|
||||||
idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections")
|
idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function suspendDraft() {
|
function suspendDraft() {
|
||||||
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen };
|
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen, backendList };
|
||||||
const title = read("id")?.value.trim() || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
|
const title = read("id")?.value.trim() || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
|
||||||
}
|
}
|
||||||
function restoreDraft(state) {
|
function restoreDraft(state) {
|
||||||
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen;
|
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen; backendList = state.backendList;
|
||||||
editing = state.editing; revision = state.revision; initialized = true; dirty = true; $("stream-editing").textContent = state.label; if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
editing = state.editing; revision = state.revision; initialized = true; dirty = true; $("stream-editing").textContent = state.label; if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||||
}
|
}
|
||||||
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("stream"); if (dialog.open) dialog.close(); return true; }
|
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("stream"); if (dialog.open) dialog.close(); return true; }
|
||||||
@@ -106,7 +185,7 @@
|
|||||||
const configuredIds = new Set(configured.map(stream => stream.id));
|
const configuredIds = new Set(configured.map(stream => stream.id));
|
||||||
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
|
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
|
||||||
const query = ($("stream-search")?.value || "").trim().toLowerCase();
|
const query = ($("stream-search")?.value || "").trim().toLowerCase();
|
||||||
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query));
|
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream, ...(stream.backends || []).flatMap(b => [b.id, b.name, b.address])].filter(Boolean).join(" ").toLowerCase().includes(query));
|
||||||
const active = new Map((latestStatus?.streams?.active || []).map(item => [item.id, item]));
|
const active = new Map((latestStatus?.streams?.active || []).map(item => [item.id, item]));
|
||||||
const sortValue = stream => {
|
const sortValue = stream => {
|
||||||
const live = active.get(stream.id), same = live && JSON.stringify(live) === JSON.stringify(stream);
|
const live = active.get(stream.id), same = live && JSON.stringify(live) === JSON.stringify(stream);
|
||||||
@@ -126,8 +205,17 @@
|
|||||||
head.append(actions); box.append(head);
|
head.append(actions); box.append(head);
|
||||||
|
|
||||||
const details = el("div", undefined, "vhost-detail-strip");
|
const details = el("div", undefined, "vhost-detail-strip");
|
||||||
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", stream.upstream], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", `${stream.backends?.length ? stream.backends.length + " · " : ""}${stream.upstream}`], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||||
box.append(details);
|
box.append(details);
|
||||||
|
const backendBox = el("div", undefined, "stream-backend-status");
|
||||||
|
backendBox.append(text("span", "streamBackendPool", "small-label"));
|
||||||
|
for (const backend of stream.backends?.length ? stream.backends : [backendDefaults(0, stream.upstream)]) {
|
||||||
|
const row = el("div", undefined, "stream-backend-status-row");
|
||||||
|
row.append(el("span", `${backend.name || backend.id} · ${backend.address}`));
|
||||||
|
const health = el("span", t("streamWidgetUnverified"), "pill"); health.dataset.streamBackendStatus = `${stream.id}|${backend.id}`;
|
||||||
|
row.append(health); backendBox.append(row);
|
||||||
|
}
|
||||||
|
box.append(backendBox);
|
||||||
|
|
||||||
const live = el("div", undefined, "vhost-live-grid stream-live-grid");
|
const live = el("div", undefined, "vhost-live-grid stream-live-grid");
|
||||||
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = stream.id; live.append(item); };
|
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = stream.id; live.append(item); };
|
||||||
@@ -156,11 +244,15 @@
|
|||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
for (const desired of envelope?.config.streams || []) {
|
for (const desired of envelope?.config.streams || []) {
|
||||||
const id = desired.id, live = active.get(id), data = metrics[id];
|
const id = desired.id, live = active.get(id), data = metrics[id];
|
||||||
|
if (currentPanel === "streams") for (const backend of data?.backends || []) {
|
||||||
|
const node = document.querySelector(`[data-stream-backend-status="${CSS.escape(`${id}|${backend.id}`)}"]`);
|
||||||
|
if (node) node.textContent = `${t("streamHealthMode")}: ${t(`streamHealthState_${backend.status}`)} · ${backend.active} ${t("streamActiveSessions")}`;
|
||||||
|
}
|
||||||
const same = live && JSON.stringify(live) === JSON.stringify(desired);
|
const same = live && JSON.stringify(live) === JSON.stringify(desired);
|
||||||
const state = desired.enabled === false ? t("vhDisabled") : !live || !same ? t("streamPendingRestart") : t("streamRunning");
|
const state = desired.enabled === false ? t("vhDisabled") : !live || !same ? t("streamPendingRestart") : t("streamRunning");
|
||||||
const set = (suffix, value) => document.querySelectorAll(`[data-${suffix.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${CSS.escape(id)}"]`).forEach(node => node.textContent = value);
|
const set = (suffix, value) => document.querySelectorAll(`[data-${suffix.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${CSS.escape(id)}"]`).forEach(node => node.textContent = value);
|
||||||
set("streamState", state); set("streamActive", data ? fmt(data.active) : "-"); set("streamSessions", data ? fmt(data.sessions_total) : "-"); set("streamErrors", data ? fmt(data.errors) : "-"); set("streamRejected", data ? fmt(data.rejected) : "-"); set("streamTransferred", data ? bytes((Number(data.bytes_in) || 0) + (Number(data.bytes_out) || 0)) : "-");
|
set("streamState", state); set("streamActive", data ? fmt(data.active) : "-"); set("streamSessions", data ? fmt(data.sessions_total) : "-"); set("streamErrors", data ? fmt(data.errors) : "-"); set("streamRejected", data ? fmt(data.rejected) : "-"); set("streamTransferred", data ? bytes((Number(data.bytes_in) || 0) + (Number(data.bytes_out) || 0)) : "-");
|
||||||
if (!data) continue;
|
if (!data || currentPanel !== "streams") continue;
|
||||||
const history = chartHistory.get(id) || { lastTime: now, lastIn: Number(data.bytes_in) || 0, lastOut: Number(data.bytes_out) || 0, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 };
|
const history = chartHistory.get(id) || { lastTime: now, lastIn: Number(data.bytes_in) || 0, lastOut: Number(data.bytes_out) || 0, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 };
|
||||||
const elapsed = Math.max(.001, (now - history.lastTime) / 1000), inBytes = Number(data.bytes_in) || 0, outBytes = Number(data.bytes_out) || 0;
|
const elapsed = Math.max(.001, (now - history.lastTime) / 1000), inBytes = Number(data.bytes_in) || 0, outBytes = Number(data.bytes_out) || 0;
|
||||||
const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0;
|
const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0;
|
||||||
|
|||||||
+416
-54
@@ -8474,8 +8474,14 @@ button.config-history-day.is-selected {
|
|||||||
display: block;
|
display: block;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.config-history-revisions { max-height: none; }
|
|
||||||
.config-history-diff { overflow: visible; }
|
.config-history-revisions {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-history-diff {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.config-history-change-values {
|
.config-history-change-values {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -8723,7 +8729,6 @@ button.config-history-day.is-selected {
|
|||||||
box-shadow: 0 1px 0 var(--line);
|
box-shadow: 0 1px 0 var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stored Logs sorting is performed by the bounded server scan before pagination. */
|
|
||||||
.file-logs-table th .file-log-sort-button {
|
.file-logs-table th .file-log-sort-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -8737,10 +8742,20 @@ button.config-history-day.is-selected {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.file-logs-table th .file-log-sort-button:hover { color: var(--accent); }
|
|
||||||
|
.file-logs-table th .file-log-sort-button:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.file-logs-table th[aria-sort="ascending"] .file-log-sort-button,
|
.file-logs-table th[aria-sort="ascending"] .file-log-sort-button,
|
||||||
.file-logs-table th[aria-sort="descending"] .file-log-sort-button { color: var(--accent); }
|
.file-logs-table th[aria-sort="descending"] .file-log-sort-button {
|
||||||
.file-logs-table .file-log-sort-indicator { font-size: 11px; opacity: .8; }
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-logs-table .file-log-sort-indicator {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
.file-logs-table tbody tr:not(.file-log-detail-row) {
|
.file-logs-table tbody tr:not(.file-log-detail-row) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -9789,7 +9804,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
stroke-linejoin: var(--chart-linejoin);
|
stroke-linejoin: var(--chart-linejoin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact toolbar fields use one scaled box model, so native selects and inputs stay aligned. */
|
|
||||||
.backend-table-tools,
|
.backend-table-tools,
|
||||||
.vhost-list-tools,
|
.vhost-list-tools,
|
||||||
.docker-toolbar,
|
.docker-toolbar,
|
||||||
@@ -9814,7 +9828,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
padding-block: 0
|
padding-block: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ACL policy builder: guided OR-composition for country + IP exceptions. */
|
|
||||||
.acl-profile-layout {
|
.acl-profile-layout {
|
||||||
grid-template-columns: minmax(260px, .72fr) minmax(440px, 1.28fr)
|
grid-template-columns: minmax(260px, .72fr) minmax(440px, 1.28fr)
|
||||||
}
|
}
|
||||||
@@ -10277,6 +10290,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
.cache-maintenance-info .metric-info-popover code+.fine {
|
.cache-maintenance-info .metric-info-popover code+.fine {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stored Logs: compact header option and distinct analysis workspace. */
|
/* Stored Logs: compact header option and distinct analysis workspace. */
|
||||||
.file-logs-heading-actions .file-log-consolidate {
|
.file-logs-heading-actions .file-log-consolidate {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -10290,23 +10304,72 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.file-log-consolidate input { width: 14px; height: 14px; margin: 0; accent-color: var(--accent); }
|
|
||||||
.file-log-consolidate:hover { color: var(--text); }
|
.file-log-consolidate input {
|
||||||
.file-log-tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--line); margin: .9rem 0 1rem; }
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-log-consolidate:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-log-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
margin: .9rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.file-log-tab {
|
.file-log-tab {
|
||||||
appearance: none; background: transparent; border: 0; border-bottom: 2px solid transparent;
|
appearance: none;
|
||||||
border-radius: 0; padding: .65rem 1rem; color: var(--muted); cursor: pointer;
|
background: transparent;
|
||||||
font: inherit; font-weight: 500;
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: .65rem 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.file-log-tab:hover { color: var(--text); }
|
|
||||||
.file-log-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
.file-log-tab:hover {
|
||||||
.file-log-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
color: var(--text);
|
||||||
#file-log-view-counts[hidden], #file-log-view-entries[hidden] { display: none !important; }
|
}
|
||||||
.file-log-advanced-parser h3 { margin: 0 0 .7rem; font-size: 1rem; }
|
|
||||||
|
.file-log-tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-log-tab:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-log-view-counts[hidden],
|
||||||
|
#file-log-view-entries[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-log-advanced-parser h3 {
|
||||||
|
margin: 0 0 .7rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.file-logs-heading-actions .file-log-consolidate { margin-inline-end: auto; }
|
.file-logs-heading-actions .file-log-consolidate {
|
||||||
.file-log-tab { padding: .6rem .7rem; }
|
margin-inline-end: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-log-tab {
|
||||||
|
padding: .6rem .7rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 0.31.5 traffic/log analysis actions */
|
/* 0.31.5 traffic/log analysis actions */
|
||||||
.traffic-copy-value,
|
.traffic-copy-value,
|
||||||
.file-log-aggregate-copy {
|
.file-log-aggregate-copy {
|
||||||
@@ -10320,18 +10383,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
cursor: copy;
|
cursor: copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
.traffic-copy-value:hover,
|
.traffic-copy-value:hover,
|
||||||
.traffic-copy-value:focus-visible,
|
.traffic-copy-value:focus-visible,
|
||||||
.file-log-aggregate-copy:hover,
|
.file-log-aggregate-copy:hover,
|
||||||
.file-log-aggregate-copy:focus-visible {
|
.file-log-aggregate-copy:focus-visible {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-log-advanced-parser {
|
.file-log-advanced-parser {
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
padding: .75rem 1rem;
|
padding: .75rem 1rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius, 12px);
|
border-radius: var(--radius, 12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-log-aggregate-form {
|
.file-log-aggregate-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(120px, 1fr) auto;
|
grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(120px, 1fr) auto;
|
||||||
@@ -10339,16 +10405,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
margin-top: .75rem;
|
margin-top: .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-log-aggregate-table {
|
.file-log-aggregate-table {
|
||||||
margin-top: .75rem;
|
margin-top: .75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-log-aggregate-table th:last-child,
|
.file-log-aggregate-table th:last-child,
|
||||||
.file-log-aggregate-table td:last-child {
|
.file-log-aggregate-table td:last-child {
|
||||||
width: 8rem;
|
width: 8rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.file-log-aggregate-form { grid-template-columns: 1fr; }
|
.file-log-aggregate-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -10356,6 +10427,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
The complete parsed JSON is available by opening any row, while the desktop
|
The complete parsed JSON is available by opening any row, while the desktop
|
||||||
table and the persisted column picker keep their original behavior. */
|
table and the persisted column picker keep their original behavior. */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
|
|
||||||
#panel-filelogs,
|
#panel-filelogs,
|
||||||
#panel-filelogs .file-logs-workspace,
|
#panel-filelogs .file-logs-workspace,
|
||||||
#file-log-view-entries,
|
#file-log-view-entries,
|
||||||
@@ -10368,33 +10440,43 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-workspace {
|
#panel-filelogs .file-logs-workspace {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .card-heading {
|
#panel-filelogs .card-heading {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-heading-actions {
|
#panel-filelogs .file-logs-heading-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-heading-actions .file-log-consolidate {
|
#panel-filelogs .file-logs-heading-actions .file-log-consolidate {
|
||||||
margin-inline-end: auto;
|
margin-inline-end: auto;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-log-search-help-popover {
|
#panel-filelogs .file-log-search-help-popover {
|
||||||
width: min(320px, calc(100vw - 54px));
|
width: min(320px, calc(100vw - 54px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-filter,
|
#panel-filelogs .file-logs-filter,
|
||||||
#panel-filelogs .file-log-aggregate-form {
|
#panel-filelogs .file-log-aggregate-form {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-log-limit { max-width: none; }
|
|
||||||
|
#panel-filelogs .file-log-limit {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-filter label,
|
#panel-filelogs .file-logs-filter label,
|
||||||
#panel-filelogs .file-logs-filter input,
|
#panel-filelogs .file-logs-filter input,
|
||||||
#panel-filelogs .file-logs-filter select,
|
#panel-filelogs .file-logs-filter select,
|
||||||
@@ -10404,21 +10486,31 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-log-tabs { min-width: 0; }
|
|
||||||
|
#panel-filelogs .file-log-tabs {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-log-tab {
|
#panel-filelogs .file-log-tab {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table-toolbar {
|
#panel-filelogs .file-logs-table-toolbar {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-log-column-picker { align-self: flex-end; }
|
|
||||||
|
#panel-filelogs .file-log-column-picker {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table {
|
#panel-filelogs .file-logs-table {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table table,
|
#panel-filelogs .file-logs-table table,
|
||||||
#panel-filelogs .file-logs-table tbody {
|
#panel-filelogs .file-logs-table tbody {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -10426,7 +10518,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-logs-table thead { display: none; }
|
|
||||||
|
#panel-filelogs .file-logs-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) {
|
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
@@ -10438,12 +10534,17 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table tbody tr:focus-visible {
|
#panel-filelogs .file-logs-table tbody tr:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-logs-table tbody tr.selected { border-color: var(--accent); }
|
|
||||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td {
|
#panel-filelogs .file-logs-table tbody tr.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td {
|
||||||
display: block;
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -10454,6 +10555,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Every user-selected column is available even when the administrator
|
/* Every user-selected column is available even when the administrator
|
||||||
replaces all default columns with custom parsed fields. */
|
replaces all default columns with custom parsed fields. */
|
||||||
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
|
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
|
||||||
@@ -10462,6 +10564,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
padding-inline-end: 16px;
|
padding-inline-end: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
|
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
|
||||||
#panel-filelogs .file-logs-table td[data-mobile-column="event"],
|
#panel-filelogs .file-logs-table td[data-mobile-column="event"],
|
||||||
#panel-filelogs .file-logs-table td[data-mobile-column="status"],
|
#panel-filelogs .file-logs-table td[data-mobile-column="status"],
|
||||||
@@ -10469,14 +10572,17 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td::before {
|
|
||||||
|
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td::before {
|
||||||
content: attr(data-mobile-label) ": ";
|
content: attr(data-mobile-label) ": ";
|
||||||
color: var(--text-subtle);
|
color: var(--text-subtle);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td[data-mobile-column="record"]::before {
|
|
||||||
|
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td[data-mobile-column="record"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)::after {
|
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)::after {
|
||||||
content: "+";
|
content: "+";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -10486,7 +10592,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-logs-table tbody tr.selected::after { content: "-"; }
|
|
||||||
|
#panel-filelogs .file-logs-table tbody tr.selected::after {
|
||||||
|
content: "-";
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table .file-log-detail-row {
|
#panel-filelogs .file-logs-table .file-log-detail-row {
|
||||||
display: block;
|
display: block;
|
||||||
margin: -8px 0 8px;
|
margin: -8px 0 8px;
|
||||||
@@ -10494,6 +10604,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
border-top: 0;
|
border-top: 0;
|
||||||
background: var(--panel2);
|
background: var(--panel2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table .file-log-detail-row td {
|
#panel-filelogs .file-logs-table .file-log-detail-row td {
|
||||||
display: block;
|
display: block;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -10502,21 +10613,40 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-log-field { grid-template-columns: minmax(0, 1fr); }
|
|
||||||
#panel-filelogs .file-log-inline-detail { min-width: 0; padding: 8px; }
|
#panel-filelogs .file-log-field {
|
||||||
#panel-filelogs .file-log-inline-detail pre { max-width: 100%; overflow-x: auto; }
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-filelogs .file-log-inline-detail {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-filelogs .file-log-inline-detail pre {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-log-aggregate-table {
|
#panel-filelogs .file-log-aggregate-table {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-log-aggregate-table table { table-layout: fixed; }
|
|
||||||
|
#panel-filelogs .file-log-aggregate-table table {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-log-aggregate-table td:first-child {
|
#panel-filelogs .file-log-aggregate-table td:first-child {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-logs-pager { justify-content: space-between; }
|
|
||||||
|
#panel-filelogs .file-logs-pager {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet/narrow-desktop log tables scroll locally instead of expanding <main>. */
|
/* Tablet/narrow-desktop log tables scroll locally instead of expanding <main>. */
|
||||||
@@ -10527,7 +10657,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
#panel-filelogs .file-logs-workspace { grid-template-columns: minmax(0, 1fr); }
|
|
||||||
|
#panel-filelogs .file-logs-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table,
|
#panel-filelogs .file-logs-table,
|
||||||
#panel-filelogs .file-log-aggregate-table {
|
#panel-filelogs .file-log-aggregate-table {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -10535,14 +10669,22 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
#panel-filelogs .file-logs-table { overflow: visible; }
|
#panel-filelogs .file-logs-table {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
|
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
|
||||||
#panel-filelogs .file-logs-table td[data-mobile-column="vhost"] { grid-column: 1 / -1; }
|
#panel-filelogs .file-logs-table td[data-mobile-column="vhost"] {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
|
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
|
||||||
max-height: 4.4em;
|
max-height: 4.4em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#panel-filelogs .file-logs-table tr.selected td[data-mobile-column="record"] {
|
#panel-filelogs .file-logs-table tr.selected td[data-mobile-column="record"] {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -10550,21 +10692,241 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */
|
/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */
|
||||||
.listener-protocol-settings-actions { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; margin-block: 10px; }
|
.listener-protocol-settings-actions {
|
||||||
.listener-protocol-settings-actions button { margin: 0; }
|
display: flex;
|
||||||
.listener-protocol-settings-actions button[hidden] { display: none; }
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-block: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listener-protocol-settings-actions button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listener-protocol-settings-actions button[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 0.31.7 revision preview shows only changed leaf fields. */
|
/* 0.31.7 revision preview shows only changed leaf fields. */
|
||||||
.config-history-change-values { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
.config-history-change-values {
|
||||||
.config-history-change-values .diff-before { color: var(--muted); text-decoration: line-through; }
|
display: flex;
|
||||||
.config-history-change-values .diff-after { color: var(--text); }
|
flex-wrap: wrap;
|
||||||
.config-history-change-values code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
align-items: center;
|
||||||
.config-history-json-dialog { width: min(1200px, calc(100vw - 24px)); max-width: calc(100vw - 24px); max-height: calc(100dvh - 28px); overflow: auto; }
|
gap: 8px;
|
||||||
.config-history-json-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; }
|
}
|
||||||
.config-history-json-columns section { min-width: 0; }
|
|
||||||
.config-history-json-columns pre { padding: 12px; margin: 0; background: var(--panel); border: 0; overflow: auto; max-height: 68dvh; font: 11px/1.5 var(--mono); }
|
|
||||||
@media (max-width: 760px) { .config-history-json-columns { grid-template-columns: 1fr; } .config-history-json-columns pre { max-height: 38dvh; } }
|
|
||||||
|
|
||||||
.acl-profile-attached { margin-block: 6px; }
|
.config-history-change-values .diff-before {
|
||||||
.acl-profile-attached summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
color: var(--muted);
|
||||||
.acl-profile-attached p { overflow-wrap: anywhere; }
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-history-change-values .diff-after {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-history-change-values code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-history-json-dialog {
|
||||||
|
width: min(1200px, calc(100vw - 24px));
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
max-height: calc(100dvh - 28px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-history-json-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-history-json-columns section {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-history-json-columns pre {
|
||||||
|
padding: 12px;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 0;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 68dvh;
|
||||||
|
font: 11px/1.5 var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.config-history-json-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-history-json-columns pre {
|
||||||
|
max-height: 38dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.acl-profile-attached {
|
||||||
|
margin-block: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acl-profile-attached summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acl-profile-attached p {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.32 Stream extensions reuse the responsive Vhost editor and stat cards. */
|
||||||
|
.stream-backend-pool {
|
||||||
|
display: grid;
|
||||||
|
gap: .8rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-backend-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-backend-row {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line, #454d59);
|
||||||
|
border-radius: .65rem;
|
||||||
|
padding: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-backend-row>legend {
|
||||||
|
font-weight: 650;
|
||||||
|
padding: 0 .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-backend-row>.quiet {
|
||||||
|
margin-top: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-backend-status {
|
||||||
|
display: grid;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-backend-status-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: .85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-backend-status-row>span:first-child {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dashboard-widget^="stream_"][hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Description text is a tooltip, not a second line inside each Appearance tile. */
|
||||||
|
.dashboard-widget-setting {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-setting:is(:hover, :focus-within) {
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-setting-label-block {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-description {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
inset-inline-start: 4px;
|
||||||
|
top: calc(100% + 5px);
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(290px, calc(100vw - 48px));
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel2);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, .24);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: normal;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
transition: opacity .12s ease, transform .12s ease, visibility .12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-setting:is(:hover, :focus-within) .dashboard-widget-description {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-enable-control.vh-enable-no-hint {
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vh-enable-control.vh-enable-no-hint> :is(input, .vh-enable-state) {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stats-grid>.stat {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stats-grid .stat>small {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stream-traffic-legend .legend-rx i {
|
||||||
|
background: var(--chart-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stream-traffic-legend .legend-tx i {
|
||||||
|
background: var(--chart-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stats-grid .stat-chart>.stat-chart-legend {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1201px) {
|
||||||
|
#panel-overview .stats-grid {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel-overview .stats-grid>.stat {
|
||||||
|
min-height: 126px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-dashboard-density="normal"] #panel-overview .stats-grid>.stat {
|
||||||
|
min-height: 136px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-dashboard-density="ultra"] #panel-overview .stats-grid>.stat {
|
||||||
|
min-height: 105px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
function basicAuthEditor(parent, settings = {}) {
|
function basicAuthEditor(parent, settings = {}) {
|
||||||
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
|
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
|
||||||
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
|
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
|
||||||
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
|
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
}
|
}
|
||||||
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
|
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
|
||||||
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
|
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostTagEditor(parent, hosts = [], onUpdate = () => {}) {
|
function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
|
||||||
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
|
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
|
||||||
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
|
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
|
||||||
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
|
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
|
||||||
@@ -31,16 +31,19 @@
|
|||||||
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
|
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
|
||||||
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
|
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
|
||||||
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
|
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(value = defaults(), migrationIds = [], show = true) {
|
function load(value = defaults(), migrationIds = [], show = true) {
|
||||||
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
|
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
|
||||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||||
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||||
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
|
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
|
||||||
let refreshCanonical = () => {};
|
let refreshCanonical = () => { };
|
||||||
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||||
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
||||||
|
const enabledLabel = enabledControl.closest("label");
|
||||||
|
enabledLabel?.querySelector(".vh-enable-hint")?.remove();
|
||||||
|
enabledLabel?.classList.add("vh-enable-no-hint");
|
||||||
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
|
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
|
||||||
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
|
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
|
||||||
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
|
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
|
||||||
@@ -385,8 +388,8 @@
|
|||||||
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
|
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
|
||||||
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
|
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
|
||||||
|
|
||||||
}
|
}
|
||||||
function collect() {
|
function collect() {
|
||||||
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
|
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
|
||||||
let paths, acl, headers;
|
let paths, acl, headers;
|
||||||
try {
|
try {
|
||||||
@@ -470,4 +473,4 @@
|
|||||||
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") },
|
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") },
|
||||||
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
|
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,78 @@ fn render_misc(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families)
|
|||||||
&l,
|
&l,
|
||||||
host.max_connections,
|
host.max_connections,
|
||||||
);
|
);
|
||||||
|
// Backend labels are bounded by configured stream/backend IDs; never client addresses.
|
||||||
|
for backend in &view.backends {
|
||||||
|
let lb = labels(&[
|
||||||
|
("stream", &host.id),
|
||||||
|
("protocol", protocol),
|
||||||
|
("backend", &backend.id),
|
||||||
|
]);
|
||||||
|
let state = match backend.status {
|
||||||
|
"checking" => 1,
|
||||||
|
"healthy" => 2,
|
||||||
|
"unhealthy" => 3,
|
||||||
|
"disabled" => 4,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
f.add(
|
||||||
|
"rust_proxy_stream_backend_health_state",
|
||||||
|
"gauge",
|
||||||
|
"0 unverified, 1 checking, 2 healthy, 3 unhealthy, 4 disabled.",
|
||||||
|
&lb,
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
f.add(
|
||||||
|
"rust_proxy_stream_backend_active",
|
||||||
|
"gauge",
|
||||||
|
"Active backend assignments including connecting TCP sessions.",
|
||||||
|
&lb,
|
||||||
|
backend.active,
|
||||||
|
);
|
||||||
|
f.add(
|
||||||
|
"rust_proxy_stream_backend_drain",
|
||||||
|
"gauge",
|
||||||
|
"One when backend refuses new sessions due to drain.",
|
||||||
|
&lb,
|
||||||
|
u8::from(backend.drain),
|
||||||
|
);
|
||||||
|
// A disabled detailed-metrics flag does not affect routing counters or probes.
|
||||||
|
if !backend.detail_enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (name, value) in [
|
||||||
|
("sessions_total", backend.sessions_total),
|
||||||
|
("sessions_completed_total", backend.sessions_completed),
|
||||||
|
("assignments_total", backend.assignments),
|
||||||
|
("rejected_total", backend.rejected),
|
||||||
|
("errors_total", backend.errors),
|
||||||
|
("bytes_in_total", backend.bytes_in),
|
||||||
|
("bytes_out_total", backend.bytes_out),
|
||||||
|
("packets_in_total", backend.packets_in),
|
||||||
|
("packets_out_total", backend.packets_out),
|
||||||
|
("health_success_total", Some(backend.health.successful)),
|
||||||
|
("health_failure_total", Some(backend.health.failed)),
|
||||||
|
] {
|
||||||
|
if let Some(value) = value {
|
||||||
|
f.add(
|
||||||
|
&format!("rust_proxy_stream_backend_{name}"),
|
||||||
|
"counter",
|
||||||
|
"Backend observation since listener startup.",
|
||||||
|
&lb,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(latency) = backend.health.latency_ms {
|
||||||
|
f.add(
|
||||||
|
"rust_proxy_stream_backend_health_latency_ms",
|
||||||
|
"gauge",
|
||||||
|
"Last successful probe latency in milliseconds.",
|
||||||
|
&lb,
|
||||||
|
latency,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if settings.include_logging {
|
if settings.include_logging {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
//! no vhost routing, ACL, cache, headers, compression or HTTP authentication.
|
//! no vhost routing, ACL, cache, headers, compression or HTTP authentication.
|
||||||
|
|
||||||
include!("stream/model.rs");
|
include!("stream/model.rs");
|
||||||
|
include!("stream/pool.rs");
|
||||||
|
include!("stream/health.rs");
|
||||||
include!("stream/runtime.rs");
|
include!("stream/runtime.rs");
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Active checks are only started for explicit verification modes. Probes are
|
||||||
|
// bounded globally and use the serving task's cancellation token.
|
||||||
|
fn probe_permits() -> &'static Arc<Semaphore> {
|
||||||
|
static PERMITS: std::sync::OnceLock<Arc<Semaphore>> = std::sync::OnceLock::new();
|
||||||
|
PERMITS.get_or_init(|| Arc::new(Semaphore::new(16)))
|
||||||
|
}
|
||||||
|
fn spawn_health(pool: Arc<Pool>, tasks: &mut JoinSet<()>, shutdown: CancellationToken) {
|
||||||
|
for backend in &pool.backends {
|
||||||
|
if backend.health.mode == HealthMode::Off
|
||||||
|
|| (backend.health.mode == HealthMode::UdpProbe && backend.health.expect_hex.is_none())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let backend = backend.clone();
|
||||||
|
let stop = shutdown.clone();
|
||||||
|
tasks.spawn(async move {
|
||||||
|
let interval = Duration::from_secs(backend.health.interval_seconds);
|
||||||
|
loop {
|
||||||
|
let permit = tokio::select! {
|
||||||
|
_ = stop.cancelled() => break,
|
||||||
|
result = probe_permits().clone().acquire_owned() => match result { Ok(p) => p, Err(_) => break },
|
||||||
|
};
|
||||||
|
let check = check_backend(&backend);
|
||||||
|
let result = tokio::select! {
|
||||||
|
_ = stop.cancelled() => break,
|
||||||
|
result = check => result,
|
||||||
|
};
|
||||||
|
drop(permit);
|
||||||
|
record_probe(&backend, result);
|
||||||
|
tokio::select! { _ = stop.cancelled() => break, _ = tokio::time::sleep(interval) => {} }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn check_backend(backend: &RuntimeBackend) -> std::result::Result<u64, String> {
|
||||||
|
let began = Instant::now();
|
||||||
|
let timeout = Duration::from_millis(backend.health.timeout_ms);
|
||||||
|
match backend.health.mode {
|
||||||
|
HealthMode::TcpConnect => {
|
||||||
|
match tokio::time::timeout(timeout, TcpStream::connect(backend.address)).await {
|
||||||
|
Ok(Ok(stream)) => {
|
||||||
|
drop(stream);
|
||||||
|
Ok(began.elapsed().as_millis() as u64)
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => Err(error.to_string()),
|
||||||
|
Err(_) => Err("TCP connect timeout".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HealthMode::UdpProbe => {
|
||||||
|
let payload =
|
||||||
|
decode_probe_hex(&backend.health.payload_hex).map_err(|e| e.to_string())?;
|
||||||
|
let expected = decode_probe_hex(backend.health.expect_hex.as_deref().unwrap_or(""))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
// Socket connect alone can succeed even when the remote UDP service is absent.
|
||||||
|
let local = match backend.address {
|
||||||
|
SocketAddr::V4(_) => "0.0.0.0:0",
|
||||||
|
SocketAddr::V6(_) => "[::]:0",
|
||||||
|
};
|
||||||
|
let socket = UdpSocket::bind(local).await.map_err(|e| e.to_string())?;
|
||||||
|
socket
|
||||||
|
.connect(backend.address)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tokio::time::timeout(timeout, async {
|
||||||
|
socket.send(&payload).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut buf = [0u8; 512];
|
||||||
|
let received = socket.recv(&mut buf).await.map_err(|e| e.to_string())?;
|
||||||
|
if buf[..received] != expected {
|
||||||
|
return Err("UDP response mismatch".into());
|
||||||
|
}
|
||||||
|
Ok(began.elapsed().as_millis() as u64)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| "UDP probe timeout".to_owned())?
|
||||||
|
}
|
||||||
|
HealthMode::Off => Err("health checking disabled".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn record_probe(backend: &RuntimeBackend, result: std::result::Result<u64, String>) {
|
||||||
|
let mut observation = backend
|
||||||
|
.observation
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
observation.last_checked_unix_ms = Some(
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64,
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(latency_ms) => {
|
||||||
|
observation.successful += 1;
|
||||||
|
observation.rises = observation.rises.saturating_add(1);
|
||||||
|
observation.falls = 0;
|
||||||
|
observation.last_result = Some("success");
|
||||||
|
observation.latency_ms = Some(latency_ms);
|
||||||
|
if observation.rises >= backend.health.rise {
|
||||||
|
backend.state.store(HEALTH_HEALTHY, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
observation.failed += 1;
|
||||||
|
observation.falls = observation.falls.saturating_add(1);
|
||||||
|
observation.rises = 0;
|
||||||
|
observation.last_result = Some("failure");
|
||||||
|
observation.last_error = Some(error.chars().take(192).collect());
|
||||||
|
observation.latency_ms = None;
|
||||||
|
if observation.falls >= backend.health.fall {
|
||||||
|
backend.state.store(HEALTH_UNHEALTHY, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-15
@@ -36,6 +36,14 @@ pub struct Host {
|
|||||||
pub protocol: Protocol,
|
pub protocol: Protocol,
|
||||||
pub listen: SocketAddr,
|
pub listen: SocketAddr,
|
||||||
pub upstream: SocketAddr,
|
pub upstream: SocketAddr,
|
||||||
|
/// Empty keeps the legacy upstream as one backend named primary.
|
||||||
|
#[serde(default)]
|
||||||
|
pub backends: Vec<Backend>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub balance: BalanceAlgorithm,
|
||||||
|
/// Default for optional backend telemetry; basic stream counters remain.
|
||||||
|
#[serde(default)]
|
||||||
|
pub metrics_enabled: Option<bool>,
|
||||||
/// TCP connect timeout. Kept in the common schema so TCP/UDP rows stay simple.
|
/// TCP connect timeout. Kept in the common schema so TCP/UDP rows stay simple.
|
||||||
pub connect_timeout_seconds: u64,
|
pub connect_timeout_seconds: u64,
|
||||||
/// Inactivity timeout for a TCP relay or a per-client UDP session.
|
/// Inactivity timeout for a TCP relay or a per-client UDP session.
|
||||||
@@ -52,6 +60,9 @@ impl Default for Host {
|
|||||||
protocol: Protocol::Tcp,
|
protocol: Protocol::Tcp,
|
||||||
listen: "127.0.0.1:10000".parse().expect("static stream listen"),
|
listen: "127.0.0.1:10000".parse().expect("static stream listen"),
|
||||||
upstream: "127.0.0.1:10001".parse().expect("static stream upstream"),
|
upstream: "127.0.0.1:10001".parse().expect("static stream upstream"),
|
||||||
|
backends: Vec::new(),
|
||||||
|
balance: BalanceAlgorithm::default(),
|
||||||
|
metrics_enabled: None,
|
||||||
connect_timeout_seconds: 5,
|
connect_timeout_seconds: 5,
|
||||||
idle_timeout_seconds: 300,
|
idle_timeout_seconds: 300,
|
||||||
max_connections: 1024,
|
max_connections: 1024,
|
||||||
@@ -73,6 +84,7 @@ pub struct Metrics {
|
|||||||
pub bytes_out: AtomicU64,
|
pub bytes_out: AtomicU64,
|
||||||
pub packets_in: AtomicU64,
|
pub packets_in: AtomicU64,
|
||||||
pub packets_out: AtomicU64,
|
pub packets_out: AtomicU64,
|
||||||
|
pub pool: std::sync::OnceLock<Arc<Pool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -86,6 +98,7 @@ pub struct MetricsView {
|
|||||||
pub bytes_out: u64,
|
pub bytes_out: u64,
|
||||||
pub packets_in: u64,
|
pub packets_in: u64,
|
||||||
pub packets_out: u64,
|
pub packets_out: u64,
|
||||||
|
pub backends: Vec<BackendView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Metrics {
|
impl Metrics {
|
||||||
@@ -101,6 +114,7 @@ impl Metrics {
|
|||||||
bytes_out: load(&self.bytes_out),
|
bytes_out: load(&self.bytes_out),
|
||||||
packets_in: load(&self.packets_in),
|
packets_in: load(&self.packets_in),
|
||||||
packets_out: load(&self.packets_out),
|
packets_out: load(&self.packets_out),
|
||||||
|
backends: self.pool.get().map_or_else(Vec::new, |pool| pool.views()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,15 +158,16 @@ pub fn validate(
|
|||||||
"stream host name must be at most 96 bytes without controls"
|
"stream host name must be at most 96 bytes without controls"
|
||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
host.listen.port() > 0 && host.upstream.port() > 0,
|
host.listen.port() > 0 && (host.upstream.port() > 0 || !host.backends.is_empty()),
|
||||||
"stream listener/upstream ports must be nonzero"
|
"stream listener/upstream ports must be nonzero"
|
||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
!host.listen.ip().is_multicast() && !host.upstream.ip().is_multicast(),
|
!host.listen.ip().is_multicast()
|
||||||
|
&& (host.backends.len() > 0 || !host.upstream.ip().is_multicast()),
|
||||||
"stream listener/upstream must not use multicast addresses"
|
"stream listener/upstream must not use multicast addresses"
|
||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
!host.upstream.ip().is_unspecified(),
|
(!host.backends.is_empty() || !host.upstream.ip().is_unspecified()),
|
||||||
"stream upstream must use a concrete IP address"
|
"stream upstream must use a concrete IP address"
|
||||||
);
|
);
|
||||||
ensure!(
|
ensure!(
|
||||||
@@ -167,6 +182,53 @@ pub fn validate(
|
|||||||
(1..=65535).contains(&host.max_connections),
|
(1..=65535).contains(&host.max_connections),
|
||||||
"stream connection/session limit must be 1..65535"
|
"stream connection/session limit must be 1..65535"
|
||||||
);
|
);
|
||||||
|
ensure!(
|
||||||
|
matches!(
|
||||||
|
(host.protocol, host.balance),
|
||||||
|
(
|
||||||
|
Protocol::Tcp,
|
||||||
|
BalanceAlgorithm::RoundRobin | BalanceAlgorithm::LeastConnections
|
||||||
|
) | (
|
||||||
|
Protocol::Udp,
|
||||||
|
BalanceAlgorithm::RoundRobin | BalanceAlgorithm::LeastSessions
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"stream balancing algorithm does not match transport"
|
||||||
|
);
|
||||||
|
ensure!(host.backends.len() <= 32, "at most 32 backends per stream");
|
||||||
|
let mut backend_ids = HashSet::new();
|
||||||
|
for backend in &mut host.backends {
|
||||||
|
ensure!(
|
||||||
|
crate::config::valid_id(&backend.id) && backend_ids.insert(backend.id.clone()),
|
||||||
|
"invalid/duplicate stream backend id"
|
||||||
|
);
|
||||||
|
backend.name = backend.name.trim().to_owned();
|
||||||
|
ensure!(
|
||||||
|
backend.name.len() <= 96 && !backend.name.chars().any(char::is_control),
|
||||||
|
"invalid stream backend name"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
backend.address.port() > 0
|
||||||
|
&& !backend.address.ip().is_unspecified()
|
||||||
|
&& !backend.address.ip().is_multicast(),
|
||||||
|
"invalid stream backend address"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
(1..=16).contains(&backend.weight),
|
||||||
|
"stream backend weight must be 1..16"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
backend
|
||||||
|
.max_sessions
|
||||||
|
.is_none_or(|n| (1..=65535).contains(&n)),
|
||||||
|
"stream backend session limit must be 1..65535"
|
||||||
|
);
|
||||||
|
backend.health.validate(host.protocol)?;
|
||||||
|
}
|
||||||
|
// Retain upstream as a representative address for older clients.
|
||||||
|
if let Some(first) = host.backends.first() {
|
||||||
|
host.upstream = first.address;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (index, host) in hosts.iter().enumerate() {
|
for (index, host) in hosts.iter().enumerate() {
|
||||||
@@ -220,24 +282,26 @@ pub fn validate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject direct loops into another RustProxy listener of the same transport.
|
// Reject direct loops for every backend (including disabled/draining entries).
|
||||||
for host in hosts.iter().filter(|item| item.enabled) {
|
for host in hosts.iter().filter(|item| item.enabled) {
|
||||||
|
for backend in host.effective_backends() {
|
||||||
|
let target = backend.address;
|
||||||
match host.protocol {
|
match host.protocol {
|
||||||
Protocol::Tcp => {
|
Protocol::Tcp => {
|
||||||
ensure!(
|
ensure!(
|
||||||
!crate::vhost::socket_overlap(host.upstream, admin)
|
!crate::vhost::socket_overlap(target, admin)
|
||||||
&& prometheus
|
&& prometheus
|
||||||
.is_none_or(|addr| !crate::vhost::socket_overlap(host.upstream, addr))
|
.is_none_or(|addr| !crate::vhost::socket_overlap(target, addr))
|
||||||
&& !http_listeners
|
&& !http_listeners
|
||||||
.iter()
|
.iter()
|
||||||
.any(|listener| crate::vhost::socket_overlap(
|
.any(|listener| crate::vhost::socket_overlap(
|
||||||
host.upstream,
|
target,
|
||||||
listener.address
|
listener.address
|
||||||
))
|
))
|
||||||
&& !hosts
|
&& !hosts
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|other| other.enabled && other.protocol == Protocol::Tcp)
|
.filter(|other| other.enabled && other.protocol == Protocol::Tcp)
|
||||||
.any(|other| crate::vhost::socket_overlap(host.upstream, other.listen)),
|
.any(|other| crate::vhost::socket_overlap(target, other.listen)),
|
||||||
"TCP stream {} upstream points at a RustProxy listener",
|
"TCP stream {} upstream points at a RustProxy listener",
|
||||||
host.id
|
host.id
|
||||||
);
|
);
|
||||||
@@ -247,20 +311,18 @@ pub fn validate(
|
|||||||
!http_listeners
|
!http_listeners
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|listener| listener.http3.is_some())
|
.filter(|listener| listener.http3.is_some())
|
||||||
.any(|listener| crate::vhost::socket_overlap(
|
.any(|listener| crate::vhost::socket_overlap(target, listener.address))
|
||||||
host.upstream,
|
|
||||||
listener.address
|
|
||||||
))
|
|
||||||
&& !hosts
|
&& !hosts
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|other| other.enabled && other.protocol == Protocol::Udp)
|
.filter(|other| other.enabled && other.protocol == Protocol::Udp)
|
||||||
.any(|other| crate::vhost::socket_overlap(host.upstream, other.listen)),
|
.any(|other| crate::vhost::socket_overlap(target, other.listen)),
|
||||||
"UDP stream {} upstream points at a RustProxy UDP listener",
|
"UDP stream {} upstream points at a RustProxy UDP listener",
|
||||||
host.id
|
host.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +335,9 @@ pub async fn serve_tcp(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let slots = Arc::new(Semaphore::new(host.max_connections));
|
let slots = Arc::new(Semaphore::new(host.max_connections));
|
||||||
let mut tasks = JoinSet::new();
|
let mut tasks = JoinSet::new();
|
||||||
|
let pool = Pool::new(&host);
|
||||||
|
let _ = metrics.pool.set(pool.clone());
|
||||||
|
spawn_health(pool.clone(), &mut tasks, shutdown.clone());
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
@@ -285,6 +350,10 @@ pub async fn serve_tcp(
|
|||||||
drop(client);
|
drop(client);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let Some(backend) = pool.choose() else {
|
||||||
|
metrics.rejected.fetch_add(1, Ordering::Relaxed);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
metrics.sessions_total.fetch_add(1, Ordering::Relaxed);
|
metrics.sessions_total.fetch_add(1, Ordering::Relaxed);
|
||||||
let cfg = host.clone();
|
let cfg = host.clone();
|
||||||
let stop = shutdown.clone();
|
let stop = shutdown.clone();
|
||||||
@@ -293,24 +362,28 @@ pub async fn serve_tcp(
|
|||||||
tasks.spawn(async move {
|
tasks.spawn(async move {
|
||||||
let _active = active;
|
let _active = active;
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
|
let target = backend.runtime.address;
|
||||||
let upstream = match tokio::time::timeout(
|
let upstream = match tokio::time::timeout(
|
||||||
Duration::from_secs(cfg.connect_timeout_seconds),
|
Duration::from_secs(cfg.connect_timeout_seconds),
|
||||||
TcpStream::connect(cfg.upstream),
|
TcpStream::connect(target),
|
||||||
).await {
|
).await {
|
||||||
Ok(Ok(stream)) => stream,
|
Ok(Ok(stream)) => stream,
|
||||||
Ok(Err(error)) => {
|
Ok(Err(error)) => {
|
||||||
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
backend.error();
|
||||||
tracing::debug!(stream=%cfg.id, %peer, %error, "TCP stream upstream connect failed");
|
tracing::debug!(stream=%cfg.id, %peer, %error, "TCP stream upstream connect failed");
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
backend.error();
|
||||||
tracing::debug!(stream=%cfg.id, %peer, "TCP stream upstream connect timed out");
|
tracing::debug!(stream=%cfg.id, %peer, "TCP stream upstream connect timed out");
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if let Err(error) = relay_tcp(client, upstream, stop, Duration::from_secs(cfg.idle_timeout_seconds), &stream_metrics).await {
|
if let Err(error) = relay_tcp(client, upstream, stop, Duration::from_secs(cfg.idle_timeout_seconds), &stream_metrics, &backend).await {
|
||||||
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
backend.error();
|
||||||
tracing::debug!(stream=%cfg.id, %peer, %error, "TCP stream relay ended");
|
tracing::debug!(stream=%cfg.id, %peer, %error, "TCP stream relay ended");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -328,6 +401,7 @@ async fn copy_with_activity<R, W>(
|
|||||||
mut writer: W,
|
mut writer: W,
|
||||||
activity: tokio::sync::watch::Sender<Instant>,
|
activity: tokio::sync::watch::Sender<Instant>,
|
||||||
transferred: &AtomicU64,
|
transferred: &AtomicU64,
|
||||||
|
detailed: Option<&AtomicU64>,
|
||||||
) -> std::io::Result<u64>
|
) -> std::io::Result<u64>
|
||||||
where
|
where
|
||||||
R: AsyncRead + Unpin,
|
R: AsyncRead + Unpin,
|
||||||
@@ -344,6 +418,9 @@ where
|
|||||||
writer.write_all(&buffer[..read]).await?;
|
writer.write_all(&buffer[..read]).await?;
|
||||||
total = total.saturating_add(read as u64);
|
total = total.saturating_add(read as u64);
|
||||||
transferred.fetch_add(read as u64, Ordering::Relaxed);
|
transferred.fetch_add(read as u64, Ordering::Relaxed);
|
||||||
|
if let Some(counter) = detailed {
|
||||||
|
counter.fetch_add(read as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
activity.send_replace(Instant::now());
|
activity.send_replace(Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,6 +431,7 @@ async fn relay_tcp(
|
|||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
idle: Duration,
|
idle: Duration,
|
||||||
metrics: &Metrics,
|
metrics: &Metrics,
|
||||||
|
backend: &BackendLease,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (client_read, client_write) = client.into_split();
|
let (client_read, client_write) = client.into_split();
|
||||||
let (upstream_read, upstream_write) = upstream.into_split();
|
let (upstream_read, upstream_write) = upstream.into_split();
|
||||||
@@ -363,12 +441,14 @@ async fn relay_tcp(
|
|||||||
upstream_write,
|
upstream_write,
|
||||||
activity.clone(),
|
activity.clone(),
|
||||||
&metrics.bytes_in,
|
&metrics.bytes_in,
|
||||||
|
backend.detail(&backend.runtime.bytes_in),
|
||||||
);
|
);
|
||||||
let u2c = copy_with_activity(
|
let u2c = copy_with_activity(
|
||||||
upstream_read,
|
upstream_read,
|
||||||
client_write,
|
client_write,
|
||||||
activity.clone(),
|
activity.clone(),
|
||||||
&metrics.bytes_out,
|
&metrics.bytes_out,
|
||||||
|
backend.detail(&backend.runtime.bytes_out),
|
||||||
);
|
);
|
||||||
drop(activity);
|
drop(activity);
|
||||||
tokio::pin!(c2u);
|
tokio::pin!(c2u);
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
// Bounded Layer-4 backend pools. No async locks on the traffic hot path.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BalanceAlgorithm {
|
||||||
|
#[default]
|
||||||
|
RoundRobin,
|
||||||
|
LeastSessions,
|
||||||
|
#[serde(alias = "least_connections")]
|
||||||
|
LeastConnections,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum HealthMode {
|
||||||
|
#[default]
|
||||||
|
Off,
|
||||||
|
TcpConnect,
|
||||||
|
UdpProbe,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct HealthCheck {
|
||||||
|
pub mode: HealthMode,
|
||||||
|
pub interval_seconds: u64,
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
pub rise: u32,
|
||||||
|
pub fall: u32,
|
||||||
|
/// Hexadecimal payload sent only to the configured backend when response matching is set.
|
||||||
|
pub payload_hex: String,
|
||||||
|
/// Exact hex response. Without this, UDP remains unverified and no probes are sent.
|
||||||
|
pub expect_hex: Option<String>,
|
||||||
|
}
|
||||||
|
impl Default for HealthCheck {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: HealthMode::Off,
|
||||||
|
interval_seconds: 10,
|
||||||
|
timeout_ms: 1000,
|
||||||
|
rise: 2,
|
||||||
|
fall: 2,
|
||||||
|
payload_hex: String::new(),
|
||||||
|
expect_hex: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl HealthCheck {
|
||||||
|
fn validate(&self, protocol: Protocol) -> Result<()> {
|
||||||
|
ensure!(
|
||||||
|
(1..=3600).contains(&self.interval_seconds)
|
||||||
|
&& (50..=30000).contains(&self.timeout_ms)
|
||||||
|
&& self.timeout_ms < self.interval_seconds * 1000,
|
||||||
|
"invalid stream health interval/timeout"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
(1..=10).contains(&self.rise) && (1..=10).contains(&self.fall),
|
||||||
|
"invalid rise/fall"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
matches!(
|
||||||
|
(protocol, self.mode),
|
||||||
|
(Protocol::Tcp, HealthMode::Off | HealthMode::TcpConnect)
|
||||||
|
| (Protocol::Udp, HealthMode::Off | HealthMode::UdpProbe)
|
||||||
|
),
|
||||||
|
"health mode does not match stream protocol"
|
||||||
|
);
|
||||||
|
let payload = decode_probe_hex(&self.payload_hex)?;
|
||||||
|
ensure!(payload.len() <= 128, "UDP probe payload exceeds 128 bytes");
|
||||||
|
if let Some(expected) = &self.expect_hex {
|
||||||
|
ensure!(
|
||||||
|
!expected.is_empty() && decode_probe_hex(expected)?.len() <= 128,
|
||||||
|
"invalid UDP probe response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.mode == HealthMode::UdpProbe && self.expect_hex.is_some() {
|
||||||
|
ensure!(
|
||||||
|
!payload.is_empty(),
|
||||||
|
"verified UDP probes require a nonempty payload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_probe_hex(value: &str) -> Result<Vec<u8>> {
|
||||||
|
ensure!(
|
||||||
|
value.len() <= 256 && value.len() % 2 == 0 && value.is_ascii(),
|
||||||
|
"invalid probe hex length"
|
||||||
|
);
|
||||||
|
value
|
||||||
|
.as_bytes()
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|chunk| {
|
||||||
|
let hex = |byte: u8| -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => Some(byte - b'0'),
|
||||||
|
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok((hex(chunk[0]).context("invalid probe hex")? << 4)
|
||||||
|
| hex(chunk[1]).context("invalid probe hex")?)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Backend {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
pub address: SocketAddr,
|
||||||
|
#[serde(default = "backend_default_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub drain: bool,
|
||||||
|
#[serde(default = "backend_default_weight")]
|
||||||
|
pub weight: u8,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_sessions: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub health: HealthCheck,
|
||||||
|
#[serde(default)]
|
||||||
|
pub metrics_enabled: Option<bool>,
|
||||||
|
}
|
||||||
|
fn backend_default_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn backend_default_weight() -> u8 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
impl Default for Backend {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: "primary".into(),
|
||||||
|
name: "Primary".into(),
|
||||||
|
address: "127.0.0.1:10001".parse().expect("static backend"),
|
||||||
|
enabled: true,
|
||||||
|
drain: false,
|
||||||
|
weight: 1,
|
||||||
|
max_sessions: None,
|
||||||
|
health: HealthCheck::default(),
|
||||||
|
metrics_enabled: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Host {
|
||||||
|
pub fn effective_backends(&self) -> Vec<Backend> {
|
||||||
|
if self.backends.is_empty() {
|
||||||
|
vec![Backend {
|
||||||
|
address: self.upstream,
|
||||||
|
..Backend::default()
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
self.backends.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// health states are distinct from eligibility: an unverified UDP backend may receive
|
||||||
|
// traffic, but must never be labeled "healthy" by a local UDP connect().
|
||||||
|
const HEALTH_UNVERIFIED: u8 = 0;
|
||||||
|
const HEALTH_CHECKING: u8 = 1;
|
||||||
|
const HEALTH_HEALTHY: u8 = 2;
|
||||||
|
const HEALTH_UNHEALTHY: u8 = 3;
|
||||||
|
#[derive(Debug, Default, Clone, Serialize)]
|
||||||
|
pub struct HealthObservation {
|
||||||
|
pub last_checked_unix_ms: Option<u64>,
|
||||||
|
pub latency_ms: Option<u64>,
|
||||||
|
pub last_result: Option<&'static str>,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub successful: u64,
|
||||||
|
pub failed: u64,
|
||||||
|
#[serde(skip)]
|
||||||
|
rises: u32,
|
||||||
|
#[serde(skip)]
|
||||||
|
falls: u32,
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RuntimeBackend {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub address: SocketAddr,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub drain: bool,
|
||||||
|
pub weight: u8,
|
||||||
|
pub max_sessions: Option<usize>,
|
||||||
|
pub health: HealthCheck,
|
||||||
|
pub detail_enabled: bool,
|
||||||
|
state: std::sync::atomic::AtomicU8,
|
||||||
|
observation: std::sync::Mutex<HealthObservation>,
|
||||||
|
active: AtomicU64,
|
||||||
|
total: AtomicU64,
|
||||||
|
completed: AtomicU64,
|
||||||
|
assignments: AtomicU64,
|
||||||
|
rejected: AtomicU64,
|
||||||
|
errors: AtomicU64,
|
||||||
|
bytes_in: AtomicU64,
|
||||||
|
bytes_out: AtomicU64,
|
||||||
|
packets_in: AtomicU64,
|
||||||
|
packets_out: AtomicU64,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BackendView {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub address: SocketAddr,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub drain: bool,
|
||||||
|
pub detail_enabled: bool,
|
||||||
|
pub status: &'static str,
|
||||||
|
pub active: u64,
|
||||||
|
pub sessions_total: Option<u64>,
|
||||||
|
pub sessions_completed: Option<u64>,
|
||||||
|
pub assignments: Option<u64>,
|
||||||
|
pub rejected: Option<u64>,
|
||||||
|
pub errors: Option<u64>,
|
||||||
|
pub bytes_in: Option<u64>,
|
||||||
|
pub bytes_out: Option<u64>,
|
||||||
|
pub packets_in: Option<u64>,
|
||||||
|
pub packets_out: Option<u64>,
|
||||||
|
pub health: HealthObservation,
|
||||||
|
}
|
||||||
|
impl RuntimeBackend {
|
||||||
|
fn new(config: Backend, host_metrics_enabled: bool) -> Self {
|
||||||
|
let checked = match config.health.mode {
|
||||||
|
HealthMode::Off => false,
|
||||||
|
HealthMode::UdpProbe if config.health.expect_hex.is_none() => false,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
let initial = if checked {
|
||||||
|
HEALTH_CHECKING
|
||||||
|
} else {
|
||||||
|
HEALTH_UNVERIFIED
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
id: config.id,
|
||||||
|
name: config.name,
|
||||||
|
address: config.address,
|
||||||
|
enabled: config.enabled,
|
||||||
|
drain: config.drain,
|
||||||
|
weight: config.weight,
|
||||||
|
max_sessions: config.max_sessions,
|
||||||
|
detail_enabled: config.metrics_enabled.unwrap_or(host_metrics_enabled),
|
||||||
|
health: config.health,
|
||||||
|
state: std::sync::atomic::AtomicU8::new(initial),
|
||||||
|
observation: std::sync::Mutex::new(HealthObservation::default()),
|
||||||
|
active: AtomicU64::new(0),
|
||||||
|
total: AtomicU64::new(0),
|
||||||
|
completed: AtomicU64::new(0),
|
||||||
|
assignments: AtomicU64::new(0),
|
||||||
|
rejected: AtomicU64::new(0),
|
||||||
|
errors: AtomicU64::new(0),
|
||||||
|
bytes_in: AtomicU64::new(0),
|
||||||
|
bytes_out: AtomicU64::new(0),
|
||||||
|
packets_in: AtomicU64::new(0),
|
||||||
|
packets_out: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn eligible(&self) -> bool {
|
||||||
|
self.enabled && !self.drain && self.state.load(Ordering::Relaxed) != HEALTH_UNHEALTHY
|
||||||
|
}
|
||||||
|
fn reserve(&self) -> bool {
|
||||||
|
let capacity = self.max_sessions.map_or(u64::MAX, |n| n as u64);
|
||||||
|
let result = self
|
||||||
|
.active
|
||||||
|
.try_update(Ordering::AcqRel, Ordering::Relaxed, |n| {
|
||||||
|
(n < capacity).then_some(n + 1)
|
||||||
|
});
|
||||||
|
if result.is_err() && self.detail_enabled {
|
||||||
|
self.rejected.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
result.is_ok()
|
||||||
|
}
|
||||||
|
fn view(&self) -> BackendView {
|
||||||
|
let load =
|
||||||
|
|counter: &AtomicU64| self.detail_enabled.then(|| counter.load(Ordering::Relaxed));
|
||||||
|
let state = if !self.enabled {
|
||||||
|
"disabled"
|
||||||
|
} else {
|
||||||
|
match self.state.load(Ordering::Relaxed) {
|
||||||
|
HEALTH_CHECKING => "checking",
|
||||||
|
HEALTH_HEALTHY => "healthy",
|
||||||
|
HEALTH_UNHEALTHY => "unhealthy",
|
||||||
|
_ => "unverified",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
BackendView {
|
||||||
|
id: self.id.clone(),
|
||||||
|
name: self.name.clone(),
|
||||||
|
address: self.address,
|
||||||
|
enabled: self.enabled,
|
||||||
|
drain: self.drain,
|
||||||
|
detail_enabled: self.detail_enabled,
|
||||||
|
status: state,
|
||||||
|
active: self.active.load(Ordering::Relaxed),
|
||||||
|
sessions_total: load(&self.total),
|
||||||
|
sessions_completed: load(&self.completed),
|
||||||
|
assignments: load(&self.assignments),
|
||||||
|
rejected: load(&self.rejected),
|
||||||
|
errors: load(&self.errors),
|
||||||
|
bytes_in: load(&self.bytes_in),
|
||||||
|
bytes_out: load(&self.bytes_out),
|
||||||
|
packets_in: load(&self.packets_in),
|
||||||
|
packets_out: load(&self.packets_out),
|
||||||
|
health: self
|
||||||
|
.observation
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Pool {
|
||||||
|
backends: Vec<Arc<RuntimeBackend>>,
|
||||||
|
algorithm: BalanceAlgorithm,
|
||||||
|
cursor: AtomicU64,
|
||||||
|
}
|
||||||
|
impl Pool {
|
||||||
|
fn new(host: &Host) -> Arc<Self> {
|
||||||
|
let backends = host
|
||||||
|
.effective_backends()
|
||||||
|
.into_iter()
|
||||||
|
.map(|backend| {
|
||||||
|
Arc::new(RuntimeBackend::new(
|
||||||
|
backend,
|
||||||
|
host.metrics_enabled.unwrap_or(false),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Arc::new(Self {
|
||||||
|
backends,
|
||||||
|
algorithm: host.balance,
|
||||||
|
cursor: AtomicU64::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn assign(&self, index: usize) -> Option<BackendLease> {
|
||||||
|
let backend = &self.backends[index];
|
||||||
|
if !backend.eligible() || !backend.reserve() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A health result may have arrived after the first eligibility read.
|
||||||
|
if !backend.eligible() {
|
||||||
|
backend.active.fetch_sub(1, Ordering::AcqRel);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
backend.total.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if backend.detail_enabled {
|
||||||
|
backend.assignments.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Some(BackendLease {
|
||||||
|
runtime: backend.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn choose(&self) -> Option<BackendLease> {
|
||||||
|
if self.backends.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let offset = self.cursor.fetch_add(1, Ordering::Relaxed) as usize;
|
||||||
|
match self.algorithm {
|
||||||
|
BalanceAlgorithm::RoundRobin => {
|
||||||
|
// Recompute the bounded eligible weight on each assignment:
|
||||||
|
// unhealthy/draining/capacity-full peers do not skew the ratio.
|
||||||
|
let available = |b: &Arc<RuntimeBackend>| {
|
||||||
|
b.eligible()
|
||||||
|
&& b.max_sessions
|
||||||
|
.is_none_or(|max| b.active.load(Ordering::Relaxed) < max as u64)
|
||||||
|
};
|
||||||
|
let total_weight: usize = self
|
||||||
|
.backends
|
||||||
|
.iter()
|
||||||
|
.filter(|b| available(b))
|
||||||
|
.map(|b| b.weight as usize)
|
||||||
|
.sum();
|
||||||
|
if total_weight == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut ticket = offset % total_weight;
|
||||||
|
let mut preferred = 0;
|
||||||
|
for (index, backend) in self.backends.iter().enumerate() {
|
||||||
|
if !available(backend) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ticket < backend.weight as usize {
|
||||||
|
preferred = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ticket -= backend.weight as usize;
|
||||||
|
}
|
||||||
|
// A concurrent session or health transition may invalidate the
|
||||||
|
// preferred peer after the snapshot: try each other peer once.
|
||||||
|
for delta in 0..self.backends.len() {
|
||||||
|
let index = (preferred + delta) % self.backends.len();
|
||||||
|
if let Some(lease) = self.assign(index) {
|
||||||
|
return Some(lease);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BalanceAlgorithm::LeastSessions | BalanceAlgorithm::LeastConnections => {
|
||||||
|
let mut ordered = (0..self.backends.len()).collect::<Vec<_>>();
|
||||||
|
ordered.sort_by_key(|&index| {
|
||||||
|
let backend = &self.backends[index];
|
||||||
|
(
|
||||||
|
backend.active.load(Ordering::Relaxed).saturating_mul(16)
|
||||||
|
/ backend.weight as u64,
|
||||||
|
(index + self.backends.len() - offset % self.backends.len())
|
||||||
|
% self.backends.len(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
for index in ordered {
|
||||||
|
if let Some(lease) = self.assign(index) {
|
||||||
|
return Some(lease);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn views(&self) -> Vec<BackendView> {
|
||||||
|
self.backends.iter().map(|b| b.view()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BackendLease {
|
||||||
|
runtime: Arc<RuntimeBackend>,
|
||||||
|
}
|
||||||
|
impl BackendLease {
|
||||||
|
fn detail<'a>(&self, value: &'a AtomicU64) -> Option<&'a AtomicU64> {
|
||||||
|
self.runtime.detail_enabled.then_some(value)
|
||||||
|
}
|
||||||
|
fn error(&self) {
|
||||||
|
if self.runtime.detail_enabled {
|
||||||
|
self.runtime.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for BackendLease {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.runtime.active.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
self.runtime.completed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-3
@@ -10,6 +10,9 @@ pub async fn serve_udp(
|
|||||||
let slots = Arc::new(Semaphore::new(host.max_connections));
|
let slots = Arc::new(Semaphore::new(host.max_connections));
|
||||||
let next_id = AtomicU64::new(1);
|
let next_id = AtomicU64::new(1);
|
||||||
let mut tasks = JoinSet::new();
|
let mut tasks = JoinSet::new();
|
||||||
|
let pool = Pool::new(&host);
|
||||||
|
let _ = metrics.pool.set(pool.clone());
|
||||||
|
spawn_health(pool.clone(), &mut tasks, shutdown.clone());
|
||||||
let mut buffer = vec![0u8; 65_535];
|
let mut buffer = vec![0u8; 65_535];
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -31,7 +34,11 @@ pub async fn serve_udp(
|
|||||||
metrics.rejected.fetch_add(1, Ordering::Relaxed);
|
metrics.rejected.fetch_add(1, Ordering::Relaxed);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let bind = match host.upstream.ip() {
|
let Some(backend) = pool.choose() else {
|
||||||
|
metrics.rejected.fetch_add(1, Ordering::Relaxed);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let bind = match backend.runtime.address.ip() {
|
||||||
IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
|
IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
|
||||||
IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
|
IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
|
||||||
};
|
};
|
||||||
@@ -39,12 +46,14 @@ pub async fn serve_udp(
|
|||||||
Ok(socket) => socket,
|
Ok(socket) => socket,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
backend.error();
|
||||||
tracing::debug!(stream=%host.id, %peer, %error, "UDP stream ephemeral bind failed");
|
tracing::debug!(stream=%host.id, %peer, %error, "UDP stream ephemeral bind failed");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = upstream.connect(host.upstream).await {
|
if let Err(error) = upstream.connect(backend.runtime.address).await {
|
||||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
backend.error();
|
||||||
tracing::debug!(stream=%host.id, %peer, %error, "UDP stream upstream connect failed");
|
tracing::debug!(stream=%host.id, %peer, %error, "UDP stream upstream connect failed");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -63,7 +72,7 @@ pub async fn serve_udp(
|
|||||||
let active = ActiveSession::begin(stream_metrics.clone());
|
let active = ActiveSession::begin(stream_metrics.clone());
|
||||||
tasks.spawn(async move {
|
tasks.spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
udp_session(cfg, peer, id, front, upstream, rx, map, stop, stream_metrics, active).await;
|
udp_session(cfg, peer, id, front, upstream, rx, map, stop, stream_metrics, active, backend).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +94,7 @@ async fn udp_session(
|
|||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
metrics: Arc<Metrics>,
|
metrics: Arc<Metrics>,
|
||||||
_active: ActiveSession,
|
_active: ActiveSession,
|
||||||
|
backend: BackendLease,
|
||||||
) {
|
) {
|
||||||
let idle = Duration::from_secs(host.idle_timeout_seconds);
|
let idle = Duration::from_secs(host.idle_timeout_seconds);
|
||||||
let deadline = tokio::time::sleep_until(Instant::now() + idle);
|
let deadline = tokio::time::sleep_until(Instant::now() + idle);
|
||||||
@@ -96,22 +106,29 @@ async fn udp_session(
|
|||||||
Some(data) = incoming.recv() => {
|
Some(data) = incoming.recv() => {
|
||||||
if upstream.send(&data).await.is_err() {
|
if upstream.send(&data).await.is_err() {
|
||||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
backend.error();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if let Some(counter) = backend.detail(&backend.runtime.bytes_in) { counter.fetch_add(data.len() as u64, Ordering::Relaxed); }
|
||||||
|
if let Some(counter) = backend.detail(&backend.runtime.packets_in) { counter.fetch_add(1, Ordering::Relaxed); }
|
||||||
deadline.as_mut().reset(Instant::now() + idle);
|
deadline.as_mut().reset(Instant::now() + idle);
|
||||||
},
|
},
|
||||||
received = upstream.recv(&mut buffer) => match received {
|
received = upstream.recv(&mut buffer) => match received {
|
||||||
Ok(length) => {
|
Ok(length) => {
|
||||||
if frontend.send_to(&buffer[..length], peer).await.is_err() {
|
if frontend.send_to(&buffer[..length], peer).await.is_err() {
|
||||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
backend.error();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if let Some(counter) = backend.detail(&backend.runtime.bytes_out) { counter.fetch_add(length as u64, Ordering::Relaxed); }
|
||||||
|
if let Some(counter) = backend.detail(&backend.runtime.packets_out) { counter.fetch_add(1, Ordering::Relaxed); }
|
||||||
metrics.packets_out.fetch_add(1, Ordering::Relaxed);
|
metrics.packets_out.fetch_add(1, Ordering::Relaxed);
|
||||||
metrics.bytes_out.fetch_add(length as u64, Ordering::Relaxed);
|
metrics.bytes_out.fetch_add(length as u64, Ordering::Relaxed);
|
||||||
deadline.as_mut().reset(Instant::now() + idle);
|
deadline.as_mut().reset(Instant::now() + idle);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
backend.error();
|
||||||
break;
|
break;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,3 +60,304 @@ mod tests {
|
|||||||
assert_eq!(view.errors, 1);
|
assert_eq!(view.errors, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod pool_regression {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn backend(id: &str, port: u16) -> Backend {
|
||||||
|
Backend {
|
||||||
|
id: id.into(),
|
||||||
|
address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
|
||||||
|
..Backend::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn host_with_pool(protocol: Protocol) -> Host {
|
||||||
|
Host {
|
||||||
|
id: "pool-test".into(),
|
||||||
|
protocol,
|
||||||
|
listen: "127.0.0.1:19001".parse().unwrap(),
|
||||||
|
upstream: "127.0.0.1:19002".parse().unwrap(),
|
||||||
|
backends: vec![backend("node-a", 19002), backend("node-b", 19003)],
|
||||||
|
..Host::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_upstream_remains_a_single_backend() {
|
||||||
|
let host: Host = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "legacy", "protocol": "tcp", "listen": "127.0.0.1:19001",
|
||||||
|
"upstream": "127.0.0.1:19002"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(host.backends.is_empty());
|
||||||
|
assert_eq!(host.effective_backends().len(), 1);
|
||||||
|
assert_eq!(host.effective_backends()[0].id, "primary");
|
||||||
|
assert_eq!(host.effective_backends()[0].address, host.upstream);
|
||||||
|
let mut hosts = vec![host];
|
||||||
|
validate(&mut hosts, &[], "127.0.0.1:9090".parse().unwrap(), None).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_validation_rejects_duplicate_id_and_listener_loop() {
|
||||||
|
let mut hosts = vec![host_with_pool(Protocol::Tcp)];
|
||||||
|
hosts[0].backends[1].id = "node-a".into();
|
||||||
|
assert!(validate(&mut hosts, &[], "127.0.0.1:9090".parse().unwrap(), None).is_err());
|
||||||
|
hosts[0].backends[1].id = "node-b".into();
|
||||||
|
hosts[0].backends[1].address = hosts[0].listen;
|
||||||
|
assert!(validate(&mut hosts, &[], "127.0.0.1:9090".parse().unwrap(), None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weighted_round_robin_respects_sequence() {
|
||||||
|
let mut host = host_with_pool(Protocol::Tcp);
|
||||||
|
host.backends[0].weight = 2;
|
||||||
|
let pool = Pool::new(&host);
|
||||||
|
let choices = (0..6)
|
||||||
|
.map(|_| pool.choose().unwrap().runtime.id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
choices,
|
||||||
|
["node-a", "node-a", "node-b", "node-a", "node-a", "node-b"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn least_load_and_draining_and_cap_do_not_leak_leases() {
|
||||||
|
let mut host = host_with_pool(Protocol::Udp);
|
||||||
|
host.balance = BalanceAlgorithm::LeastSessions;
|
||||||
|
let pool = Pool::new(&host);
|
||||||
|
let first = pool.choose().unwrap();
|
||||||
|
let second = pool.choose().unwrap();
|
||||||
|
assert_ne!(first.runtime.id, second.runtime.id);
|
||||||
|
assert_eq!(first.runtime.active.load(Ordering::Relaxed), 1);
|
||||||
|
drop(first);
|
||||||
|
drop(second);
|
||||||
|
assert!(pool
|
||||||
|
.backends
|
||||||
|
.iter()
|
||||||
|
.all(|b| b.active.load(Ordering::Relaxed) == 0));
|
||||||
|
|
||||||
|
host.backends[0].drain = true;
|
||||||
|
host.backends[1].max_sessions = Some(1);
|
||||||
|
let pool = Pool::new(&host);
|
||||||
|
let active = pool.choose().unwrap();
|
||||||
|
assert_eq!(active.runtime.id, "node-b");
|
||||||
|
assert!(pool.choose().is_none());
|
||||||
|
drop(active);
|
||||||
|
assert!(pool.choose().is_some());
|
||||||
|
host.backends[1].enabled = false;
|
||||||
|
assert!(Pool::new(&host).choose().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detailed_metrics_override_does_not_control_routing() {
|
||||||
|
let mut host = host_with_pool(Protocol::Tcp);
|
||||||
|
let legacy = Pool::new(&host);
|
||||||
|
assert!(!legacy.backends[0].detail_enabled);
|
||||||
|
assert!(legacy.choose().is_some());
|
||||||
|
host.metrics_enabled = Some(true);
|
||||||
|
host.backends[1].metrics_enabled = Some(false);
|
||||||
|
let with_stream_default = Pool::new(&host);
|
||||||
|
assert!(with_stream_default.backends[0].detail_enabled);
|
||||||
|
assert!(!with_stream_default.backends[1].detail_enabled);
|
||||||
|
host.metrics_enabled = Some(false);
|
||||||
|
host.backends[0].metrics_enabled = Some(true);
|
||||||
|
let override_pool = Pool::new(&host);
|
||||||
|
assert!(override_pool.backends[0].detail_enabled);
|
||||||
|
assert!(!override_pool.backends[1].detail_enabled);
|
||||||
|
assert!(override_pool.choose().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tcp_hysteresis_requires_rise_and_fall() {
|
||||||
|
let mut host = host_with_pool(Protocol::Tcp);
|
||||||
|
host.backends[0].health.mode = HealthMode::TcpConnect;
|
||||||
|
let pool = Pool::new(&host);
|
||||||
|
let b = &pool.backends[0];
|
||||||
|
assert_eq!(b.view().status, "checking");
|
||||||
|
record_probe(b, Ok(10));
|
||||||
|
assert_eq!(b.view().status, "checking");
|
||||||
|
record_probe(b, Ok(12));
|
||||||
|
assert_eq!(b.view().status, "healthy");
|
||||||
|
record_probe(b, Err("socket unavailable".into()));
|
||||||
|
assert_eq!(b.view().health.last_result, Some("failure"));
|
||||||
|
assert_eq!(b.view().status, "healthy");
|
||||||
|
record_probe(b, Err("socket unavailable".into()));
|
||||||
|
assert_eq!(b.view().status, "unhealthy");
|
||||||
|
assert!(!b.eligible());
|
||||||
|
record_probe(b, Ok(9));
|
||||||
|
record_probe(b, Ok(9));
|
||||||
|
assert_eq!(b.view().status, "healthy");
|
||||||
|
assert_eq!(b.view().health.last_result, Some("success"));
|
||||||
|
assert_eq!(
|
||||||
|
b.view().health.last_error.as_deref(),
|
||||||
|
Some("socket unavailable")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udp_without_verified_reply_is_not_healthy() {
|
||||||
|
let mut host = host_with_pool(Protocol::Udp);
|
||||||
|
host.backends[0].health.mode = HealthMode::UdpProbe;
|
||||||
|
host.backends[0].health.payload_hex = "50494e47".into();
|
||||||
|
host.backends[1].health.mode = HealthMode::UdpProbe;
|
||||||
|
host.backends[1].health.expect_hex = Some("504f4e47".into());
|
||||||
|
host.backends[1].health.payload_hex = "50494e47".into();
|
||||||
|
let pool = Pool::new(&host);
|
||||||
|
assert_eq!(pool.backends[0].view().status, "unverified");
|
||||||
|
assert_eq!(pool.backends[1].view().status, "checking");
|
||||||
|
host.backends[0].health.expect_hex = Some("ff".into());
|
||||||
|
host.backends[0].health.payload_hex = "".into();
|
||||||
|
assert!(host.backends[0].health.validate(Protocol::Udp).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tcp_and_udp_probes_require_real_remote_responses() {
|
||||||
|
let tcp = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let mut tcp_config = backend("tcp-probe", tcp.local_addr().unwrap().port());
|
||||||
|
tcp_config.health.mode = HealthMode::TcpConnect;
|
||||||
|
let tcp_backend = RuntimeBackend::new(tcp_config, false);
|
||||||
|
assert!(check_backend(&tcp_backend).await.is_ok());
|
||||||
|
|
||||||
|
let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let mut udp_config = backend("udp-probe", udp.local_addr().unwrap().port());
|
||||||
|
udp_config.health.mode = HealthMode::UdpProbe;
|
||||||
|
udp_config.health.payload_hex = "50494e47".into();
|
||||||
|
udp_config.health.expect_hex = Some("504f4e47".into());
|
||||||
|
let udp_backend = RuntimeBackend::new(udp_config, false);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let mut recv = [0u8; 32];
|
||||||
|
let (n, address) = udp.recv_from(&mut recv).await.unwrap();
|
||||||
|
assert_eq!(&recv[..n], b"PING");
|
||||||
|
udp.send_to(b"PONG", address).await.unwrap();
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), check_backend(&udp_backend))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tcp_relay_tracks_backend_bytes_and_closes_leases() {
|
||||||
|
let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let upstream_addr = upstream.local_addr().unwrap();
|
||||||
|
let upstream_task = tokio::spawn(async move {
|
||||||
|
let (mut conn, _) = upstream.accept().await.unwrap();
|
||||||
|
let mut request = [0u8; 4];
|
||||||
|
conn.read_exact(&mut request).await.unwrap();
|
||||||
|
conn.write_all(&request).await.unwrap();
|
||||||
|
conn.shutdown().await.unwrap();
|
||||||
|
});
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let mut host = host_with_pool(Protocol::Tcp);
|
||||||
|
host.listen = listener.local_addr().unwrap();
|
||||||
|
host.backends = vec![backend("echo", upstream_addr.port())];
|
||||||
|
host.metrics_enabled = Some(true);
|
||||||
|
let metrics = Arc::new(Metrics::default());
|
||||||
|
let stop = CancellationToken::new();
|
||||||
|
let task = tokio::spawn(serve_tcp(
|
||||||
|
host.clone(),
|
||||||
|
listener,
|
||||||
|
stop.clone(),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
metrics.clone(),
|
||||||
|
));
|
||||||
|
let mut client = TcpStream::connect(host.listen).await.unwrap();
|
||||||
|
client.write_all(b"PING").await.unwrap();
|
||||||
|
client.shutdown().await.unwrap();
|
||||||
|
let mut reply = [0u8; 4];
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), client.read_exact(&mut reply))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&reply, b"PING");
|
||||||
|
upstream_task.await.unwrap();
|
||||||
|
drop(client);
|
||||||
|
stop.cancel();
|
||||||
|
tokio::time::timeout(Duration::from_secs(3), task)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(metrics.bytes_in.load(Ordering::Relaxed), 4);
|
||||||
|
assert_eq!(metrics.bytes_out.load(Ordering::Relaxed), 4);
|
||||||
|
assert_eq!(metrics.active.load(Ordering::Relaxed), 0);
|
||||||
|
let view = metrics.view();
|
||||||
|
assert_eq!(view.backends[0].active, 0);
|
||||||
|
assert_eq!(view.backends[0].bytes_in, Some(4));
|
||||||
|
assert_eq!(view.backends[0].bytes_out, Some(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn udp_session_remains_on_same_backend_and_counts_packets() {
|
||||||
|
let one = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let two = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let first_addr = one.local_addr().unwrap();
|
||||||
|
let second_addr = two.local_addr().unwrap();
|
||||||
|
let first_echo = tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
for _ in 0..2 {
|
||||||
|
let (_, peer) = one.recv_from(&mut buf).await.unwrap();
|
||||||
|
one.send_to(b"A", peer).await.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let second_echo = tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
let (_, peer) = two.recv_from(&mut buf).await.unwrap();
|
||||||
|
two.send_to(b"B", peer).await.unwrap();
|
||||||
|
});
|
||||||
|
let listen = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let mut host = host_with_pool(Protocol::Udp);
|
||||||
|
host.listen = listen.local_addr().unwrap();
|
||||||
|
host.backends = vec![
|
||||||
|
backend("a", first_addr.port()),
|
||||||
|
backend("b", second_addr.port()),
|
||||||
|
];
|
||||||
|
host.metrics_enabled = Some(true);
|
||||||
|
let metrics = Arc::new(Metrics::default());
|
||||||
|
let stop = CancellationToken::new();
|
||||||
|
let task = tokio::spawn(serve_udp(
|
||||||
|
host.clone(),
|
||||||
|
listen,
|
||||||
|
stop.clone(),
|
||||||
|
metrics.clone(),
|
||||||
|
));
|
||||||
|
let first_client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let second_client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let mut result = [0u8; 2];
|
||||||
|
for _ in 0..2 {
|
||||||
|
first_client.send_to(b"x", host.listen).await.unwrap();
|
||||||
|
let (len, _) =
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), first_client.recv_from(&mut result))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&result[..len], b"A");
|
||||||
|
}
|
||||||
|
second_client.send_to(b"z", host.listen).await.unwrap();
|
||||||
|
let (len, _) =
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), second_client.recv_from(&mut result))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&result[..len], b"B");
|
||||||
|
first_echo.await.unwrap();
|
||||||
|
second_echo.await.unwrap();
|
||||||
|
stop.cancel();
|
||||||
|
tokio::time::timeout(Duration::from_secs(3), task)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(metrics.active.load(Ordering::Relaxed), 0);
|
||||||
|
assert_eq!(metrics.packets_in.load(Ordering::Relaxed), 3);
|
||||||
|
assert_eq!(metrics.packets_out.load(Ordering::Relaxed), 3);
|
||||||
|
let views = metrics.view().backends;
|
||||||
|
assert_eq!(views[0].packets_in, Some(2));
|
||||||
|
assert_eq!(views[1].packets_in, Some(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import tomllib
|
|||||||
root = Path(__file__).resolve().parents[2]
|
root = Path(__file__).resolve().parents[2]
|
||||||
read = lambda path: (root / path).read_text(encoding='utf-8')
|
read = lambda path: (root / path).read_text(encoding='utf-8')
|
||||||
version = tomllib.loads(read('Cargo.toml'))['package']['version']
|
version = tomllib.loads(read('Cargo.toml'))['package']['version']
|
||||||
assert version == '0.31.9', version
|
assert version == '0.32.0', version
|
||||||
spec = json.loads(read('src/admin/openapi.json'))
|
spec = json.loads(read('src/admin/openapi.json'))
|
||||||
assert spec['info']['version'] == version
|
assert spec['info']['version'] == version
|
||||||
paths = spec['paths']
|
paths = spec['paths']
|
||||||
@@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'):
|
|||||||
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
|
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
|
||||||
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
|
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
|
||||||
assert strings.get(key), (language, key)
|
assert strings.get(key), (language, key)
|
||||||
assert '0.31.9' in read('docs/TEST_REPORT.md')
|
assert '0.32.0' in read('docs/TEST_REPORT.md')
|
||||||
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
|
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
|
||||||
@@ -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.31.9"
|
assert manifest["package"]["version"] == "0.32.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.20" and "quinn" in h3dep["features"]
|
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"]
|
||||||
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
|||||||
lock_path = root / "Cargo.lock"
|
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.31.9"' in lock
|
assert 'name = "rust-proxy"\nversion = "0.32.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.31.9",
|
"README.md": "# RustProxy 0.32.0",
|
||||||
"CHANGELOG.md": "## 0.31.9",
|
"CHANGELOG.md": "## 0.32.0",
|
||||||
"ROADMAP.md": "## 0.31.9",
|
"ROADMAP.md": "## 0.32.0",
|
||||||
"AGENTS.md": "Current source version: 0.31.9.",
|
"AGENTS.md": "Current source version: 0.32.0.",
|
||||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.9",
|
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.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"
|
||||||
@@ -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.31.9"', '"/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.32.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
|
||||||
@@ -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.31.9"' in openapi
|
assert '"RuntimeInformation"' in openapi and '"version": "0.32.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")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
|
|||||||
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
|
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
||||||
assert manifest['package']['version'] == '0.31.9'
|
assert manifest['package']['version'] == '0.32.0'
|
||||||
assert manifest['package']['rust-version'] == '1.99'
|
assert manifest['package']['rust-version'] == '1.99'
|
||||||
deps = manifest['dependencies']
|
deps = manifest['dependencies']
|
||||||
expected_requirements = {
|
expected_requirements = {
|
||||||
|
|||||||
@@ -51,7 +51,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.31.9")
|
assert readme.startswith("# RustProxy 0.32.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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
html = (ROOT / 'src/admin/web/index.html').read_text()
|
html = (ROOT / 'src/admin/web/index.html').read_text()
|
||||||
assert '0.31.9' in (ROOT / "Cargo.toml").read_text()
|
assert '0.32.0' in (ROOT / "Cargo.toml").read_text()
|
||||||
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
|
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
|
||||||
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
|
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
|
||||||
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
|
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ from html.parser import HTMLParser
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
|
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
|
||||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.31.9'
|
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.0'
|
||||||
assert 'name = "rust-proxy"\nversion = "0.31.9"' in read('Cargo.lock')
|
assert 'name = "rust-proxy"\nversion = "0.32.0"' in read('Cargo.lock')
|
||||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.31.9'
|
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.0'
|
||||||
|
|
||||||
pages = read('src/pages.rs')
|
pages = read('src/pages.rs')
|
||||||
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
|
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
|
||||||
|
|||||||
@@ -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.31.9"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
assert 'version = "0.32.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.31.9"
|
assert manifest["to_version"] == "0.32.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()
|
||||||
@@ -617,7 +617,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.31.9"' in openapi_0241
|
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.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
|
||||||
@@ -1102,7 +1102,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.31.9"', '"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.32.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"']:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
|
|||||||
const en = json('langs/en_US.json').translations;
|
const en = json('langs/en_US.json').translations;
|
||||||
const pl = json('langs/pl_pl.json').translations;
|
const pl = json('langs/pl_pl.json').translations;
|
||||||
|
|
||||||
assert.match(manifest, /version = "0\.31\.9"/);
|
assert.match(manifest, /version = "0\.32\.0"/);
|
||||||
assert.match(lib, /pub mod user_agent;/);
|
assert.match(lib, /pub mod user_agent;/);
|
||||||
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
|
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
|
||||||
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
|
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
|
||||||
@@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
|||||||
assert.match(fileLogs, /aggregate_min_count/);
|
assert.match(fileLogs, /aggregate_min_count/);
|
||||||
assert.match(fileLogs, /file-log-aggregate-copy/);
|
assert.match(fileLogs, /file-log-aggregate-copy/);
|
||||||
|
|
||||||
assert.equal(openapi.info.version, '0.31.9');
|
assert.equal(openapi.info.version, '0.32.0');
|
||||||
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
|
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
|
||||||
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
|
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
|
||||||
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
|
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Verify the real opt-in Stream Dashboard component against a lightweight DOM.
|
||||||
|
// No server, browser layout engine or Rust executable is exercised here.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const src = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
|
||||||
|
const preferencesSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
||||||
|
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
|
||||||
|
assert(!/\b(setInterval|setTimeout|fetch|XMLHttpRequest)\s*\(/.test(src), 'widgets must not create timers/fetches');
|
||||||
|
const cards = new Map(ids.map(id => [id, { hidden: true }]));
|
||||||
|
const elements = new Map();
|
||||||
|
for (const name of ['overview', 'overview-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'traffic', 'traffic-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
|
||||||
|
elements.set(`stream-dashboard-${name}`, { textContent: '-', attrs: new Map(), setAttribute(k, v) { this.attrs.set(k, v); } });
|
||||||
|
}
|
||||||
|
const preferences = { dashboard_hidden_widgets: [...ids] };
|
||||||
|
const window = { rustProxyPreferences: { current: () => preferences } };
|
||||||
|
let clock = 1000;
|
||||||
|
const scriptContext = { window, document: { querySelector(selector) {
|
||||||
|
const match = selector.match(/^\[data-dashboard-widget="([^\"]+)"\]$/);
|
||||||
|
return match ? cards.get(match[1]) || null : null;
|
||||||
|
} }, $: name => elements.get(name), ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: ids },
|
||||||
|
envelope: { config: { streams: [
|
||||||
|
{ id: 'tcp', enabled: true, protocol: 'tcp' },
|
||||||
|
{ id: 'udp', enabled: true, protocol: 'udp' }
|
||||||
|
] } }, latestStatus: null, currentPanel: 'overview',
|
||||||
|
performance: { now: () => clock }, t: key => key, fmt: n => `${n}`, bytes: n => `${n} B`, Math,
|
||||||
|
};
|
||||||
|
vm.runInNewContext(src, scriptContext);
|
||||||
|
const widget = window.rustProxyStreamDashboard;
|
||||||
|
const status = { streams: { active: [
|
||||||
|
{ id: 'tcp', enabled: true, protocol: 'tcp' },
|
||||||
|
{ id: 'udp', enabled: true, protocol: 'udp' }
|
||||||
|
], metrics: {
|
||||||
|
tcp: { active: 3, bytes_in: 500, bytes_out: 1000, backends: [
|
||||||
|
{ id: 'a', enabled: true, drain: false, status: 'healthy' },
|
||||||
|
{ id: 'b', enabled: true, drain: false, status: 'unhealthy' }
|
||||||
|
] },
|
||||||
|
udp: { active: 2, bytes_in: 100, bytes_out: 200, backends: [
|
||||||
|
{ id: 'a', enabled: true, drain: false, status: 'unverified' }
|
||||||
|
] }
|
||||||
|
} } };
|
||||||
|
scriptContext.latestStatus = status;
|
||||||
|
widget.render(status);
|
||||||
|
assert(ids.every(id => cards.get(id).hidden), 'new widgets are hidden by default');
|
||||||
|
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'hidden chart must not sample');
|
||||||
|
preferences.dashboard_hidden_widgets = [];
|
||||||
|
widget.render(status);
|
||||||
|
assert(ids.every(id => !cards.get(id).hidden));
|
||||||
|
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5');
|
||||||
|
assert.equal(elements.get('stream-dashboard-overview').textContent, '2 / 2');
|
||||||
|
assert.equal(elements.get('stream-dashboard-health').textContent, '1 / 1');
|
||||||
|
clock += 1000;
|
||||||
|
status.streams.metrics.tcp.bytes_in += 1024;
|
||||||
|
status.streams.metrics.tcp.bytes_out += 512;
|
||||||
|
widget.render(status);
|
||||||
|
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '1024 B/s');
|
||||||
|
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '512 B/s');
|
||||||
|
assert(elements.get('stream-dashboard-rx').attrs.get('points')?.includes(','));
|
||||||
|
preferences.dashboard_hidden_widgets = ['stream_traffic'];
|
||||||
|
widget.syncVisibility();
|
||||||
|
assert(cards.get('stream_traffic').hidden);
|
||||||
|
const previous = elements.get('stream-dashboard-rx').attrs.get('points');
|
||||||
|
clock += 1000;
|
||||||
|
widget.render(status);
|
||||||
|
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), previous, 'hidden chart must not update');
|
||||||
|
// Explicitly enabled cards remain visible even without active/configured streams.
|
||||||
|
// The user should see a neutral state, not a widget that ignores Appearance.
|
||||||
|
preferences.dashboard_hidden_widgets = [];
|
||||||
|
scriptContext.envelope.config.streams = [];
|
||||||
|
scriptContext.latestStatus = { streams: { active: [], metrics: {} } };
|
||||||
|
widget.render(scriptContext.latestStatus);
|
||||||
|
assert(ids.every(id => !cards.get(id).hidden), 'appearance must control Stream card visibility even with no listeners');
|
||||||
|
for (const name of ['overview', 'sessions', 'health', 'traffic']) {
|
||||||
|
assert.equal(elements.get(`stream-dashboard-${name}`).textContent, '-');
|
||||||
|
if (name !== 'traffic') assert.equal(elements.get(`stream-dashboard-${name}-detail`).textContent, 'streamWidgetUnavailable');
|
||||||
|
}
|
||||||
|
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '-');
|
||||||
|
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '-');
|
||||||
|
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), '0,55 300,55', 'empty status must clear the graph');
|
||||||
|
// A desired but not yet running Stream must still make the overview useful.
|
||||||
|
scriptContext.envelope.config.streams = [{ id: 'pending', enabled: true, protocol: 'tcp' }];
|
||||||
|
preferences.dashboard_hidden_widgets = [];
|
||||||
|
widget.render({ streams: { active: [], metrics: {} } });
|
||||||
|
assert.equal(elements.get('stream-dashboard-overview').textContent, '0 / 1');
|
||||||
|
assert(!cards.get('stream_overview').hidden, 'restart-pending listener does not suppress the card');
|
||||||
|
preferences.dashboard_hidden_widgets = [...ids];
|
||||||
|
widget.render(scriptContext.latestStatus);
|
||||||
|
assert(ids.every(id => cards.get(id).hidden), 'only the Appearance switch hides opt-in cards');
|
||||||
|
widget.reset();
|
||||||
|
|
||||||
|
// Reuse original Appearance normalization logic to protect 0.31.x saved layouts.
|
||||||
|
const definitions = preferencesSource.slice(preferencesSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), preferencesSource.indexOf('const CHART_STYLE_KEYS'));
|
||||||
|
const normalize = preferencesSource.slice(preferencesSource.indexOf('function normalizeDashboardWidgets'), preferencesSource.indexOf('function browserLanguage'));
|
||||||
|
const sandbox = {};
|
||||||
|
vm.runInNewContext(`${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
|
||||||
|
const oldOrder = ['requests', 'errors'];
|
||||||
|
const migratedOrder = [...sandbox.normalizeDashboardWidgets(oldOrder)];
|
||||||
|
const migratedHidden = [...sandbox.normalizeHiddenDashboardWidgets([], oldOrder)];
|
||||||
|
assert.deepEqual(migratedOrder.slice(0, 2), oldOrder);
|
||||||
|
assert(ids.every(id => migratedOrder.includes(id) && migratedHidden.includes(id)), 'legacy order must add cards as hidden');
|
||||||
|
console.log('PASS: opt-in Stream dashboard uses shared data with no polling, handles user visibility/empty-status states, and migrates old Appearance layouts.');
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Regression for persisted Appearance preferences and Stream widgets.
|
||||||
|
// Uses the actual JS functions with a minimal DOM, no network or timers.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const prefsSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
||||||
|
const streamSource = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
|
||||||
|
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
||||||
|
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
||||||
|
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
|
||||||
|
assert(html.includes('id="panel-overview"'));
|
||||||
|
for (const id of ids) assert(html.includes(`data-dashboard-widget="${id}"`));
|
||||||
|
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
|
||||||
|
'Overview details must never become hover/focus popovers');
|
||||||
|
assert.match(css, /#panel-overview \.stats-grid \.stat-chart > \.stat-chart-legend/,
|
||||||
|
'in-card chart legends must be visible, including on touch devices');
|
||||||
|
assert.match(css, /#panel-overview \.stats-grid \{ grid-template-columns: repeat\(6, minmax\(0, 1fr\)\); \}/,
|
||||||
|
'desktop Overview must retain the original six cards per row');
|
||||||
|
assert.doesNotMatch(css, /#panel-overview \.stats-grid \{ grid-template-columns: repeat\(4, minmax\(0, 1fr\)\); \}/,
|
||||||
|
'do not replace the original six-column Overview with a four-column layout');
|
||||||
|
assert.match(css, /#panel-overview \.stats-grid > \.stat \{ min-height: 126px;/,
|
||||||
|
'cards should be tall enough for their inline metric legends');
|
||||||
|
assert.match(html, /id="stream-dashboard-traffic-detail"/);
|
||||||
|
assert.match(html, /id="stream-dashboard-rx-rate"/);
|
||||||
|
assert.match(html, /id="stream-dashboard-tx-rate"/);
|
||||||
|
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('class="overview-grid"')), /<article[^>]*tabindex="0"/,
|
||||||
|
'metric cards must not introduce redundant keyboard stops');
|
||||||
|
|
||||||
|
const cardIds = [...html.matchAll(/<article class="stat[^"]*" data-dashboard-widget="([^"]+)"/g)].map(m => m[1]);
|
||||||
|
assert.equal(cardIds.length, 16, 'existing Overview cards must be retained');
|
||||||
|
const cards = new Map(cardIds.map(id => [id, { dataset: { dashboardWidget: id }, hidden: id.startsWith('stream_') }]));
|
||||||
|
const grid = { querySelectorAll: () => [...cards.values()], append(node) { assert(cards.has(node.dataset.dashboardWidget)); } };
|
||||||
|
const elements = new Map();
|
||||||
|
for (const name of ['overview', 'overview-detail', 'traffic', 'traffic-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
|
||||||
|
elements.set('stream-dashboard-' + name, { textContent: '-', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; } });
|
||||||
|
}
|
||||||
|
const status = { streams: {
|
||||||
|
active: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
|
||||||
|
desired: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
|
||||||
|
metrics: { alpha: { active: 5, bytes_in: 1024, bytes_out: 2048, backends: [{ id: 'a', enabled: true, status: 'healthy' }] } }
|
||||||
|
} };
|
||||||
|
const window = { rustProxyRefresh: { update() {} } };
|
||||||
|
const context = {
|
||||||
|
window, latestStatus: status, envelope: { config: { streams: status.streams.desired } },
|
||||||
|
currentPanel: 'overview', authenticated: true, languagePacks: { size: 0 },
|
||||||
|
$: id => elements.get(id), document: { querySelector(selector) {
|
||||||
|
if (selector === '#panel-overview .stats-grid') return grid;
|
||||||
|
const match = selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/);
|
||||||
|
return match ? cards.get(match[1]) : null;
|
||||||
|
} }, performance: { now: () => 1000 }, t: key => key,
|
||||||
|
fmt: v => String(v), bytes: v => v + ' B', Math,
|
||||||
|
normalizeRefreshPreferences: v => v || {}, renderRefreshPreferences() {},
|
||||||
|
applyAdminTheme: v => v, applyAdminLanguage: v => v, applyAdminUiScale: v => v,
|
||||||
|
applyAdminTimeFormat: v => v, applyAdminChartStyle: v => v,
|
||||||
|
applyAdminButtonStyle: v => v, applyAdminTableDensity: v => v,
|
||||||
|
applyAdminDashboardDensity: v => v, refreshAppearanceUi() {},
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
const constants = prefsSource.slice(prefsSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefsSource.indexOf('const CHART_STYLE_KEYS'));
|
||||||
|
const normalize = prefsSource.slice(prefsSource.indexOf('function normalizeDashboardWidgets'), prefsSource.indexOf('function browserLanguage'));
|
||||||
|
const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboardLayout'), prefsSource.indexOf('function dashboardWidgetLabel'));
|
||||||
|
const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme('));
|
||||||
|
vm.runInContext(`${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
|
||||||
|
window.rustProxyPreferences = { current: () => context.getPrefs() };
|
||||||
|
vm.runInContext(streamSource, context);
|
||||||
|
|
||||||
|
// First load (or page reload): defaults hide the new widgets.
|
||||||
|
const initial = { dashboard_widgets: cardIds, dashboard_hidden_widgets: ids };
|
||||||
|
context.applyAdminPreferencesTest(initial);
|
||||||
|
assert(ids.every(id => cards.get(id).hidden), 'opt-in cards must start hidden');
|
||||||
|
|
||||||
|
// Server responds to a previously saved opt-in; at this point the old in-memory
|
||||||
|
// prefs are still hidden. The new layout must win before adminPreferences is set.
|
||||||
|
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
||||||
|
assert(ids.every(id => !cards.get(id).hidden), 'server-saved opt-in must reveal cards immediately');
|
||||||
|
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5', 'no need to wait for polling');
|
||||||
|
assert.equal(elements.get('stream-dashboard-overview').textContent, '1 / 1');
|
||||||
|
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'cached repaint is not a traffic sample');
|
||||||
|
|
||||||
|
// Hide one widget and restore it, all within the same session. Normal cards stay put.
|
||||||
|
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ['stream_traffic'] });
|
||||||
|
assert(cards.get('stream_traffic').hidden);
|
||||||
|
assert(!cards.get('requests').hidden);
|
||||||
|
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
||||||
|
assert(!cards.get('stream_traffic').hidden);
|
||||||
|
|
||||||
|
// A disabled/absent Stream must not override an explicit Appearance opt-in.
|
||||||
|
context.envelope.config.streams = [];
|
||||||
|
context.latestStatus = { streams: { active: [], desired: [], metrics: {} } };
|
||||||
|
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
||||||
|
assert(ids.every(id => !cards.get(id).hidden), 'show requested cards even with no configured Stream');
|
||||||
|
assert.equal(elements.get('stream-dashboard-overview').textContent, '-');
|
||||||
|
assert.equal(elements.get('stream-dashboard-overview-detail').textContent, 'streamWidgetUnavailable');
|
||||||
|
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ids });
|
||||||
|
assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work');
|
||||||
|
assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
|
||||||
|
assert.match(css, /dashboard-widget-setting:is\(:hover, :focus-within\) \.dashboard-widget-description/, 'description is a tooltip');
|
||||||
|
assert.match(css, /\.dashboard-widget-description \{[^}]*position: absolute/s, 'tooltip must not occupy a second row');
|
||||||
|
assert.match(prefsSource, /toggle\.setAttribute\("aria-describedby", description\.id\)/, 'description must be accessible');
|
||||||
|
// Editor-specific regressions: avoid the redundant enable hint and label the
|
||||||
|
// two distinct inheritance layers instead of saying "Inherit" everywhere.
|
||||||
|
const editor = fs.readFileSync('src/admin/web/streams.js', 'utf8');
|
||||||
|
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
||||||
|
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
|
||||||
|
assert(editor.includes('enabledLabel?.querySelector(".vh-enable-hint")?.remove()'));
|
||||||
|
const vhostEditor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
|
||||||
|
assert.match(vhostEditor, /window\.rustProxyEnableControl\(enabledControl, "vhEnableHint"\)/);
|
||||||
|
assert.match(vhostEditor, /enabledLabel\?\.querySelector\("\.vh-enable-hint"\)\?\.remove\(\)/, 'vhost editor must not show the redundant active hint');
|
||||||
|
assert.match(vhostEditor, /enabledLabel\?\.classList\.add\("vh-enable-no-hint"\)/);
|
||||||
|
|
||||||
|
assert(editor.includes('["inherit", "streamMetricsDefaultOff"]'));
|
||||||
|
assert(editor.includes('["inherit", "streamMetricsFromStream"]'));
|
||||||
|
for (const locale of [pl, en]) {
|
||||||
|
for (const key of ['streamMetricsDefaultOff', 'streamMetricsFromStream', 'streamMetricsHelp', 'streamBackendDetailMetrics'])
|
||||||
|
assert(locale[key] && locale[key].length > 5, `missing ${key}`);
|
||||||
|
}
|
||||||
|
assert.equal(pl.streamMetricsFromStream, 'Z ustawień Stream');
|
||||||
|
assert.equal(pl.streamMetricsDefaultOff, 'Domyślnie wyłączone');
|
||||||
|
console.log('PASS: Stream visibility, inline Overview legends, preserved Appearance tooltip and Vhost enable label.');
|
||||||
@@ -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.31.9', uptime_seconds: 4201,
|
version: '0.32.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.31.9');
|
assert.equal(dollar('runtime-version').textContent, '0.32.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');
|
||||||
|
|||||||
Reference in new issue
Block a user