0.34 #2

Merged
gru merged 16 commits from 0.34 into master 2026-10-18 12:01:51 +02:00
203 changed files with 5495 additions and 21820 deletions

No files matched your search

+21 -75
View File
@@ -1,83 +1,29 @@
# RustProxy implementation rules
# RustProxy source and documentation maintenance
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.33.8.
Current source version: **0.34.0**. The Cargo package and binary are named `rust-proxy`.
## Delivery
Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
Maintain English documentation and Markdown files. Keep translations in discovered langs/*.json packs, with en_US as fallback. Never hardcode a two-language switch. Preserve the existing logo.
CHANGELOG.md uses version headings with no release dates. Patch increments may be
bundled; substantial optional integrations raise the minor version. 0.7.0 adds managed renewal, server deployment helpers, Basic Auth and visual locations. The reported compression threshold type mismatch is removed without narrowing. Cloudflare Tunnel and Docker discovery are deferred.
## Keep documentation in sync
Deliver a complete archive with dev.sh, tests and an updated verification report.
Do not include databases, credentials, real certificates/keys, target directories,
node_modules or fabricated Cargo.lock content. Preserve an actually resolved
lockfile when a compiler-enabled environment can produce it.
- Write product documentation in English. Prefer focused instructions: purpose, configuration, defaults, restrictions, verification and common failures. Keep change history technical but user/operator-facing, not a series of profiling notes.
- Update the guide, [docs index](docs/README.md), [changelog](CHANGELOG.md), embedded Reader registry (`src/admin/documentation/documents.rs`) and API schema/tests together when functionality changes.
- Reader Markdown is compiled into the binary using `include_str!`. Renaming/deleting a guide without updating the registry breaks compilation. Avoid broken relative links.
- Keep per-component optional defaults accurate (for example COLD off, Prometheus off, backend `tls_verify` off unless explicitly set, and optional Web Server `sendfile` off). Mark source-only or untested changes accordingly.
- Maintain existing `langs/*.json` packs; `build.rs` discovers available locales. Do not add credentials, databases, real certificates, environment secrets or build artifacts to source archives.
## Engineering
## Engineering safeguards
Keep proxy traffic independent from control-plane database queries and providers.
Use async I/O, streaming bodies, bounded DNS work, bounded queues and explicit
cancellation. Hold request and connection permits through full body/tunnel lifetimes.
Run ACLs before opening OR reusing an upstream connection. Resolve only configured
backend origins; validate every DNS answer and use the validated addresses directly.
Backend TLS certificate verification defaults OFF when `tls_verify` is omitted for vhost
backends. Administrators may explicitly enable chain/identity verification per HTTPS backend.
This compatibility default is limited to application backends: never widen it to Tinyauth or
other verifier clients. Disabling verification must not weaken backend address/DNS/loop/control-
plane protections.
- Keep proxy traffic independent of control-plane database/provider calls. Prefer asynchronous streaming, bounded queues, resource limits and explicit cancellation. Hold backend permits for the entire body or tunnel lifetime.
- Run access rules before connecting to or reusing an upstream. Resolve only configured origin hosts; validate resolved addresses before use. Preserve controls against loopback/private/control-plane destinations unless explicitly authorized.
- Backend TLS verification's compatibility default must not weaken certificate verification for external authentication/integration clients. Preserve validation of Host, listener, authority, forwarded identity, IP/ACL and control-plane access.
- Vhosts bind to named frontends via listener IDs; never derive listener identity from spoofable forwarding headers. Listener and transport changes can require a restart. Health status is observation, not desired configuration.
- Keep HOT/COLD cache privacy, freshness, validators, scoped invalidation and persistent format compatibility. Do not expose cached private content across hosts, credentials, cookies, or `Vary` dimensions.
- Preserve administrator session, CSRF, Host/Origin, certificate-scoped credentials and the authenticated offline documentation/OpenAPI assets. Do not expose private API routes without updating access tests.
- Preserve safe deployment/upgrade behavior: stop or reject incompatible legacy layouts, validate changes before activation, respect data/secrets outside the source tree, and keep full encrypted recovery usable.
Preserve the explicit auth module path and upgrade regression coverage. Replace
managed source directories instead of leaving obsolete modules during upgrades.
Keep administrator intent, provider observations and live connection state separate.
Hot changes must publish validated, immutable snapshots after a successful revisioned
SQLite write. Invalid updates leave the old snapshot active.
## Verification and delivery
## Verification
Keep production Rust source files within 500 lines. Rust unit and integration
**test files are exempt** from this limit: do not create numbered `part_XX.rs`
fragments to satisfy source-size checks. Consolidate coherent test suites in
`tests.rs` and preserve the existing test cases.
Run `./dev.sh check` and `./dev.sh test` when Rust is available. Run the SQL and upgrade
scripts even when it is not. UI tests use a simulated API; the Python protocol-peer
self-test is not a test of RustProxy. Record these distinctions accurately.
Do not mark a stage accepted or production-ready on the basis of static inspection.
Review real protocol behavior, restored data and resource bounds before expanding
scope. Do not expand provider work ahead of the core real-runtime acceptance gate. Keep that gate explicitly open until Rust runs.
## Vhost invariants
Use the accepted socket's actual local address and TLS state for frontend selection;
never infer a private binding or sticky identity from forwarding headers. Vhosts receive
frontend sockets only through `frontend.listener_ids` referencing `listeners.endpoints`.
Keep hostname ownership unambiguous. Socket/ALPN changes require restart, with actual
and desired plans visible.
Keep health observations separate from desired configuration. Preserve observations
only for compatible members; re-enabled/changed origins require new health checks.
Keep backend leases through request/response/tunnel completion. Do not replay failed
writes to another backend. Cookies are routing affinity, never authentication. Health
probes must use the same origin/DNS policy, bounded concurrency and cancellation.
Retain HTTP/1.1 in TLS Auto negotiation; require H2 in explicit HTTPS H2 mode,
and do not implement fallback by replaying traffic.
## Vhost configuration invariants
The pre-production route/inline-listener compatibility importer has been removed.
Persist only the current vhost document and named listener references; old documents
must fail validation instead of being silently converted. Keep path-pinned origins
distinct from balanced pools. Do not silently widen listener bindings.
ACME secrets belong in private credentials files, not SQLite, stdout, argv or API.
HTTP-01 only serves bounded tokens for domains in the active job. No CA call on boot.
The exporter checks actual peer CIDRs and is disabled by default; secret configuration
stores a variable name, never a token. Keep metric labels bounded by configuration.
## Administration access regression gate (0.8.1)
No new public admin routes without updating the exact method/path allowlist and
access tests. Operational assets and the OpenAPI schema are private. Keep the
public login document/bundle separate from the dashboard. Documentation uses
the existing cookie session/CSRF, not a second auth mechanism. Preserve the pinned
Swagger build configuration and keep external validators/CDNs disabled.
- Run `python3 scripts/preflight.py` and documentation/link checks after restructuring documentation. When Rust is available, run `./dev.sh check` and `./dev.sh test`; run platform/protocol and restore tests before claiming production readiness.
- Python mock origins or static source inspection do not constitute testing of the real RustProxy binary. Record verification limits; do not claim measured performance gains from source changes.
- Ship complete reproducible source archives with `make_zip.py`, `SHA256SUMS`, the application scripts, current tests and docs. Never ship live state, private keys or generated build directories. Keep the technical deep-history backup outside the authenticated documentation Reader.
+353 -1291
View File
File diff suppressed because it is too large. Load diff
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.33.8"
version = "0.34.0"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.33.8"
version = "0.34.0"
edition = "2021"
rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration"
+32 -1035
View File
File diff suppressed because it is too large. Load diff
+14 -679
View File
@@ -1,687 +1,22 @@
# RustProxy roadmap
## 0.33.8 - Performance with retained functionality
This file records **work still requiring acceptance or planned future consideration**, rather than repeating completed release history. Implemented functionality is described in the [documentation](docs/README.md), and operator-visible changes by release are in the [changelog](CHANGELOG.md).
- [x] Reduce cache-disabled request-policy work, header copies, timer allocation
and request-ID formatting overhead without changing security or telemetry.
- [x] Move live-history cloning and eviction cleanup outside the mutex while
retaining bounded history, explicit loss counters, live streams and sinks.
- [x] Narrow filesystem job context, borrow pinned root descriptors and bound
adaptive static-file reads at 64 KiB; preserve range and symlink behavior.
- [x] Correct local HTTP/1 file completion for cache publication and accounting,
with explicit empty, short-read, cancellation and multipart coverage.
- [x] Reproduce baseline test failures and distinguish real inherited defects
from obsolete fixtures; correct both without weakening functional contracts.
- [x] Add repeatable live HTTP, verified TLS/H2/H3/WebSocket, nginx comparison,
resource sampling, syscall profiling and mixed-traffic soak runners. Measured
outcomes and limits are recorded in `docs/PERFORMANCE_0.33.8.md` and raw evidence.
- [ ] Complete deployment-specific 24-hour soak and remote-network testing with
actual PHP-FPM, Tinyauth and DNS infrastructure before production rollout.
- [ ] Evaluate further proxy pool/queue costs and a secure static-file sendfile
path from measured profiles; preserve TLS/H2/H3 and all observability semantics.
## 0.34.0 — outstanding release verification
## 0.33.7 - named dashboard presets, focused HTML extraction and chart typography
- Compile the exact 0.34.0 source candidate on the supported target environments and run the current Rust regression suite.
- Exercise full HTTP/1.1, TLS, HTTP/2, HTTP/3, WebSocket, frontend/backend routing, retry, cache/privacy, certificate and administration scenarios.
- Test selective HOT/COLD invalidation through configuration edits, restart and persistent cache recovery; verify related regression cases and migration behavior.
- Verify optional Linux `sendfile=auto` with eligible static files and that all ineligible requests use safe normal delivery.
- Run controlled before/after performance and resource measurements. **No 0.34.0 throughput improvement is established yet.**
- Perform backup/restore and managed install/update acceptance on supported systems, including the container deployment where applicable.
- [x] Apply uniform 12px sentence-case labels to all Overview widgets,
including Process, RAM cache and backend tiles. Normalize secondary
captions and small values to monospace without changing other panels.
- [x] Space the two Cache table cards by 10px without altering other sections.
- [x] Reuse the backend quick-view popover for clickable Vhost upstream H1/H2
protocol details; keep it stable during repeated live status refreshes.
- [x] Keep the clickable Backend and Health fields square, visually matching
adjacent Vhost tiles under every interface corner style.
- [x] Standardize the full legend text (including every value) at 9px monospace,
matching Requests per second across desktop, mobile and density modes.
- [x] Add a compact, nonmodal Vhost backend quick-view through both backend
count and health metric; use existing configuration and status fields, with
accurate unknown/last HTTP status handling and accessible dismissal.
## Longer-term directions
- [x] Introduce account-scoped, bounded SQLite storage for 16 named Overview
layout snapshots without changing proxy configuration or existing preferences.
- [x] Replace Arrange Overview modal with save/load/overwrite/rename/delete,
and factory reset; keep Unlock layout editing intact.
- [x] Extract metrics and charts only into compile-time embedded HTML partials
without changing existing card DOM order, IDs or polling.
- [x] Standardize chart headings on 12px proportional type (Overview in
sentence case, all other panels uppercase); use mono for full legends,
captions and chart data across all chart families.
- [x] Validate SQL migration and run available simulated-browser/JS tests.
- [ ] Build/test Rust and run the new endpoint/UI against a real server with
representative persisted data. The production acceptance gate remains open.
Future work must be tracked separately from shipped features. Potential areas include better operational verification, additional diagnostics/alerting, clearer configuration and migration UX, and compatibility work as HTTP/ACME/runtime dependencies evolve. These are **not commitments or implemented features** without a separately tested release.
## 0.33.6 - Stable dashboard chart ranges and plot heights
## Documentation policy
- [x] Fix all 14 Overview/WWW full-width SVG heights to 125px and align their
grid lines with the actual drawing range.
- [x] Apply zero-based, unit-specific scale floors to transform/compression,
rate, connection, latency, memory and WWW series; retain meaningful high
readings with stepped-up ceilings and no scale collapse on low samples.
- [x] Keep CPU/other percentage sparklines on 0-100% without changing legends.
- [x] Fix full Cache/Transform live charts at 180px and stabilize their rate
axes, including scope-change resets.
- [x] Test Chromium layout/scaling across 320-1920px and run available UI,
schema and source tests. Rust runtime acceptance remains pending.
## 0.33.5 - Fixed Overview CPU chart scale
- [x] Set System and process CPU to an absolute 0–100% axis, independent of
history, while keeping real percentage labels and both plotted series.
- [x] Fix the CPU plot height at 125px regardless of viewport width or
measured values; leave other chart scales and sizes unchanged.
- [x] Check stable CPU chart geometry, percent mapping, clipping and labels
in a simulated browser across viewport widths and Dashboard densities.
- [x] Replace the Overview Requests per second LIVE badge with its live maximum
rate, remove the LIVE TRAFFIC eyebrow and the duplicate peak footer entry.
- [x] Remove Connection Activity's LIVE badge, preserving its metrics.
- [x] Browser-test the two card headings, dynamic peak update and responsive
placement across viewport widths, dashboard densities and card spans.
## 0.33.4 - Web Server/Vhost live-metric visual parity
- [x] Show per-Web-Service live metrics without expanding a section.
- [x] Reuse Vhost metric tiles and the full-width traffic chart with the same
desktop/mobile layout, chart styling and theme-aware appearance.
- [x] Preserve counter sampling, PHP fallback, existing translations and the
existing status feed without new API or persistence state.
- [x] Add simulated Chromium layout, rendering and sampled-status regressions.
## 0.33.3 - Adjustable Overview card widths and cross-type ordering
- [x] Apply Appearance **theme** highlighting and corner radius to sidebar
navigation, including mobile layout and accessible contrast on light themes;
keep menu colors independent of the chart palette.
- [x] Test navigation colors in Chromium across 4 themes and 8 chart palettes
(invariance within each theme), 5 corner settings, hover, mobile and RTL.
- [x] Keep the visual layout editor readable at 320-1920px with stable
three/two/one-column previews, wrapping controls and no horizontal overflow;
leave actual dashboard widths and free ordering unchanged.
- [x] Stretch plotted SVG history across the actual card width when resizing,
including dynamic network-interface plots, without stretching donut charts.
- [x] Allow widths of 2, 3, 4, 6 and 12/12 columns in inline and visual editors;
keep the smallest text/sparkline cards expandable and add a 4-chart desktop row.
- [x] Allow free card placement across former size groups, including at the
top of Overview, and reduce unused row gaps with CSS dense packing.
- [x] Preserve legacy grouped layouts until edited, retain saved hidden/order
preferences, and support cancel, save, modal preview and reset.
- [x] Add browser coverage for real move/size clicks and responsive layouts.
## 0.33.2 - Shared progressbar visual settings
- [x] Apply Appearance corner rounding to native Cache progressbars and
custom Overview, WWW and traffic bars with matching track/fill shapes.
- [x] Apply the chosen chart color palette to Cache progressbars and other
progress fills; retain theme-aware tracks, without new API/status work.
- [x] Unify the System Load RAM meter with other Overview progressbars,
retaining the 5px height and accessible percentage updates.
- [x] Verify live CSS changes with Chromium across corners, chart palettes,
light/dark themes and mobile/desktop viewport widths.
## 0.33.1 - Factory dashboard preset and inline live preview
- [x] Ship the operator-supplied default ordering, hidden cards and compact density
in one shared Rust/UI JSON preset; use 600 as the first-use history sample default.
- [x] Keep all existing explicitly saved preferences and restore the factory preset
only for new accounts or an explicit dashboard reset.
- [x] Honor unsaved inline hide/show changes while backend and Stream status refresh;
preserve the draft across unrelated preference updates.
## 0.33.0 - Web Server observability (implementation complete; live acceptance pending)
- [x] Add one Overview 60/120/300/600 history selector on the connection-status header row, with temporary changes and explicit browser-persistent choice. Use the same source stream and leave the Cache and Transform selectors independent.
- [x] Match the Web Server metric method visibility to `ResponseKind` (no `private_interfaces` warning).
- [x] Align donut totals beside the ring, preserving the legend and compact tile heights.
- [x] Add opt-in Web Server Overview cards, with all new metrics hidden by default and per-service drilldown.
- [x] Extend Web Server/PHP diagnostics and bounded Prometheus metric families without untrusted labels or additional request-path database calls.
- [x] Extend the packaged Grafana operations dashboard with Web Server/PHP panels and per-service filters.
## 0.32.8 - Overview action alignment (runtime acceptance pending)
- [x] Convert bounded cache RAM/queue/disk, file descriptor, HTTP protocol share and backend capacity cards into meter/progress-bar visuals, with clear unknown-limit behavior.
- [x] Replace cache hit ratio sparklines with compact HIT/MISS donut visualizations and legends.
- [x] Add large transformation/compression four-series live charts and Polish/English labels, preserving existing status sampling and layout/editor preferences.
- [x] Add deterministic UI metric tests and Chromium responsive-layout regression checks.
- [x] Replace mobile three-column Overview actions with natural wrapping: keep the heading at left, actions to the right, without shrinking buttons.
- [x] Remove redundant button-only classes and outdated small-screen overrides while preserving existing common button variants, layout controls and editor handlers.
## 0.32.7 development follow-up: Appearance and H2-close diagnostics
- [x] Use common button sizing for Overview quick actions and Appearance visual editor/show-all; preserve the normal and compact global button styles.
- [x] Strengthen the distinction between Dashboard density levels for tiles, charts and non-table panels without changing grid gutters or preferences.
- [x] Lower only correlated graceful H2 close terminal diagnostics to TRACE; keep 502/access details and uncorrelated cancellations as errors.
- [x] Verify the UI sizes with a simulated Chromium browser at desktop and phone widths.
## 0.32.7 - Mobile Overview and upstream protocol visibility (runtime acceptance pending)
- [x] Share counter-rate pause/resume handling between Cache, Transform,
Overview, backend, Stream and Vhost plots. Retain historical points after
backgrounding, skip suspended intervals and restart normal samples after
one fresh counter baseline, with no new polling.
- [x] Development follow-up: preserve Network diagnostic chart DOM through
periodic refresh and fix descendant CSS selector minification.
- [x] Feed Process CPU/RSS and per-interface network charts from the cached
background OS sampler through live status (default 3 s). Leave slow Process
details on `system_info_seconds` and retain valid 2-s saved preferences; do
not add per-request probes. Group upstream badges with other Vhost badges
and match their corner radius while preserving observed protocol colors.
- [x] On phone-size Overview, remove the nested Network interface chart scroll
region, while retaining chart SVG/text nodes on ordinary live updates.
## 0.32.6
- [x] Reinstate two small Overview cards per row across phone-size widths.
- [x] Apply global corner styles to additional vhost/entity panel surfaces.
- [x] Avoid reparenting already-ordered Overview nodes; retain natural touch
scroll and stable values/row boundaries during live updates.
- [x] Include last HTTP protocol observed from real proxy upstream responses
in the authenticated status and each Vhost's per-backend badge. Show unknown
when no fresh observation exists instead of guessing Auto's ALPN outcome.
- [x] Add simulated mobile Chromium and mocked status UI regressions plus a
Rust unit regression for protocol-version observation.
## 0.32.5 - Dynamic DNS client ACLs (runtime acceptance pending)
- [x] Preserve CIDR/IP rules and introduce allow-only FQDNs for client ACLs.
- [x] Add bounded A/AAAA periodic resolver, immutable cache, expiration and
immediate refresh on published configuration changes.
- [x] Add DNS/DDNS editor selector, PL/EN guidance and source/regression tests.
## 0.32.4 - Upstream Host/authority parity and Overview gutters (runtime acceptance pending)
- [x] Remove `preserve_host` H1-only validation and the Auto/H2 UI forced-uncheck.
- [x] Align outgoing H2 authority with selected Host while keeping the origin DNS,
ACL, TCP destination, TLS SNI and certificate verification independent.
- [x] Update HTTPS-by-IP guidance; preserve opt-in diagnostic H1 preset.
- [x] Make Overview small-to-large row gutter consistent across density modes.
- [x] Add protocol/authority and UI/source contracts; preserve WebSocket isolation.
## 0.32.3 - Backward-compatible upstream modes and Overview Cards registry (runtime acceptance pending)
- [x] Replace ragged custom Overview masonry with symmetric CSS Grid rows grouped by widget footprint. Equal-height peers, traditional gaps, responsive columns and live-content growth are verified in Chromium. Block dragging remains supported inside its size group; group boundaries prevent visually inconsistent mixing. Registry (62), preferences and version stay unchanged.
- [x] Snap manually rearranged Overview columns to common pixel-aligned coordinates and align the bottoms of a tall panel and its stacked short neighbors where bounded distribution allows. Keep factory rows, saved widget IDs/visibility, spacing controls and narrow-screen layout unchanged. Verify with a Chromium fixture, including dynamic height, density and zoom.
- [x] Remove numeric/chart type badges from the Settings → Appearance Overview list, retaining them in the actual visual editor; keep right-aligned controls.
- [x] Open Appearance first unless Settings has a remembered last tab; honor explicit URL tabs and legacy configuration/logging links.
- [x] Decouple roundness from the color palette through a backward-compatible, account-scoped five-preset global `corner_style`. Keep original corners as the default for all four themes; restore coral/green logo marks in Ultra Dark.
- [x] Preserve original symmetric and density-aware Overview rows for the default configuration. When blocks are manually reordered on desktop, keep the same column widths/gutters while allowing multiple natural-height charts to stack next to tall diagnostics; use CSS-layout-pixel row spans for zoom safety and fall back to classic rows on narrow canvases.
- [x] Exclude the RustProxy logo from global corner style overrides. Keep unlocked Overview × Hide and arrow controls consistent under all five presets, without changing color themes or account data.
- [x] Add 6 small and 7 large opt-in time-series views (62 registered blocks total), reusing existing status updates, bounded samples and version 0.32.3. Place block metadata before right-anchored editing controls.
- [x] Preserve legacy `http2`: HTTPS ALPN H2/H1 fallback; HTTP H2C. Add a separate explicit `h2` mode requiring H2 over TLS / H2C over plaintext. Retain H1/Auto and isolate WebSocket clients.
- [x] Retain one safe Auto ALPN negotiation before the first request, without H1 retry after a potentially executed operation.
- [x] Establish a shared JSON registry for Overview IDs/metadata, server validation, ordered visibility settings and compatibility with unknown saved widget IDs.
- [x] Add fourteen opt-in HTTP origin monitoring cards, including request-rate, capacity, 5xx, timeout, network errors, retries, queue pressure and client-pool recycling; reuse existing status counters without extra polling. Retain the original 16 and existing account preferences.
- [x] Add an authenticated Overview modal for draggable visual card ordering, keyboard move, hide/show catalog, draft cancel and a single preferences PUT for Save. Keep existing Appearance controls and defaults.
- [x] Add opt-in AMOLED **Ultra dark** and muted **Warm cream** palettes with translated selectors, login-cache support, server-side preference validation and high-contrast palette checks. Preserve current dark/light themes, default and schema version.
- [x] Let administrators hide/remove visible Overview blocks directly while unlocked, including panels and tables; recover them from a compact hidden-block shelf in the same draft and persist visibility only after Save.
- [x] Add a direct Overview **Unlock layout** mode: drag actual blocks by dedicated grips, move with arrows, cancel without persisting, and save+lock in one request. Classify and label all 49 blocks by display type, including mixed number+chart cards, with a hidden-block type filter.
- [x] Expand to a shared 49-block Overview grid (30 prior widgets, 12 new opt-in charts and seven original full-size sections). Add visual default-layout preview, confirmed reset and account preference compatibility without new metrics probes or API routes.
- [x] Verify ALPN is already negotiated on connection establishment, then reused by Hyper per-backend pooling. Retain age/request-count generation rotation and new-connection negotiation rather than introduce a global H2/H1 cache or unsafe protocol-retry probe.
- [x] Present all four modes with clear HTTPS ALPN versus h2c semantics, keeping persisted `http2` records valid with unchanged meaning.
- [x] Add source/JS checks and Rust tests for strict ALPN, old TLS H1 fallback, H2C, WebSocket isolation and widget preferences.
- [x] Resolve the 0.32.2 HTTPS `http2` compatibility regression with a new serialized strict `h2` mode rather than a silent migration.
## 0.32.2 - Web Server conditional cache revalidation (runtime acceptance pending)
- [x] Reuse local Web Server `ETag` / `Last-Modified` revalidation candidates for forced browser refresh and stale/freshness-constrained requests, keeping hit counts and cache bodies on `304`.
- [x] Preserve client conditional semantics, HEAD, range, response policy and transformed representations; invalidate superseded objects.
- [x] Add Rust regression cases and source-only checks.
## 0.32.1 - Dashboard Overview typography and tile height (runtime acceptance pending)
- Restore content-driven heights for desktop Overview tiles (six columns) seen in 0.31.9, removing 0.32.0's fixed-height whitespace at 90% scale.
- Increase compact Dashboard headings, values and secondary descriptions, including the fixed-size Payload legend. Align tile gaps with the same `--ui-gap` as other dashboard blocks for all dashboard density settings.
- Keep opt-in Streams, all responsive breakpoints and chart legends visible inline. Add layout-regression checks. No proxy or persistence changes. Rust/Cargo and authenticated-UI runtime acceptance are still outstanding.
## 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)
- [x] Inspect individual saved Certificate API credential metadata without reading its bearer secret.
- [x] Edit comment, explicit certificate allowlist and deploy grant independently of token rotation.
- [x] Regenerate only a selected credential's token while keeping Key ID, comment and grants, invalidating the old secret and showing the replacement only once.
- [x] Preserve existing scopes, administrator-only access, audit trail, schema 9 and backwards-compatible creation/revocation endpoints.
- [ ] Compile and execute Rust regression tests with Rust 1.99+; verify end-to-end panel flows on a real server before production acceptance.
## 0.31.8 - Stored Logs sorting, built-in pages and allocation reduction (runtime acceptance pending)
- [x] Consolidate 28 numbered Rust test fragments into seven large `tests.rs` files; exempt test sources from the 500-line production limit and remove obsolete fragment paths.
- [x] Sort Stored Logs across the bounded server-side scan before pagination, including dynamic columns and durable account preferences.
- [x] Complete the built-in status response pages in the existing visual style, including 200 OK.
- [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] Correct Docker VHosts post-restart pending/failed status, first-read synchronization and TLS pin indicator, without changing poll/TLS/SQLite protocols.
## 0.31.7 - UA-specific throttling and managed update housekeeping (runtime acceptance pending)
- [x] Group global UA blocking, headers and response controls in a tabbed Settings page.
- [x] Add per-vhost UA rate-limit overrides with separate bounded counters.
- [x] Show leaf-level revision diffs, with optional full JSON comparison dialog.
- [x] Space apart listener protocol buttons and collapse ACL attachments by default.
- [x] Fix release retention path deletion; preserve two releases; limit Cargo cleanup to expired incremental state.
- [x] Verify update marker and forced HOT→COLD shutdown code path, and add completion logging.
- [ ] Compile and test with Rust/Cargo; run real update/rollback and browser/UI tests on a controlled host.
## 0.31.6 - H3 iOS navigation hardening (runtime acceptance pending)
- Finish live Safari/Chrome iPhone logo-navigation and repeated-refresh acceptance over H3 after Rust compilation.
- Collect matching `http3_*` diagnostic records and navigation/asset response records; verify the new top-level GET is not interrupted when old SSE/API streams cancel.
## 0.31.5 - User-Agent blocking and consolidated log analysis
- [x] Add bounded global and per-vhost User-Agent blocklists and enforce them before upstream contact.
- [x] Add one-click copy for Traffic values used during investigation.
- [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] Update OpenAPI, translations, documentation and Rust-independent regression checks.
## 0.31.4 - Administration-only public HTTPS Origin with one-field configuration
- [x] Add explicit HTTPS `administration.public_origin` and restrict its use to trusted reverse-proxy peers for panel requests.
- [x] Derive the pinned DNS hostname for the effective Host allowlist without duplicating it in saved `allowed_hosts`.
- [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] Add panel settings UI, translations, API schema, Rust source regressions and Node simulations without changing public proxy forwarding.
## 0.31.3 - Mobile session recovery and Origin diagnostics
- [x] Reproduce the overlapping resume-session checks, reload-loop marker reset and early SSE restart using the 0.31.2 source in a deterministic JavaScript simulation.
- [x] Coalesce page visibility/BFCache session verification, defer background requests until revalidated and offer a visible retry on failure without weakening Origin enforcement.
- [x] Display a safe 403 HTML message for invalid-Origin dashboard navigation and provide credential-redacted rejection diagnostics.
## 0.31.2 - Dependency updates
- [x] Prepare stable dependency-family and code API migrations, preserving existing wire/configuration/SQLite formats.
## 0.31.1 - Mobile panel loading stability
- [x] Load dashboard modules as one ordered JavaScript resource and wait for document/module readiness before session initialization.
- [x] Version dashboard JS/CSS requests; show a recoverable startup error on missing modules.
## 0.31.0 - Panel refresh orchestration
- [x] Centralize authenticated polling with source IDs, view lifecycle, hidden-tab pause, abort, in-flight protection and cleanup.
- [x] Separate compact live telemetry from infrequent cached system diagnostics; allow per-client SSE pacing and reconnect-only fallback.
- [x] Add per-account refresh preferences, reset/manual actions, language packs, endpoint contracts and regression tests.
## 0.30.1 - Protocol legend and system information
- [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] 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.
## 0.30.0 - Per-backend Health editor and logging resource review
- [x] Provide an intuitive Health-tab backend selector restricted to monitored members, with independently editable TCP/HTTP policies.
- [x] Deduplicate file/Logstash JSONL serialization; cap Stored Logs directory scan retention during enumeration; review long-lived queues and other memory-heavy facilities.
- [x] Preserve vhost wire schema, SQLite schema 9 and Cold Cache format 3.
## 0.29.7 - Backend health scoping, PHP-FPM isolation and HIT-only cache diagnostics
- [x] Support per-backend active/passive health monitoring disablement and independent health-check settings.
- [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] Preserve existing settings and schema 9/Cold format 3.
## 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] 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] Retain schema 9, Cold Cache format 3 and 0.29.x saved-configuration compatibility.
## 0.29.5 - Roundcube PHP PATH_INFO routing
- Resolve PHP `PATH_INFO` on an existing script before `try_files` falls back to the front controller, while preserving normal URL and script security checks.
- Verify FastCGI parameters from an actual mock PHP-FPM exchange in Rust integration tests when Cargo is available.
- Retain schema 9, Cold Cache format 3 and all previous PHP-FPM profile UI and vhost-cache behavior.
## 0.29.4 - Shared PHP-FPM profiles and local Web Server processing
Current direction: 0.29.3 -> 0.29.4
- [x] Add reusable PHP-FPM profiles attachable to individual Web Services, keeping their local settings on detach.
- [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] Add source/UI regression coverage for new configuration and response behavior.
- [x] Keep SQLite schema 9 and Cold Cache format 3 unchanged.
## 0.29.3 - HTTP/2 and HTTP/3 internal fallback stabilization
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] 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] Preserve SQLite schema 9 and Cold Cache format 3 without configuration migrations.
## 0.29.2 - PHP-FPM operator controls and observability
- [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] Track PHP responses by status class, response-header latency and free concurrency slots in Prometheus.
## 0.29.1 - Internal error routing, multipart ranges and PHP PATH_INFO
Current direction: 0.29.0 -> 0.29.1
- [x] Add configurable internal error fallbacks for the main vhost source and Custom locations.
- [x] Preserve original method, URI, query string and request context during internal fallback dispatch.
- [x] Reject fallback cycles at validation time and cap runtime fallback depth.
- [x] Intercept configured errors before final response/cache handling and run the target through normal route processing.
- [x] Add bounded Prometheus counters for triggered, successful and failed error fallbacks.
- [x] Support fixed fallback status codes 403/404/500/502/503/504.
- [x] Add streaming multipart byte-range responses with a bounded range count.
- [x] Add opt-in secure PHP PATH_INFO splitting for PHP-FPM.
- [x] Keep SQLite schema 9 and Cold Cache format 3 unchanged.
## 0.29.0 - Redirect, health-host, forced-cache and rotated-log controls
Current direction: 0.28.2 -> 0.29.0
- [x] Add one-checkbox canonical www/non-www redirects per vhost.
- [x] Add first-class Custom location redirects with capture-based rewrite targets and query preservation.
- [x] Default HTTP health-check Host to the first exact vhost hostname while keeping the manual field as an override.
- [x] Add explicit per-vhost forced-cache targets with response privacy protections retained.
- [x] Read bounded logrotate gzip rotations in Stored Logs.
- [x] Verify local/static HTML pages can intentionally return statuses such as 503.
- [x] Preserve valid nested URL query parameters while retaining ambiguous-backslash rejection.
- [x] Keep SQLite schema 9 and Cold Cache format 3 unchanged.
## 0.28.2 - External certificate hardening and least-privilege reload API
Current direction: 0.28.1 -> 0.28.2
- [x] Accept POSIX ACL-based read access for external certificate keys without requiring one exact chmod value.
- [x] Reject unsafe world-readable private keys, group/world-writable external PEMs and untrusted/writable external-path parent directories.
- [x] Validate both configured Certbot `live/` paths and their resolved `archive/` targets while keeping hot reload behavior.
- [x] Scope Certificate API credentials to explicit certificate IDs, default new tokens to reload-only, block global reload for tokens and make deploy an explicit permission.
- [x] Migrate existing Certificate API credentials to reload-only compatibility grants and move SQLite to schema 9.
- [x] Document read-only systemd access plus POSIX ACL examples for `/etc/letsencrypt` and `/etc/ssl` in English and Polish.
- [x] Keep the saved proxy configuration format and COLD format 3 unchanged.
## 0.28.1 - External certificate paths and certificate-source clarity
Current direction: 0.28.0 -> 0.28.1
- [x] Allow manual certificate entries to reference an absolute external certificate path and absolute external private-key path.
- [x] Resolve Certbot-style `live/` symlinks on every reload while preserving bounded PEM parsing, regular-file checks and private-key permission checks.
- [x] Keep targeted certificate reload revision-stable so renewal wrappers can call `POST /api/certificates/{certificate_id}/reload` after renewing external files.
- [x] Preserve managed `<data-dir>/certs` filenames and reject mixed managed/external pairs.
- [x] Exclude external certificate material from RustProxy backups while retaining the configured absolute paths.
- [x] Clarify certificate-source, storage and path expectations in English/Polish UI text and operator documentation.
- [x] Keep SQLite schema 8 and COLD format 3 unchanged.
## 0.28.0 - GeoIP control, listener clarity and cache policy refinement
Current direction: 0.27.3 -> 0.28.0
- [x] Add on-demand GeoIP refresh through the authenticated administration UI and reuse the automatic updater pipeline.
- [x] Hot-swap validated GeoIP databases so subsequent country ACL/rate-limit/logging lookups use the new data without a process restart.
- [x] Use direct GeoIP file URLs with optional per-file SHA-256 verification and support either separate IPv4/IPv6 CSV ranges or one native MaxMind MMDB database.
- [x] Present active listeners as readable HTTP/HTTPS endpoint cards with explicit H3/UDP state.
- [x] Add configurable `Via: RustProxy` cache response metadata while preserving upstream Via values.
- [x] Preserve cache objects across bypass and response-only diagnostic/Via setting changes.
- [x] Separate vhost Security header-rule Delete controls from the Operation selector.
- [x] Keep SQLite schema 8 and COLD format 3 unchanged.
## 0.27.3 - HTTP/3 cache parity and bounded memory retention
Current direction: 0.27.2 -> 0.27.3
- [x] Make body-less `HEAD` cache eligibility consistent between HTTP/2 and HTTP/3.
- [x] Reduce persistent COLD-index RAM while preserving the existing on-disk cache format and cache behavior.
- [x] Reclaim expired rate-limit client buckets and avoid duplicating the vhost name for every tracked IP.
- [x] Confirm downstream compression remains streaming and does not retain complete response bodies in memory.
- [x] Exclude rebuildable managed Certbot Python environments from backups.
- [x] Document DEB/RPM and OPNsense repositories as the official installation paths; keep Debian source builds optional.
- [x] Separate application-coupled scripts from QA/dev/audit tooling and external platform integrations while preserving all entry points.
- [x] Keep SQLite schema 8 and saved configuration compatibility with 0.27.2.
## 0.27.2 - Managed ACME renewal repair
Current direction: 0.27.1 -> 0.27.2
- Make the managed Certbot runtime independent of pip console-script shebang relocation.
- Reuse existing 0.27.1 `acme-python` environments without operator cleanup.
- Preserve the system Certbot fallback and schema/config compatibility.
## 0.27.1 - Portable certificate runtime and list sorting
Current direction: 0.27.0 -> 0.27.1
- [x] Show complete default and standard-service setup-token commands during first-administrator onboarding.
- [x] Initialize the Certbot Python environment under the RustProxy data directory with app-owned `venv`/`pip` state and a local `virtualenv` bootstrap when stdlib `venv` is unavailable.
- [x] Fall back to distribution Certbot binaries when usable and report missing Python/pip or setup failures in the Certificates panel.
- [x] Add account-scoped column + direction sorting for vhosts, Docker vhosts, redirects, 404 hosts and streams; keep Redirects/404 toolbar placement identical to Vhosts.
- [x] Use the installed production binary in the Certbot hook example and expose official source/package repository links plus Python/pip/Certbot runtime state in application information.
- [x] Keep SQLite schema 8 and additive/defaulted administrator-preference compatibility.
## 0.27.0 - Compression policy and persistent Docker publications
Current direction: 0.26.7 -> 0.27.0
- [x] Enable zstd/Brotli/gzip downstream compression by default, keep deflate opt-in, honor client q-values and use deterministic `zstd -> br -> gzip -> deflate` tie priority.
- [x] Allow authenticated/cookie/private/no-store responses to compress while retaining explicit no-transform/range/SSE/upgrade/existing-encoding exclusions.
- [x] Add inherited dynamic-compression overrides and per-scope codec filters for ordinary vhosts and Web Server services under the global codec ceiling; keep Docker publications intentionally simple with one Compression on/off checkbox using the global codec set.
- [x] Default fresh installations to `Server: RustProxy` without rewriting existing saved policies.
- [x] Remove Debian package-build metadata from this source tree; packaging is maintained separately at `rustproxy.linuxiarz.pl`.
- [x] Keep published Docker hostnames materialized while a service is missing, return 503 until it returns, and reuse last-known inventory during temporary agent communication failures.
- [x] Render cache occupancy progress fill consistently across Chromium/WebKit and Firefox.
- [x] Keep SQLite schema 8 and additive/defaulted saved configuration compatibility with 0.26.x.
## 0.26.7 - Cache occupancy and backend rotation continuity
Current direction: 0.26.6 -> 0.26.7
- [x] Show HOT/COLD cache occupancy progress against configured entry and byte limits on `/cache`.
- [x] Preserve compatible upstream client generations across unrelated runtime snapshot rebuilds so rotation age/counters remain continuous.
- [x] Keep request-count and age-based backend client recycling covered by tests and visible diagnostics.
- [x] Refresh container documentation and include all supported Certbot DNS plugins in the shipped OCI image.
- [x] Review current installation/cache/backend lifecycle documentation for stale behavior statements.
- [x] Keep existing 0.26.6 custom `Age` rules loadable while isolating response-side Age overrides from cache freshness/storage semantics.
- [x] Keep SQLite schema 8 and saved proxy configuration compatibility with 0.26.6.
## 0.26.6 - Access-policy composition and administration fixes
Current direction: 0.26.5 -> 0.26.6
- [x] Keep backend table visible columns, sort key and sort direction synchronized with account-scoped user preferences.
- [x] Clear the certificate-issuance form after the ACME issuance job succeeds while preserving non-secret values after failed jobs.
- [x] Document the sibling Python modules required by `scripts/migration/migrate_npm.py` and fix the container helper example.
- [x] Treat explicit IP allows and allow-only countries as alternative allow sources inside one ACL policy scope, with explicit denies taking precedence and separate scopes remaining cumulative.
- [x] Keep SQLite schema 8 and saved proxy configuration compatibility with 0.26.5.
## 0.26.5 - Scaled controls and listener navigation
Current direction: 0.26.4 -> 0.26.5
- [x] Keep compact toolbar selects and inputs on the same interface-scaled control height.
- [x] Reuse the interface-scale field variables for ordinary form controls so inputs/selects resize consistently with the UI scale setting.
- [x] Route the Streams **Add TCP/UDP listener** shortcut to Settings -> Listeners instead of opening a Stream editor.
- [x] Keep SQLite schema 8 and saved proxy configuration compatibility with 0.26.4.
## 0.26.4 - Administration consistency and backend rotation reliability
Current direction: 0.26.3 -> 0.26.4
- [x] Add 404-host forms to the session-only draft workflow.
- [x] Complete Polish translations for the Button style appearance setting.
- [x] Align toolbar search/filter input height with the active button style.
- [x] Reuse the vhost Enabled control styling in 404 hosts, Redirects and Streams.
- [x] Replace the Overview-card checkbox list with compact visibility/reorder tiles.
- [x] Run age-based backend client recycling independently of request arrival.
- [x] Keep SQLite schema 8 and saved proxy configuration compatibility with 0.26.3.
## 0.26.3 - Administration appearance and draft workflow polish
Current direction: 0.26.2 -> 0.26.3
- [x] Make interface scaling global across navigation, workspace, dialogs, overlays and the draft dock; extend the range to 70-125% and make form-control sizing respond more visibly across that range.
- [x] Make Overview telemetry cards denser without changing responsive breakpoints.
- [x] Expand chart themes to eight full-palette presets affecting colors, surfaces, grid and line treatment.
- [x] Restyle the draft dock as a square-corner RustProxy panel without rounded/glass styling.
- [x] Add a collapse-to-bar state and per-draft age indicator to the session-only draft dock.
- [x] Add Settings search without rebuilding forms, preserving unsaved values while categories are filtered.
- [x] Add chart-style palette previews beside the eight chart presets.
- [x] Add independent Normal/Compact/Ultra table-log density and Overview dashboard density preferences.
- [x] Add per-account Overview card visibility and ordering controls.
- [x] Add per-section Appearance resets plus a full Appearance reset while keeping unrelated account preferences intact.
- [x] Add an account-scoped Normal/Compact button-style selector, with Compact matching the smaller source-style control dimensions independently from global scale, including generated vhost actions and modal tabs.
- [x] Retain sticky Settings category navigation and unsaved-category indicators.
- [x] Keep SQLite schema 8 and saved proxy configuration compatibility with 0.26.2.
## 0.26.2 - Unified logs search and dynamic fields
Completed direction: 0.26.1 -> 0.26.2
- [x] Add independent in-session editor drafts for Web Server, Vhosts, Redirects and Streams, with minimize/restore/discard actions in a bottom-right dock.
- [x] Consolidate theme/language/display controls under **Settings -> Appearance**, replace density modes with an interface-scale slider and persist appearance choices in administrator preferences.
- [x] Add 12/24-hour timestamp formatting, chart presentation presets and a denser expanded Stored Logs record view.
- [x] Merge Stored Logs and Live Logs into one top-bar Logs menu.
- [x] Replace fixed Stored Logs filters with one bounded field-query syntax supporting AND, negation, quotes, wildcards and numeric comparisons.
- [x] Discover selectable Stored Logs columns from parsed record fields and keep arbitrary safe field choices in account preferences.
- [x] Parse the built-in RAW error-log line format into basic structured fields while retaining the raw record.
- [x] Remove the configured-file inventory/source selector from the Logs workspace and keep bounded all-source scanning by default.
- [x] Move Stored Logs column configuration to the table toolbar and use arrow symbols for backend sort direction.
## 0.26.1 - Vhost-aware protocol diagnostics and Logs Explorer UX
Current direction: 0.26.0 -> 0.26.1
- Attribute persistent protocol errors through one shared vhost resolver using routed vhost, authority/Host and SNI in that order.
- Keep unresolved traffic in `_unmatched.jsonl` and keep low-level protocol errors out of Live Logs.
- Add bounded protocol diagnostic identity fields for Stored Logs filtering and inspection.
- Add selectable Stored Logs columns, sticky headers, highlighted selection and inline details.
- Hide configured-file inventory by default and repair mapped client-IP/search behavior.
- Preserve schema 8 and the existing saved proxy configuration format.
## 0.26.0 - Persistent protocol diagnostics and stored-log explorer
Completed direction: 0.25.7 -> 0.26.0
- [x] Persist malformed HTTP, TLS/QUIC handshake failures, HTTP/2/HTTP/3 protocol failures, invalid h2c attempts and connection-limit rejects through the existing bounded logging sink.
- [x] Keep low-level protocol failures out of the Live Logs memory ring so hostile scans cannot displace ordinary live request history.
- [x] Add a local read-only **Logs** explorer with bounded server-side filtering and pagination over configured access/error files.
- [x] Prevent arbitrary-path browsing and symlink following; enumerate only paths derived from active RustProxy logging configuration.
- [x] Add `/logs` dashboard navigation plus a Logging-settings launcher while retaining the existing Live Logs dialog unchanged.
- [x] Keep schema 8 and configuration compatibility with 0.25.7.
## 0.25.7 - Vhost operations and backend observability
Current direction: 0.25.6 -> 0.25.7
- [x] Add a fast vhost enable/disable action to the vhost cards using the existing revisioned save path.
- [x] Add backend-table search, sorting and account-scoped persisted column visibility preferences to the main dashboard.
- [x] Style scrollbars for dark and light dashboard themes.
- [x] Export retry and upstream-client lifecycle metrics for ordinary and Docker backends.
- [x] Add Grafana panels for backend client generation, age, retry activity and rotations.
- [x] Make the vhost editor Enabled control visually distinct from ordinary settings.
## 0.25.6 - Backend memory diagnostics visibility
Current direction: 0.25.5 -> 0.25.6
- [x] Show upstream client generation, current-generation request count, generation age and total rotations in the main Backends diagnostics table.
- [x] Reuse the existing `/api/events` status snapshot refresh path; do not add a second diagnostics polling loop.
- [x] Keep the 0.25.5 client-generation recycling defaults and configuration compatibility unchanged.
## 0.25.5 - Traffic country consistency and Revision History styling
Current direction: 0.25.4 -> 0.25.5
- [x] Use trusted `CF-IPCountry` when verified Cloudflare client-IP mode is active, with local GeoIP fallback for missing/invalid country headers.
- [x] Keep country analytics, retained country logging and country policy evaluation on the same verified country source.
- [x] Align the Revision History dialog with the shared square-corner modal styling and remove duplicated history-only dialog CSS.
- [x] Add Cloudflare-country trust regression coverage and release/source-contract checks.
- [x] Extend Prometheus with a live Traffic analytics collector, fixed-cardinality User-Agent/method/source families, country/top-route views and built-in Web Server inventory/source metrics.
- [x] Extend the bundled Grafana dashboard with a Traffic vhost filter, Web Server request rate, countries, User-Agent families, serving sources, methods, status classes, cache outcomes, capture-state gauges and configured routes.
- [x] Bound backend Hyper client lifetime with configurable request/age recycling and an upstream HTTP/1 buffer ceiling so sustained traffic can release pool/TLS high-water allocations without a vhost save.
## 0.25.4 - Configuration history restore
Current direction: 0.25.3 -> 0.25.4
- [x] Restore an older retained configuration through the normal validation and hot-publish path.
- [x] Save restore as a new revision without deleting or rewinding existing history.
- [x] Require current-revision CAS plus administrator session/CSRF and audit restore source/new revision.
- [x] Add restore action and confirmation to the existing revision-history modal.
## 0.25.3 - Request policy, correlation and configuration history
Current direction: 0.25.2 -> 0.25.3
- [x] Add per-vhost HTTP method allow/block policy.
- [x] Add per-location HTTP method policy, reusable ACL profile, inline IP ACL and country allow/block policy.
- [x] Enforce host and location policies before upstream selection/connection.
- [x] Generate one SHA-256 request ID per exchange and forward it unchanged to the backend as `X-Request-ID`.
- [x] Persist bounded manual-configuration revision history in SQLite schema 8.
- [x] Add a calendar/day revision-history modal with change preview in the existing dashboard style.
## 0.25.2 - Live backup and resumed-session hardening
Current direction: 0.25.1 -> 0.25.2
- [x] Allow encrypted full-backup creation while RustProxy owns the data-directory process lock.
- [x] Snapshot SQLite consistently and stage durable files before archive encryption.
- [x] Preserve only validated relative symlinks that resolve inside the staged data tree, including Certbot `live` -> `archive` links.
- [x] Keep restore offline, validated, staged and atomically activated.
- [x] Recover transient invalid-origin state in restored/resumed admin tabs with a one-shot same-origin reload, without weakening server Host/Origin/CSRF checks.
## 0.25.1 - Traffic UX and source modularization
Current direction: 0.25.0 -> 0.25.1
- [x] Split Traffic into Overview, Clients, Requests and Delivery sub-tabs.
- [x] Chart every bounded Traffic dimension with donut or horizontal bar visualizations suited to the data shape.
- [x] Keep every Rust source file at or below 500 lines and keep unit tests outside implementation files.
- [x] Split oversized dashboard source bundles and the Docker Agent into bounded source modules while preserving behavior and URLs.
- [x] Guard the Web Server split against dangling attributes and missing `htaccess`, `server_protocol` and `anyhow::Context` imports.
## 0.25.0 - Web Server foundation
Current direction: 0.24.10 -> 0.25.0
- [x] Add independent reusable Web Service configuration with name, document root, index list and directory-listing switch.
- [x] Add Web Service CRUD through the dashboard/API and persist it through the existing revisioned configuration store.
- [x] Add `Reverse Proxy` / `Web Server` source selection to vhost fallback and custom locations without storing Web Server filesystem settings in vhosts.
- [x] Allow one Web Service to be referenced by multiple vhosts and one vhost to mix Web Server and Reverse Proxy locations.
- [x] Add `GET`, `HEAD`, MIME, `Content-Length`, streaming, index lookup, directory slash redirect and optional directory listing.
- [x] Add repeatable per-request directory listing, per-directory index-before-listing behavior, Web Service MIME overrides/default MIME and a default static `Cache-Control` policy.
- [x] Organize Web Service editing into General, Routing, MIME, Cache & headers, Delivery, PHP-FPM and Security tabs.
- [x] Add Web Service `try_files`, internal/redirect regex rewrites, ordered per-extension/MIME Cache-Control rules, fixed response headers, precompressed `.br`/`.gz` delivery and forced-download extensions.
- [x] Add bounded FastCGI/PHP-FPM execution over Unix sockets or loopback TCP with `index.php`/front-controller routing and no source fallback on upstream failure; expose advanced bounded limits, Authorization forwarding, custom FastCGI parameters and controlled `PHP_VALUE` overrides with in-panel syntax documentation.
- [x] Add explicit symlink policy: deny by default, with Linux-only inside-root following for static files; PHP-FPM remains restricted to deny.
- [x] Add a bounded `.htaccess` compatibility layer for common Apache migration rules, including rewrite conditions/rules, directory index/listing, MIME, headers, Expires and PHP AddHandler integration, with a dashboard documentation modal and fail-closed security-sensitive directives.
- [x] Add one-shot Web Service presets for Static, SPA, PHP generic, WordPress, Drupal, Laravel, Symfony, Joomla, PrestaShop and Downloads, with optional deterministic hardening for common exploit probes/sensitive paths and fingerprint-header stripping.
- [x] Expose vhost-wide request/active/error/EWMA/traffic metrics for Web Server-only vhosts and fix custom logging mappings for route/cache/transform effective fields.
- [x] Add bounded per-vhost Traffic analytics, User-Agent logging and opt-in country/GeoIP logging with top-dimension views, effective-client-IP rankings and donut charts for the main dimensions.
- [x] Keep Web Service dashboard rendering stable when configuration loads before the deferred Web Server UI module and use the existing generic/custom 403 error-page pipeline when directory listing is disabled.
- [x] Add automatic allocator release and idle HOT-cache waterline trimming so sustained traffic does not require a vhost save/reload to return reclaimable RSS.
- [x] Add metadata-based `ETag`/`Last-Modified`, conditional `304`, single `Range`/`If-Range`, `206` and `416`.
- [x] Enforce decoded-path validation and document-root confinement; Unix traversal uses `openat` + `O_NOFOLLOW` and does not serve symlinks.
- [x] Keep Web Server inside the existing routing, ACL/auth, custom-header/error-page/security and H1/H2/H3 response pipeline; do not create separate routing/error/cache subsystems.
- [x] Add unit/source-contract coverage, OpenAPI/UI language coverage and Web Server documentation.
- [x] Clean README/Docs/CHANGELOG/ROADMAP material, add a canonical docs index, authenticated Markdown Reader, 0.25 specification audit and open-work TODO.
- [ ] Run compiler-enabled `./dev.sh fmt`, `./dev.sh check`, `./dev.sh test` plus live H1/H2/H3/static-file acceptance on the release host.
## Web Server follow-up
Future Web Server work must retain the 0.25 split between reusable **Web Service** configuration and vhost/location source selection.
- Multipart byte ranges if operational demand justifies them.
- Named locations without introducing a second URL router.
- Opt-in PHP `PATH_INFO` script/path splitting with an unambiguous confinement rule.
- Race-resistant filesystem confinement for non-Unix targets before enabling local file serving there.
- Real PHP-FPM release-host acceptance over Unix socket and loopback TCP.
See [`docs/TODO.md`](docs/TODO.md) for unresolved work and release acceptance, and [`docs/ROADMAP_HISTORY.md`](docs/ROADMAP_HISTORY.md) for completed historical milestones.
- Update existing guides when code behavior changes; remove obsolete instructions and avoid version-by-version implementation notes inside functional guides.
- Keep the changelog technical from an operator's perspective (features, settings, compatibility, defaults, fixes) without Rust allocation/lock/microbenchmark details.
- Preserve old audit/development evidence in a separate archival package if an engineering investigation requires it, not in the live product Reader.
+124 -187
View File
@@ -1,13 +1,13 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
293fdbc2fc7318a9f544543c2ad518061ba1377edab1c73e93bd3c881d916195 AGENTS.md
38124997eb546aa2fb54c1b7801a127994a7911c469ae93b04b96e9816dec842 CHANGELOG.md
fcbd81ae693bf052a67a8815e227221a236e0486669e2b4668eece1f4e73bee7 Cargo.lock
8e72d66bf61579ceb5237d9195a4c09baf936abc9c9b541f17454c7946acdb58 Cargo.toml
b123132d0f1230f7fe30a1a80f51ee931fe87c82487b3d172c3db7db0876b7da AGENTS.md
88e6825d29a5864b6b7412b08a3d35be85d5f0f53fcc9c9a57a6283c0feef577 CHANGELOG.md
ca201e3cd3f9842eae0006d0b40a0a72bcd3d4da6e4e5ecaf200d1b9a1390db2 Cargo.lock
fec5f1bae9a59ef1fabd27c5e45d31448dd8f811736eebfa8826b3806a7e3349 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
bcf49d2ed52433772e249d9d991fdaf7cc82636da4de30f098193c08c4b0d7b8 README.md
61870c209a50f1c683d221b3a11b18507b084c3b602111ef62f8bd90f285f51a ROADMAP.md
9802fb994a5361186e83e71d95163c53baa890a6f93b09cb77611d25ee92376d README.md
79e6156f363636fe89c49444de79dc4793cd22db3021123d6d2fd0b4b3e592fb ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
@@ -25,54 +25,31 @@ b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/m
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
e21a5c3cea78386fcb3ebd6516a66eed2ecd1966481ae0d3b2f57fa01010542e docs/API.md
f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md
d30551a52ffd34c5963094d2826ba39795210a4e6a4e4d7333066dae7456fc50 docs/ARCHITECTURE.md
fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md
833ddb12f23dcac796223521388b19c6ec3ea04c5550cf1c5f4ea4eb7c58f3a0 docs/CUSTOM_LOCATIONS.md
3cbcf562114fae47f01ed4614d49844b88e7a4d8aee7eb724400d5681be90936 docs/DASHBOARD_PRESET.md
4d70797bda8d227787fcff897951e702e2458fc6c6f5352aa0b5577595bd834b docs/DOCKER_DISCOVERY.md
46811a09a03d83f4af686c33e30e3f94d0508afe2c75147a26885c7247c49cbd docs/ERROR_LOGGING.md
f749f7bd2eb4ae9c484f77a3cd8cd5ff21445319a3938e0384a3414134fe713a docs/FILE_LOGS.md
36599e0b59164e662442f3e8203c16b3123d09b7684d8ecdbffce42d22cc221b docs/HTTP3.md
a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
d8829cc68d982c933d04207cf7bc78cc918044c72e024e7ecad5220953bb3a79 docs/MEMORY_STRESS.md
e36247774051aa9c83c1a2fda91c1f7857991dcaa4fa90320ca5b492e31dbcf8 docs/MIGRATION.md
5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
be53a58afbeddbd7deb4b5f6447f2dc74db0327839bd83407c20915df98dcfa0 docs/NOT_FOUND_HOSTS.md
89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md
02126c23264b3afdf1e639c0334d621520e7538ff92d60eb6398c5fb10a23f2e docs/PERFORMANCE_0.33.8.md
e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md
bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md
ad6e3f003b820a4f45e30e39577d120a69d167c70910bda4891971759458c3dd docs/PROMETHEUS.md
bb15713876184682cf1907d8c5edd18e657735b6de6cc5f8ca976dbdec4dd936 docs/README.md
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md
cd4ed3af26eb51c9ff6ef3dda9d52ff5120bcfc78c842bce34a639cf85b6aea9 docs/ROADMAP_HISTORY.md
759bf2987f2b602488829d1856fa33d57c2a3b23bc681ef5a931b9633af3a533 docs/SECRET_STORE_BACKUP.md
ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
4dcf3243a13d9ae1827b5cefdbb9e27bd6310321c50d4d5934527d44437df6ef docs/SETTINGS.md
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
95148bfa1d2f8ff52b591c9acefc6c8992dadc4ebda43a195101d9b31d67a576 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md
711c69c9e05761ba5cb96dadc3c368eb2961e2d2287ca039eb49c7fbca0dd63c docs/VHOSTS.md
09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md
4257729a4c1b1f3949538410af78a12067bd3417c70525a15bd01411155d25fd docs/WEB_SERVER.md
c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md
b0eaa2cfe4f6e86582f67891e457591ae650d15145e025d85cab43e37c883ac8 docs/ACCESS.md
bade64057fd180e63845816336181f997f720a2c7b209c8d4a10333365323cfd docs/ACME_PROVIDERS.md
a11748015bb07a9f53a231a6a3fc318f6de9eac5cbc45bcb000fe34be5163255 docs/API.md
7f7021fff285a18e548c7e769a02cfec9e573f089f3e6b1ab3b4f6be0523fe1e docs/API_ACCESS.md
728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md
7e6a7b9421541c94181a712d17f6166a328e7e28c5a1d629e7d335c0ae5aa1de docs/CACHE.md
bcc8a847cfac67ef945d98ff808d92adeaa9bd7afa1878194b41faa009c41777 docs/CONFIGURATION.md
991af7afa69c0e1f7e48baae6309a5e5431e4c23c0e4538eb37e1948854b991c docs/CONTAINER.md
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
b4940716f8a89bf964f00ad6032aab1083fc1983e1d2e42515028f7b1a34c9e5 docs/DASHBOARD.md
71fcb8e67135e2197207bd8b979805a9b0ae5df9c6a8594de9b7fadb72fa15e2 docs/DOCKER_DISCOVERY.md
4238ba7e0e6071cd3b30816f29f87d8c9f8f264d1dbe53e4073d606806c5a50f docs/INSTALL.md
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
481b7c420f388c1d9338ffc794ca7d27fbcc8b01afaf4edbd2405e59295416ba docs/MONITORING.md
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
0efb12afcfccce6ca9018ddad3a93f3d81e04711f12154bd570c939e30c30a60 docs/README.md
7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md
701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md
519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md
f2f8916e5782bc9d4deef3405c36eeddcc1c1dde5198f9b415407c0351bf364e docs/TRANSPORT.md
f4c23ee07723a78e1e104991c99bac6be61d72b7b912141e69cffffddf7232fe docs/TROUBLESHOOTING.md
23765b0d3e32766966e922aa79951b8bc94fc93c0ffa38824e9dfc7e6cd4a1aa docs/VHOSTS.md
d8f30b1aaaa6a3a4679dbc4bfdbddb85b065b2fb5bf7c2760e7f92d2fbbf417b docs/WEB_SERVER.md
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/config.json
@@ -97,8 +74,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
1a69ed38b730c3b379b8b14b5707d8ae923320a0c66d084c7ee478e6a01eb53c langs/en_US.json
6513435292eb947935f50eeadf9ef2ee0995f4b57e989699dfc9cda686f21685 langs/pl_pl.json
315f673be9a929069f6ad0c60a215d30d7c3dc2de4105e208810c371c488b268 langs/en_US.json
ca68b7475d3b744b3cd091140ef1d5aaa6f7fa880832498ff9aee7b0ee252846 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -125,64 +102,18 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
afea7479fd10b7d723f147f6605283174ef81ff5e482f65819db4f01ffb02fe5 scripts/performance/README.md
0380406a059ee9ec27473f8a46435f638f73a6dc729382ae1561c08dbf7ccb53 scripts/performance/benchmark.py
9ac220163256e08513597c397c016a48345b114d7876b439259cc310b36c83fc scripts/performance/checks_cache.py
f09125d081d0eed9a44fdca75de14e6bbf09dcb00fe1f027ba2270f89e3ff192 scripts/performance/checks_policy.py
96e01db050987af431a4662fce34752842bb2575237fd3239bcfc10ae55def6b scripts/performance/checks_proxy.py
c1d8c184811ae8fb1960d1ada08d8a4a95405c3b83dd18e1a27d711b596140be scripts/performance/checks_static.py
1044eb8d225e2fa117d5435697abdf0a014aa2f9063d4b41566e74ef47d50a34 scripts/performance/common.py
0f1fa62ee3429509dc21fe1cbfa7159e38d260ada98bfc6376e8078c9840ef48 scripts/performance/concurrency_regression.py
044da66a00a04f558a6b6e6157db0a7656dd5ee22adf0b0a47482c7f951380e9 scripts/performance/lb_regression.py
1b601d68d15909d786ac63890731f0592bd9057a68993a1d90bc70d6618e6f82 scripts/performance/peer.py
830a360609e2b8ebbb1ef6f3d89e3e6151e8fefb36c3673d20df68ff8932eabb scripts/performance/profile_syscalls.py
ceb26650fc8b63d0107eb9100ccd572e5c70f7a0ce5cc533a94083e20485095d scripts/performance/regression.py
d6480962a23f55d19a54aba27648ca1b28b50f03afe2decbfa272efd142df756 scripts/performance/resources.py
f85dd0c29d5a96ad59a71e035ac4303c7b6a70a8d61280a15cdb966b32a218c7 scripts/performance/soak.py
fe6ff5bfd76ac7a2752a8a2e1e45c63e549c4a61c753008671e478df3ebff304 scripts/performance/telemetry.py
7c1ba46c623e1ade47b872fcdf4526a48ca85c56515dffeac626188b0357d23c scripts/performance/transport_clients.py
bd19431c89d7dcfe6743dffff9c4b16260a976d1936bdc4c11d4fcf6b3a7dda6 scripts/performance/transport_regression.py
1d747278da3fca093ed91c96d4a9480d0f711d0773d673906dbb56081635603c scripts/performance/wrk_report.lua
b5b3135449f6be4e97088b76ef7cf2428e2de3c7944c9cbbcb1edd2016c0fd4a scripts/preflight.py
901a6ff645fa4796bbf2c7fae53d085eaf24f9421a34965c431b81b41d6501ea scripts/preflight.py
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.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
efb7a1fde2c74f30cd4a1abb88ae07c1cac7378bb33685dd09d52bb30849daac scripts/tests/dashboard_chart_headers_0335_browser_test.py
eb2c9e5c968938b6fbe4675b547ba1f537519878b9095e1fea133f4d83f96fc8 scripts/tests/dashboard_chart_typography_0337_browser_test.py
6a908fd434d2a8a80adfd06a66d4b83c4174e448d453a8c10b1812475d036677 scripts/tests/dashboard_chart_width_0333_browser_test.py
252b401c29e2db50e105d7fb044bcae14752748261a67da1a4025d71bfa2608c scripts/tests/dashboard_cpu_autoscale_0335_browser_test.py
41a2b3765006c138324ae1312f5c1edfed6ce48051230f85779e8d2aad34d2a3 scripts/tests/dashboard_cpu_fixed_scale_0335_browser_test.py
de583adde771e0d6f64ea0d5aed45b1c942a7c78b7193598eeeb77fff00c2803 scripts/tests/dashboard_layout_0331_browser_test.py
1c813c78dfe96ba4d2c9470c93dd24e81be06e6485ebdc39cbfa30f8d32aa0fe scripts/tests/dashboard_metric_visuals_ui_test.mjs
f14626c5ec006477062b496bdc0025caa3366c7037ee598e15cd55e29041d58f scripts/tests/dashboard_modal_0333_browser_test.py
f934f5ce66105a1394b93761a6081a51133837ac038f5faf67a236f676b15c1b scripts/tests/dashboard_overview_labels_0337_browser_test.py
df87420f16c418aa17a8ba8808e506095681446d32a7d76ae0244222ed37928a scripts/tests/dashboard_presets_sql_test.py
df1457797241aab114a5671ce3f0229815dd29d06ab6f6254c0a1048576af6e1 scripts/tests/dashboard_resizing_0333_browser_test.py
149910e6e8d569443a724fd21701e2fd8f6b5e360991d4f710f5d21364402bce scripts/tests/dashboard_resume_rate_charts_ui_test.mjs
3868dc7b241f1cf15bc297104e29449ec3e35f75cb66078439bc4b5c4ee11040 scripts/tests/dashboard_stable_scales_0336_browser_test.py
fa42ec0475e4398d647b3c094309b4332cd2b5649db223cae0b8e821d6b44a28 scripts/tests/dashboard_title_color_0337_browser_test.py
3c1b2bda6410d3348b0f5a05c70494cca9636339bed8217fc4abc0a30981faae scripts/tests/overview_samples_0330_ui_test.mjs
72185f59f31bf04a4e88d53fe2fca52509f3663cb5f40b1d3bc319a5f945ef23 scripts/tests/panel_html.mjs
b0136544af717b75df2cb107900ea65c3f945fdd0040369dbb93919008fb954c scripts/tests/panel_html.py
8d1903e2b17b3d59cd0a5bec3e298e468b3d88ed2dda8b724239cc45d8353c59 scripts/tests/progressbar_0332_browser_test.py
1bb99b148d0297235b707f16865ca077329246fc19ecbb20a143bdeb56a23f37 scripts/tests/sidebar_nav_0333_browser_test.py
12f2b18b0ed2171c49eb8cde2c2b88fc627054c2f574c04bd9847821b00cd4c1 scripts/tests/system_ram_meter_0332_browser_test.py
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/tests/test_stream_tcp.py
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/tests/test_stream_udp.py
6fa1667a844b7bc2adaa78738339b58ac6c94a6d6e7be34573433dcd323ae809 scripts/tests/upgrade_performance_test.py
f0a2e3daa0a49aac54723cb5a281360e49a97c076281372c5cca2a85e096c02c scripts/tests/vhost_backend_popup_0337_browser_test.py
23cb1f790cd5cee1b76ecd85f6a2a44ef2e16b0840bf06beaa2ae13048063b2f scripts/tests/web_observability_0330_browser_test.py
3babdd0fd6e2998755ae88615112f6ea056f5c808245abb6de60fbda555594d1 scripts/tests/web_observability_0330_source_test.py
f55efa99245bbdbc98fe3202e52952d224ac3200612561580d52f6e251810049 scripts/tests/web_server_0330_ui_test.py
0dd1cef06a1c3e1546e2fb9b085bf369dfbed02107b9ab01b4fb4f3b894099c0 scripts/tests/web_server_0334_browser_test.py
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
55485a513e8c4da2a58daa7abcb15b32ed408a5fc062cf23599fb83d16b90cfe src/acl.rs
9bf4e4a61824f82b773b4e8ffa0909b9cf0a42bfc14d695bd64da240cc634a61 src/acl.rs
63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs
4803e44de39b872a5b3b55f9ca9e81c066ba52cb8f6adb879fd57c5f94b93e89 src/acl/dynamic/tests.rs
a22e5ea6381a1eacafe650be0f5cd6e653773e31f6491a562fe0459a309fb7cc src/acl/tests.rs
5e77c7e8d9781f6a8ff3d3f09da4dbcd5277b241901ff17c6c8c2703f2abbcef src/acl/tests.rs
6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs
587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs
3f158f46e80b403b658b4e9b1e3710e38c70e47b2d476b8223d9c394ffeeba95 src/acme/model.rs
@@ -197,7 +128,7 @@ b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/cert
51f46a2cb3167c4e3a784dfbb59b8fe0e9d5d3d51365b26699498e31d0afb6a9 src/admin/certificates.rs
b5f6d49f1add583cfc8cd4aa36ffe4db56dac4de3b151fe8239fe42f507019cc src/admin/dashboard_presets.rs
5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs
958546c43c05bf4bed8121b24db3b3096f9243f3c56a71e1762592b755684013 src/admin/documentation/documents.rs
ea7aafb4466caa66c6147f0e08d189c42222c43212bd7acd8e8842fd935a48ef src/admin/documentation/documents.rs
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs
ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs
@@ -205,7 +136,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
74636c6560618ee95757335452baf8db03394e1ad18acd54252f54b15746b8d5 src/admin/logging_api.rs
e552c4b565e3198cc2224d1efa3bef21db02a9872cb80cd96f446181035f19fb src/admin/mod.rs
28dd33ef19d7eab08cfa243b253785012d6c731bc4c56b4f69f28edd3d9e07a7 src/admin/openapi.json
f571add511ccd00c6b1068851ae32120e3a0b354de8d6dc8c3e762db47e97765 src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -221,7 +152,7 @@ a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/sess
4b8a0483ede651d08489a4d6e5bb616f06423094bbbefcee70b8755d65c04398 src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
ccea3b72782aa9d36e41b89f40a755289d285692155c27cc81a0968f8780fe2a src/admin/tests.rs
4a49ec5b515034e839e1a38e9950d0915538340d1871987578b8a10c2b0a8042 src/admin/tests.rs
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js
a085ee336e6669229f3ad2f74536f19bd088412ca79bcaff30dff293f1e1baf9 src/admin/web/app/chart-scales.js
@@ -242,7 +173,7 @@ daa20d634f921191cb4ac37889504f203ab39ad7e8fd8f33e1b182fc4206c24d src/admin/web/
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js
7f89bfae66e2a08a05a112733864e908f036bb1ee3d8b5b9ff23783b870acd96 src/admin/web/app/web-overview.js
bb94697a1cbeccaf80069dd47ab84efe14b052d81c472f8309478bbeb4e05f06 src/admin/web/cache-panel.js
849b2ca9d183bdf6cb1e04651cda52da55b8a631e11ab1d827a052c8436bbd76 src/admin/web/cache-panel.js
02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js
aec5755244242424e5091ff57c0258d2618588618fe1d52d36f081d03879f686 src/admin/web/dashboard-defaults.json
797e2935a34b974cb0e07a88056c31e6948cc11385bf2a076d902a332882d0a0 src/admin/web/dashboard-widgets.json
@@ -267,7 +198,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
6684e7de2540499f9129b78c790f9a820b0e6c5de33e3b60c318d9a1afc931a5 src/admin/web/index.html
0f1b53afcaac4f9ab6a0b9401d3200ec32bd42c1464e6e8c3de73559e2312a70 src/admin/web/index.html
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
@@ -283,13 +214,13 @@ c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js
b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js
1f0c6cad61554851b8f10e0a888348c3a2467af188f2369d9236aa2272698033 src/admin/web/vhosts/editor.js
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js
bb93e2017f40e3a45ff7f4ca92e4188bb464f432704b6d2c39da9d9621b7984c src/admin/web/vhosts/render.js
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
0049e5c2a35e3ec25f3c1590879684a7b15165584e96eb1b022fb60d3c548f2c src/admin/web/web-server.js
fd476445a88eb40270e706814eee50cbfbba4069a26dd843c095367e55a147a1 src/admin/web/web-server.js
53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs
b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs
775178ee5781e64574bdd7c1fb7d64e7e283977fbd9c98c99896532a7f1df612 src/auth/mod.rs
@@ -302,62 +233,64 @@ c96a87e82ff0c1c9ce3d0cd5135903bb53af78d1c4fb6ebfe5fea503bf5644ea src/auth/store
5c19490d834d9971166b6b784a75b081bb95fd50c8c634ef3c87924dfffd4a19 src/backup/tests.rs
f5afae218f3a6f91bc2f1f5a90094630ce5b7ca574de61807b5a317b13937ec2 src/backup/validation.rs
426a946b2c9e244f09f6cbc5f9664b8ad0737529a9bb5a3d549d27196a1f8043 src/balance.rs
d3257fa5222dd4f70a82400f5060d3cbd33c13862a9320931361fe0e1c5bd452 src/balance/health.rs
59d90e709bfc2be6cd48576f1c1d222891523853a46f2561f0562d74d01f60a5 src/balance/lease_runtime.rs
aed9ed896a122d7adcbedf0897cfd5acf62be3c5b63a71a4a425c8fe8474c0eb src/balance/model.rs
41c3c020a60c372dbf965bd277c330dd9cf2ab079f6608b472adf72a656f4cb3 src/balance/pool_core.rs
ca1e03b56a872dca37ed9d05ead41c1538a808e80b09fb9494823c443cd51c61 src/balance/health.rs
70c44291960b835c7a456cf066469b0002e1e35ba497663361d659d452a52e5e src/balance/lease_runtime.rs
aba0f4ae46f05b07bfc434b7174553ce20e5187e1b40a6cf946eb2aefbe1840b src/balance/model.rs
8550e5d64aa6fee442cea3e8e243fc4cfc1e8aa39726bc5cc6b2afa9307e3d05 src/balance/pool_core.rs
477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs
8abb6048303ee8c022546a528d5693167766e7450efd8e20f37ec6fd0fa9ef5e src/balance/pool_view.rs
f1a92d188628ab3fb587375e80956e1552301647f2732cafb55e1471c7f743e7 src/balance/tests.rs
814be4263c35b84de2b3c3bb3c60d7ead9f593154ac85aa6c8ae35700ee591c7 src/balance/tests.rs
5049d2bd01a9c866f3bad2288d8d594df96bba6045dfe15cd22b4173562b7388 src/basic_auth.rs
9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs
7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs
a9eeb93519aae2de7a1282296ced91d2eb8307cc966f8fbf6085af0fd21b0ad2 src/cache/cold/cleanup.rs
e022003be144598100389cf38d76f509b5dbcc035d5bae556bb9b94cc8bd30ba src/cache/cold/demotion.rs
9fc18368e2ce278ef0ad2b73ff016aa24936c2186be371ca4010a1f6f63bb0ed src/cache/cold/startup.rs
ea319b6e200b0c6ca93da5e2af7a3f86e3667cef918716f54f82b8012111b9bd src/cache/cold/worker.rs
8354ae90c1a0857773a0c6d79427925e0115d94ccfafb809ccc1ebf4ef10f9b9 src/cache/cold/demotion.rs
2eaba3261da2a547e9af91330319a3abd3a8ad8b5b44a337a84040720209017d src/cache/cold/startup.rs
e7c7fd71727d542c2df3e03a4a1cba9d839c516963f66bbfde1c29ec81053500 src/cache/cold/worker.rs
d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs
284f1cc56113fbcc3b1e617103d75a7b70fd7d43e536d6017c15912154921424 src/cache/dashboard.rs
780c2654c67dbe176d8310678e6deec8022bcebfd85b95df7262069ed41f57b1 src/cache/fill.rs
563919c53d38e9b83505a5715469833569efb2a09e91a2b9a89c335187368dee src/cache/dashboard.rs
7eaf3947cfc89cc6ef55a64fef0b3fedf1d9a1c33322aba2b5170c0ea34a5f2c src/cache/fill.rs
675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs
c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs
0cc31d8e70fb3774c4ce3713b94ed79c184a8d8f0d38ce66a97724410733e17e src/cache/invalidation/invalidate.rs
b74945c7460b76ed7afcb02a45ede09d76f08e0703c47920b9b9d794d4b74980 src/cache/lifecycle.rs
3b8f77bde71253a3ca600be5438b7cc72f7da258f3afa5de2cf64553e49922d1 src/cache/invalidation/invalidate.rs
f3c5ec56f8d81a285877f6a07239081c2b1b064fc9143ba3bf36d616b0239d86 src/cache/lifecycle.rs
bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs
88f6210c03d7a8e7a35be1f123c9c15675af6e6a599c50db927d99866851b634 src/cache/lookup/hot.rs
1779abcee77600c13050a2882f896a5b35625b1ae53737c2554179492ce0e40d src/cache/lookup/insert.rs
107a589974b9689009a9bbf088818dfaec71cd1aab8676bbc6d0118359a54a31 src/cache/lookup/tiered.rs
58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs
bbd5659a3197e443b3b5b726d39d7b7934bb9aeaedcdc04285bbf4321d1359d4 src/cache/lookup/hot.rs
5c3f749b3028e436a99614c22dbd05907b63c12a5c7c6baeda24c8f506db4f65 src/cache/lookup/insert.rs
b38f93fa2eac694aac4dd2f1d69b55688928b53b78bf095d479843956c78e605 src/cache/lookup/tiered.rs
5d4338ecccdbe9d78476e28491d92d4a1f96ab3c1d40590a7cfd1ba131b3ee12 src/cache/mod.rs
0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs
ba94226addfd6cdab67d666dc15b8412c4d117111e44639e503e89a95175d0a2 src/cache/model/cache.rs
9704265423d53e87c04cbac11effdcc3792439699494138dc86466ab50656993 src/cache/model/cache.rs
0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs
29bfce96b2ffd102d9c848c7b717a45fedb9aa44511f62f7a1f28a53dafd063a src/cache/model/entry.rs
88d74fc1b7b9ca15a54a8f4893ad3993105952148bcfee099e9bc9727b622aa3 src/cache/model/http_invalidation.rs
09a4417f82984eca8c040ea28eb82b3f129c347d951d4b4fab704bb194d9b985 src/cache/model/maintenance.rs
5ea7d8e72c714e5c5330dd81b5a6e2c8ed79e4367af85f133e63a756ad9788a3 src/cache/model/maintenance.rs
5441fa3a384add28b6fdd15300cf2648f8237dbfc211cad9117754203be76911 src/cache/model/settings.rs
55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs
e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs
989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs
d426d341a7678f8ec70406d86052c97d0f49804bbcd8402951103d61a735b9da src/cache/policy/response.rs
d6051d55168fe1db97980a54c22c708f4f325ba72bfc36f35a1851eb5dcc051d src/cache/policy/response.rs
7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs
7ad96b3baae36ec49c90d03b380e749944fb3d54abb045c90db3a35f6b89b1c1 src/cache/tests.rs
a1c750a48b4562d88d904b45dee5c783d4bf0ae1a62e67719df6bb2a7374465b src/cache/tests/cold_tier_tests.rs
3842cdcf7c3414b9c30f5fbed927b93e835eec3ef96d4c6ee44250038b834276 src/cache/tests/dashboard_tests.rs
cf2513043813421e10260cef63645fdb8c5249114f16d91eae9f919b0214242a src/cache/stats.rs
93bd3e9e5c1a045c784cee0e1fdb9e1c5d862ed70d5d91c54c5a769bca8fd949 src/cache/tests.rs
9183fa8e6086e92fb148fc278fd866d44e3dd988c848a071e32066e44a1cbf0a src/cache/tests/cold_tier_tests.rs
25ed7635bfd5e34de55b319bf163db9a0af26642664ce7685ebe1241bab52cac src/cache/tests/dashboard_tests.rs
a0a23e7ec1baa403524811f8096bbc026b1027b7b1b79357ad920b0a4be770b7 src/cache/tests/hot_hit_034_tests.rs
d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/tests/http_invalidation_tests.rs
1db70ea5f3cc1f1543504a68498ba23cafce5f9bb48c4616a4bf70d9a536790e src/cache/tests/scope_tests.rs
ddbcf27a667d779065d7a654d8094910e92958d2b8862a2ec2a85ca41b7f21e5 src/cache/tests/tests.rs
1659ffd8d227d2ec8b98e6a5e06b6143e6ac4ac06ab423c184980abcb47085da src/cache/tests/scope_tests.rs
d4429cf4f35fd8d0082108fb62602fde72c9c2fc6edfadfe3268c6a8214a8f89 src/cache/tests/tests.rs
af28534625859ff66011e10c54cff8f82c2cfa1c173c4d99b89c27c1437f0659 src/cache/tests/v022_cache_tests.rs
a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tickets.rs
f76b41a2b0ffa5699a456cc2cfa0b063098c90552fe01e0d05e6ee91a01cbe57 src/cache/tickets.rs
e0146bf5d2e2e33aedcccd9205e5bd457559c9d77dc7ce266f8c165d1b867f0f src/compression.rs
4bdbde197d135fee8be20e93427eba8ca7b60b801717ed7ca734319bedb6ef05 src/compression/tests.rs
2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs
7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs
34be288c858a037c7174f6c4dee23178b01b565aa9d65f0951f351e1685847df src/config/backend.rs
56b3d37e7447995bec2fdda1c17dcc74b60958a63ca88b88ff57e56347d8b0b9 src/config/backend.rs
beb831a208c4e5f7ebe8327686b1e0edc66608c7032f895cb4fc3366b189aae0 src/config/compile.rs
490d05d84b2fe349ddde54eae44880993fab70811652484dd9ba38b5069d603b src/config/internal_routing.rs
a19fc675720c7c2dbf4bba706e1b1c30c83c6df1da3f29e3cbb3b82442e7246a src/config/model.rs
f66c2388f31b19142c12469df23d43451e0f7c48b158d4996920282c06d32076 src/config/routing.rs
b25e212a493c6d9f6cd1d1136935182504163c7b8d7e776364389451ca2247aa src/config/snapshot.rs
f5431467dec55540f1f881541bf13d9402753680c7f83e3deb9d0e1fa747313e src/config/tests.rs
a4bb70f4da2ae1fa890e65ec15f87e919e2288637dc0eb2c383234e8babbcb05 src/config/internal_routing.rs
40e09627e655d9319b576592f8d635e8c2da12905e5cee5952f7b6596010eb8c src/config/model.rs
fc993c356386a9ff17379d48a70ef388df5b2b10ca9ee04f06342f68f9dbbaae src/config/routing.rs
48ecd9de5a54b0a4aa484f38a7e171f60a033b725e14b158d1ef07b67997f9eb src/config/snapshot.rs
975f168b0630f71ed6eb1b27fef8a3d5c356238a0ef61b85fd9401416dbecd4b src/config/tests.rs
23541660151d9005555a07db6d491455d61b185f0cc70c3ec3a0bee2aef08dc3 src/db.rs
30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs
20ba52d3755c359d670968efa53b0604ff7cbed63a416f9f765b50a1f45074ac src/db/dashboard_presets.rs
@@ -370,8 +303,9 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
4c2c988191a97f81568a8df64af772463d010844f1abf327871901cdb32d4336 src/docker/model.rs
5f4610c59b2d4ff6a205a2d9ae11bc84be689b2a68302459f546b3f43e53593c src/docker/reconcile.rs
a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs
2a02ea2e010cde1b1fe99b2e6baa80ea344ea6b332dbb3c4be795b71541f64a7 src/forwarding.rs
057e652f216a1478638cbb53b1c64ac88c7f413a65825caacbf893c5af004a63 src/forwarding/tests.rs
61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs
9c712ed59cfca15b79c3339a031c8903c83468c9d3b5e29f7e59d9a222b97561 src/forwarding/peer_formatting.rs
0259def26930308047a248548ff911f64638b43bdfe7f765ae8c82a87a6aab21 src/forwarding/tests.rs
af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs
80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs
06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs
@@ -389,11 +323,11 @@ f2a8c2a98f4ccfb708f9d75a48ec7b351232be9b8a4364b7e63ba14d28eead7c src/http3/sett
a31db7eac12b578b1eb5a74fae98bbe36312da95b7d7173135dbc41fc454da21 src/http3/tests.rs
68fb5ab6a889995a66ad12cb6acf88c5d04f0aca572e2ae15f1a9cd171a5f24b src/lib.rs
2551189a3e5701371026c9abd2c5d44d37495f28fb67e220180421bfd315e3dd src/logging.rs
18c8d74c21a47f234be93551d7264b1f8f8c00d1bbfbe555d91618c58cea0740 src/logging/error_runtime.rs
87948a511b680e1c274eb46d13b938b45792e63bd6cff1621afe3477708ad2a8 src/logging/format.rs
21ddb01b8f9c7fab60840d18103ef8de4716ff0c890cc1525b472b69af7916ec src/logging/error_runtime.rs
add1293410e87ddae85ba4b8bec57b56ff54415b8112576c450f85920ab6c38c src/logging/format.rs
f5815d778cc1785723faed4abb15f9880f22b6524b5289fe1e7158905aac0986 src/logging/model.rs
42fef80af50735cd566c6801dad86ba81be98cf4a8ec7d45c2ea26f53f556a22 src/logging/runtime.rs
584782215ea78a88526cdb5515031d8d5b87910875e26c0024b93a06680f6791 src/logging/tests.rs
b9bef61d332df0dec47f15d864a7aaea240935c432bf935e40144916e37553bc src/logging/runtime.rs
106f769f45a832079b170e1cf11d7e6a78adba354bb0a88bfb8e3aff14f8cce6 src/logging/tests.rs
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs
fc8e99eac83c083d65101f6402aacbcc8392d66508c31566a50de3ebcb12e7da src/main/admin_runtime.rs
42a9197475823f67c1ca762951ede5d7a2f10124bd6ed4d1a3365f4297be894c src/main/bootstrap.rs
@@ -403,48 +337,49 @@ fc8e99eac83c083d65101f6402aacbcc8392d66508c31566a50de3ebcb12e7da src/main/admin
e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs
49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs
49b11df65b4fb0b5b727f9f867436584680b45e3f4fbd53f74bc0cab8b9a88ec src/metrics/vhost.rs
a54b5b34ea65f29723b89b81bf7968a238e8247b9c9760f3430d3504cb1017c9 src/metrics/vhost/tests.rs
78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs
e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs
a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs
c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs
d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
c46882ed26742d93806a976257674326e105b8ca1fc23f5ca81e317b268057aa src/prometheus/docker_misc.rs
74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs
d3c6b08088df0003144544936a55e2a3661aac85292821b594f00adfd19a2951 src/prometheus/model.rs
064b2be7fed2f6acf526b1cce070c1bb1b943d36f08fa50bbfcbdf3cf1c9318c src/prometheus/tests.rs
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs
fb02fb31ab62ac47c998d418fb8fe2f1d810a451b68805f31d6ecc244ae20820 src/proxy/body.rs
b8fb54359a7c60e5a8bd8ca45a5a64641633c595f0deb84d6d906c31fbbc1312 src/proxy/body/tests.rs
c3dcd88f4b00a9dbcbb65f6fa70ee2ab98c2fba20e9a2dde3b8852306111836f src/proxy/cache_helpers.rs
2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs
66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs
7586fc88e10381eb97f3be6a66f355d16088cc9481f50c193eee8fe169c4fc8e src/proxy/cache_helpers.rs
aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs
676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs
3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs
771cc1a38927f96e593b144dbab66cd64a5dc0ed42d54b1615c359845b2b7223 src/proxy/error_fallback.rs
bf2e6bbd64fb45fceed54af0bff1c1e00da22b79c64dd8f5c23d2af0dbab0b7d src/proxy/forward/cache_lookup.rs
a8848cbdf85368548b003af2b43823c453866c472af4c7d2ff0ec7da14c44c8d src/proxy/error_fallback.rs
f2f9cae1c74eaf66fc365c0709a6eb64e0b27457d4a00728e02cff8a641a1295 src/proxy/forward/cache_lookup.rs
cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forward/response.rs
b4be19f2dccc97270f3596d7e56dc60237b7d4d254cd9362a355812026d90295 src/proxy/forward/route_cache.rs
96c97928986ca89c2d6d415d5b107b9cc7228410bae93a2c08c24974324f4d8d src/proxy/forward/success_finish.rs
44e1bae0a35ddae6a084c1d50b936fd10dd51059f331525b758447b526a2bddf src/proxy/forward/route_cache.rs
691b54a0cc33089e67d61ffc715b5c015976bae3fbd453b26cb06764be2482e5 src/proxy/forward/success_finish.rs
0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs
a064cf54ee62a374c1753f7a64940990a591888efc4516a07942c52c4e0842e2 src/proxy/forward/upstream.rs
1f36225516caefc756a49bbdf37b227a7ea97b72ab78df021c9234e5c3dd41fc src/proxy/forward/web_source.rs
a9c011061585c067f04f7885215d53e6c40474bdced15d2be5a5ba65190910a0 src/proxy/forward_entry.rs
3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs
bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs
3b9f27445c637dbf093b07a5b4e77cbff4df2bab2ad622bb87b6772dab245ec6 src/proxy/forward_entry.rs
9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs
c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs
5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs
3f25429418941eb72c3847125340db74684ad316eb278ecf658953026fd41f4f src/proxy/mod.rs
cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/headers.rs
0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs
9ed6577b381285d2b7b874f60fd38d8192a0a5f3dbabdb5b737091158c43ac31 src/proxy/identity.rs
050c66311f71cac75c1d4c8547351a1d6f1111ed0db128691366fdd73647e90a src/proxy/mod.rs
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
c61738672f210e97333c8c89f9b35f8db454cbc1c36955288dfa4cfff2bbbc04 src/proxy/request_id.rs
6ff45990f6beb914e89924bec2c41022f3240a14357cf66ec4f8ada414a49767 src/proxy/request_id/tests.rs
8ad3619359f3befa14ea71ba43c638fea8f4d8a859cd07e4ed505731b8e32e72 src/proxy/tests/authority_tests.rs
2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs
b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs
977a28e0a90a72ac902591996fe97236240f0e05c5e668e7154f8f64b1b47ce0 src/proxy/tests/authority_tests.rs
1aa9bbbfb2659facdaeaf185e11221b1c8cd5fe6e23c7c1dacd3aa55e3db4fcb src/proxy/tests/cache_fast_path_tests.rs
0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs
591b47fc40cbcb36d7c71998933dc873340dfcf2657e16c561ab64361c4dbab6 src/proxy/types.rs
8b053027bbdbbebb624164d9c1b15b2b8f5a13b8a23a9e1477ef4e840ce0334f src/proxy/upstream_helpers.rs
fecc1c7ffff740caa7168370e59234a17b9836a6dce6d08b770ee9648c22709b src/proxy/types.rs
84713a5904f9687a72aafaff6f1dbfc997343a97a4f3031d498c6a4b01d7db93 src/proxy/upstream_helpers.rs
2d9142e3ae95895b5271b4009ab48236a3a2074ce51179c021193b35777560e4 src/proxy/web_cache_revalidation.rs
d7b4b809b12ee89293df45e9a64b1c9981fb3f89deaab3762f4a3ad3992dd7b1 src/proxy/web_cache_revalidation/tests.rs
69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs
@@ -458,21 +393,23 @@ d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.r
811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs
d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs
8d0d7b01871c8c588c40a54330a1053c85024655fbe38516ca6c94c3c19a8956 src/runtime/certificates.rs
8d2443df8dab1ce6f11878e991b4928046b7362c417c5703f6d1f3a977646cbe src/runtime/constructors.rs
2ccafa8a5a8462417f65d05a0c6c42f294afa4ad1067ea0b93623181546d1f96 src/runtime/constructors.rs
92b4b284f1b69dca58e4e3f76199f0aecc397eed26d10123b427af1bb8a4a57e src/runtime/docker.rs
6b2b57888863a396461fff202bfdaa6d8e869b2628cc171c0823f31f388e5178 src/runtime/tests.rs
4036b48925f5b25de70e21dfbb278488c20b39248dcda4cad4d19d5d7aed2b8d src/runtime/types.rs
e1d172acd2b230b8f4dd671cae8862a76f28416cbf0e43f86214a796edd90fab src/runtime/updates.rs
ee2050b9f8efb45a209c91d76043a1ba29c4289978896aaf7e7d344d48fdc369 src/runtime/tests.rs
779137f320133ec96f73c8b3d357ad4f0b825ee2367e9e8300f4f143b38eecb7 src/runtime/types.rs
5947afaf7d070ea2047eb7969a8e0d84f981e91ace283f6506cf7685fb8afab9 src/runtime/updates.rs
208a378b14ca80e1412ffa19ef1b140594182971e2e797ee0bb95bcdb8404da7 src/secret_store.rs
6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs
6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs
0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs
53a4bed4d1233aa434712b946857ea2050acaaf5de011c6dadca5b5c22155a83 src/server.rs
98c52aa3b50d2e211ab752ccaf353c9a8c1500a87f9c1684b5dd0ba9d10e4619 src/server.rs
f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/http.rs
8c6ca5eaffb9acab57199d0349722703b903cfff3752baad2cf2596bd51188de src/server/listeners.rs
164384468c933320abf1c656f1e07a687aa869f5794d3e5a9b429885b5272266 src/server/http1_connection.rs
cd33fa634a5f1ee1d425e9e78269f9575b2e324967115383c80c6883c878ccfe src/server/listeners.rs
4424a99b0b688d1119ee25626778b4804aedfaa0799a4a9bc0ee99bc5c7ba96b src/server/sendfile.rs
71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs
e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs
048f2c7a8ef698a172ba7ed844558f4118b367b2cf340748c5c28da7723d8e85 src/server/tls_runtime.rs
960a82e02f09d75141fe3c1eecca915b66f17fdc8167196cd2fe21d5e6859376 src/server/tls_runtime.rs
73ec453ac2850f6963f5bc33401cb8c92aa26d5123f15d68e6633860d91f265e src/settings.rs
240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs
1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs
@@ -496,8 +433,8 @@ d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_sta
2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/tests/acme.rs
93b359e9defd62506729bc90e56c96783e2f14dd477dd67f337ca83149da7168 src/tls/tests/external_paths.rs
10c4a187e3aeb0c72a8c8968ea0cc8bfae4e3cb2f3d834ebd3da1056c4171ec5 src/tls/types.rs
c2f802d95da4332fb8480e83f0c7502334457b3408141c1c4516c9ba320a0463 src/traffic.rs
36301bcbd20b557dd3d58e77a9175f3581947f577c0bb0b972899cec6786b03f src/traffic/tests.rs
6ae584944343b83ee3a2772cd93c985ddeaece7a9e6467b7cdbf5da22ee88028 src/traffic.rs
e796efe9d94e2e79220287196fe2d2d9f4a1c7640b9f71652d409478e25d3c09 src/traffic/tests.rs
d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs
87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs
b2c5236589093454ec60984b785ad6a4af6149d47f6db37d8e08c17fec7a2c1b src/transform/css.rs
@@ -516,7 +453,7 @@ d1f3f53fedae0d8ef69eeee457f7d30ed5a670a1ed4ced60683400ee46680260 src/transform/
c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs
92efcdaafb9f5a8514daf63d7fcde29be977d7bdcd90162eda0ce00d6967ffbd src/upstream.rs
ca695e246bee5389b3757fe8588e48bff5d0ad473bc8588ad4ba7b52c99d7dfa src/upstream/client.rs
ad29f3fc15c9b627b5d752af5adc9f99ee1364fec2df63f1e4509cb223988efb src/upstream/client_tests.rs
56c8a9d1c801be0f41b35c75cb42716c46976951948009c8707122c0a6a1eb58 src/upstream/client_tests.rs
75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs
8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs
a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs
@@ -528,9 +465,9 @@ a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent
9b16b5307e140db97363f56c9fac8319e56df846bb1c919d3bc06091ab6ce69c src/vhost/tests.rs
b7d0f7f5d0bbd74add3fda9b1308350c4f70f8b6debf979fb977a4f2f659c32f src/vhost/validation.rs
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
aa38e89e84f928bd1952d3e2cb67e48ef2e92c5f444c095b6288034adc5e3d3d src/web_server/config/compile.rs
7853f6ec127a91163a4418ca45b93a92f424d5c618f27b29f9d526598a26f7ef src/web_server/config/model.rs
d9178aa934ee1a7236cb04d864fa842faf64156e9103786e3103c92bc4e18fde src/web_server/config/validation.rs
9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs
bcaa1ae15134d85668b05a88d8abea01e5d188f535ad83cd4459732fd56f2f44 src/web_server/config/model.rs
a863ad1337fbc353c0f42caea4ac8b3644792e2aa08c37f992b7882b4b8dc6d5 src/web_server/config/validation.rs
86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs
200cba8cb3482e49db73d2c60cb60786175177c7d4be2a566cd281fec7ba7fca src/web_server/fastcgi/protocol.rs
cef44ccdf882d84892a64bdbfdc1ba01380fce5e5f86d2829b0f9613f738b964 src/web_server/fastcgi/response.rs
@@ -541,17 +478,17 @@ ca3cd376b313af1ce0ee65081b3327eb213f000d5a215aa4ee6b764de9702126 src/web_server
66260d159986d41eab0dfde2cb9f0713fc1cc209c2fb47e9023bd24fd37919e8 src/web_server/htaccess/rewrite.rs
8ca74d9cc4470045301f70eb4a9fbabd0dbfe74434429148d2230df9f996c06c src/web_server/htaccess/tests.rs
3341264f6e8041ac5a5d3c4b1601a69787386116019b5febc1a5e22bdb1186f9 src/web_server/metrics.rs
7b945065e636ca74147048aa53cc5b34028cced72a780fbaf0aa2499b00718fa src/web_server/mod.rs
0dbef9b71869fc52cf01e9cb400ffd48fb433ae625383edee6c6c2d735608a76 src/web_server/mod.rs
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
d98c80663847d6d83073df074ebff65952755415574708abcc153bd8d3adfaa9 src/web_server/response.rs
c89e412584cf524cd1cd332ce555ff922a9e5549cd5d20b2e9a29393456be889 src/web_server/response/file.rs
833c9fea744b9649046e1a64d6fc6972fbf6cea640e1943b0db52b7ccfa9faad src/web_server/response/file.rs
ba793a140699956a9aef6bc79b0c731c481af8ab7ac3d816a28695743febcb30 src/web_server/response/file_body.rs
c2e54fd803a10f74490f183e8c030fd0e3e72f5de4e79c55bc393ab57d90ab9a src/web_server/response/file_body/tests.rs
20509f9302b79d3a7be76dda6edaf5711826d3ddf47d19ebe07cb289427ca328 src/web_server/response/headers.rs
305c0109b4f869a99381a142853f9e2205f5c7c4f904b2dfa4643274bfc542af src/web_server/routing.rs
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
54cf72a82e023fe46513b9cf3130adfef1cd96c40f790c533016b4825d542e06 src/web_server/serve.rs
bda82f531f61251772d3bb4bc67d98ca613aebd51b489b7cac1fd5086c653b1b src/web_server/tests.rs
23904fc0ca6d5e816b332303b148a2ec5ea3b226b5ff8f68db05c42954472a3e src/web_server/tests.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
+34
View File
@@ -0,0 +1,34 @@
# Access, authentication and traffic protection
Access control can be configured globally, on a Vhost and on selected Custom locations. Policies are **cumulative**: a more specific allow rule does not cancel a denial at a broader level. Changes should be tested with both an allowed and a denied client.
## IP, country and DDNS rules
- **IP/CIDR ACL:** allow or deny IPv4/IPv6 networks. Use reusable ACL profiles for settings shared by several domains.
- **DNS/DDNS:** an ACL `allow` rule may use a hostname, such as `allow office.example.ddns.net`; `deny` with DNS is not supported. Records are refreshed periodically; unknown, failed or expired resolutions authorize nobody under the allow rule. This is appropriate for a trusted, controlled DDNS record, not a replacement for login.
- **Country access:** allow-only or blocking based on the configured GeoIP database. Allow-only mode fails closed when a country cannot be determined. Keep the GeoIP database updated in **Settings → GeoIP updates**.
- **Cloudflare client IP:** enable the integration only when traffic really arrives through a controlled Cloudflare/trusted-proxy boundary. Never trust arbitrary client-supplied forwarding headers as an identity proof.
Default deny plus explicit allow exceptions is usually safer for private admin applications. Apply rules to a staging client before enforcing them on a live business-critical service.
## HTTP methods and rate limits
Method policies allow, block or leave methods unrestricted. A denied method returns `405`, while typical ACL/country denials return `403`. Per-client rate limits can configure request count, window and burst with optional country overrides; exceeding the limit returns `429` with `Retry-After`. Limits are application controls, not a replacement for upstream DDoS protection.
## Basic Auth and Tinyauth
- **Basic Auth:** protect selected Vhosts with configured credentials. Use HTTPS so credentials are not sent over plaintext.
- **Tinyauth:** add a verification URL and an HTTPS login URL. RustProxy makes a bounded, body-free authorization request and can pass specifically allowed identity headers upstream. An origin URL can use the service's conventional `/api/auth/nginx` path. Tinyauth has its own timeout and private-backend policy.
- The two authentication integrations should not be enabled simultaneously for the same Vhost. Protected responses bypass unsafe shared caching. Confirm that login redirections and WebSockets behave as expected.
Do not use a User-Agent blocklist as the primary access control: HTTP clients can forge their User-Agent freely.
## Headers, sessions and internal services
Custom request/response headers support set/append/remove within allowed names. Security headers can be configured at Vhost scope; check interactions with HSTS and CSP in browsers. Hostnames, forwarding identity, request IDs, hop-by-hop framing and WebSocket negotiation are protected by validation. Never expose the administration HTTP listener through a public Vhost just to bypass its local-access default.
**Streams are separate.** TCP/UDP Stream listeners do not run HTTP auth, GeoIP Vhost rules, headers, cache or TLS termination. Restrict them with firewall rules and protections on the actual protocol/backend.
## Practical verification
Test allowed/denied IPs, private application login, denied HTTP methods, country policies, websocket handshakes and rate-limit recovery. Use [Logs](LOGS.md) to identify `403/405/429` responses. For origin-side client IP settings, see [Configuration](CONFIGURATION.md) and [Security](SECURITY.md).
+41 -116
View File
@@ -1,139 +1,64 @@
> 0.7 update: configured managed certificates now have automatic renewal and
> validated hot deployment. Initial issuance/import remains explicit. Read
> [RENEWAL.md](RENEWAL.md) for defaults, retained profiles and opt-out.
# Certificates, ACME providers and renewal
# Certificate providers
Certificates are managed under **Certificates**. You can import a PEM pair, reference accessible external certificate files or request a managed ACME certificate. Issuing a certificate does **not** automatically assign it to a Vhost; choose the certificate in the intended frontend/Vhost afterwards.
Source implementation; real provider/DNS/CA issuance and Rust runtime have not been
verified in the packaging environment. Offline tests substitute Certbot, so they
cannot establish that a DNS token, zone policy or CA deployment works.
## Supported certificate methods
## Workflow
Open Certificates -> Add certificate. Choose one provider or import mode, enter
the domains and metadata, then request issuance. Production is selected by default
in the panel and still requires the separate explicit production confirmation. Staging
remains available for testing and its certificates are not browser-trusted.
After a successful job, import its validated pair into the library, then assign the
certificate to a vhost. No CA operation occurs on startup or merely opening a form.
| Selection | Mechanism and required configuration |
| Mode | Requirements |
|---|---|
| HTTP-01 / domain validation | Certbot webroot; domain names and a publicly reachable plaintext HTTP listener |
| Cloudflare | DNS-01; scoped API token; `certbot-dns-cloudflare` installed |
| OVH | DNS-01; `ovh-eu` or `ovh-ca`, application key/secret, consumer key; `certbot-dns-ovh` installed |
| RFC2136 | DNS-01; server/port, TSIG name/secret/algorithm; `certbot-dns-rfc2136` installed |
| PEM import | Upload fullchain/certificate and corresponding private key, then validate |
| Existing server files | Advanced: reference a PEM pair already in the certificate directory |
| **HTTP-01 (Let's Encrypt)** | DNS for every name and a public TCP port **80** reaching a plaintext RustProxy listener; HTTP challenge endpoint is handled by RustProxy. |
| **Cloudflare DNS-01** | A zone-limited `Zone:DNS:Edit` API token and functioning Certbot Cloudflare plugin. |
| **OVH DNS-01** | Correct regional API endpoint plus application key/secret and consumer key scoped to the DNS zone. |
| **RFC2136 DNS-01** | DNS server, TSIG name/secret/algorithm and an update policy restricted to the requested records. |
| **PEM import** | A matching certificate chain and private key, validated before activation. |
| **Existing local files** | Trusted absolute certificate/key paths accessible to the RustProxy service account. Suitable for a separately managed Certbot lineage. |
RustProxy now owns its preferred Certbot environment under `<data-dir>/acme-python`.
When it is missing, RustProxy creates a virtual environment and installs pinned Certbot
5.8.0 plus `certbot-dns-cloudflare`, `certbot-dns-ovh` and
`certbot-dns-rfc2136` 5.8.0 with pip. No distribution Python files are modified and
RustProxy never executes sudo. If the stdlib `venv` module is unavailable but system
pip works, `virtualenv` is bootstrapped only inside the RustProxy data directory.
HTTP-01 cannot create wildcard certificates; use DNS-01. A staging certificate is for testing and is not generally trusted by browsers. Production issuance requires explicit confirmation of the production operation and provider terms.
If local setup fails, a runnable system `certbot` from `PATH` is used as fallback. The
Certificates panel reports whether the managed environment, the system fallback, or no
ACME runtime is active. Systems without Python (for example some appliance layouts)
retain PEM import and existing-file certificate modes even though ACME issuance/renewal
is unavailable. `bash dev.sh acme --doctor` and `certbot plugins` inspect only the
current shell/system fallback; they do not validate provider credentials or DNS reachability.
## First issuance and assignment
The shipped OCI image contains Python, venv/pip and distribution Certbot packages as an
offline fallback. Its preferred managed environment is still created in the persisted
`/var/lib/rustproxy` data volume.
1. Open **Certificates → Add certificate** and choose the provider/import method.
2. Enter DNS names, a unique certificate ID, contact email where requested and provider-specific credentials or files.
3. For HTTP-01, confirm public DNS and HTTP port 80 reach RustProxy even when a separate public HTTPS port is active. For DNS-01, validate authoritative DNS provider and propagation permissions.
4. Run staging first when testing new credentials/DNS, then confirm production issuance deliberately.
5. Wait for the job result; inspect errors under Certificates and the service logs.
6. Assign the issued/imported certificate to the intended Vhost/listener and verify a real TLS handshake with the public hostname.
## HTTP-01 requirements and scope
The reserved HTTP-01 challenge path is available only during eligible issuance/renewal work. It is not a generic public file server and does not bypass authentication for unrelated URLs.
Public TCP port **80** for every requested name must reach an active plaintext
RustProxy listener. NAT from public 80 to a private/custom HTTP port is allowed;
setting only port 8080 in public DNS is not sufficient. Check all published A/AAAA
addresses and multi-node routing. TLS-only listeners are not enough. There is no
TLS-ALPN-01 mode. This implementation accepts DNS names, not IP certificates.
Wildcard certificates require one of the DNS-01 providers, not HTTP-01.
## Managed ACME runtime
During a running HTTP-01 job, RustProxy serves only
`/.well-known/acme-challenge/<token>` for that job's exact normalized hostnames.
Only GET/HEAD and bounded regular files from `certs/acme-http01` are accepted;
queries, traversal, unrelated names, HTTPS requests and inactive jobs cannot read
these files. The reserved path precedes application auth/cache/static pages so a
CA can validate ownership without logging in. This exception does not bypass
application authentication for any other path. Responses are no-store.
RustProxy prefers a **private Certbot/Python environment** under the application data directory (typically `/var/lib/rustproxy/acme-python`), with support for the Cloudflare, OVH and RFC2136 plugins. A working system Certbot may be used as fallback; systems without a usable runtime can still import or reference PEM files. The application does not run system-wide `sudo` to install plugins or modify distribution-owned Python packages.
A CDN or upstream proxy must forward this path and the correct Host without
rewriting it. There is no automatic forwarding configuration at your DNS/CDN vendor.
Expired challenge jobs do not leave an open generic file server.
Provider tokens and TSIG secrets are security credentials: use least privilege, protect files and the data directory, rotate compromised values and restrict backups. ACME DNS credentials may be stored in private provider/Certbot files; do not assume they are stored in the same way as RustProxy administration Secret Store records.
## DNS credentials and least privilege
## Automatic renewal
Cloudflare: use an API token with `Zone:DNS:Edit` restricted to the zones used for
issuance. The adapter intentionally does not accept the account-wide Global API Key.
Managed certificates have configurable automatic renewal. RustProxy runs the scheduler while the service is active; this is **not** a separate cron service. Certificate status and next checks are visible in Certificates. The scheduler uses spaced checks and retry/backoff on failure, without automatically forcing a CA reissue on every check.
OVH: use a consumer key allowing `GET /domain/zone/` and GET/PUT/POST/DELETE for
`/domain/zone/<YOUR_ZONE>/*`. Repeat only for required zones, not the whole account.
Choose the region matching the account. Consult the official plugin docs if API
policies change. A domain registered at OVH but DNS hosted elsewhere must use the
actual authoritative DNS provider; registration alone is not sufficient.
After successful renewal, RustProxy validates the new pair and publishes it to new TLS handshakes without interrupting existing connections. Invalid/rejected material should leave the previous active pair in place. Keep room for archived ACME generations and monitor expiry; this is not an independent external expiry-alert service. **Do not run an overlapping system Certbot timer** against the RustProxy-managed ACME profiles.
RFC2136: retain the TSIG-limited update policy described in ACME_RFC2136.md. DNS
provider propagation delay is configurable. The helper rejects malformed tokens,
unknown/cross-provider fields and INI injection. It does not test your DNS policy
in advance. Wildcard DNS-01 issuance does not add wildcard HOST routing: vhost
hostnames remain exact matches.
## Externally managed Certbot / PEM files
## API input
When another service owns the certificate, add a manual certificate with the actual certificate and key paths, for example:
Common fields: `provider`, `certificate_id`, `domains` (one or more DNS names), `email`,
`environment` (`staging`/`production`), `agree_tos`, `confirm_production`. RustProxy
does not impose its own domain-count ceiling; the selected CA/provider may still
apply its external certificate/SAN policy.
DNS providers accept `propagation_seconds`.
```text
/etc/letsencrypt/live/example.com/fullchain.pem
/etc/letsencrypt/live/example.com/privkey.pem
```
Provider-specific fields:
Keep the original Certbot renewal system and use the RustProxy certificate reload/deploy API or local CLI after rotation. RustProxy resolves relevant symlinks again during reload. The service account must have directory traverse and narrow read permissions to the **current** key file; `ReadOnlyPaths` alone does not grant filesystem permission. Renewal hooks may need to reapply ACLs to replaced target files.
- `http01`: none; wildcard domains are rejected.
- `cloudflare`: `api_token`.
- `ovh`: `endpoint`, `application_key`, `application_secret`, `consumer_key`.
- `rfc2136`: `dns_server`, `dns_port`, `key_name`, `key_secret`, `key_algorithm`, `sign_query`.
### Restricted certificate API
`POST /api/acme/issue` is authenticated and CSRF-protected; one job runs at a time.
`GET /api/acme` is safe status metadata only. Job status is process-local; account
and credential files persist. A completed certificate has optional issuer metadata
with provider/environment, not a copy of request credentials.
Create an appropriately scoped credential in **Settings → Certificate API**. Its allowed certificate IDs and action permissions control **reload** or **deploy**; it is not a general administration token. A caller supplies both a Bearer credential and its configured `X-RustProxy-Certificate-Key` ID. Use HTTPS outside local/trusted transport and never store the clear token in the repository. See [Administration API](API.md).
## Secret storage, renewal and limitations
## Problems to check
Provider secrets are sent to the private worker on stdin, not command arguments.
They are not stored in exported JSON, SQLite, localStorage or audit records.
Files are private (0600 credentials, 0700 directories); these permissions do NOT
encrypt them. The service user/root can read them. Certbot account/lineage state
and provider credential files persist for managed renewal under
`certs/acme/<environment>-<id>-<provider>`; RFC2136 keeps its old path without a
provider suffix. Issued PEM outputs use new immutable names before library import.
- **HTTP-01 validation fails:** public TCP 80, all A/AAAA DNS answers, Host forwarding and challenge path handling through any CDN/router.
- **DNS-01 fails:** authoritative DNS provider, correct zone, TSIG/API scope and propagation interval.
- **Certificate available but wrong site certificate displayed:** Vhost assignment, listener binding, hostname and restart/reload state.
- **Renewal stops:** Certbot runtime, private profile files, DNS secrets, clock skew, permissions and CA limits.
- **External file reload fails:** rotated symlink targets, certificate/key match, SAN coverage and read permissions.
Back up these private profiles separately while issuance is idle: the standard
SQLite/configured-PEM backup does not include every account/credential profile.
Do not put real credentials into shell history or source-control examples.
Restrict the admin interface and service environment. Rotate exposed API credentials.
Configured managed certificates in 0.7 renew through the shared RustProxy worker;
validated changed material is published automatically while failed checks preserve
the active generation. Use the certificate's toggle to disable scheduled renewal.
PEM-only imports remain manual. A separate system Certbot timer may own a **different,
manual RustProxy certificate id** and publish it with `tls deploy --id ...`; it must not
also own a RustProxy-managed ACME profile. There is no external expiry alert, revocation
or certificate garbage collector. Cancellation
kills the worker group; interrupted DNS cleanup can leave TXT records an operator
must remove. See [renewal and recovery](RENEWAL.md) and test CA staging first.
## Primary references
- HTTP-01/DNS-01 constraints: https://letsencrypt.org/docs/challenge-types/
- Certbot webroot and operational use: https://eff-certbot.readthedocs.io/en/latest/using.html
- Cloudflare plugin/API scope: https://certbot-dns-cloudflare.readthedocs.io/en/stable/
- OVH plugin/API scopes: https://certbot-dns-ovh.readthedocs.io/en/latest/
- RFC2136 plugin: https://certbot-dns-rfc2136.readthedocs.io/en/stable/
These document upstream requirements, not evidence that this RustProxy build has
passed live provider or CA acceptance.
Keep a private encrypted application backup and a separate copy of any external certificate/key material. See [Backup and recovery](BACKUP.md) and [Security](SECURITY.md).
-160
View File
@@ -1,160 +0,0 @@
# Basic Let's Encrypt / RFC2136 integration - historical 0.5.0 record
> Historical implementation record. For current certificate-provider behavior, use [ACME_PROVIDERS.md](ACME_PROVIDERS.md) and [RENEWAL.md](RENEWAL.md).
For the multi-provider UI, HTTP-01, Cloudflare and OVH, see ACME_PROVIDERS.md.
Managed automatic renewal added in 0.7 is documented in RENEWAL.md.
The RFC2136 fields and private account paths below remain supported.
## Scope and prerequisites
This is an integrated control-plane adapter for **Certbot + certbot-dns-rfc2136**,
not a newly written ACME or DNS-update protocol client. The plugin creates and
cleans up DNS-01 TXT records through TSIG-signed RFC2136 updates. It must be
installed separately. No real CA issuance or authoritative-DNS update has been
performed in the packaging environment.
Rust compilation, process integration and actual certificate deployment remain
acceptance work (TEST_REPORT.md).
Use Linux/Unix with Python 3.10+, a current compatible Certbot/plugin installation,
and `python3` and `certbot` available in the RustProxy service's PATH. Check:
```bash
bash dev.sh acme --doctor
certbot --version
certbot plugins --authenticators
```
The doctor command reports executable discovery and the required plugin's name;
it does NOT verify the plugin, DNS permissions or network connectivity. Nothing
is installed automatically, and RustProxy never invokes sudo. A service hardening
policy must allow child processes, the cert directory, DNS updates/queries to the
chosen server and outbound HTTPS to the CA. Do not run the proxy as root just to
make Certbot available. Use a private, service-owned cert directory (0700).
The operator must control publicly valid DNS names and an authoritative DNS
server that permits the configured TSIG identity to change **only the necessary
_acme-challenge TXT names**. Do not grant zone-wide update authority merely for
certificates. A private update-server IP is acceptable when the resulting TXT
records are visible to the public validation resolvers. Split DNS/views, CAA,
replication delays and delegation must be checked on the operator's DNS setup.
DNS-01 does not require opening the application's HTTP listener for a challenge.
## Panel workflow
Open **Certificates > Let's Encrypt / RFC2136**. Enter a certificate library ID,
contact email, comma-separated DNS names (including optional `*.example.org`),
DNS update server IP/port, TSIG name, base64 secret, algorithm and propagation
wait. ASCII/punycode DNS names only; up to 16 names. SHA256 is the default TSIG
algorithm; SHA384/SHA512 are also allowed. SHA1/MD5 are not exposed. A TSIG key
name may be a single label such as `keyname.`. Optional signed SOA queries support
DNS servers/views that require them. Port defaults to 53; propagation defaults
to 60 seconds (allowed 10..900).
Start with **staging**. Its certificates are not publicly trusted. Production
requires a separate explicit confirmation, in addition to agreement with the
Let's Encrypt subscriber agreement. The CA directory is fixed for each mode;
arbitrary directory URLs, shell hooks and arbitrary extra Certbot options are
not accepted by this interface.
Requesting starts one bounded asynchronous job, not a proxy-path operation. Refresh
shows running, failed, or ready. At ready, the Rust control plane has checked PEM
parsing, matching key and every requested DNS name before exposing only metadata.
Click **Add issued certificate to library**, then select its ID in the vhost SSL
tab and save. Replacing an existing ID requires confirmation and retains its
legacy SNI host assignments; the whole current configuration is revalidated with
revision CAS. Failed issuance/validation/import leaves active configuration alone.
Certificates are loaded for new handshakes; changing listener protocols still
requires restart. Existing connections retain their negotiated certificate.
## Renewal and operator responsibilities
Configured RFC2136 managed profiles participate in the 0.7 renewal scheduler.
Validated changed certificate material is published automatically; there is a
per-certificate opt-out, explicit Check now and Dry run. Initial issuance/import
remains explicit. The old 0.5 helper alone was issuance-only; do not confuse
retained files or an unrelated OS Certbot timer with RustProxy publication.
See RENEWAL.md for prerequisites, timing, rollback boundaries and legacy profiles.
No external expiry notification, revocation UI or account manager is included.
Account state and TSIG credentials are kept privately under:
```text
data/certs/acme/<staging-or-production>-<certificate-id>/
rfc2136.ini # SECRET, mode 0600
profile.json # non-secret parameters, mode 0600
config/ # Certbot account, renewal, live and archive data
work/
logs/ # private Certbot diagnostics
```
The location follows the configured certificate directory, not always `data/certs`.
There is one job per RustProxy process and a filesystem lock per profile. Bounds:
16 KiB request, 128 profiles, 128 staged ACME pairs and 128 KiB per PEM file. The
provider timeout is 1200 seconds plus propagation wait; the Rust worker has a
2160-second outer deadline and stops its process group on cancellation/shutdown.
Normal TXT cleanup belongs to the plugin. A hard kill, machine crash or DNS error
can leave challenge TXT records: inspect DNS and clean only the failed challenge
values, not unrelated records. Only the latest job status lives in memory; restart
loses that status, not the private Certbot files.
TSIG/API credentials are supplied through stdin to the helper, never argv, exports
or job responses. RustProxy encrypts DNS-provider credentials in the Secret Store;
Certbot-compatible `.ini` files are recreated with private permissions only for the
external operation and removed afterwards. The browser clears submitted secret
fields and does not put them in local/session storage. Protect the Certbot account
state and private logs as secrets.
Use `rust-proxy backup create` for current installations. The encrypted full backup
includes the durable ACME profile/account state, certificates and Secret Store key
material needed for renewal. The historical `dev.sh backup` helper is not sufficient
after Secret Store migration. See SECRET_STORE_BACKUP.md.
## API and local helper
`GET /api/acme` returns the latest sanitized job, provider name and
`automatic_renewal: false`. `POST /api/acme/issue` requires the normal authenticated
session, Origin/CSRF protections and an explicit bounded JSON request:
```json
{
"certificate_id": "example",
"email": "operator@example.org",
"domains": ["example.org", "*.example.org"],
"environment": "staging",
"agree_tos": true,
"confirm_production": false,
"dns_server": "192.0.2.53",
"dns_port": 53,
"key_name": "rustproxy.example.org.",
"key_secret": "REPLACE_WITH_YOUR_PRIVATE_BASE64_TSIG_SECRET",
"key_algorithm": "HMAC-SHA256",
"sign_query": false,
"propagation_seconds": 60
}
```
This documentation example is intentionally not an executable credential. A
successful job submission returns 202; busy returns 429. Invalid input, consent
and provider failures use fixed `acme.*` diagnostics rather than returning
provider stderr or credentials. Look in the private profile logs for DNS/plugin
errors. A 202 response means accepted, not issued.
Advanced local staging without the running API:
```bash
# request.json must be private (0600), with real operator-owned parameters.
bash dev.sh acme --cert-dir /absolute/private/cert-directory < request.json
```
This CLI only stages files and prints metadata. Unlike the integrated API, it
cannot run Rust's key/SAN validation on its own and never activates a certificate.
Import through the normal validated library/config workflow. Do not post secret
JSON into shell arguments, source examples, tickets or logs.
## Primary references
- Plugin: https://certbot-dns-rfc2136.readthedocs.io/en/stable/
- Certbot CLI: https://eff-certbot.readthedocs.io/en/stable/man/certbot.html
- Staging: https://letsencrypt.org/docs/staging-environment/
- Challenge background: https://letsencrypt.org/docs/challenge-types/
+47 -669
View File
@@ -1,686 +1,64 @@
# Administration API
# Administration API and configuration automation
The API is served on the separate administration listener, normally
`http://127.0.0.1:9090`. It is never mounted on reverse-proxy HTTP/TLS listeners.
Loopback HTTP is the default. Settings can enable administration HTTPS, choose a
certificate and persist a non-loopback bind; plain remote HTTP requires the explicit
startup `--allow-remote-admin` gate. Responses carry no-store, CSP and other browser
protection headers.
RustProxy exposes an authenticated administration API on the **separate administration listener** (normally `127.0.0.1:9090`). Open **`/docs → API`** after signing in to view the bundled OpenAPI/Swagger schema, including request bodies and available actions. That schema is the endpoint reference; server validation remains authoritative.
## Public-vhost HTTP cache invalidation (opt-in)
## Selected API endpoints
`PURGE /path?query` and `BAN /prefix` operate on the normal public vhost
listener and **do not** use an admin session. They are independently denied by
default unless the administrator explicitly populates the cache's
`http_invalidation_allowed_cidrs` allowlist in **Settings → Cache**. Authorization
uses the existing trusted client-IP forwarding logic, vhost/host ACL and rate
limits. BAN is a lazy prefix rule; PURGE evicts an exact URL. Both are restricted
to the request scheme (`http` vs `https`), Host and matched vhost. A shared
two-worker maintenance guard also bounds administrator-driven invalidation;
additional requests receive 429. Ambiguous duplicate control headers and
request bodies are rejected. See
[CACHE](CACHE.md#http-ban--purge-from-authorized-client-ips) for request examples,
scoping, confirmation and reverse-proxy requirements, and
[0.31.0 security review](SECURITY_AUDIT_0.31.0.md) for audit findings. The authenticated
`/api/cache/invalidate` endpoint remains unchanged.
The current bundled `src/admin/openapi.json` declares these operations (among others):
## Browser documentation and anonymous surface
Open `/docs` **on the administration listener** after signing in. The default **Reader**
shows the bundled project Markdown documentation; the separate **API** tab renders the
OpenAPI/Swagger interface. The documentation bundle, OpenAPI JSON, JavaScript, styles and
Swagger vendor assets all require the same session. API writes are opt-in and CSRF-protected.
See [API_ACCESS.md](API_ACCESS.md).
Only GET/HEAD `/`, the panel routes `/overview`, `/vhosts`, `/streams`,
`/acl-profiles`, `/web-server`, `/configuration`, `/tls`, `/cache`, `/logging`, `/settings`,
`/assets/login.js`, `/assets/login-langs.json`, `/assets/style.css`, `/favicon.svg`,
`/healthz`, `/api/auth/status`, and POST `/api/auth/setup`, `/api/auth/login` are
anonymous. Panel routes serve the same guarded shell as `/`, so a browser refresh
preserves the selected subpage instead of falling back to the landing page. Root
optionally authenticates and selects login or operational HTML. `/healthz` is exactly `{"status":"ok"}`.
Unknown/private paths and method mismatches return a generic 401 without a session.
Only top-level `/docs` and `/docs/` navigations redirect to `/?next=docs`; API/schema
fetches never redirect to login HTML. Other invalid Host/Origin checks can return 403.
## Authentication
| Method / path | Input or result |
| Method and path | Purpose |
|---|---|
| `GET /api/auth/status` | Public `setup_required` boolean only; no version or operational state |
| `POST /api/auth/setup` | One-time `{token, username, password}`; creates no session |
| `POST /api/auth/login` | `{username, password}`; sets session cookie and returns session data |
| `GET /api/auth/session` | Username, CSRF token, absolute expiry and idle timeout |
| `POST /api/auth/logout` | Revoke the current session |
| `POST /api/auth/logout-all` | Revoke all administrator sessions |
| `POST /api/auth/password` | `{current_password, new_password}`; changes password and revokes sessions |
Mutations, including setup/login, require `Origin` equal to the active administration scheme plus `{Host}` and
`X-RustProxy-Request: 1`. Authenticated mutations also need `X-CSRF-Token` from
session data. Send JSON as `application/json`. A permanent bearer access token
is not supported; the old setup capability does not authorize API reads/writes.
The `rustproxy_session` cookie is HttpOnly, SameSite=Strict and Path=/ with the configured
7–30 day absolute lifetime (default 30 days); server-side revocation still applies. HTTPS
administration adds the Secure attribute; behind a trusted HTTPS-terminating reverse proxy
the external forwarded scheme controls that flag. A protected SSH/VPN tunnel remains valid,
while direct remote administration should use HTTPS plus firewall/private-network policy.
Direct IP/localhost Host values must use the actual administration port. A DNS name explicitly listed in `administration.allowed_hosts`, or automatically
derived from the validated `administration.public_origin`, represents the
browser-facing authority and may use the trusted reverse proxy's external port. Foreign Origin/cross-site requests are rejected.
There is no permissive CORS mode.
Auth request bodies are limited to 4096 bytes.
Typical errors include 401 for missing/invalid credentials, 403 for origin/CSRF
rejection, 409 for already-completed setup, 422 for password policy and 429 for
throttling/busy workers. Error objects usually contain an `error` code/string;
framework extraction failures are not guaranteed to have that JSON shape.
## Administrator Overview layout presets (0.33.7)
The dashboard preset endpoints are private control-plane routes and use the
existing administrator session cookie. The same Origin, request-marker and
CSRF rules as other settings writes apply. They do **not** change the proxy
configuration revision and cannot be called from public vhosts.
| Method / path | Request or result |
|---|---|
| `GET /api/dashboard-presets` | Array of named layouts for the authenticated administrator |
| `PUT /api/dashboard-presets/{name}` | Create/overwrite a named layout; JSON body described below; returns 204 |
| `PATCH /api/dashboard-presets/{name}` | Rename using `{ "name": "new name" }`; returns 204 |
| `DELETE /api/dashboard-presets/{name}` | Delete the named layout; returns 204 |
A layout is `{ "dashboard_widgets": ["..."], "dashboard_hidden_widgets": ["..."],
"dashboard_widget_sizes": null, "dashboard_density": "compact",
"overview_samples": 600 }`, using registered widget IDs and the same
width/density validation as administrator view preferences. History accepts
60, 120, 300 or 600 samples. For sizes, `null` preserves legacy grouped
placement; an object maps widget IDs to valid 12-column spans. The account
may store at most 16 named layouts; names are 1-64 UTF-8 bytes with no
control characters, surrounding whitespace or slash. Invalid values receive
422; full capacity or a conflicting rename receives 409. Unknown delete
returns 404. All write endpoints require a session-bound CSRF token.
The data resides in SQLite schema 10 (`dashboard_presets`), separate from
`admin_preferences`; the factory preset is not stored as a mutable row.
The browser saves the chosen history sample count using its existing local
preference when loading a preset. The client does not use JSON configuration
files to store named presets.
## Configuration and observations
| Method / path | Result |
|---|---|
| `GET /healthz` | Public process health/version; not a backend health probe |
| `GET /api/config` | `{revision, config}` |
| `GET /api/config/history` | Retained revision metadata; `?revision=N` returns that document plus the previous retained document |
| `POST /api/config/history/restore` | Input `{revision, expected_revision}`; validates the older retained document and publishes it as a new configuration revision |
| `PUT /api/config` | Input `{expected_revision, config}`; returns the applied envelope; retained for atomic multi-section changes |
| `PUT /api/config/{section}` | Input `{expected_revision, value}`; replaces one validated section and returns the applied envelope |
| `GET /api/backends` | Authenticated per-vhost backend observations and active/desired frontend plans |
| `GET /api/status` | Uptime, active revision, configured counts, listener addresses, feature flags, aggregate metrics, active/desired TCP/UDP streams with per-stream L4 counters, active/desired administration state, TLS policy/cipher inventory and structured-log sink counters |
`GET /api/status?view=live` returns only volatile telemetry suitable for SSE fallback, without the heavy full configuration/OS/runtime objects. `GET /api/system-info` returns cached system diagnostics, host/runtime metadata and memory accounting under `system`, `runtime` and `memory_accounting`. Both are protected by the same cookie/origin admin guard as full status.
`status.streams` contains `active`, `desired`, `restart_required` and a `metrics` map keyed by startup-active stream ID. Stream counters include active/total/completed sessions, rejects, errors, RX/TX bytes and UDP packet counts; the dashboard derives rates from byte-counter deltas.
| `GET /api/logs` | Recent bounded access entries, ring capacity and contention-drop count |
| `GET /api/audit` | Last 100 audit entries; database retention is 10,000 |
| `GET /api/events` | SSE `snapshot` events containing status and logs; the compact status includes `live_system` for CPU/RSS/network charts |
| `GET /metrics` | Prometheus text exposition |
| `GET /api/metrics` | Alias of `/metrics` |
Except for health and authentication/bootstrap routes, these endpoints require a
session. These administrative previews are not anonymous. The separate configurable
exporter uses its own CIDRs and optional bearer token; see the scrape section below.
SSE has eight viewer slots, generates a snapshot every 1/2/3/5/10 seconds per authenticated client (via `?interval=`, default 3)
and rechecks session validity. It does not queue an unlimited per-viewer event log
or guarantee replay. The browser falls back to compact status/log polling only after SSE fails and stops polling after its recovery. Invalid SSE intervals are rejected with 422. An active dashboard renews
idle activity, not the absolute session deadline.
Configuration history, introduced in SQLite schema 8 and retained in current schema 10, keeps the newest 512 successful manual/configuration revisions. `GET /api/config/history` is session-protected like the rest of the administration API; `limit` is bounded to 512 and `revision` selects one retained revision for diff/inspection. `POST /api/config/history/restore` also requires the normal CSRF/request-marker protections, rejects stale `expected_revision`, and never rewinds the revision counter: restoring revision 85 while current revision is 120 creates revision 121.
Configuration updates return 409 for a stale revision, 422 for validation failure
and 429 when the eight-operation update admission bound is reached. Persistence and
snapshot publication finish before a successful response. Disconnecting the caller
after the detached writer starts does not cancel an accepted write.
The document limit is 256 KiB, with an additional 1024-byte request-envelope allowance.
For ordinary Settings saves prefer `PUT /api/config/{section}`. Supported sections are
`listeners`, `response`, `cache`, `logging`, `forwarding`, `headers`, `prometheus`, `geoip`,
`administration`, `tls`, `vhosts`, `web-services`, `redirects`, `not_found_hosts`, `streams`, `acl-profiles` and `host-policies`.
Each section write still uses revision CAS, full validation and atomic persistence.
Only a change to the derived cache-compatibility identity purges hot/cold cache;
unrelated section saves do not.
## Configuration fields
The canonical document uses `vhosts`, `web_services`, `redirects`, `listeners`, `administration`, `tls`, `logging`,
`prometheus`, `geoip_updates`, `cache`, `compression`, `server_header`, `headers`, `host_policies` and other defaulted
settings. Unknown fields are rejected. `backends`/`routes` at the top level are
input-only legacy migration fields, omitted from saved canonical documents.
```json
{
"vhosts": [{
"id": "application",
"frontend": {
"hosts": ["app.localhost", "alias.localhost"],
"listeners": [{"address":"127.0.0.1:8080", "tls":false, "http2":false}]
},
"backends": [{"id":"application", "url":"http://127.0.0.1:9000", "allow_private":true}],
"routes": [{"id":"root", "path_prefix":"/"}]
}]
}
```
The backend service must already exist. Routes without `backend_id` select from
the vhost pool; an explicit ID pins that path to a member of the same pool.
`vhosts[].force_ssl=true` redirects matching cleartext requests with HTTP 308 to the
same hostname/path/query. Force SSL requires both a selected plain HTTP listener and a
selected TLS listener. Port 443 is preferred when several TLS ports are selected;
otherwise multiple non-standard TLS ports are rejected as an ambiguous redirect target.
`vhosts[].forward_client_ip` defaults to true. Set it to false only when an application
must see RustProxy's transport peer instead of the visitor: backend requests then omit
`Forwarded`, `X-Forwarded-For` and `X-Real-IP`, while `X-Forwarded-Host/Proto/Port/URI`
remain and RustProxy/Tinyauth still use the real client address. See examples and
VHOSTS.md. HTTPS upstream certificate verification is disabled when `tls_verify` is omitted. Set `tls_verify: true` to validate the origin certificate chain and identity. For verified IP-based origins, use a DNS `tls_server_name` present in the
certificate and provide `tls_ca_pem` when the issuer is private. Disabling verification
retains TLS encryption and all backend address/DNS policy checks. Certificate metadata references
files under `<data-dir>/certs`; it does not create a vhost or expose a listener.
First-class redirect hosts live under `redirects`; see `REDIRECTS.md` for matching, target, status, path/query and cache behavior.
ACL rules have `network` CIDR and `action` (`allow`/`deny`). Omitted ACL means no
restriction in that scope; an explicit empty ACL defaults to deny. Matching deny
wins. Host and route decisions are ANDed. Identity is the socket peer, not an
untrusted forwarded header. There are up to 128 rules per ACL.
Headers run global, then vhost (where applicable), then route rules. `set` replaces, `append` adds a
separate value and `remove` accepts no value. Transport, WebSocket handshake and
client-identity headers are protected. Up to 32 rules per direction/scope, 128-byte
names and 8192-byte values are allowed. Response rules and matching vhost security policy also cover locally generated
proxy errors/denials. Multiple Set-Cookie fields remain separate.
Routing matches exact normalized hostnames, then Nginx-style location precedence: exact path,
longest priority prefix (`^~`), first matching regex in declaration order, then longest ordinary
prefix at a segment boundary (`/api` matches `/api/x`, not `/apix`). Case-insensitive regex is
also supported. Repeated literal slashes are merged for route/ACL selection, while an unrevised
backend request retains the original path/query. Locations can explicitly strip/replace/regex-
rewrite the upstream path or return a local response. Ambiguous encoded separators and traversal
forms remain rejected. Vhost pools support load balancing as described in VHOSTS.md; locations
may pin traffic to one member. Wildcard hostname matching is supported only in the documented
leading-label form; hostname regex remains unsupported.
## Metrics and logs
Metric names use `rust_proxy_` and bounded aggregate labels. Transport counters cover
HTTP/2 requests/connections, TLS handshakes/errors and WebSocket tunnel totals,
active tunnels, errors, frame bytes and closed-tunnel duration sum/count. Ordinary
HTTP body bytes and WebSocket frame bytes are separate measurements.
Access entries are emitted at exchange completion or tunnel closure, not only when
headers arrive. WebSocket exchanges retain status 101 and a `websocket_closed`
outcome when cleanly released. Errors/cancellation affect the outcome. No query
values, request bodies, cookies, password material or WebSocket payloads are logged
by the access logger. Warnings and errors use the configured error file, or stderr when that file is disabled/unavailable. The in-memory access ring retains up to 4096
completed entries; live SSE/UI snapshots use the newest 256 by default. The dashboard can
temporarily expand an open Live Logs window to the retained 4096-entry buffer and keep
merging new live snapshots until that window closes. This is not a durable lossless log archive.
## 0.1.0 cache, listener and diagnostics additions
| Method / path | Contract |
|---|---|
| GET /assets/langs.json | Public compiled language bundle: default ID, discovered language metadata/translations |
| GET /api/cache | Authenticated cache settings, metrics and bounded object metadata; never returns cached bodies |
| POST /api/cache/purge | Full/global or vhost-scoped purge with the normal mutation protections and audit |
| POST /api/cache/invalidate | Selective authenticated PURGE/BAN by exact URL, prefix, tag, safe regex or cache key; optional host/vhost scope |
| GET /api/cache/bans | List active in-memory BAN rules |
| DELETE /api/cache/bans/{id} | Remove one BAN rule with audit |
| POST /api/cache/cold/cleanup | Start an authenticated/CSRF-protected asynchronous selective Cold Cache cleanup job |
| GET /api/cache/cold/cleanup/{id} | Read status/result of an authenticated selective cleanup job |
Configuration includes defaulted `listeners`, `cache`, `transform`, `compression`, `server_header`
objects and optional per-route `cache` (`false` bypasses, null/omitted inherits).
The complete documented field contracts are in SETTINGS.md and CACHE.md. Listener
changes persist immediately but require restart; all other settings here are hot
published. Settings forms use revisioned section writes where one settings area can
be changed independently; full-document PUT remains for changes that must publish
multiple sections atomically.
Status and SSE snapshots include `cache`, `system`, and `listeners` with active,
desired, startup-default addresses and `restart_required`. Metrics include four
compression counters; log entries include `cache` (HIT/MISS/BYPASS, or `-` before
cache classification). OS measurements are optional/null and sampled off the
request path. Unsupported is not represented as zero. Prometheus adds fixed-name
cache/resource series; absent OS measurements are omitted. No arbitrary URL/cache-key
labels are introduced and the metrics endpoint remains authenticated.
The language bundle is generated by build.rs from langs/*.json and embedded into the
binary; uploading a new file requires recompilation/restart. It exposes public
translations only, not filesystem paths, administrator data or credentials.
### Web Services
`web_services` contains reusable static-file service definitions. Use `PUT /api/config/web-services` to replace that section with revision CAS. A vhost or location selects `{ "type": "web_server", "web_service_id": "..." }`; an omitted `source` remains `{ "type": "reverse_proxy" }` for compatibility. Filesystem settings remain on the Web Service and are not duplicated inside vhosts. See [WEB_SERVER.md](WEB_SERVER.md).
## 0.2.0 vhosts and backend state
`vhosts` defaults to an empty array. All models, enum values, defaults and bounds
are documented in VHOSTS.md, with complete examples under `examples/vhosts-*.json`.
Use the same revisioned PUT endpoint; there is no unauthenticated health-control
endpoint or separate unsynchronized frontend/backend write API.
`GET /api/backends` returns `{pools, active_frontends, desired_frontends}`. Pool state
includes `id`, `algorithm`, `ready`, `health_mode`, `sticky_mode`, `keepalive`,
`members`. Members expose ID/origin/weight, status, healthy flag, active exchanges,
request/error/byte totals, EWMA byte rate/header latency, last check/result, failure
and success counters, transition count, last upstream status and upstream-client
generation/request/age/rotation counters. It does not expose affinity tokens. Member status is up/down/checking/draining/disabled. In health-off
mode, eligibility is not proof that the origin is live. Draining describes operator
intent; inspect `healthy` as well. `ready` reflects new-assignment availability;
a pool containing only draining members may still serve existing cookie sessions.
Status contains `backend_pools` and `vhosts`; listener state additionally includes
`active_endpoints`, `desired_endpoints`. Each endpoint reports its address, TLS and
H2/H3 transport capabilities. `restart_required` covers socket and listener-capability
changes. Listener capabilities are configured only under `listeners.endpoints`. A vhost
then narrows them with `http2_enabled` and `http3_enabled`; both default true. For TLS,
SNI selects either H2+HTTP/1.1 or HTTP/1.1-only ALPN for the matched vhost, so disabling
H2 on one hostname does not disable it for another hostname sharing the listener.
Metrics add `rust_proxy_backend_*` per bounded vhost/member labels. Access entries
add nullable `backend` (`vhost/member` for a forwarded pool exchange). Cache hits
have no actual upstream member request and do not increase its request counters.
The `/assets/vhosts.js` frontend uses the same API/CSRF/revision
boundaries; the static script itself, like other frontend assets, is publicly readable.
## 0.3.0 certificate upload and scoped operations
`POST /api/certificates/upload` accepts JSON
`{"cert_pem":"PEM chain","key_pem":"PEM private key","hosts":["app.example"]}`.
It requires the same session, Origin and CSRF guards as configuration updates.
The request limit is 300 KiB; each PEM value is at most 128 KiB. At most two
blocking upload workers run, and the directory holds at most 128 upload-prefixed
pairs. This endpoint is staging only, not a configuration mutation.
Success: `{"certificate":{"id":"upload-...","covered_hosts":["app.example"],
"hosts":[],"cert_file":"upload-....pem","key_file":"upload-....key"},"staged":true}`.
`covered_hosts` contains the normalized DNS names extracted from the leaf certificate
SubjectAltName extension; `hosts` remains the configured active SNI bindings. No key or
certificate PEM is returned. Add the metadata to `config.tls.certificates` and its ID
to a vhost's `certificate_id`, then save `tls`/`vhosts` as needed. The panel keeps a
combined full-document Save only when staged certificate metadata and vhost assignment
must be published atomically. Unknown certificate references, mismatched keys,
uncovered DNS names and conflicting same-name selections fail validation.
There is no ACME enrollment, expiry/chain-trust guarantee or remote PEM retrieval.
`GET /api/cache` adds `vhosts: [{id, usage: {entries, resident_bytes}, settings}]`.
`POST /api/cache/purge?vhost=app` purges only that configured vhost and invalidates
its pending fills. Missing/unknown IDs return 404, not an accidental global purge.
Omitting the query purges all entries. Both actions are audited and require CSRF;
an empty body is accepted. Configuration updates purge only when cache compatibility
changes; unrelated section saves retain both hot and cold objects.
`POST /api/cache/cold/cleanup` accepts a JSON selection with optional
`unused_for_seconds`, optional `oldest` (maximum count) and `hits_zero`. At least one
criterion is required. It returns HTTP 202 with a `ColdCleanupJob`; poll
`GET /api/cache/cold/cleanup/{id}` until `status` is `done`. The result contains
`removed_entries` and `freed_bytes`. This is intentionally separate from full/scope purge
but reuses the same internal Cold Cache candidate/removal machinery as automatic GC.
### 0.22.1 selective cache invalidation
`POST /api/cache/invalidate` accepts an `InvalidationRequest`:
```json
{
"operation": "purge",
"selector": "exact",
"value": "/assets/app.js?v=42",
"host": "app.example",
"vhost": "application",
"ttl_seconds": null,
"confirm_wide": false
}
```
`operation` is `purge` or `ban`. `selector` is `exact`, `prefix`, `tag`, `regex` or
`cache_key`. `host` and `vhost` are optional independent scope restrictions. BAN accepts
`ttl_seconds` (1..604800, default 3600) and the runtime keeps at most 1024 active rules.
Every regex operation and prefix `/` requires `confirm_wide=true`; the browser panel asks
for a danger confirmation before sending it. Regex input is length-bounded and compiled
with Rust's non-backtracking `regex` engine plus a compiled-size limit.
The response includes `result` with operation/selector/value/host/vhost,
`affected_entries`, optional `ban_id`, `elapsed_ms` and `status`, plus current cache metrics
and active BAN rules. Selective PURGE removes RAM and Cold Cache matches, removes the full
`Vary` family under a matched primary key and prevents fills that started before the PURGE
from publishing afterward. BAN creation does not synchronously scan the store: matching
objects created before the rule are rejected on lookup and removed incrementally by cache
maintenance; fresh objects created after the rule may be stored. BAN rules are runtime
state and are not persisted across process restart.
`GET /api/cache/bans` returns the active rule views. `DELETE /api/cache/bans/{id}` removes
one rule. All invalidation mutations use the standard administrator session, same-origin
request marker and CSRF protections; requested operator/scope information is written to the
existing audit log.
The `transform` configuration section is replaceable through the normal revision-CAS
`PUT /api/config/transform` endpoint. Vhost objects carry a `transform` override and
Docker assignments carry `transform_enabled`; both default to disabled per vhost.
Global transform settings also expose resource controls: `memory_budget_bytes` (1 MiB..2 GiB,
default 128 MiB), `max_body_bytes` (1 KiB..32 MiB, default 2 MiB), and `max_inflight`
(1..1024, default 32). `preset` is optional UI intent metadata with values
`conservative`, `recommended`, `performance`, or `uber`; numeric limits remain authoritative.
Resource pressure is fail-open: work that cannot reserve the configured memory/concurrency
capacity is left unchanged rather than queued behind full-body transformation work.
`GET /api/transform` returns in-memory transformation observability for the administration
Transform workspace: totals and per-vhost counters for examined/applied/skipped/error
responses, transformed cache HITs, original/transformed/saved bytes, accumulated transform
time, applied counts by content kind and bounded skip/fail-open reasons. These counters are
runtime diagnostics and reset when the RustProxy process restarts.
`GET /api/logs` accepts optional `vhost`, `q`, exact numeric `status`, `cache` and
`limit` parameters. Limit clamps to 1..4096 and defaults to 256; search length is at most
256 bytes. Filters run over the retained ring before the limit is applied. `cache` accepts
HIT, MISS, BYPASS or `-`. Entries now include nullable `vhost` independently of `backend`:
cached and static responses can be attributed without claiming that an origin was contacted.
SSE still sends the newest 256 entries per global snapshot. The Live Logs UI can seed a
temporary 4096-entry buffer on demand and then merge subsequent live snapshots into it until
the window closes. This is not a durable log stream or a per-tenant authorization boundary.
The status endpoint keeps existing field names, adds feature flags for new
capabilities, and assembles small values rather than one deeply expanded macro.
The management script `/assets/management.js` is a public static asset; APIs remain
protected. Pages and per-vhost cache fields use the standard configuration endpoint.
## 0.4.0 certificate inventory, forwarding, auth and listeners
`GET /api/certificates` requires the existing admin session and returns revision,
metadata-only certificates including `covered_hosts`, active `hosts`, `assigned_vhosts`,
file names and the per-entry default flag, plus `acme:false`. Inventory reads only the
public certificate PEM on a blocking worker and derives current DNS SubjectAltName values,
so older/manual entries and in-place renewals do not depend on stale persisted coverage
metadata. Persisted `covered_hosts` is only a fallback when the public PEM cannot be read.
It never reads a private key for this enrichment and never returns PEM contents or private-key bytes. Existing upload remains a
CSRF-protected staging operation. The inventory UI saves its returned metadata in
`config.tls.certificates`; vhost assignment is a later independent configuration
save. Every configuration mutation retains revision CAS and validation.
Vhost additions: `tinyauth`, `server_header`, `diagnostic_headers`,
`backend_header`, `backend_timing_header`, and
`frontend.listener_ids`. Global additions: `forwarding`,
`diagnostic_headers`, and `listeners.endpoints`. Each effective
TLS binding can carry `http3` with the five fields described in HTTP3.md. These
are full-config fields, not independent unaudited mutation endpoints. Unknown or
invalid references, trust ranges, URLs, limits and protected header names fail
validation before publication.
Status contains named_endpoints and active/desired endpoint transport metadata,
H3 connection counters and feature flags. Feature flags describe included source
capabilities, not evidence of successful build/conformance testing. Existing
Prometheus metrics add rust_proxy_http3_connections_total,
rust_proxy_http3_active_connections and rust_proxy_http3_rejected_connections_total
and rust_proxy_http3_requests_total. See source `metrics.rs` for exact HELP/type
contracts and INTEGRATIONS_040.md for configuration examples.
## 0.32.4 backend Host and HTTP/2 authority
Existing backend `preserve_host` is now valid for all `protocol` values:
`http1`, `auto`, `http2` and `h2`. HTTP/2 :authority follows the request Host,
while socket/DNS use the configured origin and TLS SNI uses
`tls_server_name` if present. No new JSON fields, endpoints, database migration
or extra retries. HTTP health checks maintain their own Host override.
## 0.32.3 Overview registry and compatible upstream modes
The backend `protocol` wire values are `http1` (default), `auto`, `http2`
(legacy HTTPS H2/H1 ALPN fallback or cleartext H2C) and new `h2` (strict
HTTPS H2 or cleartext H2C). Old `http2` configurations are not rewritten.
See [Vhosts](VHOSTS.md).
The authenticated `/api/preferences` Dashboard card ID lists are backed by a
shared server/frontend registry with **49** IDs: the original 16, fourteen
optional HTTP backend cards, twelve optional performance charts and seven existing
full-width panels (live traffic, system, connections, cache, process, network,
backend diagnostics). OpenAPI `dashboard_widgets` and
`dashboard_hidden_widgets` enum, `maxItems` and defaults reflect that registry.
Existing saved card order is retained. New performance charts are opt-in,
while previously displayed full-width sections remain visible by default.
Unknown saved IDs are ignored rather than failing a whole profile.
The visual editor offers a default-layout preview and a confirmed Reset layout,
both using the existing per-account preference endpoint.
No new routes, tokens or permissions are introduced.
## 0.22.3 certificate hot reload and external deployment
`POST /api/certificates/reload` is an administrator-session-only, Origin/CSRF-protected control-plane
operation. Certificate API bearer tokens are intentionally not accepted by this global endpoint. It re-reads every certificate/key file referenced by the current desired
configuration, validates a fresh immutable routing/TLS snapshot and publishes it at the
**same configuration revision**. Invalid or mismatched material returns an error and the
previous snapshot stays active. The response contains `reloaded`, `revision`, `public_tls`,
`http3_new_handshakes` and `administration_restart_required`. New public H1/H2 TLS and HTTP/3
handshakes use the refreshed certificates; established connections are not renegotiated.
Administration TLS is startup-fixed and therefore reports when a process restart is needed.
`POST /api/certificates/{certificate_id}/reload` is the targeted variant for a manual/file-backed certificate (`issuer == null`). It validates the requested configured ID and atomically republishes the immutable TLS snapshot at the same configuration revision. It is intended for external renewers that replace the files already referenced by RustProxy. A manual entry may use simple filenames from `<data-dir>/certs` or two absolute external paths such as `/etc/letsencrypt/live/example/fullchain.pem` and `/etc/letsencrypt/live/example/privkey.pem`. Absolute paths are resolved again on every reload, so Certbot's `live/` symlinks follow the newly renewed generation. Application-managed ACME certificate IDs are rejected with `409` because their renewal pipeline owns those files. The endpoint accepts either the normal administrator session + CSRF flow or a Certificate API token whose grant explicitly contains that certificate ID.
`POST /api/certificates/deploy` accepts
`{"certificate_id":"shared-cert","cert_pem":"...","key_pem":"..."}` under the same
authenticated Origin/CSRF boundary. It is a convergent upsert for externally owned certificate
ids: a missing id creates a manual certificate entry, an existing manual id is rotated in place,
and an identical RustProxy-owned generation is a no-op. The pair is validated **inside the running
RustProxy process** and changed material is staged under immutable generation-specific files before
a revision-CAS configuration publish. This is important for root-run Certbot hooks when RustProxy
itself runs as an unprivileged service account: final generations are created with the service's
filesystem identity rather than `root:root`. Managed ACME ids are rejected, and failed
configuration publication removes any newly staged generation.
For a certificate owned by a local external issuer such as system Certbot, prefer the local
CLI deploy hook, which calls that loopback API after bounded local file checks:
| `GET /healthz` | Minimal health response, without privileged state. |
| `GET /api/auth/status` | Whether first-time administrator setup is required. |
| `POST /api/auth/login` · `POST /api/auth/logout` | Start/end a browser admin session. |
| `GET /api/auth/session` | Validate current admin session. |
| `GET /api/config` · `PUT /api/config` | Read or replace validated, revisioned configuration. |
| `PUT /api/config/{section}` | Update one supported configuration section. |
| `GET /api/config/history` · `POST /api/config/history/restore` | Review/restore retained configuration snapshots. |
| `GET /api/status` · `GET /api/backends` | Operational status and backend observations. |
| `GET /api/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. |
| `GET /api/logs` · `GET /api/live-logs` | Recent and streaming request logs. |
| `GET /api/file-logs` · `GET /api/file-logs/query` | Stored-log exploration. |
| `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. |
| `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. |
| `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. |
| `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. |
| `GET/PUT /api/preferences` | Personal UI settings. |
| `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. |
| `GET /docs/openapi.json` | Protected machine-readable API specification. |
Some paths have additional DELETE/PATCH operations, job-status resources, query parameters or restricted actions. Use OpenAPI for the **actual fields/methods**, not guesses based on a table. `GET /metrics` here is not a replacement for the separate Prometheus exporter.
## Authentication and safe writes
- Ordinary API calls require an administrator browser/session cookie on the admin listener. Logging in also provides CSRF context. Writes require correct Host/Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1` as enforced by the server.
- The Swagger **API** tab uses the signed-in session. Read calls can be executed; mutations require explicit confirmation. It cannot bypass server-side authorization.
- Configuration writes are revisioned (`expected_revision`) and rejected if based on a stale save. Prefer section updates over replacing the entire document when practical.
- `/docs`, API schemas, configuration, logs, certificates and most observations are **not anonymous**. Keep the listener restricted and use HTTPS or a tunnel for remote access.
## Local CLI alternatives
The included `rust-proxy` program supports configuration and backup operations without turning browser sessions into automation tokens:
```bash
/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy tls deploy --id shared-cert \
--cert /etc/letsencrypt/live/example/fullchain.pem \
--key /etc/letsencrypt/live/example/privkey.pem
rust-proxy --data-dir /var/lib/rustproxy config get
rust-proxy --data-dir /var/lib/rustproxy config validate config.json
rust-proxy --data-dir /var/lib/rustproxy config apply config.json
rust-proxy --data-dir /var/lib/rustproxy config reload
rust-proxy --data-dir /var/lib/rustproxy backup create --output /private/site.rpb
```
The command resolves Certbot's local `live/` symlinks, checks bounded regular files/private-key permissions, then sends the pair to `POST /api/certificates/deploy`. The running service validates the certificate/key and DNS SANs, stages generation-specific files inside RustProxy's private certificate directory, and creates or switches the **manual** certificate id through the normal revision-CAS configuration path. A single id may already be selected by multiple vhosts, so those vhosts switch together
without individual edits. RustProxy-managed ACME entries (`issuer != null`) are rejected to
avoid two renewal owners for the same lineage. The existing `/api/certificates/upload` remains
the generic PEM staging endpoint; deploy/upload request bodies never accept host filesystem
paths. Absolute paths may only be saved explicitly in administrator TLS configuration as a
manual certificate source, where they are validated and never returned as PEM contents.
Run CLI commands as an authorized local operator with the correct private data directory. The `admin` subcommands provide first setup, password recovery, session revocation and status; `tls` supports certificate generate/import/deploy/reload. Run `rust-proxy --help` for exact options.
## 0.28.2 least-privilege Certificate API credentials
## Restricted Certificate API credentials
External automation does not need a full administrator browser session. From **Settings -> Certificate API**, an administrator can create multiple dedicated credentials (up to 32), each consisting of a Key ID, Bearer token, optional non-secret comment and an explicit allowlist of 1-32 certificate IDs. New credentials are **reload-only by default**. Deploy is an independent opt-in permission.
**Settings → Certificate API** can create credentials containing a **Key ID + Bearer token** with explicit certificate-ID grants. New keys default to reload-only; deploy permission is separate. The clear token is displayed at creation/rotation and is not a general admin API token.
The clear token is returned only by the create/rotation response; SQLite stores only its SHA-256 hash plus metadata and the grant. Credentials created before 0.28.2 migrate to a reload-only compatibility grant for all certificate IDs (`*`). Rotate those credentials after upgrading to replace the wildcard with explicit IDs.
API clients send both headers:
An authorized client sends:
```text
Authorization: Bearer <token>
Authorization: Bearer <secret>
X-RustProxy-Certificate-Key: <key-id>
```
A reload-only token may call only the targeted endpoint for an allowlisted certificate:
```bash
curl --fail --show-error -X POST \
-H 'Authorization: Bearer TOKEN' \
-H 'X-RustProxy-Certificate-Key: certbot-main' \
-H 'Content-Type: application/json' \
--data '{}' \
https://proxy-admin.example/api/certificates/shared-cert/reload
```
Certificate API tokens are **not** accepted by the global `POST /api/certificates/reload` endpoint. This prevents a compromised renewal token from forcing reads/reloads of unrelated certificate material. `POST /api/certificates/deploy` is available only when the credential was explicitly created with deploy permission, and the `certificate_id` in the request body must also be present in that credential's allowlist.
A Certificate API credential cannot read configuration, logs, metrics, users or any other administration endpoint. Non-loopback use requires the configured HTTPS administration surface. The normal administrator session + Origin/request-marker + CSRF path remains valid for certificate operations. Credential management remains administrator-only: `GET /api/certificate-api` lists metadata/grants; `GET /api/certificate-api/{key_id}` returns a single credential's **non-secret** settings (Key ID, comment, created/last-used timestamps, allowed certificate IDs and deploy grant); `PATCH /api/certificate-api/{key_id}` edits the comment/grants without changing or returning the bearer token; and `POST /api/certificate-api/{key_id}/regenerate` creates a fresh token for the same Key ID, immediately invalidates the old token, retains metadata/grants and returns the new token exactly once. The original `PUT /api/certificate-api` still creates/rotates credentials, `DELETE /api/certificate-api/{key_id}` revokes one and `DELETE /api/certificate-api` revokes all. **Only administrator sessions** may use these management endpoints. Writes require the existing CSRF, origin and request-marker checks, including regeneration. Legacy wildcard grants cannot be saved in `PATCH`; replace them with explicit IDs first.
Example session-authenticated metadata edit (existing token stays valid; the `token` field is deliberately forbidden):
```json
{
"comment": "renewal hook on edge-02",
"certificate_ids": ["shared-cert", "backup-cert"],
"allow_deploy": false
}
```
Submit this JSON to `PATCH /api/certificate-api/certbot-main` with the normal administrator cookie + CSRF/Origin headers. Regenerate through `POST /api/certificate-api/certbot-main/regenerate` with the same session controls and securely record the one-time `token` response.
Example external deploy for a token that has **Also allow deploy** enabled and contains `shared-cert` in its allowlist:
```bash
curl --fail --show-error \
-H 'Authorization: Bearer TOKEN' \
-H 'X-RustProxy-Certificate-Key: deploy-hook' \
-H 'Content-Type: application/json' \
--data-binary @deploy.json \
https://proxy-admin.example/api/certificates/deploy
```
For an on-host Certbot hook, a token is optional: invoke the installed binary directly instead:
```bash
/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy tls deploy --id shared-cert \
--cert /etc/letsencrypt/live/example/fullchain.pem \
--key /etc/letsencrypt/live/example/privkey.pem
```
`dev.sh` is development tooling and is not required for production certificate rotation.
## 0.5.0 cache inspector and certificate jobs
`GET /api/cache` additionally returns `dashboard: {domains, objects,
matching_objects, offset, limit, statistics_revision}`. Domain rows flatten
hits/misses/bypasses/stored/saved_body_bytes/reasons beside vhost/domain and
resident counts. Object rows contain vhost/domain/path/body_bytes/resident_bytes/
hits/age_seconds/remaining_ttl_seconds, never bodies, request headers or queries.
`vhost=ID` filters, `vhost=` selects legacy only; omitted selects all. Limit 1..200
(default 50), offset <=16384. Unknown vhost ->404. The same scope semantics apply
to POST /api/cache/purge. Existing global metrics remain process-lifetime values.
The historical 0.5 `GET /api/acme` response did not support renewal; see the
current 0.7 response below.
`POST /api/acme/issue` accepts at most 16 KiB of JSON with certificate_id, email,
domains, environment, agree_tos, confirm_production, dns_server, dns_port, key_name,
key_secret, key_algorithm, sign_query and propagation_seconds. See
ACME_RFC2136.md for exact constraints. It returns 202 `{job}` only after admission;
one process-wide job, 429 when busy. Job fields: id, certificate_id, status,
environment, message, certificate. The ready certificate is immutable PEM metadata,
not private bytes. Import is a separate normal config CAS write. Both endpoints
are behind the administrator session; POST retains Origin/CSRF and audit.
LogEntry adds `cache_reason`, a fixed string token. Debug response headers add
X-Cache-Reason when X-Cache is enabled, and standardized RFC 9211 `Cache-Status` detail
when enabled. Transformed cached representations add the `transformed` Cache-Status
extension parameter. `X-Cache-Status` is stripped if supplied by an origin but is not
emitted by RustProxy.
`diagnostic_headers.hide_mock_origin` is removed and old documents containing it are rejected on
export; `request_id` is the only diagnostic-header switch. The generated request ID is a 64-hex-character SHA-256 value and the same protected value is forwarded to the origin as `X-Request-ID`. No revision response-header behavior remains. TLS listener
http3 omission is default-on; explicit null is persisted off. Legacy listeners use
https_http3 with identical default/null behavior.
## 0.6 provider and scrape additions
`POST /api/acme/issue` accepts `provider`: `http01`, `cloudflare`, `ovh` or
`rfc2136` (omitted retains RFC2136 compatibility). Common fields include
`certificate_id`, `domains`, `email`, `environment`, `agree_tos` and
`confirm_production`. DNS providers also accept `propagation_seconds`.
Cross-provider/unknown fields are rejected. Provider-specific fields, secret
handling and request examples are in ACME_PROVIDERS.md. `GET /api/acme` returns
only public job status and validated certificate metadata, not input credentials.
Issuer metadata on certificates is optional `{ "provider": "cloudflare",
"environment": "staging" }`. Do not confuse metadata with CA trust validation.
`config.prometheus` controls the new dedicated HTTP scrape endpoint, which uses
actual-peer CIDRs and optional bearer authentication, not administrator cookies.
The exact configured GET path only is accepted (query strings rejected). Disabled
or changed/restart-pending settings return 404; denied peers 403; invalid tokens
401; non-GET 405; exhausted scrape capacity 429. No administrative actions are
available there. Admin `/api/metrics` and `/metrics` remain session-protected
previews on the admin listener, even when the dedicated endpoint is disabled.
See PROMETHEUS.md for the full settings and metric contract. Traffic analytics is an independent collector group (`include_traffic`) that exports bounded country, fixed User-Agent/method/source, status/cache and configured-route aggregates without raw IP/path/full-UA labels. Docker discovery is an independent collector group (`include_docker_vhosts`) and exports only dedicated `rust_proxy_docker_vhost_*` / `rust_proxy_docker_backend_*` families; ordinary vhost/backend families exclude dynamic Docker routes.
## 0.7 renewal and host access
`GET /api/acme` now returns `{job, providers, automatic_renewal:true,
interval_seconds:43200, renewals}`. `renewals` is keyed by certificate ID and
contains last_checked, next_check, last_renewed, not_after (Unix seconds), failures,
status and a public message token. No credential/account data is returned.
`GET /api/certificates` also includes renewal observations. Statuses are observations,
not a promise of a successful future CA operation.
`POST /api/acme/renew` accepts only `{certificate_id, dry_run:false}`. It uses an
existing managed lineage and returns 202 `{job}` after admission, or 404 unknown ID,
409 unmanaged/invalid profile or disabled prerequisites, 429 worker busy. Actual
worker/CA failures arrive in job state. No force-renew flag is offered. Dry runs
never deploy their test certificate and never postpone a due real renewal.
The usual session, Origin/CSRF protections and audit apply.
A certificate's `issuer` supports `auto_renew` (default true for saved managed
metadata, explicit false opts out). Toggle through ordinary revisioned config;
no provider credentials need resubmitting. Old immutable generations are retained.
`POST /api/basic-auth/hash` accepts `{username, password}` with at most 4096 bytes
of request JSON. It requires an administrator session and the same mutation/CSRF
guards, returns `{username, password_hash}`, and never saves the plaintext password.
Passwords are 12..128 UTF-8 bytes with no control characters. Usernames are 1..128
bytes, no colon/control characters. Hashing has two shared blocking-worker slots;
no available slot returns 429. Invalid input returns 400. This is NOT a public
login endpoint and does not create a browser session for a vhost.
A vhost accepts `websockets` and `basic_auth` with enabled, realm, require_tls,
forward_credentials and users[{username,password_hash}]. Basic and Tinyauth cannot
be enabled simultaneously. Hash import accepts only the bounded Argon2id policy
created by the hash endpoint, not arbitrary-cost PHC strings or htpasswd formats.
Missing `websockets` retains the previous true behavior; the new-host UI selects
false. Basic TLS defaults true and forwarding defaults false.
Backend pool members have `pool_eligible` (default true). False excludes that
member from default load balancing and sticky selection without blocking an
explicit location `backend_id`. Visual custom locations edit existing vhost
`routes`: ID, path_prefix, backend_id, enabled, cache, ACL and advanced headers.
This is prefix routing, not regex, rewrite or nginx configuration evaluation.
See [host access](VHOST_ACCESS.md) for cumulative ACL and authentication semantics.
## 0.18 Docker discovery additions
| Method / path | Contract |
|---|---|
| `GET /api/docker` | Authenticated cached in-memory Docker-agent observations; no agent poll and no configuration write |
| `POST /api/docker/refresh` | Authenticated + Origin/CSRF immediate poll of enabled agents followed by ephemeral route reconciliation; no configuration revision write |
| `PUT /api/config/docker-agents` | Revision-CAS replacement of desired Docker-agent settings, per-agent domains and service hostname/alias/IP assignments |
`config.docker_agents` stores desired agent endpoint/token/base-domain/IP override/listener references, a per-agent 5..3600 second inventory poll interval, and assignments. Inventory data returned by agents is not persisted in the configuration document. The normal configuration API remains authoritative for desired state; the Docker observation API is operational state only. Docker assignments may also carry an ACL profile, inline ACL, health-check settings and per-vhost proxy timeouts. Publishing/unpublishing is therefore an ordinary CAS configuration update and does not require polling the agent.
## HTTP/2 lifecycle settings
As of 0.24.3 the preferred configuration is per listener: `listeners.endpoints[].binding.http2_settings` contains `idle_timeout_seconds`, `max_connection_lifetime_seconds` and `drain_timeout_seconds` for that TLS H2 or cleartext H2C endpoint. Update these values by revision-CAS replacement of the `listeners` section (`PUT /api/config/listeners`), the same ownership model used by per-listener HTTP/3 limits. `max_connection_lifetime_seconds: 0` disables the absolute lifetime limit. Idle and lifetime expiry both use graceful GOAWAY/drain shutdown.
The top-level `http2` object introduced in 0.24.0 and `PUT /api/config/http2` remain accepted for backward compatibility. They are a fallback only for listeners that do not yet have explicit `binding.http2_settings`; the structured 0.24.3 UI writes listener-scoped values.
The runtime system diagnostics object includes `platform`, supplied by the shared system-statistics backend (`Linux`, `FreeBSD`, `OpenBSD`, `NetBSD`, or the generic/unsupported fallback). The browser only renders that value; it does not detect the operating system itself.
## 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.
## 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`.
In the 0.32.0 Stream addition, `GET`/`PUT /api/preferences` own `dashboard_widgets` and `dashboard_hidden_widgets` (at that stage 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.
The credential can be used only for allowed certificate reload/deploy operations, not configuration, users, metrics or secret management. Non-loopback use requires the appropriate administration HTTPS security boundary. Rotate broad or legacy credentials. See [API access](API_ACCESS.md) and [Certificates](ACME_PROVIDERS.md).
+15 -229
View File
@@ -1,243 +1,29 @@
# Administration API access - 0.29.1
# Administration authentication and API access
## Sign-in boundary
## Default exposure and setup
The administration listener remains separate from reverse-proxy listeners and uses
loopback HTTP by default. Settings -> Administration can enable HTTPS, select either
the application-managed default certificate or an inventory certificate, persist a
new bind address and allow explicit DNS hostnames. A persisted non-loopback bind is
accepted when HTTPS is enabled. The legacy startup flags remain only for deliberately
plain remote HTTP. Public vhost `/docs` paths are not captured by the admin listener.
The administration listener uses **HTTP loopback (`127.0.0.1:9090`) by default** and is separate from normal Vhost listeners. No administrator password is bundled. Use the local CLI `admin setup-token` or `admin init` to complete first-time setup. For access from another workstation, prefer an SSH tunnel; for direct remote operation, enable **administration HTTPS**, configure a trusted certificate and narrow network access before binding outside loopback.
When the dashboard is published through a trusted reverse proxy, the hostname of
the optional `administration.public_origin` is automatically allowed for panel
Host validation. Any extra `administration.allowed_hosts` DNS entry is also
matched against the external authority rather than the private admin socket port. `X-Forwarded-Host` is trusted only from loopback or a
configured trusted proxy; the external `X-Forwarded-Proto` additionally requires the
forwarded-proto trust setting except for loopback. Origin/CSRF checks and Secure cookies use
that external origin. Direct IP/localhost access still requires the real administration port.
## Session security
### Publishing the panel through RustProxy itself
Administrator sessions use an HttpOnly, SameSite=Strict cookie, with Secure on HTTPS. Expiry is bounded by the configured absolute lifetime (7–30 days, default 30). Logout, password changes and revoke-all terminate sessions. API mutations are validated for Host/Origin, CSRF token and request marker; a cookie copied into automation is not an approved general-purpose API token.
0.16.0 supports the explicitly opted-in same-process topology where a public HTTPS vhost proxies to the local administration listener. The reserved `rustproxy_session` cookie is passed through only when all of these conditions are true: the browser-facing request is TLS, its DNS hostname either matches the pinned `administration.public_origin` or is explicitly listed in `administration.allowed_hosts`, the selected backend directly addresses the configured administration socket by IP, and that backend has `accept_local_listener_risk=true`. The control-plane route is forced out of shared cache.
Unauthenticated endpoints intentionally expose only minimal setup/sign-in and health behavior. Configuration, operational status, dashboard assets, file logs, the API specification and the documentation Reader require authentication. Admin responses use no-store and security headers, and the browser does not expose the full application state before sign-in.
This is a narrow exception to the normal isolation rule. For every ordinary backend RustProxy still removes `rustproxy_session` before forwarding requests and strips an upstream attempt to set that cookie in responses. DNS backend names are not accepted for the special self-admin cookie path. This prevents a normal application backend from reading or replacing the administration session.
## `/docs` Reader and Swagger
The anonymous page contains only the sign-in/setup form, logo, shared stylesheet,
a dedicated login script, and filtered language strings. It does not fetch the
configuration, status, metrics, logs, certificate inventory, dashboard modules or
the full translation bundle. Adding a new language JSON still discovers that
language at build time; login strings use the same fallback rules.
The authenticated documentation page has two tabs: **Reader**, embedding the registered Markdown guides at compile time, and **API**, displaying the bundled OpenAPI schema. Both are local assets; no runtime CDN access is required for viewing the reference. A document edit affects the shipped reader **only after rebuilding** RustProxy.
Exact anonymous methods/paths:
Swagger uses the current signed-in session, not a new broad credential. It requires explicit confirmation for write operations. Treat the OpenAPI schema as a request guide and RustProxy's field/authorization validation as the final authority.
| Method | Paths | Content |
|---|---|---|
| GET/HEAD | `/`, `/overview`, `/vhosts`, `/redirects`, `/streams`, `/acl-profiles`, `/configuration`, `/tls`, `/cache`, `/logging`, `/settings` | Minimal login when no valid cookie is present; the same URL returns the authenticated application shell when the supplied session is valid. `/configuration` is a compatibility alias for Settings -> Manual config. |
| GET/HEAD | `/assets/login.js`, `/assets/login-langs.json`, `/assets/style.css`, `/favicon.svg` | Login assets; no runtime state |
| GET/HEAD | `/healthz` | `{"status":"ok"}` only |
| GET/HEAD | `/api/auth/status` | `{"setup_required":false}` or `true` only |
| POST | `/api/auth/login`, `/api/auth/setup` | Credential/capability validation, exact Origin and request marker required |
## Publishing the panel behind a reverse proxy
The document allowlist above is intentionally session-aware: a browser refresh on a protected
panel path first validates the cookie server-side instead of rendering a false logged-out state.
The login document also rechecks `/api/auth/session` when restored from browser history, preventing
a stale back/forward-cache copy from contradicting a still-valid session. The authenticated shell
also treats `auth.invalid_origin` during startup/session revalidation as a stale restored-page signal
and performs at most one reload of the current same-origin URL. A repeated failure is shown normally;
server Host/Origin, request-marker and CSRF validation is unchanged. Every other admin
path/method, including full assets, `/api/metrics`, `/metrics`, unknown endpoints and the OpenAPI
schema, requires an active database session.
Missing/invalid/expired cookies return `401 {"error":"auth.required"}` before
query/body extraction. Invalid Host/Origin may return 403 first. Only GET/HEAD
`/docs` and `/docs/` navigate to login through a fixed `303 /?next=docs` redirect;
API/spec/assets never return login HTML instead of a status error.
This is a **special-risk topology**, not the default. Configure a fixed HTTPS public origin/allowed hostname and a trusted forwarding boundary. RustProxy has an explicit narrowly constrained local-listener exception, but a normal public Vhost must not pass administration cookies to an ordinary upstream. Forwarded Host/Proto values are trusted only when the direct peer satisfies the configured trust rules. Test CSRF/Origin validation and cookie security after a topology change.
## Certificate-only credentials
Administrator browser sessions have a configurable absolute lifetime of **7–30 days**
(default 30) through `administration.session_days`. The server stores only a hash of the
session secret, keeps the cookie HttpOnly/SameSite=Strict (and Secure on HTTPS), and
never extends a session past its creation-time absolute expiry. The cookie is refreshed
with only the remaining lifetime so an upgraded still-valid short session can adopt the
new configured bound. Logout, password change and revoke-all still invalidate server-side
sessions immediately.
Certificate API Key ID + Bearer credentials are limited by certificate ID and by allowed action (reload by default; deploy opt-in). They do not grant login or administrative configuration access. Revocation/rotation are administrator-only actions. Keep tokens out of logs and pass them over HTTPS outside loopback.
Responses are no-store. Headers include CSP, nosniff, frame denial, no-referrer and
restricted camera/microphone/geolocation permissions. Error responses do not expose
request-body excerpts for malformed anonymous login/setup input. Anonymous responses contain no application version; fingerprint/revision
response headers are stripped on the admin surface. Authenticated status and the
protected schema intentionally retain the current version. The application name/logo and the single setup-needed bit are intentional
public information, not claims of anonymity or software fingerprint prevention.
## Offline build assets
Authenticated `GET /api/status` also returns a small `runtime` object with the platform,
statistics adapter name, resolved data directory and SQLite database path. This is used by
the Settings UI so Linux, BSD/OPNsense and custom installations show their actual runtime
paths instead of browser-side guesses. The object is not available anonymously and does
not expose credentials, environment values, certificate contents or arbitrary filesystem
listings.
## Docs Reader and API view
`/docs` opens an authenticated documentation workspace. The default **Reader** tab loads
`/docs/content.json`, a build-time bundle of the project Markdown documentation listed in
`docs/README.md`. The bundle contains documentation source text only; it does not contain
live configuration, credentials or runtime state. Reader links between bundled Markdown
documents stay inside the same authenticated documentation surface.
The separate **API** tab uses Swagger UI. `/docs/openapi.json` describes the local endpoints and common request schemas; it
contains no live configuration, actual credentials, generated keys, user accounts,
cache entries or listener addresses. Extensible schemas summarize configuration;
the Rust validator remains authoritative for the full configuration model.
Documentation uses the same HttpOnly `rustproxy_session` cookie as the panel.
Do not paste session cookies into the schema or store them in browser storage.
GET execution is enabled by default; write execution requires a checkbox and an
explicit confirmation for each request. The script validates the current session,
allows only documented same-origin operations, and sends the current session's
`X-CSRF-Token` plus `X-RustProxy-Request: 1`. The browser supplies Origin. The server
performs the real authorization checks regardless of any browser options.
Login/setup operations and indefinite SSE streams are documented but not executed
by Swagger UI. Native logout/password writes can invalidate the browser session; reload or
sign in again afterward. Ordinary API mutations still require the administrator session,
Origin/request marker and CSRF token.
0.28.2 keeps the deliberately narrow Certificate API exception for automation and makes it least-privilege. An operator can create one or more **Certificate API Key ID + Bearer token** credentials in Settings -> Certificate API. Each new credential requires an explicit allowlist of 1-32 certificate IDs and is reload-only by default. Deploy access must be enabled separately.
A reload-only credential authorizes only:
- `POST /api/certificates/{certificate_id}/reload` when that ID is allowlisted.
Certificate API credential inspection (`GET /api/certificate-api` or `GET /api/certificate-api/{key_id}`) is likewise administrator-session only and returns no hashes/secrets. `PATCH /api/certificate-api/{key_id}` changes metadata/grants without touching the secret; `POST /api/certificate-api/{key_id}/regenerate` replaces the hash and invalidates the old token. Both writes require session + CSRF/Origin/request-marker protection. The replacement secret is revealed only once in the regeneration response. External Key ID + Bearer clients are not authorized to call any credential-management endpoint.
`POST /api/certificates/reload` is administrator-session only and never accepts a Certificate API token. `POST /api/certificates/deploy` accepts a Certificate API token only when the credential has explicit deploy permission and the request's `certificate_id` is also allowlisted.
Both `Authorization: Bearer <token>` and `X-RustProxy-Certificate-Key: <key-id>` are required. The clear token is returned only on creation/rotation; the database stores its SHA-256 hash. Up to 32 Key IDs can coexist, and each can be revoked independently or all at once. Credentials created before 0.28.2 migrate to reload-only compatibility access for all certificate IDs (`*`); rotate them to narrow the allowlist. The credential does not authorize configuration, logs, metrics, users or any other endpoint. Non-loopback use is accepted only on the configured HTTPS administration surface. Revocation from Settings invalidates that credential immediately.
Local host automation does not need this token. The installed binary can run
`rust-proxy tls deploy ...` or `rust-proxy tls reload` directly; it obtains short-lived local
CLI authority from the local database and calls the guarded loopback administration API.
`dev.sh` is a development helper and is not required in production.
The external validator, query-supplied schema/server configuration and persisted
Swagger authorization are disabled. Runtime JS/CSS are served from the same admin
origin behind its guard. Only the two necessary Swagger vendor asset filenames, the documentation bundle and a plain-text license notice
(`/docs/licenses.txt`) are routed; all require an administrator session.
Swagger's inline layout styles have a document-scoped CSP exception; scripts stay
self-only with no unsafe-eval allowance. The ordinary dashboard CSP is unchanged.
Logout broadcasts an in-memory notification between tabs. The panel drops its
operational document on logout/revocation; documentation clears its schema,
responses and write opt-in. Docs recheck sessions on actions, visibility and every
30 seconds while visible. Browser history restoration revalidates; this is not a
promise to erase browser/OS screenshots or data intentionally downloaded by an admin.
## First build / offline assets (fixed in 0.7.2)
No `SWAGGER_UI_DOWNLOAD_URL` environment variable or `.cargo/config.toml` is required.
The default is Swagger UI **5.33.1**, selected by `scripts/build/swagger_assets.py` and
called from this application's `build.rs`. The former `utoipa-swagger-ui` adapter
is removed: there is no separate dependency build silently choosing its older
historical browser default. Direct Cargo builds using `--manifest-path` use the
project's absolute manifest directory, not the caller's working directory.
Python **3.10+ is now also required for direct Cargo builds**, as it already was
for `dev.sh`. `build.rs` tries `python3`, then `python` if that executable is absent.
Use `RUST_PROXY_PYTHON=/absolute/path/to/python3` to select an interpreter explicitly.
The helper uses Python's verified HTTPS transport and standard-library ZIP reader;
`curl` is no longer a Swagger build requirement. Missing tools/network/assets give
an actionable build error rather than an absent-environment-variable panic.
Normal build (no Swagger environment setup):
```bash
cd RustProxy
bash dev.sh check
bash dev.sh test
bash dev.sh release
```
The first build still needs the normal Cargo registry and the pinned upstream
archive. Swagger JS/CSS and license notices are embedded in **both debug and
release binaries**, served behind the existing admin guard. No runtime download,
CDN, public asset endpoint or alternate authentication mechanism was introduced.
An offline or GitHub-restricted build can use a reviewed local copy of the same
GitHub **tag archive**, with its original `swagger-ui-5.33.1/` directory structure:
```bash
export SWAGGER_UI_DOWNLOAD_URL=file:///srv/build-assets/swagger-ui-5.33.1.zip
# Optional: require the archive digest independently verified by your build team.
# export SWAGGER_UI_ARCHIVE_SHA256=<64-hex-digit-verified-digest>
bash dev.sh check
bash dev.sh test
bash dev.sh release
```
Unset/empty source values use the default pin. Different remote URLs and old tag
archives are rejected. Local file URLs support escaped spaces and must be absolute.
`SWAGGER_UI_ARCHIVE_SHA256`, when set, checks the entire archive before publication.
No upstream checksum is fabricated: HTTPS provenance, the archive's declared tag
and its layout are not themselves a cryptographic authenticity guarantee for an
operator-supplied ZIP. Independently review/pin local inputs.
The helper only reads exact, bounded members; it does not extract arbitrary ZIP
paths. It retains licenses/notices, validates package name/version, rejects duplicate
required members, symlinks and oversized assets, caches only validated downloads in
Cargo's output directory, and writes outputs atomically. Failed preparation blocks
compilation instead of embedding stale assets. A `source.json` receipt records
archive and output SHA-256 values in `OUT_DIR/swagger-ui/`.
Cargo tracks changes to the helper, source/digest/interpreter environment variables,
local or cached ZIP and generated outputs. Repeated identical preparation preserves
output timestamps. `CARGO_NET_OFFLINE=true` forbids a new helper download; ordinary
Cargo dependencies must also be cached for a fully offline build. A local archive
is the recommended explicit setup for `cargo --offline`; Cargo CLI offline flags
are not assumed to propagate automatically into subprocess environments.
0.24.1 adds cross-platform statistics dependencies. This packaging workspace has no Cargo toolchain, so it does not fabricate or hand-edit a resolver result. Generate and preserve `Cargo.lock` with Cargo on the compiler-enabled release host, then run `cargo check --locked` / the normal `dev.sh` gates before deployment.
Build-helper tests use synthetic ZIPs; they do not claim to render Swagger. This
environment lacks Rust/Cargo and cannot fetch the upstream assets. Compilation,
real embedding/rendering and live access-control acceptance remain to be run on a
build server. Browser tests still label their simulated API/Swagger renderer.
## Focused live regression probe
After running the built server:
```bash
python3 tools/audit/admin_audit.py --url http://127.0.0.1:9090
```
The script sends anonymous read-only loopback probes, follows no redirects, ignores
proxy environment variables, prints no response bodies, and fails on unexpected
public content/statuses. It does not send credentials or mutate configuration. It
is also called by the real-binary security integration suite. Rust router tests
add write/method/duplicate-cookie/CSRF checks. A passing probe is a regression gate,
not a complete pentest, dependency scan or guarantee against all vulnerabilities.
## Custom location headers
Vhosts -> edit -> Custom locations -> Location headers. Add rules independently
for proxy requests (to backend) and responses (to client). `set` replaces, `append`
adds another field value, `remove` has no value. Maximum 32 rules per direction.
The existing JSON editor is synchronized and remains optional. Invalid JSON,
newlines, unknown operations and protected names cannot silently save an old draft.
Rules apply global -> vhost -> matching location. Proxy transport, forwarding,
verified authentication identity, configured Server/security/diagnostic policies
remain system-managed and can override or forbid a conflicting custom rule.
Values are literal, not Nginx variables; changing Host/forwarded identity still uses
the designated backend/forwarding controls. Headers for sibling locations remain
unchanged. No configuration schema migration is needed for existing header rules.
## References
- OWASP REST Security Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Swagger cookie authentication: https://swagger.io/docs/specification/v3_0/authentication/cookie-authentication/
- Swagger UI configuration: https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
- Cargo configuration discovery: https://doc.rust-lang.org/cargo/reference/config.html
- Cargo build scripts: https://doc.rust-lang.org/cargo/reference/build-scripts.html
- Pinned browser release: https://github.com/swagger-api/swagger-ui/tree/v5.33.1
Swagger JavaScript/stylesheets are prepared by the build and embedded behind authentication. If compilation fails during Swagger asset preparation, inspect `build.rs` and `scripts/build/swagger_assets.py` and ensure the required checked/downloaded asset inputs are available. Do not replace these authenticated assets with an unaudited public CDN workaround.
-233
View File
@@ -1,233 +0,0 @@
# Architecture
## Control plane and forwarding plane
SQLite stores administrator intent, including vhosts/paths, policies, backend definitions
and certificate metadata. `config::compile` validates this document and builds an
immutable Snapshot with routing tables, compiled ACL/header policies, per-backend
clients and an optional rustls ServerConfig. ArcSwap publishes the active snapshot.
No public request consults SQLite or a provider to choose its route.
A configuration mutation obtains a bounded admission slot and builds a candidate on
a blocking worker. The slot remains held if the HTTP caller is cancelled while the
worker is running. A serialized detached writer checks the expected revision,
commits the desired document in SQLite and swaps the active snapshot with no await
between commit completion and publication. Invalid candidates leave the old state
active. A crash between persistence and publication is recovered by loading the
committed document at startup; this is not one cross-memory/SQLite transaction.
## Module responsibilities
| Module | Responsibility |
|---|---|
| `server` | Bounded listeners, TLS acceptance, HTTP/1.1/HTTP/2 selection, connection leases and shutdown |
| `proxy` | Host/authority routing, ACL sequencing, forwarding and streaming instrumentation |
| `proxy::websocket` | Handshake validation, upgrade tasks and bounded bidirectional byte transport |
| `web_server` | Reusable static-file services, safe document-root traversal, validators/ranges and streamed local file bodies |
| `upstream` | Per-backend pools, guarded resolver, connection deadlines and verified HTTPS clients |
| `tls` | Local PEM files, generation/import, validation, SNI and loaded certificate snapshots |
| `config` | Desired models, normalization, validation and compiled snapshots |
| `vhost` | Host-owned frontend/member models, actual-address selection and listener planning |
| `balance` | Full-exchange leases, health/recovery, weighted scheduling, connection/affinity settings |
| `security` | Compiled per-vhost response protection, TLS-only HSTS |
| `acl`, `headers` | Generic access and header policies, no application-specific exceptions |
| `auth`, `admin` | Administrator lifecycle, local API, browser sessions, CSRF, SSE and static UI |
| `db` | SQLite schema, migrations, desired-state persistence and revision checks |
| `metrics`, `logging` | Aggregate instrumentation, bounded access ring and trace output |
| `transform` | Bounded decode/transform stage for eligible static identity representations before cache/compression, with live process-wide memory and concurrency ceilings |
| `docker` | Desired Docker-agent settings, in-memory observations and ephemeral vhost reconciliation |
| `runtime` | Shared process state, snapshot updates, admission limits and tunnel tracking |
| `demo` | A separate opt-in demonstration backend with its own listener |
### Rust source organization
Production Rust modules are kept as small responsibility-oriented files, with a
**500-line engineering limit** for production modules. Unit and
integration test files are explicitly exempt: related tests may live together in
large `tests.rs` files rather than arbitrary numbered fragments. Large logical modules use a thin facade (`config.rs`, `docker.rs`,
`server.rs`, `runtime.rs`, `balance.rs`, `prometheus.rs`, and similar) plus files in a
matching directory. `web_server` uses the standard `web_server/mod.rs` facade with
submodules for configuration, filesystem confinement, responses, routing, FastCGI and
`.htaccess`.
Unit-test implementations are not embedded in production source files. They live in
`tests.rs` or `tests/` files and are included or declared only behind `#[cfg(test)]`.
Keep `include!` targets, split fragments and attributes/doc comments alongside
the items they describe. After removal of the old source-check scripts, review
these invariants in code review and verify compilability with `./dev.sh check`.
Docker discovery is an optional provider boundary implemented through the standalone
`docker-agent/`; the RustProxy process never opens the Docker socket. Cloudflare Tunnel
remains a future module. Certbot-based HTTP-01/Cloudflare DNS/OVH DNS/RFC2136 issuance and managed renewal are optional
control-plane adapters. Provider work stays outside proxy request paths and
publishes certificates only through validated configuration snapshots.
## Request lifetime
A listener owns a shared TCP connection lease. Each admitted request owns an
in-flight permit and an Exchange, which remains alive through streamed request and
response bodies. HTTP/2 connections share the public TCP cap with HTTP/1.1 and TLS.
Upgraded WebSocket tasks retain both the Exchange and TCP lease after Hyper stops
managing the HTTP connection, preventing upgrades from escaping limits or counters.
Routing captures one snapshot. Socket peer identity, host and route ACLs plus configured
authentication are checked before source dispatch, DNS or upstream connection reuse. A selected
Web Server source opens only its compiled Web Service root and returns through the shared response
pipeline; a Reverse Proxy source continues to a stored backend origin. The public client never
supplies an arbitrary upstream destination or filesystem root. Header
rules cannot change the identity already used by ACL. Ordinary bodies pass through
without full aggregation. For an explicitly transform-enabled vhost, eligible bounded
static responses use the logical pipeline `origin -> decode supported Content-Encoding
-> transform -> cache identity representation -> downstream compression`. Origin
`no-transform` is authoritative; changed bytes receive a RustProxy representation ETag
while origin validators are retained separately for revalidation. WebSocket data uses
fixed-size copy buffers.
New requests on existing keep-alive/HTTP/2 connections load the current snapshot.
Existing bodies and tunnels retain the old snapshot/backend. Each accepted TLS
connection captures the current certificate configuration before its handshake.
Certificate validation and file reads are never performed inside the SNI resolver.
## Startup and shutdown
Startup opens/migrates SQLite, loads existing intent, prepares required certificates,
compiles the snapshot and binds the requested listeners. The demo only seeds a new
database. A corrupt configuration is not automatically reset. After the runtime is published,
the optional Docker poll loop contacts enabled agents; failures remain observations and do not reset desired configuration or block ordinary proxy service.
Shutdown cancels listeners and tracked tunnels, drains admitted HTTP connections
within the configured grace period, drains accepted configuration writers and
closes SQLite. Bounded runtime shutdown avoids waiting forever for cancelled OS DNS
workers. This does not turn a blocking getaddrinfo call into a killable operation.
## Deliberate development tradeoffs
The panel remains loopback HTTP. All active configuration changes currently rebuild
backend pools and certificate state, even if unrelated fields changed. Compatible health/counter/affinity observations are retained independently; idle
connection and certificate reuse optimization still requires measurements. Backend loop protection checks local listener addresses by default while allowing remote origins on any valid port. A per-backend `accept_local_listener_risk` opt-in can deliberately allow a local RustProxy listener (for example port 9090) without bypassing private/special-address policy. DNS special-range restrictions
are basic safeguards rather than an independently audited network policy engine.
No performance target has been established or measured for this package.
## 0.1.0 response pipeline and resources
Observe pins one immutable snapshot for the exchange. Host/route ACLs precede the
cache and all upstream I/O. Eligible GET lookup uses effective request headers
and a revisioned representation key. On a miss the origin response is validated
before and after global/route header rules; a bounded tee records complete bodies
while still yielding frames immediately. No request-path SQLite/provider lookup is
introduced. Server policy and optional streaming compression apply after cache
lookup/fill wrapping. The outer MeteredBody counts delivered encoded payload frames.
Cache publication uses a short mutex section and LRU/byte/entry bounds; fill buffers
have eight semaphore slots. Purge increments a generation, preventing older in-flight
fills from publishing. Config commit invalidates cache before snapshot publication,
without awaiting between those operations. New listener intent is persisted but
active sockets remain unchanged until restart; original CLI defaults are retained
separately so clearing an override has accurate desired-state reporting.
`cache`, `transform`, `compression`, `settings`, `diagnostics`, and `system_stats` are dedicated modules. `diagnostics` owns periodic sampling/export while `system_stats` adapts the shared `systemstat` host API, `sysinfo::Networks` network API, and one `metrics-process` process API. Network inventory/counters use `sysinfo` as the primary source and merge `systemstat` as a secondary library source, so missing/partial primary inventory is supplemented without shell commands; no BSD/Linux shell-command network parser is maintained. `metrics-process` uses its documented dummy fallback where no native process collector exists, so unsupported fields remain absent without adding another platform parser. Linux adds its compatibility-only `/proc`/cgroup fields after the common sample. The
sampler runs blocking OS/library reads off the async request path roughly every two
seconds and stops through the process cancellation token. Language discovery runs
in build.rs, not in browser hardcoded PL/EN logic. The result is an embedded public
JSON asset; existing logo resources remain intact.
## Vhost pool publication and observations
The immutable routing snapshot contains actual-address-constrained routes, compiled
frontends, pools and both TLS ALPN configurations. The process separately retains
its actually bound listener plan. New routing uses desired bindings immediately;
new sockets/ALPN take effect only at restart. This can temporarily deny moved hosts,
but never silently broadens them to still-active old bindings.
Each pool owns immutable member definitions and clients. Small synchronized health,
scheduling and traffic observations live separately. Compatible publication reuses
observations/tokens by vhost/member/origin/health identity; changed or re-enabled
members are quarantined. Request leases retain the old member/client until both
body directions or the upgraded tunnel finish. Changes do not replay requests.
A weak-App, cancellation-aware supervisor schedules a rotating, bounded window of
probes outside the data path. DNS and configured-origin policies are shared with
normal forwarding; HTTP checks use a dedicated connection and discard the body.
Traffic sampling and status snapshots operate on these observations, not SQLite.
Request order is actual frontend selection -> host/vhost/path ACL/auth -> location/source selection. Web Server then performs confined local filesystem handling; Reverse Proxy continues through eligible member selection -> member-isolated cache lookup -> guarded upstream request.
Cookie affinity bypasses cache. Cache hits release the selection without recording
an actual upstream request. Response protection is applied after cache selection,
and per-vhost HSTS is removed on plaintext, including synthetic responses.
## Integrations in 0.4.0
`forwarding` compiles the explicit trust boundary and reconstructs canonical
metadata. `tinyauth` owns immutable verifier configuration and an outbound client;
it authorizes before the existing origin/cache path. `http3` adapts bounded Quinn
streams to the same Axum router and response Body without a separate cache/auth
implementation. Named listener IDs resolve to actual bindings while compiling
vhosts; accepted destination addresses still control route visibility.
TLS and QUIC use the same validated certificate resolver. Active endpoint limits
remain fixed until restart; new QUIC handshakes can use the latest certificate
snapshot without changing Retry token keys. Graceful shutdown signals H3 GOAWAY
and bounds drain before endpoint close. Runtime conformance remains to be tested.
## 0.5.0 cache inspection and certificate jobs
Cache decision counters and entry metadata live in the bounded cache store. A
separate authenticated paginated read API returns metadata, not bodies, queries or
request headers. Expiry cleanup also runs during inspection; generation barriers
still prevent old in-flight fills from republishing after purge/config changes.
The ACME manager is outside proxy request paths: one job semaphore, a tracked
child-process group, JSON over stdin, sanitized status and private Certbot/RFC2136
profiles. Only validated immutable certificate metadata can enter the existing
library through explicit revision CAS publication. See ACME_RFC2136.md for the
required external tools and unverified integration boundary; RENEWAL.md documents
the 0.7 scheduler and automated publication.
## 0.6 boundaries
`legacy` is an input-only migration module, not a traffic dispatcher. It emits the
same canonical vhost model used by the UI. Validated startup migration uses one
CAS write; failure before validation completion leaves the stored JSON untouched.
`acme` manages one private worker job; `scripts/runtime/acme_provider.py` adapts Certbot
webroot/Cloudflare/OVH/RFC2136. Only the running HTTP-01 job's names and token files
are exposed by the reserved challenge middleware. Provider files remain private,
not part of SQLite or generic configuration exports. Issuance/import are separate.
`prometheus` owns a dedicated read-only HTTP router, actual-peer CIDRs, environment
bearer token and bounded scrape work. It does not share administrator cookies.
Instrumentation is process-local; per-vhost counters reuse stable-ID observations
across configuration changes. Domain cache gauges intentionally reset with revision.
Admin request timing measures response headers, while proxy/vhost timing measures
complete body/tunnel lifetime. Neither renderer queries a provider or SQLite to
handle public proxy requests.
## 0.7 renewal, access and deployment boundaries
A shutdown-tracked weak-reference scheduler selects due configured managed
certificates. It shares the issuance semaphore and per-profile advisory lock.
Provider work remains outside request paths. Publication validates PEM/key/names
and the complete current configuration, then uses the same SQLite CAS/snapshot
path. Only changed generations publish; failures do not touch the active snapshot.
TCP and QUIC handshakes consult current TLS state. Certificate publication also
uses the existing cache-generation invalidation. See RENEWAL.md for timing/limits.
Basic Auth is a separate vhost gate after cumulative IP ACLs and before static
responses, cache and upstream selection. Argon2id verification is bounded by a
shared semaphore held by blocking workers and an IP failure table. Hashes, not
passwords, are persisted. A verified Authorization header is optional and reapplied
after custom request headers; unverified identity headers are removed. Protected
responses bypass the shared cache and authentication failures cannot become custom
200 responses. WebSocket admission uses the same gates before opening a tunnel.
`pool_eligible=false` distinguishes a location-pinned backend from ordinary pool
membership. The GUI edits the same prefix route model as the configuration API;
there is no second nginx-style parser or unvalidated location execution path.
Server deployment uses root-owned immutable release directories, an atomic current
symlink, a non-root systemd service and private service-owned data. A stopped-state
backup precedes update publication. Failed health/version checks attempt to restore
both old code and data; backups and failed-generation state remain for recovery.
Live systemd/database recovery acceptance is not replaced by helper mocks.
+39
View File
@@ -0,0 +1,39 @@
# Encrypted backup and recovery
RustProxy can create a password-protected **full application backup** while the proxy is running. Restore is a separate, CLI-only operation that requires the service to be stopped. A configuration-history entry is **not** a substitute for a full backup.
## Create a backup
```bash
rust-proxy --data-dir /var/lib/rustproxy backup create \
--output /private/rustproxy-backup.rpb
```
The tool asks for a password in interactive operation or may provide a generated password according to CLI mode; preserve it independently of the archive. Store the encrypted `.rpb` file **off-host** with access controls. Never place backup archives in an exposed Web Service document root.
Backups include database state, RustProxy's private Secret Store material, managed certificates and other durable files under the data directory. They **exclude** rebuildable COLD/HOT cache data, most logs/temporary files and the managed Python environment. Certificates/keys referenced through **external absolute paths**, such as `/etc/letsencrypt/live`, must be backed up and restored separately with their permissions.
## Restore
**Stop RustProxy first** and perform restore using an interactive terminal:
```bash
sudo systemctl stop rustproxy
rust-proxy --data-dir /var/lib/rustproxy backup restore \
/private/rustproxy-backup.rpb
sudo systemctl start rustproxy
```
Use an appropriate service user/elevation and keep ownership/permissions intact. RustProxy validates the password, encrypted package, manifest/checksums, SQLite data, Secret Store consistency and compatibility before activating staged restored data. A failed validation should not replace the running state, but an external off-host copy is still essential.
After restore, reattach any external certificate mounts/files and service secrets, check network listeners, then verify the panel login, Vhosts, certificates, Docker agents, cache policy and API credentials. Test a backup restore periodically on an **isolated** environment; do not first test recovery on the live server.
## Secret Store
The application stores sensitive management records using a private local encryption key. Copying only `config.sqlite3` is not enough for disaster recovery: without the associated key, encrypted records may be unreadable. Do not commit the key, database, backup password or unredacted provider credentials to source control. A lost backup password cannot be recovered by RustProxy.
External systems (system Certbot, PHP-FPM, reverse proxies, Docker host agents, DNS credentials) may need their own backups because their complete state is not owned by RustProxy.
## Before an update
Create and verify an off-host backup, record release version and current listener state, plan a maintenance window, update, then run application-level checks. Managed installation rollback is useful but should not be relied on as the sole recovery plan. See [Installation](INSTALL.md) and [Migration](MIGRATION.md).
+45 -361
View File
@@ -1,381 +1,65 @@
# Shared hot/cold cache
# HTTP cache — HOT, COLD and invalidation
See [CACHE_DIAGNOSTICS.md](CACHE_DIAGNOSTICS.md) for practical HIT/MISS/BYPASS diagnosis.
RustProxy provides an optional shared HTTP response cache with a bounded in-memory **HOT** tier and optional persistent-disk **COLD** tier. Both the global cache and a Vhost's effective policy must permit caching. **COLD storage is disabled by default**, and newly created Vhosts do not automatically cache dynamic content.
## Scope
## Enable caching safely
RustProxy implements a bounded reverse-proxy cache with a hot RAM tier and an optional
persistent cold disk tier. Version 0.11.5 aligned request-method handling and most logical
hash behavior with Varnish built-in semantics. Since 0.19.1 the logical primary identity
includes the frontend scheme as well as Host + exact request URL, so `http://host/path`
and `https://host/path` cannot reuse the same stored response. RustProxy still isolates
different vhost/config scopes internally and uses response `Vary` to select representation
variants.
1. Open **Settings → Cache**, enable the global cache and choose memory limits. Enable COLD only if you want durable disk storage and have enough private disk capacity.
2. On the appropriate Vhost, enable cache for public, repeatable responses. Restrict or disable it for login, personalized pages, uploads, APIs and responses carrying secrets.
3. Ensure the origin supplies a valid public freshness policy, or configure a safe default TTL where supported. `Cache-Control: private` and `no-store` must prevent shared storage.
4. Optionally enable HIT-only diagnostics. Send repeated identical requests using `curl`, then inspect **Cache → Domains / Stored objects** for `MISS → HIT`.
5. Check TTL, revalidation and invalidation after updating the origin's content or configuration.
This is not a VCL implementation. Generic Varnish grace, ESI, public unauthenticated
unrestricted public BAN/PURGE and distributed cache state are not implemented. RustProxy 0.22.1 adds bounded
foreground request collapsing, authenticated selective PURGE/BAN and optional proactive
refresh while keeping directive-controlled stale delivery through RFC 5861 and request
`max-stale`; it is not an unconditional availability/grace layer. RustProxy also keeps its
validator-based handling of response `no-cache` instead of reproducing Varnish hit-for-miss
behavior exactly.
## Stored object rules
## Web Server origins (0.29.4)
- Reuse is scoped by frontend scheme, Host, exact client URL, Vhost/content policy and eligible `Vary` dimensions. HTTP and HTTPS objects do not cross-share.
- GET and suitable HEAD requests may reuse a full object; changing request methods do not silently invalidate all cached GET results.
- Authorization, dynamic cookies, `Set-Cookie`, sticky-affinity cookies, Tinyauth and other private signals normally cause **BYPASS**. A limited static-file cookie exception exists for configured static extensions and does not override `private`, `no-store`, `Set-Cookie` or `Vary: Cookie`.
- Origin status, response size, TTL, unsupported metadata, range/conditional semantics and request/client cache directives can prevent fill or require validation.
- A Web Service selected as a Vhost source uses the same cache policy for eligible local files. File changes can be checked with `ETag` / `Last-Modified`; a 304 validation may refresh a stored body without downloading it again.
A Web Service selected as the vhost source now uses the normal RustProxy shared
object-cache pipeline when both global and vhost cache policy permit it.
`Cache-Control: public, max-age=...` (or another eligible freshness policy)
can produce `MISS` followed by `HIT` instead of a browser-only cache header.
Vhost response rules run before cache fill, and transformed output is what is
stored. Private/`Set-Cookie`/`no-store` origin responses remain ineligible,
even if a later response-header rule replaces their visible cache headers.
## Freshness and diagnostics
For Web Server sources (0.32.2), requests with `Cache-Control: no-cache`,
`max-age=0`, or an expired/inadequately fresh representation check the local
origin using the stored origin `ETag` / `Last-Modified`. When the Web Server
returns `304 Not Modified`, RustProxy refreshes the same shared-cache object,
keeps its body and increments `X-Cache-Hits`. The original browser conditional
headers determine whether the client receives 304 or the cached 200 body.
Changed static files cause a new response/cache fill; `no-store`/`private`
origin policy continues to prevent shared storage. HEAD and single-range
requests can reuse a successfully revalidated full object. A Web Server
source does not run the remote-backend background revalidation worker or its
stale-if-error fallback. A PHP handler that ignores validators may still
return 200, which must be handled as a real origin response rather than an
assumed unchanged object.
| State | Interpretation |
|---|---|
| `MISS` | No suitable fresh cached representation was found; the origin was consulted. The response may still be ineligible for storage. |
| `HIT` | A matching stored object was reused. |
| `BYPASS` | Cache was disabled or a policy intentionally skipped it (for example authorization, cookies or a path exclusion). |
| Revalidated | An existing representation was checked against the origin; a `304` can retain the body. |
## HTTP BAN / PURGE from authorized client IPs
`Age` is total HTTP age, not only the time spent in RustProxy. The optional `X-Cache-Resident-Age` reports the cache residency interval. Debug headers such as `X-Cache`, `X-Cache-Hits` and reason labels are optional and should be enabled deliberately. Browser hard reloads commonly force revalidation, so test with `curl` when investigating.
The **Settings → Cache → HTTP BAN / PURGE IP allowlist** control sets
`cache.http_invalidation_allowed_cidrs`. The default is **empty (disabled)**.
Allow individual client IPs or small CIDR ranges, one per line. Saves validate
IPv4/IPv6 CIDR syntax and reject `/0` networks. An allowed request must also
pass the vhost listener binding, global/vhost ACL, and rate limits. The
operation is restricted to the matched external HTTP/HTTPS scheme, `Host` and
vhost; it cannot choose other tenants or the opposite scheme through headers. Commands use the existing
cache invalidation engine, are audit-logged before execution and share a
two-worker maintenance limit with authenticated admin invalidation (429 when
busy). They are never forwarded upstream.
Example, replacing the listener and Host values with yours:
- `PURGE /path?query` immediately invalidates exact cached representations of
that URL. Returns `200` with JSON result/count. Other query variants are not
matched.
- `BAN /prefix` records a lazy prefix ban of older cached representations
for that host/vhost. Returns `202` and ban ID, normally expires after one hour.
Optional `X-RustProxy-Ban-Ttl: 30` sets 1..604800 seconds. Newly filled
representations are allowed (Varnish-like lazy BAN).
- `BAN /` requires `X-RustProxy-Confirm-Wide: 1` to prevent accidental
whole-host invalidation. Request bodies, upgrades, arbitrary selectors and
wildcard cross-vhost operations are rejected.
Examples from an allowlisted machine (replace the domain and path):
```sh
curl -i -X PURGE 'https://site.example/assets/app.js?v=3'
curl -i -X BAN 'https://site.example/assets/'
curl -i -X BAN 'https://site.example/' -H 'X-RustProxy-Confirm-Wide: 1'
```bash
for n in 1 2 3; do
curl -sS -D - -o /dev/null -H 'Host: app.example.com' \
http://127.0.0.1:8080/public.css
sleep 2
done
```
**Behind a reverse proxy:** forward nonstandard `BAN`/`PURGE` methods to the
RustProxy public listener; do not let an upstream cache intercept them. Configure
RustProxy's existing `forwarding.trusted_proxies` accurately and ensure the
edge sanitizes/replaces untrusted `Forwarded`/`X-Forwarded-For` headers. When a
trusted proxy does not provide a usable real-client chain, BAN/PURGE fails
closed. Do **not** allowlist an entire proxy NAT network unless all its users
should have destructive cache access. Unlike full Varnish VCL, this interface
intentionally provides only exact PURGE and bounded URL-prefix BAN, not an
arbitrary untrusted regex/VCL expression.
Look for cache reason labels including `request_cookie`, `request_authorization`, `response_cache_control`, `route_disabled` and `cache_hit` when diagnostic headers are enabled. A `MISS` alone does not prove the object was admitted to HOT/COLD.
## Settings
## Advanced behavior
| Field | Default | Bounds / meaning |
|---|---|---|
| enabled | true | Global master switch; vhosts may disable cache but cannot re-enable a globally disabled cache |
| max_bytes | 268435456 | Hard HOT allocation budget (body backing allocation + charged entry metadata); minimum 64 KiB, no artificial high ceiling |
| max_object_bytes | 16777216 | Maximum logical body accepted by one fill; minimum 1 KiB and at most `max_bytes` |
| max_entries | 4096 | 1..16384 hot objects |
| transient_max_bytes | 67108864 | Hard RAM budget for fill buffers and complete bodies moving between HOT/COLD |
| cold_queue_max_bytes | 33554432 | Byte ceiling for complete demoted objects waiting for the COLD writer; at most `transient_max_bytes` |
| hot_eviction_max_objects | 64 | 1..4096 LRU removals attempted for one HOT admission before the new object passes uncached |
| hot_auto_trim_enabled | true | Periodically trim sufficiently idle HOT objects only while HOT usage is above the target waterline |
| hot_auto_trim_after_seconds | 300 | 5..86400; minimum idle age before an object can be removed by automatic HOT trim |
| hot_auto_trim_target_percent | 80 | 10..95; automatic trim stops once HOT usage reaches this percentage of `max_bytes` |
| cold_enabled | false | Enable persistent disk tier; never implied by hot-cache enable |
| cold_path | cache/cold | Relative path under the private RustProxy data directory |
| cold_max_bytes | 4294967296 | 64 KiB..1 TiB disk budget |
| cold_max_entries | 50000 | 1..1000000 cold objects |
| cold_gc_interval_seconds | 60 | 5..86400; interval between asynchronous pressure-GC checks |
| cold_gc_max_delete | 256 | 1..10000; maximum objects removed by one automatic GC pass |
| cold_after_seconds | 300 | 5..604800; idle hot objects are eligible after this delay |
| cold_on_idle | true | Move idle eligible hot objects to cold storage |
| cold_max_hot_seconds | 3600 | 5..2592000; total RAM-residency age used when `cold_on_age=true` |
| cold_on_age | false | Demote objects after the maximum hot-tier age even if they remain frequently accessed |
| cold_on_lru | true | Move objects displaced by hot-tier LRU pressure to cold storage |
| cold_on_shutdown | true | Best-effort direct hot-to-cold flush during ordinary graceful shutdown/restart |
| default_ttl_seconds | 0 | Only explicit public responses without origin freshness |
| max_ttl_seconds | 86400 | 1..604800; maximum configured retention/freshness cap |
| bypass_query | false | Bypass requests containing a query when enabled |
| bypass_prefixes | [] | Up to 64 normalized absolute path prefixes |
| http_invalidation_allowed_cidrs | [] | Opt-in IP/CIDR allowlist (max 32) for HTTP BAN/PURGE. Empty blocks all HTTP methods; `/0` is invalid. |
| static_ignore_cookies | true | For configured static extensions only, ignore request Cookie for eligibility/keying |
| expose_status | false | Emit `X-Cache: HIT`, `MISS` or `BYPASS` |
| expose_hits | false | Emit per-object `X-Cache-Hits` |
| expose_cache_status | false | Emit standardized `Cache-Status` (RFC 9211) |
| expose_resident_age | false | Emit `X-Cache-Resident-Age` on reused stored responses |
| via_header | `RustProxy` | Append this `Via` response value; `null` disables it |
| collapse_enabled | true | Collapse concurrent foreground fills for the same primary cache key |
| collapse_wait_timeout_ms | 1500 | 10..60000 ms follower wait before fallback |
| collapse_timeout_fallback | origin | `origin` or `stale_if_available` after follower timeout |
| stale_if_error_timeout | true | Allow RFC 5861 stale fallback for timeout failures |
| stale_if_error_connection | true | Allow stale fallback for connection failures |
| stale_if_error_dns | true | Allow stale fallback for DNS failures |
| stale_if_error_tls | true | Allow stale fallback for TLS failures |
| stale_if_error_statuses | [500,502,503,504] | Up to 32 unique eligible HTTP 5xx statuses |
| proactive_refresh_enabled | false | Schedule sampled cached objects for validator refresh before expiry |
| proactive_refresh_before_expiry_seconds | 15 | 1..604800 seconds before expiry |
| proactive_refresh_percent | 10 | 0..100 deterministic percentage of primary keys |
| proactive_refresh_max_inflight | 4 | 1..64 concurrent proactive refreshes |
- **Budget limits:** HOT RAM capacity, object count/size, transient fill memory and optional COLD byte/object limits. Configure based on real workload and monitor disk/RSS.
- **HOT/COLD movement:** entries may be demoted, recovered or expired; a periodic/explicit COLD cleanup is available. Cache storage is not a permanent archive.
- **Request collapsing:** eligible concurrent MISS requests can share a bounded fill, limiting origin bursts.
- **Stale-if-error and refresh:** conditional options can help on eligible backend failures, but are not an unconditional stale-serving guarantee. Web Server local-file validation has a distinct policy.
- **Compression/transformation:** cached variants and validators must remain compatible with client encoding and content-modification settings.
The hot tier enforces `max_bytes` and `max_entries` before publication. HOT accounting uses the retained body allocation (the fill buffer capacity transferred into `Bytes`) plus directly owned entry metadata and a conservative per-entry charge for the HOT hash/LRU/variant indexes, while `hot_logical_bytes` reports only visible body lengths. Rust container/allocator APIs do not expose every bucket/node allocation exactly, so `hot_allocated_bytes` is intentionally a conservative charged estimate rather than a promise that it equals process RSS. In-progress fills reserve a separate bounded transient budget before growing their buffers. When HOT pressure would exceed the budget plus a small allocator-rounding headroom (roughly 1/64 of HOT, capped at 1 MiB), RustProxy removes LRU objects first; if admission still cannot be made safe within `hot_eviction_max_objects`, the response continues normally without being cached. `max_object_bytes` is checked incrementally before every body-buffer growth, so an oversized response is not fully accumulated before rejection.
## Clear or invalidate content
Automatic HOT trim is a separate low-pressure maintenance path. Every maintenance pass first checks whether HOT allocation is above `hot_auto_trim_target_percent` of `max_bytes`; if so, only least-recently-used objects that have been idle for at least `hot_auto_trim_after_seconds` are eligible. One pass is capped by `hot_eviction_max_objects`. Active/recent objects are never removed by this mechanism. Eligible objects follow the existing COLD-on-LRU policy when enabled; otherwise they are evicted. This is intended to release stale working-set memory after traffic bursts without periodically purging the cache or reloading vhosts.
Authenticated Cache page/API operations can purge selected entries, domains or larger scopes. The optional public HTTP `PURGE`/`BAN` interface is **disabled by default**. Enable it only by listing trusted client IPs/CIDRs under **Settings → Cache → HTTP BAN / PURGE IP allowlist**; operations are additionally subject to Vhost binding and normal access controls. `PURGE` addresses an exact URL; `BAN` applies the supported bounded matching rules. Never allow untrusted Internet clients to purge all tenants' objects.
When cold storage is enabled,
`cold_on_lru`, `cold_on_idle` and `cold_on_age` independently choose which hot objects are demoted.
`cold_after_seconds` measures time since the last access. `cold_max_hot_seconds` measures total
hot-tier residency, so it can move a frequently accessed object to disk after a configured age.
The administration UI exposes these as separate policies to avoid conflating "old" with "unused".
Demotions use an asynchronous queue bounded by both object count and `cold_queue_max_bytes`. A demoted `Entry` is removed from HOT accounting and moved, not copied, into a `ColdDraft`; its `Bytes` payload remains charged to `transient_max_bytes` until persistence completes and the draft is dropped. The persisted `ColdEntry` contains compact lookup/invalidation/LRU metadata rather than the body allocation. Since 0.27.3 it also does not retain the full response `HeaderMap` or origin validator values per COLD object; those are validated and loaded from the existing metadata JSON only for an actual COLD read. The in-memory body digest is 32 raw bytes and the map-owned cache key is not duplicated inside the entry. This reduces long-lived COLD index RAM without changing Cold format 3 on disk. This prevents the COLD writer queue from becoming a second unbounded RAM cache. If an HTTP response is already using a clone of the same `Bytes`, that last transport-owned reference can keep the backing allocation alive until the response finishes; the cache-owned HOT/COLD reference itself is released after persistence. Process RSS is therefore monitored separately from cache accounting. Supported
glibc/Linux builds ask the allocator to release unused pages after bulk demotion. During
ordinary graceful shutdown, `cold_on_shutdown` performs a best-effort direct flush of remaining hot
objects before the store is closed. A **managed software update** (`update.sh` / `scripts/deploy/deploy.py`)
uses a private one-shot marker and, on RustProxy 0.13.2+, forces this hot-to-cold handoff whenever
the cold tier is enabled even if `cold_on_shutdown=false`. The updater gives the process a larger
stop budget and removes the marker before backing up state. If the cold tier is disabled there is
nowhere persistent to hand the hot objects, so RAM-only cache is still lost on process exit. The
body is stored as a raw `.body` file and metadata as JSON with a SHA-256 body digest. A fresh cold hit is integrity-checked and promoted to
RAM. Cold format 3, introduced in 0.21.0, stores objects below the first two hexadecimal
characters of the SHA-256 cache key, for example `cache/cold/7f/<hash>.body` and
`cache/cold/7f/<hash>.json`. Shard directories are created lazily. The previous flat
Cold Cache layout is deliberately not migrated; legacy flat objects are removed and can
be refilled from origin. Format 3 metadata also carries RFC 5861 windows, revalidation
flags, origin validators, last access and hit count.
In 0.34.0, changing operational Vhost settings is intended to preserve compatible HOT/COLD objects; content-affecting changes invalidate affected scopes and COLD carries an additional fingerprint against stale resurrection. **This new behavior still needs release-level verification**, so validate cache after configuration edits and during upgrades. Earlier persistent cache entries may need refilling following format/compatibility transitions.
Automatic Cold Cache GC is asynchronous. When either byte or entry usage reaches about
90% of its configured limit, it selects old/rarely used candidates and cleans toward
about 80%, removing at most `cold_gc_max_delete` objects in one pass. Metadata is
snapshotted in small batches and disk deletion is queued instead of holding request-path
state across a full-cache sort/delete loop.
## Troubleshooting
## Varnish-style identity and variants
- **Always BYPASS:** check both cache enable switches, login/cookies, exclusion rules, methods, Tinyauth and sticky sessions.
- **MISS never becomes HIT:** check response `Cache-Control`, status, object size, `Vary`, `Set-Cookie`, incomplete origin streams and whether the same scheme/Host/query is used.
- **Content unexpectedly old:** inspect Age, TTL, validators, stale rules and previous invalidation; purge only affected content after confirming its identity.
- **Memory/disk pressure:** review HOT/COLD budgets and cleanup, rather than increasing limits without measurements.
The logical primary cache identity is:
- a stable cache-compatibility revision derived only from settings that can change
cached representation/origin semantics, plus vhost scope (internal isolation),
- normalized frontend scheme (`http`/`https`),
- normalized original Host authority, including an explicit port,
- exact request path and query.
Selected backend-pool member, route/backend IDs and unrelated request headers are not
part of the representation hash. This means two healthy members of the same pool can
serve/fill the same public cache object, while HTTP and HTTPS remain isolated. Headers
such as User-Agent, X-Forwarded-For or browser client hints no longer create unnecessary
variants by themselves.
The response `Vary` header provides the secondary representation identity. For example,
`Vary: Accept-Language` stores independent language variants under the same scheme + Host + URL
primary hash. `Vary: *`, malformed Vary fields or an excessive Vary field set are not
stored. If an origin changes the Vary schema for a URL, incompatible old variants are
removed so an obsolete no-Vary object cannot shadow a newer Vary representation (or
vice versa).
Cookie and Authorization requests still pass by default, matching Varnish built-in
safety behavior. RustProxy's static-cookie optimization can ignore Cookie only for the
configured static file extensions; response `Set-Cookie`, `private`, `no-store` and
`Vary: Cookie` still prevent unsafe shared reuse.
### Per-vhost forced-cache targets
A vhost can opt into an explicitly unsafe `cache.unsafe_force` list for narrowly selected public request targets. It is intentionally not available as a global cache setting. Each configured target starts with `/`; a target without `?` matches by normalized path prefix, while a target containing `?` matches the original path+query prefix.
For matching GET/HEAD requests, forced mode may ignore request-side bypasses caused by a query string, Cookie, Authorization, `Cache-Control: no-cache`/`no-store`/`max-age=0`, and `Pragma: no-cache`. Protocol-safety exclusions such as Upgrade, unsupported Range/preconditions and request bodies remain in force. Cookie and Authorization values are added to the cached representation's `Vary` identity in forced mode so different credentials/cookies cannot reuse each other's object.
Forced mode does **not** override response-side privacy policy. `Set-Cookie`, `Cache-Control: private`, `no-store`, `Vary: *`, partial/trailer/SSE/gRPC responses and the normal size/lifetime validation still prevent storage. Use this only for URLs whose origin semantics are understood; ordinary cache defaults remain safer.
## Request methods and invalidation
GET and HEAD participate in lookup. They share one representation identity. On a
cacheable HEAD miss RustProxy fetches the backend representation as GET, may populate
the shared object, and strips the response body before delivering the original HEAD
request.
POST, PUT, PATCH, DELETE, OPTIONS and TRACE pass to the backend and are not stored.
They do **not** implicitly purge the whole cache. This fixes the pre-0.11.5 behavior
where any successful unsafe request could erase unrelated static objects and destroy
the hit ratio.
Cache invalidation remains explicit:
- authenticated full purge can clear all objects or one vhost scope,
- selective PURGE supports exact URL, URL prefix, `Cache-Tag`, safe regex and primary cache key, with optional host and vhost restrictions,
- exact/prefix matching uses the stored request target including query; a match removes the whole `Vary` family under that primary cache key,
- selective PURGE operates on RAM and Cold Cache and advances a publication generation so no fill that started before the operation can publish afterward, even if the selector depended on response metadata such as `Cache-Tag`,
- BAN records a bounded TTL rule without synchronously scanning the entire store; objects created before a matching active BAN are rejected during lookup/insertion and are removed later by bounded GC,
- new fills created after a BAN rule are allowed, so BAN invalidates the old representation set rather than permanently blocking a URL,
- regex rules and prefix `/` are treated as broad operations and require explicit confirmation,
- only cache-compatibility changes advance the normal cache generation/revision; unrelated logging/TLS/admin/metrics/response-presentation saves retain objects, and cache request-bypass edits plus response-only `X-Cache`/`Cache-Status`/resident-age/`Via` diagnostics also apply without purging stored objects.
Unrestricted anonymous cache-maintenance access is not exposed: the public
HTTP BAN/PURGE interface is explicitly IP/CIDR-authorized (empty by default),
separate from the administrator-only selective invalidation API. If an
application changes a GET resource after POST/PUT/PATCH/DELETE, use origin
freshness/validators or one of the explicit invalidation paths; RustProxy
does not guess the affected URLs. For exposure review and test scenarios, see
[0.31.0 security audit](SECURITY_AUDIT_0.31.0.md).
## Eligibility and freshness
Hard request bypasses include Cookie (outside the static exception), Authorization,
Upgrade, `If-Match`, `If-Unmodified-Since`, nonempty request bodies, configured path/query
exclusions and request `Cache-Control: no-store`.
A single `Range: bytes=...` request is special rather than a normal bypass. When a complete
fresh cached representation is available, RustProxy evaluates `If-Range` and can return
`206 Partial Content` directly with `Content-Range`, `Accept-Ranges`, the correct sliced
`Content-Length`, validators and normal cache `Age`; an unsatisfied range returns `416`
with `Content-Range: bytes */<length>`. A mismatching `If-Range` returns the complete cached
representation. If origin access is needed, RustProxy forwards the Range request as an
identity, non-transformed stream. Multiple/multipart, duplicate or otherwise unsupported
Range forms are passed through to origin and are never stored as separate range objects.
A fresh cached representation can satisfy `If-None-Match`/`If-Modified-Since` locally.
Request `no-cache`, `max-age=0` and legacy `Pragma: no-cache` require validation when a
safe stored validator exists. `max-age=N` and `min-fresh=N` are freshness constraints:
they reuse the cached response when its current Age/remaining freshness satisfies the
client limit and otherwise drive normal revalidation/fetch behavior. Request `max-stale`
can explicitly allow a retained stale response: bare `max-stale` accepts any retained
staleness, while `max-stale=N` bounds it. Freshness expiry does not itself physically
delete an object; normal RAM LRU/Cold Cache GC may still evict it. `must-revalidate` and
`proxy-revalidate` override client stale permission. With a safe validator, an origin
304 refreshes metadata and reuses the stored body.
Responses with `Set-Cookie`, `private`, `no-store`, `Content-Range`, trailers,
SSE/gRPC, `Vary: *`, invalid freshness metadata or unusable size/lifetime are not
stored. Response `Cache-Control: no-cache` may be retained only with ETag or
Last-Modified and must validate before every reuse. Both origin and transformed response
policy must remain eligible.
`s-maxage` takes precedence over `max-age`, then Expires/Date. `Age` uses the RFC 9111
current-age model and includes origin age; it is not a measure of how long the object has
been in RustProxy. Cache metadata keeps normal TTL, `stale-while-revalidate`,
`stale-if-error`, `must-revalidate` and `proxy-revalidate` separately. A representation
is therefore **fresh**, **stale-but-servable** only under an applicable directive, or
**expired/unusable**.
Inside `stale-while-revalidate=N`, RustProxy may return stale immediately and starts one
deduplicated background refresh for that cache identity. Inside `stale-if-error=N`, stale
is used only after an eligible origin failure. Version 0.22 makes timeout, connection, DNS
and TLS classes independently configurable and uses an explicit configurable 5xx status
allowlist; defaults preserve the previous behavior (`500`, `502`, `503`, `504` plus all four
transport classes). After TTL plus the corresponding stale window, that mode is unavailable.
`must-revalidate` and `proxy-revalidate` still permit storage, but prohibit all stale delivery
after expiry, including RFC 5861 and request `max-stale`.
Optional proactive refresh schedules a deterministic percentage of cache keys before normal
expiry. It reuses the same validator-based refresh logic and background-revalidation guard,
keeps only one schedule per key, and enforces a global concurrent-refresh bound. A newly
completed fill can schedule this work immediately, so sampled objects do not require another
client request to start the pre-expiry timer. The feature is disabled by default.
## Resident age
`X-Cache-Resident-Age` is the RustProxy-specific whole-second clock for how long the
stored representation has remained in this cache. In 0.11.5 that clock is independent
from the HTTP freshness `Age` clock and survives:
- ordinary hot-cache HITs,
- successful backend 304 revalidation,
- hot-to-cold demotion,
- cold-to-hot promotion.
A value of `0` is valid only during the first sub-second of residency. Revalidation or
tier movement must no longer reset a long-lived object to zero.
## Streaming and capacity
RustProxy tees response frames while delivering them; it does not wait for the complete
origin body before sending the first bytes. Only a complete successful body is
published. Oversize, body error, trailer and cancelled-fill paths discard the candidate.
Large buffers grow incrementally and consume multiple bounded fill permits.
The configured resident byte limit accounts for body bytes, estimated header storage
and fixed per-entry overhead; it is not total process RSS. Active HIT references,
in-flight origin responses, compression buffers and runtime allocations also use memory.
With `collapse_enabled=true`, the first foreground MISS for a primary cache key owns the
fill while concurrent followers wait for publication. The fill guard is held through the
actual response-body tee, so followers cannot wake merely because origin headers arrived.
Leader error/cancellation releases waiters. A follower that wakes to a different `Vary`
representation re-enters the single-flight decision instead of immediately stampeding the
origin. After `collapse_wait_timeout_ms`, the configured fallback is either an independent
origin request or an eligible stale response. SWR/background revalidation keeps its separate
existing deduplication guard.
## Diagnostics and API
`GET /api/cache` reports settings, HIT/MISS/BYPASS, hot/cold counts and bytes,
configured COLD byte/object limits, promotions/demotions, errors, fill pressure, object
views and per-domain reason counters. The `/cache` dashboard shows HOT and COLD
occupancy against the configured object-count and byte limits with progress bars and
explicit percentages. HOT byte occupancy uses the charged/allocated HOT value rather
than logical body bytes; COLD byte occupancy uses persisted storage bytes. Hit ratio is
`hits / (hits + misses)` and intentionally excludes BYPASS traffic.
`POST /api/cache/purge` requires the existing administrator session, same-origin marker
and CSRF token. `?vhost=<id>` purges only that vhost scope; omitting it purges all cache
objects. Purge is audited.
`POST /api/cache/invalidate` performs authenticated selective `purge` or `ban` using
`exact`, `prefix`, `tag`, `regex` or `cache_key`. Optional `host` and `vhost` restrict scope;
BAN accepts a bounded TTL. Regex and prefix `/` require `confirm_wide=true`. The result
reports the selector/scope, affected entry count, elapsed time, status and BAN id when one
was created. `GET /api/cache/bans` lists active rules and `DELETE /api/cache/bans/{id}`
removes one. These operations use the normal administration audit path. The Cache panel
exposes the same controls plus per-object **Purge URL**.
`POST /api/cache/cold/cleanup` starts a selective asynchronous Cold Cache cleanup using
the same candidate/removal machinery as automatic GC. Criteria can select entries unused
for a duration, cap removal to the oldest N objects, and/or require `hits == 0`.
`GET /api/cache/cold/cleanup/{id}` reports job status, removed object count and freed
bytes. Purge-all and purge-vhost remain the full-clear mechanisms; selective invalidation
is deliberately separate.
`X-Cache`, `X-Cache-Hits`, `Cache-Status`, `X-Cache-Reason` and
`X-Cache-Resident-Age` are optional diagnostics. `Via` is a separate configurable response header, enabled by default with value `RustProxy`; RustProxy appends it instead of deleting an upstream `Via` chain. `Cache-Status` is the standardized
RFC 9211 field name; RustProxy does not rename it to the legacy/custom `X-Cache-Status`.
HTTP/2 and HTTP/3 serialize field names in lowercase, so `cache-status:` on the wire is
normal. When the cached representation was produced by the transform pipeline,
RustProxy appends the extension parameter `transformed`, for example:
```http
Cache-Status: RustProxy; hit; ttl=556488; detail="cache_hit"; transformed
```
Origin lookalike values (`Cache-Status`, `X-Cache-Status` and the other RustProxy
cache diagnostics) are removed before RustProxy emits its own diagnostics. HTTP `Age`
is generated from cache freshness metadata on reused responses. For 0.26.6 configuration
compatibility an explicit response-header rule may set/append/remove `Age`, but that rule
runs only after cache lookup/revalidation/storage decisions and therefore cannot alter the
internal freshness clock. The Cache dashboard also marks transformed objects explicitly.
## Pool behavior
Backend selection and health policy still run before an origin fetch. The selected pool
member no longer fragments the cache identity in 0.11.5, so a fresh public object can be
reused regardless of which healthy member would have been selected for a MISS. A pool
with no eligible member still returns 503 before RustProxy can use the cache; this cache
is not a stale-availability layer.
Cookie affinity and protected/authenticated traffic keep their existing privacy bypass
rules. Per-vhost response/security policy also applies to HIT responses.
See [Monitoring](MONITORING.md) for cache telemetry and [Security](SECURITY.md) for privacy boundaries.
-223
View File
@@ -1,223 +0,0 @@
# Cache diagnostics and behavior
This describes implemented source, not a verified Rust runtime. See
[TEST_REPORT.md](TEST_REPORT.md).
## 0.29.7: show diagnostic headers only on cache HIT
Set `cache.expose_only_hits: true` globally (Settings > Cache) to emit
`X-Cache`, `X-Cache-Hits`, `X-Cache-Reason`, `Cache-Status` and
`X-Cache-Resident-Age` only when the response is actually served from
RustProxy cache. MISS and BYPASS continue to be recorded in internal metrics
and access logs but these diagnostic headers are absent from those responses.
Cached stale/revalidated/range/conditional responses use the HIT outcome and
continue to show cache diagnostics. Individual vhosts can override the global
flag with `vhosts[].cache.expose_only_hits: true | false | null` (`null`
inherits). Changing the flag alone does not purge cached objects.
The independent `Via` proxy-chain header can still appear on MISS/BYPASS when
`cache.via_header` is configured. Set it to `null` to turn off that header.
Existing `expose_status`, `expose_hits`, `expose_cache_status` and
`expose_resident_age` switches still select which individual HIT headers are
emitted.
## 0.19.1 cache identity safety
The frontend scheme is part of the primary cache identity. `http://host/path` and
`https://host/path` therefore cannot share an object, preventing responses that depend
on `X-Forwarded-Proto` or equivalent origin logic from crossing schemes.
## 0.11.5 cache changes
The cache follows the Varnish built-in model for the behaviors that most directly
affect hit ratio, with the 0.19.1 scheme-isolation difference noted above:
- GET and HEAD use the cache; other ordinary methods pass without clearing unrelated objects.
- The logical primary identity is frontend scheme + normalized Host authority + exact URL inside the current vhost/config scope.
- Selected backend-pool member and unrelated request headers do not fragment the primary object.
- Response `Vary` chooses representation variants under that object.
- GET and HEAD share the same stored representation; a cacheable HEAD miss can fill it from a backend GET.
- `X-Cache-Resident-Age` survives 304 revalidation and hot/cold tier movement.
RustProxy still deliberately bypasses Cookie and Authorization requests by default,
matching Varnish built-in safety behavior. The configured static-extension optimization
can ignore Cookie only for selected static paths, subject to response privacy guards.
## Why objects used to disappear after POST/PUT/etc.
Before 0.11.5 a successful non-safe request could call a global cache purge. A periodic
POST, API heartbeat or form submission could therefore erase every cached static object
even though the request was unrelated to those URLs. In 0.11.5 POST/PUT/PATCH/DELETE,
OPTIONS and TRACE pass through and do not implicitly purge the shared cache.
Only configuration changes that alter cache/origin compatibility invalidate incompatible cache
state; unrelated control-plane saves keep reusable hot/cold objects. Explicit administrator purge
still invalidates the selected cache state. Use the purge action when the application really needs
explicit invalidation.
## `Age` versus `X-Cache-Resident-Age`
These are different clocks:
- `Age` is HTTP freshness age. It can already be thousands of seconds when RustProxy
receives an object because the origin/CDN `Date`/`Age` contributes to it. On an ordinary
fresh HIT it advances from RustProxy's stored receive time even when the backend never
supplied an `Age` header. A successful validator revalidation (backend `304`) starts a
new freshness calculation, so the next `Age` can legitimately be `0`. Requests carrying
`Cache-Control: no-cache`, `max-age=0` or `Pragma: no-cache` can therefore make repeated
validated HITs appear with `Age: 0`.
- `X-Cache-Resident-Age` is RustProxy-specific and counts whole seconds that the stored
representation has remained resident in this cache.
Resident age is preserved across a successful backend 304 and across hot-to-cold/cold-to-hot
movement. An immediate first HIT can legitimately show `0` because less than one full second
has elapsed; a long-lived resident object should then keep increasing rather than repeatedly
resetting to zero. Use this header when the diagnostic question is cache residency rather than
HTTP freshness.
## Practical HIT test
Enable cache plus `X-Cache`, `X-Cache-Hits` and `X-Cache-Resident-Age`, then make
repeated identical requests that read the complete body:
```bash
for n in 1 2 3; do
curl -q -sS -D - -o /dev/null \
-H 'Host: app.localhost' \
http://127.0.0.2:8888/cache
sleep 2
done
```
For an eligible response, expect MISS then HIT/HIT. `X-Cache-Resident-Age` should grow
between later HITs. A browser is a poorer diagnostic because its own cache may satisfy
a navigation without sending any request to RustProxy.
HEAD is now eligible too. It shares the GET representation, but GET is still the best
basic test because it proves that the body was completely consumed and published.
## Vary test
A response with:
```text
Vary: Accept-Language
```
stores language variants under one primary scheme + Host + URL object identity. Two English
requests can HIT each other; a Polish request must not receive the English body. If the
origin later changes between no `Vary` and `Vary: Accept-Language`, incompatible older
variants are removed rather than used as a fallback.
`Vary: *` is not cacheable.
## Cookie/static behavior
`request_cookie` is a BYPASS by default. With `static_ignore_cookies=true`, configured
static extensions such as `.css`, `.js`, fonts and images ignore the request Cookie for
cache eligibility/keying. This is intentionally limited to static paths. A response
with `Set-Cookie`, `Cache-Control: private/no-store`, or `Vary: Cookie` still cannot be
shared through that optimization.
This setting is useful for raising the asset hit ratio for logged-in users without
turning dynamic personalized pages into shared objects.
## Conditional requests and reloads
Fresh cached objects can satisfy `If-None-Match`/`If-Modified-Since` locally. Request
`Cache-Control: no-cache`, `max-age`, `min-fresh` and legacy `Pragma: no-cache` select
validation instead of creating a separate cache key. If a stored validator exists and
the backend returns 304, RustProxy refreshes metadata and reuses the body.
Hard browser reloads commonly add revalidation directives, so they may produce
`cache_revalidated` instead of a plain `cache_hit`. This is different from a cache miss.
## Reason tokens
When cache diagnostics are enabled, these fixed reason tokens explain decisions:
| Token | Meaning |
|---|---|
| `cache_disabled` | Effective cache is disabled. |
| `route_disabled` | Selected route explicitly disables cache. |
| `request_method` | Method is not GET/HEAD. It passes and does not purge the cache. |
| `request_cookie`, `request_authorization` | Private request state bypasses shared cache. Static Cookie exception may apply. |
| `request_no_store` | Client forbids use/storage of shared cache. |
| `request_no_cache`, `request_max_age`, `request_min_fresh`, `request_pragma_no_cache` | Client requests validation. |
| `request_body`, `request_range`, `request_precondition` | Request is outside reusable shared-object semantics. |
| `query_excluded`, `path_excluded` | Configured exclusion matched. |
| `tinyauth`, `sticky_cookie` | Authentication/affinity policy deliberately bypasses shared cache. |
| `response_set_cookie`, `response_cache_control` | Origin response is not safely shareable. |
| `response_vary`, `response_ttl_size_or_metadata` | Vary/freshness/size/metadata prevents storage. |
| `response_headers_policy` | Transformed client-response policy is not cacheable. |
| `cache_miss`, `cache_hit` | No reusable variant was found, or a variant was reused. |
| `cache_conditional_hit` | Fresh cache metadata satisfied the client validator locally. |
| `cache_revalidated` | Backend 304 validated the stored body and it was reused. |
| `cache_revalidated_uncached` | Validation succeeded but refreshed policy no longer permits storage. |
A MISS only means lookup found no reusable object. The later origin response can still
fail storage because of privacy headers, size, incomplete streaming, trailers or a
changed policy.
## Memory accounting in 0.16.0
The process **RSS total** is an operating-system measurement and is intentionally shown separately from RustProxy's own cache accounting. Do not add the rows together: they overlap. The process block reports:
- `RSS total` (`VmRSS`) - all resident pages currently attributed to the process;
- `RSS anonymous` (`RssAnon`) - anonymous resident pages, which typically include heap/stacks and other private allocations;
- `RSS file-backed / shared` (`RssFile` / `RssShmem`) - resident mapped file/shared-memory pages;
- `data address space / swap` (`VmData` / `VmSwap`) - address-space and swapped-page context, not extra resident bytes.
The cache block separately reports memory RustProxy can attribute to the cache subsystem:
- `hot logical` - visible cached body bytes (`body.len()`);
- `hot allocated` - the hard-budget charge for retained backing allocations plus entry metadata (`resident_bytes` remains a compatibility alias);
- `transient` - bounded cache allocations temporarily outside the hot store;
- `fill` - active response buffers being assembled before publication;
- `cold queue` - complete bodies waiting for cold-tier persistence;
- `cold I/O` - bodies currently being written to or read from the cold tier.
This explains why, for example, a process RSS around 630 MiB can coexist with only about 100 MiB of reported hot cache: the old `Pamięć hot` card was never a whole-process memory counter. TLS/QUIC/Hyper/Tokio allocations, thread stacks, configuration/routing state, active request bodies and allocator-retained pages are also part of RSS but are not falsely assigned to a RustProxy subsystem when the source cannot measure them directly.
The existing Linux/glibc allocator-release path remains in place: large cache demotion/purge paths call `malloc_trim(0)` through `release_allocator_memory()`. Since 0.24.5, HOT allocation, active fills and bodies moving to/from COLD are explicitly budgeted. COLD queue admission is byte-bounded as well as count-bounded. RSS is still a process-wide view containing the same cache memory plus TLS/QUIC/Hyper/Tokio, stacks and allocator retention, so cache gauges must not be added to RSS. Long-running acceptance should verify that HOT stays within `hot_limit_bytes`, transient stays within `transient_limit_bytes`, demotion drains transient bytes, and process RSS reaches a practical plateau under representative traffic.
### Sustained traffic, vhost saves and RSS
A vhost configuration save can make RSS fall abruptly for two independent reasons. First, the current cache compatibility identity includes vhost configuration; when that identity changes, publication performs a cache purge so representations created under the old origin/configuration contract cannot survive incorrectly. Second, publishing the newly compiled routing snapshot retires the previous snapshot and its upstream clients; once outstanding references finish, idle connection-pool state and related buffers can be released. A visible RSS drop after saving a busy vhost is therefore a useful diagnostic clue, but saving/reloading a vhost is not a memory-maintenance requirement.
The 0.25.0 development runtime adds two maintenance mechanisms that do not reload configuration:
- every 60 seconds on Linux/glibc, RustProxy calls `malloc_trim(0)` from a blocking maintenance task; this can return allocator pages that are already free, but cannot and does not evict live Rust objects;
- HOT cache maintenance trims only least-recently-used entries that have been idle for the configured delay and only while HOT allocation is above the configured target waterline. The pass is bounded by `hot_eviction_max_objects`; recent/active entries are not purged.
Upstream HTTP clients now also have bounded generations. Each backend Hyper client is recycled after 25,000 requests or 3,600 seconds by default (both limits are configurable under vhost keepalive and either can be disabled with `0`). Recycling replaces only the backend client/pool and leaves the vhost snapshot, HOT cache and configuration untouched. HTTP/1 upstream read buffers are capped at 128 KiB by default. This bounds long-lived Hyper/TLS/pool high-water allocations while preserving normal keep-alive reuse inside each generation. The existing 60-second allocator trim can then return already-free glibc pages to the OS.
## Hot and cold tiers
HOT RAM is bounded by allocated/charged `max_bytes` and `max_entries`; `hot_logical_bytes` separately exposes payload length. Fill and COLD handoff/read memory are bounded by `transient_max_bytes`, and queued demotions also obey `cold_queue_max_bytes`. Optional cold storage demotes LRU
or sufficiently idle hot objects asynchronously. Cold hits verify the body digest and
promote the object back to RAM. Version 0.11.5 uses cold metadata format 2 to persist
Vary identity and resident age; old-format metadata is not reused.
Cold is not required for normal hot-cache stability. With cold disabled, hot objects leave
RAM because of expiry, hot capacity/LRU pressure, explicit purge, configuration generation
changes, replacement of the same logical representation, or the optional HOT auto-trim
waterline. Auto-trim is not a blind TTL: it acts only when HOT is above its configured target
and only on entries idle longer than the configured threshold; it can be disabled independently.
## Cache panel and API
The Cache panel shows hot/cold object counts, RAM/disk usage, HIT/MISS/BYPASS, hit ratio,
fill pressure, evictions/expiry, per-domain decisions and stored objects. Live polling
refreshes the view; it does not itself purge cache objects.
`GET /api/cache?offset=0&limit=50` returns aggregate and object data. Add `vhost=ID` to
filter. `POST /api/cache/purge?vhost=ID` invalidates one vhost; omitting the query purges
all cache objects. Mutations remain authenticated, CSRF-protected and audited.
Hit ratio is `HIT / (HIT + MISS)`. BYPASS requests are excluded, so a large number of
cookie/authenticated requests can reduce backend offload without mathematically lowering
that displayed ratio. For the highest safe reusable-object ratio, give static assets
long explicit public TTLs, keep their URLs stable/immutable, leave the static-cookie
optimization enabled when appropriate, and avoid unnecessary `Vary` fields.
-876
View File
@@ -1,876 +0,0 @@
# Historical developer-level changelog archive
> Archived during the 0.25 documentation cleanup. This file preserves the pre-cleanup
> detailed release record, including source refactors, regression notes and
> packaging details. It is historical technical evidence, not the operator-facing changelog.
> Use [`../CHANGELOG.md`](../CHANGELOG.md) for user/operator-visible release changes.
# Changelog
## 0.26.7
### Backend client lifecycle and cache diagnostics
- Preserve compiled upstream clients when an active-snapshot rebuild leaves the backend origin, address policy, keepalive policy and connect timeout unchanged. This retains Hyper connection pools together with generation age, per-generation request counters and cumulative rotation totals instead of resetting those diagnostics on unrelated configuration rebuilds.
- Keep incompatible backend changes isolated: changing the origin or any client-construction input still creates a fresh upstream client and fresh generation state.
- Add a pool regression proving compatible snapshot rebuilds reuse the existing backend/client object; existing upstream-client tests continue to cover age-triggered recycling.
- Extend `/cache` with explicit HOT and COLD occupancy meters for both configured object limits and byte/disk budgets. Percentages are clamped for progress rendering while the raw counters remain visible.
### Container certificate tooling
- Build the OCI runtime image with a dedicated Certbot virtual environment containing the Cloudflare, OVH and RFC2136 DNS plugins supported by RustProxy, all pinned with the same Certbot release.
- Refresh container documentation so it no longer tells operators that those provider plugins must be added separately.
## 0.25.1
### Source modularization and release guards
- Enforce a 500-line ceiling for Rust implementation/test source files and bounded dashboard JavaScript, Docker Agent Go and operational deployment/migration modules.
- Keep Rust tests in dedicated `tests.rs` / `tests/` sources and Docker Agent tests in `_test.go`; split `config`, `docker`, Web Server, dashboard bundles, Docker Agent, deployment and NPM migration source by responsibility.
- Preserve public browser asset URLs by assembling the bounded app/vhost source fragments once inside the admin router.
- Add compiler-independent regressions for dangling Rust attributes, broken `include!` targets, missing Web Server `htaccess`/`server_protocol`/`anyhow::Context` wiring and browser split-boundary syntax.
- The package workspace still has no Rust toolchain, so `cargo fmt/check/test --locked --all-targets` remains a release-host gate.
## 0.25.0
- Add reusable Web Services with document root, index-file list and optional directory listing, managed from the new **Web Server** dashboard section.
- Add `Reverse Proxy` / `Web Server` source selection for vhosts and custom locations, including mixed static/proxy routing in one vhost and reuse of one Web Service by multiple vhosts.
- Add static `GET`/`HEAD`, streaming responses, MIME/length headers, directory index/redirect/listing behavior, HTTP validators and single-range requests (`206`/`416`).
- Enforce document-root confinement for Web Server paths, reject ambiguous/encoded traversal forms and prevent symlink escape by not following symlinks during Unix filesystem traversal.
- Reuse the existing vhost ACL/authentication/header/error/TLS/HTTP pipeline for local files without adding a separate router, error-page system or object cache.
- Add an authenticated **Docs Reader** for bundled project documentation alongside the existing API/OpenAPI view, and separate the current documentation index, release audit and open TODO material.
- Split the 0.25 Web Server implementation from the former monolithic `src/web_server.rs` into the responsibility-based `src/web_server/` module (`config`, filesystem, security, response, routing, FastCGI, dispatcher, `.htaccess` and tests), keeping `mod.rs` as the stable public facade and updating source/package gates for the new layout.
- Extend the source-layout cleanup across the Rust tree: every checked-in `.rs` file is capped at 500 lines, large `config`/`docker`/`server`/`runtime`/`balance`/`prometheus`/`proxy`/cache/admin/etc. modules are split by responsibility, unit-test bodies live in separate test files, and compiler-independent contracts reject dangling attributes/doc comments, broken `include!` targets and unbalanced split fragments.
## 0.24.10
- Keep Error File inside the existing revisioned `logging` configuration section while retaining its own Settings form; both access-log and error-file forms save atomically through `/api/config/logging`, so enabled/path/format persist without introducing a special endpoint.
- Preserve backward compatibility for historical `/api/config/logging` clients that send only the access-logging object.
- Keep the Error File panel free of the redundant ERROR/logrotate help sentence.
- Remove collapsing from Response time history and add an accessible information control explaining the cumulative mean metric.
- Update package/source/UI/OpenAPI/release identity to 0.24.10.
## 0.24.9
- Added composite Settings > Logging support for `error_log.enabled`, path and format while retaining legacy `/api/config/logging` payload compatibility.
- Removed the Error Log help sentence from the panel while keeping the existing controls and behavior unchanged.
- Harden the built-in release checker for FreeBSD/OPNsense and other Unix platforms: supplement bundled Mozilla roots with common system CA bundles/`SSL_CERT_FILE`/`CURL_CA_BUNDLE`, follow bounded non-downgrading HTTP(S) redirects, and report failed checks as normal application `ERROR` events without changing dashboard behavior.
- Add a central AES-256-GCM encrypted Secret Store with private local master-key storage and migration of existing ACME DNS-provider credentials.
- Add encrypted full backup/restore with Argon2id, authenticated encryption, explicit manifests/checksums, staging validation and rollback-safe activation.
- Add an optional ERROR-only JSON/RAW file sink with bounded asynchronous writes, central secret redaction and system `logrotate` integration.
- Extend source/release regressions and update release identity to 0.24.9.
## 0.24.8
- Expand the overview Cache RAM diagnostics with HOT usage against limit, transient RAM history for total/fill/COLD queue/COLD I/O, and COLD storage occupancy against its disk budget.
- Hide the long RSS/cache accounting explanation behind an accessible information control in the Process heading.
- Keep COLD disk occupancy explicitly separate from transient process RAM accounting.
- Update release identity to 0.24.8.
## 0.24.7
- Make the transform temporary-memory ceiling configurable from Settings and preserve a 128 MiB default for existing configurations.
- Add a configurable process-wide transform concurrency ceiling; excess work fails open unchanged rather than waiting behind transformation work.
- Add persisted RAM saver, Recommended, Performance and Uber performance transform resource presets without changing enabled transform formats or extension allow-lists.
- Show transform-resource warnings when the selected budget cannot fit the worst-case buffers for one maximum compressed body or exceeds half of detected host/cgroup RAM.
- Add live dashboard resource accounting: current transform RAM reservation/budget and active slots, HOT/transient cache RAM, non-cache process RSS, plus COLD disk occupancy and object limits.
- Include global transform representation/eligibility settings in cache compatibility identity while excluding resource-only preset/budget/concurrency controls.
- Extend SQLite/OpenAPI/offline memory/release regressions and update release identity to 0.24.7.
## 0.24.6
- Persist the selected memory and COLD cache preset identities in the SQLite-backed cache configuration and restore their selected/custom state in Settings.
- Add a Recommended COLD policy preset with bounded queue sizing and conservative demotion/GC defaults while preserving operator-selected disk path, disk budget and object limit.
- Add a 128 MiB process-wide transform temporary-memory budget, bounded body aggregation independent of claimed Content-Length, and bounded decompression growth.
- Validate/compact JSON without a full DOM tree, remove routine HTML raw-tag string allocations and avoid repeated CSS output scans.
- Cap stale-while-revalidate background work at 8 keys and proactive sleeping refresh tasks at 256; keep COLD cleanup single-flight and candidate memory proportional to the requested deletion limit.
- Bound COLD delete fallback tasks and deletion metadata, and add Rust-independent regression checks for memory/task bounds across background workers.
- Update release identity to 0.24.6.
## 0.24.5
- Normalize actually empty, length-unknown HTTP/3 request bodies before HTTP/1.1 upstream forwarding so Hyper emits a zero-length request instead of chunked framing for methods such as DELETE.
- Keep non-empty HTTP/3 bodies streaming by replaying the inspected first frame, leave declared `Content-Length` requests untouched, and do not alter HTTP/2 upstream framing.
- Add focused regression coverage for empty/non-empty H3 DELETE and generic empty H3 methods forwarded to HTTP/1.1.
- Keep the source package consistent with its existing release contracts by dropping the stale unreferenced `src/system_stats/bsd.rs` file and updating the offline container check to the already-shipped Debian 13/Trixie base images.
- Harden cache RAM admission without changing the HOT -> COLD storage model: HOT now has a 256 MiB allocation-aware default budget, fills reserve memory before buffer growth, and a small bounded allocator headroom replaces max-object-sized overshoot.
- Add a separate 64 MiB transient RAM budget plus a 32 MiB byte ceiling for the COLD writer queue; demoted bodies stay charged until persistence drops the `ColdDraft`, while persisted `ColdEntry` values retain only disk paths/metadata.
- Preserve body backing-allocation accounting across fill, revalidation and COLD promotion, expose HOT logical vs allocated bytes and the HOT/transient/COLD-queue limits, and add memory-pressure/LRU-admission counters.
- Make global cache disable authoritative over per-vhost enablement, remove the artificial 1 GiB HOT/object configuration ceiling, and hot-apply reduced HOT capacity with bounded LRU demotion/eviction.
- Add Settings memory sizing based on effective host/cgroup RAM with conservative, recommended and performance presets, a live recommended profile, host/cgroup headroom warnings, and controls for transient RAM, COLD queue RAM and bounded LRU admission. Automatic presets are disabled below 256 MiB effective RAM.
- Update release identity to 0.24.5.
## 0.24.4
- Disable Linux Transparent Huge Pages for the RustProxy process with `prctl(PR_SET_THP_DISABLE)` before the Tokio runtime is created, avoiding a host-wide THP policy change while preventing 2 MiB anonymous huge pages from amplifying allocator RSS retention.
- Treat the process-local THP operation as best-effort: unsupported/rejected kernels emit a startup warning and RustProxy continues to run.
- Update release identity to 0.24.4.
## 0.24.3
- Add a per-certificate reload operation for manual/file-backed certificate IDs and authorize it through the scoped Certificate API as well as the normal administrator session.
- Allow multiple Certificate API tokens (bounded to 32), operator comments, individual revocation and revoke-all while continuing to persist token hashes only.
- Replace the overview Core State block with a live connection/activity chart, move uptime into the Process block below process start, add RX/TX-style upload/download detail under Payload transferred, and persist per-browser hidden network-interface charts.
- Add `MALLOC_ARENA_MAX=2` to managed Linux/systemd service units to reduce glibc arena growth and long-running RSS retention while retaining the environment-file override.
- Remove an identity-body copy from the transformation pipeline, remove `Vec<char>` plus final output-copy allocations from the JavaScript/CSS minifiers, release successful-transform temporary buffers earlier, and use a throttled `malloc_trim(0)` after large Linux/glibc transforms.
- Make HTTP/2 lifecycle tuning listener-scoped like HTTP/3: keep H2/H2C and H3 enablement in **Settings -> Listeners**, expose per-listener H2 GOAWAY/lifetime limits under **HTTP connections -> HTTP/2**, retain per-listener QUIC limits under **HTTP/3**, and link listener rows to the matching tuning page. Existing top-level `http2` values remain a compatibility fallback for listeners without explicit H2 settings.
- Rename **Proxy process** to **Process** and dynamically hide unsupported process diagnostics and history charts instead of showing unknown values.
- Remove the stale, unreferenced legacy `src/system_stats/bsd.rs` parser file from the source package; shared library collectors remain the only BSD statistics implementation.
- Update release identity to 0.24.3.
## 0.24.2
- Version-only release bump from 0.24.1; no functional, configuration or protocol changes.
## 0.24.1
- Restore system network interface discovery and RX/TX counters after the shared-statistics refactor: use the cross-platform `sysinfo::Networks` API as the primary network inventory/counter source, merge `systemstat` as a library-level supplement/fallback for missing or partial inventory, preserve per-interface rate calculation, and stop silently truncating the interface list.
- Remove RustProxy's artificial hostname-count ceilings from certificate SNI bindings, local certificate generation/import validation, ACME domain validation, vhost/redirect/404 hostnames, administration allowed hosts, host ACL policies and Docker aliases. Input validity and bounded control-plane/resource limits remain enforced.
- Replace per-BSD command/parsing statistics code with shared library collectors: `systemstat` for host metrics, `sysinfo::Networks` for network inventory/counters, and `metrics-process` for process metrics, using the documented dummy process fallback on targets without a native collector.
- Preserve Linux `/proc` and cgroup-specific details as additive compatibility metrics while using the common library API for portable host/process values.
- Add portable swap, system uptime, process virtual memory/limit, descriptor limit and process start-time diagnostics where the libraries support them.
- Add authenticated runtime metadata for platform, statistics source, resolved data directory and exact SQLite database path.
- Make local CLI data-directory discovery understand both `/var/lib/rustproxy` and the BSD/OPNsense `/var/db/rustproxy` convention.
- Move Logging into **Settings -> Logging** while retaining legacy `/logging` navigation compatibility.
- Update release identity to 0.24.1.
## 0.24.0
- Replace abrupt downstream H2 idle termination with a GOAWAY-based graceful shutdown that rechecks current activity and waits for accepted streams.
- Add independent H2 idle timeout, maximum connection lifetime and drain timeout settings; lifetime may be disabled with `0`.
- Bound H2 draining so stalled streams cannot keep a retiring connection open indefinitely.
- Keep H2 Extended CONNECT/WebSocket streams under the same active-stream accounting and graceful drain path.
- Extract system statistics into platform backends while preserving Linux metric names, units and behavior; add native FreeBSD/OpenBSD/NetBSD sampling where available and explicit nulls otherwise.
- Add runtime platform to Proxy Process and Prometheus platform info.
- Add Settings UI/API/OpenAPI/language-pack support plus H2 lifecycle/configuration regressions.
- Reorganize **Settings -> HTTP connections** into HTTP/2 and HTTP/3 sub-tabs; move listener-scoped H3/QUIC enablement and limits out of the Listeners editor while preserving restart semantics and the existing listener configuration model.
- Update release identity to 0.24.0.
## 0.23.3
- Make external certificate deployment convergent: create missing manual certificate ids, rotate existing manual ids, no-op identical RustProxy-owned generations, and keep managed ACME ids protected from external ownership.
- Make the OPNsense os-acme-client hook reconcile on every run so RustProxy remains the source of truth and deleted/drifted external certificate entries self-heal.
- Add per-vhost **Enable WebSocket legacy support** for older appliance/embedded WebSocket implementations.
- Force legacy-mode WebSocket upstream handshakes to HTTP/1.1 and force the WebSocket `Host` to the configured backend authority and align `Origin` to the matching backend scheme + authority.
- Tolerate legacy `101 Switching Protocols` response framing while keeping `Sec-WebSocket-Accept` and subprotocol validation strict.
- Add UI/OpenAPI/translations, documentation and regression contracts for the compatibility mode.
- Update package/source/UI identity and release verification contracts to 0.23.3.
## 0.23.2
- Add selectable 60/120/300/600-sample history windows to the Cache and Transformation live charts, with bounded per-workspace browser persistence.
- Fix `rust-proxy --livelogs` on HTTPS administration listeners by detecting the HTTP 308 redirect and reconnecting over TLS instead of looping.
- Pin local CLI HTTPS to leaf certificates present in the private `data-dir/certs` directory and reuse the HTTP/HTTPS-aware client for config and certificate CLI API calls.
- Update package/source/UI identity and release verification contracts to 0.23.2.
## 0.23.1
- Add default-collapsed, full-width live activity charts to the Cache and Transform workspaces.
- Plot Cache HIT/MISS/BYPASS rates for the selected scope using deltas from the existing cache runtime counters.
- Plot Transform applied/cache-HIT/skipped/error rates for the selected scope using the existing transform metrics endpoint.
- Reuse the administration dashboard SVG/grid/legend visual language, keep the existing 3-second live refresh, and add English/Polish language-pack labels.
- Update package/source/UI identity and release verification contracts to 0.23.1.
## 0.23.0
- Bound inactive inbound HTTP/2 connections to five minutes so idle TLS/H2 sessions cannot accumulate indefinitely toward the process connection limit; export `rust_proxy_http2_active_connections` for direct diagnosis.
- Account hot-cache response-body backing allocation capacity rather than only visible body length, preventing `BytesMut` growth slack from bypassing the configured RAM budget and under-reporting resident cache memory.
- Add a scoped external Certificate API credential managed from a new **Settings → Certificate API** tab. RustProxy stores only the token hash plus a non-secret Key ID; the cleartext token is returned only on rotation.
- Allow the scoped Key ID + Bearer Token only on `POST /api/certificates/deploy` and `POST /api/certificates/reload`, require HTTPS for non-loopback token use, track last use, support immediate revoke, and retain normal administrator-session/CSRF access as an alternative.
- Keep production certificate operations available directly from the installed binary (`rust-proxy tls deploy` / `rust-proxy tls reload`) without requiring `dev.sh`.
- Add `rust-proxy --livelogs`: an interactive varnishlog-style all-vhost live access stream with structured filters (`host`, `vhost`, `backend`, `route`, `client`, `method`, `transport`, `cache`, status classes, path prefix, errors and slow requests), pause/resume, per-request JSON inspection and detailed error/WebSocket/H3/cache fields.
- Back live logs with a bounded non-blocking broadcast stream and authenticated SSE endpoint; slow consumers receive an explicit `gap` event with the dropped-entry count instead of silent loss.
- Split the large **Settings → Cache** form into **General**, **Resilience**, **Cold cache**, and **Static & diagnostics** sub-tabs while retaining one atomic cache-policy save.
- Add SQLite schema v5 for the scoped certificate credential, OpenAPI/security schemes, UI translations, audit events and 0.23.0 regression/release documentation.
## 0.22.3
- Add downstream HTTP/1.1 `Upgrade: h2c` on listeners that explicitly enable cleartext HTTP/2, preserving the existing prior-knowledge path and rejecting malformed/body-bearing upgrade attempts before protocol switch.
- Reconstruct the accepted H1 upgrade request as HTTP/2 stream 1 and hand subsequent framing/streams to the existing Hyper H2 server path; include raw-TCP interoperability regression source.
- Add authenticated `POST /api/certificates/reload` and `tls reload` to re-read configured public certificate/key files and hot-publish a fresh TLS/H2/H3 snapshot without changing the desired configuration revision.
- Add `tls deploy --id ... --cert ... --key ...` and authenticated `POST /api/certificates/deploy` for externally issued local certificates such as Certbot. The CLI performs bounded local reads while the running service owns pair/SAN validation, immutable generation staging, revision-CAS publication and rollback on failure, avoiding root-owned staged PEMs when Certbot hooks run as root.
- Keep all vhosts assigned to one manual certificate id synchronized during external rotation; reject RustProxy-managed ACME ids to avoid dual renewal ownership and report the administration-TLS restart caveat explicitly.
- Add Certificates-panel reload UX, OpenAPI contracts, audit events, translations and 0.22.3 audit/test documentation.
## 0.22.2
- Add bounded replay-safe backend failover retry for body-free GET/HEAD/OPTIONS requests, including retry policy controls, passive-health integration, logs and per-backend retry counters.
- Correct ordinary explicit-HTTP/2 upstream requests and add a real Hyper h2c prior-knowledge regression path.
- Add per-backend request-concurrency limits with bounded capacity waiting, `503` rejection and capacity/queue-timeout metrics. This limits full proxy exchanges, not TCP socket count.
- Add detailed upstream failure classes, last failure detail/timestamp, and backend health ejection/recovery timestamps to runtime diagnostics and Prometheus.
- Expand the backend administration table with concurrency capacity, last failure and last recovery columns.
- Parse public X.509 leaf metadata for issuer, SANs, validity, remaining days and expiry warning state, and surface ACME renewal status/last attempt/error in certificate inventory.
- Extend OpenAPI, language packs, source regressions, audit/TODO and release documentation for the expanded 0.22.2 scope.
## 0.22.1
- Vhost UX: replaced separate WebSocket/H2 RFC 8441/H3 RFC 9220 checkboxes with one transport selector; H2/H3 modes keep HTTP/1.1 Upgrade as an explicit fallback and unavailable transports are disabled from listener capabilities.
- UI polish: replaced the overview TLS listener tile with compact HTTP protocol and WebSocket protocol mini charts; Target and Cache key/tags cells remain copyable with full values.
- Keep the overview metrics in one shared stats grid so the WebSocket/H2/H3/TLS row uses exactly the same card geometry as the primary metrics, without a transport-specific dashboard layout. Make Cache **Target** and **Cache key / tags** cells copy their full untruncated value on click/keyboard activation.
- Polish the 0.22.1 administration UI: move **Active tunnels** into the WebSocket dashboard card, flatten the H1/H2/H3 tunnel breakdown to match the standard transport-card styling, and align Cache modal close actions with the vhost modal header pattern.
- Fix Rust ownership in tunnel registration by releasing the client-IP mutex guard before moving the shared exchange into the active tunnel, and remove an unnecessary mutable H2 upstream reader binding.
- Move selective cache PURGE/BAN and the active BAN list into dedicated Cache modals, keep their launch actions beside **Purge selected scope**, and use null-safe inline operation feedback consistent with login errors.
- Add opt-in RFC 8441 WebSocket Extended CONNECT for HTTP/2, with per-vhost controls beside RFC 9220, listener-scoped SETTINGS advertisement, H2-to-H1 bridging, explicit H2 upstream forwarding, cancellation/idle timeout and transport-specific metrics.
- Add opt-in cleartext HTTP/2 prior-knowledge (`h2c`) listeners. Legacy HTTP/1.1 `Upgrade: h2c` remains deliberately unsupported because the current Hyper integration cannot safely reconstruct the required HTTP/2 stream 1 bootstrap.
- Add stable active-tunnel IDs, authenticated tunnel inventory/kill API, operator-supplied close reasons, audit records, RX/TX/duration metadata and an administration modal.
- Consolidate active WebSocket/tunnel visibility on the main dashboard as total plus H1 / H2 RFC 8441 / H3 RFC 9220 counters while retaining ordinary H2/H3 request statistics separately.
- Preserve `Accept-Ranges` from origins and synthesize it only for RustProxy-generated cached range responses; transformed/compressed representations continue to remove incompatible range advertising.
- Update source/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.22.1.
## 0.22.0
- Fix a Rust ownership regression in the upstream request path: retain a cloned origin URI for proactive refresh after the request URI is assigned.
- Add per-cache-key request collapsing for foreground MISS/fill traffic with bounded wait, configurable timeout fallback, cancellation-safe leader release and collapse metrics.
- Add single `Range: bytes=...` handling from complete cached objects, including `206`, `416`, `Content-Range`, `Accept-Ranges` and `If-Range`; multipart/unsupported ranges remain origin pass-through and are not cached as separate objects.
- Extend stale-if-error with configurable transport failure classes and an explicit 5xx status allowlist while preserving the previous default behavior.
- Add bounded proactive cache refresh with pre-expiry scheduling, deterministic sampling, concurrency limits and reuse of the existing validator revalidation path.
- Add selective cache PURGE by exact URL, prefix, tag, regex or cache key, including query/Vary handling, RAM + Cold Cache invalidation and a publication barrier preventing pre-PURGE fills from republishing stale data.
- Add lazy BAN rules with lookup-time enforcement, bounded rule count/TTL, incremental garbage collection, rule deletion and metrics.
- Add authenticated administration API and Cache panel controls for PURGE/BAN, broad-operation confirmation, per-object Purge URL actions, BAN listing/removal and audit entries.
- Extend cache diagnostics, OpenAPI, translations and regressions for the 0.22 cache behavior.
- Update package/source/UI/OpenAPI/Debian/Grafana/tooling identity to 0.22.0.
- Preserve the resolved dependency graph; compiler/live acceptance remains an explicit release-host gate when Cargo/Rust are unavailable in the packaging workspace.
## 0.21.0
- Fix browser refresh on the dedicated **Transform** route by serving the dashboard document at `/transform` and covering it in the panel-route regression.
- Align the Cache **Transformed** marker with the dashboard geometry by replacing the capsule outline with the standard badge border and a 3px corner radius.
- Give the Live traffic **Transformed** series a dedicated violet chart token in dark/light themes so it remains distinct from HTTP/2 green.
- Add a dedicated Transform administration workspace with global/per-vhost runtime counters for applied/skipped/error transformations, transformed cache HITs, byte savings, reduction, transform time, kind breakdown and bounded skip reasons.
- Add a transformed-response series to **Live traffic** (fresh transformations plus transformed cache HITs) and render **Core state** uptime in compact human-readable units.
- Rename the proxy-owned backend timing diagnostic to `X-RustProxy-Backend-Time`; the legacy lookalike `X-Rust-Proxy-Backend-Time` is stripped but is no longer emitted.
- Fix Docker-vhost Passive Backend Health checkbox sizing so enabling the expanded editor cannot make fields overlap.
- Remove the unused mutable `origin` binding from the background revalidation path.
- Replace the monolithic `src/cache.rs` with the responsibility-based `src/cache/` module (`model`, lifecycle, cold storage, dashboard, tickets, lookup, policy, fill and tests) and update source/package gates for the new layout.
- Persist transformed-representation metadata through hot/Cold Cache, mark transformed objects in the Cache dashboard, and append `transformed` to standardized RFC 9211 `Cache-Status` diagnostics; origin `X-Cache-Status` lookalikes are stripped rather than emitted.
- Shard Cold Cache storage and add bounded asynchronous garbage collection plus selective manual cleanup.
- Add RFC 5861 stale windows, request `max-stale`, age-aware request `max-age=N`, and correct `must-revalidate` / `proxy-revalidate` behavior while preserving `s-maxage` priority.
- Harden transformed cache fills so transformed Content-Length is authoritative, incomplete streams cannot publish partial objects, and canonical transformed identity representations do not fragment on origin `Vary: Accept-Encoding`.
- Add the static-response transformation pipeline before cache/client compression, with strict `no-transform`, separate origin/representation validators and JS/CSS plus optional conservative HTML/SVG and parsed JSON transforms.
- Keep route cache overrides tri-state and add effective route/cache/transform diagnostics.
- Make Passive Backend Health explicit opt-in (default OFF) for ordinary and Docker vhosts, independent from active TCP/HTTP health, with per-error-class policy, separate counters and persisted Docker assignment settings.
- Extend administration UI/OpenAPI and regression coverage for Cold Cache cleanup, transform settings, passive health and cache semantics.
- Keep release/package/UI/OpenAPI/Debian/Grafana/tooling identity at 0.21.0.
- Close the 0.21.0 feature scope for release packaging. Parser-backed JS/CSS minifiers are deferred; compiler/live acceptance still has to be executed on a Rust-enabled release host.
## 0.20.0
- Rebuild **Certificates** into Installed / Renewal / Generate tabs using the same accessible keyboard tab pattern as Settings.
- Keep certificate issuance, PEM import and existing-file registration inline in the Generate tab; successful imports and edits return to Installed.
- Add explicit ACME operation state cards and include the exact private Certbot log directory in failed issuance/renewal messages.
- Expose per-managed-certificate Certbot log directories from the authenticated ACME status endpoint so persisted renewal failures remain actionable after refresh/restart.
- Rework renewal entries into readable cards with provider/environment, last check, next check, expiry and grouped actions.
- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.20.0.
## 0.19.4
- Align the Docker-vhost Aliases editor with the standard 38px Docker form controls and remove the inherited `host-chip-field` label margin that created an extra visual line below `Aliases`.
- Preserve backend traffic display semantics: idle renders as `0.0 KiB/s`, and positive rates below 0.1 KiB/s render as `<0.1 KiB/s` in both backend rows and aggregate vhost traffic.
- Add a presentation filter to **Cache -> Domains and effectiveness** with All / Cache enabled / Cache disabled choices, using each vhost's resolved cache settings while leaving aggregate statistics unchanged.
- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.19.4.
## 0.19.3
- Simplify the dashboard footer to API latency and dropped-log count, and add a fail-soft built-in release checker using embedded `UPDATE_CHECK.toml` defaults. The checker reads the official Cargo manifest immediately and then hourly, exposes a newer-version link in the footer, and never performs automatic upgrades.
- Allow runtime release-check overrides through `--update-repository-url`, `--update-version-url` and `--update-check-interval-secs` (plus matching `RUST_PROXY_*` environment variables); interval `0` disables checks.
- Fix the remaining oversized Live Logs column checkboxes by excluding checkbox/radio inputs from both broad `.log-tools input` text-field sizing blocks that were overriding the shared 15px checkbox rules.
- Add an account-scoped light/dark administration theme stored in the existing SQLite `admin_preferences` document; dark remains the default and the last authenticated choice is cached locally only to avoid login/dashboard first-paint theme flicker.
- Refactor theme-sensitive UI colors into shared CSS variables and keep checkbox/radio controls out of generic text-input sizing/density rules.
- Rework the Live Logs column picker into a compact scrollbar-free list with standard-size checkboxes; short viewports use two columns instead of an internal scroll area.
- Keep expired-session re-authentication on the exact panel and Settings sub-tab, use the minimal public login document without flashing the authenticated shell, and show embedded login errors inline instead of as global toasts.
- Canonicalize the legacy `/configuration` shortcut to `/settings?tab=manual-config` and keep later Settings tab switches in the URL so F5 restores the exact page.
- Add one shared saving-state helper for settings/security/certificate forms: disable the active submit button, show `Saving…`, restore its translated label, and prevent duplicate submissions while a save is in flight.
- Highlight and focus the actual invalid control for local validation failures in Logging JSON, named listeners, Docker-agent listener selection, TLS policy, ACL profiles, manual JSON and certificate forms.
- Preserve selected certificate/key files after a failed certificate import/save so the administrator can correct the problem without reselecting both files.
- Mark Settings tabs with a subtle unsaved-change dot, including Manual config, and keep dirty values from being overwritten by live config refreshes.
- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.19.3.
## 0.19.2
- Restore the vhost local-listener risk control for administration backends when the administration socket is startup-locked or otherwise reported only by live status; treat `localhost`, `127.0.0.0/8` and `::1` as equivalent loopback forms for the same administration port.
- Make **Discard changes** reload the persisted vhost being edited instead of replacing the editor with a new blank/default vhost.
- Add descriptive HTTP reason phrases to the default-response status selectors and headings.
- Add a temporary, continuously updated 4096-entry Live-log buffer that resets when the log window closes.
- Align inline checkbox labels and compact Docker-vhost alias controls.
- Stop backend traffic EWMA tails on an idle sampling window, show idle consistently as `0.0 KiB/s`, and render positive sub-0.1 KiB/s traffic as `<0.1 KiB/s` instead of a rounded zero.
- Make the Live Logs column picker substantially smaller with subtle 11px checkboxes; let the menu escape the log-dialog clipping boundary and open upward on short viewports.
- Default newly created vhosts to the standard HTTP `:80` listener when available; HTTPS listeners are selected explicitly.
- Left-align the Settings -> Docker agents save action to match the other settings forms.
- Suppress dashboard/login first-paint flicker and preserve the active Settings sub-tab across F5, including a re-login redirect back to `/settings?tab=...` and `/docker-vhosts`.
- Replace native browser confirmations with application-styled confirmation dialogs and add panel-area context to API error messages.
- Present login failures as an inline styled error below the form, including invalid-credential responses.
- Install a login-shell PATH profile for the managed `current` release directory on first install; updates check and warn about the profile without changing it.
- Enforce `no-store` and suppress cache diagnostics/validators for local placeholder/static/default pages.
- Allow vhost-ID renames with duplicate protection, confirmation and an explicit runtime-identity warning.
- Update source, UI, OpenAPI, regressions and package identity to 0.19.2.
## 0.19.1
- Fix Default responses section saves by passing the normal `{expected_revision, value}` payload to `/api/config/pages` and add server-side `pages` section handling/regression coverage.
- Add frontend scheme to the primary cache hash so otherwise identical HTTP and HTTPS requests cannot reuse one object.
- Retain 4096 completed access-log entries while keeping the 256-entry live window; add one-shot on-demand history loading and document the API limit.
- Render backend traffic with KiB/s as the minimum unit, simplify the Live Logs column picker, align generic checkboxes and move Docker-vhost Advanced switches beside Backend protocol on desktop; remove the redundant alias help line.
- Remove stale simulated dashboard screenshots, retain only two explicitly labelled legacy references and add a documentation index for historical/version-labelled material.
- Update source, UI, OpenAPI, regressions and package identity to 0.19.1.
## 0.19.0
- Persist per-administrator Live Logs column visibility in SQLite schema v4 without changing the revisioned proxy configuration; JSON export remains complete regardless of visible columns.
- Keep the Live Logs column selector visually consistent with the existing compact `check vh-check` controls instead of rendering oversized standalone checkbox widgets.
- Add authenticated `GET/PUT /api/preferences` endpoints for dashboard-only account preferences, protected by the existing session/CSRF control-plane guards.
- Remove the duplicate version badge from the top-right dashboard toolbar while keeping the footer release identity.
- Retain the full disabled-verification legacy X.509 fix and expanded upstream connector diagnostics introduced during the 0.18.8 follow-up.
- Update release/package identity to 0.19.0.
## 0.18.8
- Make backend `tls_verify=false` a complete certificate-authentication bypass instead of delegating TLS CertificateVerify to webpki. This restores compatibility with legacy appliance certificates such as X.509 v1 while keeping TLS encryption and all backend address/DNS/loop protections in force; `tls_verify=true` is unchanged.
- Expand upstream connection diagnostics with the full nested Hyper/rustls/io error chain, backend/TLS context, 2 KiB UTF-8-safe detail storage and warning-level journal entries for connect failures.
- Replace Docker-vhost alias comma input with hostname-style chips for add/remove/paste workflows.
- Add per-Docker-vhost HTTP-to-HTTPS redirect, WebSocket and cache controls; new compatible assignments default HTTP-to-HTTPS redirect on while legacy assignments retain their prior redirect-off behavior until explicitly saved.
- Align Docker-vhost boolean controls with the application's standard checkbox classes and keep checkbox inputs from inheriting full-width text-input sizing.
- Add a Docker-agent default ACL-profile selector. Generated Docker vhosts inherit it unless the application selects its own ACL profile; existing per-app inline ACL configuration is unchanged.
- Remove the compression-codec negotiation explainer from the Overview dashboard.
- Update release/package identity to 0.18.8.
## 0.18.7
- Collapse **Response time history** by default on the Overview dashboard while keeping live sampling active.
- Restyle per-interface RX/TX histories to use the same chart geometry and visual treatment as the aggregate RX/TX history, without nested panel backgrounds.
- Align expanded Docker-vhost routing and ACL controls with uniform field blocks; Preserve Host and per-app ACL switches now occupy the same label/control rhythm as neighboring inputs, and ACL selects no longer stretch to textarea height.
- Update release/package identity to 0.18.7.
## 0.18.6
- Add bounded per-interface RX/TX diagnostics from `/proc/net/dev` to the administration status payload and render separate live interface histories without adding per-interface Prometheus labels.
- Strengthen compression negotiation regressions with explicit `q=` preference cases for gzip, Brotli, Zstd and deflate; clarify that dashboard counters track the codec actually selected per response.
- Align Docker-agent settings inputs/actions, keep Enabled/status/remove controls on one stable row, and add spacing between listener selection and the Add Docker agent action.
- Update release/package identity to 0.18.6.
## 0.18.5
- Make compression accounting follow an explicit RustProxy-applied codec marker instead of re-reading `Content-Encoding`; add independent gzip/br/zstd/deflate counter regression coverage.
- Add an explicit HTTP/1.1 request counter and render total + HTTP/1.1 + HTTP/2 + HTTP/3 request-rate series on the Overview chart.
- Add an Open app action to published Docker vhosts, using the assigned frontend listener and expanded proxied hostname.
- Label Docker-generated pools in the Backends view with their primary proxied domain.
- Remove the global Open proxy shortcut from the page header.
- Make Compact density materially tighter than Dense across cards, controls, tables, Docker rows and charts.
- Default vhost/Docker proxy timeout controls to explicit 10/120/60/60-second connect/header/read/send limits instead of blank inherited values.
- Replace free-form Docker-agent listener-ID entry with the configured named-listener selector; move Enabled left of the centered connection-status badge.
- Update release/package identity to 0.18.5.
## 0.18.4
- Default backend health checks to TCP for newly created or otherwise defaulted ordinary and Docker vhosts; explicit existing HTTP/off health modes are preserved.
- Replace the dense Docker-vhost service editor with summary-first rows. Publish state, service identity, backend runtime health and Hide/Show stay visible; routing, ACL, timeouts and health tuning are expanded only for the selected service.
- Collapse secondary Docker-agent inventory metadata behind Agent details while keeping host, service count, last observation and base domain immediately visible.
- Rework Settings -> Security into focused account/session and host-policy cards with on-demand password and policy editors.
- Move Administration audit into a dedicated modal and stop fetching audit entries merely by opening the Security settings tab.
- Update release/package identity and EN/PL UI copy to 0.18.4.
## 0.18.3
- Add Docker-vhost search plus persistent per-browser Hide/Show Hidden view controls, and allow each Docker agent to select a configured frontend certificate inherited by its generated vhosts.
- Keep Docker inventory polling driven by each agent's configured 5..3600 second interval; the 5-second dashboard refresh reads cached observations only.
- Move redirect HTTP/2 and HTTP/3 toggles below listener selection and keep Cache `Updated` + `Live` together at the right side of the toolbar.
- Remove the unused `config::yes` helper warning.
- Add an Open-in-browser action beside each vhost Enabled state; wildcard-only hosts open the wildcard base domain, while mixed host lists prefer the first non-wildcard name.
- Restore the documented HTTP/1.1 backend default for newly created ordinary and Docker vhosts: preserve the public `Host`; existing explicit backend settings are not rewritten.
- Write HTTP/1 upstream header names in conventional title case for compatibility with legacy/custom origins that incorrectly treat header names as case-sensitive; HTTP/2 is unaffected.
- Add bounded `upstream_error` diagnostics to access logs/live logs so TCP/TLS/HTTP-client failures that previously appeared only as 503 with no upstream status can be distinguished.
- Restrict local/error response selectors to supported error statuses; application-style 404 hosts offer only bundled 401/403/404/429/503/504 templates, including a new dedicated 404 template.
- Make Docker Publish/Unpublish an immediate per-service configuration action, add per-service Apply, and replace unconditional UI rerendering with an operator-controlled Live view that reads only RustProxy's in-memory state.
- Add per-agent Docker inventory polling intervals (5..3600 seconds), while manual refresh still polls immediately.
- Add reusable ACL-profile plus inline IP/CIDR ACL controls per Docker application, and protect referenced profiles from deletion.
- Add per-vhost proxy connect/response-header/read/send timeout controls for ordinary and Docker-backed vhosts.
- Print the Docker-agent bearer token after installation alongside the certificate fingerprint.
- Improve 404 Hosts response UX with supported error-status selectors, application-style built-in status pages, cache-diagnostic suppression and protocol toggles beside listener selection.
- Add optional per-vhost JSONL access-log files under a configurable directory while retaining the combined-file mode, Logstash and Loki sinks.
- Lock HTTP/HTTPS/admin listener addresses that are supplied explicitly by systemd/CLI/environment so the control plane cannot remove, disable or rewrite process-owned binds; listeners not supplied at startup remain editable.
- Remove the separate remote-admin risk-acknowledgement requirement; plain remote administration now requires only `--allow-remote-admin`, while legacy `--accept-remote-admin-risk` is accepted as a hidden no-op so older systemd units still start.
- Tighten Compact/Dense dashboard density, rename the Shared RAM cache surface to **Cache**, expose redirect targets directly on cards, and keep 404 Hosts aligned with the application error-code set.
- Clarify that TLS Policy is inherited globally and show currently effective cipher suites alongside all available rustls suites.
- Treat ACME-issued certificates as managed/read-only in the custom-file editor, require certificates to be unassigned (including the administration panel/default binding) before deletion, and default new ACME issuance to the production CA environment.
- Make classic HTTP/1.1 Upgrade the default WebSocket path; RFC 9220 Extended CONNECT over HTTP/3 is now an explicit experimental per-vhost opt-in and is advertised only on QUIC listeners with an opted-in vhost.
- Split Docker discovery/vhost observability into dedicated `rust_proxy_docker_vhost_*` and `rust_proxy_docker_backend_*` Prometheus metric families; Docker pools no longer appear in ordinary vhost/backend series.
- Add a separate live **Docker vhosts** Prometheus collector group, enabled by default, with configured agent/assignment/domain gauges plus Docker vhost traffic/status/duration and full backend health/probe/runtime metrics.
- Extend Prometheus/OpenAPI/UI documentation and regression coverage for the Docker metric boundary.
- Extend the bundled Grafana operations dashboard with a dedicated Docker-vhost section and per-agent/service filters using only the separate Docker metric families.
## 0.18.2
- Secure Docker-agent transport by default with a persistent self-signed ECDSA certificate and SHA-256 certificate pinning in RustProxy; public-CA HTTPS remains available without a pin and plain HTTP is compatibility-only.
- Add certificate lifecycle helpers, hardened systemd state storage, installer fingerprint output and TLS regression tests for the standalone Docker agent.
- Enable per-Docker-vhost HTTP/TCP backend health checks using the same bounded backend-pool health machinery as ordinary vhosts, with live checking/up/down, latency, probe, request, active and error diagnostics in Docker vhosts.
- Rework Docker-agent and Docker-vhost UX around connection security, last observation, detected addresses, route controls and collapsible health-check tuning while retaining per-agent domains, aliases and per-service IP selection.
## 0.18.1
- Extend the Docker agent inventory with all usable host addresses (interfaces, routed address, public lookup and optional advertised address) and preserve every published binding per service.
- Add per-service Docker backend-IP selection, per-agent default backend IP, arbitrary FQDNs and multiple aliases while retaining short-name expansion below each agent's own base domain.
- Support many Docker agents with independent domains such as `docker1.lab.local` and `docker2.lab.local`, plus operator-controlled custom domains.
- Remove all special `X-Rust-Proxy-Revision` response-header behavior; the name is no longer inserted, stripped, protected or accepted as a legacy diagnostic setting.
- Keep cache-Live placement, production password recovery and Settings navigation improvements from 0.18.0.
## 0.18.0
- Add a standalone Go Docker discovery agent with a hardened systemd unit, token authentication, published TCP-port inventory and host/public-IP detection.
- Add per-agent Docker configuration, in-memory observations and dynamic Docker vhosts with operator-controlled service/subdomain assignments.
- Add Docker vhost/admin UI and OpenAPI endpoints for cached observations and explicit refresh.
- Move the cache Live toggle to the action-toolbar edge.
- Remove configurable `X-Rust-Proxy-Revision` response exposure and always strip the header.
- Use the installed `rust-proxy admin reset-password` command in production recovery guidance.
- Update EN/PL strings, documentation, release checks and package identity to 0.18.0.
## 0.17.0
- Add Debian packaging assets, a hardened systemd unit, `rustproxy` service user/state/log directories, three-day maximum-gzip log rotation, local `.deb` build tooling and signed APT-repository generation suitable for `rustproxy.linuxiarz.pl`.
- Expand Prometheus coverage across all current bounded process/OS diagnostics plus GeoIP, certificate renewal, backend health-probe detail, stream, logging and frontend inventory metrics; add explicit token-free scraping for trusted actual-peer CIDRs.
- Add a Grafana operations dashboard under `grafana/`.
- Add dedicated listener/certificate-aware local-response / 404 hosts with built-in placeholder, empty/status-only and custom-HTML modes.
- Keep the default `RustProxy is running` placeholder uncacheable and intentionally free of optional proxy/cache/backend/diagnostic/security/compression headers.
- Reuse vhost-style hostname chips for Redirect source domains.
- Update OpenAPI, EN/PL UI, release checks and package identity to 0.17.0.
## 0.16.1
- Remove internal configuration revision numbers from successful save feedback in dashboard editors/manual configuration while retaining revision-based CAS and stale-write protection internally.
- Update source, UI, OpenAPI, release tooling and packaging identity to 0.16.1.
## 0.16.0
- Decouple Custom locations from the main vhost backend fallback. Unmatched paths now use the main pool even when custom rules exist; untouched legacy synthetic root routes are normalized automatically.
- Fix same-process public administration login by allowing `rustproxy_session` across only a guarded HTTPS self-admin backend that directly targets the configured administration listener and whose public host is explicitly allowed.
- Make RFC 9220 bootstrap wait for the backend HTTP/1.1 upgrade before downstream H3 success, and extend access logging with transport/WebSocket phase/error/reset/upstream status diagnostics.
- Add cache transient-memory gauges and procfs RSS/data/swap breakdowns to the dashboard and Prometheus. Existing allocator trimming/release behavior is unchanged; no new transient-memory throttle is introduced.
- Reorganize the Vhost editor around clearer backend cards, Authentication subcards and compact protocol controls. New vhosts select standard HTTP :80 plus HTTPS :443 together when available; Redirects reuse the same listener selector; disabled vhost cache hides its subordinate controls and new vhost drafts default cache to off.
- Show the local-listener risk acceptance only when the entered backend URL directly targets the configured administration listener. Add protected opt-in `X-RustProxy-Backend` and `X-RustProxy-Backend-Time` response diagnostics for selected member identity and origin response-header latency.
- Bump source, UI, OpenAPI and packaging identity to 0.16.0.
## 0.15.1
- Fix Custom locations EN/PL translation loading by keeping all UI strings inside the language-pack `translations` map and add a regression check that rejects stray top-level translation keys.
- Make visual header rules easier to edit with numbered cards, clear field grouping, empty-state guidance and localized help.
- Add a live per-location behavior preview for matching, action, rewrite, cache, ACL and header rules, and reorganize the inline documentation with common examples.
- Add one live three-series HIT/MISS/BYPASS activity chart to the RAM cache card and CPU/RSS history charts to the Proxy process card.
- Extend offline source/release checks for the new location UX and dashboard charts; package identity is 0.15.1.
## 0.15.0
- Expand vhost Custom locations with Nginx-style exact/prefix/`^~`/regex/regex-insensitive matching, upstream path preserve/strip/replace/regex rewrites and backend-free custom responses.
- Replace the location header JSON-only workflow with a visual request/response header builder, keep Advanced JSON as an escape hatch, add in-panel documentation and reorganize the location editor into compact functional sections.
- Add `nagios/` API checks for administration response time, unavailable/checking backends, backend latency and dropped logging/cold-cache work, with reusable protected session cookies and Nagios performance data.
- Move per-vhost `Server-Timing` and verified Cloudflare client-IP diagnostics into the main frontend/details configuration.
- Add compact dashboard history charts for RAM-cache residency, network RX/TX and mean response latency, plus administration API RTT and low-priority queue/drop information in the footer.
- Add independent cold-cache age policy controls: demote by time since last access, by total hot-tier residency, by LRU pressure and on graceful shutdown.
- Add verified effective client/source IP to live logs and logging field mappings; early responses still record at least the transport peer.
- Remove the runtime legacy renewal-observation JSON importer. Stopped source upgrades remove the obsolete sidecar as the final upgrade step without importing it or retaining a second legacy copy; SQLite remains the only live renewal-observation store.
- Normalize repeated literal path separators for routing/ACL decisions, matching Nginx `merge_slashes` behavior, while preserving the original request target to the backend when no rewrite is configured. URLs such as `/static/js//events.js?v=...` are therefore accepted without weakening encoded-separator/dot-segment protections.
- Bump source, panel, OpenAPI, upgrade tooling and package identity to 0.15.0 and extend offline regression contracts for the new controls.
## 0.14.0
- Fix administration reverse-proxy Host/Origin handling: explicit DNS allowlist entries describe the external authority instead of the private admin socket port; direct IP/localhost access remains bound to the actual admin port.
- Trust forwarded administration host/scheme only from loopback or configured trusted proxy peers, use the external scheme for Origin/CSRF checks, and set/clear Secure session cookies consistently behind HTTPS termination.
- Implement RFC 9220 WebSocket over HTTP/3 Extended CONNECT, including `SETTINGS_ENABLE_CONNECT_PROTOCOL`, `:protocol=websocket` validation, HTTP/1.1 backend Upgrade translation, downstream 200 bootstrap, bidirectional H3 DATA transport and FIN/reset cancellation handling.
- Replace the separate `h3`/`h3-quinn` pair with `hpx-h3 2.5.19` and its integrated Quinn feature to expose the RFC 9220 protocol extension through the HTTP/3 stack.
- Rename dashboard backend, health, security, error-page and recommended-preset labels in English and Polish.
- Update release/static checks, HTTP/3 documentation and package identity. The source contains no `certificates.json` certificate metadata path.
## 0.13.2
- Fix authenticated dashboard refresh/back-forward session restoration so valid cookies are honored on every panel document route instead of briefly rendering the login shell.
- Make managed software updates force a bounded hot-to-cold cache handoff whenever the cold tier is enabled, independent of the ordinary `cold_on_shutdown` preference, and extend the managed systemd stop budget.
- Persist non-secret automatic-certificate-renewal observations in SQLite schema v3, preload them before administration listeners are exposed, and import the older `certs/renewals.json` sidecar once for upgrade continuity.
- Move the raw full-document editor from primary navigation into **Settings -> Manual config** while retaining `/configuration` as a compatibility browser route.
- Update OpenAPI, EN/PL help, cache/renewal/API documentation, migration/backup checks and release identity for 0.13.2.
## 0.13.1
- Make administrator browser sessions configurable from 7 to 30 days (30 days by default), replacing the previous short browser lifetime/idle behavior while preserving explicit logout/password/revoke invalidation.
- Refresh authenticated cookies only to the remaining absolute server-side expiry so session persistence cannot become an unbounded sliding session.
- Add per-backend `accept_local_listener_risk` with a dashboard confirmation and validation path for deliberate local-listener targets such as `127.0.0.1:9090`; `allow_private` and special-address protections remain independent.
- Update OpenAPI, transport/security/admin-session documentation, EN/PL strings and release regressions for 0.13.1.
## 0.13.0
- Add first-class redirect hosts with dedicated dashboard navigation, named listeners/certificates, exact/prefix and scheme matching, URL/host/path targets, 301/302/303/307/308, path/query policies and redirect cache controls.
- Resolve redirect traffic locally before proxy routes while preserving listener ownership and global host ACLs.
- Change omitted backend `tls_verify` to default false and update UI/API/documentation accordingly.
- Compact TLS certificate cards: show DNS SAN coverage as the same policy-style badges used on vhosts, collapse bindings/files/assigned-vhost details, and inspect older/manual public PEM files when persisted coverage metadata is missing.
- Extend OpenAPI, English/Polish translations, regressions and release tooling for 0.13.0.
## 0.12.1
- Add revisioned per-section configuration PUT endpoints and move ordinary dashboard forms to them, retaining full-document updates only where multiple sections must publish atomically.
- Decouple cache compatibility from the SQLite configuration revision so logging, administration, TLS inventory, metrics and other unrelated saves do not purge hot/cold objects.
- Add explicit dashboard subpage routes plus History API navigation so browser refresh preserves the selected panel.
- Add independent cold-tier policies for idle objects, LRU evictions and graceful shutdown, reduce the cold-write body queue, flush remaining hot objects before process exit, and trim released allocator memory on glibc/Linux.
- Expose certificate DNS SubjectAltName coverage separately from active SNI bindings for uploaded and ACME-managed certificates.
- Preserve secure-by-default backend TLS verification when omitted; keep the explicit debug-origin verification bypass and improve corrective validation/help text.
- Extend regression/OpenAPI/release checks and bump source/package identity to 0.12.1.
## 0.12.0
- Move raw TCP/UDP stream management from Settings to a dedicated **Streams** main-navigation page directly below Vhosts.
- Add vhost-style searchable stream cards and a modal editor with Details, Listener & upstream, and Limits tabs plus an explicit **Add TCP/UDP listener** action.
- Add fixed-cardinality runtime telemetry per active stream: connections/sessions, completed sessions, active sessions, rejects, errors, RX/TX bytes and UDP packet counters.
- Add per-stream live RX/TX rate charts in the dashboard while keeping desired stream changes visibly pending until the process restarts and the listener is actually active.
- Change named HTTP/HTTPS listener bind-address editing from a textarea to chip-style addresses matching the Vhost hostname interaction, preserving existing multi-address validation.
- Keep stream forwarding as raw Layer 4 with no HTTP vhost, ACL, cache, headers, compression or authentication envelope.
- Extend authenticated-asset and offline release/source regressions for the new Streams page and telemetry contracts; bump Cargo, panel, OpenAPI and upgrade/package identity to 0.12.0.
- Extend the Debian 13+ one-line bootstrap with `auto`/`install`/`update` modes; managed updates build the new source as an unprivileged user and delegate to the existing backup/health-check/rollback update path while preserving saved listeners and private data.
## 0.11.5
- Accept percent-encoded spaces (`%20`) in otherwise unambiguous request paths while preserving rejection of encoded separators, double-decoding hazards, controls and dot-segment ambiguity.
- Avoid the `h3-quinn 0.0.10` `stop_sending()` panic path when an HTTP/3 request body is dropped while a receive read is pending.
- Add raw layer-4 TCP/UDP stream hosts on dedicated listeners with bounded TCP connections / UDP client sessions and restart-bound configuration in the admin panel.
- Add a Debian 13+ source bootstrap that installs build dependencies and Rust, clones/builds the repository and installs the systemd service with the explicitly requested public admin profile.
- Match Varnish built-in request-method behavior for the shared cache: `GET`/`HEAD` participate in lookup while successful POST/PUT/PATCH/DELETE/OPTIONS/TRACE requests no longer trigger an implicit global purge.
- Replace the conservative per-request-header/backend-member cache identity with a Varnish-style Host + URL primary identity, isolated by RustProxy vhost/config scope, and use response `Vary` as the secondary representation selector.
- Share cached objects across load-balanced members and across GET/HEAD, reducing avoidable MISSes while retaining Cookie/Authorization pass behavior and response privacy guards.
- Preserve `X-Cache-Resident-Age` across backend 304 revalidation and hot/cold tier movement. Cold metadata format 2 stores variant identity and resident age explicitly.
- Add cache regressions for Vary variants/schema changes, Host/URL hashing, explicit ports, HEAD eligibility and resident-age continuity; update release/package/API/UI metadata to 0.11.5.
## 0.11.4
- Fix Force SSL frontend validation and messaging: require an actual non-TLS listener for the incoming redirect, prefer HTTPS port 443 when multiple TLS listeners are selected, and reject only ambiguous non-standard HTTPS target ports.
- Add a visual reusable ACL-profile rule editor with add/remove/action/CIDR rows. Keep bulk paste/import as an advanced path, normalize invisible Unicode whitespace and report malformed rules with their source line.
- When a vhost disables cache, strip and suppress RustProxy `X-Cache`, `X-Cache-Hits`, `Cache-Status`, `X-Cache-Reason` and `X-Cache-Resident-Age` diagnostics for that response.
- Render backend pools belonging to disabled vhosts as muted `Disabled` rows in the main dashboard instead of classifying them as unavailable.
- Add source/offline regression coverage and bump release/package/UI/OpenAPI/update metadata to 0.11.4. Rust compilation/runtime acceptance remains open in this packaging environment.
## 0.11.3
- Add per-vhost control over forwarding the effective client IP to the application. Disabling it removes `Forwarded`, `X-Forwarded-For` and `X-Real-IP` only on the backend request; ACLs, GeoIP, rate limiting and Tinyauth continue to use the real client identity.
- Add a compact Vhost search/filter for vhost IDs, hostnames/wildcards and backend IDs/URLs.
- Fix JSONL access logging under the hardened systemd unit by provisioning `/var/log/rustproxy` as a writable service log directory while keeping `ProtectSystem=strict`.
- Add source/offline regression coverage and bump release/package/UI/OpenAPI/update metadata to 0.11.3. Rust compilation/runtime acceptance remains open in this packaging environment.
## 0.11.2
- Fix administration HTTPS access negotiated over HTTP/2. The admin guard now derives the effective authority from H2/H3 `:authority` (via the request URI) when no legacy `Host` header is present.
- Preserve strict HTTP/1.x Host handling and reject conflicting HTTP/2 `Host` versus `:authority` values to avoid ambiguous authority validation.
- Add per-vhost Force SSL beside frontend selection. Cleartext requests receive a non-cacheable HTTP 308 to the same hostname/path/query on the vhost's unique selected TLS port; ambiguous multi-port TLS selections are rejected.
- Map NPM `ssl_forced` (and migrator `--force-https`) to RustProxy `force_ssl`, retaining HTTP+HTTPS listener bindings when available instead of importing forced hosts as TLS-only.
- Add unit/integration regression coverage for HTTP/2 administration authority, `/healthz`, Force SSL redirect construction/validation and NPM `ssl_forced`; bump release/package/UI/OpenAPI/update metadata to 0.11.2. Rust compilation/runtime acceptance remains open in this packaging environment.
## 0.11.1
- Fix release update health checks when the administration panel uses persisted HTTPS or a persisted bind different from the install-time CLI address. The check now resolves the active admin listener/scheme from `config.sqlite3` and honors the admin Host allowlist.
- Add a standard-library-only Nginx Proxy Manager SQLite migrator. It reads `proxy_host`, creates RustProxy vhosts/backends, preserves wildcard names/WebSocket/enabled/H2 intent, uses the RustProxy default certificate for imported TLS hosts unless a named certificate is selected, and keeps unsupported NPM policy/custom-config items as explicit review warnings.
- Make NPM migration dry-run by default, emit `npm-migration-plan.json`, detect hostname conflicts, split source records above RustProxy's 16-host limit and use revisioned authenticated API writes only with explicit `--apply`.
- Add offline regression coverage and bump release/package/UI/OpenAPI/update metadata to 0.11.1. Rust compilation/runtime acceptance remains open in this packaging environment.
## 0.11.0
- Add reusable ACL Profiles with optional IP/CIDR ACL and country allow/block rules. Vhost profile, inline vhost ACL and route ACL enforcement remains cumulative.
- Add an ACL Profiles navigation page and per-vhost profile selection without removing individual vhost policy controls.
- Add chip-style hostname editing and validated leading `*.` wildcard domains. Exact hosts win before the longest matching wildcard on the same listener.
- Add compact browser-session request-rate history charts to vhost cards, with progressive compaction of older samples.
- Add the built-in HTTP 401 page and classify normal WebSocket tunnel EOF/reset/abort/broken-pipe termination after a successful 101 upgrade as `websocket_closed` rather than a body/proxy error.
- Redirect plaintext requests received on the HTTPS administration socket to HTTPS while keeping application content unavailable over plaintext. Permit valid DNS Host values when the HTTPS admin hostname allowlist is empty; explicit allowlists remain strict.
- Extend named listeners to up to 16 explicit local addresses under one protocol policy. Multiple-address configurations reject unspecified `0.0.0.0`/`::` binds.
- Align the Logging field-builder dialog with the Vhost configuration modal and reposition the Configure fields action next to the format preset.
- Make source ZIP generation deterministic and verify byte-identical archives in release smoke tests.
- Bump Cargo, panel, OpenAPI, installer/update metadata and package identity to 0.11.0. Rust compilation/runtime acceptance remains open in this packaging environment.
## 0.10.0
- Add an optional persistent cold cache tier under the private data directory. Hot LRU/idle objects demote through a bounded asynchronous queue; fresh cold hits are SHA-256 checked and promoted back into the bounded RAM tier.
- Add hot/cold object, byte, promotion, demotion, cold-hit, eviction/error and queue-drop observability to the Cache API, panel and Prometheus output.
- Add cold-tier path, byte/object budgets and idle demotion settings; purge/config publication invalidates both tiers and startup rebuilds the same-revision cold index.
- Document browser hard-reload revalidation behavior and add translations for the exact `request_no_cache`, `request_max_age`, `request_min_fresh` and `request_pragma_no_cache` reasons.
- Add structured-log Standard, Nginx-style, ECS-style and Minimal presets plus a visual field picker/renamer with JSON preview; preserve advanced raw JSON mappings.
- Reuse the shared application card component for Logging sinks and remove release-specific fieldset border styling.
- Add baseline multi-stage Docker/OCI packaging with an unprivileged runtime user and persistent data volume. Docker discovery remains deferred.
- Bump Cargo, panel, OpenAPI, installer/update metadata and package identity to 0.10.0. Rust compilation/runtime acceptance remains open in this packaging environment.
## 0.9.0
- Redesign the vhost list as readable cards with listener/protocol/TLS/cache identity and live pool health, request, active, error, latency and traffic metrics.
- Add bounded asynchronous structured access-log sinks for JSONL files, Logstash TCP and Loki HTTP(S), with configurable JSON field mapping/static fields and sink delivery/drop/error counters.
- Expand cache object inspection with body bytes, resident bytes and metadata overhead; add optional `X-Cache-Resident-Age`.
- Add administration HTTPS with either the application-managed default certificate or a selected inventory certificate. Persisted non-loopback administration binds are accepted when HTTPS is enabled.
- Keep loopback HTTP as the default and retain the explicit legacy risk gate for plain remote HTTP. Secure session cookies are marked `Secure` on HTTPS administration.
- Add global TLS 1.2/1.3 and rustls cipher-suite policy plus per-vhost TCP TLS overrides. HTTP/3 vhosts must use the global TLS 1.3 policy.
- Add dedicated Logging navigation and full-width Settings tabs with new Administration and TLS policy pages.
- Complete roadmap items for remote HTTPS administration and `X-Cache-Resident-Age`; bump release identity, OpenAPI and packaging metadata to 0.9.0.
- Rust compilation/runtime acceptance remains unverified in this packaging environment.
## 0.8.1
- Enable the shared RAM cache by default.
- Replace blanket conditional-request bypass with local conditional HITs for fresh `If-None-Match` / `If-Modified-Since` requests.
- Add validator-based backend revalidation: request `no-cache`, freshness constraints and legacy `Pragma: no-cache` can revalidate a stored representation; backend 304 refreshes metadata and reuses the cached body.
- Keep request `no-store` as a hard bypass. Allow response `Cache-Control: no-cache` with ETag/Last-Modified to be stored only for mandatory validation; `no-store` and `private` still forbid shared storage.
- Retain expired validator-bearing objects for a bounded one-hour revalidation keep window without stale delivery.
- Raise the configurable object ceiling from 8 MiB to 1 GiB, still bounded by the global 1 GiB resident budget.
- Grow fill buffers incrementally and serialize very large fills through the existing fill semaphore to avoid preallocating the configured object ceiling.
- Exclude cache-control/Pragma and conditional/precondition headers from representation-key hashing because they govern reuse rather than representation identity.
- Add cache diagnostics and regressions for conditional HIT, revalidation, no-cache/no-store semantics and the 1 GiB configuration ceiling.
- Bump Cargo, panel, OpenAPI, installer/update metadata and package identity to 0.8.1.
## 0.8.0
- Global static-cache defaults now include common image, stylesheet/script, font, media and download extensions, with request Cookie ignored for matching assets by default. The global and per-vhost controls are visible in the cache UI; `Set-Cookie`, `private`/`no-store` and `Vary: Cookie` still prevent shared caching.
- HTTP/2 and HTTP/3 are per-vhost frontend opt-outs next to listener selection. Controls are enabled only when a selected named listener supports the protocol; TLS H2 ALPN is narrowed by SNI per vhost.
- Settings -> Listeners uses normal page scrolling instead of an extra nested scrollbar.
- Add per-vhost request throttling with graphical request/window/burst controls, country-specific limits and country block/allow-only policies. Return built-in HTML 429/403 responses while preserving custom error-page overrides.
- Download the PDDL `sapics/ip-location-db` user-country IPv4/IPv6 data during first installation over HTTPS and verify publisher SHA-256 checksums; expose load status without exposing client data.
- Verify the installed GeoIP database against current publisher SHA-256 files on every managed update and download only stale/missing assets before downtime.
- Make managed updates regenerate the systemd unit and restore it together with code/private data on failure; preserve and report the original failure reason and add a development-only `--reset-config` recovery path for intentionally incompatible saved vhost/settings documents.
- Add an opt-in per-vhost Cloudflare client-IP mode that accepts `CF-Connecting-IP` only when the direct peer belongs to Cloudflare's published edge CIDRs.
- Add Server-Timing response diagnostics. Disabled per-vhost H3 removes `Alt-Svc` and rejects HTTP/3 requests for that vhost.
- Move vhost listener binding exclusively to named listener selection. Listener addresses are configured under Settings; remove the pre-production inline/global listener compatibility importer and reject the old vhost format instead of migrating it.
- Make health-check mode `off` fully disable active probes and passive health classification instead of quietly learning health from production requests.
- Add a new-origin debugging preset for HTTPS-by-IP origins: default pool/private-origin/HTTP1/preserve-host enabled and backend TLS verification disabled for newly created panel backends, with explicit warnings about the certificate-verification risk and mismatched settings.
- Keep per-vhost inherit/override controls for static-cookie cache behavior while the global defaults cover common static extensions. Cookie is ignored only for the cache key/request bypass decision.
- Fix cache `Age` calculation to retain sub-second response delay and apply RFC 9111 corrected received age before adding response delay.
- Bump Cargo, panel, OpenAPI, installer/update metadata and package identity to 0.8.0.
- Rust compilation/runtime acceptance remains unverified in this packaging environment.
## 0.7.3
- Generate the one-time administrator setup token from the release CLI even before
the first server start; retain the rule that it is invalid after setup completes.
- Add explicit remote-panel opt-in for `0.0.0.0`/`::`, requiring both
`--allow-remote-admin`; keep plain-HTTP risk visible.
- Make Linux/systemd install defaults listen on HTTP 80 and HTTPS 443; add
`--no-https` for explicit HTTP-only installation.
- Add deployment and admin-host regressions and bump release/package/UI/schema
identifiers to 0.7.3.
- Hotfix backend HTTPS-by-IP with optional `tls_server_name`: TLS SNI and hostname
verification may use a DNS name while the validated TCP target stays pinned to
the configured backend URL.
- Add an explicit per-backend `tls_verify` switch, secure by default. Administrators
may turn verification off for an HTTPS origin without weakening DNS/address/self-loop
policy. Add matching 503/504 HTML gateway pages while preserving custom-page overrides.
- Rust compilation/runtime acceptance remains unverified in this packaging environment.
## 0.7.2
- Publish a distinct patch release; keep both previous 0.7.1 deliveries historical.
- Remove the build.rs panic on absent SWAGGER_UI_DOWNLOAD_URL. The asset preparer
defaults to pinned Swagger UI 5.32.11 independently of Cargo config discovery.
- Remove the independently downloading utoipa-swagger-ui adapter and embed the
prepared JS/CSS directly, for debug and release. Existing admin guards stay in place.
- Prepare assets with the existing Python 3.10+ tool requirement, now also needed
for direct Cargo builds. Support absolute local tag ZIPs, optional archive SHA-256,
bounded exact-member reads, validated download caching and retained license notices.
- Resolve build input paths from CARGO_MANIFEST_DIR, track local sources and outputs,
and report missing tools/assets explicitly instead of silently changing UI versions.
- Add executable synthetic-archive regression tests and protected license notices.
- Update release identifiers, packaging/update metadata and migration/build notes.
- Rust compilation and actual Swagger rendering remain unverified in this environment.
## 0.7.1
- Add visual request/response header editing to each custom vhost location, with
set/append/remove, validation, isolation and synchronized optional JSON.
- Split anonymous sign-in from the operational document and module/language bundles.
Remove the login tagline; preserve the original brand and compact square style.
- Reduce public health to `status` and auth discovery to `setup_required` only.
Make the public allowlist exact by method and path; private/unknown requests are
denied before body/query extraction, including method mismatches and aliases.
- Keep Origin/Host/CSRF/session guards; reject duplicate CSRF/marker headers and
hide login/setup JSON parser details. Clear UI data across tabs and on revocation.
- Add authenticated `/docs` and OpenAPI with pinned, build-embedded Swagger UI,
same-session authorization, read-only execution by default and CSRF-confirmed writes.
- Replace updater reliance on a public version with local MainPID/executable checks.
- Add source contracts, browser script regressions, actual-router Rust tests and a
read-only anonymous-access probe. Rust/build/vendor-renderer acceptance not completed.
## 0.7.0
- Renew managed ACME profiles automatically with shared worker bounds, jitter,
backoff, private observations and validated CAS hot deployment; expose opt-out,
check-now and dry-run controls. Keep previous certificates active on failure.
- Add install.sh/update.sh for Linux/systemd, root-owned release switching,
stopped full-data backups and code/data rollback on failed startup/version checks.
- Add per-vhost Basic Auth with Argon2id-only storage, optional verified upstream
credentials and cumulative graphical IP ACLs. Keep protected content out of cache.
- Add WebSocket toggles and graphical custom path locations, including explicit
exclusion of dedicated path origins from normal pool/sticky selections.
- Replace the sample welcome page with neutral information, make the protocol
fixture opt-in, unify square modal and Settings-tab styling, preserve branding.
- Add renewal/deployment helper tests and Rust/browser regressions. Rust compilation,
live CA renewal and real systemd deployment remain unverified in this environment.
## 0.6.0
- Replace separate legacy routes with vhosts and a one-way, validated compatibility importer; preserve path-pinned origins, ACLs and existing listener scope.
- Add HTTP-01 webroot domain validation, Cloudflare DNS-01 and OVH DNS-01 alongside RFC2136 using private Certbot workers.
- Unify certificate creation/import in a provider-picker modal; record optional issuer metadata in the library and keep secrets outside exported configuration.
- Add an independently configurable Prometheus endpoint with actual-peer CIDRs, optional environment-sourced bearer token, bounded scrapes and collector switches.
- Export application/admin/listener/vhost/backend/cache/system data and full-exchange vhost histograms with bounded configured labels.
- Split Settings into functional switching cards; preserve dirty values, keyboard access and translation discovery; remove Security and legacy routing sidebar entries.
- Add offline provider/browser regression tests and Rust tests for migration, ACME challenge isolation and scrape authorization. Rust compilation/runtime and actual CA/provider integrations remain unverified.
- Preserve the original brand assets. Automatic certificate renewal, Cloudflare Tunnel and Docker discovery remain deferred.
## 0.5.0
- Add a compact dedicated cache panel with vhost/domain counters, object pagination,
TTL/Age/hits, memory/fill diagnostics and authenticated selective purge.
- Fix reserved administrator-cookie ordering and empty terminating DATA frames;
emit fixed cache decision reasons without exposing request secrets.
- Replace special X-Mock-Origin hiding with the explicit global/vhost revision
switch; ordinary response rules still handle arbitrary removable headers.
- Default HTTP/3 on TLS bindings, retain explicit null opt-outs, and explain
overlapping sockets with ownership and concrete protocol/limit differences.
- Return 503 for upstream connection/unavailability, 502 for invalid responses
and 504 for header timeouts; generated errors use no-store.
- Add basic asynchronous Certbot DNS-01/RFC2136 issuance, staging-first consent,
private TSIG/account profiles, output validation and explicit library import.
No automatic renewal scheduler or real CA validation is claimed.
- Add offline helper and UI checks plus Rust regression source. Rust/Cargo remain
unavailable in the packaging environment; compilation/runtime unverified.
## 0.4.0
- Rebuild standard Forwarded and X-Forwarded metadata at an explicit trusted-proxy boundary; use verified client IP for ACL/affinity, never for destination routing.
- Add per-vhost Tinyauth subrequests, identity header allowlists, safe same-login-origin redirects and fail-closed checks before origin/static/cache/WebSocket use.
- Move certificate upload into an independent inventory; assign certificates by ID per vhost and expose metadata-only inventory reads. ACME is not implemented.
- Add named multiple-IP listeners with descriptive scope, opt-in legacy routes, reusable vhost references and actual/desired plans.
- Add Quinn/h3-based HTTP/3 UDP transport, TLS 1.3/SNI, Alt-Svc, per-endpoint connection and per-connection stream/idle limits, shared proxy policies and bounded shutdown. Preserve TCP HTTP/2 plus HTTP/1.1.
- Hide request-ID/revision headers by default; add individual overrides and optional mock-origin stripping. Apply effective Server policy across all response paths, including extra sockets.
- Partition cache by actual frontend IP/port and safely probe unknown-length empty requests; preserve nonempty body frames instead of discarding them.
- Raise toast above active dialogs without making it inert under the modal; retain the logo, compact UI and discovered language packs.
- Add a loopback-only fake-auth development peer and Rust integration sources for forwarding, auth, extra-socket cache, listener ownership and real QUIC/TCP fallback.
- Execute frontend/helper/static checks documented in TEST_REPORT.md. Rust/Cargo are unavailable: no passing compilation, real proxy run or full RFC-conformance claim.
## 0.3.0
- Split the large status JSON macro into small objects and an explicit feature map;
preserve response fields without increasing Rust's macro recursion limit.
- Add optional per-vhost certificate inventory selection and PEM file upload.
Validate key/certificate pairing and names, write private immutable files, and
activate only through the existing revisioned configuration update.
- Add per-vhost cache overrides and selective invalidation, including stale
in-flight fill cancellation while leaving other host entries intact.
- Add administrator-authored, bounded global/vhost error pages, static vhost and
missing-path responses, direct-IP and unknown-host responses. Retain ACL checks,
HEAD handling and authentication challenges; never execute HTML in the admin UI.
- Move access logs into a separate modal/docked viewer with fixed filters,
vhost identity, pause, scoped search/status/method/cache controls and JSON export.
- Add a compact vhost list, tabbed dialog, live health/latency summaries and visual
per-host response/request headers; keep the original logo and favicon.
- Add density preferences and a Settings shortcut to Security.
- Add a Python-only mock origin with random data, cache/no-store/private/cookie
paths, compression-size payloads, controllable delay/health and error statuses.
- Add Rust regression source for pages, cache scoping, TLS staging and API guards.
- Rust compilation/runtime acceptance remains unverified: toolchain unavailable.
Executed checks and their exact limitations are recorded in TEST_REPORT.md.
## 0.2.0
- Replace SizeAbove(u16) with a custom u64 predicate; add >64 KiB threshold regressions.
- Add IP/port/TLS-bound virtual hosts with frontend(s), backend pool and path policies.
- Enforce real accepted destination matching; isolate legacy routes from extra sockets.
- Add weighted round-robin, least-connections, least-traffic, adaptive and IP hashing.
- Add bounded TCP/HTTP health checks, separate passive failures, recovery and slow start.
- Add connection keepalive controls, backup members, drain and opaque cookie/IP affinity.
- Preserve compatible observations/tokens across hot edits; recheck changed/re-enabled members.
- Keep leases through full uploads/responses/tunnels; never replay failed requests.
- Add per-vhost TLS-only HSTS, nosniff, frame/referrer/CSP/permissions headers.
- Retain HTTP/1.1 ALPN with HTTP/2 for TLS frontends and HTTPS origins.
- Separate cache identities by selected member; bypass cookie-affine pools.
- Add compact vhost editor, atomic legacy migration, backend observations/API/metrics/log IDs.
- Extend discovered English/Polish translations; preserve original logo and favicon.
- Add Rust regression source and a real-binary vhost/keepalive/health acceptance suite.
- Execute 63 simulated-API UI, 24 SQLite, 21 upgrade, 4 language-mirror, 10 protocol-peer
and 7 backend-fixture checks. None is a passing Rust build or runtime test.
- Rust/Cargo remain unavailable in the packaging environment; compilation/runtime
acceptance and a resolved Cargo.lock remain mandatory before deployment.
## 0.1.0
Development source advancing directly from 0.0.6. Cloudflare is deferred.
### Added
- Persisted public listener addresses/ports, 80/443 presets, startup defaults and restart status.
- Bounded streaming shared RAM cache, TTL/Age, LRU, privacy/Vary protections, per-route bypass, optional HIT/MISS headers and audited purge.
- Streaming gzip, Brotli, Zstandard and deflate controls, final Server policy and a global client-response header editor.
- Compact dashboard with system/process/cache diagnostics, log filters and export.
- Compile-time language discovery, JSON packs, metadata/RTL/fallback, and preservation of extra packs during upgrade.
- Example unprivileged low-port service and cache/settings real-binary integration test source.
### Changed
- Allow ordinary remote-origin 80/443 while guarding local proxy/admin listener targets.
- Keep original logo/favicon; reduce panel spacing and improve data density.
- Invalidate cache and older fills on config changes, explicit purge and successful unsafe methods.
- Expand source/SQL/upgrade/language/frontend checks; actual execution scope is in docs/TEST_REPORT.md.
### Verification boundary
Rust/Cargo and real Rust integration were unavailable. This is a source candidate,
not a successfully compiled or production-certified binary. No lockfile is fabricated.
## 0.0.6
Cumulative development source release. The planned 0.0.3, 0.0.4 and 0.0.5 scopes
are included here, not represented as separately published archives.
### Fixed
- Explicitly select `src/auth/mod.rs` to prevent the reported flat/folder module
conflict when obsolete 0.0.1 source remains in an existing checkout.
- Detect mixed source layouts and preserve the known legacy auth file; refuse to
delete unrecognized local changes.
- Support interactive username input for `admin init`.
- Replace managed source directories with rollback on copy failure rather than
encouraging overlay extraction.
### Added
- DNS and HTTPS manual backends with per-backend pools, bounded DNS workers,
validated DNS answers, optional CIDR allowlists and additional PEM trust anchors.
- rustls public TLS listener, persistent development default certificate, exact SNI
bindings, local generate/import commands and explicit hot reload.
- Public HTTP/2 over TLS ALPN and HTTP/2/auto backend protocol options.
- Validated HTTP/1.1 WebSocket handshakes and bounded bidirectional tunnels,
including TLS on either side, connection leases and a separate idle timeout.
- Transport metrics, WebSocket lifetime logging, TLS editor and advanced backend
controls in the English/Polish panel.
- Backup of configured/default certificate files alongside the SQLite snapshot.
- Upgrade regression tests, transport configuration tests, real protocol test
scripts and CI steps. Test execution limits are recorded in TEST_REPORT.md.
### Changed
- Public TLS is enabled by default by `dev.sh`; direct binary startup keeps it optional.
- Listener ports are protected across HTTP, HTTPS and administration.
- All Markdown documentation is maintained in English.
- The roadmap identifies the combined transport release and upcoming Docker stages.
## 0.0.2
- Introduced RustProxy naming, administrator setup, Argon2id passwords, sessions,
CSRF checks, login throttling, password recovery and credential revocation.
- Added host/route ACLs, generic request/response header policies, administration
audit, SSE dashboard updates and security controls in the bilingual panel.
- Added an additive SQLite migration, offline validation and online database backup.
- Rust build/runtime verification was unavailable for the source package.
## 0.0.1
- Initial manual-backend HTTP/1.1 proxy, streaming and keep-alive.
- SQLite configuration, dynamic host/alias/path routing and local token control plane.
- English/Polish interface, bounded access log, counters, CLI, demo backend and dev.sh.
- Rust build/runtime verification was unavailable for the source package.
+57
View File
@@ -0,0 +1,57 @@
# Configuration and settings
Most settings are managed in the authenticated panel. Save changes after validation; use **Configuration history** to compare or restore an earlier revision. Some listeners are process-owned and require restart to change. Configuration updates are not a replacement for encrypted full backups.
## Configuration map
| Panel area | Main settings | Operational note |
|---|---|---|
| **Settings → Listeners** | Named HTTP/HTTPS frontends, addresses, HTTP/2, H2C, HTTP/3, transport limits | The selected Vhost must bind the correct listener. Binding changes may require restart. |
| **Settings → Administration** | Dedicated admin address, HTTPS/certificate, accepted hostnames/public origin | Local HTTP by default. Remote admin requires deliberate security configuration. |
| **Vhosts** | Domains, certificates, source, backends, health, load balancing, custom locations | The primary place to publish a service. |
| **Web Server** | Document roots, static delivery, rewrites, PHP-FPM, cache headers | Attach a Web Service to a Vhost as its source. |
| **Access / ACL profiles** | IP/CIDR, DNS/DDNS allow rules, country policy, HTTP methods, rate limits | Access decisions combine across applicable levels. |
| **Settings → Cache** | HOT/COLD budgets, TTL, exclusions, diagnostics, invalidation | Global and Vhost permissions must both allow caching. |
| **Settings → Compression / Transform / Headers** | Response codecs, content modification, headers and security settings | Avoid changing personalized content in a shared cache. |
| **Certificates** | PEM import, managed ACME issuance, renewal and assignment | Issuing a certificate does not automatically publish a Vhost. |
| **Docker / Streams** | Docker agent hosts and published services; TCP/UDP forwarding | Streams bypass HTTP Vhost policy. |
| **Overview / Appearance** | Dashboard cards, presets, layout, charts and theme | Account-specific display preferences do not change proxy behavior. |
| **Logs / Monitoring** | Live/stored logs, error logs, analytics, Prometheus, GeoIP updates | Logs and metrics may need dedicated limits/network permissions. |
Read the dedicated guide before enabling advanced options: [Vhosts](VHOSTS.md), [Cache](CACHE.md), [Web Server](WEB_SERVER.md), [Monitoring](MONITORING.md), [Security](SECURITY.md).
## Listener and domain setup
1. Create or review a named HTTP/HTTPS listener in **Settings → Listeners**.
2. For HTTPS, choose the certificate and protocol capabilities. HTTP/3 is optional and requires UDP access.
3. In a Vhost, set exact domains or supported leading wildcards (`*.example.com`), select listener IDs and certificate.
4. Set the source: HTTP reverse proxy or an existing Web Service. Test DNS, TLS and backend health.
5. Add HTTP → HTTPS (`308`) or www/non-www redirection if needed. Check routing before turning on caching or restrictions.
Listener bindings set by startup CLI/systemd cannot be removed, disabled or modified through saved configuration. Saved settings may therefore be valid but not active until a service restart. Administration and public frontends use different security boundaries.
## Backend protocols
`http1` uses HTTP/1.1. `auto` selects H2/H1 over HTTPS and ordinary HTTP/1.1 over plaintext. The legacy `http2` value retains compatible HTTPS negotiation and H2C for HTTP. Explicit `h2` requires H2/H2C; do not use it with an origin that only supports HTTP/1.1. Backend TLS certificate verification is **off by default** in the source model; enable it deliberately for trusted origin identity. A private origin, loopback origin or origin on a local RustProxy listener also requires an appropriate explicit exception.
## Access and header policies
ACLs support IP/CIDR and **allow-only** DNS/DDNS hostname rules, plus reusable profiles and country rules. Host, Vhost and Custom-location checks are cumulative: an allowed location does not undo a host denial. Configure forwarding identity only behind an explicit trusted proxy. Optional Basic Auth or Tinyauth is attached to Vhosts; WebSockets should be enabled only where used. See [Access](ACCESS.md).
Global response headers, per-Vhost rules and Web Service-specific headers are distinct scopes. Security and proxy-protocol headers may be protected from rewriting. Compression supports eligible response encodings; availability is still constrained by global limits and the requesting client.
## Cache and transforms
Caching is opt-in. HOT holds bounded memory entries; COLD is an optional disk tier (off by default). Freshness follows eligible HTTP policy and `Vary`; private/no-store responses and protected requests are not shared. Configuration saves can selectively invalidate affected cached responses; 0.34.0 behavior needs production validation. HTTP BAN/PURGE is **disabled by default** and uses an explicit source-IP allowlist. See [Cache](CACHE.md).
Response transformations are optional and apply only to configured eligible MIME types and scopes. See [Response delivery](RESPONSE_DELIVERY.md) for codec defaults, transformation limits and header precedence. Content rewriting can affect validators, compression and cache entries; test actual representations after configuration changes.
## Administration preferences
Appearance settings, Dashboard Presets and Overview cards belong to the administrator profile. Up to 16 named presets can store card visibility, order, widths, density and sample count. Changing the dashboard does not restart proxy listeners or rewrite routing rules. Source sections under `config` have a revision guard to prevent accidental overwrite by a stale editor.
## Sample files and safe changes
Example JSON is in `examples/`. Treat it as a starting point: addresses, certificates, paths, backend health endpoints and service IDs must match your installation. The API uses full validated settings and rejects unknown fields; it is **not** a generic patch store. Use the current authenticated `/docs → API` OpenAPI reference to inspect field schemas.
For planned changes, export configuration, create an encrypted backup, make one logical change, save, verify real traffic and confirm metrics/logs. Revert via configuration history only when rollback of the config (not filesystem/secrets) is sufficient.
+25 -31
View File
@@ -1,44 +1,38 @@
# Container / OCI deployment
# Docker / OCI deployment
RustProxy ships a baseline multi-stage `Dockerfile`. It compiles the locked Rust
release and runs it as the unprivileged `rustproxy` user (UID 10001). The core image does
not mount the Docker socket. Docker discovery uses the separate `docker-agent/` process on
each Docker host, so the proxy container never needs Docker-socket access. See
[Docker discovery](DOCKER_DISCOVERY.md).
The included `Dockerfile` compiles RustProxy with a **Rust 1.99** build image and runs it on Debian as an unprivileged `rustproxy` account (UID 10001). It includes Python, Certbot and DNS-plugin packages for certificate handling. The main container does **not** need the Docker socket.
Build locally with a normal OCI builder:
```bash
docker build -t rustproxy:0.29.1 .
```
Persist `/var/lib/rustproxy`; it contains the SQLite database, certificates and the
optional cold-cache tier. The default container proxy listener is `0.0.0.0:8080`.
Administration remains `127.0.0.1:9090` inside the container and is intentionally
not remotely reachable by merely publishing port 9090.
## Build and start
Run from the repository root:
```bash
docker build -t rustproxy:0.34.0 .
docker volume create rustproxy-data
docker run --rm -d --name rustproxy \
docker run -d --name rustproxy \
-p 8080:8080 \
-v rustproxy-data:/var/lib/rustproxy \
rustproxy:0.29.1
rustproxy:0.34.0
```
In the image, the default HTTP frontend binds `0.0.0.0:8080`. The admin listener defaults to `127.0.0.1:9090` **inside the container**. Simply publishing container port 9090 does not make that loopback listener reachable from a remote host. Initialize the administrator from a local container shell:
```bash
docker exec rustproxy rust-proxy admin setup-token
```
To expose administration, first enable administration HTTPS in the persisted
RustProxy configuration and choose a certificate, then bind the administration
listener to a non-loopback address. Publish the admin port only after HTTPS is
active. Plain remote HTTP keeps the legacy explicit risk gate.
Provide a secure, private path for first-time panel access. If remote administration is required, configure its own HTTPS mode, certificate and an intentional non-loopback listener before mapping the port publicly; restrict it with firewall policy. Never expose plain HTTP administration as a convenience.
The runtime image includes Python, venv/pip, OpenSSL and distribution Certbot packages
for Cloudflare, OVH and RFC2136 fallback. RustProxy prefers its own pinned Certbot
environment under `/var/lib/rustproxy/acme-python`; because `/var/lib/rustproxy` is the
persisted volume, that environment survives container replacement. If pip setup is not
possible, the image can fall back to the distribution Certbot binaries already in `PATH`.
## Persistent data and ports
For access-log files, prefer a writable path under `/var/lib/rustproxy` or mount a
separate writable log volume. The built-in JSON file sink is disabled by default.
Low ports such as 80/443 require an explicit container capability or host port
mapping; the image itself does not run as root.
- Mount `/var/lib/rustproxy` persistently for SQLite, private keys, certificate profiles and optional COLD cache. Back it up securely and keep volume ownership correct.
- Map the chosen HTTP and HTTPS TCP ports. If enabling HTTP/3, publish the corresponding **UDP** port too, and allow it through the host firewall.
- If you need low-numbered **container** ports, use host-port forwarding or deliberate capability settings; the image runs without root privileges.
- Store file access logs under a writable location/volume if enabled. The built-in on-disk access-log sink is not necessarily on by default.
- Managed ACME material in the persisted volume survives container replacement. DNS credentials and external mounts must be protected and carried over separately.
For Docker-based application discovery, install the **separate Docker agent** on each target host and connect it over the supported restricted interface. See [Docker discovery](DOCKER_DISCOVERY.md).
## Upgrade and validate
Build a reviewed new image, preserve the volume, make an encrypted application backup and deploy with a maintenance window. Verify configuration compatibility, listener reachability, frontend certificates, backup/restore and live traffic. The supplied 0.34.0 source candidate is not itself a production-tested OCI image.
+24 -115
View File
@@ -1,132 +1,41 @@
# Custom locations
# Custom locations and path rules
RustProxy 0.22.3 supports the common location-level controls that are typically used for application reverse proxies. The UI intentionally exposes a safe subset instead of accepting arbitrary Nginx configuration text.
Custom locations add exceptions to a Vhost's normal source. They are **not** required for the root path `/`: any request not matched by a custom rule continues to the Vhost's main backend pool or Web Service. Configure them under **Vhosts → Edit → Custom locations**.
## Routing model in 0.16.0
## Path matching
The vhost's main backend pool is an internal fallback and is **not** one of the Custom locations. Custom locations are optional overrides only. Request routing is therefore:
| Match mode | Purpose | Priority |
|---|---|---|
| `exact` | One exact normalized path | Highest |
| `prefix_priority` | Longest prefix that suppresses regex evaluation | Ahead of regex when matched |
| `regex` / `regex_insensitive` | Case-sensitive or insensitive regular expression | Tested in configured order after priority-prefix rules |
| `prefix` | Ordinary longest matching path prefix | Fallback among matching rules |
```text
exact custom location
-> priority/regex/prefix custom location according to the documented precedence
-> main vhost backend pool
```
An empty Custom locations list is valid. Adding `/test`, a regex, a local response or a dedicated-backend location cannot disable `/`, `/abc` or other unmatched paths. Older saved documents that contain the former untouched synthetic `root` prefix `/` entry are normalized automatically. If `/` was deliberately customized with a backend, cache/ACL/header rule, rewrite or local response, it remains a real Custom location and intentionally overrides the main fallback.
## Dashboard editor
The visual editor is the primary workflow. Every location card now shows a live **Effective behavior preview** at the bottom with the match mode/pattern, proxy or local-response action, selected backend, rewrite behavior, cache policy, ACL composition and request/response header-rule counts. The preview is calculated from the unsaved form state so an administrator can verify intent before saving.
Request and response header rules use numbered visual cards with localized **Operation / Header name / Header value** controls. `Set`, `Append` and `Remove` are supported. The Advanced JSON view remains available for automation/copy-paste, but is no longer required for normal editing. The documentation button in the location tab explains precedence, rewrites, local responses, policy composition and common examples.
## Match modes
Each location has an ID, a pattern and one match mode:
| RustProxy mode | Nginx-style equivalent | Behaviour |
| --- | --- | --- |
| `prefix` | `location /path/` | Longest matching path prefix. A matching regex may still win. |
| `prefix_priority` | `location ^~ /path/` | Longest prefix and, when selected, skips regex locations. |
| `exact` | `location = /path` | Exact normalized path only. Exact matches have highest priority. |
| `regex` | `location ~ pattern` | Case-sensitive Rust regular expression. Regex locations are tested in configuration order. |
| `regex_insensitive` | `location ~* pattern` | Case-insensitive Rust regular expression. Regex locations are tested in configuration order. |
Routing and ACL checks collapse repeated literal slashes, matching Nginx's usual `merge_slashes on` behaviour. For example `/static/js//events.js` is routed as `/static/js/events.js`. Unless a rewrite is configured, the original request target is still sent to the backend.
Encoded separators (`%2f`, `%5c`), encoded `%`, dot segments and other ambiguous path forms are still rejected instead of being silently normalized.
Ambiguous and unsafe encoded path forms are rejected rather than treated as alternative spellings. Avoid overlapping regex rules when order matters; test paths with and without trailing `/`. A Vhost's main source handles paths without a matching custom location.
## Actions
A location can proxy to a backend, serve an existing Web Service, return a local response, or redirect the client to another absolute HTTP(S) URL.
- **Proxy:** use the Vhost backend pool or target a specific backend. A location can have an explicit cache override.
- **Web Server:** reference an existing Web Service with its saved document root and PHP/static settings.
- **Local response:** return a chosen `200–599` status, content type and body without connecting to an origin. `HEAD`, `204` and `304` do not return a body.
- **Redirect:** issue `301`, `302`, `303`, `307` or `308` to a valid absolute HTTP(S) target. Query preservation is configurable. Redirect rewrite expressions may reference regex captures.
### Proxy
### Path rewriting
The location can use the vhost pool or pin traffic to one backend. A dedicated backend can be created from the location editor. Cache policy can inherit from the vhost or be explicitly enabled/disabled for the location.
`preserve` forwards the original path, `strip_prefix` removes the matched prefix, `replace_prefix` substitutes a new normalized prefix and `regex` rewrites using captures such as `$1` or `${name}`. Queries remain intact unless a redirect explicitly changes handling. Public cache keys are based on the client-facing URL, not an internal rewritten backend path.
Supported path transformations:
**Example:** send `example.com/api/*` to a dedicated application while `/` remains on the main website. Create a `/api/` prefix location, select its backend and choose whether to preserve or strip `/api/`. Inspect the editor's effective-behavior preview before saving.
- **Preserve** — send the original client path unchanged.
- **Strip prefix** — remove the matched prefix before proxying.
- **Replace prefix** — replace the matched prefix with another normalized path prefix.
- **Regex rewrite** — available for regex locations; Rust regex captures can be referenced as `$1` or `${1}` in the replacement.
## Headers, access and method rules
The query string is preserved. Rewrites change only the selected source path; reverse-proxy cache identity remains based on the public client URL.
Each location can add request/response header operations (`set`, `append`, `remove`), reusable ACL profiles, inline IP/CIDR rules, country access and HTTP-method allow/block lists. Every applicable host and Vhost policy must still pass; a location cannot remove a higher-level denial. Transport/forwarded-identity headers are protected from unsafe overrides. Authentication, rate limits, WebSocket policy and other Vhost settings continue to apply.
### Web Server
## Internal error fallback
`Web Server` selects one reusable service previously created in the **Web Server** workspace. The location stores only the Web Service ID; document root, index list and directory-listing policy remain in that shared service. Existing location matching and path rewriting are reused, but backend pinning and reverse-proxy object-cache controls do not apply to local files. See [WEB_SERVER.md](WEB_SERVER.md).
Mark a location **Internal** to make it unavailable to ordinary public URI matching. A Vhost or location may direct specific origin responses (`403`, `404`, `500`, `502`, `503`, `504`) to a named internal location ID in the same Vhost. The dispatch is internal, not a browser redirect; request context and access evaluation are preserved. Invalid targets or loops are rejected.
### Local response
If all you need is a different error **page body**, use **Vhost → Pages → Intercept application error responses**, which keeps the original status without making a second origin request.
`Return custom response` is the equivalent of a simple Nginx `return`: choose an HTTP status from 200 through 599, a Content-Type and a response body. RustProxy does not open a backend connection for this action. `HEAD`, `204` and `304` responses do not send a body.
## Not an Nginx configuration parser
### Redirect
`Redirect` is a backend-less action. Choose `301`, `302`, `303`, `307` or `308`, set an absolute `http://` or `https://` target, and optionally preserve the incoming query string. RustProxy validates the target and does not allow capture expansion inside the target scheme/authority.
For capture-based path rewriting, configure a separate **Redirect rewrite regex**. Captures can be referenced as `$1` or `${name}` in the redirect target path/query. This separation mirrors an Nginx `location` selector followed by a separate `rewrite` expression. For example, the Nginx pattern:
```nginx
location ~* \.(?:tar\.gz|zip?)$ {
rewrite ^/(.*)$ https://objectstorage.example.invalid/b/repo/o/$1 permanent;
}
```
corresponds to a case-insensitive regex Custom location `\.(?:tar\.gz|zip?)$`, action **Redirect**, status `301`, target `https://objectstorage.example.invalid/b/repo/o/$1`, and redirect rewrite regex `^/(.*)$`. Query preservation is controlled independently.
## Request and response headers
Every location has a visual header configurator and an Advanced JSON view. Both edit the same data.
Supported operations are:
- `set` — replace the header with the configured value;
- `append` — append another header value;
- `remove` — remove the header.
Example JSON:
```json
{
"request": [
{"op": "set", "name": "X-App-Route", "value": "api"}
],
"response": [
{"op": "set", "name": "Cache-Control", "value": "no-store"},
{"op": "remove", "name": "X-Legacy-Header"}
]
}
```
Hop-by-hop/framing headers, forwarding identity headers, RustProxy request IDs and WebSocket handshake headers are protected and cannot be overwritten by custom rules. This prevents a location rule from changing transport framing or spoofing the verified client identity.
## Access control
A location can define its own IPv4/IPv6 CIDR ACL, select a reusable ACL profile, add a location-specific country block/allow-only rule, and configure an HTTP method policy (`off`, `allow` or `block`). Method names are normalized to uppercase and validated as HTTP method tokens.
All access layers are additive. The request must pass the vhost method policy, location method policy, host/profile/vhost IP ACLs, location profile ACL, inline location ACL and every applicable country policy. A location therefore cannot override a denial from its vhost. Method denial returns `405 Method Not Allowed` (with `Allow` for allow-list mode); IP/country denials retain the normal `403` pipeline. These checks occur before backend selection/connection.
Authentication, vhost rate limits, security headers, compression, custom error pages and WebSocket policy still apply through the normal vhost pipeline.
## Internal locations and error fallbacks
RustProxy 0.29.1 can mark a Custom location as `internal`. An internal location is addressed by its stable location `id` and is never selected by a public URI match. The vhost main source or another Custom location can use `error_fallback` to internally dispatch configured `403/404/500/502/503/504` responses to that target. The default intercepted status is `404`.
This is an internal request handoff, not an HTTP redirect. The original method, URI/query and request context are retained. The fallback target re-enters normal access/header/cache/compression/response processing. Fallback targets must be enabled internal locations in the same vhost; validation rejects cycles and runtime dispatch is depth-limited. See [current vhost documentation](VHOSTS.md).
### Replace origin error bodies without an internal location (0.29.7)
The independent, optional **Vhost → Pages → Intercept application error responses**
setting (`pages.intercept_statuses`, for example `[404, 503]`) replaces a
backend or Web Server/PHP origin's error *body* with the configured vhost
error page, then global error page, or a built-in page. It retains the HTTP
status and sets `Cache-Control: no-store`. Unlike `error_fallback`, it does
**not** forward the request to a different backend. The existing internal
`error_fallback` action has priority and its final response remains intact.
See [Web Server – application HTTP error interception](WEB_SERVER.md#intercept-application-http-errors-0297).
## What is intentionally not an Nginx parser
Custom locations do **not** execute arbitrary Nginx directives or configuration snippets. RustProxy does not currently implement location-level `try_files`, per-location filesystem aliases/roots, embedded scripting, arbitrary `if` expressions or arbitrary module directives. Static roots are defined only by reusable Web Services. Use the explicit UI controls and the documented JSON schema instead; unknown fields are rejected on save.
The editor does not run arbitrary Nginx snippets, filesystem aliases, arbitrary `if` rules or per-location static roots. Configure static paths in a reusable Web Service, then assign that service to the location. Inspect [Vhosts](VHOSTS.md) and [Access](ACCESS.md) for the policies applied before connecting to the backend.
+32
View File
@@ -0,0 +1,32 @@
# Administration dashboard and preferences
The authenticated panel is the primary way to manage RustProxy. Pages cover **Overview**, **Vhosts**, **Web Server**, **Certificates**, **Docker vhosts**, **Streams**, **Traffic**, **Cache**, **Logs**, **Settings** and **Documentation**. Availability of individual cards depends on enabled features and the account's display preferences.
## Overview cards and layouts
The Overview contains live counters, charts and optional backend, cache, PHP, Docker and Stream cards. Under **Settings → Appearance → Dashboard layout** or the Overview editor, configure visibility, order, card width, density and the number of history samples. A hidden card stops consuming its optional browser-side visualization work but does not turn off its underlying proxy feature.
**Dashboard Presets** supports up to **16 named layouts per administrator**. Save, load, rename, overwrite and delete presets; a factory layout can be restored. Presets preserve the dashboard arrangement, not the live traffic values. Interface theme, scale and other display settings are also account-scoped and do not alter proxy routing or rewrite shared configuration.
## Editing configuration safely
1. Open the relevant workspace and inspect existing state before editing.
2. Change a logically related group of settings and review the editor's effective-preview/status where available.
3. Save against the expected current configuration revision. A stale edit should be reloaded instead of silently overwriting a newer change.
4. Confirm whether the saved setting is active immediately or requires a process restart (notably listener binds and administration HTTPS).
5. Check the live page and request logs after sending test traffic.
Unfinished editors may be minimized into a draft dock; this is a UI convenience and **not** a durable change or backup until saved.
## History and restore
**Configuration history** stores retained validated snapshots. Restoring a snapshot creates a new configuration revision. It does not restore every filesystem file, external certificate, Secret Store key or accumulated cache/log history. For disaster recovery, use a full encrypted backup; see [Backup](BACKUP.md).
## Interpreting charts
- Live views are process/sampling-based and can show no-data when an integration has not reported yet.
- Per-backend protocol badges can distinguish configured HTTP mode from the protocol actually observed in connection traffic.
- Response-header timing excludes the full streamed body duration. HTTP server/client percentages refer to the specified layer.
- A data sampling gap, a reset after restart and a true zero are not always equivalent.
See [Monitoring](MONITORING.md) for metric details and [Configuration](CONFIGURATION.md) for persistent service settings.
-74
View File
@@ -1,74 +0,0 @@
# Factory Overview dashboard preset (0.33.3)
`src/admin/web/dashboard-defaults.json` is the single factory configuration
for the Rust admin preference API and the authenticated JavaScript panel.
The preset contains:
- `dashboard_widgets`: complete card identifier order, including hidden cards;
- `dashboard_hidden_widgets`: identifiers hidden in the factory Overview;
- `dashboard_density`: `normal`, `compact` or `ultra`;
- `overview_samples`: `60`, `120`, `300` or `600` samples for first use in a browser.
Card IDs and labels are defined in `src/admin/web/dashboard-widgets.json`.
The preset may omit newly registered card IDs: the server/client will append
those cards and apply the registry's `default_hidden` flag. The preset must
not contain unknown or duplicate IDs, or hidden IDs absent from its order.
The bundled 0.33.1 preset reproduces the supplied operator layout: 78 ordered
cards, 51 hidden, compact density and 600 initial samples. The packaged
Web Server cards remain hidden by default.
## User-selected card widths (0.33.3)
The factory preset intentionally retains the original 0.33.1 widths and
legacy grouped presentation. Version 0.33.3 adds a separate nullable preference:
- `dashboard_widget_sizes: null` (including omitted in an old record): retain
previous size groups, so upgrades do not spontaneously rearrange cards.
- `dashboard_widget_sizes: {}`: freely order all card types, while leaving
their widths at the original values.
- `dashboard_widget_sizes: { "http_protocol": 4, "wide_transform_activity": 3 }`:
also override individual widths, in units of the 12-column Overview grid.
Permitted widths: 2, 3, 4, 6 and 12 columns; regular charts need at least 3,
full diagnostic panels at least 4, and the backend table at least 6. At the
largest canvas four 3-column charts fit alongside each other. On small screens
the renderer reduces to a six- or two-column grid; the saved dimensions do not
change. In **Unlock layout**, click − / + or drag cards across their old groups;
**Arrange cards visually** provides the same controls. Save persists order,
visibility and sizes in the administrator's current preference document.
**Cancel** restores the saved layout and sizes. **Restore defaults** restores
the original factory order/visibility and width defaults, but does not change
the independently saved Overview sample limit. Dense packing reduces gaps,
so when columns do not divide evenly, a later narrow tile may fill an earlier
free space without changing the saved DOM or keyboard traversal order.
## Deploying an edited preset
1. Edit `src/admin/web/dashboard-defaults.json` in the source tree.
2. Validate the JSON and identifiers (e.g. run
`python3 scripts/tests/dashboard_layout_0331_browser_test.py`).
3. Rebuild RustProxy (`./dev.sh check` / the normal release build) and deploy
the newly built binary. The preset is **embedded**; editing the installed
source JSON alone does not update a running binary.
## Existing installation and reset behavior
- Admin accounts with stored preferences in SQLite retain their chosen
ordering, card visibility and density on upgrade. The preset never rewrites
these saved records. Accounts without saved preferences receive the new
factory defaults, even on upgraded installations.
- In Overview, **Arrange cards visually → Restore defaults** restores the
factory card order/visibility after the user confirms; it does not reset
unrelated appearance or sample-history settings.
- The history selector uses browser-local storage key
`rustproxy.overview.liveChartSamples`. An explicitly saved choice (including
60) wins over the factory sample count. A browser without a saved choice
starts with 600. The sample limit does not change polling frequency.
- While layout is unlocked, hide/show/reorder changes are temporary and stay
effective during backend/Stream status refresh. **Cancel changes** restores
the saved layout; **Save and lock** persists the current draft.
No SQLite schema migration or additional HTTP endpoint is required.
+33 -136
View File
@@ -1,150 +1,47 @@
# Docker discovery and dynamic vhosts
# Docker discovery and generated Vhosts
RustProxy 0.22.3 includes an optional standalone Docker discovery agent. The RustProxy core never opens the Docker socket. Run `docker-agent/rustproxy-docker-agent` on every Docker host that should be discovered; RustProxy polls each agent over HTTPS by default using that agent's Bearer token and certificate pin.
RustProxy discovers published Docker services through a **separate read-only Docker agent** on each Docker host. The RustProxy core does not need Docker-socket access. The agent observes Docker containers; RustProxy turns enabled assignments into dynamic, ordinary HTTP Vhosts.
## State model
## Set up a Docker host
- **Desired state** is stored in the normal RustProxy SQLite configuration: agent endpoint/token, per-agent base domain, optional default backend IP, listener IDs, optional frontend certificate and service-to-vhost assignments.
- **Observed state** stays in memory: Docker host addresses, container metadata and every non-loopback published TCP binding.
- A successful poll reconciles enabled assignments into ephemeral vhosts. Polling does not write a new configuration revision.
- If a published host port, binding or detected host address changes, the next poll rebuilds the effective route.
- If a service disappears, its dynamic vhost disappears until the service is observed again.
- Docker discovery is optional; ordinary vhosts and all other proxy functions remain independent of Docker.
1. On the Docker host, review `docker-agent/README.md`, `docker-agent/docker-agent.env.example` and `docker-agent/install.sh`.
2. Build/install the Go agent with `cd docker-agent && sudo ./install.sh`; the installer runs tests, installs the service, initializes a Bearer token and an HTTPS certificate, and prints the SHA-256 certificate fingerprint.
3. Restrict the agent port (default shown in the example environment: `9876`) to the RustProxy host/network. Access to the Docker socket is privileged, even though the agent's HTTP API is read-only.
4. In **Settings → Docker agents**, create an agent with an endpoint such as `https://10.10.0.21:9876`, its token and printed fingerprint, base domain, selected listener IDs and optional certificate/ACL/default backend IP.
5. Use **Docker vhosts → Refresh agents** and check that the agent observation is successful before publishing any service.
The agent returns only running containers with Docker-published TCP ports. Bindings limited to loopback (`127.0.0.0/8` or `::1`) are excluded. Multiple bindings for the same container port are retained instead of being collapsed to one address/port pair.
The agent normally presents its own persistent self-signed certificate; pin the exact fingerprint in RustProxy. When using a CA-issued agent certificate, normal TLS CA/hostname verification can be used instead. Plain HTTP exposes the bearer token and should be avoided.
In the dashboard, an enabled Docker agent is **WAITING** until its first background observation arrives after RustProxy starts. This is not a connection failure. Opening **Docker vhosts** fetches the current *cached* status immediately, even if the per-page **Live view** option is off. While no enabled agent has been observed, the page briefly checks cached status every five seconds (when global automatic refresh is enabled), and stops once all agents have a first result. A genuine failed poll is reported with the returned error, and the configured TLS certificate pin is marked as verified only after a successful observation. **Refresh agents** explicitly triggers an immediate agent poll. No TLS pin, token or routing verification is bypassed.
## What discovery reports
## Install an agent on a Docker host
- Running containers with **Docker-published, non-loopback TCP ports**; it does not turn an internal-only container port into a directly reachable service.
- Usable Docker-host interface/IP addresses and each published host binding, allowing an explicit backend address selection when the host has multiple networks.
- Current service status, container metadata and optional aliases. The agent does not install applications, change Docker network rules or deploy TLS on their behalf.
The agent is written in Go and uses only the standard library.
Agent inventory polling is separate from the panel's live status refresh. A new agent may initially show **WAITING** until the first poll, not a failure. **Refresh agents** requests an immediate agent update. Actual failures report a reason; a temporary agent outage can preserve the last successful inventory while a successful missing-service observation can lead to a generated route returning `503` until the service returns.
```bash
cd docker-agent
sudo ./install.sh
```
## Publish a discovered application
The installer runs the Go tests, builds one binary, creates the `rustproxy-docker-agent` system user, generates a random token on first install, starts `rustproxy-docker-agent.service` and prints the persistent self-signed certificate SHA-256 fingerprint.
In **Docker vhosts**, select the service and set:
Manual build:
| Option | Guidance |
|---|---|
| **Hostname / aliases** | A short name expands under the agent base domain; a full FQDN stays as entered. Configure public DNS separately. |
| **Backend IP/port** | Pick a detected, reachable Docker-host address or the configured automatic option. Docker host/firewall binding must permit RustProxy to connect. |
| **HTTP / HTTPS upstream** | Match the container's actual service protocol; review origin TLS verification and Host behavior. |
| **Frontend listeners / certificate** | Selected through the agent defaults and RustProxy configuration; ensure certificate covers the generated name. |
| **Force HTTPS / WebSockets** | Enable only when needed and when the right frontend listeners exist. |
| **Cache / compression / transforms** | Optional; obey the global limits and privacy policy of the application. |
| **Health / ACL / timeouts** | Set checks and protections appropriate for the application, not just the Docker host. |
```bash
cd docker-agent
go test ./...
go build -trimpath -ldflags='-s -w' -o rustproxy-docker-agent .
```
Use **Publish** and test the resulting hostname. Edits to an already published service use its own **Apply changes** action; unpublishing does not stop its Docker container. Hidden rows in the page are only a browser view preference, not a security policy.
Agent configuration is read from `/etc/rustproxy/docker-agent.env`:
## Troubleshooting
```text
RUSTPROXY_DOCKER_AGENT_LISTEN=0.0.0.0:9876
RUSTPROXY_DOCKER_AGENT_TOKEN=<long-random-token>
RUSTPROXY_DOCKER_AGENT_TLS=true
RUSTPROXY_DOCKER_AGENT_CERT_FILE=/var/lib/rustproxy-docker-agent/agent.crt
RUSTPROXY_DOCKER_AGENT_KEY_FILE=/var/lib/rustproxy-docker-agent/agent.key
RUSTPROXY_DOCKER_ADVERTISE_IP=
RUSTPROXY_DOCKER_PUBLIC_IP_URL=https://api.ipify.org
```
`RUSTPROXY_DOCKER_ADVERTISE_IP` is only a preferred address hint; it does not hide the other host addresses. The inventory includes usable addresses found on active host network interfaces, the primary routed address, the optional public-IP lookup result and the advertised address. Loopback/link-local and obvious Docker-generated bridge/veth interfaces are omitted; explicit Docker bind addresses are still preserved with each service. Each host-address entry includes its source and, where available, interface name/scope.
The inventory endpoint is `GET /v1/inventory` and requires `Authorization: Bearer <token>`. `GET /healthz` exposes no inventory. HTTPS is enabled by default. The agent creates an ECDSA P-256 self-signed certificate once and keeps it in `/var/lib/rustproxy-docker-agent/`; the key is mode `0600`.
`install.sh` prints both the generated/reused bearer token and the SHA-256 certificate fingerprint. Copy the SHA-256 fingerprint printed by `install.sh` into the agent settings in RustProxy. You can print it again with `sudo rustproxy-docker-agent --print-fingerprint`. With a fingerprint configured, RustProxy accepts only that exact certificate and still checks the TLS handshake signatures. If the agent uses a certificate issued by a normal public CA, leave the fingerprint empty to use standard CA/hostname verification. Plain HTTP is retained only for explicit compatibility setups and exposes the Bearer token to the network.
Docker-socket access is privileged. Restrict access to the agent port to the RustProxy host even when TLS is enabled.
## Configure multiple Docker hosts
Open **Settings -> Docker agents** and create one entry for every Docker host. Each agent also has its own inventory **poll interval** (5..3600 seconds, 30 seconds by default); manual refresh bypasses the interval. Agents are independent and may use different domains, for example:
```text
docker-a -> docker1.lab.local
docker-b -> docker2.lab.local
docker-c -> apps.example.net
```
For each agent configure:
- stable agent ID and display name;
- agent endpoint, normally `https://10.10.0.21:9876`;
- the Bearer token from that Docker host;
- the SHA-256 certificate fingerprint printed by the default self-signed agent installer, or an empty fingerprint only for a public-CA certificate;
- that agent's base domain;
- optional default backend IP;
- one or more RustProxy listener IDs;
- an optional configured frontend certificate to apply to every generated vhost for that agent;
- an optional default ACL profile inherited by generated vhosts unless a service selects its own profile.
RustProxy polls each enabled agent according to its configured interval (30 seconds by default, checked by the scheduler in bounded steps). The 5-second Live-view refresh reads only cached RustProxy memory and does not force a Docker request. **Docker vhosts** shows the latest cached observation for every configured agent. A failed agent request retains the last successful inventory when available, so a temporary control-plane outage does not withdraw working routes. A successful inventory that omits an already published service keeps its hostname and changes the generated route to 503 until discovery sees the service again.
## Publish a discovered service
Use the search field in **Docker vhosts** to filter by agent, service, hostname or alias. Individual service rows can be hidden from the normal view; **Show Hidden** beside Docker agents settings reveals them so they can be restored. This is a per-browser view preference and does not disable or unpublish the vhost.
For each discovered service you can change the mapping directly in **Docker vhosts**:
- **Hostname**: a short name such as `app1`, or a complete FQDN such as `portal.example.com`;
- **Aliases**: zero or more additional short names/FQDNs, edited with the same add/remove chips as normal vhost hostnames;
- **Backend host IP**: Auto/default or one concrete address detected on the Docker host;
- **Backend protocol**: HTTP or HTTPS;
- **Preserve Host**: forward the requested hostname to the container when enabled; new HTTP/1.1 assignments default to enabled, while an existing explicit disabled value is preserved;
- **Redirect HTTP to HTTPS**: per-app Force SSL switch. New assignments default on when the agent has a usable plain-HTTP + TLS listener pair; assignments saved before 0.18.8 retain their previous redirect-off behavior until explicitly saved with the new switch;
- **WebSockets**: enable or disable classic HTTP/1.1 Upgrade forwarding for this application;
- **Cache**: enable or disable normal RustProxy caching for this generated vhost;
- **Compression**: one on/off checkbox for this published vhost. When enabled, Docker vhosts use the codecs and priority configured globally under **Settings -> Response**; there are no per-Docker-vhost codec selectors;
- **Transform**: opt this generated vhost into the global static-response transform engine; it is off by default and still requires global transformation to be enabled;
- **Backend health check**: HTTP/TCP/off active-probe mode, path/Host override, interval, timeout, slow threshold, rise/fall counters and accepted HTTP status range.
- **Passive Backend Health**: independent opt-in policy, OFF by default, with separate connect/timeout/I/O/HTTP-5xx/slow-response switches and optional passive rise/fall overrides.
- **Access control**: an optional reusable ACL profile plus an optional inline IP/CIDR ACL for this application. A per-app profile overrides the agent default profile; inline rules remain cumulative with the effective profile.
- **Proxy timeouts**: optional connect, response-header, read-idle and send-idle timeouts; blank values inherit RustProxy defaults.
Publish/Unpublish is applied immediately. Editing a published service uses the per-service **Apply changes** action; there is no page-wide assignment save step. The optional **Live view** switch refreshes only cached RustProxy observations/health state and does not contact the Docker agent.
A short name is expanded below that agent's base domain. With base domain `docker1.lab.local`, `app1` becomes `app1.docker1.lab.local`. A full FQDN is not rewritten. Aliases use the same rule, so `app-old` becomes `app-old.docker1.lab.local`, while `legacy.example.com` remains unchanged.
This makes renaming and aliasing a Docker-backed vhost a normal RustProxy configuration change; the container/service identity remains the stable link to discovery.
## Multiple Docker-host IPs and bindings
The agent reports all usable addresses it can see on the Docker host. This is intended for hosts that have separate public, management and LAN addresses.
RustProxy also keeps every published Docker binding. For example, one container port may be published as:
```text
10.21.0.15:18080
192.168.50.15:28080
```
Selecting `10.21.0.15` for the Docker vhost uses port `18080`; selecting `192.168.50.15` uses `28080`. For Docker wildcard bindings (`0.0.0.0` / `::`), RustProxy uses the selected host address with the wildcard binding's published port.
Backend selection order is:
1. the Docker vhost's explicit `target_ip`;
2. the agent's optional `default_target_ip`;
3. a concrete Docker bind address;
4. the agent's preferred/primary detected address when compatible with the binding;
5. another compatible detected host address.
An explicit target that is not compatible with the service's Docker bindings does not silently fall back to another IP. In 0.27.0 the published hostname remains materialized and returns `503 Service Unavailable` until the selected service/address/binding is available again. Disabling the Docker agent stops discovery but keeps already published hostnames claimed as 503 routes; re-enabling the agent resumes reconciliation. Publication is removed when the administrator explicitly unpublishes the assignment or removes the agent configuration that owns it.
The generated backend is pinned to the selected IP through an address allowlist and uses the matching Docker-published host port. Cache, Transform, Compression, WebSockets and HTTP-to-HTTPS redirect are materialized from the per-app settings. Compression has inherit/on/off plus per-codec overrides. Global Response codec switches remain the hard availability ceiling and the minimum size remains global. If the Docker agent has a frontend certificate selected, that certificate ID is copied to every generated vhost and participates in the normal TLS/SNI certificate validation and ownership rules. The effective ACL profile is the per-app profile when selected, otherwise the agent default profile. Active backend health checks default to TCP. Passive Backend Health is materialized from the Docker assignment separately and defaults to OFF, so discovery refresh/restart does not silently re-enable production-traffic quarantine. The Docker vhosts page exposes the same passive policy as ordinary vhosts. Normal 5xx/timeouts/connect/I/O/slow-response observations remain visible in counters while passive health is OFF. Active checks can be switched between HTTP/TCP/off per service without changing the passive policy.
## DNS
DNS for Docker vhost names must resolve to the RustProxy frontend/listener that should receive the HTTP(S) request. For a per-agent base domain this is normally a wildcard record, for example:
```text
*.docker1.lab.local -> <RustProxy frontend IP>
*.docker2.lab.local -> <RustProxy frontend IP>
```
Custom FQDNs and aliases likewise need DNS records pointing at the relevant RustProxy frontend. The Docker-host addresses detected by the agent are backend routing candidates; they are not automatically published to DNS.
RustProxy does not change DNS records itself.
## Prometheus metrics
Docker discovery has its own Prometheus collector switch, `prometheus.include_docker_vhosts`, enabled by default. It is intentionally separate from `include_vhosts` and `include_backends`. Dynamic Docker routes never appear under ordinary `rust_proxy_vhost_*` or `rust_proxy_backend_*` families.
The Docker collector exports bounded configured labels only: agent ID, generated Docker-vhost ID, assigned service key, configured domains/aliases and backend ID. It includes `rust_proxy_docker_vhost_*` traffic/status/duration metrics and `rust_proxy_docker_backend_*` readiness, health, active work, requests/errors/bytes, current/EWMA latency, failure/success counters, health transitions and last-probe observations. No token, agent endpoint, backend URL or arbitrary request Host value is exported.
- **WAITING:** first poll not yet observed; refresh and inspect the agent endpoint.
- **Agent error:** verify firewall, TLS fingerprint/CA, bearer token, service state, advertised host and TCP route.
- **503 generated Vhost:** verify running container, published TCP port, selected IP/bind address, backend protocol and health check.
- **Wrong application hostname:** check base-domain expansion, aliases, DNS and conflicting ordinary Vhosts.
- **Docker service not listed:** agent reports only eligible running containers with published non-loopback TCP bindings.
For agent operations and its security model see `docker-agent/README.md`. For normal routing, health and cache controls see [Vhosts](VHOSTS.md) and [Cache](CACHE.md).
-47
View File
@@ -1,47 +0,0 @@
# Error and warning logging
RustProxy routes diagnostics according to the **Error file** switch under Settings > Logging. When the file is enabled, `ERROR` and `WARN` events, including PHP-FPM stderr and Rust worker panics, go to the configured error file rather than also being printed to the systemd journal. When the file is disabled (the default), those events go to the console/stderr (journald under systemd). `INFO` and lower-severity messages continue to use the console and any independently configured access sinks are unaffected.
A graceful upstream HTTP/2 `GOAWAY(NO_ERROR)` may coincide with a Hyper `Canceled -> connection closed` error. RustProxy treats the cancel as expected H2 churn **only when** it is correlated with a recently observed graceful GOAWAY on that backend client (one-second window). Safe empty methods may retry once. An unrecovered request remains an HTTP 502 and retains its detailed access/error context; its redundant upstream diagnostic uses `TRACE`, not `DEBUG`. Uncorrelated cancellations still follow normal error processing and may influence Passive Health. The text `connection closed` alone is not evidence of a graceful GOAWAY. This is not a global error-log suppression rule.
## Configuration
The settings document contains:
```json
{
"error_log": {
"enabled": true,
"path": "/var/log/rustproxy/error.log",
"format": "JSON"
}
}
```
`format` is `JSON` or `RAW`. The feature is disabled by default. The administration Settings UI exposes the same enable/path/format controls and applies changes at runtime, without restarting RustProxy.
The sink accepts `WARN` and `ERROR` events. JSON is one object per line (including its actual `level`); RAW is one human-readable line per event.
## Runtime behavior
Events are handed to a bounded queue and written by one asynchronous worker. The normal request path does not perform file I/O. When the queue is full or closed, the event is counted as dropped by the file sink but immediately written to the console as a redacted emergency fallback; memory usage stays bounded.
Filesystem errors do not stop RustProxy. The worker writes the affected diagnostics to stderr, rate-limits sink-failure messages, and retries opening the file. If the switch is disabled while events remain queued, they are sent to stderr rather than silently discarded. Changing the path or disabling the sink closes the current file; a format change takes effect without a restart.
RustProxy installs a panic hook after initializing tracing so worker panics are recorded as `ERROR` events including thread, source location and panic payload. It intentionally does not install the default Rust hook in parallel, which would duplicate the message on stderr. It cannot capture forced termination (`SIGKILL`, `abort`) or messages written directly to stderr by external processes.
Created log files use mode `0600` on POSIX. Symlink/non-regular targets are refused.
## Secret redaction
The common logging pipeline redacts sensitive keys and values before they reach the error-file sink. This includes authorization credentials, provider tokens/passwords, backup passwords, private-key PEM material and Secret Store/master-key related values. Structured secret fields are replaced with `[REDACTED]`.
Error messages from dependencies should still be treated as untrusted diagnostic input; do not deliberately embed secrets in error text.
## Rotation
RustProxy does not implement file rollover, retention or compression. Use the system `logrotate`. Debian packaging is maintained outside this source tree and ready packages are published at `https://rustproxy.linuxiarz.pl`; package-specific logrotate policy belongs to that packaging repository.
## Stored-log explorer
The authenticated `/logs` workspace can read the configured error file alongside structured access/protocol files. It remains independent from Live Logs and can inspect an existing error file even while `error_log.enabled` is false. The browser API does not accept arbitrary paths; it resolves only the configured error-log path and applies the same bounded, read-only regular-file/symlink protections documented in [FILE_LOGS](FILE_LOGS.md).
-109
View File
@@ -1,109 +0,0 @@
# Stored log explorer
RustProxy provides an authenticated, read-only **Logs** workspace at `/logs`. The top bar has one **Logs** menu with separate **Stored logs** and **Live Logs** actions; the two data paths remain independent.
## Separation from Live Logs
Live Logs uses the bounded in-memory request history and Tokio broadcast channel. It remains available even when file logging is disabled and is optimized for current routed request activity.
Stored Logs reads only paths derived from the active RustProxy logging configuration: the combined structured access log or per-vhost JSONL directory plus the optional error log. Existing files remain browsable after their writer is disabled. Low-level `protocol_error` records remain persistence-only and do not enter Live Logs.
## Search syntax
The dashboard uses one search field and a page-size selector. Search is case-insensitive and supports field clauses, negation, quoted values, wildcards and numeric comparisons.
Examples:
```text
req: GET AND !country: Poland
status:>=500 AND path:/api/*
error:"connection reset"
client_ip:192.0.2.* AND status:5xx
```
Rules:
- whitespace and explicit `AND` join clauses;
- `!field:value` excludes matching records;
- quotes keep spaces in one value;
- `*` acts as a wildcard;
- numeric fields accept `>`, `>=`, `<` and `<=`;
- `status:5xx` matches a status class;
- bare terms search all stored values;
- `req`/`request` aliases `method`, `ip`/`client` aliases `client_ip`, and `source` aliases the internal log-source ID;
- `OR` is not supported and is rejected explicitly.
The API continues to accept the older `source`, `event`, `status_class`, `client_ip` and `vhost` parameters for compatibility, but the dashboard no longer exposes those separate controls.
## JSON parsing and dynamic columns
JSON/JSONL lines are parsed into record objects. Canonical access-log fields are reconstructed from the active JSON field mapping where possible, so fields such as `client_ip` remain usable even when their output key was renamed.
The query response includes a bounded list of discovered leaf field names. The browser builds the column picker from those fields. The default concise view is time, event, status, client IP, vhost, record summary and source; any returned JSON field can be selected dynamically. Fields such as `matched_by`, `sni`, `authority`, `country`, `request_id` or custom mapped fields therefore appear only when the data actually contains them.
At least one column must remain visible. Stored-log column selection is saved per administrator account and accepts up to 64 bounded field names.
Selecting a row highlights it and expands parsed field/value pairs directly below the row. A nested **Raw JSON** section shows the normalized complete record.
## Sorting columns (0.31.8)
Click a Stored Logs column header (desktop/table layout) to sort by that column; click again to reverse direction. The selected field and direction are saved with the administrator's column preferences. Numeric fields sort numerically, text case-insensitively, and absent values come last. Both standard columns (`time`, `record`, `source`) and discovered nested JSON fields are supported.
Sorting takes place **on the server across the entire bounded matching scan, before page offset/limit is applied**. Thus page two continues the same sorted result instead of sorting only the current 100 displayed rows. Server bounds and truncation notices still apply; this is not an unbounded full-log historical index. The default remains newest first. The mobile card layout retains the selected sort order, set via the desktop table view.
## Narrow-screen navigation (0.31.5)
At viewport widths up to 700px, `/logs` uses a vertically scrolling list of
compact cards in place of a horizontally expanding results table. Each card
shows every enabled column, with long values wrapped or shortened until
expanded; tap the card (or focus and press Enter/Space) to inspect all parsed
fields and the complete Raw JSON. Search, source, page size, column selection,
pagination and repeated-value analysis stay available. Large raw text and
aggregate tables scroll **inside their own containers**, never across the entire
administration page. Tablet/desktop results keep the normal tabular layout.
## Repeated-value analysis
The **Repeated values** tab (advanced parser) can count repeated values for any parsed field returned by the explorer. Typical security-investigation fields are `user_agent`, `client_ip`, `host` and `path`. Counts are calculated across the bounded matching scan before pagination, so changing the page does not change the count set. Minimum repetitions and top-result count are configurable within server limits. Values in the result table are directly copyable.
Aggregation does not remove the Stored Logs safety bounds: at most 4,096 distinct aggregate values are retained and at most 200 results are returned. The response marks an aggregate as truncated when a cardinality or result cap is reached.
## RAW error logs
RustProxy's built-in RAW error-log format is also normalized for browsing. The explorer extracts timestamp, level, target, message and recognizable `key=value` fields, while retaining the original line in `raw`.
RAW is not a lossless structured serialization, so extraction is best-effort. Fields that cannot be separated safely remain visible through the message/raw record rather than being invented.
## Vhost-aware protocol records
Ordinary completed requests use `event: "request"`. Low-level failures use `event: "protocol_error"`. When RustProxy can safely identify a configured vhost, the record is persisted through that vhost's existing sink. In per-vhost file mode an unresolved record uses `_unmatched.jsonl`.
Attribution priority remains routed vhost, HTTP `:authority`, HTTP `Host`, TLS/QUIC SNI, then unmatched. Matching reuses normal exact/wildcard frontend resolution and listener binding. Client Host/SNI text never becomes a filesystem path.
## Browser security and bounds
The dashboard no longer exposes the configured-file inventory. The Source selector is optional: `All configured files` is the default, while selecting one source narrows the same bounded query to that log only.
The browser still never supplies a filesystem path. The server derives sources from the active configuration and reads only regular files. On Unix it opens with `O_NOFOLLOW`; per-vhost enumeration rejects a symlinked directory and accepts only direct regular `.jsonl` children.
Logrotate-style gzip rotations are discovered next to configured combined access/error files and inside the configured per-vhost directory. Only regular non-symlink files matching the active filename rotation pattern (for example `access.jsonl.1.gz`) are eligible. The browser still refers to them by server-generated source IDs rather than paths.
For per-vhost logging, the active `vhost.jsonl` and matching `vhost.jsonl.*.gz` files share one logical group ID. The panel enables **Consolidate rotated logs per vhost** by default, so selecting that vhost searches the active and compressed members together as one logical source. Disable the switch to inspect individual files. Physical source IDs and metadata remain separate internally, and the existing global source/byte/line scan limits still apply.
Gzip reading is bounded before and after decompression. A compressed source larger than 8 MiB is not decompressed for a query, decompressed output is capped at 64 MiB, and only the bounded tail is parsed/searched. These limits prevent a gzip rotation from becoming an unbounded memory/decompression workload.
Per-vhost directory discovery now retains only the newest **256** matching JSONL/gzip candidates while scanning, and a combined log’s gzip-rotation scan retains only the newest **64** matches. Both caps apply before sorting, so directory size no longer causes a proportional memory allocation for the inventory. The usual 32-source query budget still applies. Filesystem enumeration still takes time proportional to the number of directory entries.
The reader remains bounded:
- up to 2 MiB of retained tail data from one source;
- up to 8 MiB across one query;
- up to 20,000 lines across a query;
- at most 32 sources in an all-source query;
- page size at most 500 and offset at most 10,000;
- search input at most 512 bytes and 32 parsed clauses;
- dynamic field discovery at most 64 leaf fields.
If more than 32 sources are eligible, the newest 32 are scanned and the response is marked truncated. File reads run in a blocking worker rather than the async proxy runtime.
The explorer is diagnostic rather than a retention/indexing system. RustProxy does not create rollovers or compress files itself; use operating-system log rotation for retention. Existing gzip rotations can be read as described above.
-188
View File
@@ -1,188 +0,0 @@
# HTTP/3 transport
**Uncompiled development source in this environment.** This release adds an actual
QUIC/HTTP/3 adapter, not only an Alt-Svc header. It does not claim independent full-RFC
conformance, interoperability or production performance until the acceptance tests run.
## Stack and configuration
The adapter delegates QUIC transport/TLS to Quinn 0.11.12 and HTTP/3/QPACK plus the
Quinn adapter to `hpx-h3 2.5.20` with its `quinn` feature. The crate is imported as `h3`
in source. These are Cargo dependencies, not bundled binaries.
Rustls provides TLS 1.3 for QUIC; SNI certificates come from the existing inventory.
HTTP/1.1 and optional HTTP/2 remain on TCP at the same port. HTTP/3 is on UDP.
There is no HTTP/3 upstream client in this revision: origins still use HTTP/1.1 or HTTP/2.
Each vhost has `http3_enabled` (default true). When false, RustProxy removes
`Alt-Svc` from responses for that vhost and rejects HTTP/3 requests for it with 421,
while HTTP/1.1 and any permitted HTTP/2 traffic on the selected TLS listener keep
working. The UDP listener is still a listener-level resource; the vhost switch narrows
use of that shared capability rather than hot-unbinding the UDP socket.
HTTP/2 follows the same UI model through `http2_enabled` (default true). A vhost may
only opt into H2 when one of its selected named listeners enables H2. For TLS, RustProxy
reads ClientHello/SNI before finishing the handshake and selects H2+HTTP/1.1 or
HTTP/1.1-only ALPN for that vhost. The listener remains the capability ceiling.
Since 0.5.0, an omitted `http3` enables QUIC with defaults on TLS bindings.
Explicit `http3: null` disables it and is preserved when serialized. An object
enables it with chosen limits. Plain HTTP defaults to no QUIC. In 0.24.3 the
**Enable HTTP/3 over QUIC (UDP)** switch is edited with the TLS endpoint under
**Settings -> Listeners**. The listener row links to **Settings -> HTTP connections
-> HTTP/3**, which contains only advertisement, connection, stream and idle-timeout
tuning for listeners where H3 is enabled. Both enablement and tuning remain
restart-required. Configure this only on named `listeners.endpoints[].binding`:
```json
{
"address": "127.0.0.1:8443",
"tls": true,
"http2": true,
"http3": {
"alt_svc_max_age": 3600,
"max_concurrent_connections": 512,
"max_concurrent_bidistream": 100,
"max_concurrent_unistream": 100,
"max_idle_timeout": 10
}
}
```
| Field | Meaning and accepted limits |
|---|---|
| alt_svc_max_age | Advertisement lifetime, seconds, 0..604800 |
| max_concurrent_connections | Established/pending accepted connection slots per endpoint, 1..4096 |
| max_concurrent_bidistream | Peer bidirectional streams per connection, 1..1024 |
| max_concurrent_unistream | Peer unidirectional streams per connection, 3..1024 |
| max_idle_timeout | QUIC idle timeout, seconds, 0..86400; zero means no local QUIC idle timeout |
Three unidirectional streams are required for the peer control and QPACK streams.
These stream limits are NOT global to all listeners. The existing global in-flight
request and application body/header deadlines still apply. Setting QUIC idle to zero
does not disable application deadlines, and the peer can negotiate its own idle timeout.
All changes to socket/ALPN/QUIC transport parameters require restart.
## Transport behavior and safety
- Bind UDP before announcing readiness; failure to bind is a startup failure.
- Advertise `Alt-Svc: h3=":port"; ma=N` only on an ACTUAL active TLS+H3 endpoint.
Origin Alt-Svc is stripped so it cannot advertise the origin or a disabled service.
- UDP address validation/Retry, bounded pending handshakes, a 10-second handshake
deadline, per-endpoint permits, stream flow control and bounded application concurrency.
- 32 KiB decoded HTTP field section limit; request content-length checks and rejection
of forbidden connection/framing headers; streamed bodies and trailers.
- Shared routing pipeline: real local socket -> validated authority -> trusted client
address -> ACL -> Tinyauth -> cache -> origin -> response/security/Server policies.
- HTTP cache semantics are shared with HTTP/1.1 and HTTP/2. In 0.27.3, body-less
`HEAD` requests use the same bounded empty-body probe as `GET`, so an initially
unpolled HTTP/3 receive stream does not cause a false `request_body` cache bypass.
- 0-RTT is disabled to avoid replaying writes; no retry to another origin or transport.
- Endpoint active migration is disabled. Missing real local destination data on a
wildcard QUIC connection fails closed rather than substituting a forwarded IP.
- Configuration reload changes SNI certificates for new QUIC handshakes without
changing active socket limits. Graceful shutdown requests GOAWAY then bounded drain.
- Counters: http3_requests, http3_connections, http3_active_connections and
http3_rejected_connections in status/Prometheus; H3 request count also in dashboard.
A connection receives a maximum aggregate 8 MiB QUIC window and 256 KiB per-stream
window; these limits are not process memory guarantees. Many simultaneous connections
can use substantial memory. Size connection/stream limits to the machine and measure.
No active path migration, 0-RTT, server push, WebTransport or HTTP datagrams are enabled.
RFC 9220 WebSocket Extended CONNECT is experimental and disabled by default per vhost.
RustProxy advertises SETTINGS_ENABLE_CONNECT_PROTOCOL on a QUIC listener only when at least
one vhost bound to that listener explicitly enables `websocket_rfc9220`; request routing
still rejects RFC 9220 for vhosts that did not opt in. Generic CONNECT and other Extended
CONNECT protocols remain unsupported and are answered with 501. The downstream H3 request must
not carry HTTP/1.1-only `Connection`, `Upgrade`, `Sec-WebSocket-Key` or
`Sec-WebSocket-Accept` fields. RustProxy creates a private RFC 6455 key for the HTTP/1.1
backend handshake; a valid backend `101` becomes downstream `200`, after which H3 DATA
bytes are the WebSocket byte stream. Client FIN half-closes the backend write side;
backend EOF finishes the H3 send side; abnormal/forced termination uses
`H3_REQUEST_CANCELLED`. No side TCP listener or protocol bypass is used.
### WebSocket bootstrap ordering and diagnostics in 0.16.0
For RFC 9220, a validated backend `101` is no longer enough to send the client-facing H3 success immediately. RustProxy first awaits Hyper's backend `OnUpgrade` and obtains the actual upgraded stream. Only then is downstream `200` returned and the H3 DATA bridge started. If backend takeover is cancelled or fails, the client receives a normal 503/502 response instead of `200` followed by an immediate `H3_REQUEST_CANCELLED` reset.
Access logging can now distinguish this path with `http_version`, `transport`, `websocket_bootstrap`, `websocket_phase`, `websocket_error`, `h3_reset_code` and `upstream_status`. This makes Safari/iOS interoperability failures distinguishable from backend Socket.IO responses and from H3 tunnel resets. Query-string logging remains subject to the normal privacy policy; do not use access logging to capture session secrets.
The advertised port is the real listener port. Behind port-rewriting NAT, use a
matching externally reachable UDP/TCP port or do not enable this advertisement; there
is no separate external-port override. Open UDP as well as TCP in your firewall/load
balancer. Scope labels do not perform those operations. Clients lacking QUIC or blocked
UDP can still connect using TCP; no guarantee is made about browser protocol preference.
## H3 full-page navigation to HTTP/1.1 backend (0.31.6)
The RustProxy logo remains a normal `href="/"` link, not a client-side panel change. On an H3-enabled frontend it produces a new document GET through the proxy. When forwarding an H3 GET/HEAD to an HTTP/1.1 backend, RustProxy now probes for the stream's end for at most 35 ms per initial frame. A finished empty request is passed to H1 with a confirmed complete receive half rather than an unknown body state. Hyper already special-cases H1 GET/HEAD framing; no claim is made that ordinary GET requests were chunked. A nonempty frame and all subsequent bytes are replayed without buffering the whole request. The slow-peer case falls back to regular streaming.
If `h3.accept()` fails, existing request tasks get the normal bounded drain instead of immediate mass abort. The new diagnostics use status=0 for cancellations (not a real HTTP response), and persist interrupted-response details including `phase=`, `stream_id=`, `method=`, `path=`, `response_status=`, `body_bytes_sent=` and `error=` in the bounded, redacted `error_detail`. These are stored per vhost when file logging is enabled; the normal access logs remain available for successful document and asset GETs.
**iOS validation procedure:** keep H3 on, click the logo repeatedly on an authenticated panel behind the real RustProxy vhost, and collect timestamps for `http3_peer_cancelled_before_headers`, `http3_peer_cancelled_after_headers`, `http3_response_write_error`, `http3_accept_error`, and the GET `/` and asset access records. Collect browser Web Inspector Network/QUIC trace if a blank document persists. Correlate the **new GET/** status and transmitted bytes with older cancelled streams. Mere cancellation of an obsolete API or SSE stream is not proof that the new document failed. This implementation is not proven to resolve an iOS issue until live tests pass.
## Browser refresh and stream cancellation (0.31.5)
A browser may cancel an obsolete H3 stream when the user reloads or leaves a
page, sometimes before its HEADERS arrive. RFC 9114 `H3_REQUEST_CANCELLED`
(and a peer-originated `H3_NO_ERROR` reset) is not itself a server-side HTTP 400.
RustProxy therefore omits that fabricated 400 access-log entry and avoids
adding `H3_INTERNAL_ERROR` to a directly identified cancelled response stream.
Malformed or forbidden headers still yield a protocol-error 400.
This correction does **not** establish the cause of a blank document after F5.
If the new navigation fails, compare its actual document/asset statuses over H3
and H2, the configured reverse-proxy Host/Origin/forwarded-proto chain, and any
connection-level QUIC errors. An error record without HTTP method, path and
request headers cannot identify the browser document which failed. Capture
browser network traces and QUIC qlogs with timestamps for live acceptance.
Per-vhost `http2_enabled`/`http3_enabled` toggles also no longer rotate the global
HOT/COLD cache identity. Origin representation policy and cache invalidation are
unchanged; the frontend protocol remains part of live request routing.
## Verification gate
```bash
bash dev.sh check
cargo test --locked --test http3 -- --nocapture
cargo test --locked --test integrations --test listeners
bash dev.sh test
```
`tests/http3.rs` creates a real loopback UDP endpoint, a trusted generated test
certificate, a real origin and a Quinn+h3 client. It checks MISS/HIT, body preservation,
Server/diagnostic policy, connection capacity and ALPN HTTP/2/HTTP/1.1 compatibility.
The same integration test explicitly enables `websocket_rfc9220`, then opens an RFC 9220 Extended CONNECT stream and translates it
to a real HTTP/1.1 backend Upgrade, sends a masked WebSocket frame as H3 DATA, half-closes
the client send side and verifies byte-transparent DATA return plus tunnel metrics. Unit
regressions separately reject HTTP/1.1-only proof/framing headers on the H3 side.
It is provided but was NOT run in the packaging environment. The same-stack client
is a regression test, not independent interoperability evidence.
After applying examples/http3-vhost.json on a disposable test instance and starting
`bash dev.sh mock --port 9001`, use an independently built curl with HTTP3 support:
```bash
curl --version
curl --http3-only --cacert data/certs/default.pem -D - https://localhost:8443/cache
curl --http3-only --cacert data/certs/default.pem -D - https://localhost:8443/cache
curl --http2 --cacert data/certs/default.pem -D - https://localhost:8443/cache
curl --http1.1 --cacert data/certs/default.pem -D - https://localhost:8443/cache
```
Do not substitute `--http3` fallback or `-k` for strict protocol/certificate validation.
Also test independent clients, malformed-frame handling, stream exhaustion, slow
uploads/downloads, IPv6 wildcard destination metadata, cancellation, graceful drain,
certificate rotation, and memory/CPU at configured maxima before exposure.
No full RFC conformance or load-test result is asserted by this source package.
## Primary references
- HTTP/3 RFC 9114: https://www.rfc-editor.org/rfc/rfc9114.html
- QUIC RFC 9000: https://www.rfc-editor.org/rfc/rfc9000.html
- QPACK RFC 9204: https://www.rfc-editor.org/rfc/rfc9204.html
- Quinn: https://docs.rs/quinn/latest/quinn/
- RFC 8441 Extended CONNECT: https://www.rfc-editor.org/rfc/rfc8441.html
- RFC 9220 WebSocket over HTTP/3: https://www.rfc-editor.org/rfc/rfc9220.html
- hpx-h3 2.5.19: https://docs.rs/hpx-h3/2.5.19/h3/
+50 -326
View File
@@ -1,384 +1,108 @@
# Installing and updating RustProxy 0.29.1
# Installation, updates and first start
The scripts prepare a Linux/systemd installation. This source archive is **not a
verified production build**: Rust/Cargo, real CA operations, live systemd startup
and rollback against a real Rust database have not been exercised in the packaging
environment. Perform the acceptance steps below on an isolated server first.
Use maintained packages whenever possible. For custom installations, use the scripts included with this source version; do not combine parts of different releases.
## Official installation repositories
## Supported entry points
Use the project-maintained package repositories as the normal installation/update path:
| Installation | Recommended starting point |
|---|---|
| Linux DEB/RPM | https://rustproxy.linuxiarz.pl/ |
| OPNsense plugin | https://rustproxy-opn.linuxiarz.pl/ |
| Debian 13+ source bootstrap | `install-debian13.sh` from the project repository |
| Manual Linux/systemd install | Compile the application; use `install.sh` and `update.sh` |
| OCI container | Included `Dockerfile`; see [Container](CONTAINER.md) |
- **DEB/RPM:** `https://rustproxy.linuxiarz.pl/`
- **OPNsense plugin:** `https://rustproxy-opn.linuxiarz.pl/`
Package/plugin instructions and maintenance policies are maintained separately from the source archive.
Package-build metadata is intentionally maintained outside this source tree. The Debian source
bootstrap below remains supported for development, custom builds and environments where the
packaged path is not suitable.
## Debian 13+ source bootstrap
## Optional: Debian 13+ one-line source install/update
Review the remote script before execution. On a managed installation, `auto` selects install or update from `/opt/rustproxy/install.json`.
```bash
# auto: first install or managed update
# Initial install or existing managed update
wget -qO- https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/install-debian13.sh | bash
# force the update path
# Explicit managed update
wget -qO- https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/install-debian13.sh | bash -s -- update
```
The bootstrap installs build dependencies, installs Rust with rustup for an unprivileged build user,
clones `https://git.linuxiarz.pl/gru/RustProxy` and builds with `cargo build --release --locked`.
Its default `auto` mode calls `install.sh` only when no managed installation exists. When
`/opt/rustproxy/install.json` exists it calls `update.sh`, which uses the saved installation record,
backs up private state, switches the release, verifies health/version and rolls code/data/unit back
on failure. First install keeps administration on `127.0.0.1:9090` over HTTP, matching the normal
`install.sh` loopback-safe default. Updates preserve the saved admin/listener settings. For initial
remote access use an SSH tunnel, then enable Administration HTTPS before persisting a non-loopback bind.
Plain remote HTTP requires both an explicit non-loopback `RUSTPROXY_ADMIN_LISTEN` and
`RUSTPROXY_ALLOW_REMOTE_ADMIN=1`, and should be limited to a trusted private network.
The script installs dependencies, builds with `cargo build --release --locked` as an unprivileged build user, then uses the local deployment scripts. Run it only on Debian 13+ and with access to its required repositories. Installing from a moving `master` branch does **not** pin a specific audited release; use a reviewed commit when reproducibility matters.
Equivalent prerequisite commands are:
## Build from the source
The current `Cargo.toml` declares **Rust 1.99 minimum**, with a `stable` toolchain in `rust-toolchain.toml`. Use Cargo, a C compiler/linker, Bash and Python 3 (Python 3.11+ is advisable for project checks). Run as a normal build user:
```bash
apt install -y build-essential gcc git ca-certificates python3 python3-venv python3-pip pkg-config wget sudo certbot python3-certbot-dns-rfc2136 python3-certbot-dns-cloudflare python3-certbot-dns-ovh
wget -qO- --https-only --secure-protocol=TLSv1_2 https://sh.rustup.rs | sh
```
## Build as a normal user
Requirements: stable Rust (minimum declared 1.88), Cargo, a C compiler/linker,
Python 3.10+, Bash. Linux/systemd deployment also requires `systemctl`, `runuser`
and a usable non-login service account (created by the installer when missing).
The static TOML checker additionally needs Python 3.11+. No script downloads an
executable or installs a compiler as root.
```bash
cd RustProxy
bash dev.sh doctor
bash dev.sh check
bash dev.sh test
bash dev.sh release
```
The first build resolves and creates `Cargo.lock`; retain the real lockfile and
binary hash for repeatable subsequent builds. There is no fabricated lockfile or
precompiled binary in the archive. Set `--binary` explicitly when using a custom
`CARGO_TARGET_DIR`. Review and trust the source and built binary before installation.
## First installation
`install.sh` also downloads the direct `user-country-ipv4.csv` and `user-country-ipv6.csv`
assets from the `sapics/ip-location-db` latest release over HTTPS, validates their CSV
structure and writes them privately to `DATA_DIR/geoip`. Package/plugin installs may omit
that packaging-time download: after all listeners bind successfully, RustProxy starts an
enabled-by-default GeoIP worker, performs an immediate background refresh and then checks
every 24 hours by default. Country allow-only policies continue to fail closed until a valid
database is available. Settings can use separate IPv4/IPv6 CSV files or one native MaxMind
MMDB file; direct source URLs, optional per-file SHA-256 URLs, maximum file size and interval
are configurable under **Settings -> GeoIP updates**.
`dev.sh release` builds the application; it is not equivalent to a successful production rollout. Cargo dependencies and Swagger assets must be available to the build environment. An example systemd unit is in `deploy/`.
For a first Linux/systemd installation:
```bash
bash install.sh --plan --binary target/release/rust-proxy
sudo bash install.sh --binary target/release/rust-proxy
```
Defaults: HTTP `0.0.0.0:80`, HTTPS `0.0.0.0:443`, administration
`127.0.0.1:9090`, code `/opt/rustproxy`, all private state `/var/lib/rustproxy`,
service account `rustproxy`. The initial page is a small informational response; no demonstration
backend or sample application is started. The first database has no configured
vhosts. Existing, nonempty data is refused rather than reset.
Use `--plan` to inspect changes first. The installer refuses existing unmanaged/nonempty state rather than overwriting it. Common options include `--prefix`, `--data-dir`, `--http`, `--https`, `--no-https`, `--admin`, `--allow-remote-admin` and `--no-start` (see `install.sh --help`).
The first install also writes `/etc/profile.d/rustproxy-path.sh`, which prepends the
managed `PREFIX/current` directory (normally `/opt/rustproxy/current`) to `PATH` for
new login shells. Open a new login shell, or source that profile explicitly, before
expecting `rust-proxy` to resolve without its absolute path.
## Defaults and administrator setup
HTTPS is enabled by default and starts with a **self-signed certificate**, not a
publicly trusted certificate. For an explicit HTTP-only installation use:
For the standard managed Linux installation:
```bash
sudo bash install.sh --binary target/release/rust-proxy --no-https
```
Assign the intended certificate in the panel before exposing applications. Existing
HTTP/3 defaults enable QUIC on TLS listeners: permit the corresponding UDP port
when desired, or explicitly disable H3 in Listeners. Review firewall rules yourself;
the installer does not change them. Public frontend TLS and administration TLS are
independent settings.
| Item | Default |
|---|---|
| HTTP frontend | `0.0.0.0:80` |
| HTTPS frontend | `0.0.0.0:443` with an initially self-signed certificate |
| Administration | `127.0.0.1:9090`, HTTP on loopback |
| Installation | `/opt/rustproxy/current/` |
| Private data | `/var/lib/rustproxy` |
| Service account | `rustproxy` |
Generate the one-time setup capability directly from the installed release CLI:
The first installation has no predefined administrator and no application Vhosts. Obtain the one-time setup token locally:
```bash
sudo -u rustproxy /opt/rustproxy/current/rust-proxy \
--data-dir /var/lib/rustproxy admin setup-token
```
This works before the first server start. After an administrator exists, setup
tokens remain invalid. You can alternatively initialize the account interactively:
Use the setup page, or initialize the account interactively using `admin init`. To access loopback administration from another machine, tunnel over SSH:
```bash
sudo -u rustproxy /opt/rustproxy/current/rust-proxy \
--data-dir /var/lib/rustproxy admin init
ssh -L 9090:127.0.0.1:9090 operator@server
```
For direct remote administration, first sign in on the default loopback listener and
open **Settings -> Administration**. Enable HTTPS, choose the application-managed
certificate or a certificate from the inventory, set the bind address (for example
`0.0.0.0:9090`) and optionally add DNS names accepted by Host/Origin validation.
Save and restart RustProxy. The persisted remote bind is accepted only while
administration TLS is enabled. Keep the listener behind a firewall/private network;
the certificate must be trusted by the browser if you want a clean HTTPS connection.
Then visit `http://127.0.0.1:9090` on your workstation. For a directly reachable administration listener, configure **Settings → Administration → HTTPS** and a suitable certificate, restrict the network, save and restart. Do not publish unauthenticated or plain-HTTP administration over the Internet. Public frontend HTTPS and administration HTTPS are separate.
An SSH tunnel remains available without exposing the listener directly:
### Listeners and networking
```bash
ssh -L 9090:127.0.0.1:9090 operator@your-server
```
- Assign a trusted public certificate to the relevant hosts before exposing them.
- Configure DNS to reach the intended public listener; check IPv4/IPv6 and firewall rules.
- HTTP/3 needs UDP on the HTTPS port as well as TCP for normal HTTPS.
- Listener bindings supplied explicitly via service arguments/systemd are locked against panel changes. Network bind, administration TLS and some monitoring changes take effect after a restart.
- Ports below 1024 need the appropriate OS privileges/capability; use the installed service model rather than running the whole application as root.
Open `http://127.0.0.1:9090` on that workstation while the administration listener
is still using its default HTTP mode. No default administrator/password is added.
## Managed update
Plain-HTTP remote administration is possible with the explicit allow flag:
```bash
sudo bash install.sh --binary target/release/rust-proxy \
--admin 0.0.0.0:9090 --allow-remote-admin
```
That plain-HTTP path has no transport confidentiality and should be limited to a trusted
private network. Prefer the built-in administration HTTPS mode for a direct remote
listener.
Other installation switches: `--prefix`, `--data-dir`, `--user`, `--http`, `--https`,
`--no-https`, `--admin`, `--allow-remote-admin`, `--sha256` (expected binary digest), `--no-start`. Paths must be dedicated,
absolute, separate, non-nested and without spaces or symlink components.
`--plan` reads the supplied binary but changes nothing; it does not prove port
availability, compiler success or systemd readiness.
### Startup-owned listeners
When HTTP, HTTPS or administration listen addresses are supplied explicitly by systemd, CLI
arguments or the corresponding `RUST_PROXY_*_LISTEN` environment variables, RustProxy treats
those exact binds as process-owned. Settings shows them as locked and configuration updates
cannot remove, disable or rewrite them. Listeners not supplied at process start remain normal
editable desired-state entries.
## ACME prerequisites
Automatic renewal runs inside RustProxy, not a separately installed cron timer. RustProxy
prefers a private Python environment at `<data-dir>/acme-python`. When that environment is
missing or incomplete, the service creates it automatically and installs the pinned Certbot
core plus the Cloudflare, OVH and RFC2136 plugins with `pip`. The application never executes
`sudo` and never modifies distribution-owned Python site-packages.
The host should provide Python 3 with either the standard `venv` module or `pip`; RustProxy can
bootstrap `virtualenv` into its own data directory when a distribution splits `venv` into a
separate package. OpenSSL remains a local certificate-inspection fallback. If the private
environment cannot be created, RustProxy uses a runnable distribution `certbot` from `PATH`
when one is available.
If Python is absent (a possible layout on appliances such as OPNsense), or Python is present
but no local environment can be created and no system Certbot exists, the Certificates panel
shows ACME as unavailable. PEM upload and references to existing certificate files remain
available.
On the standard service installation the managed environment therefore lives at
`/var/lib/rustproxy/acme-python`. Do not run a second Certbot renewal timer over RustProxy's
`certs/acme` profiles. See [renewal](RENEWAL.md).
A source-tree preflight can still inspect a distribution-provided fallback environment with:
```bash
sudo -u rustproxy env PATH=/usr/local/bin:/usr/bin:/bin \
python3 scripts/runtime/acme_provider.py --doctor
```
The provider script is embedded in the RustProxy binary; this command is diagnostic only.
### Using a separate system Certbot
If another service already owns a certificate through the system Certbot, keep that lineage
outside RustProxy's managed ACME profiles and create/use a **manual** RustProxy certificate id.
The preferred 0.28.2 workflow is to select **Existing server files / external paths** and then
**External files (absolute paths)** in the Certificates panel. For example:
```text
Certificate: /etc/letsencrypt/live/example.com/fullchain.pem
Private key: /etc/letsencrypt/live/example.com/privkey.pem
```
RustProxy does not copy these files into its data directory. On every targeted reload it resolves
Certbot's `live/` symlinks again, validates the new certificate/key pair and atomically publishes a
new public TLS snapshot. A renewal wrapper can therefore call:
```bash
curl --fail --show-error -X POST \
-H 'Authorization: Bearer TOKEN' \
-H 'X-RustProxy-Certificate-Key: certbot-main' \
-H 'Content-Type: application/json' \
--data '{}' \
https://proxy-admin.example/api/certificates/shared-cert/reload
```
The `rustproxy` service identity must be able to traverse the parent directories and read both
files. RustProxy 0.28.2 supports POSIX ACLs and does not require one exact chmod value. External
certificate/key files must not be group/world-writable, and an external private key must not be
world-readable. Named ACL read access for `rustproxy` is accepted; the operating system still
performs the final access check when RustProxy opens the file. Every parent directory in the
configured and resolved path must be owned by root or the RustProxy service user and must not be
group/world-writable (root-owned sticky ancestors such as `/tmp` are the narrow exception).
These external files are not included in a RustProxy backup.
The packaged systemd service keeps `/etc/letsencrypt` and `/etc/ssl` read-only. `ReadOnlyPaths=`
does **not** grant Unix permissions, so use narrow ACLs instead of making private keys globally
readable. For example:
```bash
# Traversal only for the protected roots.
setfacl -m u:rustproxy:--x /etc/ssl/
setfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/
# Traversal only for this Certbot lineage.
setfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/
# Read only the certificate/key material RustProxy actually needs.
setfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem \
/etc/letsencrypt/archive/example.com/privkey*.pem
# Optional default ACL for renewers that preserve inherited ACL masks.
setfacl -m d:u:rustproxy:r-- /etc/letsencrypt/archive/example.com/
# Robust renewal-hook form: re-grant read access to the current generation before API reload.
setfacl -m u:rustproxy:r-- \
"$(readlink -f /etc/letsencrypt/live/example.com/fullchain.pem)" \
"$(readlink -f /etc/letsencrypt/live/example.com/privkey.pem)"
```
Because `/etc/letsencrypt/live/.../*.pem` are symlinks into `archive/`, access must cover the
resolved archive files. Some renewal tools recreate private keys as `0600` and may mask an
inherited default ACL; therefore the renewal wrapper should reapply the named file ACL before
calling the RustProxy reload API. Never grant `w` to `rustproxy` on the Certbot tree. Verify a deployment
with `sudo -u rustproxy test -r /etc/letsencrypt/live/example.com/privkey.pem && echo OK`.
If you do not want the RustProxy service to read `/etc/letsencrypt`, keep the previous copy/deploy
workflow instead. A Certbot deploy hook can publish every successful renewal into RustProxy's
managed certificate directory:
```bash
/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy tls deploy --id shared-cert \
--cert /etc/letsencrypt/live/example/fullchain.pem \
--key /etc/letsencrypt/live/example/privkey.pem
```
The deploy command reads the Certbot files locally and sends them to the authenticated loopback
`POST /api/certificates/deploy` endpoint. The running RustProxy service writes a private managed
generation and hot-publishes it for new public TLS/H2/H3 handshakes. Do not use `deploy` for a
certificate ID configured with external absolute paths; renew those files in place and call the
targeted reload endpoint instead. Administration TLS is startup-fixed and may still require a
service restart.
Optional environment overrides go in a root-owned `/etc/rustproxy/environment`
(mode 0600); create the directory when needed. Restart after changing service
environment values. Provider secrets remain in private profile files, not this
file or the exported configuration. A systemd environment file is not a shell script.
## Update an installation created by install.sh
Build and test the new release as a normal user first, then:
Test the new build first. Then execute:
```bash
bash update.sh --plan --binary target/release/rust-proxy
sudo bash update.sh --binary target/release/rust-proxy
```
Before stopping the service, the updater downloads the current direct `user-country`
IPv4/IPv6 CSV assets, validates their structure and compares their local SHA-256 with the
installed files. Unchanged files are left untouched; changed or missing assets are staged
and published only after the private-data backup exists. This intentionally avoids requiring
a separately moving publisher-checksum release. The manual updater and the in-process GeoIP
worker use the same `.geoip-update.lock`, so they cannot publish concurrently. Use
`--skip-geoip` only when intentionally accepting a stale/missing country database (for
example during an upstream outage).
Managed updates preserve the saved configuration, create a private backup, switch releases and attempt rollback on failed startup/health checks. **They interrupt traffic and are not a substitute for a separate off-host backup.** Keep your backup password and test a restore on a separate installation. Source-tree/unmanaged migrations require the dedicated stopped upgrade path: see [Migration](MIGRATION.md).
Use the original `--prefix` for a non-default installation. Other startup/account/
data settings are read from root-owned `install.json`; update does not silently
replace them with command-line defaults. Change intentional listener settings in
RustProxy, or maintain the unit separately when needed.
## ACME prerequisites
Update mode checks that `/etc/profile.d/rustproxy-path.sh` still matches the saved
installation prefix, but deliberately does not rewrite it. A missing or changed file
produces an explicit warning before service downtime; repair the profile separately
if direct `rust-proxy` shell invocation is required.
The managed certificate service can create a private Python/Certbot environment in the data directory; a usable system Certbot may be used as fallback. DNS-01 providers need credentials and appropriate permissions. HTTP-01 needs incoming public TCP `80`. On systems without an available Python/Certbot runtime, PEM import and external certificate references can still be used. See [Certificates](ACME_PROVIDERS.md).
The updater verifies the supplied binary hash and runs only its `--version` as the
service account. Immediately before stopping a RustProxy 0.14.0+ service it creates a
private one-shot managed-update marker. During graceful shutdown the process forces hot
objects into the enabled cold tier even when ordinary `cold_on_shutdown=false`; the marker
is removed before backup. The managed unit allows up to 120 seconds for controlled stop.
If cold storage is disabled, RAM-only cache cannot survive the process exit. The updater
then stages an immutable release, stops the service and backs up the **entire** data directory. This includes SQLite and sidecars, certificates, account
keys, provider credentials, Certbot renewal configs and internal relative symlinks.
Code changes use an atomic `current` symlink switch. It then starts the new process
and requires systemd activity, minimal local `/healthz` readiness, and the actual
MainPID executable matching the staged, locally version-checked binary. The local
health probe follows the persisted administration bind and HTTP/HTTPS mode (including
the configured Host allowlist), rather than assuming the install-time plain-HTTP
address. Version is no longer exposed by the anonymous health endpoint.
The managed systemd unit is regenerated from the saved installation record and
`daemon-reload` is run before startup. The previous unit is included in the backup
and restored on rollback. Keep custom service policy outside this managed unit or
reapply it after an update.
## After installation
A failed start/health check restores the previous release, private data **and**
managed systemd unit. The reported error now includes the original update failure;
recent service status/journal output is emitted before rollback when available.
Failed-generation data is retained alongside the original data directory; the
backup is retained under `/opt/rustproxy/backups`. A failed rollback health check is
reported explicitly. Protect these directories: they contain private keys and
credentials. They are never automatically pruned. Plan enough free space for the
full backup, restored data, retained failed state and old/new executable releases.
Verify `systemctl status rustproxy` and `journalctl -u rustproxy` (managed systemd), the panel login, frontend certificates, first Vhost, real backend response, access policies, optional QUIC, current logs and a full backup/restore test. For error diagnosis, consult [Troubleshooting](TROUBLESHOOTING.md).
During development, if an older saved vhost/settings document is intentionally
incompatible with the current format, use:
```bash
sudo bash update.sh --reset-config --binary target/release/rust-proxy
```
This happens only after the backup is created. It deletes the saved desired
configuration row so the new process creates current listener defaults; administrator
account data, certificates, ACME/provider files and other private state are retained.
Do not use this flag when the old vhost/settings configuration must be preserved.
After a failed update, **do not run `install.sh`**: rollback leaves the previous
installation installed. Fix the reported cause and rerun `update.sh` (optionally
with `--reset-config` for this development-only incompatible-config case).
An update interrupts traffic; zero-downtime deployment is not implemented. `--no-start`
leaves the service stopped and cannot certify a healthy installation. Backup failure
leaves the old code/data in place and attempts to restart a previously running service.
Keep an independent off-host backup; local rollback is not a disaster-recovery plan.
## Existing source-tree installations from earlier archives
Do not point a first installer at your old live data. To keep the current source-
tree deployment, stop RustProxy and use the retained source updater:
```bash
bash /path/to/new/RustProxy/update.sh --source-tree /path/to/old/RustProxy --stopped
```
This mode keeps the old `upgrade.sh` contract and does not install a systemd service
or build a binary. Back up **all** private data and ACME profiles separately before
starting it. See [migration](MIGRATION.md). To adopt the new service layout, make an
explicit offline migration with ownership, absolute Certbot paths and port settings
reviewed; do not merely move a Certbot account tree to another location.
## Server acceptance
Check actual non-root HTTP/TLS/QUIC binds, certificate assignment, a real renewal
validation (`Dry run`), backend failures, live traffic, Basic Auth over TLS, ACLs,
WebSocket enable/disable and location isolation. Verify the backup can be restored
on another isolated instance. Run `systemctl status rustproxy` and
`journalctl -u rustproxy` after installation/update. Only then decide on rollout.
**Release verification:** supplied 0.34.0 source changes still require target-platform compilation and functional acceptance before Internet-facing production use.
+21
View File
@@ -0,0 +1,21 @@
# Integrations overview
RustProxy offers built-in integrations and optional helper components. Each has its own permissions and network requirements; enabling an integration does not automatically configure its external service.
| Integration | Purpose | Setup and limits |
|---|---|---|
| **Docker agent** | Discover running Docker services and publish generated Vhosts | Install separate agent on Docker host; use Bearer token and TLS certificate pin. [Guide](DOCKER_DISCOVERY.md). |
| **Tinyauth** | Delegate authentication for selected Vhosts | Configure verifier URL/login URL, allowed headers and HTTPS. [Access](ACCESS.md). |
| **ACME / Certbot** | Issue/renew HTTPS certificates | HTTP-01 or DNS-01 via Cloudflare, OVH or RFC2136; protect credentials. [Certificates](ACME_PROVIDERS.md). |
| **Prometheus** | Scrape operational metrics | Separate endpoint/token/CIDR rules. [Monitoring](MONITORING.md). |
| **Grafana** | Visualize Prometheus results | Example dashboard under `grafana/README.md`. |
| **Nagios** | External service checks | Sample checks under `nagios/README.md`; configure authorized network/API access. |
| **Nginx Proxy Manager** | Import existing NPM proxy hosts | Included Python dry-run migration, without importing all NPM settings or private certificates. [Migration](MIGRATION.md). |
| **OPNsense certificate deploy** | Deploy certificates from OPNsense ACME plugin | Review `integrations/opnsense-acme/README.md` and use scoped certificate credentials. |
| **RouterOS OCI packaging** | Build an image suited to RouterOS-container environments | Separate experimental packaging under `integrations/routeros-oci-build/README.md`; test the host architecture/network constraints. |
## Rules of thumb
Use API credentials with the smallest allowed action/scope and terminate TLS on sensitive network links. The Docker socket must remain confined to the Docker agent host, not the main proxy. Certificate deploy/reload tokens are **not** general administrator API tokens. For imported NPM services, revisit backend TLS verification, frontend certificate assignment, access rules and special Nginx directives manually.
External compatibility depends on the versions and settings of those external products; this source archive cannot establish acceptance against a live provider or CA.
-165
View File
@@ -1,165 +0,0 @@
# RustProxy 0.4.0 integrations - historical implementation record
> Historical context. This document records the 0.4.0 integration milestone and is retained
> for traceability. Current behavior is documented in the functional/technical guides linked
> from [README.md](README.md).
This is source implementation, not a verified binary. Read TEST_REPORT.md before deployment.
The admin panel remains local; public frontend TLS does not make the admin listener remote-safe.
## Forwarded identity and the trust boundary
Every origin request now gets canonical `X-Forwarded-For`, `Forwarded`, `X-Real-IP`,
`X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Port` and `X-Forwarded-URI`.
Without configured trusted proxies, incoming proxy headers are discarded. The socket
peer IP is used, so a client cannot supply an arbitrary address in X-Forwarded-For.
Both header formats are emitted; RFC 7239 IPv6 nodes are quoted and bracketed.
There is no ephemeral client port in the representation fingerprint.
When RustProxy is behind a real reverse proxy, Settings -> Forwarding accepts explicit
IPv4/IPv6 CIDRs (max 32). Do not enter application client networks as trusted proxies.
The last hop must sanitize incoming forwarding headers. `0.0.0.0/0` and `::/0` are rejected.
With a trusted socket peer, X-Forwarded-For is preferred; Forwarded's `for=` chain is the
fallback. The chain is walked right-to-left and anything before the nearest untrusted
hop is discarded. Malformed or oversized chains fall back to the actual peer.
`X-Forwarded-Proto` is honored only with the separate `trust_forwarded_proto` opt-in.
Authority/Host always comes from validated Host or HTTP/2/3 `:authority`, not X-Forwarded-Host.
ACLs and IP affinity use this centrally resolved client address. Listener selection
continues to use the REAL local destination socket and actual TLS state.
```json
"forwarding": {
"trusted_proxies": ["10.87.7.10/32"],
"trust_forwarded_proto": true
}
```
That address is illustrative, not a default. Leave the list empty for direct clients.
RFC 7239: https://www.rfc-editor.org/rfc/rfc7239.html
## Tinyauth per vhost
Open Vhosts -> Edit -> Authentication. Enable protection, enter the verification
server and the public login URL, allow a private verifier explicitly if needed,
and save. An origin URL automatically appends `/api/auth/nginx`; an explicit URL
path remains unchanged. No public `/tinyauth` proxy route needs to be created.
The following matches the supplied nginx-style setup:
```json
"tinyauth": {
"enabled": true,
"url": "http://10.87.7.99:3000/api/auth/nginx",
"login_url": "https://auth.linuxiarz.pl/login",
"allow_private": true,
"address_allowlist": ["10.87.7.99/32"],
"connect_timeout_seconds": 5,
"timeout_seconds": 10,
"request_headers": ["cookie", "authorization", "x-api-key", "user-agent", "accept"],
"response_headers": ["remote-user", "remote-email"],
"redirect_forbidden": true
}
```
Verification is an internal body-free GET, carrying canonical forwarding headers,
X-Original-URL and X-Original-Method. Original method, path and query remain available
to Tinyauth, while the upload body is sent only to the application after authorization.
The RustProxy admin session cookie is never passed to the verifier or application.
Only 2xx approves access. Selected Remote-* / safe X-* response headers become trusted
identity headers. Incoming Remote-* and configured identity headers are removed,
including after administrator request-header rules. Missing identity fields are not
filled from client values. Transport/framing/forwarding fields cannot be selected
as identity headers. Each direction supports at most 32 names; approved identity values are bounded
to 8 KiB in total.
401 redirects to a validated X-Tinyauth-Location on the configured login origin,
or constructs `login_url?redirect_uri=<percent-encoded original URL>`.
403 stays 403 unless the provider returns an allowed same-origin redirect and the
403-redirect option is enabled. Cross-origin locations are rejected. Verifier errors
fail closed (502/504), not open. Auth denial/error is not replaced by custom error pages.
Public login URLs require HTTPS; HTTP is allowed only for loopback test servers.
Additional CA PEM can extend trust for Tinyauth HTTPS verification; there is no
verify-off switch for the authentication verifier. The per-backend `tls_verify` option
does not apply to Tinyauth.
Configure Tinyauth itself to trust the ACTUAL RustProxy source IP, not 0.0.0.0/0.
Authorization runs before origin connection, static pages, WebSocket upgrade to the
origin and every cache lookup. Authenticated vhosts always BYPASS the internal shared
cache and emit `Cache-Control: private, no-store`, even when their cache toggle is on.
This intentionally also disables privacy-sensitive compression through the existing
predicate. Login/session validity is NOT cached. Revocation is checked on each new
HTTP request or WebSocket handshake; an already upgraded socket is not periodically
reauthenticated. There is no automatic replay of POST or failed upgrades.
Do not enable this protection on the Tinyauth login vhost itself (login loop).
Provider reference: https://tinyauth.app/docs/guides/nginx-proxy-manager/
Header reference: https://tinyauth.app/docs/reference/headers/
## Named listeners
Settings -> Listeners owns every proxy socket. Each endpoint has a stable ID, display
name, scope label, enabled state and a binding with numeric IP:port, TLS, HTTP/2 and
optional HTTP/3 parameters. Scope labels do not install firewall rules.
Vhosts reference endpoints only through `frontend.listener_ids`. Inline frontend
listener definitions and the old global listener-pair compatibility fields are not
accepted in 0.8.0. A named endpoint can be shared by multiple vhosts; same-socket
transport settings must agree. Disabled endpoints create no socket and grant no vhost
binding. Unknown/duplicate IDs and admin-port conflicts are rejected.
Listener transport changes require restart. The panel shows desired and actually bound
endpoints separately. A wildcard bind never widens a vhost's own destination-IP match.
## Certificate library
Certificates is an independent sidebar section. Import a PEM certificate/full chain
and PEM private key, give the entry an ID, specify domain names to validate the pair,
and save it to the inventory. In Vhost -> SSL certificates, select the saved entry.
Import does not add public SNI names by itself; active vhost assignment supplies names.
The advanced filename editor remains for CLI-managed existing pairs and direct SNI maps.
The library shows assignment and default status. ACME/Let's Encrypt and expiry renewal
are not implemented; the inventory/API boundary is ready for that future work.
Uploads: at most 128 KiB per file, key matching/name validation, immutable randomly
named private files, limited parallel imports and mode 600 on Unix. Config stores
filenames and IDs, never PEM private key bytes. File inputs are cleared after import.
Failure between staging and configuration publication may leave unused private files;
they are not automatically activated or deleted. Removing inventory metadata does not
delete key files. Back up the cert directory together with SQLite.
Existing referenced/default entries cannot be removed in the UI without unassigning them.
Certificate changes affect new handshakes; existing sessions continue normally.
The resolver is shared by listeners: one DNS name cannot use different certificates
on different destination IPs. Conflicts are rejected rather than silently chosen.
HTTP-only startup also prepares the private certificate directory, but creates no TLS
socket unless configured. No existing certificates are overwritten.
## Response policies and cache on extra frontends
Global and per-vhost `server_header` accept `preserve`, `hide`, `custom` plus `value`.
Omitted/null vhost policy inherits the global choice. Final policy applies to origin
responses, HITs, local pages and errors for inline/named TCP/TLS/H3 listeners.
Generic header rules run before this dedicated Server policy.
As of 0.18.8, `diagnostic_headers` exposes only the request-ID switch:
```json
"diagnostic_headers": {
"request_id": false
}
```
There is no revision response-header switch or special `X-Rust-Proxy-Revision` handling. If an upstream or ordinary response-header rule supplies that name, it behaves like any other non-protected response header.
Request IDs remain available internally/upstream and in logs. X-Mock-Origin is preserved and can
be removed with ordinary global or per-vhost response-header rules. An explicit vhost diagnostic
object overrides the request-ID choice; null inherits.
Cache keys now include the actual frontend IP:port and stable canonical forwarding
metadata. Empty HTTP/2/HTTP/3 GET bodies without an exact length are checked by peeking
at most eight frames, skipping empty DATA frames; nonempty data/trailers are
replayed to the origin without buffering the full body.
A stream with data/trailers bypasses cache. Reusing the same socket with a different
client source PORT no longer changes the forwarded representation.
Client IP still affects the conservative all-header fingerprint, so different clients
can have separate cache entries. Auth, cookies, no-store, private, upgrades and partial
bodies still bypass caching. Cookies created by a login or the sticky-session feature
are not a cache bug. Close/consume the entire response before expecting a stored HIT.
-55
View File
@@ -1,55 +0,0 @@
# Interactive live logs
RustProxy provides an operator CLI that follows structured request/error events from every HTTP vhost:
```bash
rust-proxy --livelogs
```
The command connects only to the loopback administration listener and creates a bounded local CLI session from the private RustProxy database. When `./data/config.sqlite3` is absent, the bare command discovers common installed layouts: `/var/lib/rustproxy` is preferred on Linux/non-BSD systems and `/var/db/rustproxy` on BSD/OPNsense-style installations, with the alternate convention checked as a fallback. An explicit `--data-dir` always wins and custom installations should pass the same path used by the service. `--admin-listen` can select a non-default loopback port.
## Administration HTTP/HTTPS transport
The CLI first probes the loopback administration `/healthz` endpoint over HTTP. An HTTP-only administration listener stays on HTTP; the `308 Permanent Redirect` emitted by a TLS-enabled administration listener switches the CLI to HTTPS before a CLI session cookie is sent. HTTPS is pinned to leaf certificates present in the private `<data-dir>/certs` directory, so a configured self-signed administration certificate does not require a system trust-store installation.
This fixes the previous reconnect loop where `--livelogs` treated the TLS listener's expected HTTP-to-HTTPS `308` response as a stream failure. The same local transport selection is used by CLI config and certificate API operations.
The live path is separate from configured file/syslog sinks and from the `/logs` stored-log explorer. Request processing publishes ordinary routed request records into a bounded Tokio broadcast channel without waiting for viewers. Low-level malformed HTTP/TLS/HTTP2/HTTP3 protocol failures introduced in 0.26.0 are intentionally persistent-sink-only and do not enter this Live Logs ring, so scanner noise cannot evict normal live request history. A slow viewer receives an explicit `gap` event with the number of entries lost from that viewer's live queue. The CLI also consults the retained history after connecting/reconnecting and de-duplicates entries by request id. Each request now carries a fresh 64-hex-character SHA-256 `request_id`; the same protected value is forwarded to the backend as `X-Request-ID` for correlation.
## User-Agent, country and Traffic analytics
Structured access entries expose `user_agent` and `country` as selectable source fields in addition to the standard entry. User-Agent capture is enabled by default and is bounded before it enters retained logging/analytics state. Country capture is disabled by default. Normally country resolution uses the local GeoIP database; for a vhost with verified Cloudflare client-IP mode, RustProxy first accepts a valid single `CF-IPCountry` value from a direct Cloudflare edge and falls back to GeoIP of the verified effective client IP. With country capture disabled, country resolution is kept off the normal request path unless a configured country access rule or country rate-limit already requires it; in those policy cases resolution still occurs for enforcement, but the country is not retained in access logs unless capture is enabled.
The authenticated **Traffic** workspace aggregates completed requests per vhost without database writes. The request path uses a bounded non-blocking queue, and high-cardinality dimensions use fixed-capacity approximate top tables. The page shows aggregate requests, 5xx errors, request/response bytes and mean duration plus top countries, User-Agents, effective client IPs, paths, routes, methods, status classes and cache outcomes. Donut charts summarize countries, User-Agents, client IPs, methods, status classes and cache outcomes while the bounded tables retain exact displayed labels/counts from the approximate TOP state. The lists are process-lifetime diagnostics, are not persisted across restart, and may drop analytics samples under overload rather than applying backpressure to proxy traffic. Country rankings are populated only while country capture is enabled. Client-IP rankings use the same verified effective client identity used by ACL/logging after trusted-proxy processing.
## Interactive commands
No regex language is required. Type commands while the stream is running:
```text
host api.example.com
vhost api
backend origin-a
route users
client 192.0.2.10
method GET
transport h2
cache MISS
status 5xx
path /api/
errors on
slow 250
detail on
filters
show 12345
pause
resume
reset
clear
quit
```
Use `*` to clear a scalar filter, for example `backend *`, and `slow off` / `errors off` for boolean filters. `show ID` prints the complete retained structured event for that request. `detail on` expands the continuously printed view with route/upstream/cache/transform/WebSocket/H3/byte metadata.
The server revalidates the streaming session every 10 seconds rather than writing session state for every request. Password changes, session revocation or expiry therefore terminate an existing stream within that bounded interval.
+50
View File
@@ -0,0 +1,50 @@
# Live logs, stored logs and error logging
RustProxy has several independent logging paths. Choose the one matching the question; enabling stored file logging is not required to view recent in-memory requests.
## Live Logs
Use **Logs → Live Logs** in the authenticated panel, or locally:
```bash
rust-proxy --data-dir /var/lib/rustproxy --livelogs
```
The CLI connects to the local administration listener and follows structured request events. It can filter by host, Vhost, backend, route, method, client, protocol, status, cache state and latency; use `filters`, `pause`, `resume`, `detail on` and `quit` in the interactive view. It adapts to a loopback administration HTTPS listener where configured.
Live viewing is **bounded in memory**. Slow consumers may receive a gap/loss indication and are not allowed to block proxy traffic. Low-level malformed HTTP/TLS/H2/H3 protocol errors can be sent only to persistent sinks rather than appearing in the ordinary live request list. A request ID (`X-Request-ID`) helps correlate an application response with proxy records.
## Stored Logs
The authenticated **Logs → Stored logs** page reads the configured structured JSON/JSONL access logs, optional per-Vhost files and optional error log. These files can remain readable even when their current writer is disabled. The explorer supports paging, dynamic columns, sorting, record expansion/Raw JSON and repeated-value analysis.
Search uses a restricted case-insensitive query syntax, for example:
```text
status:>=500 AND path:/api/*
client_ip:192.0.2.* AND status:5xx
method:POST AND !country:Poland
error:"connection reset"
```
Plain words search stored values; quotes allow spaces, `!` negates a clause, `*` matches wildcards and numeric operators work on supported fields. **`OR` is not supported.** Searches and repeated-value statistics are bounded by server scan limits; do not interpret a truncated result as the entire historical archive.
## Error and protocol logs
RustProxy can persist warnings/errors and selected protocol failures separately from access traffic. Set output level, sink and optional retention/rotation under **Settings → Logging** and related error-log controls. Access logs may use the global structured file or per-Vhost locations. Log paths must be writable by the service identity, including when running inside a container/systemd sandbox.
The installed Linux deployment may configure `logrotate` for durable logs; check the installed policy rather than assuming all platforms rotate automatically. Logging is bounded/asynchronous where practical, and some records can be dropped under saturation; inspect sink drop/error counters in monitoring.
## Privacy and investigation
Logs can contain client IPs, URLs, application hostnames and timing metadata. Limit access and retention. RustProxy masks selected known credential fields, but avoid deliberately logging secrets or personal data in query parameters and custom headers. The administration API and stored-log search remain behind authentication.
For common symptoms:
- **502/503/504:** correlate request ID, Vhost and backend health with upstream timeout/connect/TLS errors.
- **403/405/429:** inspect ACL, method rule, country or rate limiting.
- **Unexpected cache result:** inspect optional `X-Cache` diagnostics and Cache dashboard alongside request logs.
- **Files empty:** enable the correct sink and check permissions, path and rotation state.
- **Protocol failures missing from Live Logs:** inspect configured persistent error/protocol logs instead.
Also see [Monitoring](MONITORING.md) and [Troubleshooting](TROUBLESHOOTING.md).
-139
View File
@@ -1,139 +0,0 @@
# Vhost management - historical 0.4-era implementation record
> Historical context. This document is retained for implementation traceability and must not
> be used as the current vhost/operator guide. Use [VHOSTS.md](VHOSTS.md),
> [CUSTOM_LOCATIONS.md](CUSTOM_LOCATIONS.md), [VHOST_ACCESS.md](VHOST_ACCESS.md) and
> [WEB_SERVER.md](WEB_SERVER.md) for current behavior.
This guide describes implemented source behavior. Read TEST_REPORT.md before
acceptance: the packaging environment did not compile or run the Rust backend.
## Compact operations
Vhosts opens a compact list with domains, bound sockets, backend count and live
health/latency. Edit opens a modal with Details, Backends, Health, SSL, Cache,
Security, Pages, Authentication and Advanced tabs. Existing source/ACL JSON remains available.
The structure is inspired by proxy-host managers, not a claim of full feature
parity with nginx-proxy-manager. Density is configured in Settings and stored only
as a browser preference. Logo and favicon have not been redesigned.
## Optional certificate upload
In Certificates, upload certificate/full chain PEM and an unencrypted private-key
PEM. The pair is cryptographically validated and written to private immutable files;
the form then saves inventory metadata through revision CAS. No vhost or DNS name
is required just to import a certificate. In a separate vhost save, choose its ID
in SSL; validation checks that it covers the host's configured DNS names.
The file inputs clear after the request. Failed/stale inventory saves can leave
unbound staged files; no key bytes are returned by the API or stored in the config.
Clear/inherit uses the global SNI/default behavior.
Limits: 128 KiB per file, 300 KiB JSON request, up to 16 supplied DNS names, two concurrent
upload workers, 64 configured certificate inventory entries and 128 staged/upload
pairs on disk. Canceled uploads remain staged; no automatic deletion of potentially
active keys is attempted. New uploads eventually stop at that limit. Perform
manual cleanup only after backup and confirmation that neither the saved
configuration nor the serving snapshot references those files, preferably stopped.
The configured-certificate backup does not collect unused staging files.
Certificates must cover the configured DNS names. This does not prove browser trust,
chain completeness, revocation status or remaining lifetime. ACME-issued certificates
are shown as managed/read-only inventory entries; they are renewed through the ACME
workflow rather than edited as uploaded custom PEM files. New issuance selects the
production CA environment by default but still requires explicit production confirmation;
staging remains available for testing. A certificate can be deleted only after it has
been unassigned and saved everywhere, including vhosts, redirects, 404 hosts, the
default TLS certificate and the administration panel. Encrypted-key password prompts
are not supported. The single SNI resolver rejects different selected certificates for
one DNS name on different IP bindings. Socket changes still require restart; changing
a certificate for an existing listener uses hot snapshot publication after Save.
Requests already in flight are not interrupted.
## Cache and response headers
Cache selectors distinguish inheritance, on and off. Blank numeric fields inherit.
TTL/object limits, query/path exclusions and optional diagnostics are configurable
per host. The memory/entry ceilings are shared globally; one host cannot increase
its object ceiling above the global limit. Explicit per-vhost enable is permitted
when the global cache is explicitly disabled. Cookie-affine pools and path cache=false still
bypass. Purge on a host clears its entries and invalidates its in-progress fills,
without rewriting configuration or evicting other hosts. Changing configuration
still performs a full invalidation.
Security includes visual rules for headers sent to the backend and to the client,
and per-vhost HSTS/nosniff/frame/referrer/CSP/permissions policies. Advanced JSON
edits update the visual rows. Global, vhost and path header rules run in order;
existing final Server/security/protocol safeguards retain precedence. In
particular, a hidden Server cannot be restored by an ordinary header rule. An
explicit per-vhost Server override can replace the inherited global Server policy.
## Custom pages
Global Settings / Pages supplies error defaults, direct-IP and unknown-host
responses. Vhost / Pages supplies an optional static response, a missing-path
fallback and error overrides. Modes are inheritance, built-in congratulations,
empty and administrator HTML (typed or loaded from an HTML file).
Supported error keys: 400, 401, 403, 404, 408, 413, 429, 500, 502, 503 and 504.
Keep the original status by leaving status unset; an explicit status is allowed
from 200, 204, 400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503 or 504. Only empty
mode permits 204. HTML is at most 32 KiB UTF-8 per page and the complete serialized
configuration remains bounded to 256 KiB. No CSS/image asset hosting or directory
serving is provided; use self-contained HTML or an upstream for multi-file sites.
The built-in congratulations text is English; replace it with your own HTML for
other languages. RustProxy-generated 503 and 504 failures use matching built-in dark
HTML pages when no administrator override is configured; vhost/global custom error
pages still take precedence. An origin's own 503/504 response is not rewritten unless
a custom error override is configured. Admin interface translations still come from
language packs.
Request behavior:
1. Validate authority/path and real local binding; check applicable host/path ACLs
and enabled Tinyauth authentication before serving any content.
2. Use the explicit direct-IP page for a literal IPv4/IPv6 authority, if enabled.
3. Otherwise use the matching vhost's static response, if configured.
4. If no path is selected, use that vhost's missing-path fallback; if there is no
matching vhost, use the global unknown-host fallback. Otherwise keep normal 404.
5. Forward normally when a route exists, then replace a configured error with its
vhost override or global default. Apply the normal response/security policy.
Static/fallback endpoints accept GET/HEAD; other methods get 405 with Allow.
HEAD has the selected response headers and no body. Default status for a static
vhost/direct-IP page is 200; a missing-path/unknown-host fallback retains 404 unless
explicitly changed. Error responses retain their original status by default.
Already-authored pages are not rewritten a second time by error rules. Replacement
drops old encoding, length, cookies and other representation metadata, while
preserving authentication challenges, Retry-After and Allow. Pages use no-store.
ACL denial always prevents upstream access, even when its 403 page is configured
to return 200. A success status for an authentication/server error can mislead
clients and monitoring. Prefer preserving original statuses. Direct-IP HTTPS
must still finish TLS; this HTTP feature cannot bypass certificate validation.
HTML is not previewed in the admin DOM. It is trusted administrator content and
may execute scripts when served to visitors. Never import untrusted HTML.
## Separate live-log window
Use Live logs in the toolbar or a host's Logs button. Choose modal or docked mode;
filters remain above a scrollable table. Filters combine vhost, method, status
family, cache/error outcome and text (host, path, method, vhost, backend). Pause
freezes the displayed bounded snapshot, not collection. Resume uses the latest
snapshot. Export contains exactly the selected rows. Closing/reopening keeps
filters; signing out clears viewer state.
The source is a 256-entry in-memory access ring refreshed through SSE/polling.
It is not an unbounded tail, durable file, guaranteed replay or audit store. The
vhost field comes from the actual selected frontend and remains available for
cache/static responses, without fabricating a backend request. Pool health data
are live observations, not settings; disabled checks are not proof of liveness.
## Additional 0.4.0 controls
The Authentication tab enables Tinyauth. Security includes inherited/per-vhost
Server and diagnostic header settings. Details selects named listeners shared
with other vhosts; inline listeners are not part of the current configuration model. Certificates are managed in
the separate inventory rather than uploading keys from each vhost editor.
The toast moves into the active dialog/top layer so its message and action remain
visible and interactive above the backdrop. See INTEGRATIONS_040.md for contracts.
-91
View File
@@ -1,91 +0,0 @@
# Long-running memory verification (RustProxy 0.30.0)
This procedure supplements static source checks. A synthetic Python model and a
local Python HTTP peer **do not execute RustProxy** and cannot establish its
production RSS, heap growth or absence of leaks.
## Audit coverage
| Area | Source-level finding | Action in the same 0.30.0 version |
| --- | --- | --- |
| Selective cache PURGE | Per-key generation metadata could retain every distinct purged key indefinitely after its object was removed | Reset stale per-key metadata at the existing global generation barrier; drop an abnormally large map allocation; regression for 512 different purges |
| HOT/COLD cache | Body/entry limits, bounded transient leases, limited cold queue, revalidation slots and background cleanup permits | Kept existing behavior |
| Log output (JSONL/file/Logstash/Loki) | Serialized the same entry again when Loki and a JSONL sink ran simultaneously; sink queues and per-vhost open files were already bounded | Reuse serialized JSONL content for Loki; keep redaction and sink-specific formatting |
| Historical log discovery | Prior audit already capped directory inventory while scanning, at 256 per-vhost and 64 gzip entries | Retained existing 0.30.0 limits |
| Traffic analytics | Top lists limited to 32 entries per dimension, at most 64 vhosts and a 2048-item input queue, but repeated labels incurred transient `String` allocations | Borrow existing labels, allocate only when admitting/replacing a top-list entry |
| Local directory listing | Two simultaneously live vectors for directory metadata and a copied HTML response | Filter directory metadata in place; move output bytes into response; keep all filenames and HEAD Content-Length behavior |
| TCP/UDP streams and HTTP/H2/H3 | Connection/session permits, finite queues, idle deadlines and completed task draining | No change |
| Upstream clients, DNS, health probes | DNS worker limit, timeouts, pool recycling, bounded health checks | No change |
| Compression, transforms, PHP-FPM | Streaming response paths, transform memory and concurrency budgets, FastCGI streaming | No change |
| Rate limits, ACME, Docker, background tasks | Limited client buckets with expiry and shrink, cancelled/limited background activities | No change |
This is **not** a proof that every runtime allocation is released. The directory
listing feature intentionally generates the complete visible directory as HTML;
very large directories may still need substantial temporary memory. A high RSS
after a load spike may also be allocator reuse instead of an active leak. Verify
with real-runtime measurements rather than assuming either explanation.
## Source/synthetic verification
```sh
./dev.sh check
./dev.sh test
# Perform separate real-runtime load and RSS measurements on staging.
```
The old Python cardinality model and protocol-peer load driver were removed
with `tools/checks/`. They were synthetic tests, not proof of RustProxy memory
bounds. `./dev.sh test` now runs only the Rust suite; runtime memory behavior
must still be verified by sustained staging traffic and RSS tracking.
## Real RustProxy staging traffic and RSS
On a Linux staging machine with Rust/Cargo installed, configure a vhost and a
known reachable backend/local Web Service. Enable whichever logging targets you
need to test (JSONL file, Logstash, Loki) in the normal dashboard. Start the
proxy and determine the **RustProxy** PID, not the load generator's PID.
```sh
./dev.sh check && ./dev.sh test
# In another terminal: run the configured instance, e.g. ./dev.sh run
pid=$(pgrep -n -x rust-proxy)
# Warm / repeated URL: cache HITs, persistent clients, normal JSON access logs.
python3 tools/dev/memory_load.py \
--url http://127.0.0.1:8080/ \
--host-header example.test \
--pid "$pid" --requests 400000 --concurrency 128 \
--cooldown 120 --output /tmp/rustproxy-memory-repeat.json
# High cardinality: many URL paths, cache misses, large analytics churn.
# Pick a staging path prefix accepted by your origin; status codes will be
# recorded even when the requested URLs return 404.
python3 tools/dev/memory_load.py \
--url http://127.0.0.1:8080/ \
--host-header example.test \
--pid "$pid" --requests 400000 --concurrency 128 \
--unique-paths 100000 --cooldown 120 \
--output /tmp/rustproxy-memory-distinct.json
```
Run at least three **successive** traffic/cooldown cycles on the same process.
Test separately with access JSONL only, JSONL+Logstash and JSONL+Loki using
reachable staging services; add streaming large response bodies, directory
listings, TCP/UDP and HTTP/3 using appropriate specialized clients. Send
controlled cache PURGE requests through your authenticated cache invalidation
API and ensure previously purged cache entries never reappear.
The tool samples `/proc/<pid>/status` (RSS, high-water mark, threads) and
`/proc/<pid>/fd` at a configurable interval. Output includes response codes,
requests/second, received bytes, time series and start/peak/end RSS. Check
steady-state RSS after each cooldown, open file descriptors, timeouts, dropped
logs, HIT/MISS behavior, and whether growth **continues** across periods of
similar load after caches and connections have stabilized. In addition, inspect
cache/transformation memory accounting and capture a Rust heap/allocation
profile on the actual instance if RSS rises repeatedly.
Do not run these load tests against an active production service without a
maintenance or capacity plan. The load driver does not create vhosts, backend
services, log collectors or a RustProxy executable, and it cannot reproduce
all TCP, UDP, H2 and H3 protocol behavior. None of these real-instance
acceptance steps were performed in the source-only audit environment.
+39 -511
View File
@@ -1,535 +1,63 @@
# Deployment and configuration migration history
# Upgrades and migration
## 0.32.3 -> 0.32.4 Preserve Host and Dashboard gutters
This guide covers changes that need operator action when moving to RustProxy 0.34.0 and the included Nginx Proxy Manager importer. For ordinary package upgrades, first follow [Installation and updates](INSTALL.md).
No SQL schema, saved vhost JSON, account preferences or widget IDs change.
`preserve_host: true` now works with `http1`, `auto`, `http2` and `h2` instead of
being rejected for the latter three. If an HTTPS backend is addressed by IP but
serves multiple vhosts, retaining the visitor Host also sets HTTP/2 `:authority`
to the public host, while TCP/DNS use the configured URL and optional
`tls_server_name` sets SNI/certificate identity. Keep a separate
`health_check.host` for HTTP probes that require the vhost name. The debugging
H1 preset remains optional, and original Overview widget preferences survive.
Check TLS verification, hostname/certificate coverage and Angie handling on a
real staging instance before deployment; the Rust compiler acceptance gate
is still open.
## Before changing versions
1. Record installed version, platform, current listener binds and external certificate paths.
2. Create a password-encrypted off-host backup and confirm its password is available; separately copy external cert/key and integration state.
3. Verify the target source and dependencies, compile and run tests on a staging copy when possible.
4. Read release notes for cache compatibility, configuration changes, API token permissions and listeners requiring restart.
5. Update with a maintenance window. Test production DNS, TLS, Vhost routes, access, cache behavior, PHP, Docker, Streams, logging and backup recovery before considering the upgrade complete.
## 0.32.2 -> 0.32.3 upstream protocol and Dashboard compatibility
A managed install uses `update.sh`; an unmanaged source-tree install must follow the supplied stopped upgrade path. Never overwrite an active database by extracting a ZIP over live private data. RustProxy normally applies forward SQLite schema migrations as needed; do not manually edit internal migration tables.
No database or backend JSON rewrite is required. In 0.32.2 an HTTPS origin
saved as `"protocol": "http2"` could negotiate HTTP/1.1 via TLS ALPN. The same
value **still** permits H2/H1 over TLS in 0.32.3 and still uses H2C on plaintext
HTTP. To enforce HTTP/2 only, explicitly select new `"protocol": "h2"`.
Existing `http1` and `auto` values, HTTP/1.1 WebSocket Upgrade, configured
keepalive and pool settings retain their meaning.
## Notable compatibility points
The dashboard registry grows to 24 IDs. Eight HTTP backend cards are optional,
hidden by default for all accounts until enabled; old ordering and hide/show
preferences survive. These use existing status data with no extra background
polling or schema migration. Verify live behavior and WebSocket negotiation on
a Rust-enabled staging environment before deployment.
| Change area | What to review |
|---|---|
| **0.34.0 cache** | Operational Vhost saves aim to retain compatible HOT/COLD cache. Content-affecting changes invalidate a scope; older disk cache objects can require refilling. Validate actual behavior before rollout. |
| **0.34.0 Web Server** | `sendfile=auto` is an optional Linux cleartext HTTP/1.1 path; default is `off`. No existing service needs to enable it. |
| **0.32.x origin protocols** | Legacy backend `http2` remains compatible on TLS (H2/H1) and H2C for plain HTTP. New strict `h2` must be set explicitly. |
| **0.33.x Overview** | Dashboard presets/layouts are per administrator, independent from Vhost configuration; existing preferences should be retained and new widgets may remain hidden. |
| **Certificate API grants** | Older broad/reload-only credentials can exist after additive migration; replace them with explicit certificate-ID allowlists and least privilege. |
| **PHP-FPM** | Distinct applications with different PHP INI overrides need separate external FPM pools and a worker restart; RustProxy cannot isolate FPM workers itself. |
| **Legacy custom root location** | A former untouched synthetic `/` location may normalize into the main Vhost fallback; a deliberately customized `/` rule remains an override. |
| **External Certbot files** | File-backed certificate paths stay external; backups and restores do not include those bytes. Check symlink and permission changes after renewal. |
## 0.28.1 -> 0.28.2
Restore of configuration history changes **only configuration**, not a full application state snapshot. See [Backup](BACKUP.md) for disaster recovery.
SQLite schema 8 is upgraded automatically to schema 9. The additive migration creates `certificate_api_grants`. Existing Certificate API credentials become reload-only compatibility credentials covering all certificate IDs (`*`); rotate them in **Settings -> Certificate API** to replace the wildcard with explicit certificate IDs. New credentials require 1-32 explicit certificate IDs and are reload-only unless deploy permission is enabled.
## Nginx Proxy Manager (NPM) import
The saved proxy configuration document and COLD cache format are unchanged. External certificate paths keep their 0.28.1 semantics, but 0.28.2 validates the full configured/resolved parent chain and accepts POSIX ACL read access without requiring an exact chmod value. The packaged systemd service marks `/etc/letsencrypt` and `/etc/ssl` read-only; Unix permissions/ACLs still need to grant the `rustproxy` user the required traversal/read access.
The included `scripts/migration/migrate_npm.py` reads NPM's SQLite database and prepares RustProxy Vhosts. **It does not modify NPM or migrate its private TLS keys.** Keep `migrate_npm.py` and its two sibling helper modules together when running outside this source checkout.
## 0.26.1 -> 0.26.2
What can migrate: NPM proxy-host domain names, upstream scheme/host/port, basic enabled state, WebSocket and force-HTTPS policy, and supported frontend listener selection. The importer reports more complex access lists, custom Nginx locations/directives, caching, exploit rules and certificate details **for manual review**. Private/backend origin policy and upstream TLS verification must be explicitly checked after import.
No SQLite or saved-configuration migration is required. Version 0.26.2 changes only administration/log-explorer behavior and account-scoped view preferences. Existing JSONL and RAW log files remain readable, and old Stored Logs filter query parameters remain accepted by the API for compatibility even though the dashboard now uses one query field.
### Dry run first
## 0.26.0 -> 0.26.1
No SQLite or saved-configuration migration is required. Version 0.26.1 changes persistent protocol-error attribution and the Stored Logs UI/API normalization only. Existing JSONL files remain readable, Live Logs behavior is unchanged, and unresolved protocol errors continue to use `_unmatched.jsonl`.
## 0.25.7 -> 0.26.0
There is no SQLite schema or saved-configuration migration. Version 0.26.0 extends the existing structured access-log record with optional protocol-diagnostic fields and adds a read-only stored-log explorer. Existing logging settings remain valid and file logging remains disabled by default. Low-level malformed/TLS/protocol events are written only when at least one external structured-log sink is enabled; they do not enter the Live Logs memory ring. Existing JSONL access files and the configured error file can be browsed after upgrade without conversion.
The `/logs` explorer is authenticated administration UI. Its API derives source IDs from the active RustProxy logging configuration and does not accept filesystem paths. On Unix it refuses symlink targets when opening files for browsing.
## 0.25.6 -> 0.25.7
There is no SQLite schema or saved-configuration migration. Version 0.25.7 reuses the existing `admin_preferences` document to persist backend-table visible columns and sort order per administrator account; search text remains transient. It also adds vhost enable/disable UI actions, themed scrollbars and additional Prometheus/Grafana observability. Existing vhost `enabled` values and backend configuration remain unchanged until an administrator explicitly uses the new action.
## 0.25.5 -> 0.25.6
There is no SQLite schema or saved-configuration migration. Version 0.25.6 adds the already-exposed upstream client generation/rotation diagnostics to the live Backends table and keeps the 0.25.5 backend client recycling defaults unchanged.
## 0.25.4 -> 0.25.5
There is no SQLite schema or saved-configuration migration. Existing `cloudflare_client_ip` vhost settings remain opt-in. When enabled and the direct peer is a verified Cloudflare edge, RustProxy now prefers a valid `CF-IPCountry` value for country analytics/logging and country policy evaluation, falling back to the installed GeoIP database when that header is unavailable or invalid.
## 0.25.3 -> 0.25.4
There is no SQLite schema or saved-configuration migration. Schema 8 and the existing 512-row `configuration_history` retention remain unchanged. Version 0.25.4 adds an authenticated restore operation that validates a retained document and saves it as a new revision; existing history is not rewound or overwritten.
## 0.25.2 -> 0.25.3
SQLite schema 7 is upgraded automatically to schema 8. The migration adds `configuration_history`; on first open the current revision is seeded into history, and subsequent successful configuration saves retain the newest 512 revisions for the dashboard revision-history view. No manual SQL is required.
New vhost/location access fields are additive. Missing `method_policy`, location `acl_profile_id`, location country policy and location inline ACL fields retain the prior behavior. RustProxy-generated request IDs are now SHA-256 values and are forwarded to origins as protected `X-Request-ID`; custom request-header rules cannot replace that correlation identifier.
## 0.25.1 -> 0.25.2
There is no SQLite schema or saved-configuration migration. Full backup creation may now run while the service is active; restore still requires RustProxy to be stopped. Backup archive version 2 preserves validated internal relative symlinks such as Certbot `live/` entries pointing into the profile `archive/` tree. Existing archive-version-1 backups remain accepted for restore.
The administration server keeps the same strict Host/Origin/request-marker/CSRF checks. The browser shell only adds a bounded one-shot same-origin reload when a restored or resumed authenticated tab receives `auth.invalid_origin`.
## 0.25.0 -> 0.25.1
There is no SQLite schema or saved-configuration migration. 0.25.1 reorganizes the Traffic dashboard and source tree only; existing Web Services, vhosts, traffic settings, cache state and administrator data remain compatible. Browser asset URLs remain `/assets/app.js` and `/assets/vhosts.js` even though their source is now split into bounded modules.
When updating a source checkout by extracting the archive over an older tree, remove the obsolete source monoliths `src/admin/web/app.js` and `src/admin/web/vhosts.js` if they remain. Checkouts older than the Web Server module split may also retain obsolete `src/web_server.rs`; remove it as well. Do **not** remove `src/config.rs`, `src/docker.rs`, `src/runtime.rs`, `src/balance.rs`, `scripts/deploy/deploy.py`, `scripts/migration/migrate_npm.py` or `docker-agent/main.go`: these are intentional module/CLI entry points in 0.25.1. Build/cache artifacts such as `target/` and `__pycache__/` may be deleted safely, but `.env`, `data/`, databases and local certificates are runtime/operator state and must not be deleted as part of a source refresh.
## 0.24.10 -> 0.25.0
There is no SQLite schema migration for the first Web Server stage. `web_services` is an
additive collection inside the existing revisioned configuration document, and missing
vhost/location `source` fields continue to deserialize as Reverse Proxy. Existing 0.24.10
configurations therefore remain valid without manual conversion.
A Web Service must be created separately before a vhost/location can reference it. Document
root, index files and directory-listing settings belong to that reusable Web Service and are
not migrated into individual vhosts.
## Historical deployment changes
## 0.15.1 -> 0.16.0
There is no SQLite schema migration. Existing vhosts, backends, cache files, administrator accounts and sessions are retained.
Custom locations are now interpreted only as overrides. An untouched legacy synthetic `root` prefix `/` location with no custom backend/cache/ACL/header/rewrite/local-response behavior is normalized away and replaced by the internal main-vhost fallback. A deliberately customized `/` location remains a real override. No manual edit is required for the normal legacy `root` entry.
Publishing the administration listener through a vhost remains explicit-risk functionality. For reserved session-cookie passthrough the public request must be HTTPS, the external hostname must be listed in `administration.allowed_hosts`, the backend must directly address the configured administration listener by IP, and that backend must have `accept_local_listener_risk=true`. Ordinary upstreams continue to have `rustproxy_session` removed in both directions.
Access-log JSON may contain the additional fields `http_version`, `transport`, `websocket_bootstrap`, `websocket_phase`, `websocket_error`, `h3_reset_code` and `upstream_status`. Cache/system metrics add memory-breakdown gauges; existing consumers should ignore unknown fields as usual.
## 0.15.0 -> 0.15.1
There is no schema or configuration-format migration. This patch changes only administration UI language-pack placement, Custom locations editing/help/preview and dashboard visualizations. Existing vhosts, locations, cache settings and SQLite data remain unchanged.
## 0.14.0 -> 0.15.0
There is no new SQLite schema version in 0.15.0. Renewal observations remain in the existing
schema-v3 `certificate_renewals` table. The application no longer contains a runtime JSON importer.
When `upgrade.sh` / `scripts/deploy/upgrade.py` is used while RustProxy is stopped, an obsolete
`<data-dir>/certs/renewals.json` observation sidecar is validated as a regular file and removed as
the final source-upgrade step. It is not imported and no second legacy copy is retained. If the
source-upgrade transaction fails before that final removal, the old sidecar remains untouched.
If a site replaces source manually instead of using the stopped upgrader, the old sidecar is simply
ignored. Back it up and remove it. An empty renewal-observation table is safe: the ordinary scheduler
reconstructs non-secret status/timestamps on its first 5–10 minute startup check, while certificates,
keys, ACME account material and renewal profiles remain unchanged.
## 0.13.1 -> 0.13.2
Back up `config.sqlite3` and the configured certificate files before updating. On first
0.13.2 open, RustProxy applies the additive SQLite schema-v3 migration and creates
`certificate_renewals`; existing configuration, administrator accounts, sessions, audit
records and revisions are retained. The table stores only non-secret renewal observation
state (timestamps/status/message). If it is empty and the older private
`certs/renewals.json` exists, validated observations are imported once and the sidecar is
removed only after the database write succeeds. That one-time runtime compatibility path existed in
0.13.2/0.14.0 and is removed in 0.15.0; use the 0.15.0 stopped upgrader described above instead.
The managed `update.sh` path for 0.13.2+ also marks the stop as a software update so an
enabled cold tier receives remaining hot cache objects even when ordinary
`cold_on_shutdown=false`. This is a best-effort persistent handoff bounded by the managed
stop timeout; no cache persistence is possible when cold storage is disabled.
The raw JSON editor moved to **Settings -> Manual config**. `/configuration` remains a
browser compatibility alias, so old bookmarks do not require migration.
## 0.8.0 -> 0.8.1 cache behavior
The source default for `cache.enabled` changes to `true`. Existing saved configurations
that explicitly contain `enabled: false` remain disabled; the new default applies to new
or missing cache settings and is not a silent rewrite of an administrator choice.
`cache.max_object_bytes` may now be configured up to 1 GiB when `cache.max_bytes` is at
least as large. Browser conditional requests/no-cache refreshes now use conditional HIT
or backend revalidation instead of blanket BYPASS; `no-store` remains a hard exclusion.
- Existing vhosts deserialize new traffic-policy fields with conservative/default-off
behavior except `http2_enabled` and `http3_enabled`, which default true to preserve prior protocol behavior.
- The pre-production inline/global listener format is intentionally no longer readable.
Recreate development listener configuration under `listeners.endpoints` and bind each
vhost with `frontend.listener_ids`. No automatic vhost migration is performed.
- Existing backend TLS settings are preserved. Only newly created panel backend rows use
the 0.13.0 default `tls_verify:false`; enable verification explicitly for origins that require certificate identity checks.
- First installs fetch verified GeoIP country files. Existing installations upgraded with
`update.sh` keep their data directory; install the two verified files under `data/geoip`
before enabling country rules if the installation predates 0.8.0.
No database schema migration is introduced. The release changes deployment defaults
and control-plane startup behavior:
- `admin setup-token` can create the one-time setup token before first server start.
- New managed systemd installations default to `0.0.0.0:80` and `0.0.0.0:443`.
Existing installations keep the saved service unit during `update.sh`; update does
not silently add port 443.
- Wildcard administration is opt-in only. Direct server startup requires
`--admin-listen 0.0.0.0:9090 serve --allow-remote-admin`.
The installer uses the equivalent `--admin` and `--allow-remote-admin` options.
This is plain HTTP; keep it behind a firewall/private network or use a tunnel.
Back up private state before updating and run the real Rust check/test suite on the
target build host.
---
# 0.7.1 -> 0.7.2 build fix
This is a distinct source release. It removes the absent-SWAGGER-environment panic;
it does not migrate the database, alter vhosts or reset any account/certificate.
Back up private data as usual and update the complete source tree, not just build.rs.
The patch changes Cargo dependencies and adds a required build helper, so copying
only the Rust file would leave an inconsistent package.
The existing `dev.sh` Python 3.10+ requirement now also applies to direct Cargo
builds. No `.cargo/config.toml` or manual `SWAGGER_UI_DOWNLOAD_URL` export is required.
The default pinned tag is fetched on the first build; a reviewed local tag archive
remains supported for restricted build hosts. See [API_ACCESS.md](API_ACCESS.md).
No default to an older Swagger distribution is introduced.
Use the existing stopped-source `update.sh --source-tree` workflow or build a new
binary and use the managed-service updater as documented in [INSTALL.md](INSTALL.md).
`dev.sh` backs up an older Cargo.lock before regenerating it; do not fake the new
lockfile by replacing its root version text. A valid new lockfile is not included.
Rebuild/restart after successful tests; the old process cannot load embedded changes.
There is no need to clear application data or certificates, or to run `cargo clean`
just to remove this panic. Test the real admin session and `/docs` after building.
---
# 0.7.0 -> 0.7.1 access changes
No database/configuration schema migration is introduced by this patch. Back up
existing data/certificates and use the existing install/update instructions.
The reported release is source-only; compile and test before installing a binary.
Operational JS and full translation packs are now session-protected. Old tabs
should be reloaded after upgrading. Automation reading `/api/auth/status.version`
or `/healthz.version` must use authenticated `/api/status` instead. Dedicated
Prometheus scraping retains its own CIDR/token policy and remains independent of
browser sessions. No reverse-proxy vhost auth policy is relaxed by this patch.
In 0.7.1 the Swagger pin depended on `.cargo/config.toml`. That requirement is
removed in 0.7.2 (see the build notes above). Source-tree updates still back up and
replace that managed directory; retain any unrelated local Cargo settings from
your private backup. Read [API_ACCESS.md](API_ACCESS.md) for current build inputs.
Run `python3 tools/audit/admin_audit.py --url http://127.0.0.1:9090` after deployment.
It prints only status checks, no response bodies or credentials.
---
# Updating, backup and recovery
## 0.7.0: renewal and service deployment
See [INSTALL.md](INSTALL.md) for separate installer/updater commands and complete
code-plus-data rollback. Older source deployments retain `update.sh --source-tree`.
Back up the entire private state, including Certbot profiles, before any update.
Do not overlay a new binary onto a live, untested database without a recovery plan.
Managed 0.6 certificate issuer metadata defaults to automatic renewal in 0.7.
The scheduler starts after a 5-10 minute delay and reuses existing consented
profiles. Imported PEM without issuer metadata is not scheduled. Review auto-renew
flags, service PATH, DNS permissions and HTTP-01 reachability before starting.
The new process does not implicitly switch staging certificates to production.
Missing WebSocket flags preserve existing enabled behavior. New UI-authored vhosts
default to disabled. Existing backend members remain in the normal pool; new
dedicated location origins default to `pool_eligible:false`. Basic Auth is opt-in,
requires actual TLS by default and combines with (rather than bypasses) IP ACLs.
Normal dev/server startup no longer starts the demonstration origin. Existing
sample vhosts are not silently deleted: remove or replace them deliberately, or
explicitly enable the protocol fixture in an isolated old test installation. New
databases show a neutral informational landing page with no sample backend.
New fields are not a promise of backward parser compatibility. Downgrade by
restoring both old code and the pre-upgrade private-data snapshot. No verified Rust
build is supplied; run check/test and server acceptance before rollout.
## 0.6.0: historical vhost migration note
0.6.0 originally shipped a one-way importer from the pre-vhost development format.
That importer is removed in 0.8.0. Current builds accept only `vhosts`, named
`listeners.endpoints` and `frontend.listener_ids`. Development databases containing
top-level route/backend documents or inline frontend listeners must be recreated or
manually rewritten before starting 0.8.0. No automatic compatibility conversion runs.
The dedicated Prometheus listener remains opt-in/disabled by default. Settings ->
Prometheus controls it. A restart is required for enable/bind/token-variable changes.
Certificate provider metadata remains additive and secrets remain outside exported
configuration.
## 0.5.0 behavior changes
Back up the database and configured PEM pairs before upgrading. The managed
updater replaces the full source tree, not live data. Build and test on staging.
No verified binary or fabricated Cargo.lock is supplied.
TLS bindings with omitted `http3` now enable UDP with defaults; older versions
omitted disabled options when serializing, so review ALL TLS bindings. To retain
TCP-only operation, explicitly save `http3: null` on the named TLS listener before
restarting the new process. Shared/wildcard
IP-port bindings must agree on all protocol flags and H3 limits. A UDP bind failure
is not silently ignored. HTTP/1.1 and optional HTTP/2 remain on TCP.
`diagnostic_headers.hide_mock_origin` was removed and is no longer accepted or
exported; use explicit response-header rules if needed. X-Mock-Origin is a normal upstream field; remove it with ordinary
response-header rules when desired. Request ID remains off by default. The removed 0.17 `diagnostic_headers.revision` field is no longer part of the configuration schema. Custom Server values
are NOT automatically rewritten to the new binary version.
Cache contents are process-local. Reserved admin-cookie removal now occurs before
cache eligibility; application cookies still bypass. Domain counters reset on
configuration publication. See CACHE_DIAGNOSTICS.md for explaining BYPASS.
ACME/RFC2136 is optional and adds external Python/Certbot/plugin requirements.
No DNS update or CA request happens simply by upgrading. Issuance is explicit,
staging-first and does not activate a certificate without import. Current encrypted full backup includes the durable ACME profile/account state and
Secret Store material required for DNS-provider renewal; the historical `dev.sh backup`
helper does not. Use `rust-proxy backup create` for current installations. Automatic
renewal behavior is documented in RENEWAL.md.
## Reported auth module compilation failure
0.0.1 used `src/auth.rs`. 0.0.2 moved the implementation into `src/auth/mod.rs` and
`src/auth/store.rs`. Overlay extraction can retain both layouts and cause Rust
E0761. Missing `Store::session_at`, `admin_record`, `end_session` and
`revoke_all_sessions` messages can follow because the implementation module did not
load. The 0.0.2 archive itself did not contain both auth layouts.
0.4.0 retains an explicit `#[path = "auth/mod.rs"]` declaration, including for direct
Cargo invocations. Preflight also detects mixed source trees. It recognizes the
exact original auth file by SHA-256 and moves it to
`.upgrade-backup/legacy-auth/auth.rs.0.0.1`. Unknown custom source is left untouched
and the development script stops with instructions. Never delete custom files just
to silence a compiler error.
Locate the NPM SQLite database (often `/data/database.sqlite` inside its container). Back up its live consistent database before reading it; SQLite WAL may contain uncheckpointed changes.
```bash
bash dev.sh doctor
bash dev.sh repair
python3 scripts/migration/migrate_npm.py \
--database /path/to/npm/database.sqlite \
--rustproxy https://rustproxy.example.net:9090
```
These commands do not require Rust. Repair is deliberately limited; a safe whole
source-tree update is preferable to extracting individual files over a checkout.
The default invocation creates `npm-migration-plan.json` with planned additions/skips; no write happens without `--apply`. Enter admin credentials through the prompt or secure environment, and use a trusted server certificate. The optional `--ca-file` supports a private CA; `--insecure-admin-tls` is a deliberate local/test exception, not a recommended setting for ordinary production automation.
## Recommended source update
Review each conflicting hostname, missing HTTPS listener/certificate, custom Nginx directive and backend policy. Explicit listener selection is possible with `--http-listener` and `--https-listener`.
Extract the new archive beside, not inside, the installed project. On installations
that provide the current encrypted backup command, stop RustProxy and create the
backup outside the data directory before replacing source files:
### Apply after reviewing the plan
```bash
rust-proxy --data-dir /home/mg/RustProxy/data backup create \
--output /home/mg/RustProxy-backup-before-upgrade.rpb
unzip RustProxy-0.4.0.zip -d /home/mg/rustproxy-040-staging
cd /home/mg/rustproxy-040-staging/RustProxy
python3 scripts/migration/migrate_npm.py \
--database /path/to/npm/database.sqlite \
--rustproxy https://rustproxy.example.net:9090 \
--apply
```
`tools/legacy/backup.py` is a legacy release-test helper and is not a complete Secret
Store backup. For current backup/restore behavior see `SECRET_STORE_BACKUP.md`.
The migration writes through RustProxy's **revisioned administration API**, so it should not silently overwrite a newer config save. Test each imported domain and assign actual RustProxy certificates manually. Keep NPM available for rollback until the imported configuration is verified.
A missing database means there is no database to back up. Do not initialize an empty
replacement accidentally. For data stored elsewhere, use the actual directory.
## Unsupported or very old configurations
Stop RustProxy using its terminal or service manager. Then:
```bash
bash upgrade.sh /home/mg/RustProxy --stopped
# External data directory: also pass --data-dir /actual/private/data
cd /home/mg/RustProxy
bash dev.sh test
bash dev.sh
```
The updater checks a process lock on POSIX, requires the explicit `--stopped`
acknowledgement and rejects nested source/target paths and managed symlinks.
It preserves `.env`, `data`, `.git`, `target` and unmanaged files. Source, scripts,
tests, examples and documentation are replaced as whole managed paths. Old managed
files remain in `.upgrade-backup/before-0.4.0-<identifier>`. An interrupted copy
exception triggers source rollback. Abrupt power loss is not a transactional
filesystem update; inspect the backup and source tree before restarting.
The updater does not evaluate the old `.env` or discover arbitrary external data
paths. Pass `--data-dir` when necessary, and stop every process yourself before
changing source. It does not perform a database migration or backup automatically.
Custom source edits are backed up, not merged.
## Database compatibility
RustProxy 0.13.2 advances the SQLite schema from version 2 to **version 3** with one
additive `certificate_renewals` table for non-secret ACME observation state. The existing
0.0.1-to-0.0.2 auth migration still runs when needed. Configuration-document additions
remain defaulted/non-destructive. Existing accounts, policies, revisions and configuration
remain intact. Starting with 0.15.0 the application reads renewal observations only from this table;
historical JSON observation state is handled, when present, by the stopped source-upgrade remove
described above rather than by the running service.
The generated default certificate is created on first TLS-enabled startup, in
`<data-dir>/certs`. Its key is never overwritten by startup. A partial or invalid
pair causes a clear startup error; it is not silently discarded. Demo settings
never overwrite an existing configuration, including an empty existing one.
## Backup contents and restore
Current RustProxy releases use the password-encrypted full-backup format documented
in `SECRET_STORE_BACKUP.md`. The supported interface is `rust-proxy backup create`
and CLI-only `rust-proxy backup restore`; it includes the encrypted Secret Store,
key material required to recover it, certificates and other durable application
state while excluding cache, logs and runtime files.
The historical `./dev.sh backup` / `tools/legacy/backup.py` snapshot helper described by
older release notes is not sufficient after Secret Store migration and is retained
only for offline compatibility/release tests.
## Downgrade
Older versions reject unknown configuration fields such as `tls`, `protocol` or
`tls_ca_pem`. Restoring old source alone is therefore not a safe downgrade after
saving new configuration. Restore the matching pre-upgrade database and environment
from a verified backup, with the server stopped. Do not edit password hashes or
schema versions manually.
## 0.0.6 -> 0.1.0 document and source changes
No new SQL schema version is introduced. Old documents default to inherited
listeners, disabled cache/compression and preserve-Server behavior. New optional
route cache policy does not change existing route behavior. Existing account,
revision, TLS and ACL state is retained.
Managed paths now include build.rs, langs, deploy and make_zip.py. Extra *.json
language packs from the installed langs directory are copied into the new tree
when the new distribution does not contain the same filename. Shipped packs are
updated, with old bytes preserved in the source backup. Invalid custom packs can
correctly block a build; review their JSON syntax and metadata before building. No symlinked
language pack is followed. The updater never copies a database from the package.
The old lockfile is preserved in the managed source backup and a new dependency
graph is generated by Cargo on first build. No 0.1.0 resolved Cargo.lock is included
because Cargo was unavailable here. Keep the actual newly resolved file afterward.
Saving any 0.1.0 document serializes its new fields. Older binaries use strict
unknown-field rejection, so rolling back only the source/binary may fail startup.
Restore the pre-upgrade database/certificates into a new private directory and keep
the newer data separately. Do not directly edit live SQLite to remove fields.
A saved invalid listener address or missing low-port permission can prevent
startup. The online config CLI cannot repair an offline server. Keep a pre-change
backup and use the documented stopped-server restore path; remove persisted
listener overrides through Settings while the service is still running before
changing deployment assumptions. The cache itself is RAM only and is not backed up.
## 0.1.0 -> 0.2.0: compression, vhosts and pool behavior
The `SizeAbove` u16/u64 mismatch is fixed by replacing that predicate with a custom
u64 implementation. Do not use `try_into().unwrap()` or truncate the setting to
work around the old source: configured thresholds above 65535 must remain valid.
This source change removes that call, but is not a successful compiler result;
run `bash dev.sh check` and `bash dev.sh test` on a Rust-enabled staging machine.
SQL schema version stays 2; accounts, ACLs, certificates and revisions are retained.
New `vhosts` defaults empty; named TLS listeners default to H2/H3 capability when those fields are omitted. Legacy routes
continue on the global listeners and are never silently migrated or exposed on
additional IPs. The editor's explicit migration action converts one unambiguous
route group in a single revision and keeps the migrated route group's existing health behavior.
New vhosts default to TCP health checks and remain unavailable until
`rise` checks succeed. Plan the correct path/status/timeouts before migration.
Health/counter/affinity observations are ephemeral. They survive compatible edits,
not restarts; new/changed/re-enabled origins require new checks. Cache and idle
upstream pools are not carried across config publication. Admitted exchanges finish
with their old snapshot and are not replayed on a different backend.
HTTPS origins previously configured `http2` now retain HTTP/1.1 ALPN compatibility.
Plaintext `http2` is still strict h2c. Per-listener IP/port/TLS/HTTP2 changes require
restart. During an address move, hot routing can deny the previous destination
before the new socket is bound. Plan a maintenance window, verify assigned IPs,
privileges/certificates and retain an offline-restorable backup.
After saving any 0.2.0 document, 0.1.0 can reject new fields even without a vhost,
because `https_http2` is serialized. Downgrade requires matching pre-0.2.0 data,
source and environment. Never remove fields by editing a live SQLite database.
The archive contains no fabricated lockfile, executable, database or private key.
Generate and retain the real dependency graph before acceptance/deployment.
## 0.2.0 -> 0.3.0
The reported `json_internal!` recursion error is addressed by splitting
`status_payload` into small JSON values and inserting feature flags through a map.
Do not overlay only this function: this release also adds the `query` Axum feature,
`src/pages.rs`, `management.js`, new configuration fields and matching UI assets.
The earlier u64 compression predicate remains in place. Compilation has not been
executed in the packaging environment; this is not a successful-build assertion.
Existing database documents deserialize with empty/default `pages`, vhost `cache`
and `certificate_id` fields. No SQL schema migration is added. Per-vhost certificate
selection rewrites the effective SNI map in a snapshot, not the files. Upload is a
staging action: Save must publish a validated configuration before traffic uses it.
Canceling the editor leaves an unbound pair, not an active certificate change.
Use the complete backup/whole-source update procedure above. The existing backup
copies configured keys and fallback files. Unbound upload staging files are not in
the configuration and therefore are not part of that configured-certificate backup.
Retain them separately only when needed. Never delete a filename still referenced
by the configuration or currently serving snapshot; stop the process before manual
cleanup and preserve a backup of the full private certificate directory.
New defaults do not activate custom pages or vhost cache overrides. An explicitly
disabled global cache can still be overridden by one vhost; the global byte/entry/object
limits still bound it. Current releases rotate cache compatibility only for configuration
changes that can alter cached/origin semantics; unrelated control-plane publication preserves
reusable objects. The dedicated per-vhost Purge action remains selective.
Downgrade requires restoring the earlier database/configuration together with its
matching source: 0.2.0 rejects the new fields. Do not merely relabel a new document.
The source archive contains no fabricated lockfile. Resolve and retain Cargo.lock
on the first compiler-enabled build and run all tests before restarting production.
## 0.3.0 -> 0.4.0
Use the full-source update above, not an overlay of a few Rust files. This revision
adds Quinn/h3 dependencies, new modules and a new embedded integrations.js asset.
No successful compiler run or dependency resolution is claimed. Generate and retain
a real Cargo.lock, then run check and all tests before acceptance.
SQL schema stays 2. Proxy sockets are persisted only in `listeners.endpoints`; a new
database is seeded from startup CLI listener addresses. Existing development documents
using the removed listener format are rejected rather than migrated. Tinyauth and
vhost Server policy continue to use their normal defaults. New diagnostic defaults remove x-request-id from client responses unless it is enabled explicitly where needed. Access logs retain request IDs. The old revision diagnostic field/header behavior was removed in 0.18.2. Forwarding headers are
rebuilt from actual peers unless known proxy CIDRs are explicitly configured.
Reference named listener IDs per vhost instead of duplicating bindings. There is no
inline/global compatibility conversion path in 0.8.0. Scope names have no firewall meaning. Changed sockets, ALPN or H3 limits
require restart; opening UDP is necessary for H3. Saving intent does not prove
that an OS bind or UDP firewall rule will succeed.
The certificate directory/fallback is prepared even with HTTP-only startup, so
library uploads can be staged before enabling TLS. The generated fallback is
self-signed development material and does not automatically open an HTTPS socket.
Import the PEM pair in Certificates, save inventory, then select its ID in SSL.
Unbound staged files are not automatically backed up; preserve private files needed
for rollback and never remove any currently referenced certificate/key.
Tinyauth-protected vhosts bypass shared cache regardless of cache toggles. Failures
deny access instead of serving cached/static content. Configure its verifier URL,
public login URL and explicit private-origin permission before enabling it on an
existing service. Test login, refusal, logout/revocation and WebSockets on staging.
The examples are complete replacement documents for disposable test databases,
not merge patches. Saving a 0.4 document serializes new fields; older binaries may
reject them. Downgrade requires matching pre-upgrade database, certificates,
environment and source, with the process stopped. The source backup alone is not
a database/configuration rollback.
Do not assume arbitrary old Nginx config files, plugin-specific JSON, unmanaged data directory layouts or incompatible backup formats migrate automatically. Follow the original release notes or restore on an isolated older version first, then upgrade in controlled steps. Use the current API schema rather than undocumented JSON keys. For problems see [Troubleshooting](TROUBLESHOOTING.md).
-37
View File
@@ -1,37 +0,0 @@
# Fake Tinyauth protocol peer - local development only
This is NOT authentication. Any client can mint the allow cookie. The fixture binds
only to 127.0.0.1 and must never protect a production application.
```bash
bash dev.sh mock --port 9001 --name origin-a --ttl 30
# Separate terminal:
bash dev.sh mock-auth --port 9300
```
In a disposable vhost's Authentication tab, set:
- verifier: http://127.0.0.1:9300 (adds /api/auth/nginx)
- login: http://127.0.0.1:9300/login
- explicitly allow private verifier IPs
- forwarded identities: remote-user and remote-email
Use your actual frontend address/hostname. Example for a local plain-HTTP vhost:
```bash
# Denied -> 302 to configured login, no application request:
curl -i -H 'Host: app.localhost' http://127.0.0.2:8181/inspect
# Allowed -> app receives mock-user / mock@example.test; cache remains BYPASS:
curl -i -H 'Host: app.localhost' -H 'Cookie: mock_auth=allow' http://127.0.0.2:8181/inspect
# Force authentication failure even with an allow cookie:
curl -X POST -H 'Content-Type: application/json' -d '{"status":503}' http://127.0.0.1:9300/control
# Simulate slow verifier; choose less than or greater than the vhost total timeout:
curl -X POST -H 'Content-Type: application/json' -d '{"delay_ms":12000}' http://127.0.0.1:9300/control
# Restore default deny/allow behavior:
curl -X POST -H 'Content-Type: application/json' -d '{"reset":true}' http://127.0.0.1:9300/control
curl http://127.0.0.1:9300/state
```
/control replaces status/delay/redirect behavior (omitted values reset to defaults).
Optional location sets X-Tinyauth-Location for redirect-policy tests. /state exposes
only the last 16 metadata records, not Authorization, cookies or query strings.
Real Rust-router auth tests are in tests/integrations.rs.
-116
View File
@@ -1,116 +0,0 @@
# Python-only mock origin
Run independently of Cargo/Rust, in a separate terminal:
```bash
bash dev.sh mock --port 9001 --name origin-a --ttl 30
```
This runs until Ctrl+C. A second origin for balancing uses another terminal:
```bash
bash dev.sh mock --port 9002 --name origin-b --ttl 30
```
The helper binds 127.0.0.1 by default. It accepts IPv4 addresses only and refuses
non-loopback bindings without --allow-remote. Do not expose it publicly:
/control deliberately has no authentication. It has 32 connection workers,
15-second socket timeout, a 2 MiB maximum response and a 10-second delay ceiling.
It is a development fixture, not a secure production website.
## Configure a test vhost
After building and starting RustProxy, create a vhost with domain mock.localhost,
frontend 127.0.0.1:8080, backend http://127.0.0.1:9001, private backend permission,
HTTP health path /health, and Cache enabled with X-Cache diagnostics. Leave sticky
cookies off to test shared cache. Wait for its backend to become healthy.
`examples/mock-vhost.json` is a complete ready-to-apply document for a disposable
installation. It explicitly enables the per-vhost cache (the 0.8.1 global default is
also enabled), adds a custom 503 page, and serves the congratulations page on direct-IP access. Applying
it replaces all existing routes/vhosts; it is not a merge patch. Review first:
```bash
bash dev.sh config validate examples/mock-vhost.json
bash dev.sh config apply examples/mock-vhost.json
```
Do not replace your real configuration for this demonstration. Use the panel to
add equivalent settings to an existing project instead.
## Verify cache, not just the origin
Requests must go through the proxy with the right Host:
```bash
curl -si -H 'Host: mock.localhost' http://127.0.0.1:8080/cache
curl -si -H 'Host: mock.localhost' http://127.0.0.1:8080/cache
```
After the first complete response, expect MISS then HIT with an identical nonce
while fresh. Origin JSON always generates a new nonce and timestamp; a cache hit
therefore makes reuse visible. Health probes can advance the sequence too, so
compare nonce/body, not an assumption that sequence increases by exactly one.
GET is the clearest fill test because it consumes the complete body. HEAD is also
cacheable in 0.11.5 and shares the GET representation. Only response `Vary` fields
create request-header representation variants; unrelated request headers and the
selected backend-pool member no longer fragment the same Host + URL object.
Use the host's Purge action and request again to expect a new MISS. Waiting past
TTL also refreshes. To compare with a non-cacheable route:
```bash
curl -si -H 'Host: mock.localhost' http://127.0.0.1:8080/nocache
curl -si -H 'Host: mock.localhost' http://127.0.0.1:8080/nocache
```
Bodies should change and must not become HIT. A diagnostic MISS means lookup was
attempted, not that the response was stored: no-store/private/Set-Cookie may reject
storage after origin headers arrive. BYPASS indicates a request-side exclusion.
## Endpoints
| Path | Behavior |
|---|---|
| / | Small HTML page with links |
| /cache?ttl=30 | Random JSON; public max-age; Vary: Accept-Language |
| /nocache | Random JSON; no-store |
| /private | Private JSON; cannot enter shared cache |
| /cookie | Sets a cookie; must not enter shared cache |
| /large?size=131072 | Compressible bytes; public freshness; useful above 65535 bytes |
| /slow?ms=800 | Delayed no-store response |
| /health | 200/503 according to controlled health; delay is also applied |
| /state | Current health, delay, sequence and origin ID |
| /status/401, /status/403, /status/404, /status/503 | Origin error responses for page tests; 401 includes a challenge |
| POST /control | JSON with healthy:boolean and/or delay_ms:0..10000 |
Compression needs enabled proxy compression and an appropriate Accept-Encoding.
The origin itself does not compress. Inspect both response headers and decoded body;
not all curl builds support Brotli or Zstandard. Example:
```bash
curl -si --compressed -H 'Host: mock.localhost' \
'http://127.0.0.1:8080/large?size=131072'
```
## Failover, latency and recovery
Control the origin directly, not through the proxy, especially after it is marked
down. Configure the health path /health (the default root page always returns 200).
```bash
curl -s -H 'Content-Type: application/json' -d '{"healthy":false}' http://127.0.0.1:9001/control
curl -s -H 'Content-Type: application/json' -d '{"healthy":true,"delay_ms":1200}' http://127.0.0.1:9001/control
curl -s -H 'Content-Type: application/json' -d '{"healthy":true,"delay_ms":0}' http://127.0.0.1:9001/control
```
Watch health/RTT in the vhost list/editor and dashboard. Fall/rise thresholds and
probe interval delay transitions intentionally. Add origin-b as a second backend
for failover and balancing tests. Random origin data alone does not prove that the
Rust scheduler, compression or cache is correct; run the Rust acceptance tests.
## Verification scope
The mock server is a development fixture, not a proxy test. Exercise its endpoints
directly with curl when needed, then run the RustProxy suite separately with
`bash dev.sh test` on a host that has Cargo.
+46
View File
@@ -0,0 +1,46 @@
# Monitoring, metrics and health
RustProxy has operational views in the authenticated panel and an optional standalone Prometheus exporter. They measure different things: dashboard sampling, process-lifetime counters and stored logs are not interchangeable.
## What to watch
| View | Main information | Interpretation |
|---|---|---|
| **Overview** | Active connections, request rate, errors, process CPU/RAM, protocols, backend state, optional charts | Dashboard samples are not long-term billing data. |
| **Vhosts → Backends** | Member health, weights, H1/H2 modes, current work, last observed upstream response | A `last_status` from normal proxy traffic is not an active health probe result. |
| **Web Server** | Requests/status classes, static/PHP traffic, response-header time, response bytes, PHP concurrency permits | Response header duration excludes streamed body transfer. PHP permits are not FPM child processes. |
| **Traffic** | Per-Vhost totals and top countries/IPs/paths/methods/agents/cache results | Bounded in-memory analytics, approximate top lists and possible sample loss under heavy load. |
| **Cache** | HOT/COLD occupancy, hit/miss/bypass, object metadata, selected invalidation | HTTP `Age` differs from cache residence age. |
| **Docker / Streams** | Agent status, dynamic Vhost health, TCP/UDP sessions and traffic | An agent can be waiting for first observation; Streams are non-HTTP. |
| **Logs** | Live requests, stored JSONL/error/protocol records | A log sink can be disabled while live memory view remains available. |
Overview allows account-specific card layout and history controls; see [Dashboard](DASHBOARD.md). GeoIP/country analytics depend on the installed/update database and whether country capture is enabled.
## Prometheus exporter
The exporter is **disabled by default** and uses a separate HTTP listener, normally `127.0.0.1:9191`, at `/metrics`. It is not protected by the admin browser cookie; it has its **own** client-network and bearer-token policy. In **Settings → Prometheus** enable the endpoint and configure:
- `listen`, `path`, `allowed_cidrs` and an optional environment-variable **name** `bearer_token_env` (the secret value is supplied separately to the service).
- `allow_trusted_network_without_token` only for a deliberately restricted monitoring subnet. `/0` CIDRs are not accepted in this mode.
- Collector switches for process/system, Vhosts, traffic, backends, Docker, cache domains, Streams and logging.
A non-loopback exporter must have an actual token or an explicit token-free trusted-network exception. The exporter uses plain HTTP; keep it on loopback, trusted network or a protected TLS tunnel. `Forwarded`/`X-Forwarded-For` headers cannot grant exporter access because network restrictions apply to the transport peer.
For bearer authentication, provide a 32–4096-character printable non-whitespace ASCII token in a **service environment variable** and set `bearer_token_env` to its name, e.g. `RUST_PROXY_METRICS_TOKEN`. Changing exporter activation/bind/token-variable usually needs restart; some collector/CIDR settings apply live. The exact allowed GET path must not have a query string.
Example scrape configuration, after choosing the correct private target:
```yaml
scrape_configs:
- job_name: rustproxy
static_configs:
- targets: ['127.0.0.1:9191']
```
When using a bearer token, configure Prometheus `authorization` or `bearer_token_file` securely rather than embedding a clear token in an exported dashboard. Review `grafana/README.md` and `grafana/rustproxy-overview.json` for chart examples; `nagios/README.md` contains external check scripts.
## Metric privacy and limits
Prometheus series use bounded configured labels; arbitrary request URLs, raw client IPs, credentials, raw User-Agents and query strings are intentionally not exposed as unbounded labels. Dashboard traffic top lists can still contain sensitive operational metadata, so keep the panel authenticated.
Metrics reset on process replacement, and some per-Vhost/backend records can reset when identity or config changes. Distinguish counters, rates and gauges; watch for traffic sampling gaps rather than interpreting every missing point as zero. Before deployment, verify collector reachability, firewall rules, CPU/RAM overhead and alert thresholds against real traffic.
-24
View File
@@ -1,24 +0,0 @@
# Local-response / 404 hosts
RustProxy 0.22.3 has a dedicated **404 hosts** page for domains that should be
owned by RustProxy but must not contact a backend.
Each entry has the same frontend model as vhosts/redirects:
- one or more hostnames,
- named listener selection,
- optional TLS certificate,
- independent HTTP/2 and HTTP/3 switches.
The response can be:
- the built-in `RustProxy is running` placeholder,
- an application-style built-in status page for 401, 403, 404, 429, 503 or 504,
- an empty/status-only response,
- bounded custom HTML.
The status is selected rather than typed manually. Application-style pages use the bundled 400/401/403/404/408/410/413/429/500/502/503/504 templates. Placeholder, empty and custom-HTML modes expose the supported application error set: 400/401/403/404/408/413/429/500/502/503/504. HTTP/2 and HTTP/3 toggles are shown beside the listener controls because they apply to the selected frontend listeners. Host/listener ownership is
validated against vhosts and redirect hosts, so two active configuration objects
cannot claim the same frontend binding.
All 404-host responses remain local and never open an upstream connection. They are not stored in RustProxy cache. Before the response leaves RustProxy, cache policy/validator headers and RustProxy `X-Cache*` diagnostics are removed, so 404 hosts do not advertise cache behavior on the wire. The built-in `RustProxy is running` placeholder also intentionally omits optional RustProxy server, diagnostic, security, compression and backend headers.
-120
View File
@@ -1,120 +0,0 @@
# Nginx Proxy Manager migration
RustProxy ships `scripts/migration/migrate_npm.py` for a one-time, dry-run-first migration of
Nginx Proxy Manager (NPM) proxy hosts from its SQLite database.
The tool reads NPM only. It does not modify the NPM database and it never imports
Let's Encrypt/private-key material. It logs in to the RustProxy administration API,
reads the current revision, merges generated vhosts into the existing configuration,
and writes only when `--apply` is supplied.
`migrate_npm.py` is the CLI entry point and requires the sibling modules
`scripts/migration/migrate_npm_core.py` and `scripts/migration/migrate_npm_api.py`. They use only the
Python standard library, so no `pip` packages are required. If the migrator is copied
or mounted outside the RustProxy source tree, keep all three files in the same directory.
## What is migrated
For each NPM `proxy_host` row:
- `domain_names` -> RustProxy vhost hostnames, including leading `*.` wildcards;
- `forward_scheme`, `forward_host`, `forward_port` -> one RustProxy backend;
- `allow_websocket_upgrade` -> vhost WebSocket policy;
- `enabled` -> vhost enabled state;
- `http2_support` -> vhost HTTP/2 policy;
- NPM hosts with a certificate/forced SSL -> RustProxy TLS listener. Certificate
files are not copied: the application-managed RustProxy default certificate is
used unless `--certificate-id` names an existing RustProxy inventory certificate;
- `ssl_forced` -> RustProxy `force_ssl=true`. When HTTP and HTTPS listeners exist,
both are attached so cleartext requests can receive RustProxy's `308` redirect to
HTTPS. If no TLS listener exists, an SSL-forced NPM host is skipped instead of being
imported with weaker semantics.
Migrated backends use `allow_private=true` because NPM commonly proxies to LAN or
container origins. The migration tool follows RustProxy 0.22.1 and imports backend `tls_verify=false` unless you later enable verification explicitly.
Use `--npm-compatible-backend-tls` when an NPM HTTPS origin depends on
the common Nginx behavior of not verifying the upstream certificate; review that
exception after migration.
The following are reported for manual review rather than guessed: NPM access lists,
advanced Nginx snippets, custom locations, NPM cache settings and `block_exploits`.
Existing RustProxy hostname ownership is never silently replaced; conflicting NPM
rows are skipped and shown in the report.
## SQLite location
The standard NPM SQLite path inside the container is `/data/database.sqlite`. On a
Docker host with a bind mount, pass the corresponding host path. For a named volume,
`docker volume inspect` shows its mountpoint.
Read the live mounted database rather than copying only `database.sqlite` from a
running WAL-mode instance; copying without its WAL file can omit recent changes.
## Dry run
Set the password in the environment or omit it and use the interactive prompt:
```bash
export RUSTPROXY_USERNAME=admin
export RUSTPROXY_PASSWORD='...'
python3 scripts/migration/migrate_npm.py \
--database /path/to/npm/data/database.sqlite \
--rustproxy https://rustproxy.example.net:9090
```
The default run is read-only. It creates `npm-migration-plan.json` with mode `0600`
and prints every imported/skipped NPM row plus manual-review warnings.
If the administration panel intentionally uses the application self-signed
certificate and the script connects directly to it, add:
```bash
--insecure-admin-tls
```
Prefer `--ca-file /path/to/ca.pem` or a hostname with a trusted certificate where
available.
By default the first enabled HTTP listener and first enabled TLS listener are used.
For `ssl_forced` hosts both are selected when available. `--force-https` applies the
same Force SSL policy to every imported host. Select explicit listener IDs when needed:
```bash
--http-listener public-http --https-listener public-https
```
## Apply
After reviewing `npm-migration-plan.json`:
```bash
python3 scripts/migration/migrate_npm.py \
--database /path/to/npm/data/database.sqlite \
--rustproxy https://rustproxy.example.net:9090 \
--apply
```
The write uses the revision returned by `GET /api/config`; a concurrent RustProxy
configuration change causes the API compare-and-swap to fail rather than overwrite a
newer revision.
## Running from a helper container
If the Docker host does not have Python 3, mount the NPM data read-only and mount the
RustProxy source/script into a temporary Python container. For example, from the
RustProxy directory:
```bash
docker run --rm -it \
--network host \
-v /path/to/npm/data:/npm:ro \
-v "$PWD/scripts/migration:/rustproxy-migration:ro" \
python:3.13-alpine \
python /rustproxy-migration/migrate_npm.py \
--database /npm/database.sqlite \
--rustproxy https://127.0.0.1:9090 \
--insecure-admin-tls
```
Use `--apply` only after the dry-run plan is reviewed.
-353
View File
@@ -1,353 +0,0 @@
# RustProxy 0.33.8: performance and compatibility results
RustProxy 0.33.8 substantially improves local static-file throughput in this
experiment: **5.23 times the rebuilt original for 64 KiB and 10.13 times for
1 MiB**. Small proxy and WWW responses improve by 15.71% and 10.41%, respectively.
Results are mixed for larger proxy responses: 64 KiB improves by 2.41%, while
1 MiB decreases by 2.77%. This is a first optimization package, with remaining
distance to nginx and visible memory and observability costs. It is not a
universal speedup, nginx-equivalence or production-capacity claim. [B1, B2]
The changes preserve the LB panel, schema version 10, existing feature controls,
TLS/cache defaults and bounded pool recycling. Actual process tests verify the
release separately from throughput measurements. The complete package is the
unit of attribution; no experiment isolates the contribution of each edit. [F1,
F2, S1]
## Executable and evidence identity
The tested final executable is `rust-proxy 0.33.8`, SHA256:
```text
24cc77a084ee1db4cd007f86c4b18b319bdc2e3f19afae7804739abff5e62a12
```
The rebuilt original 0.33.7 uses the same Rust 1.99.1 compiler, locked dependency
versions and release profile. Its SHA256 is
`85a91318b41a405d1322cbfb84979e05490a7c2ad429ee7cb0c0836acca63899`.
The independently supplied 0.33.7 executable has SHA256
`c68d63326ddf1e25911a583d994a213132dfb1b094425fc6cf358470299f0a92`.
Differences from the supplied executable therefore remain a separate comparison;
they cannot be assigned solely to source edits or solely to compiler provenance.
[B1, F1]
The primary run finished at `2026-10-17T09:01:41Z`. All **2,077** files in its
sealed manifest were independently rehashed. The SHA256 of that `SHA256SUMS`
file is `5036d1f27a53dd6434b4be5a9be99a6e300f10821cffcc15b79f4da06ff147ee`;
`runs.json` is
`ebd9cc06d52496f776deefc48615598a64f018a10be5811ac89401b06e821e5e`.
This report was created after the release build; raw results identify the tested
binary independently of subsequent documentation. Evidence identifiers below
refer to workspace-relative paths in the accompanying verification package. [B2]
## What changed, and what remains deferred
The supplied PERF-00..08 plan is broader than this release. Its boundaries are:
| Plan item | Implemented here | Remaining work |
|---|---|---|
| PERF-00: reproducible measurement | Real HTTP runners, interleaved comparison, resource/telemetry capture, exact payload checks, separate syscall tracing | Full concurrency sweep, remote generator/origin, allocator/cycle profiles and per-edit experiments |
| PERF-01: keep-alive tuning | Existing controls and default limits retained | Controlled idle-pool/recycle sweep; no claim from the old configuration-only experiment |
| PERF-02: compiled feature plan | Request-local cache eligibility plan, credited below | Broader immutable `CompiledRequestPlan` for all optional features |
| PERF-03: cache OFF path | Skip inapplicable cache-policy/key/header/revalidation preparation; retain bypass reasons and cache ON semantics | Further changes only with profiles and equivalent regression coverage |
| PERF-04: body/telemetry cost | Narrow compression-header copies, direct ID hex formatting, lazy idle timers, shorter history mutex section | Wider `Exchange`/analytics redesign and other locking changes |
| PERF-05: routing context | Existing validated snapshots retained | Shared validated route context and rewrite invalidation redesign |
| PERF-06: static I/O | Narrow filesystem job context, borrowed pinned root, reads capped at 64 KiB, precise local EOF | Buffer sweep, secure sendfile/io_uring and extra FD/content caches |
| PERF-07: pools/connections | Existing semantics tested with real peers | Pool/selection optimization; no LB UI additions |
| PERF-08: release gate | Listed laboratory checks and 30-minute soak completed; documented rollback | Memory plateau, deployment-specific 24-hour test and staged rollout |
`CachePlan` avoids force-cache/cookie matching and request-policy work when
ineligible, while retaining diagnostic reasons, origin conditionals, privacy,
range/transform interactions and publication tickets. WWW requests move headers
into local serving when cache conditionals do not need a retained copy.
Compression copies only relevant header values when needed. Request IDs retain
fresh OS randomness, SHA-256 and their 64-character lowercase representation.
Idle deadlines and cancellation semantics remain even when timer allocation is
deferred until a pending read. [S1, F1]
Filesystem jobs receive a narrow root context. Nonempty paths borrow the pinned
root descriptor for the first secure open, while root-directory operations keep
independent directory offsets. Local file bodies report EOF with their final
bounded DATA frame; short reads and cancellation do not publish complete cache
entries. Arbitrary upstream bodies are not declared complete from Content-Length
alone, preserving trailers and framing checks. [S1, F3]
History entry cloning and destruction of evicted entries occur outside its
mutex. The 4,096-entry history, chronological retention, `try_lock` admission,
drop counter, live broadcast and configured sinks retain their contract.
Compatibility repairs also cover explicit Host ports, long-form `.htaccess`
Expires, expired cache inventory, reserved diagnostic headers, escaped-JSON
secret redaction and certificate authorization ordering. These are included in
package-level results, without separately assigned RPS gains. [F1, F4, S1]
## Measurement method
The primary matrix contains **120 measured samples**: proxy/WWW, three object
sizes, four executables and five interleaved repetitions. Each run has 15 seconds
of measurement after three seconds of excluded warm-up, using 64 actual
HTTP/1.1 keep-alive connections. Payloads are 1,040, 65,536 and 1,048,576 bytes;
cache is OFF and requests use identity encoding. [B1]
Four frontend workers are pinned to CPUs 0–3, two origin workers to 4–5, and
four wrk threads share CPUs 6–8. All processes share one Linux host/kernel and
an eight-CPU cgroup quota. nginx 1.26.3 uses sendfile, access logging OFF,
upstream keep-alive 64 and `proxy_buffering off`. RustProxy retains its existing
history/analytics and request-policy work; nginx is a differently featured
reference. This is a warm local-file-cache experiment. [B1]
Before measurement, the actual ACME/Python startup must finish: authenticated
status must be terminal, with no active jobs/configured issuers and one stable
RustProxy process for five seconds. The harness validates process identities,
nginx worker counts and affinity throughout sampling. A discarded early attempt
had incomplete nginx descendant accounting and is retained only as diagnostic
evidence. Earlier syscall traces without this quiescence gate are also excluded.
[B1, S1]
Exact HTTP 200 and payload hashes are checked before and after every measured
run; aggregate transfer bytes receive a gross sanity check. wrk does not hash
every timed response and counts status errors above 399. All 120 runs passed
these checks, with **77,713,019 completed requests and zero reported errors**.
No completed sample was removed to improve the result. [B1, B2]
## Throughput and repeatability
The table reports median requests/second across all five runs.
| Scenario | Supplied 0.33.7 | Rebuilt 0.33.7 | Final 0.33.8 | nginx |
|---|---:|---:|---:|---:|
| Proxy, 1,040 B | 27,341.75 | 25,350.63 | 29,334.12 | 170,610.16 |
| Proxy, 64 KiB | 20,485.54 | 20,246.28 | 20,735.07 | 35,393.29 |
| Proxy, 1 MiB | 4,428.31 | 4,588.06 | 4,461.19 | 2,730.23 |
| WWW, 1,040 B | 22,535.57 | 22,558.35 | 24,906.20 | 351,605.13 |
| WWW, 64 KiB | 3,960.03 | 3,990.22 | 20,856.67 | 203,709.91 |
| WWW, 1 MiB | 272.40 | 279.80 | 2,833.71 | 12,611.49 |
Gains use `100 × (median final / median reference − 1)`. nginx attainment uses
`100 × median final / median nginx`. [B1, B2]
| Scenario | Gain vs rebuilt | Gain vs supplied | nginx attainment | Paired gain range vs rebuilt |
|---|---:|---:|---:|---:|
| Proxy, 1,040 B | +15.71% | +7.29% | 17.19% | +6.40% to +17.52% |
| Proxy, 64 KiB | +2.41% | +1.22% | 58.58% | −4.54% to +14.40% |
| Proxy, 1 MiB | **−2.77%** | +0.74% | 163.40% | −2.77% to +5.48% |
| WWW, 1,040 B | +10.41% | +10.52% | 7.08% | +1.73% to +23.63% |
| WWW, 64 KiB | +422.70% | +426.68% | 10.24% | +385.45% to +477.68% |
| WWW, 1 MiB | +912.75% | +940.29% | 22.47% | +818.88% to +991.33% |
The five final/rebuilt ratios, in scheduled repetition order, are:
| Scenario | Rep 1 | Rep 2 | Rep 3 | Rep 4 | Rep 5 |
|---|---:|---:|---:|---:|---:|
| Proxy, 1,040 B | 1.0679 | 1.0767 | 1.0640 | 1.1752 | 1.0747 |
| Proxy, 64 KiB | 1.0160 | 0.9546 | 1.0789 | 1.0707 | 1.1440 |
| Proxy, 1 MiB | 0.9723 | 0.9770 | 0.9868 | 1.0548 | 0.9883 |
| WWW, 1,040 B | 1.0173 | 1.0210 | 1.0893 | 1.1092 | 1.2363 |
| WWW, 64 KiB | 5.3555 | 4.8545 | 5.7768 | 5.3480 | 5.2270 |
| WWW, 1 MiB | 10.2897 | 10.5518 | 9.1888 | 10.9133 | 9.2866 |
Five pairs support the large WWW effect's direction and magnitude, but do not
provide a production confidence interval. Proxy 64 KiB has a small headline
improvement with one negative pair. Proxy 1 MiB is slower in four pairs, so the
modest regression remains explicit. For proxy-small, the paired median gain is
7.47%, different from the 15.71% ratio of variant medians; both statistics are
available. Its supplied comparison also contains one negative pair. Do not
average these scenario percentages into a universal speedup. [B2]
## Latency, CPU and resources
Values below are medians of per-run measurements; latency percentiles are not
pooled. Frontend CPU includes its threads and background work. [B2]
| Scenario | p95 ms, rebuilt → final | p99 ms, rebuilt → final | CPU µs/request, rebuilt → final |
|---|---:|---:|---:|
| Proxy, 1,040 B | 4.093 → 3.591 | 5.066 → 4.707 | 147.05 → 127.41 |
| Proxy, 64 KiB | 6.188 → 5.921 | 36.594 → 37.245 | 182.66 → 178.44 |
| Proxy, 1 MiB | 29.206 → 30.222 | 49.315 → 50.747 | 861.28 → 885.37 |
| WWW, 1,040 B | 4.956 → 4.432 | 6.038 → 5.432 | 155.68 → 140.05 |
| WWW, 64 KiB | 27.929 → 5.217 | 35.301 → 6.393 | 657.26 → 171.85 |
| WWW, 1 MiB | 291.222 → 31.234 | 318.346 → 35.295 | 8,388.17 → 1,219.50 |
WWW 64-KiB/1-MiB CPU cost per request falls 73.85%/85.46%, while p95 falls
81.32%/89.27%. Proxy 1-MiB CPU rises 2.80%, p95 rises 3.48% and p99 rises
2.90%. Proxy 64-KiB p99 also rises 1.78%; improvement is not uniform. [B2]
| Scenario | Sampled peak RSS MiB, rebuilt → final | Peak FDs, rebuilt → final |
|---|---:|---:|
| Proxy, 1,040 B | 56.965 → 59.137 | 162 → 162 |
| Proxy, 64 KiB | 68.543 → 69.105 | 160 → 157 |
| Proxy, 1 MiB | 81.359 → 83.684 | 153 → 151 |
| WWW, 1,040 B | 77.441 → 77.727 | 124 → 123 |
| WWW, 64 KiB | 79.043 → 102.156 | 145 → 124 |
| WWW, 1 MiB | 78.473 → 118.664 | 145 → 145 |
Large WWW responses cost **23.113 MiB and 40.191 MiB** additional sampled peak
RSS. Processes persist across scenarios, so this includes allocator/workload
history. The source-derived 7.5-MiB extra reader-capacity example is not total
measured memory cost. Candidate 1-MiB end RSS increases from 114.508 to 118.664
MiB across five repetitions; these short runs do not prove a plateau. End FDs
remain 17–19 for large WWW, without an accumulating signal here. Peaks are
sampled, not absolute bounds; nginx process RSS can double-count shared pages.
[B2, B3]
Only one cgroup throttle event occurs: 7,839 µs during nginx proxy-small; Rust
intervals record none. The nginx proxy-small origin consumes approximately
1.982 of its two CPUs, plausibly limiting that reference. nginx static 64-KiB
uses 2.573 of three generator CPUs. Rust proxy uses roughly 3.70–3.95 frontend
CPUs, with generator/origin aggregate use below their allocations. These bounds
and the shared kernel limit capacity interpretation. nginx static 64-KiB p95
is 34.361 ms despite p50 0.215 ms; its corrected closed-loop histogram is not
a generic nginx latency verdict. [B2, B3]
## Observability under saturation
History still uses best-effort admission. Median rejected-history entries as a
percentage of wrk completions are shown separately from HTTP failures. [B2]
| Scenario | Supplied | Rebuilt | Final | Final minus rebuilt, percentage points |
|---|---:|---:|---:|---:|
| Proxy, 1,040 B | 3.350984% | 3.325181% | 1.290544% | −2.034637 |
| Proxy, 64 KiB | 2.231502% | 2.203964% | 0.780042% | −1.423922 |
| Proxy, 1 MiB | 0.763138% | 0.765001% | 0.221602% | −0.543399 |
| WWW, 1,040 B | 3.908337% | 3.851348% | 1.504784% | −2.346564 |
| WWW, 64 KiB | 0.707405% | 0.700592% | 1.230427% | **+0.529835** |
| WWW, 1 MiB | 0.114600% | 0.120948% | 0.165351% | **+0.044403** |
For WWW 64 KiB, every final fraction exceeds its paired baseline at 5.23-times
throughput. For 1 MiB, four fractions increase at 10.13-times throughput. These
are saturation comparisons, not fixed-arrival-rate experiments. The increases
must remain visible; this release does not guarantee zero history rejection.
History rejection does not itself reject live broadcast or external sinks. [B2,
F1]
Analytics queue drops are zero in all Rust samples. Sink, error-file and
cold-cache queue drops are also zero, with those paths disabled in this primary
configuration; this does not prove their active throughput. Asynchronous
recorded analytics counts differ from wrk completions. Status/traffic snapshots
bracket load, postflight and possible in-flight work, making rate denominators
approximate. Raw counters, every repetition and aggregate weighted fractions are
retained; they are not presented as exact loss probabilities. [B1, B2]
## Functional verification and inherited defects
| Verification layer | Final result | Evidence |
|---|---|---|
| Rust suites | **706 passed**, zero failed, one optional ID timing microbenchmark ignored | F1 |
| Actual HTTP process regression | **152/152 cases passed** | F2 |
| Actual TLS/H2/H3/QUIC and H1 WebSocket tests | **111/111 assertions passed** | F3 |
| Two-origin LB behaviour | **17/17 cases passed** | F2 |
| Finite concurrency | **18 cases, 1,920 exact responses**, zero failures | F2 |
| Separate syscall experiment | **1,042 exact response checks** | P1 |
The suites protect cache OFF/ON, privacy, Vary/scheme separation, purge barriers,
range/conditional/HEAD, compression/transforms, ACL/SSRF policy, administration
session/CSRF, streaming uploads, cancellation, non-replayed writes and leases
through EOF. History tests check exact fields, eviction accounting and real
file/live delivery during forced contention. SQL and disposable stopped-source
upgrade checks pass; UI checks remain simulated API tests. PHP coverage uses a
deterministic FastCGI peer, not a deployed PHP-FPM application. [F1–F4]
Both original executables reproduced three live WWW failures:
`observability.static_complete_after_content_length`, `cache.static_hit` and
`cache.static_empty_hit`. All three pass on the final binary; the final expanded
original matrix retained 149 passes and those three failures. Precise local EOF
and initially empty-body handling restore complete-response accounting and cache
publication. Separately, the 22 original Rust failures were audited as six real
production defects, one missing embedded document and fifteen stale/incomplete
fixtures. Corrections retain privacy and invalidation guarantees. [F2, F4]
The syscall experiment traced 500 measured static 64-KiB requests per binary,
plus warm-up/post-detach checks. Selected calls decreased from 60,374 to 7,502
(87.57%); `read` decreased from 8,135 to 501. All 1,042 responses were exact,
and both traced processes detached and stopped cleanly. This supports a reduction
in observed system-call work; it is not an 87.57% throughput gain, allocation
profile or per-edit attribution. [P1]
## Completed 30-minute mixed-traffic soak
The final executable completed **1,800.000810 seconds** of paced HTTP/1.1
proxy/static traffic at an offered 100 requests/s. All **180,000 workload
operations passed**, with zero response errors and zero scheduler-lateness
events. This includes **1,782 intentional client closes after 128 bytes**;
the remaining **178,218 operations** checked the complete expected response
body and status, including HEAD and ranges. A controlled close does not prove
that the server had not already sent the rest of the body. Every operation used
a new frontend TCP connection; this is not a long-lived keep-alive soak. [F5]
All **14 cache toggles** passed **32 source/mode checks comprising 64 additional
GETs**, covering real HIT behaviour after enabling and bypass after disabling.
The fixture restored its base configuration, completed a 15-second cooldown,
and verified **backend active exchanges = 0**. All four recorded server/origin
processes and the disposable fixture directory were absent after teardown.
The final binary hash remained unchanged. [F5]
| Observation | Final soak result |
|---|---|
| Analytics queue rejects | **0**; recorded-request delta **180,060**, including post-baseline cache probes and cooldown |
| History admission rejects | **31 observed**, from counter 0 at 0.001 s to 31 at 1,798.842 s; this counter was not resampled after cooldown |
| Upstream pool recycling | **1 observed rotation**; the normal bounded recycling policy remained active |
| Sampled RSS peak | **60.641 MiB** |
| RSS median, first minute after warm-up to final sampled minute | **52.273 → 60.016 MiB**, a **7.742 MiB increase** |
| Open file descriptors | **20 at start, 23 sampled peak, 18 after cooldown** |
The final sampled minute includes the cooldown. RSS growth and the descriptive
linear fit of approximately 16.165 MiB/hour are observations over this short,
cache-toggling run, not an extrapolation or a diagnosis of a leak. This test
**does not establish a memory plateau**. There was no accumulating FD signal,
but low-rate correctness and resource observations do not establish stability
at the much higher saturation rates in the primary benchmark. There is no
matched baseline soak from which to attribute this RSS change to the patch.
The RSS/FD samples, both telemetry series and the independent interpretation
are retained. No 24-hour, TLS/H2/H3 or production-traffic soak was executed. [F5]
## Reproduction and deployment boundary
Use project entry points with the private umask they establish:
```bash
./dev.sh check
CARGO_INCREMENTAL=0 ./dev.sh test
./dev.sh release
```
Set the executable/tool variables to reviewed absolute paths and use a fresh
results directory. Build the untouched baseline with the same toolchain/lock
before comparing it with the candidate:
```bash
python3 -B scripts/performance/benchmark.py \
--binary "supplied=$SUPPLIED_BINARY" --binary "before=$REBUILT_BASELINE" \
--binary "after=$CANDIDATE_BINARY" --nginx "$NGINX_BINARY" --wrk "$WRK_BINARY" \
--out "$RESULTS_DIR" --workers 4 --origin-workers 2 \
--server-cpus 0-3 --origin-cpus 4-5 --client-cpus 6-8 --client-threads 4 \
--concurrency 64 --modes proxy,static --sizes small,large,huge \
--repeats 5 --duration 15 --warmup 3
```
See `scripts/performance/README.md` for dependencies and separate regression,
transport, profile and soak commands. The offline analyzer refuses incomplete
or unsealed data. Its outputs include `comparisons.csv`, `paired-ratios.csv`,
`variants.csv`, `observability.csv` and `observability-paired.csv`. [B2]
Remaining acceptance includes remote-network latency/loss, actual PHP-FPM,
Tinyauth and DNS infrastructure, H2/H3 WebSocket tunnels, deployment-specific
24-hour testing and staged rollout. No production deployment or service-manager
rollback was executed. The detailed script-specific upgrade/rollback review is
`verification/release-scope-and-rollback.txt`: current `update.sh` retains two
installed releases and three pre-update backups, conflicting with the older
installation document's no-pruning statement. Keep an independent recovery copy
outside automatic retention and restore matching code/state when needed. [S1]
## Evidence index
| ID | Workspace-relative evidence |
|---|---|
| B1 | `verification/benchmark-primary/`: `runs.json`, `schedule.json`, `environment.json`, `completion.json`, fixture configurations, raw responses/resources/telemetry and `SHA256SUMS` |
| B2 | `verification/analysis/benchmark-primary/analysis.json`, companion CSV/text files, and `verification/analysis/primary-review.txt` |
| B3 | `verification/static-primary-resource-review.txt` and `verification/primary-teardown-audit.json` |
| F1 | `verification/build/final-tests-summary.json`, `dev-test-final.log`; source `docs/TEST_REPORT.md` |
| F2 | `verification/finalrelease-summary.json`; `http-release/`, `lb-release/`, `concurrency-release/` reports beneath `verification/` |
| F3 | `verification/transport-release/release/report.json`; `verification/static-compatibility-audit.txt` |
| F4 | `verification/baseline-failure-audit.txt`; `verification/source_checks/results.json` |
| F5 | `verification/soak-release/`; `verification/soak-release-review.json` and `.txt`; `verification/soak-process-teardown.json` |
| P1 | `verification/syscalls-release/` and `verification/syscalls-release-analysis.txt` |
| S1 | `verification/release-scope-and-rollback.txt`; supplied `AGENT_PERFORMANCE_EXECUTION_PLAN.md`; implementation references listed in that review |
+40 -403
View File
@@ -1,417 +1,54 @@
# PHP-FPM / FastCGI for the Web Server
# PHP-FPM with Web Services
**Separate pools for distinct Web Services:** Reusing the same PHP-FPM socket
with different `PHP_ADMIN_VALUE` maps can leak `open_basedir` restrictions into
a different virtual host. See the 0.29.7 guide
[PHP_FPM_ISOLATION.md](PHP_FPM_ISOLATION.md) for remediation and tests.
RustProxy's Web Server supports **FastCGI** forwarding to an existing **PHP-FPM** pool for configured script extensions. RustProxy does not include a PHP interpreter or run the PHP application itself.
RustProxy 0.25 can execute configured PHP extensions through a long-running PHP-FPM pool while preserving the reusable Web Service architecture.
## Before enabling PHP
## Architecture
Install and start PHP-FPM independently, configure the application files and create a **dedicated pool/socket for each application or security domain**. Set the correct Unix user/group, socket permissions, PHP version and pool-level configuration. Restrict a TCP FPM endpoint to a trusted private interface; never expose FastCGI directly to the Internet.
In **Web Server → PHP-FPM profiles** create a reusable profile, or set PHP options in one Web Service. Select a socket such as `unix:/run/php/app.sock` or a reachable TCP endpoint (use the syntax accepted by the editor); enable PHP, choose extensions (default `.php`) and set request/connect timeouts and concurrency. Attach the profile to a Web Service and publish that service using a Vhost.
## Important settings
| Setting | Effect |
|---|---|
| **Endpoint** | Unix socket or private TCP address of a real PHP-FPM service. |
| **Extensions** | Which files are eligible for PHP execution. Confirm front-controller routes and direct PHP paths. |
| **`try_files` / PATH_INFO** | Resolve PHP front-controller or `script.php/...` paths, where supported, without accidentally executing an unintended file. |
| **Connect/request timeouts** | Bound waiting for FPM connection and application execution. Defaults in source include 1 s connect and 30 s request. |
| **Concurrent requests** | Local RustProxy FastCGI request limit (default 64), **not** the number of FPM worker processes. |
| **Authorization forwarding** | Optional preservation of eligible `Authorization` information (default enabled). Review application expectations. |
| **Custom FastCGI parameters** | Static application-specific settings. Reserved transport/identity parameters cannot be overridden arbitrarily. |
| **PHP values** | `PHP_VALUE` and `PHP_ADMIN_VALUE` options for the external FPM workers. |
| **Body/header limits** | Bound upload size, response headers and stderr exposed in logs. |
## Pool isolation is mandatory for mixed applications
PHP-FPM workers can retain `PHP_VALUE` / `PHP_ADMIN_VALUE` behavior across requests. **Two applications with different INI/admin settings must not share one FPM worker pool**, even when they have different RustProxy Vhosts. RustProxy may warn about conflicting shared endpoints but cannot isolate PHP workers inside FPM.
Example separation:
```text
Request
-> existing listener / vhost / location / ACL / auth
-> Web Server -> Web Service
|- static file
`- configured PHP extension -> FastCGI -> PHP-FPM
-> existing error/header/logging/metrics/transport pipeline
Web Service: blog -> unix:/run/php/php_blog.sock
Web Service: webmail -> unix:/run/php/php_webmail.sock
```
Vhosts and locations still reference only a Web Service. Each Web Service may
use local PHP settings or reference a shared `php_fpm_profiles` entry from the
normal revisioned configuration; its local settings are retained on detach.
Give each pool access only to its own application's files; configure `open_basedir` and other hardening at FPM pool level where possible. Restart the affected FPM pools after policy changes to retire existing workers.
### Shared PHP-FPM profiles (0.29.4)
## WordPress and Roundcube
In **Web Server → Edit Web Service → PHP-FPM**, select **Local configuration**
or a named profile. **Manage PHP-FPM profiles** opens a modal with an expandable
editor for every profile and an **Add profile** action. A profile contains the
same PHP execution, FastCGI and PHP_VALUE/PHP_ADMIN_VALUE settings as local
configuration. Changes to a shared profile affect all attached services after
validation and apply. In-use profiles cannot be deleted.
For **WordPress**, point the document root to the website directory, enable PHP for `.php`, use the appropriate front-controller (`index.php`) and verify that `wp-config.php` exists and is readable only where needed. A redirect to `setup-config.php` can indicate filesystem/FPM/open_basedir misconfiguration rather than proxy cache.
Configuration example:
For **Roundcube**, use its published web root and a separate FPM pool with the application's expected file access. Some Roundcube static script URLs use `PATH_INFO`; enable/test handling that resolves `static.php/...` without turning arbitrary strings into executable script names.
```json
{
"php_fpm_profiles": [{
"id": "php82", "name": "PHP 8.2 FPM",
"php": {"enabled": true, "endpoint": "unix:/run/php/php8.2-fpm.sock"}
}],
"web_services": [{
"id": "site", "name": "Site", "document_root": "/var/www/site",
"php_profile_id": "php82"
}]
}
```
These are routing examples; presets do not install either application or choose a safe OS-specific pool automatically.
Unspecified PHP fields inherit the safe serde defaults. A service without
`php_profile_id` uses its own `php` object as in earlier versions.
## Diagnostics
## Where to configure each PHP setting (0.29.7)
- `502/503/504`: check FPM health, Unix socket group permissions, endpoint path, capacity and configured timeouts.
- PHP errors/blank page: check FPM stderr, application logs and effective INI values.
- Incorrect site appears to inherit another site's `open_basedir`: use distinct FPM pools and restart them.
- Compressed PHP content truncated: verify current application response/framing and try the same request without compression; review FastCGI and response logs.
- Cache behavior differs from static files: PHP may ignore validators or send `private` / `Set-Cookie`, preventing shared cache storage.
Both editors expose the **same fields and validation**:
1. **Web Server → Web Service → PHP-FPM → Local configuration:** set values on one service (`web_services[].php`). Select **Local configuration** in the profile selector first.
2. **Web Server → PHP-FPM profiles → Add/Edit profile:** set shared values on a profile (`php_fpm_profiles[].php`), then attach it in a Web Service's PHP-FPM tab (`php_profile_id`). Editing the profile changes every attached service. Per-service `php` values remain saved for later detach but do **not** override an attached profile.
3. **Actual PHP-FPM pool config** (`php-fpm.d/*.conf` or distribution equivalent): the pool must exist and listen on the configured socket. Isolate restrictive `open_basedir`/`php_admin_value` settings in *separate pools and sockets*, not by alternating request-time `PHP_ADMIN_VALUE` on shared workers. RustProxy does not launch PHP-FPM.
| UI field (JSON property) | What to enter / sample | Notes |
| --- | --- | --- |
| Enable PHP-FPM / FastCGI (`enabled`) | Checked for PHP apps | Off means PHP is not executed. Never serve PHP source. |
| PHP-FPM socket (`endpoint`) | `unix:/run/php/php_wordpress.sock`, `tcp:127.0.0.1:9000`, `tcp:[::1]:9000` | Only local Unix sockets or TCP loopback **IP**, not hostnames. Socket ownership and permissions must allow RustProxy access. |
| PHP extensions (`extensions`) | `php, phtml` | Comma-separated, no leading dots; at most 16. Only listed extensions are executable. |
| Enable PHP PATH_INFO splitting (`path_info`) | Checked for Roundcube `/static.php/skins/...` | Splits an existing PHP script path and its trailing URL; off for ordinary WordPress unless needed. |
| Forward Authorization (`pass_authorization`) | Checked when API / Basic / Bearer auth is used | Sends request `Authorization` to `HTTP_AUTHORIZATION` in FastCGI. |
| Connection timeout (`connect_timeout_ms`) | `1000` = 1 second | Integer 50–60000 milliseconds. |
| Request timeout (`request_timeout_ms`) | `30000` = 30 seconds | Integer 100–3600000 milliseconds for the full FastCGI request. |
| Max simultaneous PHP requests (`max_concurrent_requests`) | `64` | Integer 1–4096 per Web Service; excess is rejected with HTTP 503. |
| Request-body limit (`max_request_body_bytes`) | `67108864` = 64 MiB; `0` = no additional limit | Integer 0–8589934592 **bytes**, independent of PHP `post_max_size`. |
| Response-header limit (`max_response_header_bytes`) | `65536` = 64 KiB | Integer 4096–1048576 bytes. |
| PHP-FPM STDERR log limit (`stderr_log_bytes`) | `4096`; `0` = disabled | Integer 0–65536 bytes collected per request. |
| Custom FastCGI parameters (`custom_params`) | `APP_ENV = production` | One static `NAME = value` per line. Names A-Z/0-9/underscore, beginning with a letter. No core CGI, HTTP_* or PHP control params. |
| PHP INI overrides (`php_values` / `PHP_VALUE`) | `memory_limit = 256M` | One php.ini `directive = value` per line. PHP decides which directives can change at this scope. |
| Administrator PHP INI overrides (`php_admin_values` / `PHP_ADMIN_VALUE`) | `open_basedir = /var/www/webmail/:/tmp/` | Trusted operator only; for security isolation prefer dedicated PHP-FPM pool configuration. Do not define the same directive in both INI maps. |
**Syntax in the three multiline fields is not JSON:** use `key = value`, a separate line per entry; **do not** wrap it in `PHP_VALUE=...` or `php_admin_value[...]` inside the RustProxy editor. Those latter directives belong to the *native PHP-FPM pool configuration*, not the RustProxy FastCGI request fields. Empty values and control characters are rejected. Up to 64 entries per map, maximum 8192 bytes per value and 128 KiB combined.
### Ready-to-use WordPress and Roundcube examples
**WordPress — Web Service `/var/www/linuxiarz.pl/`, dedicated FPM socket:**
```text
PHP enabled: checked
PHP PATH_INFO: unchecked
Endpoint: unix:/run/php/php_wordpress.sock
PHP extensions: php
PHP_VALUE:
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 60
PHP_ADMIN_VALUE: leave empty (or set security restrictions in WordPress's own FPM pool)
```
Use `index.php` in **Index files**, plus `try_files` lines `$uri`, `$uri/`, `/index.php?$query_string` in **Routing**. The WordPress pool must be allowed to read its `wp-config.php`, `wp-includes` and `wp-content` directories.
**Roundcube — Web Service `/var/www/webmail/public_html`, separate FPM socket:**
```text
PHP enabled: checked
PHP PATH_INFO: checked
Endpoint: unix:/run/php/php_roundcube.sock
PHP extensions: php
PHP_VALUE:
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
PHP_ADMIN_VALUE: prefer the native Roundcube PHP-FPM pool instead; e.g.:
open_basedir = /var/www/webmail/:/usr/lib/php/:/tmp/:/etc/enigma
```
Add additional permitted external paths only if Roundcube's actual dependencies need them. Roundcube assets such as `/static.php/skins/elastic/...` require PATH_INFO splitting. Restart/recycle workers after separating pools; using a different RustProxy profile but the **same socket** does not isolate the worker state. See [pool isolation](PHP_FPM_ISOLATION.md).
### FastCGI vs php.ini examples
```text
# Custom FastCGI parameters — application-specific static environment:
APP_ENV = production
APP_DEBUG = 0
# PHP_VALUE — regular PHP-level options:
memory_limit = 256M
max_execution_time = 60
error_reporting = E_ALL
log_errors = On
display_errors = Off
date.timezone = Europe/Warsaw
# PHP_ADMIN_VALUE — privileged operator-level options:
open_basedir = /var/www/webmail/:/usr/lib/php/:/tmp/
```
The `#` comments above are explanations separating fields; **do not paste them** into the corresponding form inputs. Boolean INI directives commonly accept `On`/`Off`, size directives can accept `K`/`M`/`G` and timeouts use seconds. The actual PHP/FPM version determines supported directives and permitted scopes.
## Runtime model
RustProxy implements the FastCGI protocol directly. It does not spawn `php`, `php-cgi` or shell processes for individual requests.
Supported endpoint syntax:
```text
unix:/run/php/php-fpm.sock
tcp:127.0.0.1:9000
tcp:[::1]:9000
```
Use the `unix:` prefix for a Unix-domain socket. Use `tcp:` followed by a literal loopback IP address and port for TCP. Hostnames and non-loopback addresses are rejected. Unix sockets are preferred for a local PHP-FPM installation; exposing FastCGI directly to an untrusted network is not supported by this configuration.
## Web Service configuration
```json
{
"index": ["index.html", "index.php"],
"try_files": ["$uri", "$uri/", "/index.php?$query_string"],
"php": {
"enabled": true,
"extensions": ["php"],
"endpoint": "unix:/run/php/php-fpm.sock",
"connect_timeout_ms": 1000,
"request_timeout_ms": 30000,
"max_concurrent_requests": 64,
"pass_authorization": true,
"max_request_body_bytes": 0,
"max_response_header_bytes": 65536,
"stderr_log_bytes": 4096,
"custom_params": {
"APP_ENV": "production"
},
"php_values": {
"memory_limit": "256M",
"upload_max_filesize": "64M",
"post_max_size": "64M"
}
},
"symlink_policy": "deny"
}
```
The normal ordered `index` list handles `index.php`; there is no separate PHP-index option.
### Additional PHP/FastCGI options
- `path_info`: default-off secure script/path splitting. When enabled, `/index.php/foo/bar` may resolve the existing `index.php` script and pass `/foo/bar` as `PATH_INFO`.
- `pass_authorization`: forwards the inbound `Authorization` header as `HTTP_AUTHORIZATION`. Disable it when the application does not need that header.
- `max_request_body_bytes`: streaming request-body limit enforced before/during FastCGI forwarding. `0` means no additional RustProxy-side limit. A request exceeding the configured value receives `413 Payload Too Large`.
- `max_response_header_bytes`: maximum bytes accepted before the CGI response-header terminator. The allowed range is 4096..1048576 bytes.
- `stderr_log_bytes`: maximum PHP-FPM stderr bytes retained for one diagnostic request entry. `0` disables stderr capture; maximum 65536.
- `custom_params`: trusted static FastCGI parameters supplied by the operator.
- `php_values`: php.ini-style overrides encoded into one validated FastCGI `PHP_VALUE` parameter.
### Custom FastCGI parameter syntax
In the administration UI use one entry per line:
```text
APP_ENV = production
APP_DEBUG = 0
TENANT = main
```
Names are normalized to uppercase and are limited to letters, digits and `_` (the first character must be a letter). Values are static: there is no request-variable interpolation in this field. RustProxy rejects core CGI parameters, `HTTP_*`, `PHP_VALUE` and `PHP_ADMIN_VALUE`, so custom values cannot replace `SCRIPT_FILENAME`, routing data, HTTP-derived fields or PHP control parameters. Up to 64 custom FastCGI parameters and 64 PHP INI overrides are accepted, with at most 128 KiB of combined custom parameter/value data.
### PHP INI (`PHP_VALUE`) syntax
Use one `directive = value` entry per line:
```text
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 30
```
RustProxy validates the lines and combines them with newline separators into the FastCGI `PHP_VALUE` parameter. PHP-FPM/PHP still decides whether a directive is changeable at this scope. `PHP_ADMIN_VALUE` is available separately through the validated administrator INI overrides for trusted PHP-FPM endpoints. Control characters/newlines inside names or values are rejected.
These custom parameters are stored as normal Web Service configuration. Do not put secrets there unless normal configuration storage is an acceptable location for them.
## `try_files` front controller
A typical application can use:
```text
$uri
$uri/
/index.php?$query_string
```
Existing static files/directories are used first. The final path becomes the fallback and can therefore reach `index.php`. This resolution remains inside the already-selected Web Service and does not introduce a second vhost router.
## Roundcube 1.7 static.php / PATH_INFO (0.29.5)
Roundcube serves CSS, JavaScript and images through URLs such as
`/static.php/skins/elastic/styles/styles.min.css?s=123`. These paths are
**PHP-FPM requests to the existing** `public_html/static.php` **script**, not
static filesystem lookups. Configure the selected Web Service (or attached
shared PHP-FPM profile) with `php.enabled: true`, `php.path_info: true`, and
`php.extensions: ["php"]`. Point its root to the Roundcube `public_html` tree.
You can keep the normal Web Service `try_files` (`$uri`, `$uri/`,
`/index.php?$query_string`). RustProxy 0.29.5 resolves a valid PHP script plus
PATH_INFO after security checks and rewrites, **before** falling back through
`try_files`. No regex rewrite or extra Web Service is required for these URLs
when the main vhost selects this Web Service.
For `/static.php/skins/elastic/images/favicon.ico?s=123`, FastCGI receives:
```text
SCRIPT_NAME=/static.php
SCRIPT_FILENAME=<document_root>/static.php
PATH_INFO=/skins/elastic/images/favicon.ico
REQUEST_URI=/static.php/skins/elastic/images/favicon.ico?s=123
QUERY_STRING=s=123
```
PHP-FPM still needs filesystem access to `static.php` and the Roundcube
installation outside `public_html` that script reads. The PHP `PATH_INFO`
option is explicit and remains off by default for unrelated services.
## Bounded execution
Each compiled Web Service owns a bounded PHP concurrency semaphore. `max_concurrent_requests` limits simultaneous FastCGI requests. RustProxy does not create an unbounded wait queue: if no execution slot is immediately available it returns `503 Service Unavailable` with `Retry-After`.
Connection establishment and complete FastCGI request execution have independent configured time limits. A connect failure produces a gateway error; a request timeout produces a gateway timeout. The outer normal RustProxy error-page processing remains able to replace these responses.
Request bodies are split into bounded FastCGI records rather than collected into one large buffer. When `max_request_body_bytes` is non-zero, the limit is checked both from `Content-Length` when available and while streaming chunked/unknown-length bodies. PHP-FPM stdout is parsed and delivered through a bounded response channel so application output can stream to the client.
## Secure script selection
Only configured extensions are executable. Before PHP is selected, RustProxy resolves the request/`try_files`/index path using the Web Server filesystem confinement rules and verifies that it is a regular file below the document root.
When PHP handling was selected:
- PHP source is never returned as a fallback;
- an unavailable PHP-FPM endpoint produces 502/504 behavior rather than source disclosure;
- encoded traversal/separators and `..` remain rejected;
- the current implementation requires `symlink_policy=deny`.
The last restriction is deliberate: PHP-FPM opens `SCRIPT_FILENAME` independently, so RustProxy does not claim an `inside_root` symlink guarantee that the external process cannot enforce atomically.
## FastCGI parameters
RustProxy supplies a controlled CGI/FastCGI parameter set including:
- `REQUEST_METHOD`
- `SCRIPT_FILENAME`
- `SCRIPT_NAME`
- `PATH_INFO` when `path_info` is enabled and a trailing path was securely split from an existing PHP script
- `QUERY_STRING`
- `REQUEST_URI`
- `DOCUMENT_ROOT`
- `SERVER_PROTOCOL`
- `SERVER_NAME`
- `SERVER_PORT`
- `REMOTE_ADDR`
- `REMOTE_PORT` when available
- `HTTPS`
- `GATEWAY_INTERFACE`
- `SERVER_SOFTWARE`
- `REDIRECT_STATUS`
- `CONTENT_TYPE`
- `CONTENT_LENGTH`
- selected request headers as `HTTP_*`
Request headers cannot become FastCGI `PHP_VALUE` or `PHP_ADMIN_VALUE` parameters. Operator-defined `custom_params` are allowed only through the validated Web Service configuration and cannot override core CGI/HTTP-derived fields. Operator-defined `php_values` are the only path that creates `PHP_VALUE`; `PHP_ADMIN_VALUE` is never generated.
An inbound `Proxy` header is **never** translated into FastCGI `HTTP_PROXY` (httpoxy mitigation), regardless of the optional Web Service hardening switch. The **Block common attack probes** option additionally blocks high-confidence sensitive/probe paths before PHP-FPM dispatch. FastCGI output cannot control downstream `Content-Length`, transfer framing, connection headers or `Proxy-*` transport headers; RustProxy owns downstream response framing.
`PATH_INFO` splitting is optional and disabled by default. When enabled, RustProxy searches for an existing configured PHP script prefix inside the document root, keeps `SCRIPT_FILENAME`/`SCRIPT_NAME` on that script, and supplies only the remaining suffix as `PATH_INFO`. `REQUEST_URI` remains the original request target.
## Response parsing
The FastCGI stdout parser understands standard CGI response headers, including `Status`. Hop-by-hop transport headers are stripped before returning the response through RustProxy.
For `HEAD`, PHP-FPM can still execute normally but the client response body is empty. GET/POST/PUT and other methods can reach a PHP script once routing resolves to a configured executable extension.
PHP stderr is bounded by `stderr_log_bytes` and recorded as a RustProxy `WARN` diagnostic. It goes to the configured error file when enabled, or the console/systemd journal otherwise; it is not sent to the client as page content. CGI response headers are separately bounded by `max_response_header_bytes`.
## Caching
PHP output does not use static file validators or the static-file OS representation path. Application response headers and the normal vhost/location response-header pipeline control client/intermediary caching.
The Web Service static `cache_rules` apply to local static file responses, not
to PHP stdout. When global/vhost caching is enabled, explicitly cacheable
PHP-FPM responses may enter the shared RustProxy cache; responses with
`Set-Cookie`, `Cache-Control: private` or `no-store` cannot be stored. In
particular, do not enable forced caching of authenticated WordPress pages
without auditing the origin response policy. HTTP/2 and HTTP/3 `:authority`
requests populate the `HTTP_HOST` FastCGI parameter, retaining the original
host/port from the incoming request when provided.
## PHP installation
RustProxy does not bundle PHP. Install and maintain PHP-FPM using the operating system/package ecosystem and enable only the extensions required by the application.
Operational requirements:
- a running PHP-FPM 8.x service supported by the operator's platform;
- a dedicated Unix socket or loopback TCP listener reachable by the RustProxy service account;
- document-root permissions usable by both RustProxy and the PHP-FPM worker identity as intended;
- correct socket owner/group/mode when using Unix sockets;
- PHP-FPM limits such as `pm.max_children`, application execution timeout and upload/body limits sized consistently with the RustProxy concurrency limit.
Extensions such as PDO drivers, mbstring, intl, gd, curl or opcache are application dependencies, not RustProxy dependencies.
## Example PHP-FPM pool concept
The exact paths/users depend on the operating system. For production, use a **separate PHP-FPM worker identity per site/application**. Never run PHP-FPM as `root` or as the `rustproxy` service account. A compromised CMS/plugin then must not inherit access to RustProxy's SQLite database, Secret Store, certificate private keys or backups.
A local pool can expose a socket such as:
```ini
user = site_example
group = site_example
listen = /run/php/rustproxy-example.sock
listen.owner = www-data
listen.group = rustproxy
listen.mode = 0660
```
The exact socket owner may differ by distribution; the important properties are that the PHP worker user is **not** `rustproxy`, and only RustProxy plus the pool manager can reach the socket. Configure the Web Service endpoint as:
```text
unix:/run/php/rustproxy-example.sock
```
Keep `/var/lib/rustproxy` mode/private ownership inaccessible to every PHP pool. Prefer separate writable upload/data directories from executable PHP source, and prevent the application from creating executable PHP files in upload areas. A CMS-level arbitrary file upload or vulnerable plugin can otherwise become PHP code execution even when the reverse proxy itself is correct.
Do not expose a PHP-FPM TCP listener to the public network.
## Verification targets
The implementation includes source/unit coverage for:
- PHP front-controller resolution through `try_files`;
- request body delivery to a FastCGI fixture;
- FastCGI response parsing;
- connection failure returning a gateway error without PHP source disclosure;
- bounded endpoint/time/concurrency configuration validation.
Release-host acceptance should additionally exercise a real PHP-FPM runtime over Unix socket and loopback TCP and verify ACL/auth/custom errors/headers plus H1/H2/H3 client transports.
## Per-Web-Service PHP administrative INI overrides (0.29.2)
`php.php_admin_values` is a validated operator-managed string map sent in a single
`PHP_ADMIN_VALUE` FastCGI parameter. Example:
```json
"php": {
"enabled": true,
"path_info": true,
"endpoint": "unix:/run/php/php-fpm-mail.sock",
"php_admin_values": {
"open_basedir": "/var/www/webmail/:/tmp/",
"display_errors": "Off"
}
}
```
Use separate Web Services for distinct per-location PHP settings, then assign those
services to the corresponding Custom Locations. Each Web Service can refer to the
same trusted PHP-FPM socket. The setting does **not** isolate worker process
identities or file permissions. Never expose PHP-FPM FastCGI to untrusted clients.
HTTP headers cannot supply `PHP_ADMIN_VALUE`, and `php_values` must not repeat
an admin directive. Control characters and excessive values are rejected.
Prometheus metrics use only configured Web Service IDs, never script paths,
URLs, PHP values, or dynamic user labels. `rust_proxy_php_requests_total` and
`rust_proxy_php_responses_total{class=...}` count responses after headers have
been received (including proxy-generated failures); the cumulative header
latency metric intentionally excludes streamed body time.
The same snapshot also exports `rust_proxy_web_server_requests_total`,
`rust_proxy_web_server_responses_total{class=...}` and
`rust_proxy_web_server_response_header_duration_microseconds_total` for all
Web Server requests (PHP and non-PHP).
Inspect **Web Server → Live metrics**, [Logs](LOGS.md) and the application's own FPM logs. The proxy's PHP request semaphore is not a replacement for monitoring external FPM worker capacity.
-31
View File
@@ -1,31 +0,0 @@
# Dedicated PHP-FPM pools for distinct Web Services
PHP-FPM may retain request-supplied `PHP_VALUE` and `PHP_ADMIN_VALUE` directives in long-lived workers. When two applications use one PHP-FPM socket but different INI overrides, a setting such as `open_basedir` can be inherited by the next request, even when RustProxy routes the request to the correct Web Service. **RustProxy cannot provide process-level isolation inside a shared external PHP-FPM worker.**
## Affected operator configuration (0.29.6)
- WordPress Web Service `blog-linuxiarz-pl` uses document root `/var/www/linuxiarz.pl/` and endpoint `unix:/run/php/php_rustproxy.sock`, without an `open_basedir` override.
- Roundcube Web Service `webmail-linuxiarz-pl` uses document root `/var/www/webmail/public_html` and the **same** endpoint. Its FastCGI `PHP_ADMIN_VALUE` includes `open_basedir=/var/www/webmail/:/usr/lib/php/:/tmp/:/etc/enigma:...`.
- WordPress PHP-FPM stderr confirms the Roundcube `open_basedir` at the WordPress file check. As a result, `wp-config.php` is not visible and WordPress redirects to `/wp-admin/setup-config.php` before triggering fatal errors. This is **not** a cache MISS defect.
## Required operator remediation
1. Back up the current FPM configuration. Provision separate, access-restricted PHP-FPM pools under the PHP-FPM service manager used by your system (NixOS configuration when applicable). Use distinct Unix sockets, for example `/run/php/php_wordpress.sock` and `/run/php/php_roundcube.sock`. Ensure RustProxy's runtime user may connect to both. Do **not** expose raw FastCGI publicly.
2. Configure pool-level `php_admin_value[open_basedir]` for **Roundcube only**, with exactly the filesystem paths it requires, or keep its validated override in RustProxy on a dedicated pool. Keep WordPress in its own pool with appropriate policy. Pool-level directives are preferable to request-supplied administrator directives for security-sensitive settings.
3. In **Web Server > Web Services** set `blog-linuxiarz-pl > PHP-FPM endpoint` to `unix:/run/php/php_wordpress.sock` and `webmail-linuxiarz-pl` to `unix:/run/php/php_roundcube.sock`. Shared PHP-FPM profiles must also refer to the corresponding correct endpoint.
4. Apply PHP-FPM configuration and **restart** the affected PHP-FPM service/pools to retire workers with leaked directives. Save the RustProxy Web Service changes. Check that both web apps respond and that private files remain blocked.
5. Repeat requests in alternating order from both vhosts and inspect PHP-FPM warnings. Suggested tests: `curl -I https://blog.linuxiarz.pl/`, `curl -I https://blog.linuxiarz.pl/wp-login.php`, `curl -I https://webmail.linuxiarz.pl/`. The WordPress home page must no longer redirect to `/wp-admin/setup-config.php` due to `open_basedir`.
If WordPress still redirects to setup-config.php with a **dedicated** pool, check whether `wp-config.php` really exists and is readable by the pool user, the configured pool's own php_admin_value restrictions, and database credentials. Do not fix the issue by using unrestricted `open_basedir=/` globally or by disabling protections on Roundcube.
## New 0.29.7 protection
During configuration compilation, RustProxy emits a warning identifying pairs of Web Services sharing an endpoint with unequal `PHP_VALUE`/`PHP_ADMIN_VALUE` maps. For backward compatibility, the existing configuration remains loadable; the warning **does not** automatically rewrite operator FPM configuration or claim that sharing becomes safe.
## Per-backend health
For an internal 404 response origin, leave `pool_eligible: false`, set `health_check_enabled: false`, and continue routing a named `internal` location to its `backend_id`. If a backend does require separate probes, use an optional per-member `health_check` object (same shape as vhost `load_balancing.health_check`).
## HIT-only cache diagnostic headers
Enable global `cache.expose_only_hits: true`, optionally override each vhost at `vhosts[].cache.expose_only_hits`. HIT-only mode filters RustProxy diagnostic cache headers for MISS/BYPASS without altering internal hit/miss metrics or upstream cache eligibility. `Via` is a separate proxy-header option and can be disabled using `cache.via_header: null`.
-241
View File
@@ -1,241 +0,0 @@
# Dedicated Prometheus endpoint
RustProxy exposes Prometheus text exposition 0.0.4 on a dedicated read-only
listener. It is independent from administrator browser sessions and does not
export raw client IPs, request paths, query strings, full User-Agent strings, cookies,
credentials, backend URLs or ACME account secrets.
## Configuration
```json
{
"prometheus": {
"enabled": false,
"listen": "127.0.0.1:9191",
"path": "/metrics",
"allowed_cidrs": ["127.0.0.0/8", "::1/128"],
"bearer_token_env": null,
"allow_trusted_network_without_token": false,
"include_system": true,
"include_vhosts": true,
"include_traffic": true,
"include_backends": true,
"include_docker_vhosts": true,
"include_cache_domains": true,
"include_streams": true,
"include_logging": true
}
}
```
The listener is plain HTTP. Keep it on loopback, a private monitoring network or
behind a protected tunnel. `allowed_cidrs` is always evaluated against the actual
transport peer (`PeerAddr`); `Forwarded` and `X-Forwarded-For` never grant metrics
access.
### Bearer token
Set `bearer_token_env` to an environment-variable name such as
`RUST_PROXY_METRICS_TOKEN`. RustProxy stores only the variable name. The value must
contain 32..4096 printable non-whitespace ASCII characters and must be present at
startup.
### Trusted network without a token
`allow_trusted_network_without_token=true` explicitly allows a peer already matched
by `allowed_cidrs` to scrape without a bearer token. This can be used for a private
Prometheus VLAN. An explicit IPv4/IPv6 `/0` is rejected in this mode. Keep the CIDR
list narrow; enabling this option is an authentication decision, not a forwarded-IP
feature.
A non-loopback exporter requires either a bearer-token variable or this explicit
trusted-network bypass. Changing enabled/listen/token-variable requires restart;
path, CIDRs, collector switches and the trusted-network bypass apply live.
Only `GET` on the exact configured path without a query string is accepted. A
wrong path is `404`, wrong method `405`, disallowed peer `403`, invalid/missing
required bearer `401`, and exporter concurrency exhaustion `429`.
## Web Server and PHP-FPM metrics (0.33.0)
Prometheus already exports core Web Server/PHP response counters by configured
`web_service` ID. The 0.33.0 additions below are enabled with the existing
Prometheus exporter (no new listener, credentials, options or open routes).
| Family | Type | Labels | Meaning |
|---|---|---|---|
| `rust_proxy_web_server_requests_total` | counter | `web_service` | Header-ready WWW responses, including PHP |
| `rust_proxy_web_server_responses_total` | counter | `web_service`, `class` | HTTP 2xx/3xx/4xx/5xx |
| `rust_proxy_web_server_response_header_duration_microseconds_total` | counter | `web_service` | Cumulative time until headers |
| `rust_proxy_web_server_requests_active` | gauge | `web_service` | Requests still awaiting headers |
| `rust_proxy_web_server_request_kind_total` | counter | `web_service`, `kind` | `static`, `php`, `other` handler |
| `rust_proxy_web_server_methods_total` | counter | `web_service`, `method` | `GET`, `POST`, `PUT`, `DELETE`, `other` |
| `rust_proxy_web_server_status_code_total` | counter | `web_service`, `status` | Selected 200/206/304/403/404/500/502/503 |
| `rust_proxy_web_server_response_body_bytes_total` | counter | `web_service` | Body bytes delivered before final compression |
| `rust_proxy_web_server_compression_total` | counter | `web_service`, `encoding` | Downstream gzip/br/zstd/deflate operations |
| `rust_proxy_php_requests_total` | counter | `web_service` | PHP response headers processed |
| `rust_proxy_php_responses_total` | counter | `web_service`, `class` | PHP response status classes |
| `rust_proxy_php_response_header_duration_microseconds_total` | counter | `web_service` | Cumulative PHP time until headers |
| `rust_proxy_php_response_body_bytes_total` | counter | `web_service` | PHP body bytes emitted before compression |
| `rust_proxy_php_concurrency_available` | gauge | `web_service` | Free RustProxy PHP request permits |
| `rust_proxy_php_concurrency_limit` | gauge | `web_service` | Configured concurrent PHP request limit |
| `rust_proxy_php_concurrency_in_use` | gauge | `web_service` | Permits currently held by PHP requests |
| `rust_proxy_php_failures_total` | counter | `web_service`, `reason` | Fixed FastCGI/PHP failure classes |
PHP `reason` is one of `capacity`, `connect_timeout`, `connect_error`,
`request_timeout`, `request_io`, `response_timeout`, `response_io`,
`invalid_response`, `body_io`. PHP concurrency is the admission semaphore in
RustProxy, not external PHP worker health or PHP-FPM child count. Status-code
labels are deliberately restricted to a stable, useful subset; arbitrary code,
request paths, IP addresses and untrusted method strings are not labels.
The exported byte counters are measured **before the final dynamic response
encoder**, so treat them as payload sizes rather than measured network transfer.
Precompressed static file delivery is not counted in the downstream compression
family. All time-to-headers metrics exclude streamed transfer duration.
Useful Grafana/PromQL examples:
```promql
sum(rate(rust_proxy_web_server_requests_total[5m]))
sum by (class) (rate(rust_proxy_web_server_responses_total[5m]))
sum by (kind) (rate(rust_proxy_web_server_request_kind_total[5m]))
100 * sum(rust_proxy_php_concurrency_in_use) / clamp_min(sum(rust_proxy_php_concurrency_limit), 1)
1e-3 * sum(rate(rust_proxy_web_server_response_header_duration_microseconds_total[5m])) / clamp_min(sum(rate(rust_proxy_web_server_requests_total[5m])), 1e-9)
sum by (reason) (rate(rust_proxy_php_failures_total[5m]))
```
Import `grafana/rustproxy-overview.json` and use the **Web service** variable
for the new Web Server, PHP-FPM, per-service and correlation sections.
## Coverage audit in 0.26.0
The exporter represents all scalar fields currently sampled by `diagnostics::View` when available. The per-interface `network_interfaces` list added in 0.18.6 is intentionally dashboard-only so kernel-created interface names cannot create an uncontrolled Prometheus label set. RustProxy also exports the bounded operational data already available from traffic metrics, cache, vhosts, backend pools, streams, logging, GeoIP and ACME renewal observations.
| Area | Exported diagnostics |
|---|---|
| Global traffic | requests/completions/active/rejects/errors, status classes, request/response bytes, exchange lifetime, accepted/active/rejected connections |
| Protocols | HTTP/1.1, HTTP/2 and HTTP/3 requests, H2/H3 connections/rejects, TLS connections/handshake failures, WebSocket tunnels/bytes/errors/lifetime, compression counters |
| Admin | request/status counters and time to control-plane response headers |
| Process/host | diagnostic source, runtime platform info, sampled timestamp, system/process CPU, logical CPUs, RAM total/used, system swap total/used, system uptime, RSS total/anon/file/shmem, virtual memory/limit, VmData, process swap, FDs/limit, threads, process start time, load 1/5/15, network totals/rates, process disk I/O, cgroup memory current/max |
| GeoIP | loaded state plus IPv4/IPv6 range counts |
| Cache | hot entries/bytes, global hit ratio and counters, cold entries/bytes and movement/error counters, fill bytes, cold queue bytes/items, active cold read/write bytes, combined transient bytes, configured-domain hot/cold occupancy, hit ratio and decision data |
| Vhosts | desired enable/domain/path/cache settings, per-vhost traffic/status/error/byte counters and cumulative exchange-duration histogram |
| Traffic analytics | independent live collector (`include_traffic`): capture-state gauges, per-vhost request/error/body-byte/duration totals; approximate bounded top countries; fixed User-Agent families; fixed HTTP method families; status classes; cache outcomes; serving source (`web_server`, `reverse_proxy`, `local_response`, `unresolved`); and currently configured route IDs |
| Built-in Web Server | configured Web Service count/info, PHP and directory-listing flags, vhost/route source metadata, plus actual request traffic through the shared vhost counters and Traffic serving-source metrics |
| Redirect/404 hosts | desired enable state, domain/rule counts and local-response status |
| Backends | pool readiness/policy info, bounded backend status, health/drain/backup, active work and configured request-concurrency/queue limits, requests/errors/bytes, weight, separate active/passive failure state, active/passive successes and health-failure totals, unconditional HTTP-5xx/timeout/connect/I/O/slow-response observation counters, DNS/TLS/header-timeout/read-timeout/send-timeout diagnostics, capacity rejection/queue-timeout counters, retry attempt/success totals, upstream-client generation/current-generation requests/generation age/rotation totals, plus `passive_http_5xx_total`, `passive_timeouts_total`, `passive_connect_errors_total`, `passive_io_errors_total` and `passive_slow_total` aliases, health transitions, last status, current/EWMA latency, last-probe/failure/ejection/recovery timestamps and traffic rate for ordinary vhosts |
| Docker vhosts | separate `rust_proxy_docker_vhost_*` and `rust_proxy_docker_backend_*` families: configured agents/assignments/domains, vhost traffic/status/duration plus backend readiness, health, requests/errors/bytes, latency/EWMA, separate active/passive failure state, unconditional observation counters and matching passive-health aliases, retry attempt/success totals, upstream-client generation/current-generation requests/generation age/rotation totals, transitions and probe results |
| Streams | desired enable state, active/session/reject/error counters, bytes/packets and max connections |
| Logging | structured sink queue capacity, sent/error/drop counters plus the global live-log contention drop counter |
| TLS/ACME | configured certificate count, auto-renew flag, bounded issuer metadata, latest ACME job state, renewal check/expiry timestamps, failure count and status |
| Inventory | listeners/protocol flags, vhost/redirect/404/stream/ACL counts, request capacity and available permits, config revision and restart-required state |
Values unavailable on the running OS are omitted rather than fabricated. Series
labels are bounded by configured IDs/domains, finite states, fixed protocol/status/UA/method/source
families, or the finite two-letter country space. Per-request URLs, raw client IPs, full
User-Agent values and arbitrary Host input are never labels.
This is complete for **currently implemented bounded diagnostic views**. It does
not intentionally expose secrets, raw configuration documents, access-log entries
or unbounded per-request values merely to make them visible to Prometheus.
## Prometheus examples
Loopback/private CIDR scrape without a token when the explicit trusted option is
used:
```yaml
scrape_configs:
- job_name: rustproxy
scrape_interval: 15s
metrics_path: /metrics
static_configs:
- targets: ['127.0.0.1:9191']
```
With bearer authentication:
```yaml
authorization:
type: Bearer
credentials_file: /etc/prometheus/secrets/rustproxy-token
```
Vhost rate:
```promql
sum by (vhost) (rate(rust_proxy_vhost_requests_total[5m]))
```
Web Server traffic rate:
```promql
sum by (vhost) (rate(rust_proxy_traffic_source_requests_total{source="web_server"}[5m]))
```
Top country snapshot from bounded Traffic analytics:
```promql
sum by (country) (rust_proxy_traffic_country_requests)
```
Country series are populated when **Settings -> Logging -> Capture country** is enabled; User-Agent family series follow **Capture User-Agent**. The exporter never turns those request-path captures on by itself.
Current capture state is exposed as `rust_proxy_traffic_country_capture_enabled` and `rust_proxy_traffic_user_agent_capture_enabled`. Configured-route snapshots use `rust_proxy_traffic_route_requests`; raw URL paths are never exported as labels.
Fixed User-Agent families:
```promql
sum by (family) (rust_proxy_traffic_user_agent_family_requests_total)
```
Vhost p95 exchange lifetime:
```promql
histogram_quantile(0.95,
sum by (vhost, le) (rate(rust_proxy_vhost_exchange_duration_seconds_bucket[5m])))
```
Docker-vhost request rate (kept separate from ordinary vhosts):
```promql
sum by (agent, vhost, service) (rate(rust_proxy_docker_vhost_requests_total[5m]))
```
Docker backend availability and latency:
```promql
rust_proxy_docker_backend_up
rust_proxy_docker_backend_ewma_latency_seconds
```
Backend client lifecycle and retry diagnostics:
```promql
rust_proxy_backend_upstream_client_generation_requests
rust_proxy_backend_upstream_client_generation_age_seconds
rate(rust_proxy_backend_upstream_client_rotations_total[5m])
rate(rust_proxy_backend_retry_attempts_total[5m])
rate(rust_proxy_backend_retry_successes_total[5m])
```
The same fields are exported for Docker-discovered backends under the `rust_proxy_docker_backend_*` prefix.
Certificate expiry window:
```promql
rust_proxy_certificate_renewal_not_after_timestamp_seconds - time()
```
The supplied dashboard is `grafana/rustproxy-overview.json`; Debian packages also
install it under `/usr/share/rustproxy/grafana/`. Its dedicated **Docker vhosts** row
uses only the separate Docker metric families and provides Docker-agent/service
filters so multi-agent installations can be inspected independently from ordinary
vhosts/backends.
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.
+31 -67
View File
@@ -1,77 +1,41 @@
# RustProxy documentation index
# RustProxy documentation
This is the canonical index for the current project documentation. The authenticated administration documentation page at `/docs` embeds these Markdown files in its **Reader** tab and keeps OpenAPI/Swagger in a separate **API** tab.
Practical guides for **RustProxy 0.34.0**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive.
## Project and release
## Getting started
- [Project README](../README.md) - product overview, installation entry points and current release notes.
- [ROADMAP](../ROADMAP.md) - planned and completed release scope.
- [CHANGELOG](../CHANGELOG.md) - user/operator-visible changes by release.
- [TODO](TODO.md) - open work only; completed work is not retained as TODO.
- [Installation and updates](INSTALL.md) — packages, build, first administrator, systemd and updates.
- [Container deployment](CONTAINER.md) — image, persistent state, ports and isolation.
- [Settings and configuration](CONFIGURATION.md) — what to configure and which changes need restart.
- [Troubleshooting](TROUBLESHOOTING.md) — symptom-based checks and release validation.
## Functional documentation
## Publishing services
- [WEB_SERVER](WEB_SERVER.md) - reusable Web Services, static-file behavior, ranges/validators and document-root security.
- [PHP_FPM](PHP_FPM.md) - implemented FastCGI/PHP-FPM integration, security model and runtime requirements.
- [PHP_FPM_ISOLATION](PHP_FPM_ISOLATION.md) - separate PHP-FPM pools to prevent cross-vhost INI settings leakage.
- [VHOSTS](VHOSTS.md) - vhosts and backend/source behavior.
- [CUSTOM_LOCATIONS](CUSTOM_LOCATIONS.md) - location matching, rewrites and local responses.
- [VHOST_ACCESS](VHOST_ACCESS.md) - access rules applied to vhosts and locations.
- [CACHE](CACHE.md) - cache behavior and policy.
- [TRANSFORM](TRANSFORM.md) - response transformation.
- [STREAMS](STREAMS.md) - stream proxying.
- [REDIRECTS](REDIRECTS.md) - redirect hosts and rules.
- [NOT_FOUND_HOSTS](NOT_FOUND_HOSTS.md) - local 404 host handling.
- [DOCKER_DISCOVERY](DOCKER_DISCOVERY.md) - Docker discovery and generated vhosts.
- [ACME_PROVIDERS](ACME_PROVIDERS.md) and [RENEWAL](RENEWAL.md) - certificate automation.
- [LIVE_LOGS](LIVE_LOGS.md), [FILE_LOGS](FILE_LOGS.md) and [ERROR_LOGGING](ERROR_LOGGING.md) - independent in-memory live request viewing, bounded on-disk log exploration, protocol-error persistence and optional error-file logging.
- [Vhosts and backends](VHOSTS.md) — domains, listener bindings, balancing, health and redirects.
- [Custom locations](CUSTOM_LOCATIONS.md) — path routing, rewrites, local responses and error fallbacks.
- [Access and authentication](ACCESS.md) — ACL, country/DDNS, Basic Auth, Tinyauth and rate limits.
- [Static Web Server](WEB_SERVER.md) — document roots, downloads, rules and file delivery.
- [PHP-FPM](PHP_FPM.md) — PHP service profiles, security and application examples.
- [Cache](CACHE.md) — HOT/COLD storage, privacy, TTL and invalidation.
- [Response delivery](RESPONSE_DELIVERY.md) — compression, static transformations and response headers.
- [HTTP transport](TRANSPORT.md) — HTTPS/H2/H3, WebSockets, origin protocols and timeouts.
- [TCP/UDP Streams](STREAMS.md) — non-HTTP forwarding and its security boundaries.
## Administration and operations
## Integrations and operations
- [INSTALL](INSTALL.md) - installation and managed updates.
- [SETTINGS](SETTINGS.md) - listeners, administration and response defaults.
- [API](API.md) - administration API overview.
- [API_ACCESS](API_ACCESS.md) - API authentication, CSRF and `/docs` security model.
- [SECRET_STORE_BACKUP](SECRET_STORE_BACKUP.md) - encrypted secret storage and backup/restore.
- [PROMETHEUS](PROMETHEUS.md) - monitoring endpoint and metrics.
- [CONTAINER](CONTAINER.md) - container deployment.
- [NPM_MIGRATION](NPM_MIGRATION.md) and [MIGRATION](MIGRATION.md) - migration tooling and historical migration notes.
- [Certificates and automatic renewal](ACME_PROVIDERS.md) — PEM, HTTP-01, DNS-01, Certbot and external certificates.
- [Docker discovery](DOCKER_DISCOVERY.md) — agent installation, networking and generated Vhosts.
- [Other integrations](INTEGRATIONS.md) — Tinyauth, NPM migration, OPNsense and monitoring tools.
- [Dashboard](DASHBOARD.md) — metrics panels, presets and configuration history.
- [Monitoring](MONITORING.md) — Prometheus, traffic, backend and cache observations.
- [Logs](LOGS.md) — live logs, stored logs, error logs and searches.
- [Backups and recovery](BACKUP.md) — encrypted backups, restore and Secret Store.
- [Migration](MIGRATION.md) — managed upgrades and Nginx Proxy Manager import.
- [Security](SECURITY.md) — important trust boundaries and limitations.
- [Administration API](API.md) and [API access](API_ACCESS.md) — endpoints, sessions and restricted certificate credentials.
## Development and testing
## Release information
- [MOCK_SERVER](MOCK_SERVER.md) - local Python mock origin used by development/integration checks.
- [MOCK_AUTH](MOCK_AUTH.md) - local fake Tinyauth peer for development only.
- [MEMORY_STRESS](MEMORY_STRESS.md) - sustained-memory audit, offline load simulations, real RustProxy staging stress and RSS measurement procedure.
[Changelog](../CHANGELOG.md) tracks user/operator-visible technical changes; [Roadmap](../ROADMAP.md) separates current verification work from future ideas. Historical low-level investigations, audits and benchmarks are not part of the live Reader. Keep them separately if you need an audit trail.
## Technical documentation
- [ARCHITECTURE](ARCHITECTURE.md) - process and request architecture.
- [SECURITY](SECURITY.md) - current security model and limitations.
- [HTTP3](HTTP3.md) - HTTP/3 transport behavior.
- [TRANSPORT](TRANSPORT.md) - transport background/contract notes.
- [CACHE_DIAGNOSTICS](CACHE_DIAGNOSTICS.md) - cache diagnostics and interpretation.
- [REFERENCES](REFERENCES.md) - implementation references.
The pre-cleanup developer-level changelog is retained as [CHANGELOG_TECHNICAL_ARCHIVE](CHANGELOG_TECHNICAL_ARCHIVE.md) when low-level historical detail is needed. The root `CHANGELOG.md` remains the operator-facing release record.
Completed roadmap history is retained separately as [ROADMAP_HISTORY](ROADMAP_HISTORY.md); the root `ROADMAP.md` contains the current release scope and future direction.
## Historical or version-labelled context
### Retained audits and historical context
- [SECURITY_AUDIT](SECURITY_AUDIT.md) and [SECURITY_AUDIT_0.31.0](SECURITY_AUDIT_0.31.0.md) - current security audit material and the 0.31.0 audit.
- [CHANGELOG_TECHNICAL_ARCHIVE](CHANGELOG_TECHNICAL_ARCHIVE.md) and [ROADMAP_HISTORY](ROADMAP_HISTORY.md) - retained long-form technical history and completed milestones.
- [ACME_RFC2136](ACME_RFC2136.md) - historical DNS provider details.
- [INTEGRATIONS_040](INTEGRATIONS_040.md) and [MANAGEMENT](MANAGEMENT.md) - retained design and integration notes.
- [TEST_REPORT](TEST_REPORT.md) - verification history and release gates.
The old per-version implementation notes and audits removed from this trimmed source archive are no longer required to build the application or use the documentation Reader. Only registered documents are embedded at compile time.
## Maintenance rules
- Put user/operator-visible release changes in `CHANGELOG.md`.
- Put future release scope in `ROADMAP.md` and unresolved work in `docs/TODO.md`.
- Keep detailed implementation evidence in technical documents or version audits, not in CHANGELOG.
- When functionality changes, update the functional document, this index, Reader coverage and cross-links in the same change.
- Remove obsolete or duplicated documents when they no longer provide historical or technical value; retain useful audits with an explicit historical label.
When changing functionality, update the appropriate guide, this index, the registered Reader documents and any OpenAPI/schema references together. Describe actual code behavior, not unimplemented plans.
-66
View File
@@ -1,66 +0,0 @@
# Redirect hosts
RustProxy 0.13.0 adds first-class redirect hosts on the **Redirects** dashboard page. A redirect host owns one or more source hostnames and named HTTP/HTTPS listeners and answers locally without opening a backend connection.
Each host contains ordered redirect rules. The longest matching path wins. Rules can match `http`, `https` or either scheme and can use exact or path-prefix matching. Targets can be:
- `url`: an absolute `http://` or `https://` URL,
- `host`: a hostname/authority with the target scheme set to preserve, HTTP or HTTPS,
- `path`: an absolute path on the same request authority.
Supported status codes are 301, 302, 303, 307 and 308. Use 307/308 when the client must preserve the HTTP method. Path handling can append the complete source path, strip the matched prefix before appending, or replace the path entirely. Source query parameters can be preserved or dropped. `cache_seconds=0` emits `Cache-Control: no-store`; a positive value emits a bounded public `max-age`.
Redirect hosts use the same named listener inventory and certificate library as vhosts. The dashboard uses the same listener-selection component for both editors. A new redirect preselects the standard enabled HTTP :80 and HTTPS :443 listeners together when available, while existing bindings are preserved. The same hostname cannot overlap a vhost or another redirect host on the same socket. Global host ACL policy still runs before a redirect response. Redirect rules never make DNS/TCP/TLS connections to the target; the client follows the resulting `Location` header.
Configuration lives under `redirects` and normal dashboard saves use `PUT /api/config/redirects`.
## Example
```json
{
"redirects": [
{
"id": "legacy-site",
"name": "Legacy site redirects",
"frontend": {
"hosts": ["old.example.com"],
"listener_ids": ["http", "https"]
},
"certificate_id": "old-example",
"rules": [
{
"id": "application",
"source_scheme": "any",
"match_type": "prefix",
"path_prefix": "/app",
"target_type": "host",
"target": "app.example.com",
"target_scheme": "https",
"path_mode": "strip_prefix",
"query_mode": "preserve",
"status_code": 308,
"cache_seconds": 300
},
{
"id": "fallback",
"match_type": "prefix",
"path_prefix": "/",
"target_type": "url",
"target": "https://www.example.com/",
"path_mode": "replace",
"query_mode": "drop",
"status_code": 302,
"cache_seconds": 0
}
]
}
]
}
```
Rules are evaluated by longest path first; exact matching wins over prefix matching at the same path. A disabled redirect host reserves its hostname/listener ownership just like a disabled vhost, so another frontend cannot silently take over that traffic.
## Hostname editor
The Redirect source-domain field uses the same chip editor as Vhosts: add one hostname/wildcard at a time, remove individual chips, and keep listener selection in the shared frontend control.
-65
View File
@@ -1,65 +0,0 @@
# Implementation references
Primary specifications and library documentation consulted while implementing the
transport code. These references are not evidence that this source package was built
or tested successfully against a resolved dependency graph.
## HTTP and WebSocket
- HTTP semantics and intermediary header handling: https://www.rfc-editor.org/rfc/rfc9110
- HTTP/1.1 messaging: https://www.rfc-editor.org/rfc/rfc9112
- HTTP/2: https://www.rfc-editor.org/rfc/rfc9113
- WebSocket handshake and framing: https://www.rfc-editor.org/rfc/rfc6455
- Extended CONNECT bootstrap semantics: https://www.rfc-editor.org/rfc/rfc8441
- WebSocket over HTTP/3: https://www.rfc-editor.org/rfc/rfc9220
- hpx-h3 2.5.19: https://docs.rs/hpx-h3/2.5.19/h3/
- Hyper HTTP/2 server builder: https://docs.rs/hyper/latest/hyper/server/conn/http2/struct.Builder.html
- Hyper upgrades: https://docs.rs/hyper/latest/hyper/upgrade/index.html
- hyper-util legacy client: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/struct.Client.html
- Resolver interface: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/connect/dns/index.html
- Tokio bounded-buffer bidirectional copying: https://docs.rs/tokio/latest/tokio/io/fn.copy_bidirectional.html
## TLS
- HTTPS connector builder: https://docs.rs/hyper-rustls/latest/hyper_rustls/struct.HttpsConnectorBuilder.html
- rustls signing key/certificate validation: https://docs.rs/rustls/latest/rustls/sign/struct.CertifiedKey.html
- rustls server certificate resolver: https://docs.rs/rustls/latest/rustls/server/trait.ResolvesServerCert.html
- SNI certificate bindings: https://docs.rs/rustls/latest/rustls/server/struct.ResolvesServerCertUsingSni.html
- Development certificate generation: https://docs.rs/rcgen/0.13.2/rcgen/fn.generate_simple_self_signed.html
## Persistence and security
- SQLx SQLite connection model: https://docs.rs/sqlx/latest/sqlx/struct.SqliteConnection.html
- SQLite online backup API: https://www.sqlite.org/backup.html
- OWASP SSRF guidance: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP password storage: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- Prometheus instrumentation practices: https://prometheus.io/docs/practices/instrumentation/
The next Docker stages must use actual published port bindings and reconciliation,
not infer exposure from EXPOSE alone. Start with the official engine networking and
events documentation, and record the API versions actually tested in that release.
## Cache and compression
- HTTP caching semantics and Age/freshness: https://www.rfc-editor.org/rfc/rfc9111
- Cache-Status response field: https://www.rfc-editor.org/rfc/rfc9211
- tower-http streaming compression: https://docs.rs/tower-http/latest/tower_http/compression/
- Compression implementation API: https://docs.rs/crate/tower-http/latest/source/src/compression/service.rs
- Compression predicates: https://docs.rs/tower-http/latest/tower_http/compression/predicate/
References describe upstream APIs/specifications, not successful compilation of the
unresolved 0.2.0 dependency graph. Consult the exact Cargo.lock when generated.
## Vhosts, keepalive and negotiation
- Client pool timers, idle limits and HTTP/2 pings: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/struct.Builder.html
- DNS Name parsing interface: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/connect/dns/struct.Name.html
- TLS ALPN server configuration: https://docs.rs/rustls/latest/rustls/server/struct.ServerConfig.html
- HTTP strict transport security: https://www.rfc-editor.org/rfc/rfc6797
The library documentation is reference material, not proof that a particular future
resolved graph compiles. The reported tower-http 0.6.11 `SizeAbove::new(u16)` call
has been removed entirely; the custom predicate uses the generic Predicate API.
- Generic compression predicate API: https://docs.rs/tower-http/latest/tower_http/compression/trait.Predicate.html
-165
View File
@@ -1,165 +0,0 @@
# Automatic certificate renewal
## Scope and defaults
RustProxy 0.7.0 renews **configured managed certificates** whose `issuer` metadata
identifies an existing HTTP-01, Cloudflare, OVH or RFC2136 Certbot profile. Issuance
and initial library import are still explicit, staging-first operations. Importing
a PEM pair alone does not create a managed lineage or authorize CA requests.
For managed certificates, `issuer.auto_renew` defaults to `true`, including saved
0.6 metadata lacking that field. Explicit `false` opts out. Therefore upgrading
an already configured managed certificate can enable scheduled CA checks; review
this before starting the new binary. Missing profiles fail closed with an error;
there is no silent recreation, provider switch or production issuance.
Certificates shows an independent enable/disable action, Check now and Dry run,
last check, next check, expiry and result. Expiry is populated after a worker check;
it is not a pre-existing inventory of every manually imported certificate. Renewal
is not a configurable new ACME account lifecycle or revocation feature.
## Schedule and recovery
Saved renewal observations are loaded from SQLite **before administration listeners are
exposed**, so last check, next check, expiry and result are available immediately after a
restart without pressing **Check now**. RustProxy then waits 5-10 minutes before the first
automatic CA check, avoiding a renewal burst during startup. Successful checks are scheduled
approximately every 12 hours with up to 30 minutes of additional jitter. Only one issue/renew
worker runs at once. Failures back off from one to six hours; a missing/invalid profile is
checked again after one hour. Observation rows contain non-secret status/timestamps only and
are stored only in `config.sqlite3`. RustProxy 0.15.0 no longer reads or imports historical JSON
observation sidecars at runtime. When SQLite has no observation row, the normal scheduler safely
reconstructs status on its next automatic check; this does not replace or regenerate certificate,
key, account or renewal-profile material. A manual dry run does not postpone a real due check. The
service must be running; this is not an independent timer that works while RustProxy is stopped.
The helper runs `certbot renew --cert-name rustproxy` in the original isolated
lineage, reusing saved authenticator/account/credential settings. It does not use
`--force-renewal`, change the CA or run directory hooks. Do not add shell hooks
to the private Certbot renewal configuration: hooks already saved by an operator
are still part of Certbot's trusted configuration. Certbot decides when the
certificate needs renewal. A successful exit can mean nothing was due: actual
certificate and key bytes are compared with the currently installed generation.
For changed material, the helper verifies validity times using OpenSSL and creates
private immutable, content-addressed staging files. Rust validates the certificate/
key match and requested domain coverage, then validates a complete new snapshot
including current vhost assignments. Only a successful revisioned SQLite write
publishes that snapshot. New TCP and QUIC TLS handshakes use the new certificate;
existing connections are not restarted. A simultaneous manual replacement/removal
or automatic-renewal disable wins over an older automatic worker result.
Worker failure, invalid PEM/SANs, expiry regression or configuration conflict leaves
the previously active generation intact. If Certbot succeeded but publication did
not, the next check compares files again and can retry the same staged generation.
Unchanged checks do not write a new configuration revision. Changed certificate publications
use the ordinary revisioned configuration path, but certificate inventory changes are not part
of cache compatibility and therefore do not discard otherwise compatible hot/cold objects.
Private profile/file checks, single-worker and per-profile locks, bounded subprocess
timeouts and process-group cancellation constrain work. Original certificate files
and Certbot archives are retained, not automatically deleted. Immutable renewal
staging is limited to 1024 keys; initial issuance staging remains limited to 128.
Plan monitored disk space and controlled offline cleanup after verified backups;
there is no automatic certificate garbage collector or external expiry alert yet.
## Provider-specific prerequisites
HTTP-01 still requires public TCP port 80 to reach an active plaintext RustProxy
listener for every name. The reserved challenge handler exposes only bounded tokens
for the current worker's domains, before application auth/ACL/cache. It is not a
public file browser. Wildcard certificates use DNS-01, not HTTP-01.
Cloudflare, OVH and RFC2136 need their original DNS permissions, credentials and
propagation settings. Secrets are stored in private files, not returned by the
renew endpoint or included in desired configuration. They are not encrypted at
rest. The helper, Certbot, plugins and OpenSSL must be usable by the service user.
Keep absolute profile paths stable; moving the Certbot directory requires explicit
migration of its stored paths. A system-wide timer should not also manage these
RustProxy-isolated profiles.
For a certificate intentionally owned by a separate system Certbot instead of this managed
renewal subsystem, 0.28.2 can reference the Certbot lineage directly. Configure a manual
certificate id with both absolute paths, for example
`/etc/letsencrypt/live/example.com/fullchain.pem` and
`/etc/letsencrypt/live/example.com/privkey.pem`. RustProxy resolves the `live/` symlinks again on
every targeted reload, so a renewal wrapper only needs to call:
```bash
curl --fail --show-error -X POST \
-H 'Authorization: Bearer TOKEN' \
-H 'X-RustProxy-Certificate-Key: certbot-main' \
-H 'Content-Type: application/json' \
--data '{}' \
https://proxy-admin.example/api/certificates/shared-cert/reload
```
The service identity must be able to traverse the Certbot directories and read the files. RustProxy
0.28.2 accepts named POSIX ACL read access and does not require an exact `0600`/`0640` mode.
External certificate/key files must not be group/world-writable, and private keys must not be
world-readable. Parent directories must be root- or RustProxy-owned and not group/world-writable
(apart from root-owned sticky ancestors such as `/tmp`). The referenced files are not copied into
RustProxy backups.
For a normal Certbot lineage, grant traversal without directory listing and read only the archive
files. A default ACL is useful, but a renewer may create a new private key with mode `0600` and
mask inherited ACL permissions. The reliable hook therefore reapplies the named read ACL to the
newly resolved generation before calling the reload API:
```bash
setfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/
setfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/
setfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem \
/etc/letsencrypt/archive/example.com/privkey*.pem
setfacl -m d:u:rustproxy:r-- /etc/letsencrypt/archive/example.com/
# Run these two resolved-file grants after renewal, before the API reload.
setfacl -m u:rustproxy:r-- \
"$(readlink -f /etc/letsencrypt/live/example.com/fullchain.pem)" \
"$(readlink -f /etc/letsencrypt/live/example.com/privkey.pem)"
```
For external material below `/etc/ssl`, traversal of the protected root can be granted separately:
```bash
setfacl -m u:rustproxy:--x /etc/ssl/
```
If direct read access to the Certbot tree is undesirable, use the copy/deploy workflow instead:
```bash
/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy tls deploy --id shared-cert \
--cert /etc/letsencrypt/live/example/fullchain.pem \
--key /etc/letsencrypt/live/example/privkey.pem
```
That command resolves the Certbot `live/` symlinks locally, validates and stages a managed pair,
then publishes the existing id. Do not use deploy for an id configured with external absolute
paths, and do not point either external-manual workflow at a certificate carrying RustProxy
`issuer` metadata; one lineage must have one owner.
Staging certificates are not trusted by browsers. A Dry run checks renewal
validation but does not deploy the staging result. Only an explicit production
issuance creates a production profile; renewal never upgrades staging implicitly.
## API
All routes below require the existing local administrator session and CSRF checks.
- `GET /api/acme`: current job, provider names, nominal interval and renewal states.
- `POST /api/acme/renew`: `{"certificate_id":"site","dry_run":false}`; returns 202
and a job. Only identity/options, no API credential resubmission.
- Certificate opt-out is a revisioned config update setting
`tls.certificates[].issuer.auto_renew` to `false`.
`--dry-run` is **not** a claim of live CA validation in this release's test report:
packaging tests used a simulated Certbot. Run a real staging/production-lineage
validation on your server. Full runtime scheduler/publication acceptance is pending.
## Primary references
Certbot renewal behavior and flags:
https://eff-certbot.readthedocs.io/en/stable/using.html#renewing-certificates
https://eff-certbot.readthedocs.io/en/stable/man/certbot.html
Challenge types: https://letsencrypt.org/docs/challenge-types/
+47
View File
@@ -0,0 +1,47 @@
# Compression, transformations and response headers
RustProxy can compress eligible responses and optionally transform supported static content before caching or delivery. These controls are **separate**: compression changes transfer encoding; transformation changes representation bytes. Always verify content and response headers with a real client after enabling either feature.
## Response compression
Open **Settings → Response / Compression** to configure the global codec ceiling. Vhosts and Web Services may further restrict enabled codecs; a local override cannot enable a codec forbidden globally.
| Setting | Current default / behavior |
|---|---|
| `gzip` | Enabled globally |
| `br` (Brotli) | Enabled globally |
| `zstd` | Enabled globally; used only when the client accepts it |
| `deflate` | Disabled globally |
| `min_bytes` | 1,024 bytes by default, configurable from 32 to 1,048,576 |
RustProxy negotiates encoding against the request's `Accept-Encoding` values and quality weights. An eligible reply with a preexisting `Content-Encoding` is **not compressed again**. Compression is skipped for range responses, HEAD/204/304, upgrades, event streams, `Cache-Control: no-transform` and other ineligible cases. Unknown-length streams are handled without buffering the whole body merely to compress it.
The Web Server can additionally use precompressed `.br`/`.gz` file sidecars when eligible. Sidecar delivery is distinct from live response compression. Do not manually force a `Content-Encoding` header that does not match the actual body.
## Static response transformations
Under **Settings → Transform**, the engine is disabled by default. Even when enabled globally, **each Vhost must opt in**. The default globally enabled transform types, once the engine is turned on, are JavaScript and CSS. HTML, SVG and JSON transformations are individually optional. A Vhost may override the allowed types and extensions within global restrictions.
The available resource controls include:
- `max_body_bytes` — 2 MiB default per eligible response (1 KiB–32 MiB accepted).
- `memory_budget_bytes` — 128 MiB default process-wide budget (1 MiB–2 GiB accepted).
- `max_inflight` — 32 default concurrent transformation slots (1–1024 accepted).
- Presets in the panel can set suggested limits; the stored numeric settings determine actual limits.
Transformation is a bounded operation for eligible, supported representations, **not a general rewrite engine**. Unsupported MIME types, larger or unknown-size bodies, non-transformable responses and resource-saturated periods are skipped or handled without changing the original body. Transforming JS/CSS/HTML can break applications that rely on specific syntax or content, so test before enabling site-wide.
Transformations can run on eligible reverse-proxy and Web Server responses. When a transformed response is safely cacheable, the cache stores a representation compatible with its validators and later per-client encoding. Changing transform rules may invalidate affected cached content. See [Cache](CACHE.md).
## Custom and security headers
Configure global response headers, Vhost-specific policies and Web Server/location headers at the appropriate scope. Header rules can set, append or remove allowed names. Certain routing, forwarding, framing, cache diagnostics and security-sensitive fields are protected or subject to extra validation. Review especially HSTS, CSP, CORS and application-specific cookies before using a broad rule.
For controlled rollout:
1. Enable only the codec/types required, then test with and without `Accept-Encoding` and with ordinary browsers.
2. Compare response size, actual decoding, `Vary`, cache status and content hash for sensitive assets.
3. Check the **Transform** and **Cache** dashboards, response logs and browser console for errors.
4. Disable the particular Vhost or transform type if output changes unexpectedly; investigate before re-enabling.
The Transform dashboard provides examined/transformed/skipped/failed counts, saved bytes and resource-limit observations. These are runtime diagnostics, not a guarantee that every asset was transformed or that end-user page loads improved.
-714
View File
@@ -1,714 +0,0 @@
# Historical roadmap archive
> Archived during the RustProxy 0.25 documentation cleanup. This file preserves completed historical roadmap entries. The active roadmap is [`../ROADMAP.md`](../ROADMAP.md); unresolved work is tracked in [`TODO.md`](TODO.md).
# RustProxy roadmap
## 0.25.0
Current direction: 0.24.10 -> 0.25.0
- [x] Add independent reusable Web Service configuration with name, document root, index list and directory-listing switch.
- [x] Add Web Service CRUD through the dashboard/API and persist it through the existing revisioned configuration store.
- [x] Add `Reverse Proxy` / `Web Server` source selection to vhost fallback and custom locations without storing Web Server filesystem settings in vhosts.
- [x] Allow one Web Service to be referenced by multiple vhosts and one vhost to mix Web Server and Reverse Proxy locations.
- [x] Add `GET`, `HEAD`, MIME, `Content-Length`, streaming, index lookup, directory slash redirect and optional directory listing.
- [x] Add metadata-based `ETag`/`Last-Modified`, conditional `304`, single `Range`/`If-Range`, `206` and `416`.
- [x] Enforce decoded-path validation and document-root confinement; Unix traversal uses `openat` + `O_NOFOLLOW` and does not serve symlinks.
- [x] Keep Web Server inside the existing routing, ACL/auth, custom-header/error-page/security and H1/H2/H3 response pipeline; do not create separate routing/error/cache subsystems.
- [x] Add unit/source-contract coverage, OpenAPI/UI language coverage and Web Server documentation.
- [x] Clean 0.25 README/Docs/CHANGELOG release material so current user-facing changes are separated from historical audits.
- [x] Add an authenticated `/docs` Markdown Reader with a separate API/OpenAPI tab, canonical docs index, 0.25 specification audit and open-work TODO.
## 0.24.10
Current direction: 0.24.9 -> 0.24.10
- [x] Make Error File a first-class revisioned configuration section and persist enabled/path/format across database reopen.
- [x] Remove the redundant Error File help sentence while retaining JSON/RAW controls and live apply.
- [x] Keep response-time history permanently expanded and add an accessible information popover matching Process.
- [x] Extend UI/API/source regressions and update release identity to 0.24.10.
## 0.24.9
Current direction: 0.24.8 -> 0.24.9
- [x] Add the encrypted Secret Store and migrate ACME Cloudflare/OVH/RFC2136 credentials away from persistent plaintext files.
- [x] Add password-encrypted full backup/restore with manifest validation, integrity checks, staging and rollback-safe activation.
- [x] Add the optional bounded asynchronous ERROR file sink with JSON/RAW formats, shared secret redaction and external logrotate integration.
- [x] Extend offline source/release contracts and update release identity to 0.24.9.
## 0.24.8
Current direction: 0.24.7 -> 0.24.8
- [x] Expand Cache RAM overview history with HOT usage/limit and transient total/fill/COLD queue/COLD I/O series.
- [x] Add COLD storage occupancy history without mixing disk bytes into process RAM.
- [x] Move the RSS/cache accounting caveat behind an accessible information control.
- [x] Extend offline UI/release contracts and update release identity to 0.24.8.
- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test` and browser/live-cache acceptance on the release host.
## 0.24.7
Current direction: 0.24.6 -> 0.24.7
- [x] Make the transform temporary-memory budget configurable and persisted with the normal SQLite-backed transform settings.
- [x] Add a process-wide maximum concurrent transformation limit with fail-open pressure behavior.
- [x] Add persisted RAM saver / Recommended / Performance / Uber performance resource presets while keeping transform types/extensions independent.
- [x] Add UI budget safety warnings and manual-edit -> Custom preset behavior.
- [x] Expose live transform RAM/concurrency, process/cache RAM accounting and COLD disk occupancy in the dashboard.
- [x] Clean externally reported cache/CSS compiler warnings without lint suppression.
- [x] Rotate cache compatibility identity for global transform representation/eligibility changes but not resource-only capacity metadata.
- [x] Extend OpenAPI, SQLite persistence, memory-contract and release/source regressions and update release identity to 0.24.7.
## 0.24.6
Current direction: 0.24.5 -> 0.24.6
- [x] Persist selected HOT/COLD cache preset intent in the SQLite-backed configuration and restore it in Settings.
- [x] Add a Recommended COLD policy preset without overriding disk capacity/path choices.
- [x] Bound transform aggregation/decompression/concurrency memory and remove avoidable JSON/HTML/CSS allocation churn.
- [x] Bound stale revalidation, proactive refresh scheduling, COLD cleanup selection/concurrency and delete fallback tasks.
- [x] Audit the remaining long-lived/background workers for queue/task cardinality and cancellation ownership.
- [x] Add Rust-independent memory contract regression checks and update release identity to 0.24.6.
## 0.24.5
Current direction: 0.24.4 -> 0.24.5
- [x] Resolve unknown-length, actually empty HTTP/3 bodies before HTTP/1.1 upstream framing.
- [x] Preserve streaming for non-empty H3 bodies and declared `Content-Length` requests.
- [x] Add source-level regression coverage for DELETE and other body-capable methods.
- [x] Bound HOT by allocation-aware admission, enforce `max_object_bytes` during fill, and add a separately bounded transient/COLD-queue memory model.
- [x] Preserve HOT -> COLD ownership semantics while charging demoted payloads until persistence drops them; expose logical/allocated/transient telemetry.
- [x] Make global cache disable authoritative over vhost overrides and remove the artificial 1 GiB HOT/object ceiling.
- [x] Add host/cgroup-aware conservative/recommended/performance cache presets and non-blocking memory/headroom warnings in Settings.
- [x] Update source/package/UI/OpenAPI identity and offline release checks to 0.24.5.
## 0.24.4
Current direction: 0.24.3 -> 0.24.4
- [x] Make the Linux THP mitigation process-local with `PR_SET_THP_DISABLE` before Tokio worker creation.
- [x] Preserve the existing glibc arena limit and allocator-trim behavior.
- [x] Add source/release regression coverage for THP opt-out ordering and release identity.
## 0.24.3
Current direction: 0.24.2 -> 0.24.3
- [x] Reduce Linux/glibc arena retention by adding `MALLOC_ARENA_MAX=2` to managed systemd units.
- [x] Remove redundant identity-body and JavaScript/CSS transformation allocations; release successful-transform temporaries earlier and trim large glibc transform churn at a bounded cadence.
- [x] Move HTTP/3 enablement to listener editing while keeping HTTP/3 tuning under HTTP connections.
- [x] Rename Proxy Process to Process and hide unsupported platform diagnostics dynamically.
- [x] Remove the stale unreferenced per-BSD parser source from the release archive.
- [x] Update source/package/UI/OpenAPI identity and Rust-independent release gates to 0.24.3.
## 0.24.2
Current direction: 0.24.1 -> 0.24.2
- [x] Bump package/source/UI/OpenAPI release identity to 0.24.2 without functional changes.
## 0.24.1
Current direction: 0.24.0 -> 0.24.1
- [x] Replace the duplicated BSD statistics implementation with shared cross-platform library APIs.
- [x] Preserve Linux-specific `/proc`/cgroup diagnostics as additive compatibility fields.
- [x] Populate additional portable system/process metrics exposed by the selected libraries where available.
- [x] Expose low-risk runtime metadata through the authenticated status API, including the resolved data/database path.
- [x] Make CLI installed-data discovery use Linux and BSD/OPNsense conventions without hardcoding the path in the browser.
- [x] Move Logging into Settings and retain legacy `/logging` navigation compatibility.
- [x] Fix the release-host compiler error in portable uptime sampling and remove the stale H2 import warning.
- [x] Remove artificial hostname-count ceilings from certificate bindings/issuance and hostname-list configuration while retaining syntax, payload and resource-safety bounds.
- [x] Update source/package/UI/OpenAPI identity and source-side regression contracts to 0.24.1.
- [ ] Generate and commit a resolver-produced `Cargo.lock`, then run compiler-enabled `cargo fmt --all`, `cargo check --locked --all-targets` and `cargo test --locked --all-targets` on the release host.
The 0.24.1-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.24.0
Current direction: 0.23.3 -> 0.24.0
- [x] Graceful H2 idle retirement with activity/stream recheck, GOAWAY and bounded drain.
- [x] Independent H2 maximum connection lifetime and drain timeout settings.
- [x] Settings API/UI/OpenAPI contracts for all three H2 lifecycle limits.
- [x] Extract system statistics into platform backends without changing Linux metric semantics.
- [x] Add FreeBSD/OpenBSD/NetBSD native best-effort sampling and explicit unsupported/null metrics.
- [x] Surface platform in Proxy Process and Prometheus metadata.
- [x] Update source/package/UI identity and source-side regression contracts to 0.24.0.
The 0.24.0-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.23.3
Current direction: 0.23.2 -> 0.23.3
- [x] Add opt-in per-vhost legacy WebSocket compatibility without changing ordinary HTTP behavior.
- [x] Force legacy-mode upstream WebSocket handshakes to HTTP/1.1 and force legacy WebSocket `Host` to the configured backend authority and align `Origin` with it.
- [x] Tolerate old `101` response framing while keeping the WebSocket accept proof and subprotocol validation strict.
- [x] Add dashboard/OpenAPI/language-pack controls and source regressions.
- [x] Update package/source identity and Rust-independent release gates to 0.23.3.
The 0.23.3-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.23.2
Current direction: 0.23.1 -> 0.23.2
- [x] Let Cache and Transformation live charts retain 60, 120, 300 or 600 samples without changing the 3-second polling cadence.
- [x] Persist each chart history-window choice locally in the browser and keep history memory bounded.
- [x] Fix local CLI administration transport detection so HTTPS administration no longer traps `--livelogs` in a 308 reconnect loop.
- [x] Pin local CLI HTTPS to certificates from the private RustProxy certificate directory and reuse the transport for config/TLS CLI calls.
- [x] Update package/source identity and Rust-independent release gates to 0.23.2.
The 0.23.2-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.23.1
Current direction: 0.23.0 -> 0.23.1
- [x] Add default-collapsed, full-width live activity charts to the Cache and Transform workspaces.
- [x] Plot selected-scope Cache HIT/MISS/BYPASS rates from runtime counters without adding proxy-path work.
- [x] Plot selected-scope Transform applied/cache-HIT/skipped/error rates from the existing runtime metrics endpoint.
- [x] Reuse the dashboard SVG/chart styling, live 3-second refresh and language-pack model.
- [x] Update package/source identity and Rust-independent release gates to 0.23.1.
The 0.23.1-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.23.0
Current direction: 0.22.3 -> 0.23.0
- [x] Add scoped external certificate API authentication with Key ID + Bearer Token, hash-only storage, rotation/revoke/status and last-used metadata.
- [x] Restrict the external credential to certificate deploy/reload and require HTTPS outside loopback.
- [x] Add Settings → Certificate API UX and preserve direct production binary commands for local deploy/reload.
- [x] Add `rust-proxy --livelogs` with a bounded live stream, interactive structured filters, detailed request inspection and explicit lag reporting.
- [x] Split Settings → Cache into General, Resilience, Cold cache and Static & diagnostics sub-tabs without changing the atomic cache settings document.
The 0.23.0-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.22.3
Current direction: 0.22.2 -> 0.22.3
- [x] Add HTTP/1.1 `Upgrade: h2c` on opt-in cleartext H2 listeners while preserving prior-knowledge h2c.
- [x] Validate Upgrade/HTTP2-Settings and reject malformed or body-bearing bootstrap requests before `101`.
- [x] Reconstruct the initiating H1 request as HTTP/2 stream 1 and retain the normal Hyper H2 path for later streams.
- [x] Add dedicated certificate hot reload without mutating desired configuration or revision.
- [x] Add external certificate deployment for Certbot/manual issuers with immutable generation staging and atomic multi-vhost switch by certificate id.
- [x] Add API/CLI/panel support plus audit/OpenAPI/documentation for certificate reload/deploy.
The 0.22.3-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.22.2
Current direction: 0.22.1 -> 0.22.2
- [x] Add replay-safe failover retry with bounded policy and observability.
- [x] Correct explicit HTTP/2 upstream request versioning and retain h2c prior-knowledge regression coverage.
- [x] Add per-backend maximum concurrent proxy exchanges with bounded queue timeout / 503 behavior and metrics.
- [x] Add detailed upstream failure classification plus last failure and health ejection/recovery timestamps.
- [x] Consolidate backend operations data in the existing dashboard table: health, active/limit, requests/errors, latency, traffic, last failure and last recovery.
- [x] Parse and expose public-certificate issuer/SAN/validity/remaining-days/expiry status plus renewal status/last attempt/error.
The 0.22.2-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.22.1
Current direction: 0.22.0 -> 0.22.1
- [x] Move selective PURGE/BAN and active BAN management into dedicated Cache modals with inline operation feedback.
- [x] Add per-vhost RFC 8441 HTTP/2 WebSocket Extended CONNECT beside RFC 9220.
- [x] Bridge H2 RFC 8441 WebSockets to H1 upstreams and preserve Extended CONNECT to explicit H2 upstreams.
- [x] Add opt-in cleartext HTTP/2 prior-knowledge (`h2c`) listeners.
- [x] Add stable active-tunnel inventory, per-tunnel kill, operator reason and audit logging.
- [x] Split WebSocket tunnel statistics by H1 / H2 RFC 8441 / H3 RFC 9220 on the main dashboard and Prometheus.
- [x] Preserve the 0.22.0 cache feature set and `Accept-Ranges` policy.
- [x] Update 0.22.1 source/package identity and Rust-independent release gates.
The 0.22.1-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.21.0
Current direction: 0.20.0 -> 0.21.0
- [x] Shard Cold Cache storage and add bounded asynchronous GC plus selective cleanup.
- [x] Add RFC 5861 stale windows, request `max-stale`, correct request `max-age=N`, and correct revalidation-directive semantics.
- [x] Add the extensible decode -> transform -> canonical cache -> encode stage with transformed lengths/validators, strict `no-transform`, HEAD/fill safety and canonical `Vary: Accept-Encoding` handling.
- [x] Add global, per-vhost and Docker-discovery transformation controls and administration UI/API coverage.
- [x] Make Passive Backend Health explicit opt-in for ordinary and Docker vhosts while preserving independent active health checks and unconditional observability.
- [x] Keep route cache override tri-state and expose effective route/cache/transform diagnostics.
- [x] Close 0.21.0 with the current conservative JS/CSS transformers; defer parser-backed replacements to a later development cycle.
- [x] Finish release polish: backend timing header rename, human-readable uptime, transformed Live traffic series, Docker Passive Backend Health layout fix, warning cleanup and cache module split.
- [x] Keep package/source identity and OpenAPI at 0.21.0 and package the completed feature scope as the 0.21.0 release source.
The 0.21.0-era notes are summarized in the retained [technical changelog archive](CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.20.0
Current direction: 0.19.4 -> 0.20.0
- [x] Split Certificates into Installed / Renewal / Generate tabs.
- [x] Move generation/import into the tabbed workspace and keep existing certificate actions compatible.
- [x] Improve ACME/renewal status readability and display exact Certbot log locations on failures.
- [x] Update package/source identity, OpenAPI and release checks to 0.20.0.
## 0.19.4
Current direction: 0.19.3 -> 0.19.4
- [x] Normalize Docker-vhost Aliases field geometry with neighboring inputs and remove the extra inherited label spacing.
- [x] Retain `0.0 KiB/s` idle and `<0.1 KiB/s` positive low-rate backend traffic formatting with release regression coverage.
- [x] Add Cache domain-table filtering by resolved cache-enabled state without changing aggregate cache statistics.
- [x] Update package/source identity, OpenAPI and release checks to 0.19.4.
## 0.19.3
Current direction: 0.19.2 -> 0.19.3
- [x] Preserve the exact panel and Settings tab through session expiry/re-login without shell flicker.
- [x] Canonicalize `/configuration` into persistent Settings tab routing.
- [x] Add shared save-button busy state and field-focused validation feedback.
- [x] Preserve certificate import file selections after failed saves.
- [x] Add per-Settings-tab unsaved-change indicators, including Manual config.
- [x] Add persisted dark/light administrator theme selection and consolidate theme-sensitive CSS tokens.
- [x] Keep Live Logs column selection compact with standard checkboxes and no internal scrollbars.
- [x] Add a fail-soft embedded-config release checker with hourly default cadence and CLI/environment overrides.
- [x] Keep the release checker compiler-compatible by mapping boxed HTTP body collection failures explicitly into `anyhow::Error`.
- [x] Reduce the footer to API latency/dropped logs and fix the last broad Live Logs checkbox sizing override.
- [x] Update package/source identity, OpenAPI and release checks to 0.19.3.
## 0.19.2
Current direction: 0.19.1 -> 0.19.2
- [x] Restore administration-backend local-listener risk detection for startup-locked listeners and loopback aliases; make vhost discard restore the persisted draft baseline.
- [x] Use descriptive status names for default responses.
- [x] Add an on-demand 4096-entry live log buffer that lasts until the log window closes.
- [x] Align inline checkboxes and reduce Docker-vhost alias editor height.
- [x] Render idle backend traffic as `0.0 KiB/s` and positive sub-0.1 KiB/s traffic as `<0.1 KiB/s`.
- [x] Remove cache diagnostics/validators from locally generated placeholder/default pages and enforce `no-store`.
- [x] Allow safe vhost-ID rename with collision protection and operator warning.
- [x] Update package/source identity, OpenAPI and release checks to 0.19.2.
## 0.19.1
Current direction: 0.19.0 -> 0.19.1
- [x] Repair Default responses section serialization/server routing.
- [x] Isolate cache objects by frontend scheme.
- [x] Keep a 256-entry live log window with 4096 retained entries available on demand.
- [x] Polish backend traffic units, checkbox/column UI and Docker-vhost Advanced layout/alias spacing.
- [x] Remove stale screenshots and index historical/version-labelled documentation.
- [x] Restore source-package test files/fixtures required by the release gates.
- [x] Update package/source identity, OpenAPI and release checks to 0.19.1.
## 0.19.0
Current direction: 0.18.8 -> 0.19.0
- [x] Persist per-admin Live Logs column visibility in SQLite without affecting JSON export or proxy configuration revisions.
- [x] Remove the duplicate top-right version badge.
- [x] Carry the legacy X.509 `tls_verify=false` compatibility fix and expanded upstream diagnostics into the release.
- [x] Add schema-v4 migration, administration API coverage and update package/source identity to 0.19.0.
## 0.18.8
Current direction: 0.18.7 -> 0.18.8
- [x] Use hostname-style chips for Docker-vhost aliases.
- [x] Add per-app HTTP-to-HTTPS redirect, WebSocket and cache switches for Docker vhosts.
- [x] Add an agent-level default ACL-profile selector without removing per-app ACL overrides/inline rules.
- [x] Remove the compression-codec explainer from Overview.
- [x] Update package/source identity, API schema and release checks to 0.18.8.
## 0.18.7
Current direction: 0.18.6 -> 0.18.7
- [x] Keep Response time history collapsed by default without stopping its live samples.
- [x] Make per-interface RX/TX charts visually consistent with the aggregate network history.
- [x] Fix expanded Docker-vhost route/ACL field alignment from the 0.18.6 UI.
- [x] Update package/source identity and release checks to 0.18.7.
## 0.18.6
Current direction: 0.18.5 -> 0.18.6
- [x] Add network-throughput diagnostics split by non-loopback host interface.
- [x] Re-audit compression counters and add q-weighted negotiation coverage for all four supported codecs.
- [x] Polish Docker-agent settings alignment and spacing around listener selection / Add agent.
- [x] Update package/source identity and release checks to 0.18.6.
## 0.18.5
Current direction: 0.18.4 -> 0.18.5
- [x] Make compression counters track the codec actually applied by RustProxy and add independent per-codec regression coverage.
- [x] Split the Overview request-rate chart into total, HTTP/1.1, HTTP/2 and HTTP/3 series.
- [x] Add a proxied-app launcher to Docker vhosts and show their domains beside generated backend-pool IDs.
- [x] Remove the global Open proxy shortcut.
- [x] Make Compact density clearly tighter than Dense.
- [x] Give proxy timeout controls explicit safe defaults instead of blank inherited values.
- [x] Replace Docker-agent listener CSV entry with the named-listener picker and tighten the agent status-row UX.
- [x] Update package/source identity and release checks to 0.18.5.
## 0.18.4
Current direction: 0.18.3 -> 0.18.4
- [x] Make TCP the default backend health-check mode without rewriting existing explicit HTTP/off configuration.
- [x] Make Docker vhosts summary-first so large container inventories do not render every routing/ACL/timeout/health control at once.
- [x] Simplify Settings -> Security with progressive disclosure for credential and host-policy editing.
- [x] Move Administration audit to a dedicated on-demand modal.
- [x] Update package/source identity and release checks to 0.18.4.
## 0.18.3
Current direction: 0.18.2 -> 0.18.3
- [x] Export Docker vhosts/backends under dedicated Prometheus metric families rather than ordinary vhost/backend families.
- [x] Add a separate live Docker-vhosts metric-group switch in Prometheus settings.
- [x] Cover Docker vhost traffic/duration and backend health/latency/probe/runtime observations with bounded labels.
- [x] Add regression checks for ordinary-vs-Docker metric isolation.
- [x] Add configurable separate access-log files per vhost and preserve bounded asynchronous sink behavior.
- [x] Lock startup-owned HTTP/HTTPS/admin binds while keeping non-startup listeners editable.
- [x] Polish compact UI, 404 response statuses, redirect summaries, global TLS cipher visibility and managed-certificate lifecycle UX.
- [x] Default WebSocket proxying to HTTP/1.1 Upgrade and make RFC 9220 HTTP/3 Extended CONNECT an explicit experimental vhost opt-in.
## 0.18.2
Current direction: 0.18.1 -> 0.18.2
- [x] Serve the standalone Docker agent over HTTPS by default using a persistent self-signed ECDSA certificate.
- [x] Add SHA-256 certificate pinning for self-signed Docker agents while retaining normal public-CA validation when no pin is configured.
- [x] Add per-service Docker backend health-check settings backed by the normal bounded backend health machinery.
- [x] Show Docker backend health/runtime diagnostics and agent TLS/observation state in the Docker vhosts UX.
## 0.18.1
Current direction: 0.18.0 -> 0.18.1
- [x] Add a standalone, token-authenticated Docker discovery agent that reports only running containers with non-loopback published TCP ports.
- [x] Add multi-agent endpoint/token/base-domain/listener/default-IP settings and keep observed Docker inventory separate from desired persisted assignments.
- [x] Detect multiple usable Docker-host addresses and preserve every public Docker bind for each discovered service.
- [x] Add a Docker vhosts panel with short names, arbitrary FQDNs, aliases and per-service backend-IP selection; reconcile assignments into dynamic ephemeral vhosts without revision writes.
- [x] Move the cache Live toggle to the right edge of the cache action toolbar.
- [x] Remove all dedicated `X-Rust-Proxy-Revision` handling; it is no longer generated, stripped, protected or migrated.
- [x] Replace production password recovery help with the installed `rust-proxy admin reset-password` command.
## 0.17.0
Current direction: 0.16.1 -> 0.17.0
- [x] Add Debian/systemd/logrotate packaging and local APT-repository generation.
- [x] Export all currently implemented bounded process/OS diagnostics and extend safe Prometheus coverage for backend, GeoIP, certificate renewal, stream and logging state.
- [x] Add explicit trusted-CIDR Prometheus scraping without a token and preserve actual-peer-only ACL evaluation.
- [x] Ship a Grafana operations dashboard.
- [x] Add dedicated certificate/listener-aware local-response / 404 hosts and a dashboard editor.
- [x] Keep the built-in welcome placeholder `no-store` and strip optional response metadata.
- [x] Match Redirect source-domain editing UX to Vhost hostname chips.
## 0.16.1
Current direction: 0.16.0 -> 0.16.1
- [x] Keep revision CAS as an internal concurrency mechanism but stop showing revision numbers in successful configuration-save feedback.
- [x] Preserve all 0.16.0 proxy/cache/H3/admin/UX behavior and bump release identity to 0.16.1.
## 0.16.0
- [x] Keep the main vhost backend as an internal fallback separate from Custom locations, with legacy synthetic-root normalization.
- [x] Preserve the reserved administration session cookie only for an explicitly allowed, TLS-protected self-admin reverse-proxy route; ordinary upstreams remain isolated.
- [x] Complete backend WebSocket takeover before HTTP/3 Extended CONNECT success and add phase/error/reset/upstream diagnostics to access logs.
- [x] Split process-memory and cache-memory reporting into RSS categories plus hot/transient fill/cold-queue/cold-I/O gauges while retaining the existing allocator-release paths.
## 0.15.1
Current direction: 0.15.0 -> 0.15.1
- [x] Repair EN/PL Custom locations/help translations and reject misplaced language-pack keys in release checks.
- [x] Rework visual header-rule editing and add a live effective-behavior preview to each location.
- [x] Expand in-panel location documentation with structured sections and common examples.
- [x] Add combined HIT/MISS/BYPASS activity and Proxy process CPU/RSS charts.
## 0.15.0
Current direction: 0.14.0 -> 0.15.0
- [x] Add Nginx-style Custom location matching, rewrite and local-response controls with inline documentation and a visual header-rule builder.
- [x] Add reusable Nagios/Icinga API checks for control-plane latency, backend availability/latency and dropped-work counters.
- [x] Move per-vhost timing/Cloudflare diagnostics to the main frontend configuration and add source IP to live logs.
- [x] Add dashboard history charts for RAM cache, network throughput and response latency plus compact API/footer diagnostics.
- [x] Add separate idle-age and total-hot-residency cold-cache policies.
- [x] Remove the runtime renewal-observation JSON importer and quarantine obsolete state only during a stopped source upgrade.
- [x] Accept repeated literal path separators by normalizing them for routing/ACL decisions while preserving the original backend target.
## 0.14.0
Current direction: 0.13.2 -> 0.14.0
- [x] Accept explicitly allowlisted browser-facing administration DNS authorities behind a trusted reverse proxy without requiring the internal admin port.
- [x] Keep direct IP/localhost administration access on the configured admin port and derive same-origin/cookie security from trusted external proxy metadata.
- [x] Implement RFC 9220 WebSocket Extended CONNECT over HTTP/3 without a side-channel transport or HTTP/1.1 bootstrap fields on the client-facing H3 request.
- [x] Move the H3 stack to `hpx-h3 2.5.19` with integrated Quinn transport and advertise Extended CONNECT support.
- [x] Rename the requested dashboard sections/actions in EN/PL and update release contracts/documentation.
## 0.13.2
Current direction: 0.13.1 -> 0.13.2
- [x] Honor a valid administrator session on direct panel refresh and revalidate stale browser-history login documents.
- [x] Force hot-cache handoff to enabled cold storage during managed updates and provide a longer controlled-stop budget.
- [x] Store automatic-renewal observations in SQLite schema v3 and restore them before the administration listener starts, with one-time legacy JSON import.
- [x] Move the raw configuration document editor under **Settings -> Manual config** while preserving `/configuration` as a compatibility alias.
- [x] Update documentation, OpenAPI, language help, offline regressions and release identity.
## 0.13.1
Current direction: 0.13.0 -> 0.13.1
- [x] Extend administrator browser sessions to a configurable absolute 7–30 day lifetime, default 30 days.
- [x] Keep password change, logout and revoke-all as immediate session invalidation boundaries.
- [x] Allow deliberate local-listener backends, including port 9090, only behind explicit per-backend risk acceptance while retaining private/special-address policy.
- [x] Add panel confirmation, OpenAPI fields, EN/PL strings, documentation and regressions.
## 0.13.0
Current direction: 0.12.1 -> 0.13.0
- [x] Add first-class redirect hosts and dashboard navigation.
- [x] Support scheme/path matching, URL/host/path targets, 301/302/303/307/308, path/query and redirect-cache controls.
- [x] Share named listeners and certificate inventory with redirects while rejecting conflicting frontend ownership.
- [x] Default backend HTTPS `tls_verify` to false when omitted; keep explicit opt-in verification.
- [x] Compact certificate inventory cards, render DNS SANs as vhost-style badges and inspect legacy/manual public PEM files when stored coverage metadata is absent.
- [x] Update OpenAPI, docs, translations, regressions and release identity.
## 0.12.1
Current direction: 0.12.0 -> 0.12.1
- [x] Split ordinary configuration writes into revisioned section endpoints and retain atomic full-document publication for intentional multi-section changes.
- [x] Stop unrelated control-plane saves from rotating cache compatibility identity.
- [x] Preserve dashboard subpage routing across browser refresh/back-forward navigation.
- [x] Add independently selectable cold-cache idle/LRU/shutdown persistence policies.
- [x] Flush hot cache objects to cold storage during graceful shutdown and release RAM after bulk demotion where the allocator supports it.
- [x] Expose certificate DNS SubjectAltName coverage separately from configured SNI bindings.
- [x] Improve HTTPS backend TLS validation guidance while preserving secure-by-default omitted configuration and the explicit debug preset.
- [x] Update OpenAPI, regression checks and release identity to 0.12.1.
- [ ] Compile and execute the Rust test suite on a Rust 1.88+ staging host.
## 0.12.0
Current direction: 0.11.5 -> 0.12.0
- [x] Move Layer-4 TCP/UDP stream management out of Settings into a dedicated main-navigation page directly below Vhosts.
- [x] Give Streams the vhost-style searchable card + modal workflow with explicit active-versus-desired restart state.
- [x] Add fixed-cardinality per-stream connection/session, reject, error, byte and UDP packet telemetry without database work on the data path.
- [x] Add browser-session RX/TX rate charts derived from monotonic backend stream counters.
- [x] Restyle named listener bind addresses with the same chip interaction used for vhost hostnames.
- [x] Extend offline source/release regression gates for Streams navigation, authenticated assets, UI contracts and L4 metrics source invariants.
- [x] Stop successful unsafe methods from globally purging otherwise unrelated cached GET objects.
- [x] Use Host + URL as the logical primary cache identity and response `Vary` for representation variants instead of hashing unrelated request headers/backend members.
- [x] Share cache objects across backend-pool members and GET/HEAD requests.
- [x] Preserve resident age through 304 revalidation and hot/cold movement; persist the additional cold metadata in format 2.
- [x] Add variant-schema safety and cache regression coverage for the new behavior.
- [x] Make Force SSL require a real HTTP ingress and choose port 443 deterministically when several TLS ports are selected.
- [x] Add a graphical ACL-profile rule editor and resilient bulk-rule import with exact line errors.
- [x] Hide RustProxy cache diagnostics when a vhost explicitly disables cache.
- [x] Distinguish disabled vhost pools from unhealthy backends in the dashboard.
- [x] Add per-vhost client-IP forwarding control without weakening RustProxy ACL/GeoIP/rate-limit/Tinyauth identity.
- [x] Add a compact vhost search/filter for large installations.
- [x] Keep the checked-in Cargo lockfile stable and correct the Quinn manifest version to the resolved release.
- [x] Make the standard JSONL log directory writable under the hardened systemd service.
- [x] Accept HTTP/2/H3 `:authority` on the HTTPS administration panel without requiring a duplicate legacy Host header.
- [x] Reject ambiguous requests where legacy Host conflicts with protocol-native authority, while preserving strict HTTP/1.x origin-server semantics.
- [x] Add administration H2 authority and `/healthz` regression tests.
- [x] Add per-vhost Force SSL with an HTTP 308 redirect to the configured TLS frontend while preserving path/query.
- [x] Preserve NPM `ssl_forced` in the SQLite migrator as RustProxy Force SSL with usable HTTP+HTTPS listener bindings.
- [x] Fix managed update/rollback health checks for persisted administration HTTPS and changed admin bind/Host policy.
- [x] Add a dry-run-first SQLite migration tool for Nginx Proxy Manager proxy-host names and backends, with generic RustProxy TLS certificate fallback and manual-review warnings for unsupported NPM features.
- [x] Add offline migration-planner and HTTPS health-target regression coverage.
- [x] Add reusable ACL Profiles with country allow/block and IP/CIDR policy, attachable per vhost while retaining cumulative inline vhost and route ACL controls.
- [x] Add chip-style hostname editing and leading `*.` wildcard domains with exact-host and longest-wildcard precedence.
- [x] Add compact browser-session request-rate charts to vhost cards with progressive lifetime compaction.
- [x] Add the built-in HTTP 401 error page and treat benign post-upgrade WebSocket closes as normal tunnel completion.
- [x] Redirect plaintext requests arriving on the HTTPS administration socket and accept valid DNS Host values when no explicit admin hostname allowlist is configured.
- [x] Allow one named frontend listener policy to bind up to 16 explicit local addresses while forbidding wildcard binds in multi-address mode.
- [x] Align the Logging format builder with the Vhost configuration modal and reposition its field-configuration action.
- [x] Make source ZIP generation deterministic and verify reproducibility in release smoke tests.
- [x] Add bounded hot RAM and optional persistent cold disk cache tiers with asynchronous demotion/promotion, restart recovery, integrity metadata and separate hot/cold statistics.
- [x] Add cold-tier settings for path, disk/object budgets and idle demotion time while preserving global hot RAM limits.
- [x] Add structured-log format presets plus a visual field selector/renamer with JSON preview and advanced raw mapping fallback.
- [x] Normalize Logging sink cards to the shared application card styling instead of release-specific fieldset CSS.
- [x] Add baseline multi-stage Docker/OCI packaging with an unprivileged runtime user and persistent data volume.
- [x] Document browser hard-reload cache revalidation semantics and expose the exact request revalidation reasons in the cache inspector.
- [x] Redesign vhost cards around listener/protocol/certificate/cache identity plus live pool health, requests, active exchanges, errors, latency and traffic rate.
- [x] Add bounded structured JSON access-log sinks for JSONL files, Logstash TCP and Loki HTTP(S), with configurable field mapping/static fields and sink counters.
- [x] Expand cache object inspection with body bytes, resident bytes, overhead and aggregate stored-object memory counters.
- [x] Add optional `X-Cache-Resident-Age` and expose resident age for stored HITs.
- [x] Add administration HTTPS using the application-managed default certificate or a selected certificate, with restart-aware persisted bind address and hostname allow list.
- [x] Permit non-loopback administration binding when HTTPS is enabled; keep plain remote HTTP behind the legacy explicit risk gate.
- [x] Add global TLS protocol/cipher policy plus per-vhost TLS 1.2/1.3 and cipher overrides for TCP TLS. HTTP/3 vhosts inherit the global TLS 1.3 policy.
- [x] Make Settings tabs fill the available width and add dedicated Administration/TLS policy pages plus a separate Logging navigation page.
- [x] Enable shared cache by default while keeping explicit vhost/route disable controls.
- [x] Implement fresh conditional HIT for `If-None-Match` / `If-Modified-Since`.
- [x] Implement backend validator revalidation and cached-body reuse on backend 304.
- [x] Treat request/response `no-cache` as validation semantics rather than no-store; preserve `no-store` as hard exclusion.
- [x] Retain stale validators for bounded revalidation without stale serving.
- [x] Raise maximum object configuration to 1 GiB and remove eager object-sized fill allocation.
- [x] Add diagnostics, real-binary smoke scenarios, unit regressions and 0.8.1 release documentation.
- [x] Per-vhost H2/H3 frontend opt-outs next to named-listener selection, capability-gated by Settings listeners.
- [x] Global static-cookie cache defaults for common assets, visible global/per-vhost controls and response-side privacy guards.
- [x] Listener editor uses page scrolling without a nested scrollbar.
- [x] Per-vhost fixed-window request rate limiting with burst allowance.
- [x] Per-country rate overrides and block/allow-only country access rules backed by verified install-time GeoIP data.
- [x] Cloudflare client IP mode gated by direct-peer Cloudflare CIDRs.
- [x] Per-vhost H2/H3 frontend opt-outs and Server-Timing diagnostics.
- [x] Named listener selection in vhosts; listening IP/ports managed in Settings.
- [x] Health-check `off` disables active and passive classification.
- [x] HTTPS-by-IP backend debugging defaults and warnings.
- [x] Global static-cookie cache defaults plus per-vhost inheritance/overrides with response privacy guards.
- [x] Corrected cache Age calculation.
- [x] Updated deployment regressions, docs and 0.8.0 release identity.
- [x] Release CLI can generate the one-time setup token before first server start.
- [x] Explicit two-flag risk gate for wildcard remote administration binding.
- [x] Numeric Host validation for the wildcard panel; DNS hostnames remain rejected.
- [x] Linux/systemd install defaults enable 80/443; explicit `--no-https` opt-out.
- [x] Updated deployment regressions, docs and 0.7.3 release identity.
- [ ] Build/check/test the Rust binary and exercise remote-panel/systemd behavior live.
- [x] Explicit release identity, without overwriting the 0.7.1 artifact.
- [x] Swagger asset default independent of environment presence/Cargo config lookup.
- [x] Embedded assets prepared by our build, without an adapter's old-version fallback.
- [x] Offline local ZIP, bounded member validation, optional SHA-256 and safe caching.
- [x] Executable build-helper regressions and updated build/migration instructions.
- [ ] Build/check/test real Rust and render the actual upstream Swagger distribution.
- [x] Per-location visual proxy-request and client-response header rules.
- [x] Minimal anonymous login document and filtered extensible language bundle.
- [x] Exact default-deny admin route/method policy including private assets and docs.
- [x] Protected OpenAPI/Swagger, cookie session and CSRF-confirmed mutations.
- [x] Generic public discovery/errors and cross-tab UI revocation cleanup.
- [x] Source audit, access probe and regression tests with explicit execution limits.
- [ ] Compile and run router/proxy tests, inspect real Swagger rendering, and run the
anonymous probe against the built release before production acceptance.
- [x] Scheduled renewal for HTTP-01, Cloudflare, OVH and RFC2136 managed profiles.
- [x] Validated revisioned hot certificate publication with unchanged/failure safety.
- [x] Per-certificate opt-out, manual check, dry run, next/last/expiry observations.
- [x] Separate install/update scripts; isolated service, full data backup and rollback.
- [x] Vhost Basic Auth with bounded Argon2id checks and optional credential forwarding.
- [x] Per-vhost WebSocket toggle and graphical cumulative CIDR ACL editing.
- [x] Visual prefix locations with pool exclusion for dedicated origins.
- [x] Neutral welcome page, opt-in protocol fixture, shared square modal/Settings style.
- [x] Independent proxy and local administrator control plane; no Docker dependency.
- [x] SQLite documents, revision CAS, immutable snapshots, audit and private backups.
- [x] Accounts, bounded sessions, setup capability, origin/CSRF protections.
- [x] Host/path routes, aliases, explicit private-origin permission and CIDR ACLs.
- [x] Streaming bodies and full-exchange limits; bounded logs, SSE and Prometheus.
- [x] Public TLS/SNI, verified HTTPS upstreams, guarded DNS, HTTP/2 and WebSocket source.
- [x] Whole-source upgrade, legacy auth-module repair and rollback on copy failure.
- [x] Persist public HTTP/HTTPS bind addresses and ports, including 80/443 presets.
- [x] Distinguish active listeners, desired listeners and startup defaults; show restart pending.
- [x] Allow remote origins on ordinary 80/443 without allowing local listener loops.
- [x] RAM cache with TTL/Age, entry/object/byte limits, LRU eviction and route bypass.
- [x] Bounded streaming fills; do not publish partial, failed, oversized or trailer bodies.
- [x] Privacy exclusions, conservative Vary identity and ACL-before-cache ordering.
- [x] Optional X-Cache, X-Cache-Hits and Cache-Status; counters and authenticated purge.
- [x] Invalidate old fill generations on purge, config publication and successful unsafe methods.
- [x] Streaming gzip, Brotli, Zstandard and deflate negotiation with privacy/no-transform safeguards.
- [x] Preserve/hide/custom Server policy; client-response and upstream-request header editors.
- [x] Denser responsive dashboard; original logo retained.
- [x] OS/process CPU, RAM/RSS, load, descriptors, threads, network, cgroup and cache diagnostics.
- [x] Compile-time language discovery from langs/*.json; metadata, fallback and RTL.
- [x] Extra language files survive managed source updates.
- [x] Log filtering by host/path/cache outcome and bounded JSON export.
- [x] Unit and real-binary integration test source; 49 simulated-API frontend checks.
- [x] Example unprivileged systemd service with low-port capability.
- [x] Remove reported compression threshold type mismatch without narrowing or panic conversion.
- [x] Explicit frontend IP/port/TLS ownership inside a vhost; actual-destination isolation.
- [x] Multiple backend members, weights, backup and drain modes.
- [x] Weighted round-robin, least active exchanges, least traffic, adaptive and IP hash.
- [x] Connection keepalive/reuse limits and timers, not a Keepalived/VRRP integration.
- [x] Bounded TCP/HTTP health checks, slow-response exclusion, fall/rise and recovery.
- [x] Opaque cookie/IP affinity; compatible edit continuity, safe cache bypass.
- [x] Full-exchange backend leases; no automatic replay of failed writes.
- [x] Per-vhost response/security headers, TLS-only HSTS and explicit preload policy.
- [x] HTTP/2 TLS preference retains HTTP/1.1 for clients and HTTPS origins.
- [x] Vhost editor, atomic legacy migration, health/traffic/latency dashboard and API metrics.
- [x] All new user-facing text in discovered language packs; logo unchanged.
- [x] Rust regression and real-binary acceptance test source; 63 simulated-API UI checks.
- [x] Complete source update, examples, migration documentation and evidence report.
- [x] Split oversized status JSON expansion instead of masking it with a larger recursion limit.
- [x] Optional per-vhost certificate upload/selection with bounded private staging and validated SNI mappings.
- [x] Independent vhost cache behavior, inherited defaults and shared resident/object ceilings.
- [x] **Selective cache invalidation by vhost**, promoted from Cache evolution; invalidate pending fills too.
- [x] Compact manager list and tabbed vhost dialog, visual header rules and live health/latency.
- [x] Dashboard density presets and top alignment, without redesigning the logo.
- [x] Separate modal/docked live logs with persistent controls, vhost filters, pause and scoped export.
- [x] Global/vhost error pages, maintenance/static response and direct-IP/unknown-host behavior.
- [x] Security shortcut within Settings; new labels remain in discoverable language packs.
- [x] Python-only random-data mock origin for cache, health, delay, compression and error-page development.
- [x] Regression test source for new runtime policies; executed frontend/helper checks in TEST_REPORT.md.
- [x] Canonical Forwarded/X-Forwarded-For/X-Real-IP with explicit bounded proxy trust; keep destination-IP routing independent.
- [x] Per-vhost Tinyauth auth subrequest, sanitized identity headers, restricted login redirects and fail-closed handling before cache/static/upgrades.
- [x] Separate certificate inventory tab with upload, per-vhost selection and metadata-only read API; leave ACME as future work.
- [x] Named multiple-IP listeners, descriptive scope, reusable vhost references and explicit legacy-route permission.
- [x] HTTP/3 UDP adapter using Quinn/h3 with TLS 1.3, same SNI/policy pipeline, Alt-Svc, requested limits and bounded drain; retain TCP H2/H1.
- [x] Request ID/revision off by default, independent per-vhost controls, optional mock-origin removal and effective per-vhost Server policy.
- [x] Actual frontend socket in cache identity; streaming-safe empty request detection for H2/H3.
- [x] Toast above active modal backdrop, including browser top-layer/inertness handling.
- [x] Python fake-auth peer plus Rust regression sources for auth, headers, extra-frontend cache, listeners and real QUIC/TCP.
- [x] Dedicated bounded cache panel: resident objects/bytes, per-domain effectiveness, TTL/Age/hits, reasons and vhost filters.
- [x] Remove the reserved administrator cookie before cache eligibility; preserve application-cookie privacy.
- [x] Handle empty terminating DATA frames and expose fixed X-Cache-Reason / Cache-Status detail diagnostics.
- [x] Replace special mock-origin hiding with explicit revision controls; keep general header rules.
- [x] Default HTTP/3 for TLS while persisting explicit opt-out and TCP fallbacks.
- [x] Diagnose overlapping sockets with addresses, ownership, protocol/limit differences and remedies.
- [x] Classify unavailable/invalid/timed-out upstreams as 503/502/504 respectively.
- [x] Basic Let's Encrypt DNS-01/RFC2136 job and library-import workflow through Certbot; private TSIG/account state and staging-first consent.
- [x] Offline adapter tests, browser fixtures and Rust regression sources; unchanged brand assets.
- [x] Vhosts-only runtime/UI and one-way legacy import preserving path/backend and IP ownership.
- [x] HTTP-01 active-job/domain-scoped challenge serving with private webroot storage.
- [x] Certbot Cloudflare and OVH API DNS-01 adapters alongside RFC2136.
- [x] Unified provider/import dialog, masked credentials, explicit consent and issuer metadata.
- [x] Dedicated opt-in Prometheus endpoint, scrape CIDRs and environment-token authentication.
- [x] Application/admin/listener/vhost/backend/cache/system collectors and exchange histograms.
- [x] Functional Settings tabs with preserved dirty fields, keyboard navigation and no Security sidebar.
- [x] Offline provider regressions, simulated-API UI checks and unexecuted Rust acceptance tests.
- [ ] Validate real HTTP-01, Cloudflare/OVH/RFC2136 DNS updates, CA issuance, cancellation cleanup and certificate hot-deployment.
- [x] Add the managed renewal scheduler and audited validated deployment source.
- [ ] Verify real renewal/publication and add external expiry alerts, revocation/account lifecycle.
- [ ] Resolve and retain Cargo.lock; run check, test and all real-binary suites.
- [ ] Verify real Tinyauth integration, revocation, trusted forwarding and independent HTTP/3 client interoperability.
- [ ] Run an independent RFC 9114/QUIC conformance suite, malformed-stream/load tests and actual TLS/UDP production deployment checks.
- [ ] Execute vhost isolation/weighted balancing/health/recovery/keepalive/affinity acceptance against a real Rust binary.
- [ ] Verify compression output with independent Brotli/Zstandard decoders.
- [ ] Run real TLS/HTTP2/WebSocket/cache concurrency, cancellation and graceful-drain tests.
- [ ] Benchmark HIT/MISS/streaming RSS and CPU under slow clients and origin failures.
- [ ] Fuzz malformed HTTP/cache directives and language-pack validation.
- [ ] Exercise real non-root 80/443 bindings and systemd service confinement.
- [ ] Verify native browser HTTP/CSP/cookie enforcement (navigation blocked in this environment).
- [ ] Validate the dedicated scrape endpoint/token with a real Prometheus server and production network policy.
- [x] X-Cache-Resident-Age - implemented in 0.9.0
## Optional module follow-ups
### Docker discovery
Implemented in 0.18.1 through the standalone `docker-agent/` provider boundary.
Future work can add provider-side filters, mTLS and DNS-provider automation; the core remains useful without Docker and never opens the Docker socket itself.
### Cache evolution
Conditional revalidation is implemented. Stale-while-revalidate/stale-if-error, request collapsing,
tag-based invalidation and richer per-route policy remain open. Persistent hot/cold storage is implemented in 0.10.0.
Selective invalidation by vhost is implemented in 0.3.0; the domain/object inspector in 0.5.0; tags are not. No VCL engine,
distributed cache, ESI or Varnish-protocol compatibility is implemented.
### Operations
Container/OCI packaging is implemented in 0.10.0. Certificate alerting/revocation and account lifecycle, reproducible releases,
performance baselines and cross-platform diagnostics remain open. Remote HTTPS administration is implemented in 0.9.0;
loopback remains the default and plain remote HTTP remains explicitly risk-gated.
-59
View File
@@ -1,59 +0,0 @@
# Secret Store and encrypted full backup/restore
RustProxy keeps application credentials in a central encrypted Secret Store. Provider and ACME secrets are not stored as plaintext configuration values in SQLite.
## Secret Store
Secret records live in the `secret_store` SQLite table and contain an identifier, provider/type, encryption version, nonce, ciphertext, metadata and timestamps. Values are encrypted with AES-256-GCM using a random local data key stored at:
```text
<data-dir>/secrets/master.key
```
The key file is created with mode `0600`; its directory is private. The application loads it during normal startup, so normal operation does not require an interactive password.
Existing ACME DNS credential files are migrated on startup for Cloudflare, OVH and RFC2136. After successful import, the legacy plaintext credential file is removed. Certbot-compatible credential files are recreated only as short-lived private files around the external operation and removed afterwards.
Do not copy only `config.sqlite3` as a Secret Store backup: the local key material is also required. Use the encrypted full-backup command below.
## Create a full backup
`backup create` may run while RustProxy is serving traffic. If the service is stopped, the CLI takes the data-directory lock; if the service is running, it creates a consistent SQLite snapshot and stages durable filesystem state without taking ownership of that lock. The output file must be outside the RustProxy data directory and must not already exist.
```bash
rust-proxy --data-dir /var/lib/rustproxy backup create \
--output /private/rustproxy-backup.rpb
```
When run from a TTY, RustProxy asks for the backup password without putting it in process arguments or shell history. Press Enter at the password prompt to generate a strong random password. A generated password is printed exactly once and is required for restore.
For non-interactive `backup create`, RustProxy generates the password automatically and prints it once to standard output. Protect that output as a secret.
The encrypted backup contains the durable application state needed for recovery, including:
- a consistent SQLite snapshot,
- configuration and Secret Store records,
- backup-wrapped Secret Store key material,
- certificates and private keys,
- ACME state and durable provider data,
- other durable RustProxy data accepted by the backup manifest,
- validated internal relative symlinks, including Certbot `live/` links into the same profile's `archive/` tree.
It excludes rebuildable/runtime data such as cache, logs, temporary files, sockets, PID/lock files, SQLite WAL/SHM sidecars and the managed Certbot Python environment (`acme-python`; the `acme_python` spelling is excluded as well). Certificate/ACME durable state inside the RustProxy data directory remains included; the virtual environment is rebuilt when needed. Manual certificates configured with absolute external paths (for example `/etc/letsencrypt/live/...`) are intentionally **not** copied into the backup: only the configured path remains in SQLite, so the external files or mounts must be restored separately. Symlinks inside the backed-up RustProxy data tree are accepted only when their target is relative and resolves inside that staged tree; absolute, escaping or dangling links fail backup/restore. Restore recreates accepted in-tree links as links rather than dereferencing them.
The outer container derives a backup key with Argon2id and encrypts authenticated chunks with AES-256-GCM. The backup password is independent from the local Secret Store key.
## Restore
Restore is CLI-only, requires RustProxy to be stopped, and requires an interactive TTY for the password:
```bash
rust-proxy --data-dir /var/lib/rustproxy backup restore \
/private/rustproxy-backup.rpb
```
Before replacing the destination, RustProxy verifies the encrypted container, password/authentication tags, manifest, checksums, backup format, RustProxy/schema compatibility, restored SQLite database and decryptability of all Secret Store records.
Files are first restored into a private staging directory. Only after validation succeeds is the staged data directory atomically activated; activation failure attempts to roll back to the previous directory. Sensitive restored files use restrictive permissions.
A lost backup password cannot be recovered by RustProxy.
+29 -212
View File
@@ -1,225 +1,42 @@
# Security model and limitations
# Security model and deployment boundaries
This is a development source release with no executed Rust integration results in
the packaging environment. Do not expose it as a production security boundary until
the actual build, protocol tests, deployment review and an appropriate security
assessment have passed.
RustProxy provides several security controls, but it is not a complete Web Application Firewall, PHP sandbox or network firewall. Safe deployment depends on frontend TLS, trusted origin connectivity, application updates, least-privilege operating-system access and release testing.
## Administration
## Administration and private state
Administration binds to loopback HTTP by default. Settings can enable a dedicated
administration HTTPS listener, choose the application-managed certificate or a
certificate from the inventory, persist a non-loopback bind and allow explicit DNS
Host/Origin names. Remote persisted binding is accepted only with administration TLS.
The normal installer, one-line bootstrap and reference service keep the plain-HTTP admin listener
on loopback by default. An explicit startup `--allow-remote-admin` can expose plain HTTP on a
non-loopback address; that path should remain behind a trusted firewall/private network boundary and
should be replaced by Administration HTTPS for direct remote use.
Passwords use salted Argon2id; password work has admission limits and runs off the
network I/O path. Setup requires local filesystem authority or the one-time random
capability. No default administrator/password is created.
- Keep the separate admin listener on **loopback** unless explicitly configuring trusted administration HTTPS and restricted reachability. Use an SSH tunnel for simple remote management.
- Create the first administrator locally. There are **no default passwords**. Protect CLI recovery operations, sessions, session revocation and API token creation.
- Keep the SQLite database, Secret Store encryption material, certificate keys, `.env`, ACME profiles, external credentials and backups readable only by trusted operators. `.env` and service configuration may be trusted executable/input material.
- Never expose stored logs, OpenAPI, detailed status, full config or backup files anonymously. Keep public Vhosts away from the administration listener except in a carefully reviewed explicit-risk topology.
Sessions have HttpOnly/SameSite=Strict cookies, idle and absolute expiries and
credential-generation revocation. Mutations require scheme-aware Host/Origin checks,
the request marker and a CSRF token. Local CLI reset changes the hash and invalidates
all existing sessions without editing SQLite manually. Administration HTTPS marks the
session cookie Secure; HTTP cannot. The admin certificate and bind changes are
restart-required. A dedicated metrics credential is separate from browser sessions.
## Origin and request trust
The data directory and SQLite files are private. The local OS user with data-directory
access is a recovery authority and can create short-lived CLI sessions. Protect that
account, filesystem and backup copies; browser authentication does not defend against
a compromised process owner. `.env` is trusted shell code, never untrusted input.
- Enforce IP/country/DDNS policies, rate limits and HTTP method rules **before** connecting to backends. A Custom location cannot undo a Vhost-level denial.
- Private and local backend addresses require deliberate exceptions. Routing to the local administration listener can expose secrets or create routing loops.
- HTTPS to a backend is not proof of identity unless upstream certificate/hostname verification is enabled; current backend defaults do **not** enable verification automatically.
- Honor forwarding identity headers only from correctly configured trusted peers. A client can forge arbitrary `X-Forwarded-*` and User-Agent headers.
- Restrict Basic Auth to HTTPS and protect Tinyauth endpoints. Authentication traffic should never be stored in public shared cache.
## Backend and routing boundary
## Content and cache privacy
Only configured HTTP/HTTPS origins may be dialed. Absolute-form client targets and
CONNECT cannot turn the proxy into an unrestricted forward proxy. ACL is evaluated
before new TCP/TLS connections and before reuse of an existing upstream connection.
Host and route restrictions combine; route allow cannot override host deny.
Forwarded headers are rebuilt from socket identity by default. An explicit
trusted-proxy CIDR policy can accept a bounded existing chain and derive the client
IP for ACL/affinity; see INTEGRATIONS_040.md. Routing still uses the actual local
destination, never any forwarded address or host.
- Shared cache must refuse private/no-store content and unsafe authenticated representations. Test behavior with cookies, `Authorization`, `Set-Cookie`, `Vary` and conditional requests.
- HTTP BAN/PURGE is disabled by default and must use a narrow allowlist if enabled. Never grant untrusted clients cross-site invalidation rights.
- Rewrites/transforms and custom headers can change semantics; verify that CSP, HSTS, CORS, validators and `Cache-Control` remain appropriate.
Private/loopback destinations require explicit approval. DNS answers pass the same
policy and optional per-backend CIDR restriction; an unsafe answer fails the entire
resolution. Validated results are used without a second DNS lookup. Special-address
blocks include common metadata/link-local targets and normalize mapped IPv6.
Listener self-target restrictions prevent simple loops and control-plane access by
default. A privileged administrator can deliberately set `accept_local_listener_risk=true`
on one backend; private/loopback destinations additionally require `allow_private=true`.
This is intended for consciously accepted local-listener use (including port 9090) and
can create a request loop or expose administration through a public vhost. It does not
relax metadata/link-local/special-address filtering or CIDR restrictions. Keep network
egress and frontend ACLs as defense in depth.
## Web Server and PHP
No backend `Location` redirect is followed internally. HTTPS transport encryption remains
available, but backend trust/hostname verification is off by default. Set `tls_verify: true`
for origins where certificate-chain and hostname authentication is required. With
verification disabled, a network attacker able to intercept that route may impersonate
the origin. Address/DNS restrictions still apply; the local-listener exception exists only
when explicitly accepted as above. Operator-supplied backend configuration is privileged
configuration, not a field that an unauthenticated caller can edit.
- Put credentials, source secrets and backups **outside document roots**. Directory listing and symlink relaxation are disabled by default; change only when deliberate.
- Run PHP-FPM as a separate controlled service. Give unrelated PHP applications distinct FPM pools to prevent cross-application INI/worker leakage.
- Treat `.htaccess` support as a restricted compatibility feature, not arbitrary Apache configuration. RustProxy hardening rules supplement but do not replace application security updates.
## TLS and tunnels
## Integrations and transport
Private key files are size-bounded, regular files, owner-only on POSIX and opened
without following final-component symlinks. Named files cannot escape the certificate
directory by using path separators. Keep the entire directory tree private as well.
Keys and certificate name coverage are validated before publication. Generated
self-signed certificates are development material, not automatic public trust.
- Docker agents have read-only inventory endpoints but their local Docker socket permission is highly privileged. Restrict the host account, port, Bearer token and pinned certificate.
- Prometheus has its own endpoint/network credential policy and no admin browser session. Keep it private or behind a secure tunnel.
- TCP/UDP Streams do **not** inherit HTTP ACL/auth/cache/TLS termination. Protect protocol endpoints separately.
- HTTP/3 needs UDP and should be tested with firewall, NAT and TLS behavior on the actual deployment.
- Managed ACME and external certificate reload integrations require least-privilege DNS/file/token access.
Existing TLS sessions/tunnels are not revoked by changing a certificate or ACL.
Policies apply to new requests and handshakes. Restart/stop the relevant process for
immediate termination until a scoped administrative close operation is implemented.
WebSocket payloads remain opaque byte streams. The proxy validates upgrade metadata,
not application-level authorization or message semantics. Frame/message limits on a
backend remain the backend's responsibility. Proxy buffers, tunnel/connection counts
and inactivity timers bound proxy-side resource usage.
Web Server PHP-FPM integration never forwards the client `Proxy` header as CGI `HTTP_PROXY`
(httpoxy protection), regardless of optional Web Service hardening. FastCGI response framing and
hop-by-hop headers are discarded and RustProxy owns downstream framing. PHP-FPM endpoints are
limited to Unix sockets or loopback TCP. Optional custom FastCGI parameters are validated static
operator values and cannot replace core CGI, `HTTP_*`, `PHP_VALUE` or `PHP_ADMIN_VALUE`; php.ini
overrides use a dedicated validated `PHP_VALUE` map. Configurable request-body, response-header
and stderr bounds keep the PHP connector resource usage explicit.
## Information and resource controls
Access logging does not collect credential headers, query values, cookies, bodies
or WebSocket payloads. Hostnames and paths are logged; never put secrets in them. Do not enable third-party verbose tracing or application logging without
reviewing their output separately. Access rings, viewer slots, auth work, updates,
DNS workers and request/connection admission are bounded. A slow log viewer does not
create an unbounded delivery queue.
Backups include TLS private keys and credential/session state. Restore matching
SQLite and certificate data together, then revoke restored sessions. Never commit
`.env`, the data directory, backups or generated certificates to a public repository.
## Not a completed security feature
Docker socket access controls, service-account metrics access,
2FA, roles, provider secret storage, certificate
expiry automation, a full special-address registry policy and independent fuzz/load
assessment remain roadmap work. Feature availability is not evidence of a completed
security audit or a performance guarantee.
## Cache, response transforms and ports (0.1.0)
Cache is enabled by default in 0.8.1 and ACLs precede lookup. Cookie (outside the
configured static-asset exception), Authorization, Range, unsafe preconditions and
request/response `no-store` remain hard shared-cache exclusions. Conditional validators
can be satisfied from a fresh object or revalidated against the origin. Response
`no-cache` with a usable validator may be retained only for mandatory validation before reuse. Origin privacy is
checked before custom header removal, and generated request IDs are not cached. Bounded
fill permits, object/entry/byte limits and generation checks protect cancellation,
update and purge paths. Purge is authenticated, CSRF-protected and audited, not a
public PURGE/BAN capability.
Compression is opt-in, with authenticated/private/no-transform exclusions. Response
rules cannot overwrite protected framing/identity fields. For compatibility with 0.26.6,
custom `Age` rules remain accepted. Response-side `Age` is deferred to the final wire stage,
after RustProxy's cache freshness/revalidation logic, so it cannot alter stored age, TTL or
cache eligibility. Server hiding is cosmetic and
must not be considered an access-control or vulnerability mitigation substitute.
Standard public 80/443 ports are supported without globally forbidding remote
origins using those ports. Local listener targets remain guarded. Review SETTINGS.md
for interface-enumeration and restart semantics. A Linux service can grant only
CAP_NET_BIND_SERVICE to a dedicated unprivileged account; the application does not
acquire capabilities itself. Public listener controls do not implicitly expose the separate admin
API. The provided systemd unit is an unexecuted example, not an audit result.
Language packs are trusted build inputs with size/type/metadata bounds. The browser
renders strings as text and falls back per key. Translations are not executable
plugins, and no directory browsing or runtime arbitrary file loading is exposed.
## 0.2.0 frontend and pool boundaries
An explicit vhost binding is checked using the accepted stream's real local IP,
port and TLS state. Host, SNI and forwarded headers cannot assert a different local
address. A shared wildcard TCP listener does not grant every vhost access on every
accepted IP. Legacy routing does not inherit additional frontend sockets. Conflicting
hostname ownership is rejected, including disabled declarations.
Binding a private IP is not an authorization mechanism or firewall. NAT, port
forwarding or another proxy can make that destination reachable. Configure network
restrictions and application authentication as well; no trusted-proxy identity or
Keepalived/VRRP service is introduced. New unavailable IP bindings fail startup.
Health probes share literal/DNS/allowlist/loop protections, including actual old
listeners and desired new listeners, and cannot follow redirects to arbitrary
origins. They are bounded and do not use user cookies or credentials. Probes can
still hit an operator-selected GET path; select a side-effect-free health endpoint.
Affinity uses opaque random tokens but is not authentication, CSRF protection or
session replication. Tokens survive compatible edits, not process restarts. Cookie
mode bypasses cache; the dedicated cookie is stripped before origin forwarding and
cannot be overridden by an origin Set-Cookie. No failed request, especially a write,
is replayed on another member to obtain a successful response.
Dedicated per-vhost security headers also cover matching-host denials/errors and
cache hits. HSTS is removed on plaintext; includeSubDomains/preload remain explicit
opt-ins. Preload directives do not enroll a domain in a browser list. Arbitrary CSP
and Permissions-Policy values are encoding-validated, not semantically audited.
Old configurations retain origin headers unless an explicit policy overrides them.
## Certificate uploads and custom pages (0.3.0)
Certificate upload is an authenticated local-control-plane mutation protected by
Origin and CSRF validation, body/per-file bounds and two-worker admission. PEM pairs
are parsed, key-matched and checked against supplied DNS names. Private immutable
files use generated simple names, mode 600 and the existing no-symlink/directory
lock protections. Public API responses/config/audit contain metadata, not key PEM.
JavaScript clears file controls after use, but does not claim secure erasure of
browser memory. Only configuration publication activates an inventory selection.
No new listener or public certificate-upload endpoint is created.
Custom HTML is fully trusted administrator-authored content served on the chosen
public vhost. It may contain executable HTML/scripts there; it is never previewed
or inserted as HTML in the admin DOM. Do not upload untrusted markup. The fixed
welcome page interpolates only the software version, not Host, URL or user input.
HTML is bounded to 32 KiB per page and by the overall 256 KiB configuration limit.
Responses use no-store; old representation encoding/length/cookies are discarded
when replacing an origin error. Authentication challenges, Allow and Retry-After
are retained, then administrator response/security rules are applied.
ACLs run before static/fallback/direct-IP pages. Even a custom status 200 for a
blocked request never authorizes an upstream fetch. Such overrides can confuse
clients and monitoring; retaining the original 401/403/5xx status is recommended.
Direct-IP HTTPS still needs a completed TLS handshake and a suitable certificate;
an HTTP welcome page cannot remove a TLS certificate-name error.
## Integration boundaries in 0.4.0
Tinyauth runs after host/path ACLs and before any cached/static/origin response or
WebSocket upgrade. Incoming identity headers are stripped, including after generic
header rules, and only configured fields from a successful verifier are copied.
The verifier gets no request body or admin session cookie. Its DNS/private-network
and TLS checks match the origin safety boundary. Authentication failure cannot be
turned into a 200 by custom pages. Denials and protected responses are no-store;
there is no auth-result or protected-content shared cache. Existing upgraded
sessions are not reauthenticated continuously.
Only configured login-origin redirects are accepted. Do not expose the fake-auth
helper or treat its test cookie as an actual login. H3 uses the same routing,
auth, request limits and response policies; 0-RTT is disabled. Transport resource
limits do not replace rate limiting, firewalls or independent security/conformance
testing. H3 is uncompiled/unexecuted in this packaging environment.
Diagnostic response fields are hidden by default, but origin secrets still need
explicit response rules. In 0.5.0 the dedicated controls expose request ID and
revision independently; the obsolete hide_mock_origin field is not accepted. X-Mock-Origin can be
removed using ordinary response-header rules. Scope labels on listeners never
enforce network restrictions. See ACME_RFC2136.md for private TSIG/account storage
and the separate backup requirement for manual DNS-01 issuance.
## User-Agent blocklists
RustProxy can apply an optional global User-Agent blocklist and an additional per-vhost blocklist. Matching is case-insensitive; plain text matches a substring and `*` is a wildcard. Policies are bounded and disabled by default. They are evaluated before opening an upstream; a match returns HTTP 403. Requests without a User-Agent are not denied by this rule.
User-Agent is client-controlled and easy to spoof, so this is an operational noise/blocking control rather than an authentication or identity boundary. Keep ACLs, authentication and rate limits as the authoritative controls.
## Release and operational validation
The provided 0.34.0 source includes changes without completed native compilation, all-protocol regression or production performance measurement. Do **not** treat source-code inspection as proof of security or performance. Before using it at an Internet edge, test on the target OS, run the automated suite, verify TLS/H2/H3/WebSocket handling, access and cache privacy, backup/restore, logging and failure recovery. Keep monitored off-host backups and a rollback plan.
-78
View File
@@ -1,78 +0,0 @@
# Limited administration exposure audit - 0.7.1
## Scope and evidence
Reviewed the supplied 0.7.0 source and this patch's admin router/middleware,
authentication endpoints, sessions, API assets, dashboard login/logout, Swagger
integration, proxy header rule precedence and deployment readiness consumer.
This is a manual source review plus static/browser-script regression tests. It
is **not** a penetration test of the user's installed binary or a complete audit
of proxy/TLS/QUIC/ACME/operating-system/dependency security. No Rust compiler was
available; runtime enforcement and complete vendor rendering remain unverified.
The reported unauthenticated operational JSON disclosure was not reproduced on
an installed server. In the input source, `/api/config`, status, logs, metrics,
backend/cache and certificate endpoints already passed through a session guard.
The concrete findings were public version/mode metadata, unnecessarily public
operational HTML/JS/full language assets, broad path-only method exceptions and
incomplete browser-state minimization. Do not infer that a user's older build,
reverse-proxy front or a different exposed endpoint had the same behavior.
## Findings and source changes
| Observation | 0.7.1 change | Verification limit |
|---|---|---|
| `/healthz` disclosed version; auth discovery disclosed version/mode/password policy | Health reduced to one status field; auth discovery to setup-required boolean | Source/contract tests; actual router tests supplied, not run |
| Full operational HTML/modules/language bundle could be fetched anonymously | Root selects minimal login or authenticated document; full modules/bundle are private | Login fetch/DOM checks use mocked fetch, not real HTTP |
| Public route exemptions were not a strict method/path matrix | Explicit allowlist; private and unknown requests denied before JSON/query parsing | Six additional async Rust test functions supplied, unexecuted |
| Anonymous JSON rejection could include extractor input detail | Login/setup map parser failures to a fixed message, preserving 400/413/415 status semantics | Source and unexecuted router tests |
| Mutation marker/CSRF duplicate values were not explicitly rejected | Require one exact Origin, marker and session-bound CSRF value | Existing guards retained; added unexecuted duplicate-header tests |
| Stale DOM/form values can outlive a visible logout screen | Scrub inputs/state, notify other tabs, reload root; docs clear schema/results on session change | Chromium script tests with simulated location/storage |
| New API docs would otherwise create another disclosure surface | Protect document, schema and every necessary asset with existing guard; no wildcard asset route | Route/schema contracts; vendor-renderer acceptance outstanding |
| Interactive docs can carry credentials into requests | Fixed same-origin schema, per-action session verification, no external validator/persisted auth, writes opt-in + CSRF | Custom interceptor tests use a vendor stub; server guard is the authority |
| Updater used public version in `/healthz` | Locally verify systemd MainPID executable against staged release after minimal readiness | Temporary-file and simulated-systemd tests; real deployment not run |
## Deliberate public minimum
Anonymous login assets, favicon/shared CSS, setup-needed boolean, minimal health
status, and guarded login/one-time setup POSTs remain. These are necessary for
sign-in, initial administration and local readiness. Static CSS may contain UI
selectors; it contains no runtime configuration or secrets. Invalid Host/Origin
still returns generic 403 instead of bypassing the boundary.
Login/setup POST success is allowed to create/return session information **only
after credential/capability validation**. No API Bearer token or setup token was
added as an alternative authority for operational reads. Setup remains one-time,
local-capability-based; password checking/rate limiting/session invalidation were
not weakened or replaced by JavaScript-only checks.
## Separate Prometheus listener
Admin `/metrics` and `/api/metrics` require the browser/admin session. The optional
standalone Prometheus listener remains independent, disabled by default, with
configured source CIDRs and its existing bearer-token policy. Non-loopback export
requires a token by the existing Rust validation. A loopback exporter can be
intentionally configured without a browser login. Do not treat it as an overlooked
admin API exception, and do not place it on an untrusted network without reviewing
its token, CIDRs and protected transport. This patch does not verify its runtime.
## Outstanding acceptance and operational caveats
- Build and execute `dev.sh check` / `dev.sh test`; run `admin_audit.py` against
the actual upgraded service. Compare 401/no-store responses without cookies.
- Open real `/docs` in a normal browser; verify rendering, actual cookie+Origin+CSRF,
logout, CSP console output and failure after revocation. Vendor assets were not
downloaded/rendered here, and build dependency compatibility is unverified.
- Test real install/update restart/rollback; local MainPID and `/proc` verification
now fail closed when the expected executable cannot be established.
- Retain loopback-only administration and use a protected tunnel. Cookie/session
security does not protect a compromised local account, malicious browser extension,
an administrator sharing exported configuration, or every possible web attack.
- No RBAC/read-only administrator role, general API token scopes, dependency CVE
scan, fuzzing or proof of vulnerability absence is delivered by this small patch.
- Review a real resolved Cargo.lock and the pinned Swagger artifact before shipping
binaries. Source review is not a substitute for release acceptance.
For the exact exposure matrix and test/build commands, read [API_ACCESS.md](API_ACCESS.md).
The review uses OWASP REST/session guidance as a checklist, not a certification:
https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html
-148
View File
@@ -1,148 +0,0 @@
# RustProxy 0.31.0 - API, panel and HTTP cache-maintenance security review
Scope: source snapshot supplied for the v0.31.0 maintenance follow-up, reviewed
2026-10-10. This is a **code/contract review with offline regressions**, not a
penetration test or proof of production safety. The supplied archive has no
`.git` directory; the review does not establish parity with remote `master`.
## Interfaces and trust boundaries
| Surface | Exposure | Authentication and mutation rules | Assessment |
|---|---|---|---|
| Public administration bootstrap (`/`, login JS/CSS, `/api/auth/status`, setup/login POST) | Login/public administration listener | Host/origin validation; request marker for unsafe bootstrap actions; one-time setup capability and login throttling | Source-reviewed; live browser flow pending |
| Private panel HTML, JS, documentation, OpenAPI | Administration listener | Administrator session; `Cache-Control: no-store`; CSP, frame and MIME protections | Source and anonymous-response test reviewed; runtime pending |
| `/api/status`, `/api/system-info`, `/api/traffic`, `/api/cache`, logs, config, preferences, audit | Administration listener | Administrator cookie session; sensitive reads not on anonymous allowlist | Source and regression tests added; runtime pending |
| `/api/events`, live-log SSE | Administration listener | Session on subscribe and repeated validity checks; bounded viewers; heartbeat/disconnect cleanup | Source-reviewed; reconnect/load pending |
| State-changing admin APIs (including cache/config/preferences) | Administration listener | Valid session, Origin/Host, `X-RustProxy-Request: 1`, CSRF token; audit on cache commands | Source and regression tests added; runtime pending |
| Certificate API (`/api/certificates/{id}/reload`, deploy) | Administration listener | Narrow-scoped Key ID + Bearer grant; no general read access; HTTPS required remotely; origin validation when supplied | Source-reviewed; token rotation/revocation live tests pending |
| Public `BAN`/`PURGE` | Configured public vhost listener | **No admin session**: separate explicit IP/CIDR allowlist (empty by default), matched listener/vhost/ACL and optional rate limit | Hardened below; reverse/HTTP tests pending |
| Separate Prometheus metrics exporter | Distinct optional listener | Configured IP CIDRs and bearer-token policy; not the admin session | Unchanged; deployment exposure audit pending |
## Findings addressed in this follow-up
1. **E0716 / compilation defect (high functional impact).** `error()` accepts
`&'static str`; a reference to `format!(...)` was a temporary. Invalid
selector errors now log the detailed reason server-side and return a fixed,
non-sensitive message with status 422. This removes the reported lifetime
violation; the Rust compiler must still verify the entire crate.
2. **Cache protocol-scope confusion (medium).** Path-only `BAN`/`PURGE`
selectors could affect representations of the same host/path across both
HTTP and HTTPS. Targets are now built with the trusted external scheme,
validated to `http`/`https`, and the matched Host/vhost. IPv6 literal Hosts
are bracketed for the URL parser. Other tenants, schemes and hosts remain
outside the selector.
3. **Unbounded blocking maintenance work (medium availability risk).** Cache
invalidation may scan hot/cold entries. Both public HTTP and authenticated
admin cache purge/selective invalidation share two maintenance slots and
execute scans with `spawn_blocking`. Bursts receive 429 instead of piling
up unbounded blocking work on Tokio core threads. The permit stays with the
worker after a client disconnects. This is a concurrency bound, **not** a
per-client rate quota or a hard time limit for disk work.
4. **Ambiguous maintenance input (medium).** Duplicate TTL/confirmation or
`Content-Length` headers, transfer-encoded bodies, nonempty bodies and
upgrade requests are rejected; `Content-Length: 0` remains compatible with
CLI clients. TTL is limited to 1..604800 s. Broad `BAN /` requires the
explicit confirmation header; `PURGE` cannot use BAN-only controls.
5. **Audit ordering (low/medium).** Removal of an administrator-created BAN
rule now records the audit entry before mutating state. Unavailable audit
storage blocks the mutation rather than allowing an unaudited removal.
6. **Operator safety/usability (low).** An EN/PL help popover beside the HTTP
allowlist gives exact `curl` syntax, IP-only authentication semantics and
reverse-proxy identity requirements. No new global config variable is added.
## Existing protections checked in source
- Admin router's exact anonymous method/path allowlist contains only the
expected authentication bootstrap APIs; private docs and assets remain
session-protected. Request guard rejects duplicate Host/Origin and checks
browser `Sec-Fetch-Site` cross-site signals.
- Mutation guard uses an Origin/Host match, per-session CSRF comparison and
`X-RustProxy-Request` marker; cookie sessions are HttpOnly/SameSite=Strict
and use Secure under HTTPS/trusted forwarded protocol.
- Secure response policy includes CSP, `X-Frame-Options: DENY`,
`X-Content-Type-Options: nosniff`, referrer restrictions and no-store for
private material. A static scan checked 27 first-party browser JS files for
direct unsafe HTML injection and dynamic `eval`/`Function` sinks; this scan
**cannot establish that the entire DOM is XSS-free**.
- IP trust comes from the transport peer or a validated forwarding chain only
when the peer is configured trusted. An untrusted client-supplied XFF does
not independently grant BAN/PURGE permission. The cache allowlist rejects
`/0`, invalid CIDRs, duplicates and more than 32 entries. Requests are never
forwarded upstream.
- Certificate API tokens have explicit operation/ID grants and cannot use the
general admin read API; Bearer grant operations are intentionally separate
from cookie + CSRF authentication.
## HTTP maintenance CLI (allowlisted client only)
```sh
# One URL, including the query string: 200 with JSON result.
curl -i -X PURGE 'https://site.example/assets/app.js?v=3'
# Lazily ban previously cached objects under this path: 202 with JSON result.
curl -i -X BAN 'https://site.example/assets/'
# Entire host: explicit opt-in confirmation header.
curl -i -X BAN 'https://site.example/' -H 'X-RustProxy-Confirm-Wide: 1'
# Optional BAN expiration (30 seconds).
curl -i -X BAN 'https://site.example/assets/' -H 'X-RustProxy-Ban-Ttl: 30'
```
Use **Settings -> Cache -> HTTP BAN / PURGE IP allowlist** to grant the
caller IP/CIDR. This HTTP interface does not accept admin cookies/tokens as an
alternative to the allowlist. The domain, Host header, external scheme and
listener must select the intended vhost. Behind a reverse proxy, forward the
non-standard methods untouched and **replace**, rather than append untrusted,
XFF/Forwarded headers at the edge. Never add the entire reverse-proxy subnet
as a maintenance client merely because it is trusted for forwarding.
## Release security test plan (requires compiled RustProxy)
1. On the deployment host, run `cargo fmt --all -- --check`,
`./dev.sh check`, `./dev.sh test`, and `./dev.sh release`. Address any
compiler/regression errors before installation. `cargo audit` or an
equivalent advisory scan requires an up-to-date advisory DB and was not run
in this offline environment.
2. Start a disposable instance with a populated cache under both HTTP and
HTTPS for the same vhost/URL. Confirm `PURGE` returns 200 and drops the
exact query variant only; `BAN` returns 202 and invalidates only **older**
entries on the URL prefix. Confirm the *other* scheme and vhost stay hot.
3. From a denied IP, expect 403 on `BAN` and `PURGE`. With an empty allowlist,
all clients must be denied. Send duplicate TTL/confirmation/Content-Length,
TE/body and `BAN /` without confirmation: expect 400. On a trusted-proxy
hop with missing real client identity, expect 403. Test both IPv4 and IPv6.
4. From an allowed IP, send 10 concurrent broad maintenance requests; confirm
that only two workers run simultaneously and excess return 429. Verify
server CPU/IO and audit entries while the cold tier is populated.
5. Send GET requests without admin cookies to `/api/config`, `/api/status`,
`/api/system-info`, `/api/events`, `/api/logs`, `/docs/openapi.json` and
`/assets/app.js`; expect 401 and protective headers. With valid cookie but
missing/bad Origin, marker or CSRF on PUT/POST/DELETE, expect 403. Test
logout and password/session invalidation, live SSE reconnect/expiry.
6. Test the Certificate API with no grant, a valid certificate-specific grant,
revoked token, HTTP remote peer, and misuse against `/api/config`/logs.
Check errors for token disclosure and audit coverage.
7. Test actual browser startup, CSP console, output escaping and form save
with malicious HTML strings; inspect reverse-proxy Host/Origin forwarding.
Prefer HTTPS administration with a strict `allowed_hosts` list.
## Residual risk / non-claims
- **Not executed in this environment:** Cargo format/check/tests/release,
actual Rust listener requests, HTTP/2/3, fuzzing, penetration tests,
session revocation under load, proxy deployment configuration, dependency
CVE scanning and long-running cold-cache resource measurements.
- Proxy trust configuration is security-critical. A misconfigured edge that
accepts forged forwarded client addresses can let external users impersonate
an allowlisted IP. Restrict direct listener reachability and sanitize headers.
- A public IP allowlist is not user authentication; anyone using an allowed
IP/network can issue destructive maintenance commands. Use narrow CIDRs and
optionally enforce additional controls on the edge (mTLS, firewall).
- The two-worker concurrency bound mitigates bursts, not all denial-of-service
scenarios: configured cache size and storage latency still determine scan
cost. Per-IP quotas are not provided here.
- Proxy protocol path, certificate verification, arbitrary upstream code,
all application content and third-party packages are **not certified safe**
by this audit. Production acceptance remains open until the runtime test
plan is completed.
-674
View File
@@ -1,674 +0,0 @@
# Listener and response settings
## Administration appearance preferences
**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. 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.
The **Corner style** choice applies to standard dashboard/metric cards, vhost
cards, entities, notices, backend protocol badges and inputs independently from
color themes; the default preset retains the original appearance. On narrow
screens the Overview uses two small metric tiles per row rather than one.
Status refreshes update values without resetting widget order or scroll.
The Vhosts list shows one upstream protocol badge per configured reverse-proxy
backend: the configured mode (H1, Auto, H2 compatible or H2 only) plus the last
HTTP/1.1 or HTTP/2 response version observed by RustProxy while proxying a
request. An unknown or stale observation (over five minutes old) appears as `?`,
including in Auto mode; no new probe is issued, and parallel connections can
use different negotiated versions. Hover/focus displays the observation note
and aggregate response-version counters. These are authenticated live-status
observations, not the configured frontend HTTP version.
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.
The Settings page includes a local search field that filters category tabs using translated category labels and page content. Filtering never rebuilds settings forms, so unsaved values remain in the DOM. Category navigation remains sticky on desktop and marks categories containing unsaved forms.
Unfinished Web Server, Vhost, Redirect and Stream forms use a session-only draft dock. Closing a dirty editor minimizes its exact browser state to the bottom-right dock so another editor can be opened independently. The dock follows the square-corner panel styling of the dashboard and intentionally avoids rounded/glass presentation. Drafts are intentionally not written to SQLite because form state can contain credentials or other secrets; they are discarded on logout/session reset.
## Certificate API credentials (0.31.9)
Open **Settings → Certificate API** (`/settings?tab=certificate-api`) to manage external certificate automation credentials. The credential list now has **View settings**, **Edit settings**, **Regenerate token** and **Revoke** actions. The preview shows Key ID, non-secret comment, allowed certificate IDs, reload-only/deploy scope and creation/last-use times. Existing bearer token values cannot be viewed: only hashes are stored.
**Edit settings** loads the current credential into a form with fixed Key ID. You may update its comment, list of 1–32 explicit certificate IDs, and **Also allow deploy** permission. **Save settings** changes only this metadata; the current secret stays valid and is never shown or replaced. Legacy `*` certificate grants must be replaced with explicit IDs before saving an edit.
**Regenerate token** requires confirmation. It generates a new random bearer token, preserves the Key ID/comment/permissions/allowed IDs, immediately invalidates the old token and reveals the new token only once. Copy it into the external automation before closing the page. Regeneration updates the token's creation/rotation timestamp and clears last-used metadata. The same existing session + Origin + CSRF checks protect both operations; bearer-only external clients cannot read or change credentials.
The existing **Generate / rotate token** form still creates new Key IDs (and retains its previous ability to rotate a Key ID by supplying it). Use **Edit settings** to modify settings without rotating, and **Regenerate token** to rotate without changing any settings. See [API](API.md) and [access control](API_ACCESS.md).
## Compact Overview card controls (0.32.6 development branch)
**Settings → Appearance → Dashboard layout** now puts Dashboard density on a
compact row and gives the Overview card controls the full width of the section.
The card selector is a single horizontally scrollable strip by default, with
left/right browsing buttons and accessible keyboard focus. **Show all cards**
expands the same 62 visibility/reorder controls into a wrapped grid. Use the
existing visual layout editor for drag-and-drop arrangement. Reordering and
visibility still save through `/api/preferences` per account; this UI-only
change does not alter server polling, proxy routing or backend metrics.
### Overview controls and density (0.32.7 development follow-up)
**Refresh now**, **Arrange Overview**, **Unlock layout**, **Visual Overview editor** and **Show all cards** inherit the same Normal/Compact button size rules as other buttons. Responsive placement still adapts to narrow screens, but no independent font-size, padding or height override applies. Dashboard density more clearly adjusts numeric tile sizing, inline chart height and the padding/title size of non-table Overview panels; the common tile gutters are unchanged. Button style and dashboard density remain independent per-account preferences.
### Overview header actions (0.32.8)
On the Overview page, **Traffic, in focus.** remains on the left and actions
align to the right on the same row when space permits. Buttons wrap to the right
on very narrow displays instead of being compressed into equal-width columns.
The existing Normal/Compact button preferences, editor confirmation actions,
layout locking and account-specific saved card layout are unchanged.
## Refresh preferences (0.31.0)
**Settings -> Appearance -> Data refresh** saves an additional `refresh` object in the existing `admin_preferences` document for the signed-in account. It does not modify proxy configuration or create any migration. Older documents, including partially populated `refresh` objects, use defaults. Browser and server enforce exact allowed values:
| Preference | Default | Allowed seconds |
|---|---:|---|
| `refresh.live_enabled` | `true` | `true`, `false` |
| `refresh.overview_seconds` | 3 | 1, 2, 3, 5, 10 |
| `refresh.traffic_seconds` | 5 | 0, 3, 5, 10, 30 |
| `refresh.cache_seconds` | 5 | 0, 3, 5, 10, 30 |
| `refresh.system_info_seconds` | 60 | 0, 15, 30, 60, 300 |
`live_enabled=false` disables automatic SSE **and all polling**, including system information. `0` disables only that source's automatic polling. Overview and System information have **Refresh now** buttons; Traffic and Cache retain their own buttons, filters, charts and pagination. The per-view Auto checkboxes for Traffic/Cache/Transform and the Docker live switch remain independent opt-outs, and none changes another administrator's preferences. **Reset section** restores refresh defaults without changing theme or unrelated preferences.
The main `/api/events?interval=N` connection (allowlisted 1/2/3/5/10, default 3 seconds) carries compact live status, cached CPU/RSS/aggregate and per-interface network chart samples from the server's existing background OS collector, plus the existing bounded log snapshot. The server checks the session on every event and retains its heartbeat, viewer cap and normal disconnect cleanup. On an SSE error the browser polls `/api/status?view=live` and `/api/logs` until snapshots resume, not concurrently during healthy streaming. The old full `/api/status` response is kept for API clients and initial diagnostics. Process/System detail values still update only after the separate authenticated `/api/system-info` read at `refresh.system_info_seconds`, while CPU/RSS and network **charts** follow the Overview live cadence (3 seconds by default). The `/api/system-info` endpoint reuses cached diagnostics and ACME metadata, never runs diagnostic subprocesses per request, and is fetched automatically only for Overview or the relevant Settings pages. A hidden browser tab pauses the channel and all automatic polling; returning revalidates its session before normal operation. All interval changes take effect immediately, without reloading or changing any server-global cadence.
## System information (0.30.1)
**Settings -> System information** (`/settings?tab=system-info`) is a read-only view of the authenticated `/api/system-info` endpoint (and the initial, backwards-compatible full `/api/status` response). It reports the OS name/kernel on Linux when available, executable architecture, logical CPU count, CPU and RAM telemetry, host/process uptime, RustProxy version, process RSS, thread and file-descriptor counts, and the existing statistics source and data/database locations. Values not supported by the host/platform display as unavailable, never as zero.
ACME diagnostics formerly shown in About and under Administration are now on this tab: mode/state, certificate environment path, and the active Python/pip/Certbot versions. Versions are probed by the ACME runtime setup with bounded subprocess output/timeouts and published from stored runtime status. They are not queried at every page refresh or status API request. Failed/unavailable probes leave the version empty. No new persistent settings or permissions are introduced.
## Rules and headers (0.31.7)
**Settings -> Rules & headers** (`/settings?tab=policies`) now groups the existing **Global User-Agent blocklist**, **Global custom headers** and **Client response** forms as persistent sub-tabs, following the Cache settings layout. There is no migration: each form continues to update its previous configuration section. **Settings -> Security** retains administrative hostname, Origin, session and password policies.
**Vhosts -> Security -> Traffic policy** also supports optional `rate_limit.user_agent_limits` in each vhost, for example:
```json
{
"rate_limit": {
"enabled": true, "requests": 120, "window_seconds": 60, "burst": 30,
"user_agent_limits": [
{"pattern": "*scanner*", "requests": 10, "window_seconds": 60, "burst": 0},
{"pattern": "BadBot", "requests": 2, "window_seconds": 60, "burst": 0}
]
}
}
```
The first case-insensitive match wins. A plain pattern searches for a substring and `*` matches any sequence. A matching User-Agent uses its **own bucket for the vhost, rule and client IP** and overrides that request's standard/country throttle; nonmatching requests continue using existing rate rules. Rules also work with `enabled=false` for the base limiter. The User-Agent header is untrusted, so avoid treating these limits as authentication or identity enforcement.
The **Revision history** detail shows only changed leaf fields, scrolling locally when there are many. **Full JSON comparison** is directly below Restore in the action group and opens a separate standard dialog. ACL profile *Attached to* details are collapsed by default. Listener HTTP/2 and HTTP/3 controls have additional spacing.
## Configuration and update semantics
GET `/api/config` returns a revisioned envelope. PUT the entire document with
`expected_revision`. Defaults apply when fields are omitted. Unknown fields are
rejected; failed validation/persistence leaves the old snapshot active. Existing
backend/route/TLS/ACL data must be retained when changing settings. The panel does
this and preserves dirty settings forms across status/language refreshes.
```json
{
"listeners": {
"endpoints": [
{"id":"http", "name":"HTTP", "scope":"public", "enabled":true,
"binding":{"address":"0.0.0.0:80", "tls":false, "http2":false, "http3":null}},
{"id":"https", "name":"HTTPS", "scope":"public", "enabled":true,
"binding":{"address":"0.0.0.0:443", "tls":true, "http2":true,
"http2_settings":{"idle_timeout_seconds":300, "max_connection_lifetime_seconds":0, "drain_timeout_seconds":10}}}
]
},
"server_header": {"mode":"custom", "value":"RustProxy"},
"compression": {"gzip":true, "br":true, "zstd":true, "deflate":false, "min_bytes":1024},
"headers": {
"request": [{"op":"set", "name":"x-proxy-name", "value":"RustProxy"}],
"response": [
{"op":"remove", "name":"x-powered-by"},
{"op":"set", "name":"x-served-by", "value":"RustProxy"}
]
}
}
```
This is a settings fragment, not a replacement for the existing routing/vhost document.
A complete opt-in example is in `examples/cache-dashboard.json`.
The dashboard exposes the complete raw JSON editor under **Settings -> Manual config**.
The same card exposes **Revision history**, which reads the newest 512 retained configuration revisions from the configuration-history table introduced in SQLite schema 8 (current database schema 9) and groups them by month/day. Selecting a revision shows timestamp, actor and only the changed fields against the previous retained revision; the separate Full JSON comparison dialog contains complete JSON documents. An older retained revision can be restored from the same dialog; RustProxy validates it through the normal configuration update path and saves it as a new revision, preserving the prior history.
The historical `/configuration` browser route is retained only as a compatibility alias and
opens that Settings block; it is no longer a primary-navigation item. Prefer the structured
section editors for routine changes so unrelated sections are not republished.
## GeoIP updates
**Settings -> GeoIP updates** controls the background country-database refresh and is enabled by default. The worker starts only after all server sockets have bound successfully, attempts the first refresh immediately, and then refreshes every 24 hours by default. Sources are direct HTTPS file URLs rather than coupled release/checksum base URLs. Publisher SHA-256 verification is optional per file; every downloaded payload is still size-bounded, streamed to a private staging file and parsed as the selected database format before publication.
Two formats are supported. **CSV ranges** uses separate IPv4 and IPv6 files and defaults to the direct `sapics/ip-location-db` `user-country-ipv4.csv` and `user-country-ipv6.csv` assets. **MaxMind DB (MMDB)** uses one `country.mmdb` file for both address families and defaults to the combined sapics `user-country.mmdb` asset. Standard MaxMind country databases are read from `country.iso_code`, then `registered_country.iso_code`; custom country MMDBs may expose top-level `country_code` or a root two-letter country string. To manage a private MMDB outside the updater, place it at `<data-dir>/geoip/country.mmdb`, select MMDB, disable automatic updates and leave the MMDB source URL empty; **Download now** then validates/reloads the local file instead of fetching it.
When a checksum URL is empty, RustProxy deliberately skips publisher-checksum comparison. It still computes a local SHA-256 for the validated download and compares it with the installed file, so unchanged content is not republished or hot-reloaded. This avoids coupling a moving `latest` data URL to a separately moving checksum release. If a checksum URL is supplied, a mismatch rejects the staged file. The configured maximum is 1 MiB..256 MiB and the interval is 60 seconds..31 days. Failures are warnings only; proxy traffic continues and the worker retries. Manual UI refresh, the background worker and managed upgrades share `.geoip-update.lock`. After publication the `ArcSwap` database is replaced atomically: requests already holding a snapshot may finish on it, while subsequent country ACL/rate-limit/logging lookups and status/metrics reads use the new database without a restart.
```json
{
"geoip_updates": {
"enabled": true,
"format": "csv_pair",
"ipv4_url": "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv4.csv",
"ipv6_url": "https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv",
"mmdb_url": "https://github.com/sapics/ip-location-db/releases/download/latest/user-country.mmdb",
"ipv4_checksum_url": null,
"ipv6_checksum_url": null,
"mmdb_checksum_url": null,
"max_bytes": 268435456,
"interval_seconds": 86400
}
}
```
## Administration HTTPS
`administration` is separate from public proxy listeners. It defaults to the startup
loopback address and plain HTTP. Persisted address/certificate changes are validated
immediately but take effect after process restart. A non-loopback persisted bind is
accepted only when `administration.tls` is true. Plain remote HTTP remains available only when process startup explicitly allows it with
`--allow-remote-admin`.
```json
{
"administration": {
"listen": "0.0.0.0:9090",
"tls": true,
"certificate_id": "admin-example",
"allowed_hosts": ["admin.example.com"],
"public_origin": null,
"session_days": 30,
"panel_compression": {"zstd": true, "br": true, "gzip": true}
}
}
```
### Fixed public origin for a reverse-proxied panel (0.31.4)
For a **panel** reached through a trusted reverse proxy, set the optional
`administration.public_origin` in **Settings → Administration** to the exact
browser-facing HTTPS origin (for example `https://panel.example.test`).
**Do not duplicate that hostname in `administration.allowed_hosts`.** Its DNS
hostname is implicitly included in the runtime panel Host allowlist. **Restart
RustProxy**; this is a restart-bound access-control setting. Omit it (`null`) for
direct administration or when forwarded headers are canonical. This setting
is not a wildcard, does not accept HTTP or a URL path, and requires a DNS
hostname rather than an IP address. SQLite schema 9 remains unchanged.
The advanced **Additional allowed DNS hostnames** field controls independent,
extra hostnames only. Older configurations containing both values are
accepted; on editing/saving the redundant duplicate is omitted from the
persisted list. If `public_origin` changes or is cleared, its old hostname is
no longer implicitly allowed (unless explicitly retained as an additional host).
Example for the default **private loopback** administration listener behind an
HTTPS-terminating proxy:
```json
{
"administration": {
"allowed_hosts": [],
"public_origin": "https://panel.example.test"
}
}
```
The last hop must connect from loopback or an existing explicitly trusted proxy
CIDR. If it is not loopback, configure **only the actual proxy peer CIDR** under
Forwarding → Trusted proxies; be aware the general trusted-proxy setting also
applies to public traffic and must never include untrusted clients. The
administration listener should not be reachable directly from external clients.
Do **not** grant trust to public networks to work around Origin failures.
The pinned URL, not an element of `X-Forwarded-Proto`, defines the panel
scheme/authority. A trusted, bounded proxy chain `https, http` (HTTPS on the
browser side, HTTP on an internal hop) is permitted in this explicit mode.
An `http` first hop, malformed/too-long protocol chain, duplicate/conflicting
`X-Forwarded-Host`, different `Origin`, `Sec-Fetch-Site: cross-site`, and missing
CSRF protection for state-changing requests still result in HTTP 403. The
public proxy/vhost data plane is **not changed**, except that an already opted-in
self-admin vhost can recognize the pinned hostname for its reserved panel session
cookie; other upstreams continue to strip it. Credential-bearing certificate
automation retains its original TLS/forwarding checks.
Prefer fixing the proxy's forwarding headers rather than configuring the pin
when practical. For a TLS-only public entry, the closest trusted hop should
**replace**, not append, forwarded headers using known values, for example:
```nginx
proxy_set_header Host panel.example.test;
proxy_set_header X-Forwarded-Host panel.example.test;
proxy_set_header X-Forwarded-Proto https;
```
Only use that `https` setting on an entry that actually verifies external TLS;
never trust an arbitrary inbound `X-Forwarded-Proto`. If multiple intermediate
proxy hops must append protocol values, the pinned mode provides an explicit
server-side comparison target without accepting a client-supplied origin as
authoritative. Confirm deployment using a real mobile browser and the safe
`administration origin rejected` logs before production rollout.
**Administration → Panel content compression** (next to the admin listener/TLS settings)
controls Zstd, Brotli and gzip for the authenticated HTML document, JavaScript,
CSS and language bundles. Each browser request is negotiated using
`Accept-Encoding` quality values: the highest positive `q` wins, and ties
prefer **Zstd → Brotli → gzip**. Codecs can be enabled independently; the
identity representation is returned when none is acceptable or all are off.
The source is encoded only once per embedded asset and shared across visitors.
Settings persist in the existing revisioned administration document, without
additional CLI flags or a separate environment/config variable, and take effect
without restart. Each compressed response includes `Content-Encoding`,
`Vary: Accept-Encoding` and the representation-specific `Content-Length`.
The response compression of proxied websites remains independent; API responses,
SSE and WebSocket streams are not affected.
**Reverse proxy:** an upstream may remove or replace `Accept-Encoding`. RustProxy
negotiates from the header it actually receives, not the original browser request.
A reverse proxy normally passes through already encoded responses instead of
recompressing them; if the reverse should exclusively manage encoding, configure
it to strip `Accept-Encoding` on requests upstream, or turn the panel codecs off.
This provides compatible HTTP negotiation but does not guarantee identical
compression with every reverse proxy configuration.
`certificate_id: null` uses `data/certs/default.pem` and `default.key`, generated by
the application. A selected inventory certificate is loaded from the same private
certificate directory. `session_days` controls the absolute browser-session lifetime
and must be 7..30 (default 30). It applies without restart; changing listener/TLS/certificate
settings still requires restart. Session cookies are HttpOnly/SameSite=Strict and receive
the `Secure` attribute on HTTPS. When HTTPS is enabled, accidental plain HTTP received on the same administration
port receives a permanent redirect to the same authority/path over `https://`; the
plaintext path contains no administration handlers. An empty `allowed_hosts` accepts
valid DNS Host values on the configured HTTPS port as well as valid numeric destination
addresses. Once `allowed_hosts` contains entries it becomes a strict DNS allowlist for
browser-facing DNS authorities. An explicitly listed hostname may arrive from a trusted
reverse proxy on an external port such as 443 even when the private admin socket is 9090.
Direct IP/`localhost` access is still accepted only on the actual configured admin port.
For HTTPS termination in front of a plain local admin socket, the trusted proxy must pass
its external Host and scheme; `X-Forwarded-Host` is accepted only from loopback/configured
trusted proxy peers and `X-Forwarded-Proto` additionally follows the forwarded-proto trust
policy. Origin/CSRF comparison and the session cookie Secure flag use that external origin.
Use a firewall and normal network policy even with HTTPS.
## Structured access logging
The **Settings -> Logging** page is split into **Outputs**, **JSON format** and **Error log** subsections, using the same in-card sub-tab layout and keyboard interaction as Cache. Outputs controls optional external access-log sinks while the existing bounded live ring remains available to the panel. Sink I/O is performed by a bounded background queue; request processing only performs a non-blocking enqueue. Supported destinations:
- JSONL file append, either one combined file such as `/var/log/rustproxy/access.jsonl` or
one file per resolved vhost under a configurable directory (default `/var/log/rustproxy/vhosts`).
Unmatched traffic uses `_unmatched.jsonl`. The managed systemd service provisions
`/var/log/rustproxy` for the service account and keeps that directory writable under
`ProtectSystem=strict`. Other custom paths still need matching service permissions.
- Logstash TCP using `host:port` (IP or DNS). Each event is one JSON line.
- Loki HTTP(S) push. Optional bearer credentials are read from an environment variable
named by `bearer_token_env`; the secret itself is not stored in configuration.
`logging.format.fields` maps output field names to standard event fields; an empty map
emits the complete standard event. The panel provides Standard, Nginx-style, ECS-style
and Minimal presets plus a visual field selector/renamer with JSON preview. Advanced raw
mapping remains available for aliases not representable by the simple builder. Supported mapped sources include `vhost`, `route`, `cache_effective_enabled`, `transform_effective_enabled`, `backend`, request/response metadata and cache outcome fields. `static_fields` adds constant string fields. For low-level `protocol_error` events, RustProxy always preserves the required diagnostic envelope (`event`, error kind/detail, time, client/local/TLS/protocol fields, status and outcome) even with a custom mapping, so malformed-traffic records remain searchable in the built-in Logs explorer. Loki labels are separately configurable.
`/api/status.logging.sinks` reports queue capacity, dropped events, successfully delivered
events and sink errors.
## Hot and cold cache tiers
`cache.max_bytes` and `cache.max_entries` bound the HOT RAM tier. `max_bytes` is charged from retained body backing allocation plus entry metadata rather than only payload length. `cache.transient_max_bytes` separately bounds in-progress fills and full bodies moving between HOT and COLD; `cache.cold_queue_max_bytes` additionally caps queued demotions in bytes, and `cache.hot_eviction_max_objects` bounds one LRU admission attempt. The source defaults are 256 MiB HOT, 16 MiB maximum object, 64 MiB transient and 32 MiB COLD queue. The Settings panel calculates conservative/recommended/performance presets from the lower of host RAM and a finite cgroup memory limit without changing the COLD disk budget. It also warns when HOT + transient is a large fraction of effective RAM, when the additional configured cache budget is large compared with current host/cgroup headroom, when the COLD queue exceeds transient RAM, or when the object ceiling cannot fit in transient RAM. Automatic presets are disabled below 256 MiB effective RAM. On a 2 GiB effective-memory VM the recommended profile selects approximately 400 MiB HOT, 96 MiB transient, 16 MiB maximum object, 48 MiB COLD queue, 6400 HOT entries and a 64-object LRU admission limit. The optional persistent
cold tier is controlled by `cold_enabled`, `cold_path`, `cold_max_bytes`,
`cold_max_entries`, `cold_gc_interval_seconds`, `cold_gc_max_delete`,
`cold_after_seconds`, `cold_on_lru`, `cold_on_idle` and `cold_on_shutdown`.
`cold_gc_interval_seconds` defaults to 60 and `cold_gc_max_delete` to 256. Pressure GC
starts at roughly 90% of the configured byte/entry budget and cleans toward roughly 80%
without deleting more than the configured per-pass cap. `cold_path` is relative to the RustProxy data directory and cannot
contain parent traversal or an absolute path. The two demotion switches independently
select LRU-pressure and idle objects; shutdown flush is a best-effort direct write during
a graceful restart. Demotions use a small bounded queue and release hot accounting before
disk persistence so cold storage does not retain an unnecessary second body buffer in RAM.
Cold metadata is readable JSON and bodies are raw `.body` files; reads are size/SHA-256
checked before promotion to RAM. Since 0.21.0 objects are sharded by the first two SHA-256
hex characters (`cache/cold/00/...` through `ff/...`), with shard directories created on
first write. The previous flat Cold Cache is intentionally not migrated. Purge operations
clear both in-memory indexes and schedule disk cleanup; selective cleanup uses the same
GC/eviction path and runs asynchronously.
Version 0.22 adds foreground fill collapsing (`collapse_enabled`,
`collapse_wait_timeout_ms`, `collapse_timeout_fallback`), detailed stale-if-error policy
(`stale_if_error_timeout`, `stale_if_error_connection`, `stale_if_error_dns`,
`stale_if_error_tls`, `stale_if_error_statuses`) and opt-in proactive refresh
(`proactive_refresh_enabled`, `proactive_refresh_before_expiry_seconds`,
`proactive_refresh_percent`, `proactive_refresh_max_inflight`). Defaults preserve the
previous stale-if-error behavior; proactive refresh is disabled by default. Selective
PURGE/BAN is available in the authenticated API/panel, and HTTP BAN/PURGE has an optional, separately stored CIDR allowlist (`cache.http_invalidation_allowed_cidrs`, empty by default). See
[CACHE.md](CACHE.md) for request-collapse, Range and invalidation semantics.
The adjacent **info (`i`) popover** in Settings → Cache shows ready-to-copy
`curl -X PURGE` and `curl -X BAN` examples in English and Polish, including
the explicit `BAN /` confirmation. These public methods authorize by the
source IP/CIDR only; they do not accept a browser admin session as a bypass.
See [0.31.0 security audit](SECURITY_AUDIT_0.31.0.md) for reverse-proxy trust and tests.
## Static-response transformation
`transform` is a formal identity-response stage between the upstream response and shared
cache storage. `transform.enabled` is the global kill switch, but every vhost must also
set its own `transform.enabled=true`; vhosts stay disabled by default even when the global
engine is enabled. Global defaults enable JavaScript and CSS transformation and leave
HTML, SVG and JSON off. `extensions` controls extension-based detection and
`max_body_bytes` (1 KiB..32 MiB, 2 MiB default) bounds aggregation/decoding.
The pipeline is `origin -> decode supported Content-Encoding -> transform -> cache
identity representation -> client compression`. JavaScript/CSS can be enabled normally;
HTML minification, SVG optimization and JSON compaction are independent optional formats.
Recognition uses safe configured extensions and known Content-Type values. Binary/image
formats outside those explicit transform types are not transformed.
Origin `Cache-Control: no-transform` always disables transformation. When bytes change,
RustProxy removes representation-specific length/digest/encoding metadata and creates a
new strong `rustproxy-*` ETag for the transformed representation. The original origin
ETag/Last-Modified are kept separately and used for origin revalidation, so a proxy ETag
is never sent upstream as though it validated the original object. The transformed
identity body is what enters cache, preventing repeated minification on every HIT.
## Global TLS protocol and cipher policy
`tls.policy` is the global baseline inherited by every TLS host. It controls TLS 1.2,
TLS 1.3 and optional rustls cipher-suite names. An empty `cipher_suites` list uses the
ring provider defaults. Settings shows both the currently effective cipher suites and
the complete selectable suite inventory. At least one protocol version and
one compatible effective suite are required. This policy is also used by administration
HTTPS. HTTP/3 requires TLS 1.3 and a TLS 1.3 suite. Vhosts can narrow TCP TLS with
`tls_policy`; when a vhost has HTTP/3 enabled on a selected H3-capable listener it must
match the global policy because QUIC TLS is shared per UDP listener. Available suite names are exposed in authenticated status for
the Settings editor.
## Listeners: saved intent, not hot rebinding
When an HTTP, HTTPS or administration address was explicitly supplied by systemd/CLI
(or the corresponding service environment variable), that exact startup bind is a
process contract: the panel shows it as locked and configuration updates cannot remove,
disable or change it. If an address was not explicitly supplied at startup, listener
entries remain freely addable/removable/editable subject to normal validation.
`listeners.endpoints` is the only persisted frontend socket configuration. Each
endpoint has a stable id/name, an informational scope label, an enabled flag and one
primary binding containing IP:port, TLS, HTTP/2 plus its optional lifecycle settings, and optional HTTP/3 settings. Optional
`additional_addresses` reuse that same transport policy on up to 15 further addresses.
When more than one address is configured every address must be explicit; `0.0.0.0` and
`::` are rejected so a multi-IP selection cannot silently mean every interface. IP/port
and transport capability belong here, not inside a vhost. IPv6 uses `[address]:port`.
A vhost references endpoints through `frontend.listener_ids`. Listener changes are
restart-required because they alter TCP/UDP sockets or TLS ALPN capability. The
listener editor owns the HTTP/2/H2C and HTTP/3 enable/disable switches. An H2/H2C row
links to **HTTP connections -> HTTP/2** for idle, absolute-lifetime and graceful-drain
limits; an enabled H3 row links to **HTTP connections -> HTTP/3** for advertisement,
connection, stream and idle-timeout limits. Both tuning pages are listener-scoped and
do not own protocol enablement. Vhost H2/H3 switches only narrow the protocols already
enabled by the selected endpoints; they never create a protocol that Settings disabled.
The administration listener is independent and cannot share a frontend port.
Bindings must be valid local unicast/wildcard addresses; conflicting overlapping
endpoints are rejected rather than silently widened.
## Server and other client headers
`server_header.mode` is `preserve`, `hide`, or `custom`. Fresh installations default to `custom` with the exact value `RustProxy`; existing saved installations retain their configured policy. Custom values are
nonempty, bounded to 256 bytes and validated against header injection. Preserve
means retain the result of origin/global/route rules; it does not add a missing
header. Hide/custom take final precedence, including proxy-generated errors.
Global and route `headers.response` apply to client responses; `headers.request`
apply only upstream. Each scope/direction supports up to 32 ordered set/append/remove
rules. Remove has no value. Global rules precede route rules. Multiple Set-Cookie
values remain distinct. Admin-session cookie names are never forwarded or set by
public proxy rules. Transport, framing and trusted forwarding/identity fields are
protected. Cache diagnostic fields are owned by the cache controls, not generic rules.
Global response rules also cover generated errors; route-specific rules apply only
after an origin route response exists. Client policy changes use hot configuration
publication and invalidate the RAM cache. In-flight exchanges retain their old
snapshot; no mixed-version configuration is applied midway through a response.
## Compression
`zstd`, Brotli and gzip default ON; deflate defaults OFF. `min_bytes` defaults to 1024
(valid 32..1048576). The streaming middleware keeps its default content-type safety
predicate plus the RustProxy minimum-size predicate. Unknown-length eligible bodies can
be streamed without aggregating the complete response merely to discover its size.
RustProxy parses `Accept-Encoding` quality weights before invoking the encoder. Client
`q` values win. When two or more enabled codecs have equal quality, the deterministic
server preference is `zstd -> br -> gzip -> deflate`. An existing `Content-Encoding` is
always passed through and never recompressed. `no-transform`, request ranges/Content-Range,
SSE, upgrades, HEAD/1xx/204/304 and explicitly skipped internal responses remain excluded.
`Cookie`, `Authorization`, `Set-Cookie`, `Cache-Control: private` and `no-store` do not by
themselves disable compression; cacheability and dynamic compression are separate policies.
Applications that place secrets and attacker-controlled reflection in the same compressed
response can disable compression at the vhost or Web Service scope when their threat model
requires BREACH-style mitigation.
The global Response panel selects available codecs and the minimum size. Each ordinary
vhost and Web Server service may inherit, enable or disable dynamic compression and independently
inherit/enable/disable zstd, Brotli, gzip and deflate. Docker vhosts intentionally expose only one
Compression checkbox: when enabled they use the globally enabled codec set and priority, with no
per-Docker-vhost codec selectors. Global codec switches remain the hard ceiling: a local `true`
override cannot enable a codec disabled in the global Response panel. `min_bytes` remains global.
`Vary: Accept-Encoding` is added when compression is negotiated; content length is removed
for the transformed body. Strong ETags are weakened, and identity-body content digests and
`Accept-Ranges` are removed. Encoders use their fastest preset; there is no panel
compression-quality slider in this release.
## Diagnostics and units
Samples run off the traffic path roughly every two seconds. CPU and network rates
need two successful samples. Host CPU is aggregate; process CPU uses 100% per busy
logical CPU and can exceed 100%. Neither is normalized to a container CPU quota.
Host memory is MemTotal minus MemAvailable. RSS/FDs/threads and process I/O come
from procfs. Network rates sum non-loopback interfaces in the process network
namespace, not just RustProxy traffic. Load average is OS scope, not latency.
The process's cgroup v2 memory files are read at the standard mount location when
available. Linux keeps the existing `/proc`/cgroup metric names, units and behavior.
FreeBSD/OpenBSD/NetBSD use native system interfaces where the same metric is available;
unknown or unsupported values remain null rather than being fabricated. Snapshot timestamps and sample age are shown;
cache counters and proxy request metrics are separate from OS resource metrics.
The dashboard history is browser-local and resets on reload/sign-out. No time-series
database, disk utilization chart or Windows/macOS OS sampler is implemented.
## HTTP/2 compatibility and per-vhost settings
HTTP/2 lifecycle settings are listener-scoped under `listeners.endpoints[].binding.http2_settings`:
- `idle_timeout_seconds` (default 300) retires only a truly unused H2 connection; active streams and current frame I/O postpone it.
- `max_connection_lifetime_seconds` (default 0) is an independent absolute lifetime; `0` disables this limit.
- `drain_timeout_seconds` (default 10) bounds how long accepted streams may finish after retirement begins.
Idle and lifetime retirement both send GOAWAY, stop accepting new streams, drain already accepted streams and then close TCP. RFC 8441 WebSocket/H2 Extended CONNECT streams are counted as active until their response body/tunnel ends. The same settings apply to TLS H2 and cleartext H2C on that named listener.
For backward compatibility, the top-level `http2` object is still accepted as the fallback for an H2/H2C listener that has no explicit `binding.http2_settings`. The structured UI writes explicit per-listener settings; operators should treat the top-level object and `PUT /api/config/http2` as legacy compatibility surfaces rather than the preferred configuration path.
A named TLS listener with `binding.http2: true` advertises H2 capability and always
retains HTTP/1.1 fallback. `binding.http2: false` makes that listener HTTP/1.1-only.
Cleartext H2 uses `binding.h2c: true`. Changing listener transport capability or H2 lifecycle settings requires restart.
A vhost can additionally set `http2_enabled: false`. RustProxy reads TLS ClientHello
and SNI before committing to ALPN, so a vhost that disables H2 gets HTTP/1.1-only
ALPN even while another hostname on the same listener keeps H2. `http3_enabled` is
the equivalent per-vhost H3 opt-out. The panel enables these switches only when a
selected listener provides the protocol. No failed application request is replayed
to downgrade.
Vhosts have their own backend pool, health/keepalive/affinity controls, ACL and
security/custom response headers. See VHOSTS.md. Global Server policy still takes
final precedence. Per-vhost compression can filter individual globally enabled codecs.
The reported `SizeAbove(u16)` compilation error is removed by a custom u64 predicate,
not a cast or panic-producing conversion. Compression thresholds retain the full
32..1048576-byte setting range. Known body length/Content-Length is compared exactly;
unknown streaming lengths are eligible without buffering the full body. Actual
compression execution still requires the compiler-enabled acceptance suite.
## Management in 0.3.0
Settings contains a direct Security shortcut (accounts, sessions and host ACLs)
and a vhost shortcut (SSL, headers, cache and pages). Compact is the default density;
Dense and Comfortable change spacing/control sizes without browser zoom or logo
redesign. The preference is local to the browser, not a global proxy setting.
Global Pages configures error overrides, direct-IP and unknown-host responses.
See MANAGEMENT.md for inheritance, allowed statuses and security implications.
Access logs have moved out of the dashboard into a separate modal/docked window.
Filters stay above the scrollable rows; counts and exports use the same selection.
## Named listeners, forwarding and diagnostics (0.4.0)
As of 0.8.0, the vhost editor no longer asks for a free-form listener IP. Configure
listener IP/port/protocol once in this Settings section, then select named endpoints in
the vhost. The pre-production inline/global listener format is no longer accepted. This separates
socket intent from host routing and avoids duplicating listening IP configuration across
vhosts.
`listeners.endpoints` is the only proxy-listener configuration. Each record has id,
name, scope, enabled and a binding with address, tls, http2, optional http2_settings, h2c and optional http3. Scope
is only a label. A vhost uses `frontend.listener_ids`; every reference must exist.
Conflicting protocols on shared sockets are rejected.
Changes to effective transport plans need restart, including H2 lifecycle and H3 limits. The UI
shows saved named entries and actual bound transports separately.
`forwarding` controls trusted CIDRs and forwarded-proto acceptance.
`diagnostic_headers` exposes only `request_id` (false initially). Vhosts can override the diagnostic
policy and Server policy; absent/null means inherit. Runtime/cache/static/error
responses pass through the same final header policy.
See INTEGRATIONS_040.md and HTTP3.md for complete JSON, limits and security details.
## 0.5.0 changes (override earlier baseline examples)
A named TLS listener with omitted http3 uses default H3 limits. Explicit null is
persisted as disabled. Overlapping named sockets must match protocols AND H3 limits;
the error identifies owners and remedies. A changed binding requires restart, including
UDP protocol/limit changes. Check UDP port availability before upgrading.
The diagnostic control is `request_id` (false by default). The removed hide_mock_origin field is not accepted; use normal response-header removal
for X-Mock-Origin. Existing custom Server values remain unchanged.
Cache decision/object inspection is under Cache, separate from Overview. Basic
manual certificate issuance is under Certificates; TSIG is never a configuration
field. See CACHE_DIAGNOSTICS.md and ACME_RFC2136.md for exact scopes and limits.
## 0.6 navigation and exporter
The Settings page now has eight switching cards: Listeners, Response, Cache,
Headers, Forwarding, Pages, Prometheus and Security. Unsaved values remain in
place while switching; switching alone never writes configuration. Security is
not a separate sidebar item. Use arrow keys/Home/End in the tab bar.
The Prometheus card controls enabled, bind IP/port, URL path, client CIDRs,
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
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). Appearance Stream card descriptions use browser-native `title` hints on hover, with the same localized descriptions available to assistive technology through `aria-description` on the visibility buttons; they no longer add floating boxes or horizontal scrolling.
## Full Overview layout and additional metrics (0.32.3)
Open **Overview -> Arrange cards visually** (or **Settings -> Appearance -> Dashboard layout -> Visual layout editor**) to change **all 49** dashboard blocks: 30 previous tiles, 12 new optional charts, three large live charts, three diagnostic panels and the backend table. Drag tiles/sections, use arrow buttons for keyboard access, or hide and re-enable individual blocks. Large sections retain their own original charts, controls and live data after being moved. The responsive six-column desktop view scales to narrower widths. Visibility and order are account-scoped; they never alter backend behavior.
**Default view** loads the factory order and visibility into the editor **without saving**; **Save layout** applies it. **Reset layout** asks for confirmation, then persists the same default order and visibility in one preferences write. **Cancel** preserves the current layout. All 12 added charts are hidden until explicitly enabled and use a maximum of 30 status samples without additional HTTP polling. They cover process CPU/RSS/file descriptors, connections, WebSocket tunnels, TLS handshake failure and request-error rates, cache HIT share and HOT RAM usage, incoming HTTP/2 request share, mean response duration, and unhealthy backends. Unknown and first-delta measurements show `-`; response time is a mean, not a percentile, and HTTP/2 share is **frontend** traffic rather than negotiated upstream ALPN.
## HTTP backend Overview cards (0.32.3)
**Settings -> Appearance -> Dashboard layout** now contains fourteen opt-in
HTTP origin cards: **Backend health**, **Active backend requests**, **Backend
errors**, **Health probe RTT**, **Backend traffic**, **Configured backend
modes**, **Backend request rate**, **Backend capacity**, **HTTP 5xx**, **Backend
timeouts**, **Upstream connection errors**, **Backend retries**, **Backend
queue pressure**, and **Backend connection pools**. All are hidden by default on new and migrated administrator profiles;
card visibility and ordering use existing per-account `/api/preferences` fields.
They do not affect routing, health checks, upstream keep-alive or listeners.
The cards reuse `backend_pools` already included in the live status payload:
- Health separates up/down/checking and draining/disabled members. Active
requests is a sum of in-flight pool member exchanges, not open TCP sockets.
- Errors show lifetime member error counts and a sampled errors/s graph. Traffic
uses the positive differences of per-member in/out byte counters; resets and
newly added backend members cannot generate an artificial rate spike.
- Health probe RTT is the mean last observed healthy probe time, **not** client
end-to-end latency. It shows no data when health probes are unavailable.
- Configured backend modes distinguish saved `http1`, `auto`, compatible `http2`
and strict `h2` values, and count H2C cleartext members. This is **not an observed
H1/H2 negotiation rate**: TLS ALPN can select H1 for Auto and compatible HTTP/2.
- Backend request rate uses started-request counter deltas, with per-member
baselines after a restart or config change. Backend capacity compares active
requests against finite per-member concurrency limits; unlimited members are
excluded and a neutral state is shown when no such limit is configured.
The six additional diagnostics use pre-existing `backend_pools.members` counters:
HTTP 5xx responses, all timeout classes, combined connect/DNS/TLS/I/O failures,
retry attempts and successes from the existing safe retry policy, queue timeouts
and capacity rejections, and Hyper client-generation rotations and age. Rotations
are **not TLS handshake counts**. Rates are based on per-member positive counter
differences; newly added or reset members cannot create arbitrary spikes.
To arrange the actual Overview cards visually, click **Arrange cards visually**
on Overview or **Visual layout editor** in Settings -> Appearance -> Dashboard
layout. The modal displays copies of the existing telemetry cards; drag to
reorder, or use the move arrows for keyboard-only input. Hide/show tiles in
the modal and select **Save layout** once to persist order and visibility in
the existing `dashboard_widgets` and `dashboard_hidden_widgets` preferences.
Cancel/Escape discards the draft. The existing Appearance visibility and move
buttons remain available. No extra API endpoint, refresh loop, or database
migration is needed.
TLS ALPN selects H2 or H1 only when opening a **new TLS connection**, not for
each request. Hyper already pools reusable connections per compiled backend;
subsequent requests share the negotiated connection (H2 multiplexing when
possible). Existing bounded pool recycling (`pool_recycle_requests` and
`pool_recycle_seconds`), configured idle timeouts, and reopening failed
connections naturally re-evaluate TLS capabilities. A backend-global protocol
cache or scheduled H2 probe would be unsafe for origins with multiple DNS/peer
capabilities and would add network work. No traffic replay/downgrade on
post-request errors was added; strict H2, H2C, H1 and WebSocket separation
remain intact.
The sample charts are browser-local, bounded and reset on a changed config
revision, logout or source unavailability. They are not computed while hidden.
No additional polling, SQLite schema migration or new administrator endpoint
is introduced. Existing Stream widgets remain independent.
## Dynamic DNS / DDNS client ACL (0.32.5)
In **ACL profiles**, add an **Allow** rule and select **DNS / DDNS** instead of
IP/CIDR. Enter the hostname without a URL, port or wildcard, for example:
```text
allow myoffice.example.ddns.net
```
Apply the profile to the desired vhost. Use the **Allow exceptions** preset
(default deny) to admit only resolved addresses (and any other explicitly
allowed IP/country exceptions). Host policies and inline vhost/location ACLs
accept the same rule format. A/AAAA records are refreshed every 60 seconds,
with immediate refresh on published configuration changes. No request-path DNS
lookup is performed. The initial state and failed, empty, oversized, or expired
DNS results **do not authorize any client IP** for that DNS rule. Only
**allow** rules may contain DNS names; `deny example.com` is rejected.
Limits: 128 distinct names across active ACLs, 32 returned IPs/name, four-
second lookup timeout and eight parallel DNS lookups. Successful answers expire
after 180 seconds if the refresh task stalls. DNS changes may take up to the
next poll interval to take effect. The chosen DDNS provider controls which IPs
receive access; always trust the provider and protect its credentials. Existing
IP/CIDR ACLs and country rules are unchanged. No new admin access path or
backend origin DNS exceptions are created by this feature.
+18 -117
View File
@@ -1,127 +1,28 @@
# TCP/UDP Stream pools (v0.32.0)
# TCP and UDP Streams
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.
The **Streams** workspace forwards non-HTTP TCP or UDP traffic through dedicated sockets. It is separate from Vhost HTTP routing. Suitable examples include selected private TCP application ports and UDP protocols that need basic forwarding rather than HTTP features.
## Compatible configuration
## Configure a Stream
The legacy document still works without modification:
1. Open **Streams → Add** and choose `TCP` or `UDP`, a unique name/ID and enabled state.
2. Set the listening IP/port and an upstream address, or configure a backend pool.
3. If using several backends, select weights, balancing, health checks and draining appropriately.
4. Configure connection/session limit, TCP connect timeout and idle timeout.
5. Save, restart RustProxy if the listener change requires it, then test with a real client and inspect Stream metrics.
```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 source model supports a legacy single-upstream configuration or a backend list (up to the validated per-host limit), with pool-based load balancing and health observations. Do not assume HTTP health-check response codes can be applied to a UDP-only application: use the probe options actually supported by the Stream type.
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.
## Behavior and boundaries
A TCP pool can be configured as follows (the same backend fields apply to UDP):
- **TCP** forwards a bidirectional byte stream. The proxy does not interpret application messages or terminate application TLS.
- **UDP** maintains bounded per-client forwarding sessions; datagrams and inactivity timeouts behave differently from TCP connections.
- **Not available:** HTTP Vhost routing, Host/path matching, HTTP cookies, WebSocket logic, shared HTTP cache, HTTP Basic/Tinyauth, HTTP header rules or frontend HTTP/3/TLS termination. Protect these endpoints with firewall rules and protocol-specific backend authentication/encryption.
- Listeners and backend IP/ports must be concrete, valid and nonconflicting with other RustProxy/admin/exporter sockets. Resource limits and network policy still apply.
```json
{
"id":"ssh", "name":"SSH pool", "enabled":true, "protocol":"tcp",
"listen":"0.0.0.0:2222", "upstream":"10.0.0.20:22",
"balance":"round_robin", "metrics_enabled":true,
"connect_timeout_seconds":5, "idle_timeout_seconds":300, "max_connections":512,
"backends":[
{"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}},
{"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}}
]
}
```
## Status and testing
`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.
Stream views/Overview include sessions, connections, traffic, rejected/errors and backend health/capacity when enabled. Counter reset or no-data state after service restart is normal.
## Health monitoring
The source package includes `scripts/start_stream_backends.sh`, `scripts/test_stream_tcp.py` and `scripts/test_stream_udp.py` for isolated test networks. Their mock selftests alone do **not** establish that RustProxy forwarding works; run end-to-end client traffic through the configured Stream listener.
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.
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.:
```json
{"mode":"udp_probe","interval_seconds":10,"timeout_ms":1000,"rise":2,"fall":2,"payload_hex":"50494e47","expect_hex":"504f4e47"}
```
**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.
## 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 use native `title` tooltips on hover and accessible button descriptions (`aria-description`) instead of floating elements that expand the layout. 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.
If clients cannot connect, inspect listen IP/port, host firewall, TCP vs UDP selection, routing to the chosen backend, backend health and stream logs. See [Security](SECURITY.md) and [Monitoring](MONITORING.md).
-115
View File
@@ -1,115 +0,0 @@
# RustProxy 0.33.8 verification report
This report accompanies the performance and compatibility work on the supplied
0.33.7 source archive and executable. All measurements use disposable local
state. Production data, certificates, configuration and installed services are
not modified. Raw results and exact binary hashes are supplied in the separate
verification archive. The live runners are in `scripts/performance/`.
## Scope and acceptance
The target is reduced work per uncached reverse-proxy request and per local
static-file response while retaining routing, security, cache policy, streaming,
observability and administrative functionality. The LB panel, database schema,
default TLS policy, cache defaults and bounded connection recycling are unchanged.
The performance comparison requires the same compiler, locked dependencies,
release profile, CPU allocation, payloads and client configuration for the
unmodified source and the candidate. The supplied executable is also retained as
an independent compatibility baseline.
Acceptance has separate layers: compilation and Rust tests; source/SQL/upgrade
checks; actual HTTP process tests; actual TLS/H2/H3/WebSocket tests; repeated
before/after/nginx throughput measurements; and a bounded mixed-traffic soak.
A passing source assertion or simulated DOM check is not a live runtime result.
Passing this matrix is not a claim that every possible deployment is covered.
## Baseline findings
The first candidate Rust run completed 658 tests successfully, with 22 failures
and one explicitly ignored formatting microbenchmark. An unmodified-source run
reproduces the old failures; the audit records each stale fixture and each actual
defect separately. The raw initial failed runs remain in the evidence archive.
Tests that expected obsolete behavior are corrected to exercise the currently
documented contract; security, cache invalidation and transport guarantees are
not weakened to obtain a passing result.
The initial live HTTP matrix gave identical results for the supplied executable
and rebuilt 0.33.7: 145 passes, three failures and one skipped optional Brotli
decoder case. All three failures concerned local HTTP/1 file-body completion:
normal responses were recorded as cancelled, and full or empty files failed to
publish a cache entry. The candidate uses a bounded local-file body with accurate
end-of-stream reporting. Arbitrary upstream bodies retain their original trailer
and end-of-stream handling.
The supplied executable passed 111 actual transport assertions covering verified
TLS, H2, H3/QUIC, stream multiplexing, uploads, cache behavior, ranges, validators
and HTTP/1 WebSocket data/control frames. The same runner is used for the final
candidate. The H3 client adapter accounts for HEAD semantics without disabling
GET body-length validation.
## Compatibility checks
The Rust suites exercise cache privacy, Vary and scheme separation, publication
generation barriers, stale access rules, disk tiers, header policies, ACL and
SSRF decision paths, timeout and permit lifetime, actual codecs, filesystem
bounds, symlink policies, range/conditional handling, SQL-backed administration,
TLS configuration, loopback upstream H1/H2 and raw H2 listener behavior. Stream
tests include real TCP/UDP peers. PHP tests use a deterministic FastCGI protocol
peer rather than an installed PHP-FPM application.
The HTTP process runner checks exact response bytes and hashes, streaming before
the final upload chunk, non-replayable POST failures, client cancellation,
configuration reloads, scoped headers and security policy, administration
session/CSRF, cache OFF/ON and privacy behavior, compression and transformations.
The transport runner uses actual local TLS and QUIC connections and trusts the
fixture certificate explicitly.
History-specific tests retain exact log fields, bounded chronological retention,
drop accounting and delivery to live subscribers and configured sinks during
forced history-lock contention. Cloning and destruction of evicted entries occur
outside the history mutex. This preserves the existing best-effort policy;
saturation measurements must still disclose actual history drops.
SQL checks apply all ten migrations twice, exercise named dashboard presets and
account isolation, and verify the 16-preset bound and cascade behavior. The source
upgrade test uses disposable installations to check the stopped-process guard,
held lock rejection, data/configuration preservation, managed source replacement,
backup creation and symlink rejection. Existing JavaScript checks retain their
status as simulated UI checks; no browser acceptance is inferred from them.
## Reproduction
Use the project entry points, which set the private process umask required by
key-store and certificate fixtures:
```bash
./dev.sh check
CARGO_INCREMENTAL=0 ./dev.sh test
python3 scripts/tests/dashboard_presets_sql_test.py
python3 scripts/tests/upgrade_performance_test.py
```
The isolated validation environment uses Rust 1.99.1 with the original locked
dependency versions, nginx 1.26.3 and wrk 4.2.0. See
`scripts/performance/README.md` for live regression, transport, profiling,
benchmark and soak commands. Supply explicit executable paths and new output
directories; preserve failed runs as evidence.
## Measurement boundaries
Throughput measurements use HTTP/1.1 keep-alive, cache OFF, identity encoding,
1040-byte, 64-KiB and 1-MiB objects, separate server/origin/client CPU sets,
interleaved variants and excluded warm-up. Resource samples retain CPU time,
RSS, file descriptors, generator errors and application drop counters. Medians
use all scheduled repetitions. Closed-loop wrk latency is not an open-loop SLA.
Syscall profiles are collected separately from timed throughput runs.
Remaining deployment-specific checks include a 24-hour production-like soak,
remote-network latency and loss, actual PHP-FPM applications, external Tinyauth,
DNS rebinding against a real resolver, and HTTP/2 or HTTP/3 WebSocket tunnels.
The measured same-host comparison does not establish internet-facing capacity.
Final executable-specific outcomes, run counts, percentages and resource costs
are recorded in the separately generated performance/evidence report after the
release build. This embedded document describes the test contract and baseline;
it does not substitute planned work for a completed measurement.
-19
View File
@@ -1,19 +0,0 @@
# RustProxy TODO
This file contains open work that remains after the current RustProxy 0.25 Web Server implementation.
## Web Server follow-up
- Multipart byte-range responses if required; current single-range/If-Range support remains the default scope.
- Named locations only if they can reuse the existing vhost/location router rather than introduce a second router.
- Optional PHP `PATH_INFO` script/path splitting after a confinement-safe rule is defined.
- Additional FastCGI observability (bounded active/error/latency counters) without per-script labels.
- A race-resistant non-Unix filesystem confinement implementation before enabling the Web Server on non-Unix targets.
## Release acceptance
- Run `./dev.sh fmt`, `./dev.sh check` and `./dev.sh test` on a host with the stable Rust toolchain.
- Run live static-file acceptance over HTTP/1.1, HTTP/2 and HTTP/3, including ACL/auth/custom-error/header behavior.
- Run a real PHP-FPM acceptance matrix over a Unix socket and loopback TCP, including `index.php`, front-controller `try_files`, POST bodies, timeouts and source-disclosure failure tests.
Completed release work belongs in `CHANGELOG.md`; planned version work belongs in `ROADMAP.md`; implementation/audit evidence belongs in the matching audit document.
-55
View File
@@ -1,55 +0,0 @@
# Static-response transformation
RustProxy 0.22.3 retains static transformation between origin decoding and shared-cache
storage. A transformed identity representation is cached once and downstream compression
is selected later for each client. Per-vhost and Docker-discovery transform opt-in remain
disabled by default even when the global engine is available.
## Web Server origins (0.29.4)
Static-file and PHP-FPM responses selected through Web Server vhost sources
use the same optional vhost transform stage as reverse-proxy backends.
The normal rules still apply: supported MIME/extension and response encoding,
known bounded body size, memory/concurrency limits, and `no-transform`
exclusions. Successful transformed representations are eligible for shared
cache storage under the original/derived cache privacy policy. Downstream
compression is still applied separately.
## Administration dashboard
The main **Transform** workspace reads `GET /api/transform` and exposes in-memory runtime
statistics globally or per vhost:
- responses examined, transformed, skipped/fail-open and failed;
- transformed cache HITs (served without running the transformer again);
- original, transformed and saved bytes before client compression;
- average transform time;
- applied transforms by JavaScript/CSS/HTML/SVG/JSON;
- bounded skip/fail-open reason counters;
- current process-wide temporary-memory reservation versus the configured budget;
- current active transform slots versus the configured concurrency limit;
- a per-vhost effectiveness table.
Counters reset on process restart. They are diagnostic telemetry, not persisted cache
metadata. Resource usage is process-wide even when a vhost counter scope is selected. The
reservation is the transform limiter's conservative buffer estimate, not a second RSS
measurement. The Cache workspace separately marks stored transformed objects.
## Cache diagnostics
When `cache.expose_cache_status=true`, transformed cached representations are identified
with the RFC 9211 field plus an extension parameter:
```http
Cache-Status: RustProxy; hit; ttl=556488; detail="cache_hit"; transformed
```
The field remains `Cache-Status`, not `X-Cache-Status`. HTTP/2 and HTTP/3 normalize field
names to lowercase on the wire. Origin-provided `Cache-Status` and `X-Cache-Status` values
are removed before RustProxy emits its own diagnostics.
## Release status
The dashboard/telemetry does not change the existing v0.22.3 release gate. JavaScript and
CSS still require mature parser-backed minifiers, and the complete Rust compiler/runtime
acceptance gate must pass before release acceptance.
+26 -209
View File
@@ -1,223 +1,40 @@
# Transport contract
# HTTP transport, TLS, HTTP/2 and HTTP/3
This document describes the implementation contract of 0.22.3. It is not a record
of successful Rust execution; see TEST_REPORT.md for verification status.
RustProxy distinguishes **frontend** client protocols from the **upstream** protocol used to contact an application. Selecting HTTP/3 on a listener does not require an HTTP/3 backend.
## Protocol matrix
## Frontend listeners
| Connection | Implemented mode |
|---|---|
| Client to cleartext listener | HTTP/1.1 plus opt-in HTTP/2 prior knowledge and HTTP/1.1 `Upgrade: h2c`; WebSocket Upgrade on H1 and RFC 8441 Extended CONNECT on H2 when enabled per vhost |
| Client to TLS listener | HTTP/1.1 or HTTP/2 through ALPN; WebSocket uses H1 Upgrade by default or opt-in RFC 8441 Extended CONNECT on H2 |
| Client to HTTP/3 TLS listener | HTTP/3 over QUIC; RFC 9220 WebSocket Extended CONNECT only when a bound vhost explicitly opts in |
| Proxy to HTTP backend, `http1` | HTTP/1.1 |
| Proxy to HTTPS backend, `http1` | TLS with HTTP/1.1 |
| Proxy to HTTP backend, `http2` | HTTP/2 prior knowledge; the backend must support it |
| Proxy to HTTPS backend, `http2` | Prefer HTTP/2 through ALPN, retain HTTP/1.1 |
| Proxy to HTTPS backend, `auto` | ALPN HTTP/2 or HTTP/1.1 |
| Proxy to HTTP backend, `auto` | HTTP/1.1; no automatic h2c upgrade |
| Proxy to a WebSocket backend | HTTP/1.1 Upgrade over HTTP or HTTPS |
- **HTTP/1.1:** default plaintext HTTP and standard Upgrade-based WebSocket support where enabled.
- **HTTPS / HTTP/2:** TLS listeners may negotiate HTTP/2 and HTTP/1.1. Settings include H2 idle/connection-drain controls.
- **HTTP/3 (QUIC):** optional UDP transport on an enabled TLS frontend, using the listener certificate and supported H3 settings. The host firewall/NAT must pass UDP on the actual HTTPS port, not only TCP/443. Browsers can fall back to H2/H1 when H3 is unavailable.
- **H2C:** explicit cleartext HTTP/2 capability only where the listener or upstream policy enables it; it is not ordinary browser HTTPS.
Cleartext downstream HTTP/2 is an explicit listener opt-in. The same listener accepts both
the HTTP/2 prior-knowledge preface and a standards-oriented body-free HTTP/1.1 `Upgrade: h2c`
bootstrap. Upgrade requests must include `Connection: Upgrade, HTTP2-Settings`, exactly one
`Upgrade: h2c` and one valid base64url `HTTP2-Settings` field. RustProxy sends `101`, waits for
the client's normal HTTP/2 connection preface and initial SETTINGS frame, then reconstructs the
accepted HTTP/1.1 request as HTTP/2 stream 1 before handing later streams to Hyper's ordinary H2
state machine. Body-bearing or malformed upgrade requests are rejected before `101`. HTTP/1.1
remains available on the same cleartext listener.
Transport settings attach to **named listeners**, and a Vhost must select the appropriate listener IDs. Addresses locked by CLI/systemd startup cannot be rewritten from the panel. Changing a listener bind or startup-owned TLS option may require a restart.
Enabling WebSocket on a vhost keeps the HTTP/1.1 Upgrade path by default. `websocket_rfc8441`
and `websocket_rfc9220` are independent per-vhost opt-ins for HTTP/2 and HTTP/3 Extended
CONNECT with `:protocol=websocket`. HTTP/2 listeners advertise ENABLE_CONNECT_PROTOCOL when
serving H2; QUIC advertises it when required by the HTTP/3 transport. Routing still rejects
Extended CONNECT on vhosts that did not opt in. Ordinary CONNECT and other Extended CONNECT
protocols remain rejected because RustProxy is not an open forward proxy. RFC 8441 may bridge
to an HTTP/1.1 WebSocket backend or use a backend explicitly configured for HTTP/2.
## Backend protocol selection
Since 0.32.4, `preserve_host: true` is supported with `http1`, `auto`, `http2`
and strict `h2`. H1 carries the public Host; on H2 the outgoing `:authority`
follows that Host. The *network* destination and DNS/ACL policy stay pinned to
the configured backend URL. TLS SNI and certificate validation independently use
`tls_server_name` when supplied or the configured origin otherwise.
The client hostname also remains available via `X-Forwarded-Host`. HTTP health
probes are separate H1 requests and may need an explicit `health_check.host`.
| Backend mode | HTTPS origin | Plain HTTP origin |
|---|---|---|
| `http1` | HTTP/1.1 | HTTP/1.1 |
| `auto` | ALPN H2 or H1 | HTTP/1.1 |
| Legacy `http2` | Compatible ALPN H2/H1 | H2C |
| Strict `h2` | Requires H2 | Requires H2C |
For ordinary proxy traffic, an explicit `protocol=http2` backend is forwarded with an
HTTP/2 request version. Cleartext `http://` uses h2 prior knowledge. HTTPS keeps the
existing ALPN policy and offers HTTP/2 with HTTP/1.1 compatibility; RustProxy does not
replay a failed TLS request merely to downgrade protocols.
WebSockets normally use HTTP/1.1 Upgrade. HTTP/2/HTTP/3 client requests can still reach HTTP/1.1 backends when the selected bridge/path is supported; do not assume the frontend and origin protocol always match. HTTP/2 GOAWAY, H3 stream cancellation and retries are constrained to safe lifecycle cases. Use backend protocol telemetry and logs when diagnosing negotiation.
Balanced routes may perform bounded failover retry only for body-free `GET`, `HEAD` and
`OPTIONS`. Defaults allow one retry on connection failure, response-header timeout or
`502/503/504`, always selecting another currently healthy backend. Path-pinned routes,
WebSocket handshakes and write/body-bearing requests are never automatically replayed.
## TLS and trust
## DNS and upstream TLS
Public HTTPS requires a correct certificate with hostname coverage. Administration TLS is separate. Backend `https://` encrypts traffic, but **upstream certificate verification is opt-in by default** in the current backend model; enable it and specify CA/name where needed. Private/loopback addresses and routing to a local RustProxy listener require explicit risk overrides.
Only explicit HTTP/HTTPS backend origins are accepted; no credentials, path, query
or fragment are allowed. Client requests cannot choose an arbitrary upstream URL.
## Timeouts, request bodies and streaming
Each new DNS connection uses a guarded resolver. Every returned IPv4/IPv6 address
must pass the configured address policy, with IPv4-mapped IPv6 normalized for checks.
A mixed safe/unsafe response is rejected as a whole. Up to 32 answers and 16 OS
resolver workers are allowed. Validated answers go directly to the HTTP connector:
there is no second unvalidated DNS lookup before dialing. Existing pooled sockets
remain bound to their original peer until closed; they do not silently move when
DNS changes. New snapshots get new connection pools.
Idle/read/connect/response-header timeouts and backend connection pools are configurable. A response may stream after its headers have arrived; response-header metrics are not full-body duration. WebSockets and Streams have separate idle lifetimes and should be tested with the actual application. RustProxy rejects suspicious ambiguous request targets rather than normalizing them into potentially unsafe backend paths.
`allow_private` is explicit approval for eligible private/loopback addresses, not a
universal SSRF bypass. `address_allowlist` further restricts eligible addresses by
CIDR. Include both address families when a hostname legitimately resolves to both.
Link-local/metadata, multicast, unspecified and certain special ranges remain
blocked. Remote origins may use any valid backend port, including 9090. A target that
resolves to an actual/local RustProxy proxy, TLS or administration listener remains
blocked by default, including validated DNS answers. An administrator may intentionally
override only that self-target guard per backend with `accept_local_listener_risk: true`;
loopback/private targets still require `allow_private: true`. This opt-in can create a
proxy loop or expose the administration plane, so keep it disabled unless that route is
deliberate and separately access-controlled. For example, an intentional local backend
`http://127.0.0.1:9090` requires both `allow_private: true` and
`accept_local_listener_risk: true`; this only permits the connection and does not bypass
the administration listener's own Host/Origin/authentication policy. The special-range
policy is a basic safeguard, not a substitute for network egress controls or an independent
security audit.
## Troubleshooting
HTTPS upstream transport remains encrypted, but certificate-chain/hostname verification
is **off by default**. Set `tls_verify: true` to authenticate the backend identity. An
optional `tls_ca_pem` adds up to 16 trust anchors within 64 KiB per backend when
verification is enabled. The configured `tls_server_name`, when present, determines TLS
identity while DNS/TCP still targets the configured URL. Upstream redirects are returned
to the client, not followed internally.
- **HTTPS works but H3 does not:** check UDP firewall/NAT, listener H3 enablement, TLS certificate, network middleboxes and actual browser negotiation.
- **502 / protocol error to backend:** verify origin supports selected `auto`, `http2`, `h2` or H2C mode and the correct TLS hostname/verification policy.
- **WebSocket disconnects:** enable websocket forwarding in the Vhost and ensure idle limits/proxy/backend are compatible.
- **Unexpected HTTP→HTTPS redirect:** inspect Vhost `force_ssl`, selected listeners and actual public scheme.
## Public certificates
`dev.sh` enables `127.0.0.1:8443` by default. Direct binary startup enables TLS only
when `serve --tls-listen ADDRESS` or the corresponding environment setting is used.
Changing listener addresses or enabling a new listener requires a restart.
`data/certs/default.pem` and `default.key` are created once when needed. Generated
certificates are self-signed development material; they do not provide public trust.
Explicit bindings use exact hostname names and already-present certificate/key files.
Manual entries support two addressing modes: simple filenames are loaded from
`<data-dir>/certs`, while two absolute paths are read in place (for example Certbot
`/etc/letsencrypt/live/<name>/fullchain.pem` plus `privkey.pem`). Mixed relative/absolute
pairs are rejected. The API stores filenames/paths and names, never private key bytes.
CLI generate/import refuses to overwrite named files. Input size, regular-file status,
private-key permissions, matching keys, chain parsing and name coverage are validated.
```bash
rust-proxy tls generate --name app --hosts app.localhost,alias.localhost
rust-proxy tls import --name app-v2 --cert fullchain.pem --key private.key
rust-proxy tls deploy --id shared-cert \
--cert /etc/letsencrypt/live/example/fullchain.pem \
--key /etc/letsencrypt/live/example/privkey.pem
rust-proxy tls reload
```
Select these files in the panel or `tls.certificates` configuration. Set
`tls.default_certificate` to a binding ID to change the fallback. Unknown/no SNI
receives that fallback, not a default HTTP route. Unknown Host still returns 404.
Wildcard/SAN coverage and certificate expiry/issuer/renewal status are exposed by the certificate inventory; certificate ownership remains explicit per certificate id.
`tls deploy` is intended for an external issuer such as a system Certbot. Certbot's
`live/*.pem` symlinks are resolved locally; the final files are bounded regular files and
the private key must not be group/world readable. The local CLI performs bounded file/permission checks and sends the PEM pair through the authenticated loopback deploy API. The running RustProxy process treats the request as a convergent upsert: it creates a missing manual `certificate_id`, rotates changed material through immutable generation-specific files, and returns a no-op for an identical RustProxy-owned generation. The same id may be assigned to many vhosts, which all switch together when the pair changes. RustProxy-managed ACME certificate ids are deliberately rejected by this command so one lineage never has two renewal owners.
`POST /api/certificates/reload` and `rust-proxy tls reload` re-read all currently configured
certificate/key files and publish a fresh public TLS snapshot without changing the desired
configuration or its revision. This supports an operator that replaces the already configured
files by another safe local mechanism. Validation and file reads happen on bounded control-plane
workers; invalid material leaves the previous snapshot active. New public TLS/H2 and HTTP/3
handshakes use the refreshed snapshot, while established sessions/tunnels are not renegotiated.
The administration TLS listener remains startup-fixed and reports that a service restart is
required when its selected certificate changed.
For an externally managed/manual certificate whose configured files were replaced in place, `POST /api/certificates/{certificate_id}/reload` provides the same revision-stable hot-publication contract while targeting that certificate ID. For absolute external paths RustProxy canonicalizes the path again on every reload, intentionally following Certbot `live/` symlinks to the current `archive/` generation before opening the final regular file. The TLS resolver is immutable, so RustProxy validates and republishes a complete snapshot atomically; this avoids partially updated SNI state. Managed ACME certificate IDs are not accepted by this manual reload path.
External absolute-path files are not copied into RustProxy full backups. Their path strings remain in configuration; restore or mount the certificate material separately on the destination host before starting TLS. The `rustproxy` service identity must be able to traverse every parent directory and read both files. RustProxy accepts POSIX ACL read access and does not require an exact chmod value. External certificate/key files must not be group/world-writable, and private keys must not be world-readable. The configured and resolved parent chains must be root- or RustProxy-owned and not group/world-writable, except for root-owned sticky ancestors such as `/tmp`. The final file is opened with `O_NOFOLLOW` after path validation.
## WebSocket behavior
The default WebSocket path validates an HTTP/1.1 GET upgrade, version 13, one valid key,
connection upgrade tokens and a body-free handshake. RFC 8441 over HTTP/2 and RFC 9220 over
HTTP/3 must be enabled explicitly for the selected vhost and use Extended CONNECT rather
than H1-only Upgrade/Connection/Key headers. When RFC 8441/9220 is bridged to an HTTP/1.1
backend, RustProxy performs and validates the backend RFC 6455 handshake before exposing a
successful downstream tunnel. Direct HTTP/2 upstream RFC 8441 validates the 2xx response and
selected subprotocol. Protected handshake fields cannot be replaced by generic custom-header
rules. Unsupported/malformed upgrades fail; unsolicited upstream 101 responses become a
gateway error.
After an accepted handshake the proxy copies bytes in both directions using bounded
buffers and backpressure. It does not aggregate messages, interpret application
payloads, log frames or hardcode a particular WebSocket application. Fragmentation,
control frames and negotiated extensions remain the peers' responsibility.
The demo echo server separately caps messages/frames at 64 KiB.
Request and TCP-connection leases survive Hyper's upgrade and are held until the
tunnel closes. An independent idle timeout observes traffic in either direction.
Shutdown cancels tunnels; routing/ACL changes affect new handshakes, not existing tunnels.
The administration API and dashboard expose active tunnels with a stable exchange ID, client,
host, upstream, transport, duration and RX/TX counters. An administrator can cancel one tunnel;
the action and supplied reason are written to the audit log.
## HTTP/2 connection retirement
Downstream H2 idle retirement is activity-aware: RustProxy tracks transport I/O and accepted streams, rechecks that a connection is actually unused, then asks Hyper for graceful shutdown. Hyper sends GOAWAY and refuses newer streams while accepted streams continue. Each H2/H2C listener uses `binding.http2_settings.max_connection_lifetime_seconds` for the independent absolute lifetime (zero disables it) and `binding.http2_settings.drain_timeout_seconds` for the maximum drain before TCP is forcibly dropped. Older configurations without per-listener values inherit the legacy top-level `http2` settings. RFC 8441 Extended CONNECT/WebSocket streams remain active for this accounting until their tunnel body ends.
## Resource and timeout defaults
| Resource | Default / bound |
|---|---|
| Shared public TCP connections, HTTP + TLS | 512; configurable up to 4096 |
| Active request/body/tunnel exchanges | 256; configurable up to 4096 |
| Concurrent HTTP/2 streams per public connection | 64 |
| HTTP/2 header list / send buffer | 32 KiB / 64 KiB |
| HTTP/2 stream / connection receive window | 65,535 bytes / 1 MiB |
| HTTP/2 idle / max lifetime / drain | 300 s / disabled (0) / 10 s |
| Public HTTP/1 header timeout | 10 seconds |
| TLS accept / upstream connect including TLS | 10 seconds |
| DNS lookup caller timeout / OS workers | 5 seconds / 16 |
| Backend response-header deadline | 120 seconds, including upload until headers |
| HTTP body idle timeout | 60 seconds |
| WebSocket idle timeout / upgrade completion | 300 seconds / 10 seconds |
| Idle pooled upstream connections | 4 per backend origin, 30-second idle timeout |
| Configuration writers / log-viewer streams | 8 / 8 |
CLI serve flags expose connection/in-flight counts and HTTP body, WebSocket idle,
upstream and shutdown timeouts. OS DNS calls cannot be forcibly cancelled; their
worker permits remain occupied until completion. Runtime shutdown stops waiting
for blocking workers after an additional bounded timeout.
Request and response bodies stream without collection, including trailers. Body-bearing/write requests are not transparently replayed. Replay-safe body-free
GET/HEAD/OPTIONS may use the bounded backend retry policy described above. Bytes counted at the body layer are not
acknowledged network bytes. WebSocket counters measure raw frame bytes separately
from HTTP body counters and exclude TLS/TCP overhead.
## Vhost sockets and keepalive in 0.22.3
Public connections across global and vhost endpoints share the same connection
budget. Per-vhost routing uses the actual accepted local IP/port/TLS state, including
when an OS wildcard socket is shared. See VHOSTS.md for overlap rules and restrictions.
Changing sockets or their ALPN policy needs restart. Certificate refresh remains hot.
TLS `http2: true` advertises h2 followed by http/1.1; false advertises HTTP/1.1 only.
An HTTP/1.1-only client, including one without ALPN, can connect when h2 is enabled.
HTTPS origins configured `http2` also negotiate h2 or HTTP/1.1. This is one TLS
negotiation, not a failed-request retry or TLS-verification downgrade. Plaintext
h2c remains explicit and strict: when `h2c: true`, a cleartext listener accepts both HTTP/2
prior knowledge and validated HTTP/1.1 `Upgrade: h2c`. The Upgrade bootstrap is body-free,
requires `HTTP2-Settings`, and becomes HTTP/2 stream 1 after `101`. Optional downstream HTTP/3
remains a separate QUIC listener capability (see HTTP3.md).
Pool keepalive settings configure idle connection reuse, idle timeout/limit, TCP
keepalive and HTTP/2 active-connection pings. They also bound each backend Hyper client
generation by request count/age and cap the upstream HTTP/1 adaptive read buffer. Client
generation recycling is local to the backend pool and does not publish a new vhost
snapshot or purge cache. Health probes are separate no-reuse HTTP/1.1 or raw TCP
operations; their concurrency cap is 16. Existing active exchanges are not intentionally
killed by disabling reuse, recycling a generation or draining a member. Config
publication still rebuilds client pools while preserving compatible observations and
existing exchange leases. The full contract and bounds are in VHOSTS.md. Keepalived/VRRP
is not involved.
For listener configuration see [Configuration](CONFIGURATION.md); for safer deployment see [Security](SECURITY.md).
+61
View File
@@ -0,0 +1,61 @@
# Troubleshooting and validation checklist
Start with a reproducible request, its hostname/path and the exact listener. Compare **Vhost**, **Backend**, **Cache**, **Logs** and the service journal before changing multiple settings at once.
## Quick symptoms
| Symptom | First checks |
|---|---|
| Domain returns the wrong site or local 404 | DNS A/AAAA, selected listener ID, exact vs wildcard host precedence, Vhost enabled state, redirect/404 host ownership. |
| HTTP works, HTTPS does not | Trusted certificate, hostname/SAN coverage, TLS listener, port 443 firewall, certificate assignment and whether restart was needed. |
| HTTPS/H2 works but H3 fails | UDP firewall/NAT, H3 toggle on TLS listener, browser QUIC support and actual transport negotiation. |
| `502` / `503` / `504` to origin | Backend IP/port, pool eligibility/health, private-address policy, upstream HTTPS verification, protocol `auto`/`h2`, timeout and container/PHP process availability. |
| WebSocket handshake fails or disconnects | Vhost WebSockets flag, backend Upgrade support, TLS/frontend protocol path, WebSocket idle limits and auth redirect. |
| `403` / `405` / `429` | Applicable IP/ACL/country policy, HTTP methods, rate limit, GeoIP availability, Basic/Tinyauth permissions. |
| Cache always BYPASS or MISS | Global + Vhost enablement, cookies/login, origin `Cache-Control`, `Set-Cookie`, `Vary`, TTL, size and exclusion rules. |
| Content remains stale | Cache Age/resident age, origin validator, policy changes, matching scheme/Host/query and selected purge scope. |
| Static file not found | Document root, process read permissions, index files, symlink policy, `try_files` and rewrite ordering. |
| PHP returns errors or another site's PHP settings | FPM endpoint/socket permission, distinct pool/INI settings, pool restart, PHP `PATH_INFO`, application logs. |
| Docker Vhost remains WAITING / 503 | First agent poll, token/certificate pin, published host TCP binding, backend IP, container state and health. |
| Stream TCP/UDP unreachable | Actual listen address/port, protocol and firewall, backend IP, Stream health/session limits; HTTP Vhost settings do not apply. |
| Administration cannot be accessed remotely | Admin binds loopback by default. Use SSH tunnel or configure **separate admin HTTPS** and intentional bind address. |
| Prometheus exporter unavailable | Dedicated listener, CIDR and token, configured path, startup/restart state and monitoring server route. |
| Logs missing or search incomplete | Check sink enabled/path/permissions, Live vs Stored distinction, log rotation and bounded scan/truncation notices. |
| Certificate renewal fails | ACME runtime, HTTP-01 TCP 80, DNS-01 token/zone, expired credentials, Certbot files and external key ACL after rotation. |
## Useful commands
Local service status for installed Linux/systemd deployments:
```bash
systemctl status rustproxy
journalctl -u rustproxy -n 200 --no-pager
```
Inspect a specific HTTP frontend with the correct Host (adapt hostname, port and path):
```bash
curl -v -H 'Host: app.example.com' http://127.0.0.1:8080/health
```
For HTTPS certificate/routing testing use the real site DNS or a deliberate `curl --resolve` mapping; avoid permanently disabling certificate verification as a workaround. The local CLI can check configuration without saving:
```bash
rust-proxy --data-dir /var/lib/rustproxy config get
rust-proxy --data-dir /var/lib/rustproxy config validate config.json
```
Use **Logs → Live Logs** or the `--livelogs` CLI mode to correlate request IDs, Vhost/route/backend and status. Inspect **Stored Logs** for previously saved JSONL and selected low-level protocol failures.
## Rollout gate for RustProxy 0.34.0
The latest source optimizations were **not all compiled or performance-tested** in the evidence supplied with this release. Before deploying:
1. Build using the declared Rust toolchain and run the project's available regression suite on the target platform.
2. Verify HTTP/1.1, HTTPS, HTTP/2, HTTP/3 (if enabled), WebSockets and multi-backend failover with a real origin.
3. Test private origin constraints, ACL/auth/CSRF, rate limits and protected cache content.
4. Test Web Server static files, PHP-FPM, cache revalidation/invalidation and optional `sendfile=auto` only if enabled.
5. Exercise certificate renewal/reload and backup **restore** on isolated staging.
6. Compare performance only under matched configurations with controlled repeatable traffic; do **not** label 0.34.0 as faster merely because its source reduces internal work.
For persistent problems, use the feature-specific guide in [Documentation](README.md) and preserve a redacted error example with environment/version and request ID.
+38 -474
View File
@@ -1,493 +1,57 @@
> 0.7 adds graphical locations, Basic Auth, cumulative IP ACL editing and a
> WebSocket toggle. See [host access and locations](VHOST_ACCESS.md).
# Vhosts, domains and backends
# Virtual hosts, frontend bindings and backend pools
A **Vhost** publishes one or more hostnames on named HTTP/HTTPS listeners. Its source can be an HTTP reverse-proxy backend pool or a reusable local Web Service. Use **Vhosts → Add** in the panel.
This is the source implementation contract. Rust compilation and runtime acceptance
were not performed in the packaging environment; see TEST_REPORT.md.
## Publish a site
## Backend quick-view in Vhosts
1. Enter an ID and the exact domain (`app.example.com`) or supported wildcard (`*.example.com`). Exact names win over wildcards. Configure DNS independently.
2. Choose the enabled HTTP/HTTPS frontend listeners. Select a certificate for HTTPS.
3. Choose **Reverse Proxy** and add an upstream address such as `http://10.0.0.20:3000`, or select an existing **Web Service**.
4. Check backend reachability and TLS policy; configure health checks and load balancing for multiple origins.
5. Decide whether to force HTTPS, redirect www/non-www, enable WebSockets, cache, access rules and custom paths.
6. Save, visit the hostname, inspect the Vhost's health and logs, then test failure and recovery.
Click the **Backends** count or the **Status** tile of a Vhost to open its
backend list. Each member displays the configured ID, origin host/IP and port,
HTTP/HTTPS scheme and HTTP mode, current reported health and `last_status`.
`last_status` is the most recent HTTP code from a proxied request; zero/no
member means no known HTTP response, not a failed probe. DNS origins show the
configured hostname, as the status API does not report the resolved IP.
The panel updates from the existing status feed without initiating requests,
and supports Escape and click-outside dismissal.
A Vhost may bind several frontends. Hostnames and selected listeners must match: the existence of a certificate or an upstream does not create DNS entries or a listening socket. Existing Vhosts retain their bindings when new listeners are created.
## One vhost, one backend pool
## Source and backends
A `vhosts` entry owns an `id`, `enabled`, `frontend`, `source`, `backends`, `load_balancing`,
`routes`, optional `acl`, `headers` and `security_headers`. `source` defaults to Reverse Proxy
for older documents; it can instead reference an independently configured Web Service. See
[WEB_SERVER.md](WEB_SERVER.md). The pre-production top-level
routing and inline-listener formats are no longer accepted. Unknown JSON fields are
rejected. The examples are complete documents, not JSON merge patches:
- **Reverse Proxy:** one Vhost has a backend pool. Each member has a configured URL, ID, enabled flag, weight, optional backup/drain state, health settings and capacity limits.
- **Web Server:** Vhost references a Web Service by ID. Its document root, directory handling, PHP options and static-file headers remain configured under **Web Server**.
- **Custom locations:** can override selected URL paths and use the default pool, a pinned backend, a different Web Service, a redirect or a local response. Unmatched paths still use the main source.
- `examples/vhosts-private.json`: explicit loopback IP, two existing HTTP origins,
adaptive balancing, HTTP health checks, keepalive, no cache or cookie affinity.
- `examples/vhosts-balanced.json`: explicitly public HTTP 80 and TLS 443, cookie
affinity and a deliberately short HSTS value; requires a trusted, imported `app`
certificate/key and real origins. It does not create those services or files.
Sample documents in `examples/vhosts-private.json` and `examples/vhosts-balanced.json` illustrate JSON formats, but their IP addresses, certificates and health endpoints are examples only.
In the examples, both origins need `/health` to return a quick 2xx response. Adjust
that path/status range for the real service. New pools start quarantined until
`rise` successful checks; an unavailable or misconfigured check deliberately causes
503 instead of sending traffic to an unverified member. `mode: off` opts out.
## Backend connection and HTTP modes
The editor includes frontends, members, balancing, health, connection reuse, sticky
sessions and response protection. Custom locations have graphical match/action/rewrite/ACL/header
controls plus synchronized Advanced JSON for automation. Unsaved drafts survive translation/SSE refreshes. A stale draft must be
reloaded before saving: the panel does not silently merge competing revisions.
Existing vhost IDs can be renamed in the dashboard as long as the new ID is valid and
unique. The rename keeps the configured frontend hostnames, routes and backend IDs, but it
intentionally starts a new vhost runtime identity for metrics/log attribution and current
health/traffic history. Because the vhost configuration participates in cache compatibility,
a rename also rotates that compatibility identity and invalidates incompatible cached objects.
The editor warns before applying the rename.
## Internal error routing
The main vhost `source` may define `error_fallback`, and each Custom location may do the same. A fallback references the `id` of an enabled `internal` Custom location in the same vhost. Supported statuses are `403`, `404`, `500`, `502`, `503` and `504`; omitted status lists default to `404`. Internal targets are not reachable by ordinary path matching.
Configured upstream/Web Server failures are intercepted before final response/cache publication. The original request method, URI/query and context are internally dispatched to the target, which then follows normal vhost/location processing. Validation rejects fallback cycles and runtime dispatch is depth-limited. See [Custom Locations](CUSTOM_LOCATIONS.md) for the active internal-fallback behavior.
## Per-backend health monitoring (0.29.7)
Each backend has an independent `health_check_enabled` switch (default `true`).
Setting it to `false` excludes that member from both active health probes and
passive health ejection, **without** disabling the member for a named location.
This is useful for an internal 404 handler configured with `pool_eligible: false`
and a location pinned through `backend_id`: the 404 handler never receives
ordinary balanced traffic and does not need health monitoring.
A backend may also define `health_check` with the same fields as
`load_balancing.health_check` to use its own TCP/HTTP/off probe policy, timing,
path, Host and status ranges. Omitting it inherits the vhost policy. A custom
HTTP health check without `host` inherits the vhost Host default even when the
vhost's main health check is TCP. When monitoring is disabled, the backend is
assumed available; connection errors during real requests are still counted and
logged, so monitoring off is not a guarantee of backend reachability.
## Frontend selection and private IP isolation
`frontend.hosts` is a normalized hostname-pattern list. Exact hostnames and a single
leading wildcard such as `*.example.com` are supported; regex and mid-label wildcards
are not. Routing tries the exact hostname first, then the longest matching wildcard.
This means `api.example.com` can have its own vhost while `*.example.com` handles other
subdomains on the same listener without stealing the exact route.
`frontend.listener_ids` references named `listeners.endpoints` configured under
Settings. A listener owns its IP:port, TLS flag and H2/H3 transport capabilities;
a vhost never stores a listening address directly. For a new dashboard vhost, the
editor preselects both an enabled HTTP listener on port 80 and an enabled TLS listener
on port 443 when they exist (public-scope listeners are preferred). Existing vhost
bindings are never replaced by that convenience default.
`force_ssl` defaults to false. When enabled, any cleartext request that matches this
vhost receives HTTP `308 Permanent Redirect` to the same normalized hostname and
original path/query. At least one selected plain HTTP listener and one selected TLS
listener are required so there is both an ingress to redirect and an HTTPS target. If
several TLS ports are selected, port 443 is preferred; otherwise the selected TLS target
port must be unique.
`www_redirect` defaults to false. When enabled, `www_redirect_target` selects an exact DNS hostname from `frontend.hosts` as canonical (default: the first hostname for backwards compatibility). RustProxy also claims its direct companion (`example.com` <-> `www.example.com`) on the same listeners and returns `308 Permanent Redirect` from that companion to the first hostname while preserving path/query. Wildcards and IP literals cannot be the canonical hostname for this feature. The companion also participates in vhost certificate assignment, but the selected certificate must still contain both DNS names in its SANs. When `force_ssl` is also enabled, an HTTP companion request redirects directly to the HTTPS canonical URL instead of creating two redirect hops.
`forward_client_ip` defaults to true. Normal vhosts therefore receive the sanitized
`Forwarded`, `X-Forwarded-For` and `X-Real-IP` client identity generated by RustProxy.
Set it to false for applications whose own trusted-proxy/bypass logic must see the
RustProxy TCP peer instead. Only the backend application request loses those three
client-IP headers; ACLs, GeoIP, rate limiting and Tinyauth still use the effective client
address, and origin metadata such as `X-Forwarded-Proto`, `X-Forwarded-Host`,
`X-Forwarded-Port` and `X-Forwarded-URI` remains available to the backend.
`websockets` enables WebSocket handling for the vhost. The dashboard defaults new WebSocket
vhosts to the nginx-compatible HTTP/1.1 Upgrade transport. `websocket_legacy_support: true` is
a separate per-vhost compatibility switch for older appliances and embedded servers. It affects
only WebSocket handshakes: RustProxy forces an HTTP/1.1 upstream WebSocket hop, uses the configured backend authority as upstream `Host`,
rewrites `Origin` to the matching upstream scheme + backend authority, and tolerates legacy `101 Switching Protocols` response
framing while keeping the `Sec-WebSocket-Accept` proof and offered subprotocol checks strict.
Ordinary HTTP requests are unchanged. `websocket_rfc8441: true` and `websocket_rfc9220: true` are
separate opt-ins for HTTP/2 and HTTP/3 Extended CONNECT and are valid only when WebSocket
support is enabled. They are presented together in the vhost editor.
RFC 8441 requires an H2-capable selected listener (TLS ALPN or cleartext h2c prior knowledge).
Because the RFC 9220 advertisement is QUIC-connection scoped, one opted-in vhost causes the
shared QUIC listener to advertise Extended CONNECT, while request routing still rejects RFC
9220 on non-opted-in vhosts.
`http2_enabled` and `http3_enabled` are per-vhost opt-outs (both default true). The
panel presents the two switches together and enables each only when at least one
selected listener provides that protocol. For TLS H2, ClientHello/SNI selects H2+HTTP/1.1 or HTTP/1.1-only ALPN for
the matched vhost. H3 remains a shared UDP listener capability; disabling it on a
vhost removes Alt-Svc and rejects H3 for that host.
Each vhost can optionally set `tls_policy` with `tls12`, `tls13` and a list of rustls
cipher-suite names. Omitting it inherits `tls.policy` from Settings. The override is
selected from ClientHello/SNI before the TCP TLS handshake, so a vhost can for example
run TLS 1.2 only while another frontend continues to use the global TLS 1.2/1.3 policy.
At least one protocol version and a compatible effective cipher suite are required.
Because HTTP/3 shares QUIC TLS on the UDP listener and requires TLS 1.3, a vhost with
HTTP/3 enabled must use the same TLS policy as the global setting. Disable HTTP/3 on
that vhost before using a differing per-vhost TLS policy.
Routing checks Host/HTTP authority, actual accepted local IP, local port and actual
TLS state. Neither `Forwarded` nor `X-Forwarded-*` can create frontend access. A
request for a private hostname on a different local IP is not routed to that host.
Unknown or unmatched hosts return 404.
Bindings must be local addresses assigned to the OS. The process does not add IPs,
manage routes, configure firewall rules, acquire virtual IPs or run Keepalived/VRRP.
A missing IP, occupied port or permission error aborts startup without wildcard
fallback. Low ports may need deployment capabilities; see `../deploy/README.md`.
Identical or same-family wildcard sockets are consolidated only when the named
listener transport capabilities agree. Vhost routing restrictions are not
consolidated: a wildcard TCP listener can accept on a public IP while still rejecting
a private vhost there. Per-vhost H2 is narrowed by SNI and therefore does not require
a second TCP socket. IPv4/IPv6 wildcard overlap is rejected; use explicit addresses.
Native IPv4 binds are required instead of mapped IPv6 literals. The administration
port is reserved even on a different IP.
IP isolation is not client authentication. A firewall, router, NAT or preceding
proxy can forward external clients to the private destination IP; RustProxy sees
that local destination and the connecting peer. Use network ACLs, firewall rules
and application authentication too. Trusted forwarding headers have a separate explicit CIDR policy (see
INTEGRATIONS_040.md); PROXY protocol is not implemented. Interface changes require config
reload/restart to refresh the upstream loop-guard's local-address enumeration.
The same literal hostname pattern may occur only where its listener bindings remain
unambiguous. Exact and wildcard patterns are distinct ownership keys, so an exact host
and its parent wildcard may coexist. Disabled vhosts reserve their patterns and are
still validated, but create no pool or additional active listener.
## Paths, policies and migration
`routes` contains per-vhost paths: `id`, match fields, `enabled`, `source`, nullable `cache`,
optional `acl_profile_id`, inline `acl`, `country_access`, `method_policy`, `headers` and `backend_id`. `source` selects Reverse Proxy or a reusable Web Service;
`backend_id` is valid only for Reverse Proxy. An empty array leaves the main vhost source as the fallback. Longest
segment-boundary prefix wins. Origins receive the original path/query; prefixes
are not stripped. IDs must use ASCII letters, digits, hyphens and underscores.
Host method policy, reusable ACL profiles, vhost ACL/country policy and route method/profile/inline
ACL/country policies are cumulative before pool selection/DNS/cache. A vhost and each route can
attach a reusable `acl_profile_id`; profiles may contain an IP ACL plus a country block/allow-only
rule. Inline controls remain independent and cannot be weakened by a profile. Method-policy denial
returns 405; allow-list mode also emits `Allow`. Their client identity is the
validated effective peer address, not an untrusted forwarding header.
There is no legacy routing UI/runtime or automatic importer in 0.8.0. Development
databases using the old top-level routes or inline frontend listeners must be reset
or migrated explicitly before startup. An omitted path backend ID selects from the
balanced pool; a specified ID must belong to the same vhost and does not fail over to a
different application's member. Imported health checks start OFF. Ambiguous names,
unreferenced old backends and invalid/oversized documents stop migration before
saving. See MIGRATION.md before updating an existing installation.
## Backend members and connection reuse
Each member has `id`, HTTP(S) origin `url`, `weight` (1..256), `enabled` (true),
`backup` (false), `drain` (false), `allow_private` (false),
`accept_local_listener_risk` (false), `preserve_host` (true), `protocol` (`http1`),
`address_allowlist`, optional `tls_server_name`, `tls_verify` (false) and optional
`tls_ca_pem`. `tls_server_name` is an HTTPS-only DNS hostname
used for SNI and, while verification is enabled, certificate hostname verification;
DNS/TCP still targets the configured `url`. `tls_verify` defaults to false. HTTPS connections remain encrypted, but backend certificate authentication is skipped until an administrator explicitly sets `tls_verify: true`; this includes compatibility with legacy certificate encodings that modern webpki validation rejects. Address/DNS policy remains enforced; local-listener self-targeting has only the explicit risk exception described below. `tls_ca_pem` is ignored while verification is disabled.
Backend IDs are unique inside a vhost. URLs cannot contain credentials, paths,
queries or fragments.
Every DNS answer and every configured literal must pass the same upstream policy
for health probes and forwarding. Active, pending and prior-snapshot frontend
addresses and local administration are guarded against self-proxying by default.
For an intentional local target such as `http://127.0.0.1:9090`, enable both
`allow_private: true` and `accept_local_listener_risk: true`. The latter is a narrow,
explicit acceptance of loop/control-plane exposure risk; metadata/link-local and other
special-address blocks are not bypassed. In the dashboard this risk checkbox is hidden
unless the entered backend URL directly targets an administration listener reported by
persisted configuration or live status. Startup-owned/locked administration sockets are
therefore handled too. On the same administration port, loopback spellings such as
`localhost`, `127.0.0.1` (and the rest of `127.0.0.0/8`) and `::1` are treated as
equivalent for showing the explicit risk control, so it is not presented as a normal
backend option.
A vhost can independently enable two proxy-owned response diagnostics:
- `backend_header: true` -> `X-RustProxy-Backend: <backend-id>`,
- `backend_timing_header: true` -> `X-RustProxy-Backend-Time: <milliseconds>ms`.
The timing value covers the origin request through receipt of the backend response
headers; it is not full response-body duration. Both proxy-owned names are protected
from custom header rules and any upstream values are removed before RustProxy optionally
writes its own value. The former `X-Rust-Proxy-Backend-Time` spelling is also stripped
as a legacy lookalike but is never emitted. Responses served without contacting an
origin do not expose a selected backend or stale origin timing.
`timing_headers` remains the separate opt-in `Server-Timing` diagnostic.
`keepalive` controls **connection pooling**, not an HA service:
| Field | Default | Accepted range |
|---|---|---|
| `enabled` | true | Boolean |
| `idle_timeout_seconds` | 30 | 1..3600 |
| `max_idle_per_host` | 4 | 1..64 |
| `tcp_keepalive_seconds` | 30 | 1..3600 |
| `pool_recycle_requests` | 25000 | 0 (off) or 1000..10000000 |
| `pool_recycle_seconds` | 3600 | 0 (off) or 60..604800 |
| `http1_max_buffer_bytes` | 131072 | 8192..1048576 |
These configure HTTP idle reuse and TCP keepalive; HTTP/2 also uses pings for active
connections, with a 10-second ping timeout and no pinging solely to keep idle
connections alive. `pool_recycle_requests` and `pool_recycle_seconds` bound the lifetime
of each backend Hyper client generation. Recycling swaps only that backend client/pool;
it does not reload the vhost, purge cache or intentionally abort active exchanges. The
request counter is per backend client generation, not per TCP connection. Setting either
recycle value to `0` disables that trigger independently. `http1_max_buffer_bytes` bounds
Hyper's adaptive upstream HTTP/1 read buffer per connection; it does not apply to HTTP/2.
Compatible backend clients are retained across unrelated runtime snapshot rebuilds, so
generation age, request count and rotation totals remain continuous. A changed backend
origin, address policy, keepalive settings or connect timeout creates a fresh client
generation state instead of reusing an incompatible pool.
Disabling reuse sets the idle pool limit to zero and disables keepalive probes. The idle
limit is not a separate per-backend active-request cap; global connection/exchange bounds
still apply. HTTP health probes use separate no-reuse HTTP/1.1 connections.
Backend upstream `protocol` has four modes:
| UI mode | Serialized value | Backend behavior |
|---|---|---|
| **HTTP/1.1** | `http1` | HTTP/1.1 only over HTTPS or HTTP; default for new backends |
| **Auto** | `auto` | HTTPS: prefer H2 and negotiate H1 fallback with ALPN before sending a request; HTTP: H1 only, without H2C probing |
| **HTTP/2 compatible** | `http2` | **Unchanged 0.32.2 behavior**: HTTPS offers H2/H1 via ALPN; plaintext HTTP uses H2C prior knowledge (no H1 downgrade on HTTP) |
| **H2 only** | `h2` | HTTPS: require H2 via ALPN; reject H1-only peers. HTTP: prior-knowledge H2C, no fallback |
**0.32.2 -> 0.32.3 compatibility:** saved `"protocol": "http2"` retains its existing
HTTPS ALPN fallback and cleartext H2C behavior. No conversion, migration or
administrator action is needed to preserve the old selection. Choose the new
`"protocol": "h2"` only when H2 must be enforced. The editor describes the
transport for the selected backend URL; a descriptive notice next to compatible
HTTPS `http2` is informational, not a migration warning.
HTTPS Auto negotiation selects the wire protocol **before sending the request**.
RustProxy does not replay an ambiguous failed POST/PATCH/DELETE through H1, and
does not interpret backend 4xx/5xx responses as a reason to switch protocols.
Existing pool reuse, keep-alive, client generation rotation and the independent
HTTP/1.1 WebSocket Upgrade / H2 Extended CONNECT paths are retained.
HTTP health probes continue to use HTTP/1.1 independently of ordinary requests;
H2C-only peers can use TCP probes. A health probe that needs a virtual-host
name must configure `health_check.host` separately; `preserve_host` affects
visitor requests, not independent health probes.
**Since 0.32.4:** all four upstream modes allow `preserve_host: true`.
With HTTP/2, `:authority` follows the selected HTTP Host instead of the backend
IP; with HTTP/1.1 it remains the regular Host. TCP/DNS always target the
configured backend URL and still enforce the address and local-listener
policies; `tls_server_name` independently controls HTTPS SNI and certificate
identity, not the visitor Host. HTTPS backend certificate verification defaults
to OFF when `tls_verify` is omitted. Turn on verification and configure
`tls_server_name` / `tls_ca_pem` for a private DNS certificate; a valid IP-SAN
certificate can instead authenticate an IP backend without a DNS name.
The form's HTTP/1.1 troubleshooting preset is optional; it is not required
for Auto/H2, private origins or Host preservation.
## Scheduling
| `algorithm` | Decision |
| Setting | Use |
|---|---|
| `round_robin` | Smooth weighted round-robin |
| `least_connections` (default) | Lowest `(active exchanges + 1) / effective weight` |
| `least_traffic` | Lowest recent EWMA payload rate plus active-exchange allowance, divided by effective weight |
| `adaptive` | Combines active exchanges, EWMA time-to-headers and payload rate, divided by effective weight |
| `ip_hash` | Weighted rendezvous hash of vhost/member and canonical actual peer IP |
| `http1` | Standard HTTP/1.1 upstream. |
| `auto` | HTTPS negotiates HTTP/2 or HTTP/1.1; plaintext uses HTTP/1.1. |
| `http2` (legacy) | Compatible HTTPS ALPN or plaintext H2C, kept for existing configurations. |
| `h2` | Strict HTTP/2/H2C; requires origin support. |
An active exchange spans upload, response body and upgraded tunnel, not just the
wait for response headers. Slow-start gradually increases effective weight after
initial success/recovery (`slow_start_seconds`: 10, allowed 0..300; 0 disables it).
IP hashing uses configured weights instead of slow-start to keep assignments stable.
These are explicit heuristics, not workload predictions or benchmark-backed claims.
No remote host CPU measurement is used by the scheduler.
A backend's `preserve_host` option can retain the frontend authority when appropriate. HTTPS encryption alone does not guarantee verified backend identity: `tls_verify` is **false by default**. Enable verification and configure trusted CA/name for sensitive connections. Private, loopback and RustProxy-local listener targets require explicit security exceptions; do not point a public route to the admin listener. Read [Security](SECURITY.md).
Only enabled, healthy, non-draining primary members receive new assignments. If
none exists, eligible backups are used. All unavailable means 503 with Retry-After;
there is no fallback into another vhost. Balanced routes may retry a body-free `GET`,
`HEAD` or `OPTIONS` on another healthy member according to `load_balancing.retry`:
Connection reuse is bounded by configured idle-pool/recycle limits. 0.34.0 changes the default H1 upstream idle pool for newly configured Vhosts; previously saved explicit values remain. Configure timeouts to suit expected request and upload behavior; a slow origin may need more than the default response-header timeout.
| Retry field | Default | Accepted range / meaning |
|---|---|---|
| `max_retries` | 1 | 0..3; zero disables failover retry |
| `retry_connect_errors` | true | Retry connection establishment failures |
| `retry_timeouts` | true | Retry the response-header timeout |
| `retry_statuses` | `[502,503,504]` | Up to 8 HTTP 5xx statuses |
## Load balancing and health
A backend already attempted in the same exchange is excluded from later attempts.
Path-pinned routes and WebSocket handshakes are not retried. POST/PUT/PATCH and any
request with a body are never automatically replayed. Failed attempts still feed the
normal passive-health policy and retry counters/logs; successful failover updates sticky
affinity to the backend that actually answered.
Backend pools support scheduling/weighting, standby members, draining and optional session affinity. Check the **Backends** status view for member health, recently observed HTTP response code, upstream protocol and resource usage. A reported status from normal traffic is not necessarily the result of a health probe.
## Health checks and recovery
- Health monitoring may use TCP or HTTP checks with a path, Host, interval and thresholds. Avoid write endpoints; use a lightweight `/health` or equivalent.
- Each member can override the pool health check or disable active/passive monitoring with `health_check_enabled=false`. Disabling checks treats the member as available, not proven healthy.
- New monitored pools can temporarily have no eligible healthy member until rise checks succeed. A failed/incorrect check can produce `503` even if the process is reachable.
- `backup` members act as fallbacks; `drain` prevents new normal selection while letting existing work complete within normal lifetimes. Explicitly pinned backends need careful policy review.
- Concurrency and queue limits protect overloaded origins. Retries are restricted; do not assume non-idempotent requests are automatically replayed.
| Field | Default | Accepted range / meaning |
|---|---|---|
| `mode` | `tcp` | `off`, `tcp`, `http` |
| `path` | `/` | Leading slash, up to 1024 bytes; no authority/fragment |
| `host` | null | Optional validated HTTP Host override; otherwise the first exact vhost DNS hostname is used |
| `interval_ms` | 5000 | 250..300000 |
| `timeout_ms` | 2000 | 50..60000; no greater than interval |
| `slow_response_ms` | 1500 | 0 disables; otherwise no greater than timeout |
| `fall` / `rise` | 3 / 2 | 1..20 consecutive failed/successful checks |
| `expected_status_min` / `max` | 200 / 399 | Inclusive, ordered HTTP status range within 100..599 |
## Hostnames, redirects and errors
TCP checks verify that an approved origin address accepts a connection. HTTP checks
perform GET on the configured path, without redirects, cookies or authorization.
They use HTTP/1.1, including verified TLS when appropriate, and measure time to
headers, not the full body. When `health_check.host` is empty, RustProxy automatically uses the first exact DNS hostname configured on the vhost so an IP-address backend can still route the probe through its name-based virtual host. If no exact DNS hostname exists, the existing backend-host fallback remains. Set `health_check.host` only when the probe must use a different Host value. For an h2c-only service, use TCP checks. Slow probes
count as active failures. Active TCP/HTTP health is independent from `passive_health`.
- `force_ssl` redirects matching plaintext traffic to HTTPS using `308`, preserving path/query; configure both HTTP and HTTPS frontends.
- WWW controls can direct `www.example.com` to `example.com` or the opposite as supported by the editor; ensure DNS/certificate coverage for both.
- Use the dedicated **Redirects** workspace for redirect-only domains. Use **404 hosts** to claim a hostname and return a local not-found page without forwarding.
- **Custom locations** may return status/body or redirect for selected paths. **Internal locations** can be used as fallbacks for configured `403/404/500/502/503/504` application responses.
- The **Pages** section can replace supported origin error bodies without contacting a second backend. This keeps the response status and disables caching of the replaced error body.
`passive_health.enabled` defaults to `false`. With that default, normal user traffic
never changes the shared backend healthy/unhealthy state: HTTP 5xx, connection errors,
timeouts, response-body I/O failures and slow responses are still counted/logged but
cannot quarantine the member. When explicitly enabled, the administrator can choose
`count_connect_errors`, `count_timeouts`, `count_io_errors`, `count_http_5xx` and
`count_slow_responses`, with optional passive `fall`/`rise` overrides. Missing overrides
inherit the active health thresholds. Active and passive streaks are separate, so a
successful user request cannot erase an active-probe failure streak and an active probe
cannot erase a passive failure streak. `mode: off` disables only active probes; an
explicitly enabled passive policy may still quarantine a backend.
## Safe operating practices
One cancellation-aware supervisor schedules at most 16 probes, one per member,
with a rotating window for fairness. The tick is 250 ms; configured intervals are
best-effort under resource pressure, not precise service-level guarantees. DNS
workers have their own bounded slots. Probe bodies are not collected or cached.
## Sticky sessions and drain
`sticky.mode` is `off`, `cookie` or `ip_hash`. IP mode overrides the normal algorithm
using the actual peer IP. Users behind one NAT/proxy share that identity.
Cookie mode uses a random 32-character opaque member token, not a backend URL.
The default name is `RP_AFFINITY_<vhost-id>`; custom names accept 1..96 ASCII letters,
digits, underscores or hyphens and cannot use the admin session name. TTL defaults
to 3600 seconds (60..604800), with Path=/, HttpOnly, SameSite=Lax and Secure on TLS.
`__Host-`/`__Secure-` names require every frontend to use TLS. Cookies are host-only,
so aliases have independent browser cookies. They do not authenticate users.
Cookie-affine requests always bypass shared cache. The dedicated routing cookie
is stripped before forwarding; origins cannot override it via Set-Cookie. Unknown,
duplicate or unhealthy-target tokens are ignored/reassigned. Existing valid cookie
sessions may continue on a healthy draining/backup member; new sessions do not
choose a draining member. Disabling a member prevents all new assignments,
including existing cookie sessions, while already admitted exchanges finish.
Affinity tokens, health and traffic observations survive compatible hot edits.
A changed origin/health policy or re-enabled member is checked afresh. Process
restart resets tokens/counters; clients can be reassigned. Session data itself is
never replicated: applications needing durable sessions require a shared store.
## Response protection
`security_headers` supports `hsts` (`enabled`, `max_age_seconds`,
`include_subdomains`, `preload`), `nosniff`, `frame_options` (`preserve`, `deny`,
`sameorigin`), `referrer_policy`, `content_security_policy` and `permissions_policy`.
Header values are validated against splitting and bounded to 8192 bytes.
HSTS is configured only for TLS responses. For a matched vhost, plaintext responses
remove Strict-Transport-Security even if an origin/custom rule adds it. On TLS,
disabled HSTS means no forced replacement of an origin value; use a custom remove
rule to suppress that value. Enabled HSTS overrides generic rules. `max_age_seconds`
accepts 0..63072000 (default31536000); setting zero is available for explicit expiry.
Preload is opt-in and requires enabled HSTS, includeSubDomains and at least one year.
This only emits the directive; it does not register a hostname in a preload list.
Start with a short lifetime and verify every affected hostname before increasing it.
Request rules run global -> vhost -> path, then protected identity fields are
regenerated. Response rules run global -> vhost -> path, followed by dedicated
security/Server policy and optional compression. Security headers cover cache hits
and matched-host synthetic errors/ACL denials too. Protected transport fields
remain uneditable. `false`, `preserve` or null security settings otherwise leave
origin values unchanged. CSP and Permissions-Policy must suit the application;
RustProxy validates header encoding, not the semantic correctness of those policies.
The **Compression** tab inherits the enclosing/global policy by default. It can enable/disable dynamic compression and independently inherit/enable/disable zstd, Brotli, gzip and deflate. Selecting **Disabled** hides and disables the codec selectors while preserving their saved values for a later Inherit/Enabled selection. Global **Settings -> Response** codec switches are the hard availability ceiling, so a vhost cannot activate a codec disabled globally; `min_bytes` also remains global. Client `q` values are honored; equal-quality codecs use `zstd -> br -> gzip -> deflate`, and an existing `Content-Encoding` is never recompressed.
## Live state, cache and limits
The dashboard defaults a newly created vhost to `cache.enabled: false`. When the
effective cache state is disabled, the editor hides the remaining per-vhost cache
controls; enabling cache (or explicitly choosing inheritance from an enabled global
cache) reveals them. This is a UI default for new drafts, not a migration that rewrites
existing vhost cache settings.
`GET /api/backends` requires an admin session and returns `pools`,
`active_frontends`, `desired_frontends`. `/api/status` and SSE contain `backend_pools`
and complete active/desired endpoint plans. The dashboard renders per-member state,
active exchanges, request/error totals, EWMA header latency and payload traffic.
Prometheus adds bounded vhost/member labels; access logs include `backend` as
`vhost/member` for actual pool forwarding (null when no upstream exchange is made).
Bytes are observed payload, not NIC throughput; response counters include the
client-delivered encoded representation. Header latency includes connection/setup and any request upload; it is not full
exchange time. Active probes use body-free requests for an independent measurement.
Health mode off is labeled as checks off, not proof of a probe passing. Statistics
are observations, not persisted configuration. They reset on process restart.
Cookie affinity bypasses cache entirely. A pool with no eligible member does not invent
an unselected cached fallback. Config changes invalidate incompatible cache state.
RFC 5861/request `max-stale` behavior is owned by the shared cache and does not alter
backend member selection; request coalescing remains separate from stale-while-revalidate
deduplication.
Limits: 64 vhosts; hostname lists are not capped by an application-level item count;
listener entries remain capped at 16 per host, with 32 members and 64 paths per host;
128 backend definitions and 256 paths across all vhosts;
128 additional declared frontend entries plus two globals; 256 KiB serialized
configuration. Existing request/connection/DNS/queue bounds remain in force.
Every successful config publication rebuilds upstream connection pools. Compatible
member observations/tokens and in-flight exchanges survive, but idle sockets are
not retained across publication. Listener/address/ALPN changes are saved immediately
and require restart; routing changes apply to existing bound sockets immediately.
When moving a host away from an old IP, it can become unavailable until restart
rather than being served on an address no longer permitted by desired policy.
## Per-vhost operations in 0.3.0
The compact list opens a tabbed editor rather than displaying a full form beside
the list. Health summaries update on the list and editor without replacing unsaved
form controls. Header rules have a visual request/response editor; Advanced JSON
remains available and stays synchronized with those rows.
The SSL tab selects an existing certificate ID or uploads a chain/private-key
pair into private staging. Only Save activates the selection. A shared global SNI
resolver cannot serve different certificates for the same DNS name by destination
IP, so conflicting selections are rejected. Browser upload is local-admin only;
there is no ACME, encrypted-key password prompt, certificate download or automated
cleanup. Details and limits: MANAGEMENT.md.
Cache overrides inherit field by field and use one shared memory budget. The list
and Cache tab offer selective purge. An explicitly disabled global cache can still be
overridden by an explicitly enabled vhost. Path exclusions and sticky-cookie safeguards
remain in force. See CACHE.md.
The **Transform** tab is a separate vhost opt-in. `transform.enabled` defaults to false
for every vhost even when the global transform engine is enabled. JavaScript, CSS, HTML,
SVG and JSON can inherit or override global format switches, and the extension list may
be replaced per vhost. Eligible origin responses are transformed before shared-cache
storage and before downstream compression. Origin `Cache-Control: no-transform` always
wins and cannot be overridden from the vhost.
Pages offers a static/maintenance response, a fallback for unassigned paths, and
error overrides. The static/maintenance response can set an explicit HTTP status independently of its HTML body, so an operator can serve a maintenance document as `503 Service Unavailable` instead of returning `200`. Vhosts still retain their configured backend pool; static mode
avoids forwarding but is not a backend-less static-file server. Host/path ACLs run
first. Explicit error-status overrides change presentation, not authorization.
Empty/inherit means use the global error policy; to disable one inherited error
page specifically, configure an explicit replacement rather than expecting null
to override inheritance. See MANAGEMENT.md for exact precedence.
### Backend Host compatibility
New backends use the backend URL host for the upstream `Host` header by default, matching nginx `proxy_pass` behavior. Enable **Preserve client Host** only when the application intentionally routes on the public hostname. This is especially important for embedded/lighttpd-style administration panels that may close or reject requests carrying an unknown Host. Existing explicit settings are preserved during upgrades.
When an upstream request fails before an HTTP response is received, access logs include a bounded `upstream_error` field in addition to `upstream_status`.
Multiple explicit hostnames can belong to a single vhost: `suba.local` and `subb.local` share the same routing, backends and configuration. Only the www/non-www companion of the selected canonical hostname redirects; other aliases continue serving traffic directly.
Keep Vhost IDs stable for monitoring/history unless renaming is intentional. Renaming may reset the runtime identity and invalidate incompatible cache objects. Confirm the deployed scheme and Host, HTTPS trust, method/access restrictions, health status and logs after saving. For per-path behavior see [Custom locations](CUSTOM_LOCATIONS.md); for policies see [Access](ACCESS.md); for caching see [Cache](CACHE.md).
-136
View File
@@ -1,136 +0,0 @@
# Vhost access and visual locations
## 0.8.0 edge traffic controls
The Security/Traffic policy section now includes per-client-IP request limits, burst,
window length, country-specific limit overrides, country blocking or allow-only mode,
per-vhost HTTP/3 enable/disable, optional `Server-Timing`, and a Cloudflare real-client-IP
switch. Country rules use the install-time GeoIP country database. Allow-only mode
fails closed when a country cannot be resolved; block mode only blocks resolved matches.
Exceeded limits return 429 plus `Retry-After`; country policy denial returns 403. Both
use built-in HTML pages unless a configured custom error page replaces them.
Cloudflare mode trusts `CF-Connecting-IP` only when the **direct peer** is inside the
compiled Cloudflare edge CIDRs. An arbitrary client sending that header directly does
not become trusted. The verified address becomes the effective client IP for ACLs,
rate limiting and forwarded metadata. On the same verified Cloudflare hop, a single
two-letter `CF-IPCountry` value is accepted as the country source for country policy,
logging and Traffic analytics. Missing, duplicate, invalid and Cloudflare `XX` values
fall back to the local GeoIP lookup of that verified effective client IP.
Vhost listener editing is now selection-only. Listener addresses/IPs and transport
settings live under Settings > Listeners; a vhost selects one or more named endpoints.
Existing inline/legacy bindings are promoted to named endpoints when the vhost is saved.
New backend rows use the HTTPS-by-IP debugging defaults: include in the default LB pool,
allow private origin, HTTP/1.1, preserve client Host and backend TLS verification OFF.
The panel shows the risk and asks before saving an HTTPS-by-IP backend that does not
match the complete preset. Existing backend values are not rewritten automatically.
Health mode `off` disables active probes only. Passive Backend Health is a separate
opt-in policy and defaults to OFF. With the default policy, normal request failures are
still counted but never mark a backend healthy/unhealthy; an administrator may explicitly
enable passive health even while active probe mode remains `off`.
## WebSockets
`vhosts[].websockets` controls HTTP/1.1 Upgrade forwarding for that host. Existing
configuration omitting it retains the previous enabled behavior. New hosts created
in the panel default to disabled. Disabled valid upgrades return 403 without opening
an upstream connection; normal HTTP continues. Basic Auth/Tinyauth and ACLs still
apply to an allowed handshake. This does not implement WebSockets over HTTP/3,
WebTransport or generalized CONNECT, and does not terminate already open tunnels
when toggled; it governs subsequent handshakes. After a successful `101`, normal TCP
EOF/reset/abort/broken-pipe closure is recorded as tunnel closure rather than an invalid
proxy request; handshake/protocol errors remain errors.
## Basic Auth and ACL
The Authentication tab selects None, Tinyauth or HTTP Basic. Basic and Tinyauth
cannot both be enabled. The Basic editor manages users, realm, TLS requirement
and whether verified `Authorization: Basic ...` is passed to the backend.
Only bounded Argon2id password hashes are persisted. New/changed passwords are
sent to `POST /api/basic-auth/hash` through the protected administrator API; no
password is stored in browser storage, desired configuration, audit or access logs.
An empty password while editing keeps the saved hash. New passwords use the existing
12-128 byte administrator password policy; Basic credentials cannot contain control
characters. Usernames are case-sensitive, up to 128 UTF-8 bytes, without colons or
control characters. This is not an htpasswd/APR1/bcrypt importer.
TLS is required by default and is checked from the accepted connection, not
`X-Forwarded-Proto`. Plaintext HTTP is allowed only by an explicit host option; avoid
that outside a deliberately protected TLS-termination network. HTTP Basic encodes,
not encrypts, the username/password. Keep the frontend and any credential-carrying
upstream hop protected. Administrator access defaults to loopback; direct remote access is supported when Administration HTTPS is enabled and the bind/hostname policy is configured.
Successful credentials are removed before forwarding by default. Selecting forward
passes the original **verified** Basic Authorization header, not a password from
configuration or an unverified client value. The final enforcement step runs after
custom request-header rules, which cannot reintroduce removed credentials.
`Remote-User`/`Remote-Email` are stripped in Basic mode; the separate Tinyauth mode
continues to use verified identity headers from that service.
Global host policies, reusable ACL profiles, vhost ACLs and location ACLs remain
cumulative: access requires all applicable policy scopes **and** successful authentication.
An ACL profile can be defined once under **ACL profiles**, combine default allow/deny
IPv4/IPv6 CIDR rules with country block/allow-only policy, then be attached to several
vhosts. Per-vhost rules remain available and are evaluated in addition to the profile.
Within one policy scope, explicit IP deny rules and blocked countries take precedence.
When country mode is **Allow only**, a listed country or an explicit IP allow rule may
satisfy that scope; therefore `default=deny` can express e.g. private CIDRs **or** selected
countries. An unresolved country still fails closed unless the client matches an explicit
IP allow. This does not let a permitted IP skip another profile/vhost/location policy or
a password. The graphical inline ACL editor still supports default allow/deny and
IPv4/IPv6 CIDR rules.
Trusted-proxy settings control which peer can contribute the effective client IP;
untrusted forwarding headers do not grant network access.
Authentication runs before static pages, cache, origin connections and upgrades.
401 responses include a Basic challenge and `private, no-store`; custom pages or
header rules cannot convert these failures into successful access. Protected vhosts
bypass shared response caching. A process-wide limit allows two Argon2 workers;
failed-password accounting is bounded to 4096 IPs, with ten failures per minute
blocking that address for the rest of the window. Busy hashing returns 503; a
blocked address returns 429 with Retry-After. This is abuse resistance, not MFA or
a replacement for perimeter rate limiting, especially for many clients behind NAT.
## HTTP method policy
Each vhost can leave method filtering **Off**, define an **Allow only** list, or define a **Block** list. Methods are validated as HTTP method tokens and normalized to uppercase. A denied request returns `405 Method Not Allowed` before authentication, cache, DNS or origin connection; allow-list mode also returns an `Allow` header containing the configured methods.
A Custom location has the same independent method policy. Vhost and location policies are cumulative: a location cannot re-enable a method rejected by its vhost. This makes patterns such as `GET, HEAD, POST` allow-only at host level and a stricter `GET, HEAD` allow-only policy on a read-only location explicit and fail-closed.
## Custom locations
The **Custom locations** tab supports ordinary prefix, priority prefix (`^~`), exact (`=`),
case-sensitive regex (`~`) and case-insensitive regex (`~*`) matching. Exact matches win first;
a selected priority prefix skips regex evaluation; regexes are tested in declaration order; the
longest ordinary prefix is the fallback. Exact paths preserve a meaningful trailing slash.
A proxy location can use the normal pool, pin one backend, inherit/override cache behavior and
apply an additional inline ACL. It can also select a reusable ACL profile, apply its own country
allow/block policy and define an HTTP method allow/block policy. These controls are cumulative with
the vhost policies and are enforced before a backend connection is opened. Path handling can preserve the public URL, strip a prefix, replace a
prefix or perform a capture-based regex rewrite. Query parameters are preserved. Repeated literal
slashes are normalized only for routing/ACL decisions; without an explicit rewrite the backend
receives the original target, including `//`. Encoded separators, percent double-decoding and dot
segments remain rejected.
A location can alternatively **Return custom response** with a 200–599 status, Content-Type and
body without opening a backend connection. Route/vhost ACL and authentication still apply. A 3xx
return plus a visual `Location` response-header rule is the supported simple redirect pattern.
Request/response headers have a graphical `set`/`append`/`remove` builder, inline documentation
and a synchronized **Advanced JSON** view. Protected framing, forwarding-identity, request-ID and
WebSocket handshake headers cannot be overridden. The full routes JSON remains available as an
automation/escape hatch, but invalid/unknown fields are rejected rather than silently ignored.
RustProxy intentionally implements an explicit safe subset, not arbitrary Nginx configuration:
location-level `try_files`, filesystem `root`/`alias`, embedded scripting, arbitrary `if` and
module directives are not accepted. See [Custom locations](CUSTOM_LOCATIONS.md) for examples and
the exact supported subset.
Reference: HTTP Basic security considerations, RFC 7617:
https://www.rfc-editor.org/rfc/rfc7617.html#section-4
+39 -421
View File
@@ -1,441 +1,59 @@
# Web Server - RustProxy 0.25
# Web Server — static sites, downloads and routing
RustProxy 0.25 provides a Web Server source inside the existing vhost/location pipeline. A Web Service is a reusable configuration resource; vhosts and locations select it by ID and do not duplicate filesystem/PHP settings.
The built-in Web Server serves local files and, optionally, passes PHP scripts to an **external PHP-FPM service**. A reusable **Web Service** is referenced by a Vhost or by a Custom location. Its files and PHP settings are not duplicated in the Vhost.
## Architecture
## Create a Web Service
```text
listener -> vhost -> location -> shared ACL/auth/policy
-> source
|- Reverse Proxy
`- Web Server -> Web Service
|- static file
`- FastCGI -> PHP-FPM
-> shared response/error/header/compression pipeline
```
1. Open **Web Server → New Web Service**. Choose an ID/name and an absolute document root readable by the RustProxy service account.
2. Set index files (default `index.html`, `index.htm`), whether directory listing is allowed (off by default), MIME handling and download extensions.
3. Add `try_files`, rewrites, response headers and cache rules only if your application requires them.
4. Enable PHP only if you have a reachable, protected PHP-FPM endpoint; otherwise static files do not need any PHP runtime.
5. Save, create or edit a **Vhost**, select **Web Server** as source and choose the service ID.
6. Test index files, static assets, missing files, direct directory access, downloads and headers.
A Web Service can be reused by several vhosts and one vhost can mix Web Server and Reverse Proxy locations. Web Services live in the normal revisioned RustProxy configuration (`web_services`); there is no separate Web Server configuration database.
A Web Service can be shared across several Vhosts. A Vhost may also use ordinary Reverse Proxy as its default and a Web Service for `/assets` or another custom location. Web Server and HTTP reverse proxy use the normal frontend TLS, access, logging and eligible cache pipeline.
### Source-module layout
## File serving options
The Web Server implementation is split under `src/web_server/` by responsibility. `mod.rs` is only the public facade; `config.rs` owns persisted/compiled service configuration, `filesystem.rs` owns document-root confinement and directory reads, `security.rs` owns Web Service hardening, `response.rs` owns static HTTP response/validator/range/listing behavior, `routing.rs` owns rewrites/`try_files`/precompressed selection, `fastcgi.rs` owns PHP-FPM/FastCGI, `serve.rs` owns request dispatch, and `htaccess.rs` owns the compatibility layer. Regression tests live in `tests.rs`. This keeps filesystem/security and FastCGI changes reviewable without growing another single Web Server source file.
| Feature | Behavior / caution |
|---|---|
| **Document root** | Files are served only through the configured root; keep private keys, source secrets and writable control files outside it. |
| **Indexes / listing** | Configurable index priority; directory listing is disabled by default. |
| **`try_files`** | Try requested paths, then a fallback such as `/index.html` or the PHP front controller. Useful for SPAs and frameworks. |
| **Rewrites** | Bounded regex internal rewrites or supported redirect statuses; evaluated in the Web Service routing pipeline. |
| **MIME / downloads** | Built-in type mapping with extensions and explicit attachment options. |
| **Headers / cache** | Service-level headers and optional static cache-control rules; later Vhost/location response rules may override. |
| **Compression** | Dynamic gzip/Brotli/Zstandard/deflate policy and eligible precompressed `.br`/`.gz` sidecars. |
| **File validators / ranges** | Conditional `ETag`/date checks, HEAD and supported byte ranges for eligible files. |
| **Symlinks** | `deny` by default; `inside_root` is an explicit less restrictive policy. |
| **Hardening** | Optional checks for common sensitive/attack paths and removal of selected server-identifying headers; not a full WAF. |
## Live Web Service diagnostics (0.33.0)
### Static delivery (`sendfile`) — 0.34.0
Each configured Web Service card contains an **always-visible Live metrics**
section (since 0.33.4), styled like the Vhost live metrics: six responsive
metric tiles and a full-width per-service response-rate history chart. It shows
completed requests, responses/s, HTTP 5xx count, mean elapsed time until
response headers, PHP request permits in use/limit and delivered response body
bytes. Static/PHP/other counts appear below the chart. The section needs no
expansion and reuses the existing authenticated live status stream.
Unconfigured/non-PHP services do not claim PHP worker capacity.
In **Web Server → Edit service → Delivery**, `sendfile` supports `off` (default) and `auto`. On **Linux**, eligible full static **GET** responses of at least **64 KiB** over **unencrypted HTTP/1.1** may use a kernel file-transfer path. TLS, HTTP/2, HTTP/3, ranged or transformed content, compressed responses and active object-cache responses keep the normal streaming path. This transport choice should not invalidate otherwise compatible cached objects. It is an **unverified source candidate** in the provided archive: test it on the actual server before use and do not assume a measured speedup.
**Overview** provides 12 additional opt-in Web Server blocks under
**Settings -> Appearance -> Dashboard layout -> Overview cards**. All 12 are
hidden by default, including when opening an older saved dashboard layout.
They follow normal widget reordering, theme, card sizing and SSE updates.
Sample history length is selectable as 60/120/300/600 points (temporary or
browser-persistent), independently of Cache and Transform.
## Rewrites and application presets
The authenticated `/api/status` and compact `/api/status?view=live` payloads
include `web_server_metrics`, keyed only by *configured Web Service ID*:
```json
{
"web_server_metrics": {
"site": {
"web": {
"requests": 120, "status_2xx": 100, "status_3xx": 5,
"status_4xx": 12, "status_5xx": 3,
"static_requests": 90, "php_requests": 25, "other_requests": 5,
"response_headers_us": 1500000, "response_bytes": 98000,
"active_headers": 0,
"methods": [110, 8, 0, 0, 2],
"selected_status": [95, 1, 3, 4, 8, 0, 2, 1],
"failures": [0, 0, 0, 0, 0, 0, 0, 0, 0],
"compression": [12, 1, 0, 0]
},
"php": {
"metrics": {
"requests": 25, "response_headers_us": 750000,
"response_bytes": 24000,
"failures": [0, 0, 0, 0, 0, 0, 0, 0, 0]
},
"capacity_limit": 32, "capacity_available": 24,
"capacity_used": 8
}
}
}
}
```
The example is illustrative; the actual `php` object is `null` when PHP is
not configured. Array orders: methods `GET, POST, PUT, DELETE, other`;
selected statuses `200, 206, 304, 403, 404, 500, 502, 503`;
FastCGI failures `capacity, connect_timeout, connect_error, request_timeout,
request_io, response_timeout, response_io, invalid_response, body_io`;
compression `gzip, br, zstd, deflate`.
**Accounting semantics:** The response-kind counters describe the handler
that produced the response; `other` covers early rejections not dispatched
to a static file or PHP. `active_headers` means requests still awaiting their
response headers, *not* live streaming body counts. Mean duration is until
response headers are ready. Body-byte counters advance only when body frames
are polled, **before final dynamic compression**; these are not network wire
bytes. The compression counter records only the downstream encoder, not static
precompressed `.gz`/`.br` sidecar selection. Failed PHP requests may count as
both PHP responses and FastCGI failures. PHP permit capacity is RustProxy's
configured request semaphore, *not* the number of processes in the external
PHP-FPM pool. All counters are process/snapshot lifetime observations and may
restart on a config publication.
## Dashboard configuration
The Web Service editor is split into stable configuration tabs so later features do not create one long form:
- **General** - optional one-shot application preset, ID, name, document root, ordered index files, directory listing and quick hardening switches.
- **Routing** - `try_files` and Web Service rewrite rules.
- **.htaccess** - bounded migration-oriented Apache compatibility, recursive scan and strict-mode behavior.
- **MIME** - per-extension MIME overrides and fallback MIME type.
- **Cache & headers** - default `Cache-Control`, ordered cache rules and fixed response headers.
- **Delivery** - precompressed `.br`/`.gz` sidecars, forced-download extensions and Range/streaming information.
- **PHP-FPM** - attach a shared PHP-FPM profile through the selector, open the profile-management modal to expand/edit/create profiles, or choose local configuration for all FastCGI tuning options.
- **Security** - symlink policy, request/response hardening summary and filesystem-confinement notes.
After the Web Service is saved, attach it under **Vhosts** by selecting `Web Server` and the service ID for the main source or a custom location.
### Intercept application HTTP errors (0.29.7)
Open **Vhosts → Edit vhost → Pages** (not the Web Service PHP tab), enable
**Intercept application error responses**, and enter statuses such as `404, 500, 503`.
This opt-in vhost policy applies to response bodies returned by both **Reverse
Proxy origins** and **Web Server/PHP-FPM origins**. It is configured in
`vhosts[].pages.intercept_statuses`; an omitted/empty list disables it and
preserves the application error HTML. Supported codes: 400, 401, 403, 404,
408, 413, 429, 500, 502, 503, 504.
For each opted-in origin status, RustProxy replaces the *body* using the
vhost's configured error page, then the global error page, then its built-in
error page. The HTTP response status always remains the application's status,
even if the selected page has a custom `status` for standalone error handling.
`Cache-Control: no-store` is set and origin response cookies, compression
metadata and application error HTML are not forwarded. Relevant protocol
headers such as `WWW-Authenticate`, `Allow` and `Retry-After` are retained.
Local RustProxy security/routing failures are not treated as application
responses. If `error_fallback` sends an error to a named internal location,
that response takes precedence and is not overwritten by this option.
```json
{
"id": "my-vhost",
"pages": {
"intercept_statuses": [404, 500, 503],
"errors": {
"404": {"kind": "html", "status": null, "html": "<h1>Not found</h1>"}
}
}
}
```
This is a **vhost fragment**, not a standalone valid vhost object. A page's
`status` override still applies to locally generated RustProxy error pages,
but is ignored when replacing an application's error body. See also
[Custom locations](CUSTOM_LOCATIONS.md).
### Application presets
The editor provides one-shot presets for **Static site, SPA, PHP generic, WordPress, Drupal, Laravel, Symfony, Joomla, PrestaShop and Downloads/file repository**. Applying a preset writes normal Web Service fields; it does not store a hidden CMS mode and every generated field remains editable. Service ID/name, document root and the deployment-specific PHP-FPM endpoint/tuning are preserved. Laravel/Symfony operators should point `document_root` at the application's `public/` directory.
The preset control also exposes two independent hardening switches. They are saved as normal Web Service configuration and remain active even after the generated preset settings are edited manually.
## Configuration example
```json
{
"id": "main-site",
"name": "Main site",
"document_root": "/var/www/main",
"index": ["index.html", "index.php"],
"directory_listing": false,
"htaccess": {
"enabled": true,
"filename": ".htaccess",
"scan_subdirectories": true,
"strict": false
},
"try_files": ["$uri", "$uri/", "/index.php?$query_string"],
"rewrites": [
{"pattern": "^/old/(.*)$", "replacement": "/new/$1", "status": 301}
],
"mime_types": {"avif": "image/avif"},
"default_mime_type": "application/octet-stream",
"cache_control": "public, max-age=3600",
"cache_rules": [
{"matcher": "ext:html", "cache_control": "no-cache"},
{"matcher": "mime:image/", "cache_control": "public, max-age=604800"}
],
"response_headers": {"x-content-type-options": "nosniff"},
"precompressed": {"enabled": true, "brotli": true, "gzip": true},
"download_extensions": ["zip", "iso"],
"symlink_policy": "deny",
"hardening": {
"block_common_attacks": true,
"strip_unnecessary_headers": true
},
"php": {
"enabled": true,
"extensions": ["php"],
"endpoint": "unix:/run/php/php-fpm.sock",
"connect_timeout_ms": 1000,
"request_timeout_ms": 30000,
"max_concurrent_requests": 64
}
}
```
Older configurations remain compatible because all newly added settings have safe defaults.
## Request routing inside a Web Service
The outer vhost/location router always runs first. Inside the selected Web Service the file-serving sequence is:
1. validate and decode the already-selected public path;
2. apply configured Web Service rewrite rules in order;
3. apply the compiled `.htaccess` rewrite subset when enabled;
4. resolve `try_files` candidates when configured;
5. open the selected filesystem object within the document-root boundary;
6. execute configured PHP extensions through PHP-FPM or serve a static file;
7. for a directory, redirect to the slash form if required, then test the effective index list;
8. if no index exists, generate directory listing when effectively enabled or return the normal customizable 403 response.
This is Web Service file resolution, not a replacement for vhost/location routing.
### `try_files`
One candidate is configured per line. Supported substitutions are `$uri` and `$query_string`.
Example for an SPA:
```text
$uri
$uri/
/index.html
```
Example for a PHP front controller:
```text
$uri
$uri/
/index.php?$query_string
```
A final `=4xx`/`=5xx` candidate can return an error status, for example `=404`. Non-final candidates are tested for existence; the final path candidate is the fallback target.
### Rewrite rules
Rules use a regular-expression pattern and a replacement path. The replacement must start with `/`.
- without a status: internal rewrite;
- with `301`, `302`, `307` or `308`: client redirect.
The current query string is retained unless the replacement supplies its own query. Rewritten paths still pass normal Web Server path validation and filesystem confinement.
The editor supports common website presets to fill suggested fields; review their document root, `try_files`, MIME and PHP values before saving. Presets do not install WordPress, Roundcube or other applications. For a single-page app, a fallback similar to `$uri`, `$uri/`, `/index.html` serves the SPA entry for routes without an existing file. For PHP frameworks, use the appropriate front controller; see [PHP-FPM](PHP_FPM.md).
## `.htaccess` compatibility
RustProxy implements a **bounded compatibility subset for migration**, not a complete Apache runtime. The files are read and compiled when the Web Service is validated/saved/applied. They are **not parsed on every request**. After changing `.htaccess` on disk, apply the Web Service again.
An optional bounded Apache-style `.htaccess` compatibility layer supports a **subset** of rules useful for migrations, not arbitrary Apache modules or dynamic directive execution. It can scan configured paths and enforce stricter handling of unsupported expressions. Prefer explicit `try_files`/rewrite/header controls when possible and test migrated sites. A real Apache configuration cannot be pasted unchanged into RustProxy.
Supported directives in 0.25 development:
## PHP and error responses
- `RewriteEngine`, `RewriteBase`, `RewriteCond`, `RewriteRule`;
- `DirectoryIndex`;
- `Options +Indexes`, `Options -Indexes` and the common `-MultiViews` no-op;
- `AddType`;
- `AddHandler` when the handler is PHP-related;
- `Header set`, `Header add`, `Header unset`, including `always`/`onsuccess`;
- `ExpiresActive`, `ExpiresDefault`, `ExpiresByType`;
- `Require all granted` / `Require all denied` outside `Files` blocks.
A Web Service may enable PHP via a shared PHP-FPM profile or its own configuration. PHP is executed by the FPM service, not RustProxy, and must not be exposed as a raw Internet port. Use dedicated pools for different applications. See [PHP-FPM](PHP_FPM.md).
Rewrite compatibility covers the common front-controller patterns used by WordPress/Laravel-style deployments. Conditions can inspect `REQUEST_FILENAME` with `-f`, `-d` and `-s`, plus `REQUEST_URI`, `REQUEST_METHOD`, `QUERY_STRING`, `HTTP_HOST`, `HTTPS`, `REQUEST_SCHEME`, `DOCUMENT_ROOT`, `THE_REQUEST`, selected server/client variables and request headers. Supported rewrite flags are `L`, `END`, `R`, `R=301/302/307/308`, `QSA`, `NC`, `F` and condition `OR`. `PT`/`NE` are accepted as no-ops. `E=` is accepted as a compatibility no-op because HTTP request headers are already forwarded to PHP-FPM.
Vhosts can intercept selected application error bodies in **Pages** and return a configured HTML error page while preserving the status. Internal location `error_fallback` is different: it routes the error to a named internal handler. Error-page replacements are marked uncacheable.
Example front controller:
## Monitoring and issues
```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
```
A Web Service's dashboard shows request rate, status/error counts, response-header timing, delivered bytes and PHP permit usage (when PHP is configured). These are application observations, not proof of external PHP worker count or final compressed network bytes.
`DirectoryIndex`, `Options ±Indexes`, MIME, header and Expires directives are scoped by the directory containing the compiled `.htaccess`. Subdirectory files can therefore override the effective index/listing/MIME/header/cache behavior for their subtree when recursive scanning is enabled.
- **403 / unexpected block:** inspect document-root permissions, symlink policy, hardening and inherited ACLs.
- **404 / unwanted SPA rewrite:** check actual files, index list, `try_files`, location matching and rewrite order.
- **Wrong Content-Type/download:** check MIME override and attachment extensions.
- **PHP script downloaded instead of executed:** check PHP enablement, script extension and FPM endpoint.
- **Cache not filling:** inspect both global and Vhost cache, static response headers and cookies.
PHP remains intentionally controlled by RustProxy: `AddHandler` may extend the set of PHP file extensions, but the PHP-FPM endpoint, timeouts and concurrency are configured only in the Web Service PHP-FPM tab. Apache `SetHandler` socket/proxy syntax cannot choose an arbitrary FastCGI endpoint.
The compatibility scanner is bounded to 128 files, depth 16, 128 KiB per file and 1 MiB total. Symlinked directories are not traversed. `.htaccess`, `.htpasswd` and `.ht*` control files are blocked from HTTP delivery. Unsupported non-security directives are logged and ignored by default; **Strict** mode rejects them instead. General `Files`/`FilesMatch`, unsupported authentication/authorization directives, non-PHP handlers and `SetHandler` are rejected even with Strict off because ignoring them could weaken access control or expose source. `ErrorDocument`, `SetEnvIf`, `Redirect`/`RedirectMatch` and `FallbackResource` are not part of this subset. Existing RustProxy vhost/location ACL, authentication, custom error pages and response rules remain authoritative.
## Index and directory behavior
`index` is an ordered list and can contain static files and, when PHP is enabled, `index.php`. For every requested directory RustProxy checks the list from the beginning. If no configured index exists, directory listing is used only when enabled.
Directory listing is disabled by default. A generated listing:
- opens a fresh directory handle for every request, so repeated refreshes remain correct;
- HTML-escapes names and percent-encodes URL path segments;
- distinguishes files/directories without revealing local filesystem paths;
- sends `Cache-Control: no-store`.
If listing is disabled and no index exists, RustProxy returns the standard generic `403 Access denied` response. Existing vhost/global custom error pages may replace it.
## MIME handling
Static `Content-Type` is selected in this order:
1. Web Service extension override;
2. built-in MIME table;
3. `default_mime_type` (default `application/octet-stream`).
The built-in table covers common HTML/CSS/JS/data formats, archives, images, audio/video, fonts and common Office file formats. Extension override keys are normalized to lowercase without a leading dot.
## Cache policy and Web Service headers
For Web Server sources, filesystem/OS page cache still accelerates file reads, and
static files retain validators and normal browser cache headers. Additionally,
when global and vhost RustProxy caching are enabled, eligible Web Server
responses pass through the same shared HOT/COLD RustProxy object cache as
reverse-proxy origins. Cache policy requires an eligible GET/HEAD, public
freshness and response-side safety; `Set-Cookie`, `private`, `no-store` and
other ineligible responses are not stored. Vhost response-header rules and
transformations now run for Web Server responses before cache fill. PHP-FPM
responses are protected by these same rules.
A Web Service can define:
- default `Cache-Control`;
- ordered `cache_rules` using `ext:<extension>` or `mime:<prefix>`;
- fixed response headers.
Rules are evaluated before the default. Example:
```text
ext:html = no-cache
mime:image/ = public, max-age=604800, immutable
```
Vhost/location response-header rules still execute later in the shared pipeline and can impose URL-specific policy. Framing/transport headers such as `Content-Length`, `Content-Encoding`, `Content-Range`, `Transfer-Encoding`, connection headers and `Vary` cannot be replaced by Web Service fixed headers.
## Precompressed static files
Optional precompressed delivery checks sidecar files only after the original resource has been resolved safely:
- `file.js.br` for Brotli;
- `file.js.gz` for gzip.
The client must advertise the matching coding in `Accept-Encoding`. Brotli is preferred when both are available. The response keeps the original file's MIME type and adds the appropriate `Content-Encoding` and `Vary: Accept-Encoding`.
Validators, streaming and a single Range request operate on the selected representation. Multipart Range responses use the identity representation so MIME boundaries are never mixed with a precompressed sidecar. RustProxy does not compress the file on every request in this mode.
## Range and downloads
Static responses support:
- single `Range: bytes=...`;
- multipart `Range: bytes=0-99,200-299` with at most 16 ranges;
- `If-Range`;
- `206 Partial Content`;
- `416 Range Not Satisfiable`;
- `Accept-Ranges` and per-part `Content-Range`;
- streaming rather than whole-file buffering.
Single ranges can operate on a selected precompressed representation as before. Multipart ranges intentionally use the identity representation and stream `multipart/byteranges`.
Configured `download_extensions` add `Content-Disposition: attachment` using an encoded filename. This is useful for storage/download services without changing the MIME type.
## Validators
Static files emit metadata-based `ETag` and `Last-Modified` and handle `If-None-Match` / `If-Modified-Since` with `304 Not Modified`. Large files are not hashed on each request.
## PHP-FPM / FastCGI
PHP execution is optional per Web Service. RustProxy speaks FastCGI directly to a long-running PHP-FPM process; it never starts `php`, `php-cgi` or a shell command per request.
Supported endpoint forms:
```text
unix:/run/php/php-fpm.sock
tcp:127.0.0.1:9000
tcp:[::1]:9000
```
TCP endpoints are deliberately restricted to literal loopback addresses. The connector has explicit connect/request timeouts and a bounded semaphore. If all PHP execution slots are busy the request receives `503`; connection failures/timeouts become gateway errors and PHP source is never returned as a fallback.
The PHP-FPM tab also exposes an optional streaming request-body limit (`0` means no additional RustProxy limit), response-header and stderr bounds, an Authorization-forwarding switch, static custom FastCGI parameters and php.ini-style `PHP_VALUE` overrides. Custom parameters use `NAME = value` per line; PHP settings use `directive = value` per line. Core CGI/HTTP/PHP control parameter names cannot be overridden. The in-panel **FastCGI syntax and safety** dialog contains the exact syntax and examples.
Configured PHP extensions are executable rather than downloadable source. `index.php` and `try_files` front-controller patterns use the same Web Service root and path validation. See [PHP_FPM.md](PHP_FPM.md) for the operator/runtime details.
## Optional Web Service hardening
`hardening.block_common_attacks` is a deterministic high-confidence protection layer for the local Web Server. It is **not a generic WAF** and deliberately avoids broad SQL-injection/XSS regex matching that would create application false positives. When enabled it:
- denies TRACE, CONNECT and TRACK at the Web Service source;
- blocks hidden dot/control paths except `.well-known`;
- blocks common secret/configuration targets such as `.env`, `wp-config.php`, Joomla `configuration.php`, Drupal `settings.php`, Composer auth/config metadata and private-key file names;
- blocks common backup/database suffixes (`.bak`, `.old`, `.sql`, `.sqlite`, `.db`, editor swap files and `~` backups);
- blocks high-confidence exploit-probe paths such as exposed PHPUnit `eval-stdin`, Laravel Ignition execute-solution, Apache server-status/server-info and `cgi-bin`;
- suppresses the incoming `Proxy` header when building FastCGI parameters, mitigating the CGI/FastCGI `HTTP_PROXY`/httpoxy class.
Existing decoded-path/traversal validation, document-root confinement, `.ht*` protection, vhost ACL/authentication and PHP source protection continue to run independently of this switch. A denied hardening request uses the normal customizable 403 pipeline.
`hardening.strip_unnecessary_headers` removes common implementation/fingerprint response headers after Web Service and `.htaccess` response processing: `Server`, `X-Powered-By`, ASP.NET version headers, `X-Runtime`, `X-Generator` and Drupal cache fingerprints. Vhost/location response rules run later and can intentionally add an operator-controlled header if required.
## Filesystem security and symlinks
The request path rejects malformed encoding, encoded separators, backslashes, control characters and `.`/`..` traversal before filesystem access.
On Unix, filesystem lookup starts from an opened document-root descriptor and walks relative components with `openat`.
Two symlink policies are available:
- `deny` (default) - every path component is opened with `O_NOFOLLOW`; symlinks are rejected.
- `inside_root` - Linux-only; symlinks may be followed only when the opened descriptor resolves back inside the canonical document root through `/proc/self/fd` verification after every component.
`inside_root` is not available with PHP-FPM in 0.25 because PHP-FPM reopens `SCRIPT_FILENAME` itself. PHP therefore requires the safer `deny` policy.
The secure Web Server implementation remains Unix-only until an equivalent race-resistant confinement implementation exists for non-Unix systems.
## Shared RustProxy pipeline
Web Server dispatch occurs after the existing vhost/location selection and access controls. Responses continue through the existing common mechanisms, including:
- ACL, authentication and authorization;
- rate/access policy;
- vhost/location custom and security headers;
- custom error pages;
- access logging and per-vhost Traffic metrics;
- response processing/compression where applicable; each Web Service can inherit, enable or disable dynamic compression independently of its precompressed `.br`/`.gz` sidecar policy and can filter individual codecs; selecting Disabled hides/disables those codec selectors without clearing their saved values, while globally disabled codecs stay unavailable and the minimum size remains global;
- TLS and existing HTTP/1.1, HTTP/2 and HTTP/3 listeners.
A local Web Server request does not create a reverse-proxy backend connection. WebSocket upgrades remain unsupported for a Web Server source.
## Validation limits
Current configuration limits include:
- index files: maximum 16;
- MIME overrides: maximum 64;
- cache rules: maximum 64;
- fixed Web Service headers: maximum 32;
- `try_files`: maximum 16 candidates;
- rewrites: maximum 32 rules;
- download extensions: maximum 64;
- PHP extensions: maximum 16;
- PHP concurrency: 1..4096;
- PHP connect timeout: 50..60000 ms;
- PHP request timeout: 100..3600000 ms;
- `.htaccess`: maximum 128 files, depth 16, 128 KiB per file and 1 MiB total.
Document root must be absolute, exist and be a directory at configuration compile time. References to unknown Web Services are rejected and deletion of a referenced service is blocked/caught by validation.
## Remaining follow-up
The current implementation intentionally leaves multipart Range, named locations, `PATH_INFO` script splitting, full Apache `.htaccess` semantics and non-Unix confinement for later work. These additions must preserve the existing Web Service / vhost-source separation.
Keep all document roots and external application processes separately patched and secured.
-8
View File
@@ -1,8 +0,0 @@
# UI preview policy
Only two legacy simulated previews are retained as narrow visual references. They were generated with injected HTML/CSS/JS and simulated API/fetch/storage, so they are **not current Rust runtime evidence** and may differ from the 0.22.3 UI. Runtime verification status belongs in [../TEST_REPORT.md](../TEST_REPORT.md).
- `login.png`: anonymous minimal Polish login document.
- `location-headers.png`: request/response rules for an individual location.
RustProxy 0.19.1 removes the other old simulated screenshots so stale layouts are not presented as current product state.
+6 -2
View File
@@ -619,12 +619,13 @@
"cacheObjectList": "Stored objects",
"cachePrevious": "Previous",
"cacheNext": "Next",
"cacheObjectHint": "Most recently used first. Click Target or Cache key / tags to copy the full value. Bodies and request headers are not exposed.",
"cacheObjectHint": "Most recently used first. HTTP Age includes upstream/CDN time; Time in RustProxy is the actual HOT/COLD residency. Stale retained objects can also appear. Click Target or Cache key / tags to copy the full value.",
"cachePath": "Path",
"cacheBodySize": "Body",
"cacheAge": "Age",
"cacheResidentAgeValue": "Time in RustProxy",
"cacheTtlRemaining": "Remaining TTL",
"cacheNoObjects": "No fresh objects in this scope. Inspect the decision counters above.",
"cacheNoObjects": "No stored objects in this scope. Check the vhost filter and decision counters.",
"cacheLegacy": "Unassigned",
"cacheGlobalLimit": "Global limit",
"cacheFills": "Active fills (global)",
@@ -1903,6 +1904,9 @@
"webServiceRewritesHelp": "One regex rule per line. Use pattern => /target for an internal rewrite or pattern => 301:/target for a redirect.",
"webServiceRewritesInvalid": "Rewrite rules must use pattern => /target or pattern => 301:/target (301/302/307/308).",
"webServiceCompression": "Dynamic compression",
"webServiceSendfile": "Linux sendfile (zero-copy)",
"webServiceSendfileAuto": "Auto (eligible plain HTTP/1.1)",
"webServiceSendfileHelp": "Opt-in for static GET files >=64 KiB on Linux plain HTTP/1.1 with cache and transforms bypassed and no negotiated compression. TLS, HTTP/2, HTTP/3, ranges and sidecars keep streaming. Does not clear cache.",
"webServiceCompressionHelp": "Inherits the vhost/global policy by default. Individual codecs can be filtered here; globally disabled codecs remain unavailable and minimum size stays global.",
"webServicePrecompressed": "Serve precompressed sidecars",
"webServicePrecompressedBr": "Brotli (.br)",
+6 -2
View File
@@ -619,12 +619,13 @@
"cacheObjectList": "Zapisane obiekty",
"cachePrevious": "Poprzednie",
"cacheNext": "Następne",
"cacheObjectHint": "Najpierw ostatnio używane. Kliknij Target albo Cache key / tags, aby skopiować pełną wartość. Treści i nagłówki żądań nie są ujawniane.",
"cacheObjectHint": "Najpierw ostatnio używane. Wiek HTTP (Age) obejmuje także czas w upstream/CDN; Czas w RustProxy to rzeczywista rezydencja w HOT/COLD. Wpisy nieświeże mogą nadal być widoczne. Kliknij Target lub Cache key / tags, aby skopiować wartość.",
"cachePath": "Ścieżka",
"cacheBodySize": "Treść",
"cacheAge": "Wiek",
"cacheResidentAgeValue": "Czas w RustProxy",
"cacheTtlRemaining": "Pozostały TTL",
"cacheNoObjects": "Brak świeżych obiektów w tym zakresie. Sprawdź powyższe liczniki decyzji.",
"cacheNoObjects": "Brak zapisanych obiektów w tym zakresie. Sprawdź filtry vhosta oraz liczniki decyzji.",
"cacheLegacy": "Nieprzypisane",
"cacheGlobalLimit": "Limit globalny",
"cacheFills": "Aktywne zapisy (globalnie)",
@@ -1903,6 +1904,9 @@
"webServiceRewritesHelp": "Jedna reguła regex na linię. Użyj wzorzec => /cel dla rewrite wewnętrznego albo wzorzec => 301:/cel dla przekierowania.",
"webServiceRewritesInvalid": "Reguły rewrite muszą mieć format wzorzec => /cel albo wzorzec => 301:/cel (301/302/307/308).",
"webServiceCompression": "Kompresja dynamiczna",
"webServiceSendfile": "Linux sendfile (zero-copy)",
"webServiceSendfileAuto": "Auto (HTTP/1.1 bez TLS)",
"webServiceSendfileHelp": "Dla statycznych plików GET od 64 KiB w Linuksie przez HTTP/1.1 bez TLS, przy wyłączonym cache, transformacjach i bez kompresji. TLS, HTTP/2, HTTP/3, Range i pliki skompresowane korzystają ze standardowego strumieniowania. Zmiana opcji nie czyści cache.",
"webServiceCompressionHelp": "Domyślnie dziedziczy politykę vhosta/globalną. Tutaj można ograniczyć poszczególne kodeki; kodeki wyłączone globalnie pozostają niedostępne, a minimalny rozmiar pozostaje globalny.",
"webServicePrecompressed": "Serwuj gotowe pliki skompresowane",
"webServicePrecompressedBr": "Brotli (.br)",
-276
View File
@@ -1,276 +0,0 @@
# Live performance and regression harness
These scripts exercise actual RustProxy executables through loopback sockets.
They do not simulate Rust request handling. The Python server in `peer.py` is a
controllable test **upstream**, used to verify streaming, non-replayable writes,
headers, cache policy and WebSocket frames. Performance measurements use nginx
as the shared upstream instead of Python.
## Requirements
- Linux with readable `/proc`, Python 3.10+, executable RustProxy binaries.
- nginx and wrk for performance comparisons. Both paths are explicit; no
system-wide installation is required.
- `taskset` from util-linux for the benchmark's explicit CPU affinity and for
optional affinity settings in the other scripts.
- Optional Python `brotli` and `zstandard` decoders increase compression
acceptance coverage. A missing optional decoder is reported as **skipped**.
- Permission to bind unprivileged loopback sockets and start local processes.
In a sandbox without sockets or `/proc`, run in the approved local execution
environment. Do not replace real runtime results with a peer self-test.
Each run creates a new private temporary data directory and initializes its own
administrator with a random password supplied only over stdin. Configurations
are applied through RustProxy's existing authenticated CLI/API and listener
changes receive a restart. No historical SQLite database is copied or edited.
TLS keys, databases and passwords are never written to the evidence directory.
Temporary state is outside the evidence tree and is removed on normal exit.
`fixture-cleanup.json` records the cleanup check. Interrupted runs can leave an
unrelated `/tmp/rustproxy-perf-*` directory; do not include it in a release.
Before timed benchmark, soak or syscall-profile work, `wait_quiescent()` waits
for the existing authenticated ACME runtime API to report `ready` or
`unavailable`. A fresh RustProxy data directory can start a Python/Certbot
environment bootstrap even when no certificates have an issuer. The gate
requires no configured issuers, jobs or renewal observations, the same live Rust
PID and start time, and no child processes for five continuous seconds. It polls
every 0.5 seconds and fails after 180 seconds instead of measuring installation
work. Selected status, process count and timing evidence is saved in
`<label>-quiescence.json`. No PATH, pip settings or application features are
changed. An `unavailable` result permits the HTTP fixture to proceed; these runs
do not test ACME issuance or certificate-provider availability.
## Regression tests
Run from the repository root, providing absolute binary and output paths:
```bash
python3 scripts/performance/regression.py \
--binary before=/absolute/path/rust-proxy-before \
--binary after=/absolute/path/rust-proxy-after \
--out /absolute/path/results/regression
```
`--groups static,proxy,policy,cache,transform` selects groups; all are enabled by
default. `--workers 4` sets the same `TOKIO_WORKER_THREADS` for each binary.
The report contains individual pass/fail/skip observations and a comparison
matrix. An existing failure in the supplied binary remains a failure in its
report; it is not silently converted to a successful baseline expectation.
Coverage includes:
- Static GET/HEAD and byte checksums at 1,040 B, 64 KiB and 1 MiB; empty files;
ETag/Last-Modified conditional requests; prefix/suffix/open/multipart ranges,
If-Range and 416; directory redirect/index/listing; decoded path rejection;
deny/inside-root symlinks; precompressed gzip; MIME/download headers;
`try_files`, redirects and the compiled `.htaccess` header subset.
- Proxy POST/PUT, 1 MiB upload checksums, chunked upload delivery **before the
last chunk is sent**, early response delivery, client disconnect, original
status/redirect preservation, failed-POST no replay, request/response header
scopes, hop-by-hop stripping, request-ID shape/uniqueness and WebSocket echo.
- Global host/vhost/profile/location ACL composition, upstream suppression on
denial, trusted versus spoofed forwarding, untrusted country hints, method
and rate policies, hot changes and rejection of invalid configuration.
- Private administration routes, login, session/CSRF and Origin enforcement,
Basic Auth, ACL-before-auth, private-origin rejection, retained exchange
logging fields and byte accounting.
- Cache global/vhost/route OFF and ON, MISS/HIT, Vary isolation, cookie,
Authorization/private/no-store bypass, PURGE, concurrent fills, validator
revalidation, static cached ranges/conditionals; transforms OFF/ON with cache
OFF/ON; compression negotiation, exclusions and scope/global codec ceilings.
The generic suite does not establish TLS/H2/H3, Tinyauth, real PHP-FPM, real DNS
rebinding, concurrent filesystem rename-race safety or long-soak acceptance.
Use the dedicated transport tests and existing Rust integration suites for
their covered cases. The sequential symlink-target test is explicitly not a
concurrent rename-race proof.
## TLS, HTTP/2, HTTP/3 and WebSocket interoperability
```bash
python3 scripts/performance/transport_regression.py \
--binary before=/absolute/path/rust-proxy-before \
--binary after=/absolute/path/rust-proxy-after \
--out /absolute/path/results/transport
```
This suite requires `aioquic`, `httpx[http2]` and `websockets`; use
`--python-deps /absolute/path/pydeps` for a separate dependency directory.
It verifies the temporary server certificate, negotiated protocol, response
status and bytes for TLS H2 and QUIC H3, including HEAD, validators, ranges,
cache ON/OFF, 1 MiB bodies and concurrent streams. Dedicated WebSocket checks
include bidirectional echo and a normal close handshake. Client protocol
self-checks are reported separately from RustProxy acceptance cases.
This establishes covered interoperability, not TLS/H2/H3 throughput or
production-certificate compatibility.
## Two-origin load-balancer regression
```bash
python3 scripts/performance/lb_regression.py \
--binary before=/absolute/path/rust-proxy-before \
--binary after=/absolute/path/rust-proxy-after \
--out /absolute/path/results/lb
```
This separate, small suite sends real requests through RustProxy to two local
TCP origins. It verifies equal round-robin allocation, GET failover after a 503
and after a stopped origin refuses connections, and the absence of POST replay.
A controlled response pauses after its first body fragment while configuration
changes drain or disable its member. New requests must use the other member,
the admitted response must finish intact, and its active lease must remain
counted until completion and then return to zero. The main HTTP case matrix is
unchanged. This suite does not cover sticky sessions, active health probes,
DNS rotation or TLS upstream failures.
## Finite concurrency correctness stress
```bash
python3 scripts/performance/concurrency_regression.py \
--binary before=/absolute/path/rust-proxy-before \
--binary after=/absolute/path/rust-proxy-after \
--nginx /absolute/path/nginx --out /absolute/path/results/concurrency
```
This separate functional check uses 1, 16 and 128 concurrent client tasks for
proxy and static responses at 1,040 B, 64 KiB and 1 MiB. Each finite batch sends
`max(32, 2 * clients)` requests and verifies every status, length and body SHA-256.
After the clients finish, backend active-exchange counters must settle to zero.
RSS and file descriptors are recorded before the batch and after its drain.
These are not peak resource measurements or throughput results. Client tasks
start behind a barrier; configured concurrency does not establish that the same
number of server exchanges were active simultaneously.
## Before/after/nginx benchmark
First verify starts, loopback routing and exact payload hashes without invoking
the load generator:
```bash
python3 scripts/performance/benchmark.py \
--binary before=/absolute/path/rust-proxy-before \
--binary after=/absolute/path/rust-proxy-after \
--nginx /absolute/path/nginx --wrk /absolute/path/wrk \
--server-cpus 0-3 --origin-cpus 4-5 --client-cpus 6-8 \
--out /absolute/path/results/preflight --prepare-only
```
For a host with CPUs 0–8 available, a reproducible primary run is:
```bash
python3 scripts/performance/benchmark.py \
--binary before=/absolute/path/rust-proxy-before \
--binary after=/absolute/path/rust-proxy-after \
--nginx /absolute/path/nginx --wrk /absolute/path/wrk \
--out /absolute/path/results/primary \
--workers 4 --origin-workers 2 --client-threads 4 \
--server-cpus 0-3 --origin-cpus 4-5 --client-cpus 6-8 \
--concurrency 64 --sizes small,large,huge --modes proxy,static \
--duration 15 --warmup 3 --repeats 5
```
Choose explicit, nonempty and disjoint CPU sets allowed in the run environment;
adapt all three sets on a smaller host. Frontends use the same CPU set and worker
count, the shared origin uses another set, and the generator uses a third. The
harness checks each process/thread's actual affinity and refuses unavailable
resource counters. Four wrk threads share the three client CPUs in this example.
The structured result records thread count and effective connection count; at
64 connections, four threads keep exactly 64 connections, avoiding wrk's integer
division of connections between threads. Only one frontend is loaded at a time.
Stop builds, other tests and profilers during throughput measurement.
The primary run takes approximately 27 minutes: six scenarios, three variants,
five samples, and 18 seconds per sample including excluded warm-up. To explore
concurrency, repeat in a fresh output directory with
`--concurrency 1,16,64,128`. More scenarios intentionally take more time.
Short sanity checks require the explicit `--exploratory` option and cannot
satisfy the >=5 samples / >=15-second measurement gate.
Cache is explicitly disabled for the primary benchmark. Transforms retain their
disabled defaults. Identity encoding is requested. Logging history, analytics,
request IDs, origin policy, body accounting, health/lease plumbing and normal
application functionality remain active. nginx uses `sendfile on`, HTTP/1.1,
keep-alive and streaming proxy delivery. The generated exact configurations are
saved, so this intentional difference in nginx/application features is visible.
No cache-disabled claim is inferred from a cache-on speedup.
Optional keep-alive tuning uses `--max-idle label=64`. Keep this as a separately
labelled experiment. The harness does not turn off bounded pool recycling.
## Recorded evidence and interpretation
- `schedule.json`: complete deterministic crossover order, rotating and
reversing variants between repetitions. No best-run selection.
- `preflight.json`, `fixture/payloads.json`, binary/version/config metadata:
exact status, body SHA-256, payload sizes and inputs.
- Per-sample exact status/body checks before and after the measured interval,
plus a wire-bytes-per-request sanity check, detect wrong content and gross
transfer-size anomalies. These do not hash every response inside wrk.
- `raw/*.txt` and `raw/*.json`: unmodified wrk output and structured requests,
duration, bytes, RPS, p50/p95/p99, effective concurrency, and
connect/read/write/timeout/status errors.
- `raw/*-resources.json`: timestamped `/proc` samples for each server process
and its children, plus the separate origin process group.
- `raw/*-affinity.json`: observed process/thread CPU masks for the front end,
origin and generator.
- `runs.csv/json`, `summary.csv/json`: all samples, medians, RPS range and
coefficient of variation, latency medians, server CPU time per completed
request, peak/end RSS and file descriptors.
- `status/*.json`: numeric drop and pool-generation/rotation counters read
outside each measured interval. Warm-up output is kept separately.
- `completion.json` and `SHA256SUMS`: completion state and hashes of evidence.
Any generator/network/status error stops the run with `failure.json`; partial
data remains available and is not presented as completed acceptance.
Use `(after_median / before_median - 1) * 100` for throughput improvement and
`after_median / nginx_median * 100` for relative nginx throughput. Rebuilding
both revisions with one compiler, target and resolved lockfile is required for
source-change attribution. Compare the supplied executable separately: build
flags and dependency versions can change its performance.
These are same-host, closed-loop HTTP/1.1 diagnostics. They do not establish
production capacity, TLS/H2/H3 performance, an open-loop latency SLA, allocator
rates, CPU cycles or 24-hour resource stability. wrk latency follows wrk's own
histogram/correction behavior. RSS and descriptor peaks are sampled observations,
so shorter spikes may be missed. Summed nginx RSS can double-count shared pages.
Origin or generator CPU saturation can limit observed throughput; inspect their
resource data and move the generator/origin to separate machines for release
measurements. Run profilers separately so profiling overhead does not enter the
headline A/B throughput numbers.
## Mixed-traffic resource soak
Run this in its own phase, after correctness and throughput checks:
```bash
python3 scripts/performance/soak.py \
--binary /absolute/path/rust-proxy-after --label after \
--nginx /absolute/path/nginx --out /absolute/path/results/soak \
--duration 1800 --rate 100 --clients 8 \
--workers 4 --origin-workers 2 --server-cpus 0-3 --origin-cpus 4-5 \
--toggle-seconds 120 --sample-seconds 5 --abort-every 101
```
The bounded client executor alternates proxy/static and 1,040 B/64 KiB/1 MiB
payloads, verifies full bytes and occasional HEAD/range semantics, closes some
clients after an initial body prefix, and applies live cache OFF/ON changes.
Every workload request opens a new frontend HTTP/1.1 connection; upstream
keepalive retains its configured defaults. This exercises connection churn.
The primary wrk benchmark separately uses persistent frontend connections.
The nominal request rate is paced; scheduler delays and actual completion rate
are reported instead of hiding an under-driven client. Pending jobs are bounded
to twice the client count. The script restores base configuration, drains and
rechecks both listeners before shutdown. After each cache apply, two separate
GETs per source verify exact contents and require a HIT on the second GET when
enabled; globally disabled cache must expose no X-Cache header. These probe
requests are recorded separately from paced traffic. Backend active-exchange
counters must settle to zero after the final drain and cooldown.
Five-second resource/status records, body/status failures, observed cache-status
counts, cache transitions/probes, RSS/FD trend and completed traffic totals are
retained. Controlled client closes mean that the client closes after reading a
prefix. The server may already have sent the complete response, so those counts
are not presented as proven server-side cancellations.
Memory trend is descriptive: switching caching and allocator retention affect
RSS. This is a 30-minute resource gate, not a 24-hour leak proof. A short
`--exploratory --duration ...` sanity run stays explicitly outside that gate.
-479
View File
@@ -1,479 +0,0 @@
#!/usr/bin/env python3
"""Interleaved before/after/nginx process benchmarks with immutable raw evidence.
This is a same-host diagnostic experiment, not a simulation or release sign-off.
Runs >=5 measured samples per variant/scenario by default, each >=15 seconds.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import itertools
import json
import os
from pathlib import Path
import platform
import resource
import statistics
import subprocess
import sys
import time
import traceback
from common import Fixture, http, seal_results, sha256, write_json
from resources import (Monitor, affinity_snapshot, cgroup_cpu_delta, cgroup_cpu_stat,
parse_cpu_set, validate_cpu_sets, wait_for_affinity)
from telemetry import traffic_analytics_delta, traffic_analytics_snapshot
SIZES = {"small": "small.html", "large": "large.bin", "huge": "huge.bin"}
DROP_COUNTER_FIELDS = {
"logs_dropped": "history_dropped_delta",
"logging.sinks.queue_dropped": "logging_sinks_queue_dropped_delta",
"logging.error_file.queue_dropped": "logging_error_file_queue_dropped_delta",
"cache.cold_queue_dropped": "cache_cold_queue_dropped_delta",
}
DROP_INTERVAL_NOTE = ("Delta brackets measured wrk plus one postflight request and possible in-flight completions; "
"percentage uses wrk completed requests as an approximate denominator. "
"History rejection does not imply rejection by live broadcast or external sinks.")
def usage_snapshot():
usage = resource.getrusage(resource.RUSAGE_CHILDREN)
return usage.ru_utime + usage.ru_stime
def effective_threads(requested, concurrency):
if requested < 1 or concurrency < 1:
raise ValueError("client threads and concurrency must be positive")
# wrk floor-divides the connection count between threads. Select a divisor
# so its advertised and effective connection counts remain identical.
return next(value for value in range(min(requested, concurrency), 0, -1)
if concurrency % value == 0)
def wrk_run(args, port, path, duration, concurrency, prefix, roots=None, expected_processes=None):
threads = effective_threads(args.client_threads, concurrency)
command = [str(args.wrk), "--latency", "-t", str(threads),
"-c", str(concurrency), "-d", f"{duration}s", "--timeout", "5s",
"-H", "Accept-Encoding: identity", "-s", str(Path(__file__).with_name("wrk_report.lua")),
f"http://127.0.0.1:{port}/{path}"]
if args.client_cpus:
command = ["taskset", "-c", args.client_cpus, *command]
affinities = {}
expected_processes = expected_processes or {}
if roots:
for label, pid in roots.items():
expected = parse_cpu_set(args.server_cpus if label == "server" else args.origin_cpus)
affinities[label] = affinity_snapshot(pid, expected, expected_processes.get(label))
monitor = Monitor(roots, args.resource_interval, expected_processes) if roots else None
if monitor:
monitor.start()
cpu_start = usage_snapshot()
started = time.monotonic()
process = None
stdout, stderr = "", ""
try:
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
affinities["generator"] = wait_for_affinity(process, parse_cpu_set(args.client_cpus))
stdout, stderr = process.communicate(timeout=duration + 30)
finally:
if process is not None and process.poll() is None:
process.kill()
stdout, stderr = process.communicate(timeout=5)
if monitor:
monitor.stop()
prefix.parent.mkdir(parents=True, exist_ok=True)
prefix.with_suffix(".txt").write_text(stdout + "\n" + stderr)
write_json(prefix.with_name(prefix.name + "-affinity.json"), affinities)
if monitor:
write_json(prefix.with_name(prefix.name + "-resources.json"), monitor.samples)
if monitor.errors:
write_json(prefix.with_name(prefix.name + "-resource-errors.json"), monitor.errors)
cpu_seconds = usage_snapshot() - cpu_start
wall_seconds = time.monotonic() - started
raw = stdout + "\n" + stderr
if process.returncode:
raise RuntimeError(f"wrk exited {process.returncode}: {raw[-2000:]}")
line = next((line for line in stdout.splitlines() if line.startswith("RUSTPROXY_BENCH_JSON ")), None)
if line is None:
raise RuntimeError("wrk produced no structured report")
result = json.loads(line.removeprefix("RUSTPROXY_BENCH_JSON "))
if result["requests"] <= 0 or result["duration_us"] <= 0:
write_json(prefix.with_suffix(".json"), result)
raise RuntimeError("wrk completed no requests or reported an invalid duration; raw evidence retained")
result.update({"command": command, "wall_seconds": wall_seconds,
"client_threads": threads, "requested_connections": concurrency,
"client_threads_requested": args.client_threads,
"effective_connections": threads * (concurrency // threads),
"generator_cpu_seconds": cpu_seconds,
"rps": result["requests"] * 1_000_000 / result["duration_us"],
"error_total": sum(value for key, value in result.items() if key.startswith("errors_"))})
if monitor:
result["resources"] = monitor.summary(result["requests"])
write_json(prefix.with_suffix(".json"), result)
return result
def payload_check(port, path, expected):
"""A full exact-body check outside the wrk measurement interval."""
try:
status, headers, body = http(port, "/" + path, headers={"Accept-Encoding": "identity"})
return {"status": status, "bytes": len(body), "sha256": hashlib.sha256(body).hexdigest(),
"expected_bytes": len(expected), "expected_sha256": hashlib.sha256(expected).hexdigest(),
"content_length": headers.get("content-length"), "content_encoding": headers.get("content-encoding"),
"approximate_header_bytes": 19 + sum(len(key) + len(value) + 4 for key, value in headers.items()),
"passed": status == 200 and body == expected}
except Exception as error:
return {"passed": False, "error": str(error), "expected_bytes": len(expected),
"expected_sha256": hashlib.sha256(expected).hexdigest()}
def transfer_sanity(result, payload_bytes, header_bytes, concurrency):
"""Check gross truncation/size anomalies; this is not per-response hashing."""
requests, wire_bytes = result["requests"], result["bytes"]
minimum = requests * payload_bytes
# wrk counts headers and partially received final responses in its wire bytes.
header_allowance = max(1024, 2 * header_bytes)
maximum = (requests + concurrency) * (payload_bytes + header_allowance)
return {"passed": requests > 0 and minimum <= wire_bytes <= maximum,
"wire_bytes_per_request": wire_bytes / requests if requests else None,
"expected_payload_bytes_per_request": payload_bytes,
"minimum_wire_bytes": minimum, "maximum_wire_bytes": maximum,
"header_allowance_per_response": header_allowance,
"inflight_response_allowance": concurrency}
def counter_values(value, prefix=""):
"""Retain numeric drop and rotation counters without session/config data."""
found = {}
if isinstance(value, dict):
for key, child in value.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(child, (int, float)) and any(part in key for part in ["dropped", "rotations", "generation", "requests_in_generation"]):
found[path] = child
found.update(counter_values(child, path))
elif isinstance(value, list):
for index, child in enumerate(value):
found.update(counter_values(child, f"{prefix}[{index}]"))
return found
def validate_drop_counters(values):
for name in DROP_COUNTER_FIELDS:
if type(values.get(name)) is not int or values[name] < 0:
raise RuntimeError(f"missing or invalid cumulative status counter: {name}")
return values
def status_counter_snapshot(rust):
if rust is None:
return None
status, _, body = rust.admin("/api/status")
if status != 200:
raise RuntimeError(f"{rust.label} status capture failed: HTTP {status}")
return counter_values(json.loads(body))
def drop_counter_report(before, after, requests):
if requests <= 0:
raise ValueError("drop-rate denominator must contain completed requests")
if (before is None) != (after is None):
raise RuntimeError("both status snapshots are required for a drop delta")
if before is not None:
validate_drop_counters(before)
validate_drop_counters(after)
deltas = {name: after[name] - before[name] if before is not None else None
for name in DROP_COUNTER_FIELDS}
if any(value is not None and value < 0 for value in deltas.values()):
raise RuntimeError("cumulative drop counter decreased during measured interval")
fields = {field: deltas[name] for name, field in DROP_COUNTER_FIELDS.items()}
fields["history_drop_percent_of_completed_requests"] = (
100 * deltas["logs_dropped"] / requests if before is not None else None)
return {"applicable": before is not None, "counter_deltas": deltas, "row_fields": fields,
"completed_requests_denominator": requests, "denominator_is_approximate": True,
"interval_note": DROP_INTERVAL_NOTE}
def analytics_counter_report(before, after, requests):
if requests <= 0 or (before is None) != (after is None):
raise ValueError("analytics delta requires paired snapshots and completed requests")
delta = traffic_analytics_delta(before, after) if before is not None else {
"queue_dropped_delta": None, "recorded_requests_delta": None, "interval_seconds": None}
fields = {"analytics_queue_dropped_delta": delta["queue_dropped_delta"],
"analytics_recorded_requests_delta": delta["recorded_requests_delta"],
"analytics_queue_drop_percent_of_completed_requests": (
100 * delta["queue_dropped_delta"] / requests if before is not None else None)}
return {"applicable": before is not None, **delta, "row_fields": fields,
"completed_requests_denominator": requests, "denominator_is_approximate": True,
"interval_note": DROP_INTERVAL_NOTE,
"recorded_requests_note": "Asynchronous analytics records; not HTTP success or completion counts."}
def persist_rows(out, rows):
write_json(out / "runs.json", rows)
if not rows:
return
with (out / "runs.csv").open("w", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
groups = {}
for row in rows:
groups.setdefault((row["mode"], row["size"], row["concurrency"], row["variant"]), []).append(row)
summary = []
for (mode, size, concurrency, variant), group in sorted(groups.items()):
rps = [row["rps"] for row in group]
summary.append({"mode": mode, "size": size, "concurrency": concurrency, "variant": variant,
"samples": len(group), "rps_median": statistics.median(rps),
"rps_min": min(rps), "rps_max": max(rps),
"rps_cv_percent": statistics.stdev(rps) / statistics.mean(rps) * 100 if len(rps) > 1 else None,
"p50_us_median": statistics.median(row["p50_us"] for row in group),
"p95_us_median": statistics.median(row["p95_us"] for row in group),
"p99_us_median": statistics.median(row["p99_us"] for row in group),
"server_cpu_us_per_request_median": statistics.median(row["server_cpu_us_per_request"] for row in group),
"server_rss_peak_bytes": max(row["server_rss_peak_bytes"] for row in group),
"server_fd_peak": max(row["server_fd_peak"] for row in group),
"requests": sum(row["requests"] for row in group),
"cgroup_throttled_samples": sum(row["cgroup_nr_throttled_delta"] > 0 for row in group),
"cgroup_nr_throttled_delta": sum(row["cgroup_nr_throttled_delta"] for row in group),
"cgroup_throttled_usec_delta": sum(row["cgroup_throttled_usec_delta"] for row in group),
"validation_failures": sum(not (row["payload_before_passed"] and row["payload_after_passed"]
and row["transfer_sanity_passed"]) for row in group),
"errors": sum(row["error_total"] for row in group)})
write_json(out / "summary.json", summary)
with (out / "summary.csv").open("w", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=list(summary[0]))
writer.writeheader()
writer.writerows(summary)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
parser.add_argument("--max-idle", action="append", default=[], metavar="LABEL=COUNT")
parser.add_argument("--nginx", type=Path, required=True)
parser.add_argument("--wrk", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--origin-workers", type=int, default=4)
parser.add_argument("--client-threads", type=int, default=4)
parser.add_argument("--server-cpus")
parser.add_argument("--origin-cpus")
parser.add_argument("--client-cpus")
parser.add_argument("--duration", type=int, default=15)
parser.add_argument("--warmup", type=int, default=3)
parser.add_argument("--repeats", type=int, default=5)
parser.add_argument("--concurrency", default="64")
parser.add_argument("--sizes", default="small,large")
parser.add_argument("--modes", default="proxy,static")
parser.add_argument("--resource-interval", type=float, default=.5)
parser.add_argument("--exploratory", action="store_true", help="explicitly allow <5 repetitions or <15s samples")
parser.add_argument("--prepare-only", action="store_true", help="start processes and verify hashes; run no load generator")
args = parser.parse_args()
if (args.duration < 15 or args.repeats < 5) and not args.exploratory:
parser.error("acceptance requires >=15-second runs and >=5 repetitions; use --exploratory for labelled short diagnostics")
if args.out.exists() and any(args.out.iterdir()):
parser.error("--out must be a new or empty directory so raw evidence is never overwritten")
modes, sizes = args.modes.split(","), args.sizes.split(",")
concurrencies = [int(value) for value in args.concurrency.split(",")]
if any(mode not in ("proxy", "static") for mode in modes) or any(size not in SIZES for size in sizes):
parser.error("supported modes: proxy,static; sizes: small,large,huge")
if min(concurrencies) < 1 or max(concurrencies) > 4096:
parser.error("concurrency must be 1..4096")
if args.client_threads < 1:
parser.error("--client-threads must be positive")
if args.workers < 1 or args.origin_workers < 1:
parser.error("--workers and --origin-workers must be positive")
try:
cpu_sets = validate_cpu_sets({"server": args.server_cpus, "origin": args.origin_cpus,
"client": args.client_cpus}, set(os.sched_getaffinity(0)))
except ValueError as error:
parser.error(str(error))
if args.resource_interval <= 0:
parser.error("--resource-interval must be positive")
args.nginx, args.wrk, args.out = args.nginx.resolve(), args.wrk.resolve(), args.out.resolve()
variants = dict(item.split("=", 1) for item in args.binary)
if len(variants) != len(args.binary) or "nginx" in variants:
parser.error("variant names must be unique and must not be nginx")
max_idle = dict(item.split("=", 1) for item in args.max_idle)
args.out.mkdir(parents=True, exist_ok=True)
environment = {"kind": "same-host real HTTP/1.1 process benchmark", "simulation": False,
"exploratory": args.exploratory, "prepare_only": args.prepare_only,
"acceptance_sampling": args.duration >= 15 and args.repeats >= 5 and not args.prepare_only,
"platform": platform.platform(), "python": sys.version,
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"cpu_affinity_available": sorted(os.sched_getaffinity(0)),
"cpu_sets": {label: sorted(cpus) for label, cpus in cpu_sets.items()},
"resource_process_discovery": "PPid map from /proc/*/stat; root and configured nginx process counts must be readable",
"expected_nginx_processes": {"frontend": args.workers + 1, "origin": args.origin_workers + 1},
"generator_plan": [{"connections": concurrency, "requested_threads": args.client_threads,
"effective_threads": effective_threads(args.client_threads, concurrency)}
for concurrency in concurrencies],
"settings": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()},
"wrk_sha256": sha256(args.wrk), "nginx_sha256": sha256(args.nginx),
"limitations": ["Generator, upstream and frontend share one host and kernel; not production capacity",
"Closed-loop wrk latency includes its histogram correction; no open-loop arrival-rate SLA claim",
"RSS sums processes and may double-count shared pages in nginx",
"RSS and FD peaks are sampled, not absolute instantaneous peaks",
"Exact HTTP 200 and payload hashes are checked before/after each measured run, not for every response during load",
"wrk status errors count only status >399; aggregate bytes/request is a gross sanity check, not response integrity proof",
"cgroup CPU-throttling deltas cover all processes in the shared cgroup, not only the measured server",
DROP_INTERVAL_NOTE,
"No TLS/H2/H3 speed inference; no CPU cycles/allocation profiler claim",
"nginx has a smaller feature set; request logging/ACL/analytics work is not feature-matched"]}
for path in ["/sys/fs/cgroup/cpu.max", "/sys/fs/cgroup/memory.max", "/proc/version"]:
try:
environment[path] = Path(path).read_text().strip()
except OSError:
environment[path] = None
write_json(args.out / "environment.json", environment)
expected_samples = len(modes) * len(sizes) * len(concurrencies) * args.repeats * (len(variants) + 1)
write_json(args.out / "completion.json", {"completed": False, "samples": 0,
"expected_samples": expected_samples,
"simulation": False, "prepare_only": args.prepare_only})
rows, error = [], None
try:
with Fixture(args.out / "fixture", workers=args.workers, server_cpus=args.server_cpus,
origin_cpus=args.origin_cpus, origin_workers=args.origin_workers) as fixture:
nginx = fixture.nginx(args.nginx)
servers = {"nginx": {"process": nginx["process"], "proxy": nginx["reverse_port"],
"static": nginx["static_port"], "rust": None}}
for label, binary in variants.items():
rust = fixture.rust(Path(binary), label, max_idle=int(max_idle[label]) if label in max_idle else None)
rust.login()
servers[label] = {"process": rust.process, "proxy": rust.reverse_port, "static": rust.static_port, "rust": rust}
quiescence = {label: server["rust"].wait_quiescent()
for label, server in servers.items() if server["rust"] is not None}
write_json(args.out / "quiescence-preflight.json", quiescence)
initial_affinity = {"origin": affinity_snapshot(nginx["origin"].pid, cpu_sets["origin"], args.origin_workers + 1)}
initial_affinity.update({label: affinity_snapshot(server["process"].pid, cpu_sets["server"],
args.workers + 1 if label == "nginx" else 1)
for label, server in servers.items()})
write_json(args.out / "affinity-preflight.json", initial_affinity)
# The fixture has only master + configured workers. Refuse a
# master-only snapshot even if HTTP payload checks would succeed.
prepare_monitor = Monitor({"server": nginx["process"].pid, "origin": nginx["origin"].pid},
args.resource_interval,
{"server": args.workers + 1, "origin": args.origin_workers + 1})
prepare_monitor.sample()
write_json(args.out / "resources-preflight.json", prepare_monitor.samples)
telemetry_preflight = {label: {"status": status_counter_snapshot(server["rust"]),
"traffic": traffic_analytics_snapshot(server["rust"]) if server["rust"] else None}
for label, server in servers.items()}
write_json(args.out / "telemetry-preflight.json", telemetry_preflight)
for snapshot in telemetry_preflight.values():
if snapshot["status"] is not None:
validate_drop_counters(snapshot["status"])
preflight = []
for label, server in servers.items():
for mode, size in itertools.product(modes, sizes):
payload = (fixture.site / SIZES[size]).read_bytes()
check = payload_check(server[mode], SIZES[size], payload)
preflight.append({"variant": label, "mode": mode, "size": size, **check})
write_json(args.out / "preflight.json", preflight)
if not check["passed"]:
raise AssertionError(f"payload preflight failed: {label} {mode} {size}: {check}")
write_json(args.out / "preflight.json", preflight)
labels = [*variants, "nginx"]
schedule = []
for mode, size, concurrency in itertools.product(modes, sizes, concurrencies):
for repetition in range(args.repeats):
offset = repetition % len(labels)
order = labels[offset:] + labels[:offset]
if repetition % 2:
order.reverse()
for variant in order:
schedule.append({"sequence": len(schedule) + 1, "mode": mode, "size": size,
"concurrency": concurrency, "repetition": repetition + 1, "variant": variant})
write_json(args.out / "schedule.json", schedule)
if not args.prepare_only:
for item in schedule:
server = servers[item["variant"]]
expected_processes = {"origin": args.origin_workers + 1,
"server": args.workers + 1 if item["variant"] == "nginx" else 1}
stem = f"{item['sequence']:04}-{item['mode']}-{item['size']}-c{item['concurrency']}-{item['variant']}"
if args.warmup:
warmup = wrk_run(args, server[item["mode"]], SIZES[item["size"]], args.warmup,
item["concurrency"], args.out / "warmup" / stem)
if warmup["error_total"]:
raise RuntimeError(f"warmup errors in {stem}; raw evidence retained")
payload = (fixture.site / SIZES[item["size"]]).read_bytes()
before = payload_check(server[item["mode"]], SIZES[item["size"]], payload)
write_json(args.out / "checks" / (stem + "-before.json"), before)
if not before["passed"]:
raise RuntimeError(f"exact payload check failed before {stem}: {before}")
counters_before = status_counter_snapshot(server["rust"])
write_json(args.out / "status" / (stem + "-before.json"), counters_before)
if counters_before is not None:
validate_drop_counters(counters_before)
analytics_before = traffic_analytics_snapshot(server["rust"]) if server["rust"] else None
write_json(args.out / "status" / (stem + "-traffic-before.json"), analytics_before)
cpu_before = cgroup_cpu_stat()
try:
result = wrk_run(args, server[item["mode"]], SIZES[item["size"]], args.duration,
item["concurrency"], args.out / "raw" / stem,
{"server": server["process"].pid, "origin": nginx["origin"].pid},
expected_processes)
finally:
cpu_after = cgroup_cpu_stat()
cpu_delta = cgroup_cpu_delta(cpu_before, cpu_after)
throttling = {"before": cpu_before, "after": cpu_after, "delta": cpu_delta}
write_json(args.out / "checks" / (stem + "-cgroup-cpu.json"), throttling)
after = payload_check(server[item["mode"]], SIZES[item["size"]], payload)
write_json(args.out / "checks" / (stem + "-after.json"), after)
write_json(args.out / "checks" / (stem + "-affinity-after.json"), {
"server": affinity_snapshot(server["process"].pid, cpu_sets["server"], expected_processes.get("server")),
"origin": affinity_snapshot(nginx["origin"].pid, cpu_sets["origin"], expected_processes["origin"])})
counters_after = status_counter_snapshot(server["rust"])
write_json(args.out / "status" / (stem + ".json"), counters_after)
analytics_after = traffic_analytics_snapshot(server["rust"]) if server["rust"] else None
write_json(args.out / "status" / (stem + "-traffic-after.json"), analytics_after)
drops = drop_counter_report(counters_before, counters_after, result["requests"])
write_json(args.out / "status" / (stem + "-delta.json"), drops)
analytics = analytics_counter_report(analytics_before, analytics_after, result["requests"])
write_json(args.out / "status" / (stem + "-traffic-delta.json"), analytics)
sanity = transfer_sanity(result, len(payload), before["approximate_header_bytes"], item["concurrency"])
result.update({"payload_before": before, "payload_after": after, "transfer_sanity": sanity,
"cgroup_cpu": throttling, "observability": drops, "traffic_analytics": analytics})
write_json((args.out / "raw" / stem).with_suffix(".json"), result)
row = {**item, **{key: result[key] for key in ["requests", "bytes", "duration_us", "rps", "p50_us", "p95_us", "p99_us",
"client_threads_requested", "client_threads", "requested_connections", "effective_connections",
"error_total", "errors_connect", "errors_read", "errors_write", "errors_timeout", "errors_status", "generator_cpu_seconds"]}}
row.update({"payload_before_passed": before["passed"], "payload_after_passed": after["passed"],
"transfer_sanity_passed": sanity["passed"], "wire_bytes_per_request": sanity["wire_bytes_per_request"],
"cgroup_nr_throttled_delta": cpu_delta["nr_throttled"],
"cgroup_throttled_usec_delta": cpu_delta["throttled_usec"]})
row.update(drops["row_fields"])
row.update(analytics["row_fields"])
for group in ["server", "origin"]:
row.update({f"{group}_{key}": value for key, value in result["resources"][group].items()})
rows.append(row)
persist_rows(args.out, rows)
print(json.dumps({"sequence": item["sequence"], "total": len(schedule), "variant": item["variant"],
"scenario": f"{item['mode']}_{item['size']}_c{item['concurrency']}",
"rps": round(result["rps"], 2), "p95_ms": result["p95_us"] / 1000,
"errors": result["error_total"]}), flush=True)
if result["error_total"]:
raise RuntimeError(f"benchmark errors in {stem}; raw evidence retained")
if not after["passed"] or not sanity["passed"]:
raise RuntimeError(f"payload/byte-count validation failed in {stem}; raw evidence retained")
except (Exception, KeyboardInterrupt) as exc:
error = {"error": str(exc) or type(exc).__name__, "type": type(exc).__name__,
"interrupted": isinstance(exc, KeyboardInterrupt), "traceback": traceback.format_exc()}
write_json(args.out / "failure.json", error)
print(json.dumps(error), flush=True)
finally:
persist_rows(args.out, rows)
completed = error is None and (args.prepare_only or len(rows) == expected_samples)
write_json(args.out / "completion.json", {"completed": completed, "samples": len(rows),
"expected_samples": expected_samples,
"simulation": False, "prepare_only": args.prepare_only,
"finished_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
seal_results(args.out)
return (130 if error.get("interrupted") else 1) if error else (0 if completed else 1)
if __name__ == "__main__":
sys.exit(main())
-180
View File
@@ -1,180 +0,0 @@
"""Cache and response-processing paths must survive disabled-path optimization."""
from concurrent.futures import ThreadPoolExecutor
import copy
import gzip
import io
import json
import time
import zlib
from common import http
def run(check, rust, peer):
port = rust.reverse_port
before = peer.count("GET", "/fresh")
results = [http(port, "/fresh") for _ in range(2)]
check("cache.global_off", all(s == 200 and h.get("x-cache") != "HIT" for s, h, b in results)
and peer.count("GET", "/fresh") - before == 2)
config = copy.deepcopy(rust.base)
config["cache"] = {"enabled": True, "expose_status": True, "expose_hits": True,
"expose_cache_status": True, "http_invalidation_allowed_cidrs": ["127.0.0.1/32"]}
config["web_services"][0]["cache_control"] = "public, max-age=60"
rust.apply(config, "cache-enabled")
first = http(port, "/fresh")
second = http(port, "/fresh")
check("cache.miss_then_hit", first[0] == 200 and first[1].get("x-cache") == "MISS"
and second[1].get("x-cache") == "HIT" and first[2] == second[2],
first=first[1].get("x-cache"), second=second[1].get("x-cache"))
results = {}
for language in ["en", "pl", "en", "pl"]:
response = http(port, "/vary", headers={"Accept-Language": language})
previous = results.get(language)
if previous:
check("cache.vary_hit." + language, response[1].get("x-cache") == "HIT" and response[2] == previous[2],
cache=response[1].get("x-cache"))
results[language] = response
check("cache.vary_isolation", results["en"][2] != results["pl"][2])
for path in ["/private", "/no-store", "/cookie"]:
before = peer.count("GET", path)
first, second = http(port, path), http(port, path)
check("cache.nonpublic_bypass." + path, first[0] == second[0] == 200
and first[1].get("x-cache") != "HIT" and second[1].get("x-cache") != "HIT"
and peer.count("GET", path) - before == 2,
first=first[1].get("x-cache"), second=second[1].get("x-cache"))
for name, headers in [("authorization", {"Authorization": "Bearer fixture-value"}),
("cookie", {"Cookie": "application_session=fixture"})]:
before = peer.count("GET", "/fresh")
s, h, b = http(port, "/fresh", headers=headers)
check("cache.request_bypass." + name, s == 200 and h.get("x-cache") != "HIT"
and peer.count("GET", "/fresh") - before == 1, cache=h.get("x-cache"))
before = peer.count("GET", "/fresh")
s, h, b = http(port, "/fresh", "GET", {"Content-Type": "application/octet-stream"}, b"nonempty-get-body")
check("cache.nonempty_get_bypass_and_preserve", s == 200 and h.get("x-cache") != "HIT"
and h.get("x-received-body-bytes") == "17" and peer.count("GET", "/fresh") - before == 1,
cache=h.get("x-cache"), origin_body_bytes=h.get("x-received-body-bytes"))
s, h, b = http(port, "/fresh", "PURGE")
after = http(port, "/fresh")
check("cache.http_purge", s in (200, 202, 204) and after[1].get("x-cache") == "MISS", status=s)
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda _: http(port, "/concurrent"), range(8)))
check("cache.concurrent_fill", all(item[0] == 200 for item in results)
and len({item[2] for item in results}) == 1, origin_requests=peer.count("GET", "/concurrent"))
first = http(port, "/revalidate")
time.sleep(1.2)
second = http(port, "/revalidate")
check("cache.expired_validator_revalidation", first[0] == second[0] == 200 and first[2] == second[2]
and peer.count("GET", "/revalidate") >= 2, cache=second[1].get("x-cache"))
first, second = http(rust.static_port), http(rust.static_port)
check("cache.static_hit", first[0] == second[0] == 200 and second[1].get("x-cache") == "HIT"
and first[2] == second[2], cache=second[1].get("x-cache"))
empty_first, empty_second = http(rust.static_port, "/empty.txt"), http(rust.static_port, "/empty.txt")
check("cache.static_empty_hit", empty_first[0] == empty_second[0] == 200
and empty_second[1].get("x-cache") == "HIT" and empty_second[2] == b"",
first=empty_first[1].get("x-cache"), second=empty_second[1].get("x-cache"))
hs, hh, hb = http(rust.static_port, "/download.txt", "HEAD")
gs, gh, gb = http(rust.static_port, "/download.txt")
check("cache.static_head_cannot_fill_empty_get", hs == gs == 200 and hb == b""
and gb == (rust.fixture.site / "download.txt").read_bytes(), head_status=hs, get_status=gs)
s, h, b = http(rust.static_port, headers={"Range": "bytes=0-9"})
check("cache.static_range_survives", s == 206 and b == first[2][:10], status=s)
s, h, b = http(rust.static_port, headers={"If-None-Match": first[1]["etag"]})
check("cache.static_conditional_survives", s == 304 and b == b"", status=s)
config["vhosts"][0]["cache"] = {"enabled": False}
rust.apply(config, "cache-vhost-disabled")
first, second = http(port, "/fresh"), http(port, "/fresh")
check("cache.vhost_off", all(response[1].get("x-cache") != "HIT" for response in [first, second])
and first[2] != second[2])
config["vhosts"][0]["cache"] = {"enabled": True}
config["vhosts"][0]["routes"] = [{"id": "fresh-no-cache", "path_prefix": "/fresh", "cache": False}]
rust.apply(config, "cache-route-disabled")
first, second = http(port, "/fresh"), http(port, "/fresh")
check("cache.route_off", all(response[1].get("x-cache") != "HIT" for response in [first, second])
and first[2] != second[2])
config["cache"]["enabled"] = False
config["vhosts"][0]["routes"] = []
rust.apply(config, "cache-global-disabled-vhost-enabled")
first, second = http(port, "/fresh"), http(port, "/fresh")
check("cache.vhost_cannot_override_global_off", first[2] != second[2]
and second[1].get("x-cache") != "HIT")
def transforms(check, rust, peer):
config = copy.deepcopy(rust.base)
original = (rust.fixture.site / "app.json").read_bytes()
for port, source in [(rust.reverse_port, "proxy"), (rust.static_port, "static")]:
s, h, b = http(port, "/app.json")
check("transform.off." + source, s == 200 and b == original, status=s)
config["transform"] = {"enabled": True, "json": True, "html": True}
for vhost in config["vhosts"]:
vhost["transform"] = {"enabled": True, "json": True, "html": True}
rust.apply(config, "transform-enabled-cache-off")
for port, source in [(rust.reverse_port, "proxy"), (rust.static_port, "static")]:
s, h, b = http(port, "/app.json")
check("transform.on." + source, s == 200 and json.loads(b) == json.loads(original)
and len(b) < len(original), original_bytes=len(original), result_bytes=len(b), status=s)
s, h, b = http(port, "/large.bin")
check("transform.binary_unchanged." + source, s == 200 and b == (rust.fixture.site / "large.bin").read_bytes())
s, h, b = http(port, "/app.json", headers={"Range": "bytes=0-9"})
check("transform.range_exclusion." + source, s == 206 and b == original[:10], status=s)
config["cache"] = {"enabled": True, "expose_status": True}
rust.apply(config, "transform-enabled-cache-on")
first, second = http(rust.reverse_port, "/app.json"), http(rust.reverse_port, "/app.json")
check("transform.cache_combination", second[0] == 200 and second[1].get("x-cache") == "HIT"
and first[2] == second[2] and json.loads(second[2]) == json.loads(original),
cache=second[1].get("x-cache"))
rust.reset()
expected = (rust.fixture.site / "compress.txt").read_bytes()
for port, source in [(rust.reverse_port, "proxy"), (rust.static_port, "static")]:
s, h, b = http(port, "/compress.txt", headers={"Accept-Encoding": "gzip"})
check("compression.gzip." + source, s == 200 and h.get("content-encoding") == "gzip"
and gzip.decompress(b) == expected and len(b) < len(expected), bytes=len(b))
s, h, b = http(port, "/compress.txt", headers={"Accept-Encoding": "gzip;q=0, identity"})
check("compression.identity_qvalue." + source, s == 200 and "content-encoding" not in h and b == expected)
s, h, b = http(port, "/compress.txt", headers={"Accept-Encoding": "gzip", "Range": "bytes=0-9"})
check("compression.range_exclusion." + source, s == 206 and "content-encoding" not in h and b == expected[:10])
s, h, b = http(rust.reverse_port, "/no-transform", headers={"Accept-Encoding": "gzip"})
check("compression.no_transform_exclusion", s == 200 and "content-encoding" not in h)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["compression"] = {"enabled": False}
rust.apply(config, "compression-vhost-disabled")
s, h, b = http(rust.reverse_port, "/compress.txt", headers={"Accept-Encoding": "gzip"})
check("compression.vhost_off", s == 200 and "content-encoding" not in h and b == expected)
config["compression"] = {"gzip": False, "br": False, "zstd": False, "deflate": False}
config["vhosts"][0]["compression"] = {"enabled": True, "gzip": True}
rust.apply(config, "compression-global-disabled")
s, h, b = http(rust.reverse_port, "/compress.txt", headers={"Accept-Encoding": "gzip"})
check("compression.global_codec_ceiling", s == 200 and "content-encoding" not in h and b == expected)
config = copy.deepcopy(rust.base)
config["compression"] = {"gzip": True, "br": True, "zstd": True, "deflate": True}
config["cache"] = {"enabled": True, "expose_status": True}
rust.apply(config, "compression-cache-enabled")
for codec in ["gzip", "deflate", "br", "zstd"]:
decoder = None
if codec == "gzip":
decoder = gzip.decompress
elif codec == "deflate":
decoder = zlib.decompress
elif codec == "br":
try:
import brotli
decoder = brotli.decompress
except ImportError:
pass
else:
try:
import zstandard
decoder = lambda data: zstandard.ZstdDecompressor().stream_reader(io.BytesIO(data)).read()
except ImportError:
pass
if decoder is None:
check("compression.codec." + codec, None, skip_reason="optional Python decoder is not installed")
continue
first = http(rust.reverse_port, "/compress.txt", headers={"Accept-Encoding": codec})
second = http(rust.reverse_port, "/compress.txt", headers={"Accept-Encoding": codec})
check("compression.codec." + codec, second[0] == 200 and second[1].get("content-encoding") == codec
and decoder(first[2]) == decoder(second[2]) == expected, cache=second[1].get("x-cache"))
-184
View File
@@ -1,184 +0,0 @@
"""Live ACL, authentication, atomic reload and logging regressions."""
import base64
import copy
import json
import re
import time
from common import http, write_json
DENY = {"default": "deny", "rules": []}
ALLOW_LOCAL = {"default": "deny", "rules": [{"network": "127.0.0.1/32", "action": "allow"}]}
def run(check, rust, peer):
port = rust.reverse_port
config = copy.deepcopy(rust.base)
config["host_policies"] = [{"hosts": ["fixture.test"], "acl": DENY}]
rust.apply(config, "policy-global-host")
count = peer.count("GET", "/echo")
s, h, b = http(port, "/echo", headers={"Host": "fixture.test"})
check("acl.global_host_without_vhost_acl", s == 403 and peer.count("GET", "/echo") == count,
status=s, upstream_requests=peer.count("GET", "/echo") - count)
s, h, b = http(port, "/echo", headers={"Host": "localhost"})
check("acl.global_other_host_unchanged", s == 200, status=s)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["acl"] = DENY
rust.apply(config, "policy-vhost-deny")
s, h, b = http(port)
check("acl.vhost_deny", s == 403, status=s)
s, h, b = http(rust.static_port)
check("acl.vhost_isolation", s == 200, status=s)
config["vhosts"][0]["acl"] = ALLOW_LOCAL
rust.apply(config, "policy-vhost-allow")
s, h, b = http(port)
check("acl.hot_enable_allow", s == 200, status=s)
config = copy.deepcopy(rust.base)
config["acl_profiles"] = [{"id": "restricted", "name": "Restricted", "acl": DENY}]
config["vhosts"][0]["acl_profile_id"] = "restricted"
config["vhosts"][0]["acl"] = ALLOW_LOCAL
rust.apply(config, "policy-profile-deny")
s, h, b = http(port)
check("acl.profile_and_vhost_stack", s == 403, status=s)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["routes"] = [{"id": "denied", "path_prefix": "/echo", "acl": DENY}]
rust.apply(config, "policy-location-deny")
count = peer.count("GET", "/echo")
s, h, b = http(port, "/echo")
check("acl.location_before_upstream", s == 403 and peer.count("GET", "/echo") == count, status=s)
s, h, b = http(port)
check("acl.location_other_path_unchanged", s == 200, status=s)
config["vhosts"][0]["acl"] = DENY
config["vhosts"][0]["routes"][0]["acl"] = ALLOW_LOCAL
rust.apply(config, "policy-location-allow-vhost-deny")
s, h, b = http(port, "/echo")
check("acl.location_cannot_override_vhost_deny", s == 403, status=s)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["acl"] = {"default": "deny", "rules": [{"network": "203.0.113.22/32", "action": "allow"}]}
rust.apply(config, "policy-untrusted-forwarded")
s, h, b = http(port, headers={"X-Forwarded-For": "203.0.113.22", "X-Real-IP": "203.0.113.22"})
check("acl.spoofed_forwarded_ip_denied", s == 403, status=s)
config["forwarding"] = {"trusted_proxies": ["127.0.0.1/32"]}
rust.apply(config, "policy-trusted-forwarded")
s, h, b = http(port, headers={"X-Forwarded-For": "203.0.113.22"})
check("acl.trusted_forwarded_ip_allowed", s == 200, status=s)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["rate_limit"] = {"country_access": {"mode": "allow", "countries": ["PL"]}}
config["vhosts"][0]["cloudflare_client_ip"] = True
rust.apply(config, "policy-country")
s, h, b = http(port, headers={"CF-IPCountry": "PL", "CF-Connecting-IP": "203.0.113.22"})
check("acl.untrusted_country_header_cannot_allow", s == 403, status=s)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["method_policy"] = {"mode": "allow", "methods": ["GET", "HEAD"]}
rust.apply(config, "policy-method")
s, h, b = http(port, "/echo", "POST", body=b"denied method")
check("policy.method_allowlist", s in (403, 405), status=s)
s, h, b = http(port)
check("policy.allowed_method_survives", s == 200, status=s)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["rate_limit"] = {"enabled": True, "requests": 2, "window_seconds": 60, "burst": 0}
rust.apply(config, "policy-rate-limit")
statuses = [http(port)[0] for _ in range(3)]
check("policy.rate_limit", statuses[:2] == [200, 200] and statuses[2] == 429, statuses=statuses)
rust.reset()
s, h, b = http(port)
check("policy.hot_disable_restores_request", s == 200, status=s)
# Every route check uses the real admin socket and no bearer/backdoor token.
for path in ["/api/config", "/api/status", "/api/logs", "/api/events", "/api/openapi.json", "/assets/app.js"]:
s, h, b = http(rust.admin_port, path)
check("admin.private." + path, s in (401, 403), status=s)
check("admin.login", rust.login() == 200)
s, h, b = rust.admin("/api/config")
envelope = json.loads(b)
check("admin.authenticated_config", s == 200 and "config" in envelope and "revision" in envelope, status=s)
s, h, b = rust.admin("/api/config", "PUT", {"expected_revision": envelope["revision"], "config": rust.base}, csrf=False)
check("admin.csrf_required", s == 403, status=s)
s, h, b = rust.admin("/api/config", origin="http://foreign.example")
check("admin.foreign_origin_denied", s == 403, status=s)
# Generate the disposable Basic Auth hash through the existing secured API.
s, h, b = rust.admin("/api/basic-auth/hash", "POST", {"username": "fixtureuser", "password": rust.password})
hashed = json.loads(b)
check("basic.hash_api", s == 200 and "password_hash" in hashed, status=s)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["basic_auth"] = {"enabled": True, "require_tls": False,
"users": [{"username": "fixtureuser", "password_hash": hashed["password_hash"]}]}
rust.apply(config, "policy-basic-auth")
s, h, b = http(port, "/echo")
check("basic.challenge", s == 401 and "Basic" in h.get("www-authenticate", ""), status=s)
credentials = base64.b64encode(("fixtureuser:" + rust.password).encode()).decode()
s, h, b = http(port, "/echo", headers={"Authorization": "Basic " + credentials})
check("basic.correct_password_and_strip", s == 200 and "authorization" not in json.loads(b)["headers"], status=s)
s, h, b = http(port, "/echo", headers={"Authorization": "Basic Zml4dHVyZXVzZXI6d3Jvbmc="})
check("basic.wrong_password_denied", s == 401, status=s)
config["vhosts"][0]["acl"] = DENY
rust.apply(config, "policy-acl-before-basic")
s, h, b = http(port, "/echo")
check("basic.acl_precedes_auth", s == 403, status=s)
rust.reset()
config = copy.deepcopy(rust.base)
config["vhosts"][0]["frontend"]["listener_ids"] = ["does-not-exist"]
rust.apply(config, "invalid-config-retains-snapshot", expected_success=False)
s, h, b = http(port)
check("reload.invalid_document_retains_previous_snapshot", s == 200, status=s)
s, h, b = http(rust.static_port)
check("reload.invalid_document_keeps_other_vhost", s == 200, status=s)
config = copy.deepcopy(rust.base)
config["vhosts"][0]["backends"][0]["allow_private"] = False
# Explicit private-address rejection is checked through apply, preserving the
# active configuration if compile-time validation rejects the backend.
rust.apply(config, "ssrf-private-origin-disallowed", expected_success=False)
s, h, b = http(port)
check("ssrf.rejected_origin_does_not_replace_snapshot", s == 200, status=s)
query = "/echo?observability=" + rust.label
s, h, b = http(port, query)
request_id = json.loads(b)["headers"]["x-request-id"]
log = None
for _ in range(20):
ls, lh, lb = rust.admin("/api/logs?vhost=reverse&limit=512")
logs = json.loads(lb)
log = next((entry for entry in logs.get("entries", []) if entry.get("request_id") == request_id), None)
if log:
break
time.sleep(.05)
check("observability.log_fields_and_body_bytes", log is not None and log.get("status") == 200
and log.get("response_bytes") == len(b) and log.get("request_bytes") == 0
and log.get("cache_effective_enabled") is False and log.get("transform_effective_enabled") is False
and bool(re.fullmatch("[0-9a-f]{64}", log.get("request_id", ""))),
recorded_status=log.get("status") if log else None,
recorded_response_bytes=log.get("response_bytes") if log else None, response_bytes=len(b))
if log:
write_json(rust.fixture.results / f"{rust.label}-sample-exchange.json", log)
config = copy.deepcopy(rust.base)
config["diagnostic_headers"] = {"request_id": True}
rust.apply(config, "observability-static-eos")
s, h, b = http(rust.static_port)
static_request_id = h.get("x-request-id")
static_log = None
for _ in range(20):
ls, lh, lb = rust.admin("/api/logs?vhost=static&limit=64")
static_log = next((entry for entry in json.loads(lb).get("entries", [])
if entry.get("request_id") == static_request_id), None)
if static_log:
break
time.sleep(.05)
check("observability.static_complete_after_content_length", s == 200 and static_log is not None
and static_log.get("outcome") == "complete" and static_log.get("response_bytes") == len(b),
outcome=static_log.get("outcome") if static_log else None,
recorded_response_bytes=static_log.get("response_bytes") if static_log else None,
response_bytes=len(b))
if static_log:
write_json(rust.fixture.results / f"{rust.label}-sample-static-exchange.json", static_log)
s, h, b = rust.admin("/api/status")
status = json.loads(b)
write_json(rust.fixture.results / f"{rust.label}-runtime-status.json", status)
check("observability.status_available", s == 200, status=s)
-135
View File
@@ -1,135 +0,0 @@
"""Proxy streaming, status, header, request-ID and WebSocket tests."""
import copy
import hashlib
from http.client import HTTPConnection, HTTPResponse
import json
import re
import socket
import time
from common import http
def run(check, rust, peer):
port = rust.reverse_port
for name in ["small.html", "large.bin", "huge.bin"]:
expected = (rust.fixture.site / name).read_bytes()
s, h, b = http(port, "/" + name)
check("proxy.get." + name, s == 200 and b == expected, status=s, bytes=len(b))
s, h, b = http(port, "/" + name, "HEAD")
check("proxy.head." + name, s == 200 and b == b"" and h.get("content-length") == str(len(expected)), status=s)
s, h, b = http(port, "/large.bin", headers={"Range": "bytes=0-127"})
check("proxy.range", s == 206 and b == bytes(range(128)), status=s, bytes=len(b))
s, h, b = http(port)
s, h, b = http(port, headers={"If-None-Match": h["etag"]})
check("proxy.conditional304", s == 304 and b == b"", status=s)
for status in [201, 400, 404, 500, 503]:
s, h, b = http(port, f"/status/{status}")
check(f"proxy.origin_status.{status}", s == status and b == b"origin status payload\n"
and h.get("x-origin-fixture") == "yes", status=s)
s, h, b = http(port, "/redirect")
check("proxy.redirect", s == 302 and h.get("location") == "/small.html", status=s)
payload = bytes(range(256)) * 4096
ids = []
for method in ["POST", "PUT"]:
s, h, b = http(port, "/echo?query=preserved", method,
{"Content-Type": "application/octet-stream", "X-Request-ID": "untrusted-client-id"}, payload)
data = json.loads(b)
request_id = data["headers"].get("x-request-id", "")
ids.append(request_id)
check("proxy.upload." + method, s == 200 and data["method"] == method
and data["path"] == "/echo?query=preserved" and data["body_bytes"] == len(payload)
and data["body_sha256"] == hashlib.sha256(payload).hexdigest(), status=s, bytes=data["body_bytes"])
check("proxy.request_id." + method, bool(re.fullmatch("[0-9a-f]{64}", request_id)),
valid_64_hex=bool(re.fullmatch("[0-9a-f]{64}", request_id)))
check("proxy.request_id_unique", len(set(ids)) == 2)
s, h, b = http(port, "/echo", "GET", {"Content-Type": "application/octet-stream"}, b"nonempty-get-body")
data = json.loads(b)
check("proxy.nonempty_get_body_preserved", s == 200 and data["body_bytes"] == 17
and data["body_sha256"] == hashlib.sha256(b"nonempty-get-body").hexdigest(), status=s)
peer.started_upload.clear()
first, last = b"first streamed chunk" * 64, b"final streamed chunk" * 64
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
sock.sendall((f"POST /stream-upload HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
"Transfer-Encoding: chunked\r\nContent-Type: application/octet-stream\r\n\r\n").encode())
sock.sendall(f"{len(first):x}\r\n".encode() + first + b"\r\n")
forwarded_before_end = peer.started_upload.wait(2)
sock.sendall(f"{len(last):x}\r\n".encode() + last + b"\r\n0\r\n\r\n")
response = HTTPResponse(sock)
response.begin()
data = json.loads(response.read())
check("proxy.chunked_streaming_upload", forwarded_before_end and response.status == 200
and data["body_sha256"] == hashlib.sha256(first + last).hexdigest(),
first_chunk_arrived_before_end=forwarded_before_end, status=response.status)
peer.release_stream.clear()
connection = HTTPConnection("127.0.0.1", port, timeout=3)
connection.request("GET", "/stream-download")
started = time.monotonic()
response = connection.getresponse()
first = response.read(5)
first_seconds = time.monotonic() - started
peer.release_stream.set()
last = response.read()
connection.close()
check("proxy.streaming_response", response.status == 200 and first == b"first" and last == b"last"
and first_seconds < 2, first_chunk_seconds=first_seconds)
peer.release_stream.clear()
connection = HTTPConnection("127.0.0.1", port, timeout=3)
connection.request("GET", "/stream-download")
response = connection.getresponse()
response.read(5)
connection.close()
peer.release_stream.set()
s, h, b = http(port)
check("proxy.client_disconnect_survives", s == 200, status=s)
before = peer.count("POST", "/no-replay")
s, h, b = http(port, "/no-replay", "POST", {"Content-Type": "application/octet-stream"}, b"write-once")
after = peer.count("POST", "/no-replay")
check("proxy.failed_post_not_replayed", s in (502, 503, 504) and after - before == 1,
status=s, upstream_writes=after - before)
config = copy.deepcopy(rust.base)
config["headers"] = {"request": [{"op": "set", "name": "X-Fixture-Global", "value": "global"}],
"response": [{"op": "set", "name": "X-Fixture-Global", "value": "response"}]}
config["vhosts"][0]["headers"] = {"request": [{"op": "set", "name": "X-Fixture-Vhost", "value": "vhost"}]}
config["vhosts"][0]["routes"] = [{"id": "echo", "path_prefix": "/echo", "headers": {
"request": [{"op": "set", "name": "X-Fixture-Route", "value": "route"}],
"response": [{"op": "set", "name": "X-Fixture-Route", "value": "response"}]}}]
rust.apply(config, "proxy-headers")
s, h, b = http(port, "/echo", headers={"Connection": "keep-alive, x-hop-fixture", "X-Hop-Fixture": "must-be-stripped"})
observed = json.loads(b)["headers"]
check("proxy.header_scopes", s == 200 and observed.get("x-fixture-global") == "global"
and observed.get("x-fixture-vhost") == "vhost" and observed.get("x-fixture-route") == "route"
and h.get("x-fixture-global") == "response" and h.get("x-fixture-route") == "response", status=s)
check("proxy.hop_by_hop_removed", "x-hop-fixture" not in observed)
s, h, b = http(port, "/echo", headers={"X-Forwarded-For": "203.0.113.22", "X-Real-IP": "203.0.113.22"})
observed = json.loads(b)["headers"]
check("proxy.untrusted_forwarded_ip", observed.get("x-real-ip") == "127.0.0.1"
and "203.0.113.22" not in observed.get("x-forwarded-for", ""),
observed_x_real_ip=observed.get("x-real-ip"), observed_x_forwarded_for=observed.get("x-forwarded-for"))
rust.reset()
with socket.create_connection(("127.0.0.1", port), timeout=4) as sock:
sock.sendall((f"GET /ws HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n").encode())
buffer = b""
while b"\r\n\r\n" not in buffer:
piece = sock.recv(4096)
if not piece:
break
buffer += piece
upgraded = buffer.startswith(b"HTTP/1.1 101")
echoed = b""
if upgraded:
message, mask = b"hello", b"\x11\x22\x33\x44"
sock.sendall(bytes([0x81, 0x80 | len(message)]) + mask +
bytes(c ^ mask[i % 4] for i, c in enumerate(message)))
frame = sock.recv(2)
if len(frame) == 2:
echoed = sock.recv(frame[1] & 127)
check("proxy.websocket_echo", upgraded and echoed == b"hello", upgraded=upgraded, bytes=len(echoed))
-132
View File
@@ -1,132 +0,0 @@
"""Live static-file regression cases against RustProxy's network listener."""
import copy
from email.parser import BytesParser
from email.policy import default
import gzip
from common import http
def run(check, rust, peer):
port, fixture = rust.static_port, rust.fixture
for name in ["small.html", "large.bin", "huge.bin", "empty.txt"]:
expected = (fixture.site / name).read_bytes()
status, headers, body = http(port, "/" + name)
check(f"static.get.{name}", status == 200 and body == expected,
status=status, bytes=len(body))
head_status, head_headers, head_body = http(port, "/" + name, "HEAD")
check(f"static.head.{name}", head_status == 200 and head_body == b""
and head_headers.get("content-length") == str(len(expected)),
status=head_status, content_length=head_headers.get("content-length"))
status, headers, body = http(port)
tag = headers.get("etag")
modified = headers.get("last-modified")
check("static.validators_present", bool(tag and modified), etag=tag, last_modified=modified)
for label, condition in [("etag", {"If-None-Match": tag}),
("modified", {"If-Modified-Since": modified})]:
s, h, b = http(port, headers=condition)
check("static.conditional304." + label, s == 304 and b == b"", status=s, bytes=len(b))
s, h, b = http(port, headers={"If-None-Match": '"missing"'})
check("static.conditional_miss", s == 200 and b == body, status=s)
data = (fixture.site / "large.bin").read_bytes()
cases = [("prefix", "bytes=0-127", data[:128], "bytes 0-127/65536"),
("suffix", "bytes=-37", data[-37:], "bytes 65499-65535/65536"),
("open", "bytes=65500-", data[65500:], "bytes 65500-65535/65536")]
for name, range_header, expected, content_range in cases:
s, h, b = http(port, "/large.bin", headers={"Range": range_header})
check("static.range." + name, s == 206 and b == expected
and h.get("content-range") == content_range,
status=s, bytes=len(b), content_range=h.get("content-range"))
s, h, b = http(port, "/large.bin", headers={"Range": "bytes=999999-"})
check("static.range.unsatisfiable", s == 416 and h.get("content-range") == "bytes */65536",
status=s, content_range=h.get("content-range"))
s, h, b = http(port, "/large.bin", headers={"Range": "bytes=0-15,100-115"})
mime = BytesParser(policy=default).parsebytes(
b"Content-Type: " + h.get("content-type", "").encode() + b"\r\n\r\n" + b)
parts = list(mime.iter_parts())
check("static.range.multipart", s == 206 and len(parts) == 2
and parts[0].get_payload(decode=True) == data[:16]
and parts[1].get_payload(decode=True) == data[100:116], status=s, parts=len(parts))
for name, value, expected_status in [("match", tag, 206), ("mismatch", '"other"', 200)]:
s, h, b = http(port, headers={"Range": "bytes=0-15", "If-Range": value})
expected = body[:16] if expected_status == 206 else body
check("static.if_range." + name, s == expected_status and b == expected,
status=s, bytes=len(b))
s, h, b = http(port, "/directory")
check("static.directory_slash", s in (301, 308) and h.get("location", "").endswith("/directory/"),
status=s, location=h.get("location"))
s, h, b = http(port, "/directory/")
check("static.directory_index", s == 200 and b == b"directory index\n", status=s)
s, h, b = http(port, "/listing/")
check("static.directory_listing_denied", s == 403, status=s)
for path in ["/escape-link.txt", "/inside-link.txt"]:
s, h, b = http(port, path)
check("static.symlink_deny." + path, s == 403 and b"MUST-NOT" not in b, status=s)
for path in ["/%2e%2e/outside-secret.txt", "/%2fetc/passwd", "/%252e%252e/outside-secret.txt",
"/%00", "/..%5coutside-secret.txt"]:
s, h, b = http(port, path)
check("static.invalid_path." + path, s in (400, 403, 404) and b"MUST-NOT" not in b,
status=s)
config = copy.deepcopy(rust.base)
service = config["web_services"][0]
service["symlink_policy"] = "inside_root"
service["directory_listing"] = True
rust.apply(config, "static-inside-root")
s, h, b = http(port, "/inside-link.txt")
check("static.symlink_inside_root_allowed", s == 200 and b == b"download fixture\n", status=s)
s, h, b = http(port, "/escape-link.txt")
check("static.symlink_inside_root_escape_denied", s == 403 and b"MUST-NOT" not in b, status=s)
s, h, b = http(port, "/listing/")
check("static.directory_listing_enabled", s == 200 and b"visible.txt" in b, status=s)
# Alternate a symlink target between valid and outside-root files. The file
# read must stay confined even when a pathname changes between requests.
link = fixture.site / "changing-link.txt"
race_statuses = []
escaped = False
for index in range(12):
if link.is_symlink():
link.unlink()
link.symlink_to(fixture.outside if index % 2 else fixture.site / "download.txt")
s, h, b = http(port, "/changing-link.txt")
race_statuses.append(s)
escaped = escaped or b"MUST-NOT" in b
check("static.changing_symlink_confinement", not escaped and all(s in (200, 403) for s in race_statuses),
statuses=race_statuses, note="Sequential target replacement; not a concurrent rename-race proof")
config = copy.deepcopy(rust.base)
config["web_services"][0]["precompressed"] = {"enabled": True, "gzip": True, "brotli": False}
rust.apply(config, "static-precompressed")
s, h, b = http(port, headers={"Accept-Encoding": "gzip"})
sidecar = (fixture.site / "small.html.gz").read_bytes()
check("static.precompressed_gzip", s == 200 and h.get("content-encoding") == "gzip"
and b == sidecar and gzip.decompress(b) == body, status=s, bytes=len(b))
s, h, b = http(port, headers={"Accept-Encoding": "gzip", "Range": "bytes=0-9"})
check("static.precompressed_range", s == 206 and b == sidecar[:10], status=s, bytes=len(b))
config = copy.deepcopy(rust.base)
config["web_services"][0]["try_files"] = ["$uri", "/small.html"]
config["web_services"][0]["rewrites"] = [{"pattern": "^/old$", "replacement": "/small.html", "status": 302}]
config["web_services"][0]["download_extensions"] = ["txt"]
config["web_services"][0]["mime_types"] = {"bin": "application/x-fixture"}
rust.apply(config, "static-routing")
s, h, b = http(port, "/spa/deep/link")
check("static.try_files_spa", s == 200 and b == body, status=s)
s, h, b = http(port, "/old")
check("static.rewrite_redirect", s == 302 and h.get("location") == "/small.html", status=s)
s, h, b = http(port, "/download.txt")
check("static.download_header", s == 200 and "attachment" in h.get("content-disposition", ""), status=s)
s, h, b = http(port, "/large.bin")
check("static.mime_override", s == 200 and h.get("content-type") == "application/x-fixture", status=s)
config = copy.deepcopy(rust.base)
(fixture.site / ".htaccess").write_text("Header set X-Fixture-Htaccess yes\nDirectoryIndex small.html\n")
config["web_services"][0]["htaccess"] = {"enabled": True, "strict": True}
rust.apply(config, "static-htaccess")
s, h, b = http(port)
check("static.htaccess_header", s == 200 and h.get("x-fixture-htaccess") == "yes", status=s)
s, h, b = http(port, "/.htaccess")
check("static.htaccess_not_served", s in (403, 404) and b"Header set" not in b, status=s)
-448
View File
@@ -1,448 +0,0 @@
#!/usr/bin/env python3
"""Disposable, loopback-only fixtures for testing actual RustProxy processes.
Runtime databases and credentials live in TemporaryDirectory, never in results.
No pre-existing SQLite database, fixed port, or historical /mnt/data path is used.
"""
from __future__ import annotations
import copy
import gzip
import hashlib
from http.client import HTTPConnection, HTTPException
import json
import os
from pathlib import Path
import pwd
import secrets
import shutil
import signal
import socket
import subprocess
import tempfile
import time
def sha256(path: Path) -> str:
result = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
result.update(block)
return result.hexdigest()
def write_json(path: Path, value) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
def seal_results(directory: Path) -> None:
"""Checksums cover every regular evidence file, except the checksum manifest."""
paths = sorted(p for p in directory.rglob("*") if p.is_file()
and p.name != "SHA256SUMS")
(directory / "SHA256SUMS").write_text("".join(
f"{sha256(path)} {path.relative_to(directory)}\n" for path in paths))
def http(port: int, path="/small.html", method="GET", headers=None, body=None,
timeout=10, source_address=None):
connection = HTTPConnection("127.0.0.1", port, timeout=timeout,
source_address=source_address)
try:
connection.request(method, path, body, headers or {})
response = connection.getresponse()
payload = response.read()
return response.status, {k.lower(): v for k, v in response.getheaders()}, payload
finally:
connection.close()
def free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def wait_ready(port: int, process: subprocess.Popen, timeout=30):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError(f"process {process.pid} exited with {process.returncode}")
try:
http(port, "/", timeout=.5)
return
except (OSError, HTTPException):
time.sleep(.05)
raise RuntimeError(f"loopback listener {port} did not start")
class Fixture:
def __init__(self, results: Path, workers=4, temp_parent: Path | None = None,
server_cpus=None, origin_cpus=None, origin_workers=None):
self.results = results.resolve()
self.results.mkdir(parents=True, exist_ok=True)
# Keep live state outside the evidence tree. Even an interrupted run or
# workspace synchronization must never put databases/keys in results.
self.temp = tempfile.TemporaryDirectory(prefix="rustproxy-perf-", dir=temp_parent)
self.root = Path(self.temp.name)
self.root.chmod(0o755) # nginx workers must traverse the public fixture root
self.site = self.root / "site"
self.site.mkdir(mode=0o755)
self.workers = workers
self.server_cpus = server_cpus
self.origin_cpus = origin_cpus
self.origin_workers = origin_workers or workers
self.children = []
self.handles = []
self.ports = set()
self.origin_port = self.port()
self.make_site()
def port(self):
while True:
port = free_port()
if port not in self.ports:
self.ports.add(port)
return port
def make_site(self):
# Exact, reproducible payload sizes match the original report's cases.
small = b"<!doctype html><html><body>RustProxy benchmark "
small += b"x" * (1040 - len(small) - len(b"</body></html>"))
small += b"</body></html>"
files = {"small.html": small,
"large.bin": bytes(range(256)) * 256,
"huge.bin": bytes(range(256)) * 4096,
"empty.txt": b"",
"app.json": (b'{\n "message": "stable fixture",\n "values": [1, 2, 3]\n}\n'),
"app.css": b"/* fixture comment */\nbody { color: red; padding: 0px; }\n",
"compress.txt": (b"compressible RustProxy text fixture. " * 4096),
"download.txt": b"download fixture\n"}
for name, body in files.items():
(self.site / name).write_bytes(body)
(self.site / "small.html.gz").write_bytes(gzip.compress(small, mtime=0))
(self.site / "directory").mkdir()
(self.site / "directory" / "index.html").write_bytes(b"directory index\n")
(self.site / "listing").mkdir()
(self.site / "listing" / "visible.txt").write_bytes(b"listing fixture\n")
(self.site / "inside-link.txt").symlink_to(self.site / "download.txt")
self.outside = self.root / "outside-secret.txt"
self.outside.write_bytes(b"MUST-NOT-BE-SERVED-OUTSIDE-ROOT\n")
(self.site / "escape-link.txt").symlink_to(self.outside)
write_json(self.results / "payloads.json", {
name: {"bytes": len(body), "sha256": hashlib.sha256(body).hexdigest()}
for name, body in files.items()})
def spawn(self, argv, name, env=None, affinity=None):
log = (self.results / f"{name}.log").open("w")
self.handles.append(log)
if affinity:
argv = ["taskset", "-c", affinity, *argv]
child = subprocess.Popen([str(x) for x in argv], stdout=log,
stderr=subprocess.STDOUT, env=env, start_new_session=True)
self.children.append(child)
return child
@staticmethod
def stop(process):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=12)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=3)
def close(self):
for process in reversed(self.children):
self.stop(process)
for handle in self.handles:
handle.close()
self.temp.cleanup()
write_json(self.results / "fixture-cleanup.json", {
"temporary_state_removed": not self.root.exists(),
"state_was_outside_results": not self.root.is_relative_to(self.results)})
if self.root.exists():
raise RuntimeError("temporary runtime state was not removed; do not package the fixture directory")
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def base_config(self, reverse_port, static_port):
endpoints = []
for label, port in [("reverse", reverse_port), ("static", static_port)]:
endpoints.append({"id": label, "name": label, "scope": "internal",
"enabled": True, "binding": {
"address": f"127.0.0.1:{port}", "tls": False,
"http2": False, "http3": None}})
return {
"cache": {"enabled": False},
"geoip_updates": {"enabled": False},
"listeners": {"endpoints": endpoints},
"web_services": [{"id": "fixture-static", "name": "Fixture static",
"document_root": str(self.site), "index": ["index.html", "small.html"],
"directory_listing": False}],
"vhosts": [{"id": "reverse", "frontend": {
"hosts": ["127.0.0.1", "localhost", "fixture.test"],
"listener_ids": ["reverse"]},
"backends": [{"id": "origin", "url": f"http://127.0.0.1:{self.origin_port}",
"allow_private": True, "preserve_host": False}],
"load_balancing": {"health_check": {"mode": "off"}}, "routes": []},
{"id": "static", "frontend": {
"hosts": ["127.0.0.1", "localhost", "fixture.test"],
"listener_ids": ["static"]},
"source": {"type": "web_server", "web_service_id": "fixture-static"},
"routes": []}]
}
def rust(self, binary: Path, label: str, max_idle=None):
return RustInstance(self, binary.resolve(), label, max_idle=max_idle)
def nginx(self, executable: Path, frontend=True):
"""Use a separate nginx master for the shared origin and comparator."""
executable = executable.resolve()
result = subprocess.run([str(executable), "-V"], capture_output=True, text=True, timeout=10)
(self.results / "nginx-version.txt").write_text(result.stdout + result.stderr)
origin_conf = self.nginx_config("nginx-origin", f"""
server {{ listen 127.0.0.1:{self.origin_port}; server_name _;
root "{self.site}"; location / {{ add_header Cache-Control "public, max-age=60"; }}
}}
""", workers=self.origin_workers)
origin = self.spawn([executable, "-p", self.root, "-c", origin_conf], "nginx-origin",
affinity=self.origin_cpus)
wait_ready(self.origin_port, origin)
result = {"origin": origin, "origin_port": self.origin_port}
if frontend:
reverse_port, static_port = self.port(), self.port()
conf = self.nginx_config("nginx-frontend", f"""
upstream fixture_origin {{ server 127.0.0.1:{self.origin_port}; keepalive 64; }}
server {{ listen 127.0.0.1:{reverse_port}; server_name _;
location / {{ proxy_pass http://fixture_origin; proxy_http_version 1.1;
proxy_set_header Connection ""; proxy_buffering off; }}
}}
server {{ listen 127.0.0.1:{static_port}; server_name _; root "{self.site}"; }}
""")
process = self.spawn([executable, "-p", self.root, "-c", conf], "nginx-frontend",
affinity=self.server_cpus)
wait_ready(reverse_port, process)
wait_ready(static_port, process)
result.update({"process": process, "reverse_port": reverse_port,
"static_port": static_port})
return result
def nginx_config(self, name, servers, workers=None):
path = self.root / f"{name}.conf"
text = f"""daemon off;
master_process on;
user {pwd.getpwuid(os.geteuid()).pw_name};
worker_processes {workers or self.workers};
pid {self.root}/{name}.pid;
error_log stderr warn;
events {{ worker_connections 8192; }}
http {{
access_log off;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
keepalive_requests 1000000;
default_type application/octet-stream;
types {{ text/html html; text/css css; application/json json; text/plain txt; }}
client_body_temp_path {self.root}/{name}-body;
proxy_temp_path {self.root}/{name}-proxy;
{servers}
}}
"""
path.write_text(text)
(self.results / path.name).write_text(text)
return path
class RustInstance:
def __init__(self, fixture, binary, label, max_idle=None):
self.fixture, self.binary, self.label = fixture, binary, label
if not os.access(binary, os.X_OK):
target = fixture.root / f"{label}-rust-proxy"
shutil.copyfile(binary, target)
target.chmod(0o700)
self.binary = target
self.admin_port, self.reverse_port, self.static_port = (fixture.port() for _ in range(3))
self.data = fixture.root / f"{label}-data"
self.base = fixture.base_config(self.reverse_port, self.static_port)
if max_idle is not None:
self.base["vhosts"][0]["load_balancing"]["keepalive"] = {"max_idle_per_host": max_idle}
self.password = "Fixture!" + secrets.token_hex(20)
self.username = "performance"
self.env = os.environ.copy()
self.env["TOKIO_WORKER_THREADS"] = str(fixture.workers)
self.env["RUST_PROXY_PROXY_LISTEN"] = f"127.0.0.1:{self.reverse_port}"
self.env["RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS"] = "0"
self.env.pop("RUST_PROXY_TLS_LISTEN", None)
self.applies = []
self.cli("admin", "init", "--username", self.username, "--password-stdin",
input=self.password + "\n")
self.start("bootstrap")
self.apply(self.base, "base")
fixture.stop(self.process)
self.start("runtime")
wait_ready(self.static_port, self.process)
version = self.cli("--version").stdout.strip()
write_json(fixture.results / f"{label}-binary.json", {
"label": label, "path": str(binary), "sha256": sha256(binary),
"version": version, "worker_threads": fixture.workers,
"pid": self.process.pid, "protocol": "HTTP/1.1 plaintext loopback",
"cpu_affinity": fixture.server_cpus,
"max_idle_per_host_override": max_idle})
def cli(self, *args, input=None, check=True):
result = subprocess.run([str(self.binary), "--data-dir", str(self.data),
"--admin-listen", f"127.0.0.1:{self.admin_port}", *args],
input=input, text=True, capture_output=True,
env=self.env, timeout=40)
if check and result.returncode:
raise RuntimeError(f"{self.label} CLI {' '.join(args[:2])}: "
f"{result.stdout[-2000:]} {result.stderr[-3000:]}")
return result
def start(self, phase):
self.process = self.fixture.spawn([
self.binary, "--data-dir", self.data, "--admin-listen", f"127.0.0.1:{self.admin_port}",
"--update-check-interval-secs", "0", "serve", "--proxy-listen",
f"127.0.0.1:{self.reverse_port}", "--max-connections", "4096", "--max-inflight", "4096"],
f"{self.label}-{phase}", env=self.env, affinity=self.fixture.server_cpus)
wait_ready(self.admin_port, self.process)
wait_ready(self.reverse_port, self.process)
def apply(self, config, name, expected_success=True):
config_path = self.fixture.root / f"{self.label}-{name}.json"
write_json(config_path, config)
result = self.cli("config", "apply", str(config_path), check=False)
# Configurations with ephemeral test passwords/hashes are intentionally not copied.
safe = copy.deepcopy(config)
for vhost in safe.get("vhosts", []):
if "basic_auth" in vhost:
vhost["basic_auth"]["users"] = "REDACTED fixture users"
write_json(self.fixture.results / "configs" / f"{self.label}-{name}.json", safe)
event = {"name": name, "sha256": sha256(config_path), "returncode": result.returncode,
"expected_success": expected_success, "stdout": result.stdout[-2000:],
"stderr": result.stderr[-3000:]}
self.applies.append(event)
write_json(self.fixture.results / f"{self.label}-config-applies.json", self.applies)
if (result.returncode == 0) != expected_success:
raise RuntimeError(f"config {name}: {result.stdout} {result.stderr}")
return result
def reset(self):
return self.apply(self.base, "restored-base")
def login(self):
status, headers, body = http(self.admin_port, "/api/auth/login", "POST",
{"Origin": f"http://127.0.0.1:{self.admin_port}", "Content-Type": "application/json",
"X-RustProxy-Request": "1"},
json.dumps({"username": self.username, "password": self.password}))
if status != 200:
raise RuntimeError(f"fixture login failed: HTTP {status}: {body[:200]!r}")
self.cookie = headers["set-cookie"].split(";", 1)[0]
self.csrf = json.loads(body)["csrf_token"]
return status
def wait_quiescent(self, timeout=180, quiet_seconds=5, poll_seconds=.5):
"""Wait for the real, one-time ACME bootstrap before any timed work.
This does not disable ACME or change the process environment. The
private fixture has no issuers/jobs; a terminal unavailable state is
valid for HTTP tests, but does not establish ACME issuance support.
"""
from resources import descendants, process_stats
if timeout <= 0 or quiet_seconds <= 0 or poll_seconds <= 0 or timeout <= quiet_seconds:
raise ValueError("quiescence timeout must exceed the positive quiet period")
started = time.monotonic()
deadline = started + timeout
output = self.fixture.results / f"{self.label}-quiescence.json"
report = {"completed": False, "label": self.label, "pid": self.process.pid,
"timeout_seconds": timeout, "required_quiet_seconds": quiet_seconds,
"poll_seconds": poll_seconds, "observations": [],
"acme_issuance_tested": False}
def read_admin(path):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"{self.label} did not become quiescent within {timeout}s")
if self.process.poll() is not None:
raise RuntimeError(f"{self.label} exited during quiescence")
status, _, body = self.admin(path, timeout=min(1, remaining))
if status != 200:
raise RuntimeError(f"{self.label} quiescence {path}: HTTP {status}")
return json.loads(body)
try:
root = process_stats(self.process.pid)
if root is None:
raise RuntimeError(f"{self.label} has no live root process")
identity = (root["pid"], root["start_ticks"])
report["start_ticks"] = root["start_ticks"]
envelope = read_admin("/api/config")
if not isinstance(envelope, dict) or not isinstance(envelope.get("config"), dict):
raise RuntimeError("quiescence requires the actual configuration envelope")
certificates = envelope["config"].get("tls", {}).get("certificates", [])
if not isinstance(certificates, list) or any(not isinstance(cert, dict) for cert in certificates):
raise RuntimeError("unexpected certificate configuration during quiescence")
report["configured_issuers"] = sum(cert.get("issuer") is not None for cert in certificates)
if report["configured_issuers"]:
raise RuntimeError("performance fixture must have no configured ACME issuers")
quiet_started, previous_terminal = None, None
while True:
payload = read_admin("/api/acme")
if not isinstance(payload, dict) or not {"runtime", "job", "renewals"} <= payload.keys():
raise RuntimeError("quiescence requires explicit ACME runtime/job/renewal state")
if payload["job"] is not None or payload["renewals"] != {}:
raise RuntimeError("performance fixture has an unexpected ACME job or renewal")
runtime = payload["runtime"]
if not isinstance(runtime, dict) or runtime.get("status") not in {
"checking", "initializing", "ready", "unavailable"}:
raise RuntimeError("unexpected ACME runtime state during quiescence")
pids = descendants(self.process.pid)
root = process_stats(self.process.pid)
if root is None or (root["pid"], root["start_ticks"]) != identity:
raise RuntimeError(f"{self.label} root process identity changed during quiescence")
now = time.monotonic()
if now >= deadline:
raise TimeoutError(f"{self.label} did not become quiescent within {timeout}s")
terminal = tuple(runtime.get(key) for key in ("status", "mode", "message"))
quiet = runtime["status"] in {"ready", "unavailable"} and pids == [self.process.pid]
if not quiet:
quiet_started, previous_terminal = None, None
elif quiet_started is None or terminal != previous_terminal:
quiet_started, previous_terminal = now, terminal
elapsed_quiet = now - quiet_started if quiet_started is not None else 0
report["observations"].append({
"elapsed_seconds": now - started, "process_count": len(pids),
"acme_status": runtime["status"], "acme_mode": runtime.get("mode"),
"acme_message": runtime.get("message"), "quiet_seconds": elapsed_quiet})
if elapsed_quiet >= quiet_seconds:
report.update({"completed": True, "elapsed_seconds": now - started,
"terminal_status": runtime["status"], "process_count": len(pids)})
write_json(output, report)
return report
write_json(output, report)
time.sleep(min(poll_seconds, max(0, deadline - time.monotonic())))
except (Exception, KeyboardInterrupt) as error:
report.update({"elapsed_seconds": time.monotonic() - started,
"error_type": type(error).__name__, "error": str(error)})
write_json(output, report)
raise
def admin(self, path, method="GET", body=None, csrf=True, cookie=True, origin=None,
timeout=10):
headers = {"Origin": origin or f"http://127.0.0.1:{self.admin_port}", "X-RustProxy-Request": "1"}
if cookie:
headers["Cookie"] = self.cookie
if csrf:
headers["X-CSRF-Token"] = self.csrf
if body is not None:
headers["Content-Type"] = "application/json"
body = json.dumps(body)
return http(self.admin_port, path, method, headers, body, timeout=timeout)
@@ -1,138 +0,0 @@
#!/usr/bin/env python3
"""Finite live HTTP concurrency correctness stress; no throughput/latency claim."""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor
import hashlib
import json
from pathlib import Path
import threading
import time
import traceback
from common import Fixture, http, seal_results, sha256, write_json
from resources import process_stats
PAYLOADS = ["small.html", "large.bin", "huge.bin"]
def active_backends(rust):
status, _, body = rust.admin("/api/status")
if status != 200:
return {"error": f"HTTP {status}"}
values = []
def walk(value):
if isinstance(value, dict):
if "id" in value and "active" in value and "status" in value and "requests" in value:
values.append({key: value[key] for key in ("id", "active", "status", "requests")})
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(json.loads(body))
return values
def batch(rust, port, name, expected, concurrency):
count = max(32, concurrency * 2)
expected_sha = hashlib.sha256(expected).hexdigest()
ready = threading.Barrier(concurrency, timeout=15)
lock = threading.Lock()
running = peak = 0
def fetch(index):
nonlocal running, peak
with lock:
running += 1
peak = max(peak, running)
try:
if index < concurrency:
ready.wait()
status, headers, body = http(port, "/" + name, timeout=15)
digest = hashlib.sha256(body).hexdigest()
return {"index": index, "status": status, "bytes": len(body), "sha256": digest,
"passed": status == 200 and len(body) == len(expected) and digest == expected_sha}
except Exception as error:
return {"index": index, "passed": False, "error": str(error)}
finally:
with lock:
running -= 1
before = process_stats(rust.process.pid)
started = time.monotonic()
with ThreadPoolExecutor(max_workers=concurrency) as executor:
responses = list(executor.map(fetch, range(count)))
elapsed = time.monotonic() - started
# Completion means every client consumed or failed its full response. Give
# asynchronous server bookkeeping a bounded interval to release its leases.
for _ in range(40):
active = active_backends(rust)
if isinstance(active, list) and active and all(row["active"] == 0 for row in active):
break
time.sleep(.05)
after = process_stats(rust.process.pid)
return {"configured_concurrent_clients": concurrency, "peak_running_client_tasks": peak,
"client_tasks_include_barrier": True, "requests": count, "elapsed_seconds": elapsed,
"expected_bytes": len(expected), "expected_sha256": expected_sha,
"response_failures": sum(not response["passed"] for response in responses),
"passed": all(response["passed"] for response in responses)
and isinstance(active, list) and bool(active) and all(row["active"] == 0 for row in active),
"responses": responses, "active_backends_after_drain": active,
"server_before": before, "server_after_drain": after}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
parser.add_argument("--nginx", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
if args.out.exists() and any(args.out.iterdir()):
parser.error("--out must be new or empty")
reports = {}
for specification in args.binary:
label, binary = specification.split("=", 1)
cases, error = [], None
try:
with Fixture(args.out / label, workers=4, origin_workers=2) as fixture:
fixture.nginx(args.nginx, frontend=False)
rust = fixture.rust(Path(binary), label)
rust.login()
for source, port in [("proxy", rust.reverse_port), ("static", rust.static_port)]:
for name in PAYLOADS:
expected = (fixture.site / name).read_bytes()
for concurrency in [1, 16, 128]:
result = batch(rust, port, name, expected, concurrency)
case = {"case": f"concurrency.{source}.{name}.c{concurrency}", **result}
cases.append(case)
write_json(args.out / label / "cases" / f"{source}-{name}-c{concurrency}.json", case)
print(f"{label} {'PASS' if case['passed'] else 'FAIL'} {case['case']}", flush=True)
if rust.process.poll() is not None:
raise RuntimeError("RustProxy exited during concurrency regression")
except Exception as exc:
error = {"error": str(exc), "traceback": traceback.format_exc()}
write_json(args.out / label / "failure.json", error)
report = {"kind": "live HTTP/1.1 finite concurrency functional stress", "simulation": False,
"binary_sha256": sha256(Path(binary)), "throughput_measurement": False,
"passed_cases": sum(case["passed"] for case in cases),
"failed_cases": sum(not case["passed"] for case in cases),
"requests": sum(case["requests"] for case in cases),
"response_failures": sum(case["response_failures"] for case in cases),
"completed": error is None and len(cases) == 18,
"cases": [{key: case[key] for key in ("case", "passed", "requests", "response_failures")} for case in cases],
"limitations": ["configured client concurrency is not proof of simultaneous active server exchanges",
"Python client is not a throughput or latency benchmark",
"FD/RSS are before/after snapshots, not peaks or a long-term leak test"]}
reports[label] = report
write_json(args.out / label / "report.json", report)
write_json(args.out / "comparison.json", reports)
seal_results(args.out)
return 0 if all(report["completed"] and not report["failed_cases"] for report in reports.values()) else 1
if __name__ == "__main__":
raise SystemExit(main())
-223
View File
@@ -1,223 +0,0 @@
#!/usr/bin/env python3
"""Small live two-origin failover and active-exchange regression, not a benchmark."""
from __future__ import annotations
import argparse
from collections import Counter
import copy
import hashlib
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import threading
import time
import traceback
from common import Fixture, http, seal_results, write_json
class Origin:
def __init__(self, port, name):
self.name, self.port = name, port
self.fail_get = False
self.counts, self.post_hashes = Counter(), []
self.release, self.lock = threading.Event(), threading.Lock()
origin = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_):
pass
def do_GET(self):
self.respond()
def do_POST(self):
self.respond()
def respond(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
with origin.lock:
origin.counts[(self.command, self.path)] += 1
if self.command == "POST":
origin.post_hashes.append(hashlib.sha256(body).hexdigest())
hold = self.path == "/hold"
status = 503 if self.command == "POST" or origin.fail_get else 200
payload = b"firstlast" if hold else origin.name.encode()
try:
self.send_response(status)
self.send_header("Content-Length", str(len(payload)))
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Cache-Control", "no-store")
# Fresh connections make refused-listener tests independent
# of already accepted upstream keepalive connections.
self.send_header("Connection", "close")
self.end_headers()
if hold:
self.wfile.write(payload[:5])
self.wfile.flush()
origin.release.wait(15)
self.wfile.write(payload[5:])
else:
self.wfile.write(payload)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass
self.close_connection = True
self.server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.closed = False
def close(self):
self.release.set()
if not self.closed:
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=3)
self.closed = True
def backend_views(rust):
status, _, body = rust.admin("/api/status")
if status != 200:
raise RuntimeError(f"status unavailable: {status}")
found = {}
def walk(value):
if isinstance(value, dict):
if value.get("id") in ("a", "b") and "active" in value and "status" in value:
found[value["id"]] = {key: value[key] for key in value
if key in ("id", "status", "active", "requests", "errors", "bytes_out")
or key.startswith("retry_") or key.startswith("connect_errors")}
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(json.loads(body))
return found
def run(check, fixture, binary, label):
a, b = Origin(fixture.origin_port, "a"), Origin(fixture.port(), "b")
try:
rust = fixture.rust(binary, label)
rust.login()
config = copy.deepcopy(rust.base)
vhost = config["vhosts"][0]
vhost["backends"] = [{"id": origin.name, "url": f"http://127.0.0.1:{origin.port}",
"allow_private": True, "preserve_host": False} for origin in [a, b]]
vhost["load_balancing"] = {"algorithm": "round_robin", "health_check": {"mode": "off"},
"slow_start_seconds": 0, "passive_health": {"enabled": False},
"retry": {"max_retries": 1, "retry_statuses": [503]}}
vhost["routes"] = [{"id": "hold-a", "path_prefix": "/hold", "backend_id": "a"}]
rust.apply(config, "lb-two-origins")
replies = [http(rust.reverse_port, "/rr") for _ in range(8)]
counts = Counter(body.decode() for status, _, body in replies if status == 200)
check("lb.round_robin_both_origins", counts == {"a": 4, "b": 4}, counts=dict(counts))
a.fail_get = True
replies = [http(rust.reverse_port, "/retry-status") for _ in range(6)]
check("lb.get_503_failover", all(status == 200 and body == b"b" for status, _, body in replies)
and a.counts[("GET", "/retry-status")] > 0,
failed_origin_attempts=a.counts[("GET", "/retry-status")])
views = backend_views(rust)
check("lb.retry_observations_recorded", sum(view.get("retry_attempts_total", 0) for view in views.values()) > 0,
backends=views)
a.fail_get = False
payload = b"one POST must be attempted once"
replies = [http(rust.reverse_port, "/post-no-replay", "POST", body=payload) for _ in range(4)]
attempts = sum(origin.counts[("POST", "/post-no-replay")] for origin in [a, b])
check("lb.post_503_never_replayed", all(status == 503 for status, _, _ in replies) and attempts == 4,
client_requests=4, origin_attempts=attempts)
check("lb.post_body_preserved", len(a.post_hashes + b.post_hashes) == 4 and
all(digest == hashlib.sha256(payload).hexdigest() for digest in a.post_hashes + b.post_hashes))
for mode in ["drain", "disable"]:
vhost["backends"][0].update({"enabled": True, "drain": False})
rust.apply(config, f"lb-{mode}-before")
a.release.clear()
connection = HTTPConnection("127.0.0.1", rust.reverse_port, timeout=10)
try:
connection.request("GET", "/hold")
response = connection.getresponse()
first = response.read(5)
before = backend_views(rust)
check(f"lb.{mode}.lease_spans_response_body", response.status == 200 and first == b"first"
and before.get("a", {}).get("active") == 1, backends=before)
vhost["backends"][0].update({"drain": mode == "drain", "enabled": mode != "disable"})
rust.apply(config, f"lb-{mode}-active")
replies = [http(rust.reverse_port, "/new") for _ in range(4)]
check(f"lb.{mode}.new_requests_use_healthy_member",
all(status == 200 and body == b"b" for status, _, body in replies))
during = backend_views(rust)
check(f"lb.{mode}.active_lease_survives_hot_edit", during.get("a", {}).get("active") == 1,
backends=during)
a.release.set()
last = response.read()
check(f"lb.{mode}.admitted_body_finishes", first + last == b"firstlast")
for _ in range(20):
after = backend_views(rust)
if after.get("a", {}).get("active") == 0:
break
time.sleep(.05)
check(f"lb.{mode}.lease_released_after_body", after.get("a", {}).get("active") == 0,
backends=after)
finally:
a.release.set()
connection.close()
vhost["backends"][0].update({"enabled": True, "drain": False})
rust.apply(config, "lb-connect-refused")
a.close()
replies = [http(rust.reverse_port, "/retry-connect") for _ in range(6)]
views = backend_views(rust)
check("lb.connect_refused_failover", all(status == 200 and body == b"b" for status, _, body in replies)
and views.get("a", {}).get("connect_errors_total", 0) > 0, backends=views)
rust.reset()
check("lb.static_vhost_survives_reload", http(rust.static_port)[0] == 200)
finally:
a.close()
b.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
if args.out.exists() and any(args.out.iterdir()):
parser.error("--out must be new or empty")
reports = {}
for specification in args.binary:
label, binary = specification.split("=", 1)
cases = []
def check(case, passed, **details):
cases.append({"case": case, "passed": passed, **details})
print(f"{label} {'PASS' if passed else 'FAIL'} {case}", flush=True)
write_json(args.out / label / "checks.json", cases)
try:
with Fixture(args.out / label) as fixture:
run(check, fixture, Path(binary), label)
except Exception as exc:
check("lb.runner", False, error=str(exc), traceback=traceback.format_exc())
report = {"passed": sum(case["passed"] for case in cases),
"failed": sum(not case["passed"] for case in cases), "cases": cases,
"simulation": False, "kind": "live RustProxy two-origin HTTP/1.1 regression",
"not_covered": ["sticky affinity", "active health probes", "long-running connection soak",
"TLS origin failures", "DNS rotation"]}
reports[label] = report
write_json(args.out / label / "report.json", report)
write_json(args.out / "comparison.json", reports)
seal_results(args.out)
return 1 if any(report["failed"] for report in reports.values()) else 0
if __name__ == "__main__":
raise SystemExit(main())
-223
View File
@@ -1,223 +0,0 @@
#!/usr/bin/env python3
"""A protocol peer, not a RustProxy emulator; used only as the test upstream.
Performance comparisons use nginx as the upstream, never this Python fixture.
"""
from __future__ import annotations
import base64
import collections
import hashlib
import json
import socket
import struct
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit, parse_qs
class Peer:
def __init__(self, port, site):
self.site = site
self.counts = collections.Counter()
self.received = {}
self.lock = threading.Lock()
self.started_upload = threading.Event()
self.release_stream = threading.Event()
peer = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_):
pass
def emit(self, status, body=b"", headers=None):
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
if "Content-Type" not in (headers or {}):
self.send_header("Content-Type", "application/octet-stream")
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
if self.command != "HEAD" and body:
self.wfile.write(body)
def upload(self):
chunks = []
if self.headers.get("Transfer-Encoding", "").lower() == "chunked":
while True:
length = int(self.rfile.readline().split(b";", 1)[0], 16)
if length == 0:
while self.rfile.readline().strip():
pass
break
payload = self.rfile.read(length)
self.rfile.read(2)
chunks.append(payload)
peer.started_upload.set()
else:
remaining = int(self.headers.get("Content-Length", 0))
while remaining:
payload = self.rfile.read(min(remaining, 1024))
if not payload:
break
chunks.append(payload)
remaining -= len(payload)
peer.started_upload.set()
return b"".join(chunks)
def do_POST(self):
self.handle_method()
def do_PUT(self):
self.handle_method()
def do_HEAD(self):
self.handle_method()
def do_GET(self):
self.handle_method()
def handle_method(self):
try:
self.route()
except (BrokenPipeError, ConnectionResetError, socket.timeout):
self.close_connection = True
def route(self):
parsed = urlsplit(self.path)
path, query = parsed.path, parse_qs(parsed.query)
with peer.lock:
peer.counts[(self.command, path)] += 1
count = peer.counts[(self.command, path)]
body = self.upload() if self.command in ("POST", "PUT") or self.headers.get("Content-Length") or self.headers.get("Transfer-Encoding") else b""
if path == "/no-replay":
self.close_connection = True
self.connection.shutdown(socket.SHUT_RDWR)
self.connection.close()
return
if path in ("/echo", "/stream-upload"):
data = {"method": self.command, "path": self.path,
"headers": {key.lower(): value for key, value in self.headers.items()},
"body_bytes": len(body), "body_sha256": hashlib.sha256(body).hexdigest()}
with peer.lock:
peer.received[path] = data
self.emit(200, json.dumps(data).encode(), {"Content-Type": "application/json",
"Cache-Control": "no-store", "X-Origin-Fixture": "yes"})
return
if path == "/stream-download":
self.send_response(200)
self.send_header("Transfer-Encoding", "chunked")
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(b"5\r\nfirst\r\n")
self.wfile.flush()
peer.release_stream.wait(5)
self.wfile.write(b"4\r\nlast\r\n0\r\n\r\n")
self.wfile.flush()
return
if path == "/ws":
key = self.headers.get("Sec-WebSocket-Key", "")
accept = base64.b64encode(hashlib.sha1(
(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
self.send_response(101)
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept", accept)
self.end_headers()
self.wfile.flush()
frame = self.rfile.read(2)
if len(frame) == 2:
length = frame[1] & 127
if length == 126:
length = struct.unpack("!H", self.rfile.read(2))[0]
mask = self.rfile.read(4) if frame[1] & 128 else b"\0\0\0\0"
data = self.rfile.read(length)
data = bytes(byte ^ mask[i % 4] for i, byte in enumerate(data))
self.wfile.write(bytes([0x81, len(data)]) + data)
self.wfile.flush()
self.close_connection = True
return
if path.startswith("/status/"):
self.emit(int(path.rsplit("/", 1)[1]), b"origin status payload\n",
{"Cache-Control": "no-store", "X-Origin-Fixture": "yes"})
return
if path == "/redirect":
self.emit(302, b"redirect body\n", {"Location": "/small.html", "Cache-Control": "no-store"})
return
if path in ("/private", "/cookie", "/no-store", "/vary", "/fresh", "/revalidate", "/concurrent"):
cache_control = "public, max-age=60"
headers = {"Content-Type": "text/plain"}
if body:
headers["X-Received-Body-Bytes"] = str(len(body))
if path == "/private":
cache_control = "private, max-age=60"
if path == "/no-store":
cache_control = "no-store"
if path == "/cookie":
headers["Set-Cookie"] = "application-session=fixture; Path=/"
if path == "/vary":
headers["Vary"] = "Accept-Language"
if path == "/concurrent":
time.sleep(.08)
if path == "/revalidate":
cache_control = "public, max-age=1, stale-if-error=10"
headers["ETag"] = '"fixture-v1"'
if self.headers.get("If-None-Match") == headers["ETag"]:
self.emit(304, b"", {**headers, "Cache-Control": cache_control})
return
data = f"{path}:count={count}:lang={self.headers.get('Accept-Language', '')}".encode()
if path == "/revalidate":
data = b"stable revalidated object"
self.emit(200, data, {**headers, "Cache-Control": cache_control})
return
if path == "/no-transform":
self.emit(200, b"unchanged compression fixture " * 1024,
{"Content-Type": "text/plain", "Cache-Control": "no-transform, public, max-age=60"})
return
file = peer.site / path.lstrip("/")
if not file.is_file() or ".." in file.parts:
self.emit(404, b"fixture not found")
return
payload = file.read_bytes()
tag = '"' + hashlib.sha256(payload).hexdigest() + '"'
headers = {"Cache-Control": "public, max-age=60", "ETag": tag,
"Accept-Ranges": "bytes", "X-Origin-Fixture": "yes"}
headers["Content-Type"] = {".html": "text/html", ".txt": "text/plain",
".css": "text/css", ".json": "application/json"}.get(file.suffix, "application/octet-stream")
if self.headers.get("If-None-Match") == tag:
self.emit(304, b"", headers)
return
range_value = self.headers.get("Range")
if range_value and self.headers.get("If-Range", tag) == tag:
try:
first, last = range_value.removeprefix("bytes=").split("-", 1)
start = int(first) if first else max(0, len(payload) - int(last))
end = min(int(last), len(payload) - 1) if first and last else len(payload) - 1
if start >= len(payload) or end < start:
self.emit(416, b"", {**headers, "Content-Range": f"bytes */{len(payload)}"})
else:
self.emit(206, payload[start:end + 1], {**headers,
"Content-Range": f"bytes {start}-{end}/{len(payload)}"})
return
except ValueError:
pass
self.emit(200, payload, headers)
self.server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
self.server.daemon_threads = True
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def count(self, method, path):
with self.lock:
return self.counts[(method, path)]
def close(self):
self.release_stream.set()
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
-284
View File
@@ -1,284 +0,0 @@
#!/usr/bin/env python3
"""Profile selected server syscalls separately from throughput measurements.
Runs each RustProxy binary sequentially against a disposable loopback fixture.
The measured operation is one persistent HTTP/1.1 connection requesting the
64-KiB static file with cache disabled and Accept-Encoding: identity.
Every response is validated; syscall counts include background server work.
strace elapsed/CPU times must not be interpreted as uninstrumented performance.
"""
from __future__ import annotations
import argparse
import hashlib
from http.client import HTTPConnection
import json
import os
from pathlib import Path
import platform
import re
import shutil
import signal
import subprocess
import sys
import time
import traceback
from common import Fixture, seal_results, sha256, write_json
from resources import descendants
SYSCALLS = ("read", "pread64", "write", "writev", "openat", "close", "fcntl", "lseek", "futex")
REQUEST_PATH = "/large.bin"
def thread_tracers(pid: int) -> dict[int, int]:
result = {}
for task in Path(f"/proc/{pid}/task").iterdir():
try:
status = (task / "status").read_text()
except FileNotFoundError:
continue # A thread may exit between the directory read and status.
match = re.search(r"^TracerPid:\s+(\d+)$", status, re.MULTILINE)
if match:
result[int(task.name)] = int(match.group(1))
return result
def wait_attached(tracer, server, timeout=10):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if tracer.poll() is not None:
raise RuntimeError("strace exited before attachment; see strace diagnostic log (ptrace may be unavailable)")
if server.poll() is not None:
raise RuntimeError("server exited during strace attachment")
traced = thread_tracers(server.pid)
if traced and all(traced.values()):
return sorted(traced)
time.sleep(.01)
raise RuntimeError("strace did not attach to every server thread within 10 seconds")
def detach(tracer, server):
# Do not signal the process group: only the independently started tracer.
if tracer.poll() is None:
tracer.send_signal(signal.SIGINT)
try:
tracer.wait(timeout=10)
except subprocess.TimeoutExpired:
tracer.terminate()
try:
tracer.wait(timeout=3)
except subprocess.TimeoutExpired:
tracer.kill()
tracer.wait(timeout=3)
raise RuntimeError("strace failed to finish on SIGINT; profile is incomplete")
if server.poll() is not None:
raise RuntimeError("server exited while the tracer was detached")
traced = thread_tracers(server.pid)
if not traced or any(traced.values()):
raise RuntimeError("server still has traced threads after strace exited")
return {"signal": "SIGINT to strace PID only", "strace_returncode": tracer.returncode,
"server_alive": True, "remaining_traced_threads": 0}
def parse_summary(path: Path, requests: int):
rows = {name: {"calls": 0, "errors": 0, "calls_per_request": 0.0}
for name in SYSCALLS}
total = None
for line in path.read_text().splitlines():
fields = line.split()
if len(fields) not in (5, 6) or fields[-1] not in (*SYSCALLS, "total"):
continue
percent, seconds, usecs, calls = map(float, fields[:4])
errors = int(fields[4]) if len(fields) == 6 else 0
row = {"calls": int(calls), "errors": errors,
"calls_per_request": int(calls) / requests,
"strace_system_seconds": seconds, "strace_percent_time": percent,
"strace_us_per_call": usecs}
if fields[-1] == "total":
total = row
else:
rows[fields[-1]] = row
if total is None:
raise RuntimeError(f"no complete strace summary found in {path}")
if sum(row["calls"] for row in rows.values()) != total["calls"]:
raise RuntimeError("parsed syscall counts do not match the raw strace total")
return {"selected_syscalls": rows, "selected_total": total}
def request_checked(connection, expected_socket, expected_sha, phase, sequence, evidence):
if connection.sock is not expected_socket:
raise RuntimeError("keepalive connection was replaced before the next request")
connection.request("GET", REQUEST_PATH,
headers={"Accept-Encoding": "identity", "Connection": "keep-alive"})
response = connection.getresponse()
payload = response.read()
digest = hashlib.sha256(payload).hexdigest()
headers = {key.lower(): value for key, value in response.getheaders()}
valid = (response.status == 200 and len(payload) == 65536 and digest == expected_sha
and headers.get("content-length") == "65536"
and headers.get("content-encoding", "identity") == "identity"
and connection.sock is expected_socket and not response.will_close)
result = {"phase": phase, "sequence": sequence, "status": response.status,
"bytes": len(payload), "sha256": digest,
"content_length": headers.get("content-length"),
"content_encoding": headers.get("content-encoding"),
"connection_reused": connection.sock is expected_socket,
"will_close": response.will_close, "passed": valid}
evidence.write(json.dumps(result, sort_keys=True) + "\n")
evidence.flush()
if not valid:
raise RuntimeError(f"response verification failed in {phase} request {sequence}: {result}")
def profile_variant(fixture, label, binary, args):
rust = fixture.rust(binary, label)
rust.login()
rust.wait_quiescent()
raw = args.out / "raw"
raw.mkdir(exist_ok=True)
summary_path = raw / f"{label}.strace.txt"
diagnostic_path = raw / f"{label}.strace.log"
responses_path = raw / f"{label}.responses.jsonl"
expected_sha = sha256(fixture.site / "large.bin")
quiescence_path = fixture.results / f"{label}-quiescence.json"
config_path = fixture.results / "configs" / f"{label}-base.json"
config_sha = sha256(config_path)
if config_sha != rust.applies[-1]["sha256"]:
raise RuntimeError("saved fixture configuration differs from the applied configuration")
command = [str(args.strace), "-f", "-c", "-e", "trace=" + ",".join(SYSCALLS),
"-p", str(rust.process.pid), "-o", str(summary_path)]
connection = HTTPConnection("127.0.0.1", rust.static_port, timeout=args.timeout)
tracer = None
result = {"variant": label, "binary": str(binary), "binary_sha256": sha256(binary),
"config": str(config_path.relative_to(args.out)), "config_sha256": config_sha,
"quiescence": str(quiescence_path.relative_to(args.out)),
"quiescence_sha256": sha256(quiescence_path),
"pid": rust.process.pid, "request_path": REQUEST_PATH, "payload_bytes": 65536,
"payload_sha256": expected_sha, "cache_enabled": False,
"request_encoding": "identity", "concurrency": 1,
"warmup_requests": args.warmup, "profile_requests": args.requests,
"strace_command": command, "raw_summary": str(summary_path.relative_to(args.out)),
"raw_diagnostics": str(diagnostic_path.relative_to(args.out)),
"responses": str(responses_path.relative_to(args.out)), "passed": False}
try:
with responses_path.open("w") as responses, diagnostic_path.open("w") as diagnostics:
connection.connect()
expected_socket = connection.sock
result["client_local_address"] = expected_socket.getsockname()
for index in range(args.warmup):
request_checked(connection, expected_socket, expected_sha, "warmup", index + 1, responses)
result["processes_before_attach"] = descendants(rust.process.pid)
if result["processes_before_attach"] != [rust.process.pid]:
raise RuntimeError("server spawned a child after quiescence; refusing contaminated syscall trace")
environment = os.environ.copy()
environment["LC_ALL"] = "C"
tracer = subprocess.Popen(command, stdout=diagnostics, stderr=diagnostics,
env=environment, start_new_session=True)
result["initial_traced_threads"] = wait_attached(tracer, rust.process)
started = time.monotonic()
try:
for index in range(args.requests):
request_checked(connection, expected_socket, expected_sha, "profile", index + 1, responses)
if tracer.poll() is not None:
raise RuntimeError("strace exited before all profile requests completed")
finally:
result["instrumented_request_loop_seconds"] = time.monotonic() - started
result["detach"] = detach(tracer, rust.process)
tracer = None
# An actual HTTP response after detaching proves the server stayed usable.
request_checked(connection, expected_socket, expected_sha, "after_detach", 1, responses)
result["processes_after_detach"] = descendants(rust.process.pid)
if result["processes_after_detach"] != [rust.process.pid]:
raise RuntimeError("server has a child after profiling; syscall trace needs review")
result.update(parse_summary(summary_path, args.requests))
result["verified_responses"] = args.requests + args.warmup + 1
result["passed"] = True
return result
finally:
if tracer is not None:
detach(tracer, rust.process)
connection.close()
fixture.stop(rust.process)
write_json(args.out / f"{label}.json", result)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
parser.add_argument("--nginx", type=Path, required=True, help="nginx executable for the shared fixture origin")
parser.add_argument("--strace", type=Path, default=shutil.which("strace"))
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--requests", type=int, default=500)
parser.add_argument("--warmup", type=int, default=20)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--origin-workers", type=int, default=4)
parser.add_argument("--server-cpus")
parser.add_argument("--origin-cpus")
parser.add_argument("--timeout", type=float, default=20)
args = parser.parse_args()
if args.requests < 1 or args.warmup < 0 or args.workers < 1 or args.origin_workers < 1 or args.timeout <= 0:
parser.error("requests/workers/timeout must be positive, warmup must be nonnegative")
if args.strace is None:
parser.error("strace is required; pass --strace /absolute/path")
if args.out.exists() and (not args.out.is_dir() or any(args.out.iterdir())):
parser.error("--out must be a new or empty directory; existing evidence is never overwritten")
variants = {}
for value in args.binary:
label, separator, filename = value.partition("=")
if not separator or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,48}", label) or label in variants:
parser.error("each --binary must use a unique filesystem-safe LABEL=/ABS/PATH")
binary = Path(filename).resolve()
if not binary.is_file():
parser.error(f"binary does not exist: {binary}")
variants[label] = binary
args.out, args.nginx, args.strace = args.out.resolve(), args.nginx.resolve(), args.strace.resolve()
for executable in (args.nginx, args.strace):
if not executable.is_file() or not os.access(executable, os.X_OK):
parser.error(f"executable is missing or is not executable: {executable}")
args.out.mkdir(parents=True, exist_ok=True)
version = subprocess.run([str(args.strace), "-V"], capture_output=True, text=True, timeout=10)
environment = {"kind": "selected syscall counts with ptrace instrumentation", "simulation": False,
"platform": platform.platform(), "python": sys.version, "strace_version": version.stdout + version.stderr,
"strace_sha256": sha256(args.strace), "nginx_sha256": sha256(args.nginx),
"variant_order": list(variants), "selected_syscalls": SYSCALLS,
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"settings": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()},
"limitations": ["strace changes scheduling and latency; do not use instrumented times as RPS evidence",
"Counts cover selected syscalls in every traced server thread, including background work",
"Normalized counts divide the whole trace by verified profile requests; they do not isolate per-request causality",
"This is a warm-file-cache, c1, HTTP/1.1 plaintext static-file scenario; no general capacity inference",
"Syscall counts do not directly count Tokio task dispatches or memory allocations"]}
for path in ("/proc/sys/kernel/yama/ptrace_scope", "/sys/fs/cgroup/cpu.max", "/sys/fs/cgroup/memory.max"):
try:
environment[path] = Path(path).read_text().strip()
except OSError:
environment[path] = None
write_json(args.out / "environment.json", environment)
rows, error = [], None
try:
with Fixture(args.out / "fixture", workers=args.workers, server_cpus=args.server_cpus,
origin_cpus=args.origin_cpus, origin_workers=args.origin_workers) as fixture:
fixture.nginx(args.nginx, frontend=False)
for label, binary in variants.items():
result = profile_variant(fixture, label, binary, args)
rows.append(result)
write_json(args.out / "profiles.json", rows)
print(json.dumps({"variant": label, "requests": args.requests, "passed": result["passed"],
"selected_syscall_calls": result["selected_total"]["calls"]}), flush=True)
except Exception as exc:
error = {"error": str(exc), "traceback": traceback.format_exc()}
write_json(args.out / "failure.json", error)
print(json.dumps(error), flush=True)
finally:
write_json(args.out / "profiles.json", rows)
write_json(args.out / "completion.json", {"completed": error is None, "profiles": len(rows),
"simulation": False, "throughput_measurement": False,
"finished_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
seal_results(args.out)
return 1 if error else 0
if __name__ == "__main__":
sys.exit(main())
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""Run black-box acceptance tests against one or more real RustProxy binaries.
Example:
python3 scripts/performance/regression.py --binary before=/abs/before \
--binary after=/abs/after --out /abs/results/regression
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import platform
import sys
import time
import traceback
from common import Fixture, seal_results, write_json
from peer import Peer
import checks_cache
import checks_policy
import checks_proxy
import checks_static
GROUPS = {"static": checks_static.run, "proxy": checks_proxy.run,
"policy": checks_policy.run, "cache": checks_cache.run,
"transform": checks_cache.transforms}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
parser.add_argument("--out", required=True, type=Path)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--groups", default=",".join(GROUPS))
args = parser.parse_args()
groups = args.groups.split(",")
if any(group not in GROUPS for group in groups):
parser.error(f"groups must be selected from {','.join(GROUPS)}")
args.out = args.out.resolve()
args.out.mkdir(parents=True, exist_ok=True)
metadata = {"kind": "live RustProxy black-box HTTP acceptance", "simulation": False,
"python": sys.version, "platform": platform.platform(),
"cpu_affinity": sorted(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else None,
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"groups": groups,
"not_covered": ["TLS/ALPN", "HTTP/2", "HTTP/3", "PHP-FPM",
"Tinyauth verifier", "real DNS rebinding", "24-hour soak",
"concurrent filesystem rename-race proof"]}
write_json(args.out / "environment.json", metadata)
reports = {}
for binary_arg in args.binary:
label, separator, name = binary_arg.partition("=")
if not separator or not label.replace("-", "").replace("_", "").isalnum():
parser.error("--binary must be LABEL=/ABS/PATH with a simple unique label")
if label in reports:
parser.error("binary labels must be unique")
cases = []
started = time.monotonic()
output = args.out / label
def check(case, passed, **details):
row = {"case": case, "passed": passed, **details}
cases.append(row)
print(f"{label} {'SKIP' if passed is None else 'PASS' if passed else 'FAIL'} {case}", flush=True)
write_json(output / "checks.json", cases)
try:
with Fixture(output, workers=args.workers) as fixture:
peer = Peer(fixture.origin_port, fixture.site)
try:
rust = fixture.rust(Path(name), label)
check("runtime.started", True)
for group in groups:
try:
rust.reset()
GROUPS[group](check, rust, peer)
except Exception as exc:
check(group + ".runner", False, error=str(exc), traceback=traceback.format_exc())
finally:
rust.reset()
check("runtime.still_alive", rust.process.poll() is None)
finally:
peer.close()
except Exception as exc:
check("runtime.runner", False, error=str(exc), traceback=traceback.format_exc())
report = {"label": label, "kind": metadata["kind"], "simulation": False,
"seconds": time.monotonic() - started, "passed": sum(c["passed"] is True for c in cases),
"failed": sum(c["passed"] is False for c in cases),
"skipped": sum(c["passed"] is None for c in cases), "cases": cases}
reports[label] = report
write_json(output / "report.json", report)
print(f"{label}: {report['passed']} passed, {report['failed']} failed, {report['skipped']} skipped", flush=True)
comparison = {"variants": {name: {key: report[key] for key in ["passed", "failed", "skipped", "seconds"]}
for name, report in reports.items()},
"cases": [{"case": case, **{name: next((entry["passed"] for entry in report["cases"]
if entry["case"] == case), "not_run") for name, report in reports.items()}}
for case in sorted({entry["case"] for report in reports.values() for entry in report["cases"]})]}
write_json(args.out / "comparison.json", comparison)
seal_results(args.out)
return 1 if any(report["failed"] for report in reports.values()) else 0
if __name__ == "__main__":
sys.exit(main())
-228
View File
@@ -1,228 +0,0 @@
"""Linux /proc sampling outside the server process; no synthetic resource data."""
from __future__ import annotations
import os
from pathlib import Path
import threading
import time
def cgroup_cpu_stat(path=Path("/sys/fs/cgroup/cpu.stat")):
values = {key: int(value) for key, value in (line.split() for line in path.read_text().splitlines())}
if not {"nr_throttled", "throttled_usec"} <= values.keys():
raise RuntimeError(f"{path} lacks cgroup v2 throttling counters")
return {"path": str(path), "observed_monotonic": time.monotonic(), "values": values}
def cgroup_cpu_delta(before, after):
delta = {key: after["values"][key] - value for key, value in before["values"].items()
if key in after["values"]}
if any(value < 0 for value in delta.values()):
raise RuntimeError("cgroup CPU counters reset during the measured run")
return delta
def parse_cpu_set(value):
if not value:
raise ValueError("an explicit, nonempty CPU set is required")
result = set()
for part in value.split(","):
bounds = part.strip().split("-")
if len(bounds) not in (1, 2) or any(not item.isascii() or not item.isdigit() for item in bounds):
raise ValueError(f"invalid CPU set {value!r}; use comma-separated numbers or ranges")
start, end = int(bounds[0]), int(bounds[-1])
if start > end or end - start > 65536:
raise ValueError(f"invalid CPU range {part!r}")
result.update(range(start, end + 1))
return result
def validate_cpu_sets(values, allowed):
groups = {label: parse_cpu_set(value) for label, value in values.items()}
for label, cpus in groups.items():
if not cpus <= allowed:
raise ValueError(f"{label} CPUs {sorted(cpus - allowed)} are outside the allowed affinity")
for other, other_cpus in groups.items():
if label < other and cpus & other_cpus:
raise ValueError(f"{label} and {other} CPU sets overlap: {sorted(cpus & other_cpus)}")
return groups
def affinity_snapshot(root, expected, expected_processes=None):
"""Verify every currently visible thread in the root process and children."""
tasks = []
for pid in descendants(root):
try:
threads = list(Path(f"/proc/{pid}/task").iterdir())
except FileNotFoundError:
continue
for thread in threads:
try:
actual = set(os.sched_getaffinity(int(thread.name)))
except ProcessLookupError:
continue
if actual != expected:
raise RuntimeError(f"affinity mismatch for PID {pid} TID {thread.name}: "
f"expected {sorted(expected)}, got {sorted(actual)}")
tasks.append({"pid": pid, "tid": int(thread.name), "cpus": sorted(actual)})
if not tasks or not any(task["pid"] == root for task in tasks):
raise RuntimeError(f"cannot inspect affinity of live process {root}")
processes = len({task["pid"] for task in tasks})
if expected_processes is not None and processes != expected_processes:
raise RuntimeError(f"process count mismatch for PID {root}: "
f"expected {expected_processes}, inspected {processes}")
return {"root_pid": root, "processes": processes,
"threads": len(tasks), "tasks": tasks}
def wait_for_affinity(process, expected, timeout=2):
"""Allow taskset to execute before inspecting the generator's inherited mask."""
deadline = time.monotonic() + timeout
while True:
if process.poll() is not None:
raise RuntimeError(f"generator exited before affinity verification: {process.returncode}")
try:
return affinity_snapshot(process.pid, expected)
except RuntimeError:
if time.monotonic() >= deadline:
raise
time.sleep(.01)
def descendants(pid, proc_root=Path("/proc")):
"""Find the process tree through stat PPids, including nginx workers.
/proc/PID/task/PID/children is optional on some Linux kernels and may also
omit children created by another thread. Missing that file is not evidence
that a live process is a leaf. A single PPid-map scan avoids either case.
"""
children, seen = {}, set()
for entry in proc_root.iterdir():
if not entry.name.isdecimal():
continue
current = int(entry.name)
try:
fields = (entry / "stat").read_text().rsplit(")", 1)[1].split()
parent = int(fields[1])
except (FileNotFoundError, ProcessLookupError):
# A non-root process may finish while the snapshot is assembled.
continue
except (OSError, ValueError, IndexError) as error:
raise RuntimeError(f"cannot discover process tree from PID {current}: {error}") from error
if fields[0] in {"Z", "X", "x"}:
# A dead/zombie worker cannot satisfy the configured live count.
continue
seen.add(current)
children.setdefault(parent, []).append(current)
if pid not in seen:
raise RuntimeError(f"no readable live root process for PID {pid}")
result, pending = [], [pid]
while pending:
current = pending.pop()
if current in result:
continue
result.append(current)
pending.extend(sorted(children.get(current, []), reverse=True))
return result
def process_stats(pid):
try:
data = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
if data[0] in {"Z", "X", "x"}:
return None
ticks = os.sysconf("SC_CLK_TCK")
pages = os.sysconf("SC_PAGE_SIZE")
return {"pid": pid, "start_ticks": int(data[19]),
"cpu_seconds": (int(data[11]) + int(data[12])) / ticks,
"rss_bytes": int(data[21]) * pages,
"fd": len(list(Path(f"/proc/{pid}/fd").iterdir())),
"threads": int(data[17])}
except (FileNotFoundError, ProcessLookupError):
return None
except (OSError, ValueError, IndexError) as error:
raise RuntimeError(f"cannot read resource counters for PID {pid}: {error}") from error
class Monitor:
def __init__(self, roots, interval=.5, expected_processes=None):
self.roots = roots
self.expected_processes = expected_processes or {}
self.process_identities = {}
self.interval = interval
if interval <= 0:
raise ValueError("resource interval must be positive")
self.samples = []
self.errors = []
self.stop_event = threading.Event()
self.started = time.monotonic()
self.thread = threading.Thread(target=self.run, daemon=True)
def sample(self):
groups = {}
for label, root in self.roots.items():
processes = [stats for pid in descendants(root) if (stats := process_stats(pid)) is not None]
if not any(process["pid"] == root for process in processes):
raise RuntimeError(f"no readable live root process for {label} (PID {root})")
expected = self.expected_processes.get(label)
if expected is not None and len(processes) != expected:
raise RuntimeError(f"resource process count mismatch for {label} (PID {root}): "
f"expected {expected}, read {len(processes)}")
identities = {(process["pid"], process["start_ticks"]) for process in processes}
if expected is not None:
original = self.process_identities.setdefault(label, identities)
if identities != original:
raise RuntimeError(f"process identities changed while measuring {label}: "
f"expected {sorted(original)}, read {sorted(identities)}")
groups[label] = {"processes": processes,
"rss_bytes": sum(p["rss_bytes"] for p in processes),
"fd": sum(p["fd"] for p in processes),
"threads": sum(p["threads"] for p in processes)}
self.samples.append({"seconds": time.monotonic() - self.started, "groups": groups})
def run(self):
while not self.stop_event.wait(self.interval):
try:
self.sample()
except Exception as error:
self.errors.append(str(error))
self.stop_event.set()
def start(self):
self.sample()
self.thread.start()
def stop(self):
self.stop_event.set()
self.thread.join(timeout=3)
if self.thread.is_alive():
self.errors.append("resource sampler did not stop")
try:
self.sample()
except Exception as error:
self.errors.append(str(error))
def summary(self, requests):
if self.errors:
raise RuntimeError("resource sampling failed: " + "; ".join(self.errors))
result = {}
for label in self.roots:
observations = [sample["groups"][label] for sample in self.samples]
totals = {}
for group in observations:
for process in group["processes"]:
key = (process["pid"], process["start_ticks"])
low, high = totals.get(key, (process["cpu_seconds"], process["cpu_seconds"]))
totals[key] = (min(low, process["cpu_seconds"]), max(high, process["cpu_seconds"]))
if not totals:
raise RuntimeError(f"no readable process samples for {label}; refusing zero resource metrics")
seconds = sum(high - low for low, high in totals.values())
result[label] = {"cpu_seconds": seconds,
"cpu_us_per_request": seconds * 1_000_000 / requests if requests else None,
"rss_peak_bytes": max(group["rss_bytes"] for group in observations),
"rss_start_bytes": observations[0]["rss_bytes"],
"rss_end_bytes": observations[-1]["rss_bytes"],
"fd_peak": max(group["fd"] for group in observations),
"fd_start": observations[0]["fd"], "fd_end": observations[-1]["fd"],
"processes_seen": len(totals)}
return result
-325
View File
@@ -1,325 +0,0 @@
#!/usr/bin/env python3
"""Fixed-rate mixed traffic, controlled client closes and cache hot-toggle soak.
The default is a 30-minute live run. Short invocations must be explicitly marked
--exploratory and cannot satisfy the release soak gate. No run is auto-scheduled.
"""
from __future__ import annotations
import argparse
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
import copy
import hashlib
from http.client import HTTPConnection
import json
import os
from pathlib import Path
import platform
import statistics
import sys
import time
import traceback
from benchmark import counter_values
from common import Fixture, http, seal_results, sha256, write_json
from resources import Monitor
from telemetry import traffic_analytics_snapshot, traffic_analytics_delta
PAYLOADS = ["small.html", "large.bin", "huge.bin"]
def fetch(index, rust, expected, abort_every):
name = PAYLOADS[index % len(PAYLOADS)]
source = "proxy" if (index // len(PAYLOADS)) % 2 else "static"
port = rust.reverse_port if source == "proxy" else rust.static_port
started = time.monotonic()
case = {"index": index, "source": source, "file": name, "method": "GET", "intentional_abort": False}
try:
if abort_every and index % abort_every == 0:
case.update({"file": "huge.bin", "intentional_abort": True})
connection = HTTPConnection("127.0.0.1", port, timeout=10)
try:
connection.request("GET", "/huge.bin", headers={"Accept-Encoding": "identity"})
response = connection.getresponse()
body = response.read(128)
case.update({"status": response.status, "bytes": len(body),
"cache": response.getheader("X-Cache"),
"passed": response.status == 200 and body == expected["huge.bin"][:128]})
finally:
connection.close()
else:
headers = {"Accept-Encoding": "identity"}
method = "HEAD" if index % 29 == 0 else "GET"
if method == "GET" and index % 37 == 0:
headers["Range"] = "bytes=0-127"
expected_status, expected_body = 206, expected[name][:128]
else:
expected_status, expected_body = 200, b"" if method == "HEAD" else expected[name]
status, response_headers, body = http(port, "/" + name, method, headers)
case.update({"method": method, "status": status, "bytes": len(body),
"cache": response_headers.get("x-cache"),
"passed": status == expected_status and body == expected_body})
if not case["passed"]:
case.update({"expected_status": expected_status, "expected_bytes": len(expected_body),
"body_sha256": hashlib.sha256(body).hexdigest()})
except Exception as exc:
case.update({"passed": False, "error": str(exc)})
case["seconds"] = time.monotonic() - started
return case
def probe_cache_mode(rust, expected, enabled, phase, out, observations):
"""Record two exact GETs per source, separate from the paced workload."""
phase_observations = []
for source, port in [("proxy", rust.reverse_port), ("static", rust.static_port)]:
replies = []
for attempt in [1, 2]:
try:
status, headers, body = http(port, "/small.html?soak_cache_probe=1",
headers={"Accept-Encoding": "identity"})
replies.append({"attempt": attempt, "status": status, "cache": headers.get("x-cache"),
"bytes": len(body), "sha256": hashlib.sha256(body).hexdigest(),
"body_passed": status == 200 and body == expected["small.html"]})
except Exception as error:
replies.append({"attempt": attempt, "body_passed": False, "error": str(error)})
mode_passed = replies[1].get("cache") == "HIT" if enabled else all(
reply.get("cache") is None for reply in replies)
phase_observations.append({"phase": phase, "source": source, "cache_enabled": enabled,
"responses": replies,
"passed": all(reply["body_passed"] for reply in replies) and mode_passed})
observations.extend(phase_observations)
write_json(out / "cache-probes.json", observations)
if not all(observation["passed"] for observation in phase_observations):
raise RuntimeError(f"cache mode/body probe failed after {phase}; see cache-probes.json")
def backend_drain_snapshot(rust):
status, _, body = rust.admin("/api/status")
if status != 200:
return {"passed": False, "error": f"admin status HTTP {status}"}
backends = []
def walk(value):
if isinstance(value, dict):
if "id" in value and "active" in value and "status" in value and "requests" in value:
backends.append({key: value[key] for key in ("id", "active", "status", "requests")})
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(json.loads(body))
return {"passed": bool(backends) and all(backend["active"] == 0 for backend in backends),
"backends": backends}
def memory_trend(samples, label="server"):
observations = [(sample["seconds"], sample["groups"][label]["rss_bytes"], sample["groups"][label]["fd"])
for sample in samples if sample["seconds"] >= 60]
if len(observations) < 3:
return {"available": False, "reason": "less than three samples after the first minute"}
tail_seconds = observations[-1][0]
early = [row[1] for row in observations if row[0] < observations[0][0] + 60]
late = [row[1] for row in observations if row[0] >= tail_seconds - 60]
x_mean = statistics.mean(row[0] for row in observations)
y_mean = statistics.mean(row[1] for row in observations)
denominator = sum((row[0] - x_mean) ** 2 for row in observations)
slope = sum((row[0] - x_mean) * (row[1] - y_mean) for row in observations) / denominator if denominator else 0
return {"available": True, "rss_first_minute_after_warmup_median": statistics.median(early),
"rss_last_minute_median": statistics.median(late),
"rss_growth_bytes": statistics.median(late) - statistics.median(early),
"rss_linear_bytes_per_hour": slope * 3600, "fd_first": observations[0][2],
"fd_last": observations[-1][2], "fd_peak": max(row[2] for row in observations),
"interpretation": "Descriptive only: cache toggles and allocator retention can change RSS without a leak"}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", type=Path, required=True)
parser.add_argument("--label", default="after")
parser.add_argument("--nginx", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--duration", type=int, default=1800)
parser.add_argument("--rate", type=float, default=100, help="offered requests per second, not a saturation benchmark")
parser.add_argument("--clients", type=int, default=8)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--origin-workers", type=int, default=2)
parser.add_argument("--server-cpus")
parser.add_argument("--origin-cpus")
parser.add_argument("--toggle-seconds", type=int, default=120)
parser.add_argument("--sample-seconds", type=int, default=5)
parser.add_argument("--abort-every", type=int, default=101)
parser.add_argument("--cooldown-seconds", type=int, default=15)
parser.add_argument("--exploratory", action="store_true")
args = parser.parse_args()
if args.duration < 1800 and not args.exploratory:
parser.error("the soak gate requires >=1800 seconds; label short diagnostics with --exploratory")
if args.rate <= 0 or not 1 <= args.clients <= 128 or args.toggle_seconds < 1 or args.sample_seconds < 1:
parser.error("rate, sample/toggle intervals and clients must be positive; clients <=128")
args.out = args.out.resolve()
if args.out.exists() and any(args.out.iterdir()):
parser.error("--out must be a new or empty evidence directory")
args.out.mkdir(parents=True, exist_ok=True)
settings = {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()}
write_json(args.out / "environment.json", {
"kind": "actual RustProxy fixed-rate mixed-traffic resource soak", "simulation": False,
"settings": settings, "platform": platform.platform(), "binary_sha256": sha256(args.binary),
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"frontend_connection_policy": "A new HTTP/1.1 TCP connection for each workload request and probe",
"controlled_close_interpretation": "Client closes after reading 128 bytes; the server may already have sent the full body. No server-cancellation count is inferred.",
"traffic_analytics_observation_policy": "Global counters are read before traffic, periodically with status diagnostics, and after final drain. Deltas include cache-mode probes and cooldown, and are separate from HTTP errors.",
"not_covered": ["24-hour soak", "TLS/H2/H3 soak", "multi-backend failures", "PHP-FPM",
"real application write load", "origin DNS/TLS rotation", "production traffic",
"long-lived frontend keepalive connections"]})
totals = {"submitted": 0, "completed": 0, "passed": 0, "failed": 0, "controlled_client_closes": 0,
"response_bytes": 0, "scheduler_late_events": 0, "peak_pending": 0}
failure_examples, progress, toggles, counter_samples = [], [], [], []
traffic_samples, traffic_delta = [], None
cache_probes, cache_status_counts = [], {"proxy": Counter(), "static": Counter()}
drain_snapshot, configuration_restored = None, False
samples, resources = [], {}
fatal, runtime_seconds = None, 0
try:
with Fixture(args.out / "fixture", workers=args.workers, server_cpus=args.server_cpus,
origin_cpus=args.origin_cpus, origin_workers=args.origin_workers) as fixture:
nginx = fixture.nginx(args.nginx, frontend=False)
rust = fixture.rust(args.binary, args.label)
rust.login()
rust.wait_quiescent()
expected = {name: (fixture.site / name).read_bytes() for name in PAYLOADS}
config = copy.deepcopy(rust.base)
config["cache"].update({"expose_status": True})
config["web_services"][0]["cache_control"] = "public, max-age=60"
rust.apply(config, "soak-initial")
probe_cache_mode(rust, expected, False, "initial-off", args.out, cache_probes)
traffic_before = traffic_analytics_snapshot(rust)
traffic_samples.append({"phase": "before", "seconds": 0, "snapshot": traffic_before})
write_json(args.out / "traffic-analytics.json", traffic_samples)
monitor = Monitor({"server": rust.process.pid, "origin": nginx["origin"].pid}, args.sample_seconds)
monitor.start()
start = time.monotonic()
deadline = start + args.duration
next_toggle, next_snapshot, next_submit = start + args.toggle_seconds, start, start
pending = set()
def consume(done):
for future in done:
result = future.result()
totals["completed"] += 1
totals["passed" if result["passed"] else "failed"] += 1
totals["controlled_client_closes"] += int(result["intentional_abort"])
totals["response_bytes"] += result.get("bytes", 0)
cache_status = (result.get("cache") or "absent") if "status" in result else "no_response"
cache_status_counts[result["source"]][cache_status] += 1
if not result["passed"] and len(failure_examples) < 100:
failure_examples.append(result)
try:
with ThreadPoolExecutor(max_workers=args.clients) as pool:
while time.monotonic() < deadline:
now = time.monotonic()
done = {future for future in pending if future.done()}
consume(done)
pending -= done
if now >= next_toggle:
config["cache"]["enabled"] = not config["cache"]["enabled"]
rust.apply(config, f"soak-toggle-{len(toggles) + 1}")
toggles.append({"seconds": time.monotonic() - start, "cache_enabled": config["cache"]["enabled"]})
write_json(args.out / "cache-toggles.json", toggles)
probe_cache_mode(rust, expected, config["cache"]["enabled"],
f"toggle-{len(toggles)}", args.out, cache_probes)
next_toggle += args.toggle_seconds
if now >= next_snapshot:
status, headers, body = rust.admin("/api/status")
if status != 200:
raise RuntimeError(f"admin status became unavailable: HTTP {status}")
counter_samples.append({"seconds": time.monotonic() - start,
"cache_enabled": config["cache"]["enabled"],
"counters": counter_values(json.loads(body))})
traffic_now = traffic_analytics_snapshot(rust)
traffic_delta = traffic_analytics_delta(traffic_before, traffic_now)
traffic_samples.append({"phase": "during", "seconds": time.monotonic() - start,
"snapshot": traffic_now, "delta_from_start": traffic_delta})
row = {"seconds": time.monotonic() - start, **totals,
"cache_enabled": config["cache"]["enabled"], "pending": len(pending),
"observed_cache_status_counts": {source: dict(counts)
for source, counts in cache_status_counts.items()}}
progress.append(row)
write_json(args.out / "progress.json", progress)
write_json(args.out / "status-counters.json", counter_samples)
write_json(args.out / "traffic-analytics.json", traffic_samples)
write_json(args.out / "resources.json", list(monitor.samples))
print(json.dumps(row), flush=True)
next_snapshot = time.monotonic() + args.sample_seconds
now = time.monotonic()
if len(pending) >= args.clients * 2:
done, pending = wait(pending, timeout=.1, return_when=FIRST_COMPLETED)
consume(done)
continue
if now < next_submit:
time.sleep(min(.02, next_submit - now))
continue
if now > next_submit + .25:
totals["scheduler_late_events"] += 1
next_submit = now
totals["submitted"] += 1
pending.add(pool.submit(fetch, totals["submitted"], rust, expected, args.abort_every))
totals["peak_pending"] = max(totals["peak_pending"], len(pending))
next_submit += 1 / args.rate
done, pending = wait(pending, timeout=30)
consume(done)
if pending:
raise RuntimeError("requests failed to settle during bounded soak drain")
runtime_seconds = time.monotonic() - start
rust.reset()
configuration_restored = True
cooldown_until = time.monotonic() + args.cooldown_seconds
while time.monotonic() < cooldown_until:
time.sleep(max(0, min(.5, cooldown_until - time.monotonic())))
probe_cache_mode(rust, expected, False, "restored-off", args.out, cache_probes)
for _ in range(40):
drain_snapshot = backend_drain_snapshot(rust)
if drain_snapshot["passed"]:
break
time.sleep(.05)
write_json(args.out / "backend-drain.json", drain_snapshot)
if not drain_snapshot["passed"]:
raise RuntimeError("backend active exchanges did not drain to zero")
traffic_after = traffic_analytics_snapshot(rust)
traffic_delta = traffic_analytics_delta(traffic_before, traffic_after)
traffic_samples.append({"phase": "after_drain", "seconds": time.monotonic() - start,
"snapshot": traffic_after, "delta_from_start": traffic_delta})
finally:
monitor.stop()
samples = monitor.samples
resources = monitor.summary(totals["completed"])
write_json(args.out / "resources.json", samples)
write_json(args.out / "status-counters.json", counter_samples)
write_json(args.out / "traffic-analytics.json", traffic_samples)
write_json(args.out / "failure-examples.json", failure_examples)
except Exception as exc:
fatal = {"error": str(exc), "traceback": traceback.format_exc()}
write_json(args.out / "failure.json", fatal)
report = {"completed": fatal is None, "simulation": False, "exploratory": args.exploratory,
"duration_seconds": runtime_seconds, "soak_duration_gate": runtime_seconds >= 1800,
"totals": totals, "resources": resources, "memory_trend": memory_trend(samples) if samples else None,
"cache_toggles": len(toggles), "errors": failure_examples,
"traffic_analytics": {"observations": len(traffic_samples), "delta": traffic_delta,
"includes_cache_mode_probes_and_cooldown": True},
"cache_mode_probes": {"source_checks": len(cache_probes),
"requests": sum(len(probe["responses"]) for probe in cache_probes),
"passed": bool(cache_probes) and all(probe["passed"] for probe in cache_probes)},
"observed_cache_status_counts": cache_status_counts,
"configuration_restored": configuration_restored, "backend_drain": drain_snapshot,
"actual_completed_rps": totals["completed"] / runtime_seconds if runtime_seconds else None,
"passed": fatal is None and totals["failed"] == 0 and runtime_seconds >= args.duration}
write_json(args.out / "report.json", report)
seal_results(args.out)
print(json.dumps(report), flush=True)
return 0 if report["passed"] else 1
if __name__ == "__main__":
sys.exit(main())
-47
View File
@@ -1,47 +0,0 @@
"""Selected counters from existing authenticated APIs, without traffic records."""
from __future__ import annotations
import json
import time
def traffic_analytics_snapshot(rust, timeout=2):
"""Read global analytics queue loss; never retain paths, IPs or user agents.
queue_dropped is process-global even when the API's vhost query is used.
recorded_requests is asynchronous analytics work, not an HTTP success count.
Call outside a timed load interval so this control-plane read is not charged
to the request path being measured.
"""
status, _, body = rust.admin("/api/traffic", timeout=timeout)
if status != 200:
raise RuntimeError(f"traffic analytics counter capture failed: HTTP {status}")
payload = json.loads(body)
if not isinstance(payload, dict) or payload.get("scope") != "*" or payload.get("since") != "process_start":
raise RuntimeError("traffic analytics requires the global process-start scope")
analytics = payload.get("analytics")
if not isinstance(analytics, dict) or analytics.get("scope") != "*":
raise RuntimeError("traffic analytics response lacks the global queue view")
data = analytics.get("data")
if not isinstance(data, dict):
raise RuntimeError("traffic analytics response lacks the recorded counters")
values = {"queue_capacity": analytics.get("queue_capacity"),
"queue_dropped": analytics.get("queue_dropped"),
"recorded_requests": data.get("requests")}
if any(type(value) is not int or value < 0 for value in values.values()) or not values["queue_capacity"]:
raise RuntimeError("traffic analytics queue counters are absent or malformed")
return {"endpoint": "/api/traffic", "scope": "*", "since": "process_start",
"pid": rust.process.pid, "observed_monotonic": time.monotonic(), **values}
def traffic_analytics_delta(before, after):
"""Keep telemetry loss separate from failed HTTP requests and log rotation."""
for key in ["pid", "endpoint", "scope", "since", "queue_capacity"]:
if before[key] != after[key]:
raise RuntimeError(f"traffic analytics identity changed during capture: {key}")
result = {"queue_dropped_delta": after["queue_dropped"] - before["queue_dropped"],
"recorded_requests_delta": after["recorded_requests"] - before["recorded_requests"],
"interval_seconds": after["observed_monotonic"] - before["observed_monotonic"]}
if any(value < 0 for value in result.values()):
raise RuntimeError("traffic analytics counters or clock reset during capture")
return result
-191
View File
@@ -1,191 +0,0 @@
"""Strict H2/TLS and H3/QUIC clients for local RustProxy acceptance tests.
The generated fixture certificate is explicitly trusted. Neither transport
silently falls back to HTTP/1.1 or disables certificate verification.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pathlib import Path
import ssl
import httpx
from aioquic.asyncio import QuicConnectionProtocol, connect
from aioquic.h3.connection import H3Connection, MessageError
from aioquic.h3.events import DataReceived, HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import ConnectionTerminated, ProtocolNegotiated, StreamReset
@dataclass
class Response:
status: int
headers: dict[str, str]
body: bytes
protocol: str
alpn: str | None
stream_id: int | None = None
class H2Client:
def __init__(self, client: httpx.AsyncClient, port: int):
self.client, self.port = client, port
async def request(self, method, path, headers=None, body=None):
async def chunks():
for offset in range(0, len(body), 16384):
yield body[offset:offset + 16384]
await asyncio.sleep(0)
response = await self.client.request(
method, f"https://127.0.0.1:{self.port}{path}",
headers=headers or {}, content=chunks() if body is not None else None,
)
network = response.extensions.get("network_stream")
tls = network.get_extra_info("ssl_object") if network else None
alpn = tls.selected_alpn_protocol() if tls else None
if response.http_version != "HTTP/2" or alpn != "h2":
raise AssertionError(f"H2 required, got {response.http_version}, ALPN={alpn!r}")
return Response(response.status_code, dict(response.headers), response.content,
"HTTP/2", alpn, response.extensions.get("stream_id"))
@dataclass
class Pending:
future: asyncio.Future
status: int = 0
headers: dict[str, str] = field(default_factory=dict)
body: bytearray = field(default_factory=bytearray)
class HeadAwareH3Connection(H3Connection):
"""Supply request-method context missing from aioquic 1.7.0's length check.
RFC 9110 section 8.6 permits HEAD's Content-Length to describe a GET body.
Still reject any actual HEAD DATA; keep aioquic's exact length check for all
other requests. This changes only the test client, never the target server.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.head_streams = set()
def _check_content_length(self, stream):
if stream.stream_id in self.head_streams:
if stream.content_length:
raise MessageError("HEAD response contained DATA")
return
super()._check_content_length(stream)
def validate_head_adapter():
"""Keep adapter checks separate from the real-server acceptance totals."""
from types import SimpleNamespace
connection = HeadAwareH3Connection.__new__(HeadAwareH3Connection)
connection.head_streams = {0}
for stream_id, declared, received, valid in [(0, 1040, 0, True), (0, 1040, 1, False),
(4, 1040, 1040, True), (4, 1040, 0, False)]:
stream = SimpleNamespace(stream_id=stream_id, expected_content_length=declared,
content_length=received)
try:
connection._check_content_length(stream)
accepted = True
except MessageError:
accepted = False
assert accepted == valid, "H3 fixture HEAD adapter changed ordinary framing validation"
return {"cases": 4, "passed": 4, "kind": "test-client self-test, not RustProxy coverage"}
class H3Client(QuicConnectionProtocol):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.http = HeadAwareH3Connection(self._quic)
self.pending: dict[int, Pending] = {}
self.alpn = None
self.authority = None
def quic_event_received(self, event):
if isinstance(event, ProtocolNegotiated):
self.alpn = event.alpn_protocol
if isinstance(event, ConnectionTerminated):
for item in self.pending.values():
if not item.future.done():
item.future.set_exception(ConnectionError(
f"QUIC closed code={event.error_code}: {event.reason_phrase}"))
if isinstance(event, StreamReset):
item = self.pending.get(event.stream_id)
if item is not None and not item.future.done():
item.future.set_exception(ConnectionError(
f"H3 stream reset code={event.error_code}"))
for http_event in self.http.handle_event(event):
if not isinstance(http_event, (HeadersReceived, DataReceived)):
continue
item = self.pending.get(http_event.stream_id)
if item is None or item.future.done():
continue
if isinstance(http_event, HeadersReceived):
for name, value in http_event.headers:
if name == b":status":
item.status = int(value)
elif not name.startswith(b":"):
item.headers[name.decode("ascii").lower()] = value.decode("latin-1")
else:
item.body.extend(http_event.data)
if len(item.body) > 4 * 1024 * 1024:
item.future.set_exception(ValueError("fixture response exceeded 4 MiB bound"))
continue
if http_event.stream_ended:
item.future.set_result(Response(item.status, item.headers, bytes(item.body),
"HTTP/3", self.alpn, http_event.stream_id))
async def request(self, method, path, headers=None, body=None):
if self.alpn != "h3":
raise AssertionError(f"H3 required, got ALPN={self.alpn!r}")
stream_id = self._quic.get_next_available_stream_id()
item = Pending(asyncio.get_running_loop().create_future())
self.pending[stream_id] = item
if method == "HEAD":
self.http.head_streams.add(stream_id)
fields = [(b":method", method.encode("ascii")), (b":scheme", b"https"),
(b":authority", self.authority.encode("ascii")),
(b":path", path.encode("ascii"))]
fields += [(name.lower().encode("ascii"), value.encode("latin-1"))
for name, value in (headers or {}).items()]
try:
self.http.send_headers(stream_id, fields, end_stream=body is None)
self.transmit()
if body is not None:
if not body:
self.http.send_data(stream_id, b"", end_stream=True)
self.transmit()
for offset in range(0, len(body), 16384):
end = min(offset + 16384, len(body))
self.http.send_data(stream_id, body[offset:end], end_stream=end == len(body))
self.transmit()
await asyncio.sleep(0)
return await asyncio.wait_for(item.future, timeout=10)
finally:
self.pending.pop(stream_id, None)
self.http.head_streams.discard(stream_id)
@asynccontextmanager
async def client(protocol: str, port: int, certificate: Path):
if protocol == "h2":
context = ssl.create_default_context(cafile=str(certificate))
async with httpx.AsyncClient(http2=True, verify=context, trust_env=False,
timeout=10, limits=httpx.Limits(max_connections=1)) as session:
yield H2Client(session, port)
elif protocol == "h3":
config = QuicConfiguration(is_client=True, alpn_protocols=["h3"],
server_name="localhost", idle_timeout=10)
config.verify_mode = ssl.CERT_REQUIRED
config.load_verify_locations(cafile=str(certificate))
async with connect("127.0.0.1", port, configuration=config,
create_protocol=H3Client) as session:
session.authority = f"127.0.0.1:{port}"
yield session
else:
raise ValueError(f"unsupported fixture protocol {protocol!r}")
-281
View File
@@ -1,281 +0,0 @@
#!/usr/bin/env python3
"""Real, bounded RustProxy H2/TLS, H3/QUIC and H1 WebSocket acceptance.
Install Python dependencies aioquic, httpx[http2] and websockets. Example:
python3 scripts/performance/transport_regression.py --binary before=/abs/binary \
--binary after=/abs/binary --out /abs/results --python-deps /abs/optional/pydeps
Only fresh temporary databases and loopback listeners are used. Certificates
are generated by RustProxy and verified by clients; no protocol fallback is used.
This is an interoperability/functional suite, not a throughput benchmark.
"""
from __future__ import annotations
import argparse
import asyncio
import copy
import hashlib
import importlib.metadata
import json
from pathlib import Path
import platform
import socket
import sys
import time
import traceback
from common import Fixture, seal_results, sha256, write_json
from peer import Peer
def response_details(response):
return {"status": response.status, "protocol": response.protocol,
"alpn": response.alpn, "stream_id": response.stream_id,
"bytes": len(response.body), "sha256": hashlib.sha256(response.body).hexdigest(),
"cache": response.headers.get("x-cache"),
"cache_reason": response.headers.get("x-cache-reason")}
def tls_port(fixture):
while True:
port = fixture.port()
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
try:
udp.bind(("127.0.0.1", port))
return port
except OSError:
continue
def transport_config(rust, reverse_tls, static_tls, websocket_port, websocket_origin):
config = copy.deepcopy(rust.base)
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
identifier = f"{source}-tls"
config["listeners"]["endpoints"].append({
"id": identifier, "name": identifier, "scope": "internal", "enabled": True,
"binding": {"address": f"127.0.0.1:{port}", "tls": True, "http2": True,
"http3": {"max_concurrent_connections": 16,
"max_concurrent_bidistream": 16,
"max_concurrent_unistream": 16, "max_idle_timeout": 10}}})
vhost = next(v for v in config["vhosts"] if v["id"] == source)
vhost["frontend"]["listener_ids"].append(identifier)
vhost["http2_enabled"] = True
vhost["http3_enabled"] = True
config["listeners"]["endpoints"].append({
"id": "websocket", "name": "WebSocket", "scope": "internal", "enabled": True,
"binding": {"address": f"127.0.0.1:{websocket_port}", "tls": False,
"http2": False, "http3": None}})
config["vhosts"].append({
"id": "websocket", "frontend": {"hosts": ["127.0.0.1", "localhost"],
"listener_ids": ["websocket"]},
"websockets": True,
"backends": [{"id": "echo", "url": f"http://127.0.0.1:{websocket_origin}",
"allow_private": True}],
"load_balancing": {"health_check": {"mode": "off"}}, "routes": []})
config["cache"].update({"enabled": False, "expose_status": True, "expose_hits": True,
"expose_cache_status": True, "expose_only_hits": False})
config["compression"] = {"gzip": False, "br": False, "zstd": False, "deflate": False}
config["web_services"][0]["cache_control"] = "public, max-age=60"
return config
async def http_cases(check, session, protocol, source, cache, fixture):
prefix = f"{protocol}.{source}.cache_{'on' if cache else 'off'}"
expected = (fixture.site / "small.html").read_bytes()
path = f"/transport-{protocol}.html"
first = await session.request("GET", path)
check(prefix + ".get", first.status == 200 and first.body == expected,
**response_details(first))
second = await session.request("GET", path)
check(prefix + ".cache_delivery", second.status == 200 and second.body == expected
and ((first.headers.get("x-cache") == "MISS" and second.headers.get("x-cache") == "HIT")
if cache else second.headers.get("x-cache") not in ("MISS", "HIT")),
first_cache=first.headers.get("x-cache"), **response_details(second))
head = await session.request("HEAD", path)
check(prefix + ".head", head.status == 200 and head.body == b""
and head.headers.get("content-length") == str(len(expected)), **response_details(head))
etag = second.headers.get("etag")
check(prefix + ".validator", bool(etag), etag=etag)
ranged = await session.request("GET", path, {"range": "bytes=11-40", "if-range": etag or ""})
check(prefix + ".range", ranged.status == 206 and ranged.body == expected[11:41]
and ranged.headers.get("content-range") == f"bytes 11-40/{len(expected)}",
**response_details(ranged))
mismatch = await session.request("GET", path, {"range": "bytes=11-40", "if-range": '"different"'})
check(prefix + ".if_range_mismatch", mismatch.status == 200 and mismatch.body == expected,
**response_details(mismatch))
conditional = await session.request("GET", path, {"if-none-match": etag or ""})
check(prefix + ".conditional_304", conditional.status == 304 and conditional.body == b"",
**response_details(conditional))
invalid = await session.request("GET", path, {"range": "bytes=999999-"})
check(prefix + ".range_416", invalid.status == 416 and invalid.body == b""
and invalid.headers.get("content-range") == f"bytes */{len(expected)}",
**response_details(invalid))
for name in ["large.bin", "huge.bin"]:
expected_file = (fixture.site / name).read_bytes()
response = await session.request("GET", "/" + name)
check(prefix + ".get." + name, response.status == 200 and response.body == expected_file,
**response_details(response))
parallel = await asyncio.gather(*(session.request("GET", "/large.bin") for _ in range(4)))
expected_large = (fixture.site / "large.bin").read_bytes()
check(prefix + ".multiplex_four_streams", all(r.status == 200 and r.body == expected_large for r in parallel)
and len({r.stream_id for r in parallel}) == 4,
stream_ids=[r.stream_id for r in parallel], response_bytes=[len(r.body) for r in parallel])
if source == "reverse":
for payload in [b"", (bytes(range(256)) * 512) + b"streamed-end"]:
response = await session.request("POST", "/echo?transport=preserved",
{"content-type": "application/octet-stream"}, payload)
data = json.loads(response.body) if response.status == 200 else {}
check(prefix + f".post_stream_{len(payload)}", response.status == 200
and data.get("method") == "POST" and data.get("body_bytes") == len(payload)
and data.get("body_sha256") == hashlib.sha256(payload).hexdigest()
and data.get("path") == "/echo?transport=preserved",
upload_bytes=len(payload), **response_details(response))
else:
response = await session.request("POST", path, body=b"static-method-gate")
check(prefix + ".post_static_method_gate", response.status == 405, **response_details(response))
async def websocket_cases(check, port, cache):
import websockets
prefix = f"h1.websocket.cache_{'on' if cache else 'off'}"
async with websockets.connect(f"ws://127.0.0.1:{port}/echo", open_timeout=5,
close_timeout=3, ping_interval=None, compression=None) as session:
text = "RustProxy echo: Zażółć gęślą jaźń"
await session.send(text)
returned = await asyncio.wait_for(session.recv(), timeout=5)
check(prefix + ".text_echo", returned == text)
binary = bytes(range(256)) * 256
await session.send(binary)
returned = await asyncio.wait_for(session.recv(), timeout=5)
check(prefix + ".binary_echo", returned == binary, bytes=len(returned))
pong = await session.ping(b"rustproxy-transport")
await asyncio.wait_for(pong, timeout=5)
check(prefix + ".ping_pong", True)
await session.close(code=1000, reason="fixture complete")
check(prefix + ".normal_close", session.close_code == 1000, close_code=session.close_code)
async def run_variant(binary, label, output, workers):
import websockets
from transport_clients import client
cases = []
started = time.monotonic()
def check(case, passed, **details):
cases.append({"case": case, "passed": bool(passed), **details})
print(f"{label} {'PASS' if passed else 'FAIL'} {case}", flush=True)
write_json(output / "checks.json", cases)
try:
with Fixture(output, workers=workers) as fixture:
plain = (fixture.site / "small.html").read_bytes()
for name in ["transport-h2.html", "transport-h3.html", "transport-shared.html"]:
(fixture.site / name).write_bytes(plain)
peer = Peer(fixture.origin_port, fixture.site)
websocket_origin, websocket_port = fixture.port(), fixture.port()
async def echo(session, _path=None):
async for message in session:
await session.send(message)
try:
async with websockets.serve(echo, "127.0.0.1", websocket_origin,
ping_interval=None, compression=None, close_timeout=3):
rust = fixture.rust(binary, label)
reverse_tls, static_tls = tls_port(fixture), tls_port(fixture)
config = transport_config(rust, reverse_tls, static_tls, websocket_port, websocket_origin)
rust.apply(config, "transport-listeners")
fixture.stop(rust.process)
rust.start("transport")
certificate = rust.data / "certs" / "default.pem"
write_json(output / "transport.json", {
"certificate_sha256": sha256(certificate), "certificate_verification": True,
"h2_alpn_required": "h2", "h3_alpn_required": "h3",
"reverse_tls_tcp_udp": reverse_tls, "static_tls_tcp_udp": static_tls,
"websocket_h1_tcp": websocket_port, "fallback_allowed": False})
for cache in [False, True]:
if cache:
config["cache"]["enabled"] = True
rust.apply(config, "transport-cache-on")
for protocol in ["h2", "h3"]:
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
group = f"{protocol}.{source}.cache_{'on' if cache else 'off'}"
try:
async with client(protocol, port, certificate) as session:
await http_cases(check, session, protocol, source, cache, fixture)
except Exception as exc:
check(group + ".runner", False, error=str(exc), traceback=traceback.format_exc())
try:
await websocket_cases(check, websocket_port, cache)
except Exception as exc:
check(f"h1.websocket.cache_{'on' if cache else 'off'}.runner", False,
error=str(exc), traceback=traceback.format_exc())
for source, port in [("reverse", reverse_tls), ("static", static_tls)]:
try:
async with client("h2", port, certificate) as h2:
warm = await h2.request("GET", "/transport-shared.html")
async with client("h3", port, certificate) as h3:
hit = await h3.request("GET", "/transport-shared.html")
check(f"cache_cross_protocol.{source}.h2_to_h3",
warm.status == 200 and hit.status == 200 and hit.body == plain
and hit.headers.get("x-cache") == "HIT",
first_cache=warm.headers.get("x-cache"), **response_details(hit))
except Exception as exc:
check(f"cache_cross_protocol.{source}.runner", False,
error=str(exc), traceback=traceback.format_exc())
check("runtime.still_alive", rust.process.poll() is None)
finally:
peer.close()
except Exception as exc:
check("runtime.runner", False, error=str(exc), traceback=traceback.format_exc())
report = {"label": label, "simulation": False, "seconds": time.monotonic() - started,
"passed": sum(case["passed"] for case in cases),
"failed": sum(not case["passed"] for case in cases), "cases": cases}
write_json(output / "report.json", report)
print(f"{label}: {report['passed']} passed, {report['failed']} failed", flush=True)
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", action="append", required=True, metavar="LABEL=/ABS/PATH")
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--python-deps", type=Path)
args = parser.parse_args()
if args.python_deps:
sys.path.insert(0, str(args.python_deps.resolve()))
variants = {}
for value in args.binary:
label, separator, path = value.partition("=")
if not separator or not label.replace("-", "").replace("_", "").isalnum() or label in variants:
parser.error("--binary must be unique SIMPLE_LABEL=/ABS/PATH")
variants[label] = Path(path).resolve()
args.out = args.out.resolve()
args.out.mkdir(parents=True, exist_ok=True)
from transport_clients import validate_head_adapter
write_json(args.out / "environment.json", {
"kind": "real RustProxy transport acceptance", "simulation": False,
"python": sys.version, "platform": platform.platform(),
"dependencies": {name: importlib.metadata.version(name) for name in ["aioquic", "httpx", "h2", "websockets"]},
"aioquic_head_adapter": validate_head_adapter(),
"started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"not_covered": ["production certificates", "TLS upstreams", "H3 upstreams", "H2/H3 WebSocket extended CONNECT",
"network loss or migration", "24-hour soak", "real-browser navigation"]})
reports = {}
for label, binary in variants.items():
reports[label] = asyncio.run(run_variant(binary, label, args.out / label, args.workers))
write_json(args.out / "comparison.json", {
"variants": {label: {key: report[key] for key in ["passed", "failed", "seconds"]}
for label, report in reports.items()},
"cases": [{"case": case, **{label: next((row["passed"] for row in report["cases"] if row["case"] == case), "not_run")
for label, report in reports.items()}}
for case in sorted({row["case"] for report in reports.values() for row in report["cases"]})]})
seal_results(args.out)
return 1 if any(report["failed"] for report in reports.values()) else 0
if __name__ == "__main__":
sys.exit(main())
-14
View File
@@ -1,14 +0,0 @@
-- No response hook: benchmarks must not be limited by Lua body processing.
-- Known payload/status/headers are verified separately before measurement.
done = function(summary, latency, requests)
io.write("RUSTPROXY_BENCH_JSON {")
io.write(string.format('"requests":%d,"duration_us":%d,"bytes":%d,',
summary.requests, summary.duration, summary.bytes))
io.write(string.format('"p50_us":%.3f,"p95_us":%.3f,"p99_us":%.3f,',
latency:percentile(50), latency:percentile(95), latency:percentile(99)))
io.write(string.format('"latency_mean_us":%.3f,"latency_max_us":%.3f,', latency.mean, latency.max))
io.write(string.format('"errors_connect":%d,"errors_read":%d,"errors_write":%d,"errors_timeout":%d,"errors_status":%d',
summary.errors.connect, summary.errors.read, summary.errors.write,
summary.errors.timeout, summary.errors.status))
io.write("}\n")
end
+4 -4
View File
@@ -74,15 +74,15 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
flat = module.parent.with_suffix(".rs")
if flat.is_file():
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.31.9 source tree: missing {path}")
raise ValueError(f"Incomplete RustProxy 0.34.0 source tree: missing {path}")
# The Reader uses compile-time include_str! for its bundled Markdown. A
# dangling registration is a real compiler error, unlike an omitted old
# release audit. Match both reader_doc! and the expanded include_str! form.
registry = root / "src/admin/documentation/documents.rs"
if not registry.is_file():
raise ValueError("Incomplete RustProxy 0.31.9 source tree: missing src/admin/documentation/documents.rs")
raise ValueError("Incomplete RustProxy 0.34.0 source tree: missing src/admin/documentation/documents.rs")
embedded_docs = set(re.findall(
r'"(docs/[A-Za-z0-9_.-]+\.md)"', registry.read_text(encoding="utf-8")
))
@@ -101,7 +101,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
raise ValueError("src/admin/dashboard_presets.rs starts with an inner doc comment; use /// instead (Rust E0753)")
text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.31.9 entry point; extract the complete package, not individual files.")
raise ValueError("src/lib.rs does not match the expected entry point; extract the complete package, not individual files.")
return messages
@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""Overview traffic/connection heading cleanup against Chromium and the bundled JS.
Runs a simulated authenticated DOM rather than a live RustProxy service.
"""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
ROUTER = (ROOT / 'src/admin/router.rs').read_text(encoding='utf-8')
def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
result = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
result.append((ROOT / 'src/admin' / include.group(1)).read_text(encoding='utf-8'))
elif line == 'admin_vhosts_javascript(),':
result.append('(() => {\n' + '\n'.join(
(ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text(encoding='utf-8')
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
result.append(json.loads(line[:-1]))
return '\n'.join(result)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
bundle = app_bundle()
for width in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': width, 'height': 920})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=bundle)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
currentPanel = 'overview';
samples.splice(0); samples.push(1.5, 85.5, 12.3);
redrawOverviewHistoryCharts();
}''')
for density in ('normal', 'compact', 'ultra'):
for span in (3, 6):
data = page.evaluate('''({density,span}) => {
document.documentElement.dataset.dashboardDensity = density;
const visible = ['traffic_chart', 'connection_chart'];
const hidden = ADMIN_DASHBOARD_WIDGETS.filter(id => !visible.includes(id));
applyDashboardLayout([...visible,...hidden], hidden,
{traffic_chart:span,connection_chart:span});
const traffic = document.querySelector('[data-dashboard-widget="traffic_chart"]');
const connection = document.querySelector('[data-dashboard-widget="connection_chart"]');
const heading = traffic.querySelector('.card-heading');
const peak = traffic.querySelector('#chart-peak');
const peakRect = peak.getBoundingClientRect();
const cardRect = traffic.getBoundingClientRect();
const headingRect = heading.getBoundingClientRect();
return {
title: heading.querySelector('h2')?.textContent.trim(),
peak: peak.textContent,
peakInHeading: peak.parentElement === heading,
peakNotInFooter: !traffic.querySelector('.chart-footer #chart-peak'),
noTrafficEyebrow: !traffic.querySelector('.small-label'),
noTrafficLiveBadge: !traffic.querySelector('.pill.live'),
noConnectionLiveBadge: !connection.querySelector('.pill.live'),
connectionTitleIntact: !!connection.querySelector('.card-heading h3.overview-chart-heading[data-i18n="connectionActivity"]'),
peakInsideCard: peakRect.left >= cardRect.left - 1 && peakRect.right <= cardRect.right + 1,
peakInsideHeading: peakRect.top >= headingRect.top - 1 && peakRect.bottom <= headingRect.bottom + 1,
browserOverflow: document.documentElement.scrollWidth - innerWidth,
};
}''', {'density':density,'span':span})
assert data['title'] == 'Requests per second', (width, density, span, data)
assert data['peak'] == 'max 85.5 req/s', (width, density, span, data)
for key in ('peakInHeading', 'peakNotInFooter', 'noTrafficEyebrow',
'noTrafficLiveBadge', 'noConnectionLiveBadge',
'connectionTitleIntact', 'peakInsideCard', 'peakInsideHeading'):
assert data[key], (width, density, span, key, data)
assert data['browserOverflow'] <= 2, (width, density, span, data)
assert not errors, (width, errors)
print(f'PASS traffic/connection chart headers at {width}px (all densities, spans 3 and 6)')
page.close()
browser.close()
print('PASS Overview chart heading cleanup')
@@ -1,168 +0,0 @@
#!/usr/bin/env python3
"""All chart titles and series labels in simulated Chromium (no live Rust server).
Covers Overview, diagnostics, Web Server, Traffic, Cache/Transform, Vhosts,
Streams, and DOM-created chart/legend variants. Preserve localized DOM text.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
CHECK = r'''(settings) => {
const { density, compactBody, theme } = settings;
document.documentElement.dataset.dashboardDensity = density;
document.documentElement.dataset.theme = theme;
document.body.dataset.density = compactBody ? 'compact' : 'comfortable';
const titleSelectors = [
'#panel-overview .overview-wide-chart .card-heading h2',
'#panel-overview .activity-card .card-heading h2',
'#panel-overview :is(.system-card, .connection-card) .overview-chart-heading',
'#panel-overview :is(.stat-chart, .stat-donut) > span:first-child',
'.mini-live-chart > .mini-live-chart-head > span:first-child',
'.traffic-chart-card > h3.subheading',
'.function-live-chart-heading > strong',
'.vhost-chart-head > span:first-child',
];
const monoSelectors = [
'.overview-wide-summary', '.overview-wide-legend',
'.overview-donut-legend', '.www-method-legend', '.stat-chart-legend',
'.mini-chart-legend', '.connection-legend',
'.traffic-protocol-legend', '.traffic-chart-legend',
'.function-live-chart-legend', '.stream-chart-legend',
'.chart-footer', '.vhost-chart-head > :not(:first-child)',
'.mini-live-chart-head > b', '.mini-live-chart-head > .mini-live-chart-actions',
'.network-interface-rate',
'.function-live-chart-summary > b', '.function-live-chart-heading small',
'.function-live-chart-samples',
'#panel-overview .stat-chart > small',
'#panel-overview .overview-wide-chart > .fine',
'#panel-overview .www-top-row', '.traffic-bar-row',
'#panel-overview .system-card .card-heading h2',
'#panel-overview .system-card .card-heading .meta',
'#panel-overview .system-card .memory-line',
'#panel-overview .connection-card .card-heading h2',
];
const legendSelectors = [
'.overview-wide-legend', '.overview-donut-legend', '.www-ring-legend',
'.www-method-legend', '.stat-chart-legend', '.mini-chart-legend',
'.connection-legend', '.traffic-protocol-legend', '.traffic-chart-legend',
'.function-live-chart-legend', '.stream-chart-legend', '.stream-traffic-legend'
];
const legendNodes = [...document.querySelectorAll(legendSelectors.join(','))];
const requestLegend = document.querySelector('[data-dashboard-widget="traffic_chart"] .traffic-protocol-legend');
const referenceLegendSize = getComputedStyle(requestLegend).fontSize;
const titleNodes = Array.from(document.querySelectorAll(titleSelectors.join(',')));
const monoNodes = Array.from(document.querySelectorAll(monoSelectors.join(',')));
const base = document.createElement('div');
base.style.fontFamily = 'var(--mono)';
document.body.append(base);
const mono = getComputedStyle(base).fontFamily;
base.style.fontFamily = 'var(--sans)';
const sans = getComputedStyle(base).fontFamily;
base.remove();
const titleErrors = titleNodes.flatMap(node => {
const css = getComputedStyle(node);
const inOverview = !!node.closest('#panel-overview');
const isSmallCaption = inOverview && !!node.closest('.mini-live-chart-head');
const wantedFont = isSmallCaption ? mono : sans;
const wantedSize = isSmallCaption ? '10px' : '12px';
const wantedCase = inOverview ? 'none' : 'uppercase';
const problems = [];
if (css.fontFamily !== wantedFont) problems.push(`font=${css.fontFamily}`);
if (css.fontSize !== wantedSize) problems.push(`size=${css.fontSize}`);
if (css.textTransform !== wantedCase) problems.push(`case=${css.textTransform}`);
return problems.map(problem => `${node.outerHTML.slice(0, 100)}: ${problem}`);
});
const monoErrors = monoNodes.flatMap(node => {
const all = [node, ...node.querySelectorAll('*')].filter(el => el.tagName.toLowerCase() !== 'svg');
return all.filter(el => getComputedStyle(el).fontFamily !== mono)
.map(el => `${el.outerHTML.slice(0, 100)}: ${getComputedStyle(el).fontFamily}`);
});
const legendErrors = legendNodes.flatMap(root => {
const children = [root, ...root.querySelectorAll('span, b, strong, small, li, em')];
return children.flatMap(node => {
const style = getComputedStyle(node);
const issues = [];
if (style.fontSize !== referenceLegendSize) issues.push(`size=${style.fontSize}`);
if (style.fontFamily !== mono) issues.push(`font=${style.fontFamily}`);
return issues.map(issue => `${node.outerHTML.slice(0, 95)} ${issue}`);
});
});
// Keep localized Overview titles in sentence case; other sections retain
// their independent uppercase chart-heading convention.
const compression = document.querySelector('[data-dashboard-widget="wide_compression_activity"]');
const compressionHeading = compression.querySelector('.card-heading h2');
const compressionLegend = compression.querySelector('.overview-wide-legend');
const originalTranslation = compressionHeading.textContent.trim() === 'Aktywność kompresji';
const legendFonts = [...compressionLegend.querySelectorAll('span, b')]
.map(el => getComputedStyle(el).fontFamily);
const unrelatedHeading = document.querySelector('#panel-traffic .card-heading h2');
const numericReading = document.querySelector('#system-cpu');
return {
counts: {titles: titleNodes.length, mono: monoNodes.length, legends: legendNodes.length},
titleErrors, monoErrors, legendErrors, referenceLegendSize, mono, sans,
originalTranslation, compressionTitleSize: getComputedStyle(compressionHeading).fontSize,
compressionTitleCase: getComputedStyle(compressionHeading).textTransform,
compressionLegendIsMono: legendFonts.every(font => font === mono),
unrelatedTitleSize: getComputedStyle(unrelatedHeading).fontSize,
numericReadingIsMono: getComputedStyle(numericReading).fontFamily === mono,
};
}'''
with sync_playwright() as api:
browser = api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': width, 'height': 960})
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.evaluate('''() => {
// Representative dynamically rendered Vhost/Web Service/Stream charts.
for (const panel of ['panel-vhosts','panel-web-server','panel-streams']) {
document.getElementById(panel).insertAdjacentHTML('beforeend', `
<div class="vhost-chart"><div class="vhost-chart-head">
<span class="small-label">Connection history</span>
<span class="fine">42 req/s</span></div>
<svg viewBox="0 0 300 54"><path d=""></path></svg>
<div class="stream-chart-legend"><span>RX 2 MB/s</span><span>TX 1 MB/s</span></div>
</div>`);
}
const comp = document.querySelector('[data-dashboard-widget="wide_compression_activity"] .card-heading h2');
comp.textContent = 'Aktywność kompresji';
// The traffic renderer inserts these series dynamically.
document.querySelectorAll('.traffic-chart-legend').forEach(root => {
root.innerHTML = `<li><span class="traffic-chart-label">GET requests</span><b>12</b></li>`;
});
const sample = document.createElement('div');
sample.innerHTML = `<div class="www-top-row"><span>php-worker</span><b>11/s</b></div>
<div class="traffic-bar-row"><span class="traffic-bar-label">GET</span><b class="traffic-bar-value">22</b></div>
<div class="network-interface-rate">100 Mb/s</div>`;
document.getElementById('panel-overview').append(sample);
}''')
for density in ('normal','compact','ultra'):
for compact_body in (False, True):
for theme in ('dark', 'light'):
result = page.evaluate(CHECK, {'density': density, 'compactBody': compact_body, 'theme': theme})
description = (width, density, compact_body, theme)
assert result['counts']['titles'] >= 43, (description, result['counts'])
assert result['counts']['mono'] >= 90, (description, result['counts'])
assert not result['titleErrors'], (description, result['titleErrors'][:10])
assert not result['monoErrors'], (description, result['monoErrors'][:10])
assert result['counts']['legends'] >= 39, (description, result['counts'])
assert result['referenceLegendSize'] == '9px', description
assert not result['legendErrors'], (description, result['legendErrors'][:10])
assert result['originalTranslation'], description
assert result['compressionTitleSize'] == '12px', description
assert result['compressionTitleCase'] == 'none', description
assert result['compressionLegendIsMono'], description
assert result['numericReadingIsMono'], description
assert result['unrelatedTitleSize'] != '12px', (description, result['unrelatedTitleSize'])
print(f'PASS all chart families at {width}px (3 dashboard densities, 2 interface densities, 2 themes)')
page.close()
browser.close()
@@ -1,110 +0,0 @@
#!/usr/bin/env python3
"""Real-browser regression for Overview chart content when card width changes.
Runs the production HTML/CSS/assembled admin JS against a simulated DOM and
fake network-interface samples. This does not exercise Rust HTTP/SSE.
"""
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
scripts = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
scripts.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
scripts.append('(() => {\n' + '\n'.join(
(ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
scripts.append(json.loads(line[:-1]))
return '\n'.join(scripts)
def inspect(page):
return page.evaluate('''() => {
const selectors = {
'websocket_protocol':'[data-dashboard-widget="websocket_protocol"] svg',
'wide_transform_activity':'[data-dashboard-widget="wide_transform_activity"] svg',
'www_requests':'[data-dashboard-widget="www_requests"] svg',
'traffic_chart':'[data-dashboard-widget="traffic_chart"] svg',
'system_chart':'[data-dashboard-widget="system_chart"] svg',
'cache_diagnostics':'[data-dashboard-widget="cache_diagnostics"] .mini-live-chart svg',
'network_diagnostics':'[data-dashboard-widget="network_diagnostics"] .mini-live-chart svg',
'network_interface':'[data-dashboard-widget="network_diagnostics"] .network-interface-row svg',
};
return Object.fromEntries(Object.entries(selectors).map(([name, selector]) => {
const svg = document.querySelector(selector);
if (!svg) throw new Error('Missing SVG: ' + name);
const box = svg.getBoundingClientRect();
const matrix = svg.getScreenCTM();
const viewBox = svg.viewBox.baseVal;
const contentWidth = Math.hypot(matrix.a, matrix.b) * viewBox.width;
const series = svg.querySelector('.overview-wide-series');
return [name, {width:box.width, contentWidth,
preservation:svg.getAttribute('preserveAspectRatio'),
lineStroke: series ? getComputedStyle(series).vectorEffect : null}];
}));
}''')
with sync_playwright() as browser_api:
browser = browser_api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for screen_width in (320, 375, 800, 1440, 1920):
page = browser.new_page(viewport={'width': screen_width, 'height': 920})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=app_bundle())
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
currentPanel = 'overview';
renderNetworkInterfaceCharts([{name:'eth0',rx_bytes_per_second:12500,tx_bytes_per_second:5200}], true);
}''')
ids = ['websocket_protocol', 'wide_transform_activity', 'www_requests', 'traffic_chart',
'system_chart', 'cache_diagnostics', 'network_diagnostics']
results = []
for span in (3, 12):
page.evaluate('''({ids,span}) => {
const hidden = ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id));
const sizes = Object.fromEntries(ids.map(id => [id, span]));
applyDashboardLayout([...ids, ...hidden], hidden, sizes);
}''', {'ids': ids, 'span': span})
measured = inspect(page)
results.append(measured)
for chart, value in measured.items():
assert value['width'] > 50, (screen_width, span, chart, value)
assert abs(value['width'] - value['contentWidth']) <= 2, (
'chart not filling its own SVG viewport', screen_width, span, chart, value)
assert value['preservation'] == 'none', (screen_width, chart, value)
if value['lineStroke'] is not None:
assert value['lineStroke'] == 'non-scaling-stroke', (chart, value)
if screen_width >= 1440:
for name in results[0]:
assert results[1][name]['width'] > results[0][name]['width'] * 1.4, (
'chart does not grow when widening card', screen_width, name, results)
assert page.evaluate('document.documentElement.scrollWidth - innerWidth') <= 2, screen_width
assert not errors, (screen_width, errors)
print('PASS SVG chart uses full width at spans 3 and 12:', screen_width)
page.close()
browser.close()
print('PASS resizable Overview chart content regression')
@@ -1,128 +0,0 @@
#!/usr/bin/env python3
"""Legacy CPU plot regression: low and high real values on a fixed 0-100% axis.
Autoscaling was intentionally replaced by 0.33.5 fixed scaling. This test
retains the original history/reset and label checks using the current scale.
Chromium is simulated, not a live RustProxy server.
"""
from pathlib import Path
from panel_html import render_admin_index
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
def inspect(page):
return page.evaluate('''() => {
const get = id => document.querySelector(`#dashboard-overview-grid [data-dashboard-widget="${id}"]`);
const cpu = get('wide_cpu'), cpuSvg = cpu.querySelector('svg');
const protocolSvg = get('wide_protocol_rates').querySelector('svg');
const backendSvg = get('backend_traffic').querySelector('svg');
const legend = cpu.querySelector('.overview-wide-legend').getBoundingClientRect();
const bounds = cpu.getBoundingClientRect();
const matrix = cpuSvg.getScreenCTM();
return {
cpuHeight: cpuSvg.getBoundingClientRect().height,
protocolHeight: protocolSvg.getBoundingClientRect().height,
backendHeight: backendSvg.getBoundingClientRect().height,
svgWidth: cpuSvg.getBoundingClientRect().width,
drawnWidth: Math.hypot(matrix.a, matrix.b) * cpuSvg.viewBox.baseVal.width,
ratio: cpuSvg.getAttribute('preserveAspectRatio'),
cpuSeries: cpuSvg.querySelectorAll('.overview-wide-series').length,
lineEffect: getComputedStyle(cpuSvg.querySelector('.overview-wide-series')).vectorEffect,
gridEffect: getComputedStyle(cpuSvg.querySelector('.chart-grid')).vectorEffect,
legendInCard: legend.top >= bounds.top && legend.bottom <= bounds.bottom + 1,
overflow: document.documentElement.scrollWidth - innerWidth,
};
}''')
def render_cpu(page, revision, system, process):
return page.evaluate('''({revision,system,process}) => {
window.rustProxyWideOverview.render({revision,live_system:{
cpu_percent:system, process_cpu_percent:process
}});
const read = name => {
const prefix = `overview-wide-wide_cpu-${name}`;
return {
path: document.getElementById(`${prefix}-line`).getAttribute('d'),
label: document.getElementById(`${prefix}-value`).textContent,
};
};
return {system:read('system'),process:read('process')};
}''', {'revision': revision, 'system': system, 'process': process})
def ys(path):
return [int(v) for v in re.findall(r'[ML]\d+\s+(\d+)', path)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for viewport in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': viewport, 'height': 950})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
const grid = document.getElementById('dashboard-overview-grid');
grid.classList.add('is-aligned');
for (const card of grid.querySelectorAll(':scope > [data-dashboard-widget]')) {
card.hidden = !['wide_cpu', 'wide_protocol_rates', 'backend_traffic'].includes(card.dataset.dashboardWidget);
}
window.currentPanel = 'overview';
window.$ = id => document.getElementById(id);
window.fmt = (value, digits = 0) => Number(value).toFixed(digits);
window.fmtBytes = value => `${value} B`;
window.rustProxyOverviewSamples = {getLimit: () => 60, onChange: () => {}};
}''')
page.add_script_tag(content=SCALE_JS)
page.add_script_tag(content=WIDE_JS)
for density in ('compact', 'normal', 'ultra'):
page.evaluate('(density) => document.documentElement.dataset.dashboardDensity = density', density)
result = inspect(page)
assert result['protocolHeight'] >= 124, (viewport, density, result)
assert abs(result['cpuHeight'] - result['protocolHeight']) <= 1, (
'CPU must use standard chart height', viewport, density, result)
assert result['backendHeight'] > 0, (viewport, density, result)
assert result['ratio'] == 'none' and result['cpuSeries'] == 2, result
assert result['lineEffect'] == result['gridEffect'] == 'non-scaling-stroke', result
assert result['svgWidth'] > 100 and abs(result['svgWidth'] - result['drawnWidth']) < 2, result
assert result['legendInCard'] and result['overflow'] <= 2, (viewport, density, result)
# Measured 0.5-0.8% stay numerically visible without axis auto-expansion.
first = render_cpu(page, 1, 0.5, 0.2)
second = render_cpu(page, 1, 0.8, 0.6)
assert first['system']['label'] == '0.5%' and second['process']['label'] == '0.6%'
assert ys(second['system']['path']) == [136, 136], (viewport, second)
assert ys(second['process']['path']) == [137, 136], (viewport, second)
# Multi-core values display accurately but clip at the fixed 100% ceiling.
third = render_cpu(page, 1, 3.4, 2.2)
assert ys(third['system']['path'])[-1] == 133, (viewport, third)
assert ys(third['process']['path'])[-1] == 134, (viewport, third)
high = render_cpu(page, 1, 250, 123)
assert high['system']['label'] == '250.0%'
assert ys(high['system']['path'])[-1] == 17, (viewport, high)
assert ys(high['process']['path'])[-1] == 17, (viewport, high)
# Revision changes reset the history. Zero stays at the plot baseline.
idle = render_cpu(page, 2, 0, 0)
assert ys(idle['system']['path']) == [137] and ys(idle['process']['path']) == [137]
missing = render_cpu(page, 3, None, None)
assert missing['system']['path'] == '' and missing['process']['label'] == '-'
assert not errors, (viewport, errors)
print(f'PASS legacy CPU labels/history on fixed Y-scale at {viewport}px (all densities)')
page.close()
browser.close()
@@ -1,149 +0,0 @@
#!/usr/bin/env python3
"""0.33.5 fixed 0-100% CPU plot and height regression with production HTML/CSS/JS.
Uses Chromium with simulated authenticated DOM and status samples, not a live
RustProxy server, SSE stream or real CPU telemetry.
"""
from pathlib import Path
from panel_html import render_admin_index
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
def inspect(page):
return page.evaluate('''() => {
const get = id => document.querySelector(`#dashboard-overview-grid [data-dashboard-widget="${id}"]`);
const cpu = get('wide_cpu'), cpuSvg = cpu.querySelector('svg');
const protocolSvg = get('wide_protocol_rates').querySelector('svg');
const connection = get('connection_chart');
const backendSvg = get('backend_traffic').querySelector('svg');
const legend = cpu.querySelector('.overview-wide-legend').getBoundingClientRect();
const bounds = cpu.getBoundingClientRect();
const matrix = cpuSvg.getScreenCTM();
return {
cpuHeight: cpuSvg.getBoundingClientRect().height,
cpuCardHeight: bounds.height,
connectionCardHeight: connection.getBoundingClientRect().height,
protocolHeight: protocolSvg.getBoundingClientRect().height,
backendHeight: backendSvg.getBoundingClientRect().height,
svgWidth: cpuSvg.getBoundingClientRect().width,
drawnWidth: Math.hypot(matrix.a, matrix.b) * cpuSvg.viewBox.baseVal.width,
ratio: cpuSvg.getAttribute('preserveAspectRatio'),
cpuSeries: cpuSvg.querySelectorAll('.overview-wide-series').length,
lineEffect: getComputedStyle(cpuSvg.querySelector('.overview-wide-series')).vectorEffect,
gridEffect: getComputedStyle(cpuSvg.querySelector('.chart-grid')).vectorEffect,
gridPath: cpuSvg.querySelector('.chart-grid').getAttribute('d'),
legendInCard: legend.top >= bounds.top && legend.bottom <= bounds.bottom + 1,
overflow: document.documentElement.scrollWidth - innerWidth,
};
}''')
def render_cpu(page, revision, system, process):
return page.evaluate('''({revision,system,process}) => {
window.rustProxyWideOverview.render({revision,live_system:{
cpu_percent:system, process_cpu_percent:process
}});
const read = name => {
const prefix = `overview-wide-wide_cpu-${name}`;
return {
path: document.getElementById(`${prefix}-line`).getAttribute('d'),
label: document.getElementById(`${prefix}-value`).textContent,
};
};
return {system:read('system'),process:read('process')};
}''', {'revision': revision, 'system': system, 'process': process})
def ys(path):
return [int(v) for v in re.findall(r'[ML]\d+\s+(\d+)', path)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for viewport in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': viewport, 'height': 950})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
const grid = document.getElementById('dashboard-overview-grid');
grid.classList.add('is-aligned');
for (const card of grid.querySelectorAll(':scope > [data-dashboard-widget]')) {
card.hidden = !['wide_cpu', 'wide_protocol_rates', 'backend_traffic', 'connection_chart'].includes(card.dataset.dashboardWidget);
}
window.currentPanel = 'overview';
window.$ = id => document.getElementById(id);
window.fmt = (value, digits = 0) => Number(value).toFixed(digits);
window.fmtBytes = value => `${value} B`;
window.rustProxyOverviewSamples = {getLimit: () => 60, onChange: () => {}};
}''')
page.add_script_tag(content=SCALE_JS)
page.add_script_tag(content=WIDE_JS)
for density in ('compact', 'normal', 'ultra'):
page.evaluate('(density) => document.documentElement.dataset.dashboardDensity = density', density)
result = inspect(page)
assert result['protocolHeight'] >= 124, (viewport, density, result)
assert abs(result['cpuHeight'] - 125) <= 1, (
'CPU height must remain fixed at 125px', viewport, density, result)
assert result['backendHeight'] > 0, (viewport, density, result)
assert result['ratio'] == 'none' and result['cpuSeries'] == 2, result
assert result['lineEffect'] == result['gridEffect'] == 'non-scaling-stroke', result
assert result['gridPath'] == 'M0 47H600 M0 77H600 M0 107H600', result
assert result['svgWidth'] > 100 and abs(result['svgWidth'] - result['drawnWidth']) < 2, result
assert result['legendInCard'] and result['overflow'] <= 2, (viewport, density, result)
# Low measured CPU values stay near zero instead of filling the plot.
first = render_cpu(page, 1, 0.5, 0.2)
second = render_cpu(page, 1, 0.8, 0.6)
assert first['system']['label'] == '0.5%' and second['process']['label'] == '0.6%'
assert ys(second['system']['path']) == [136, 136], (viewport, second)
assert ys(second['process']['path']) == [137, 136], (viewport, second)
# The Y axis is always 0-100% regardless of recorded history.
third = render_cpu(page, 1, 3.4, 2.2)
assert ys(third['system']['path'])[-1] == 133, (viewport, third)
assert ys(third['process']['path'])[-1] == 134, (viewport, third)
high = render_cpu(page, 1, 250, 123)
assert high['system']['label'] == '250.0%' and high['process']['label'] == '123.0%'
assert ys(high['system']['path'])[-1] == 17, (viewport, high)
assert ys(high['process']['path'])[-1] == 17, (viewport, high)
mid = render_cpu(page, 1, 50, 25)
assert ys(mid['system']['path'])[-1] == 77, (viewport, mid)
assert ys(mid['process']['path'])[-1] == 107, (viewport, mid)
# Revision changes reset history; missing readings do not plot a zero.
idle = render_cpu(page, 2, 0, 0)
assert ys(idle['system']['path']) == [137] and ys(idle['process']['path']) == [137]
full = render_cpu(page, 2, 100, 100)
assert ys(full['system']['path'])[-1] == 17 and ys(full['process']['path'])[-1] == 17
missing = render_cpu(page, 3, None, None)
assert missing['system']['path'] == '' and missing['process']['label'] == '-'
# Samples may change paths, but not the plot/card or neighboring card sizes.
for density in ('compact', 'normal', 'ultra'):
page.evaluate('(density) => document.documentElement.dataset.dashboardDensity = density', density)
unchanged = inspect(page)
assert abs(unchanged['cpuHeight'] - 125) <= 1, (viewport, density, unchanged)
for value in (0.1, 99.9, 250, 0.0):
before = inspect(page)
render_cpu(page, 4, value, value)
after = inspect(page)
for key in ('cpuHeight', 'cpuCardHeight', 'connectionCardHeight'):
assert abs(before[key] - after[key]) < 1, (viewport, density, value, key, before, after)
assert not errors, (viewport, errors)
print(f'PASS CPU fixed 0-100% Y-scale and 125px plot height at {viewport}px (all densities)')
page.close()
browser.close()
@@ -1,199 +0,0 @@
#!/usr/bin/env python3
"""0.33.1 factory/default + unsaved layout regression against the full bundled JS.
The authenticated app bundle runs against simulated status/preferences; this is
not a Rust server integration test.
"""
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
PRESET = json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())
REGISTRY = json.loads((ROOT / 'src/admin/web/dashboard-widgets.json').read_text())
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
result = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
result.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
result.append(json.loads(line[:-1]))
return '\n'.join(result)
assert len(PRESET['dashboard_widgets']) == len(REGISTRY)
assert set(PRESET['dashboard_widgets']) == {w['id'] for w in REGISTRY}
assert len(set(PRESET['dashboard_hidden_widgets'])) == len(PRESET['dashboard_hidden_widgets'])
assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_widgets']))
assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600
assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
BUNDLE = app_bundle()
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
def load(page):
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=BUNDLE)
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyBackendDashboard')
def inspect(page, ids):
return page.evaluate('''ids => Object.fromEntries(ids.map(id => {
const node = document.querySelector(`[data-dashboard-widget="${id}"]`);
return [id, {hidden:node.hidden, display:getComputedStyle(node).display}];
}))''', ids)
def assert_visible(page, visible, hidden):
result = inspect(page, visible + hidden)
for widget in visible:
assert result[widget]['hidden'] is False and result[widget]['display'] != 'none', (widget, result)
for widget in hidden:
assert result[widget]['hidden'] is True and result[widget]['display'] == 'none', (widget, result)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in [320, 375, 800, 1440]:
context = browser.new_context(viewport={'width': width, 'height': 950})
page = context.new_page()
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
load(page)
result = page.evaluate('''() => {
const prefs = ADMIN_APPEARANCE_DEFAULTS;
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('overview-layout-unlock').hidden = false;
currentPanel = 'overview';
window.__savedDashboard = JSON.parse(JSON.stringify(prefs));
window.rustProxyPreferences.load = async () => window.__savedDashboard;
window.rustProxyPreferences.current = () => window.__savedDashboard;
window.rustProxyPreferences.update = async patch => {
window.__savedDashboard = {...window.__savedDashboard, ...patch};
applyAdminPreferences(window.__savedDashboard);
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); };
appConfirm = async () => true;
return {
order: ADMIN_DASHBOARD_WIDGETS,
hidden: ADMIN_DEFAULT_HIDDEN_WIDGETS,
density: document.documentElement.dataset.dashboardDensity,
samples:window.rustProxyOverviewSamples.getLimit(),
serviceCount:document.querySelectorAll('#dashboard-overview-grid [data-dashboard-widget]').length,
};
}''')
assert result['order'] == PRESET['dashboard_widgets'], (width, result['order'])
assert result['hidden'] == PRESET['dashboard_hidden_widgets'], (width, result['hidden'])
assert result['samples'] == 600 and result['density'] == 'compact' and result['serviceCount'] == 78, result
assert_visible(page, ['backend_traffic', 'extra_response_latency', 'wide_transform_activity'],
['backend_health', 'stream_sessions', 'www_status', 'bandwidth'])
# Reproduce the previously flaky operation: opt in hidden backend +
# Stream cards and opt out a visible backend card, WITHOUT saving yet.
page.locator('#overview-layout-unlock').click()
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
outcome = page.evaluate('''() => {
const add = id => {
const label = dashboardWidgetLabel(id);
const item = [...document.querySelectorAll('#overview-inline-hidden-list .overview-inline-hidden-item')]
.find(node => node.firstElementChild.textContent === label);
if (!item) throw new Error('no hidden card to restore: ' + id);
item.querySelector('button').click();
};
add('backend_health');
add('stream_sessions');
const card = document.querySelector('[data-dashboard-widget="backend_traffic"]');
card.querySelector('.overview-inline-hide').click();
return window.rustProxyInlineLayout.currentDraft();
}''')
assert 'backend_traffic' in outcome['hidden'] and 'backend_health' not in outcome['hidden']
assert 'stream_sessions' not in outcome['hidden']
assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic'])
for _ in range(5):
page.evaluate('''() => {
const status = {revision:4,backend_pools:[],streams:{active:[],desired:[],metrics:{}}};
window.rustProxyBackendDashboard.render(status);
window.rustProxyStreamDashboard.render(status);
}''')
assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic'])
# Unrelated settings update must not repaint the saved layout over the draft.
page.evaluate('''() => applyAdminPreferences({...window.__savedDashboard, ui_scale:105})''')
assert_visible(page, ['backend_health', 'stream_sessions'], ['backend_traffic'])
assert page.evaluate('window.rustProxyInlineLayout.isEditing()')
page.locator('#overview-inline-cancel').click()
assert not page.evaluate('window.rustProxyInlineLayout.isEditing()')
assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions'])
# Save under live traffic, ensure the in-flight preference application
# uses the current draft before the editor exits.
page.locator('#overview-layout-unlock').click()
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
page.evaluate('''() => {
const label = dashboardWidgetLabel('backend_health');
const item = [...document.querySelectorAll('#overview-inline-hidden-list .overview-inline-hidden-item')]
.find(node => node.firstElementChild.textContent === label);
item.querySelector('button').click();
document.querySelector('[data-dashboard-widget="backend_traffic"] .overview-inline-hide').click();
}''')
page.locator('#overview-inline-save').click()
page.wait_for_function('!window.rustProxyInlineLayout.isEditing()')
assert_visible(page, ['backend_health'], ['backend_traffic'])
saved = page.evaluate('''() => window.__savedDashboard.dashboard_hidden_widgets''')
assert 'backend_traffic' in saved and 'backend_health' not in saved
page.evaluate('''() => {
const status={backend_pools:[],streams:{active:[],desired:[],metrics:{}}};
window.rustProxyBackendDashboard.render(status);
window.rustProxyStreamDashboard.render(status);
}''')
assert_visible(page, ['backend_health'], ['backend_traffic'])
# Restore factory layout through the new named-presets modal.
page.locator('#overview-layout-open').evaluate('(node) => node.hidden = false')
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions', 'www_status'])
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
assert page.evaluate('window.__savedDashboard.dashboard_hidden_widgets') == PRESET['dashboard_hidden_widgets']
assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2
assert not errors, (width, errors)
context.close()
print('PASS inline layout defaults, add/hide, live ticks, other preferences, cancel/save', width)
# An already-persisted sample choice must override the factory 600.
context = browser.new_context()
page = context.new_page()
# This sandbox blocks all network origins; stub browser storage before
# loading the bundle rather than navigating to a real host.
page.evaluate("""() => Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {getItem: key => key === 'rustproxy.overview.liveChartSamples' ? '120' : null,
setItem: () => undefined},
})""")
load(page)
assert page.evaluate('window.rustProxyOverviewSamples.getLimit()') == 120
context.close()
browser.close()
print('PASS RustProxy 0.33.1 simulated browser layout regression')
@@ -1,134 +0,0 @@
import { renderAdminIndex } from './panel_html.mjs';
// Deterministic Overview widget regression; simulated DOM/status, no running API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const html = renderAdminIndex(root);
const registry = JSON.parse(fs.readFileSync(path.join(root, 'src/admin/web/dashboard-widgets.json'), 'utf8'));
const lang = JSON.parse(fs.readFileSync(path.join(root, 'langs/en_US.json'), 'utf8')).translations;
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
const widgets = new Map(), ids = new Map();
class Element {
constructor() { this.hidden = false; this.attributes = new Map(); this.style = {}; this.textContent = '-'; }
setAttribute(key, value) { this.attributes.set(key, String(value)); }
removeAttribute(key) { this.attributes.delete(key); }
getAttribute(key) { return this.attributes.get(key) ?? null; }
}
for (const [, id] of html.matchAll(/\bid="([^"]+)"/g)) {
assert.ok(!ids.has(id), `duplicate DOM id: ${id}`);
ids.set(id, new Element());
}
for (const [, id] of html.matchAll(/data-dashboard-widget="([^"]+)"/g)) widgets.set(id, new Element());
assert.equal(registry.length, widgets.size, 'registry and DOM widget count must match');
for (const entry of registry) assert.ok(widgets.has(entry.id), `registry references missing DOM card ${entry.id}`);
let now = 1000;
const document = {
hidden: false,
addEventListener() {},
querySelector(query) { return widgets.get(query.match(/^\[data-dashboard-widget="([^"]+)"\]$/)?.[1]) ?? null; },
};
const window = { addEventListener() {}, rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) } };
const context = vm.createContext({
window, document, performance: { now: () => now },
currentPanel: 'overview', $: id => ids.get(id) ?? null,
fmt: (v, digits = 0) => Number(v).toFixed(digits),
fmtBytes: v => `${Number(v)} B`,
t: key => lang[key] || key,
console,
});
for (const script of ['app/rate-sampling.js', 'app/chart-scales.js', 'app/overview-extra-metrics.js', 'app/overview-wide-metrics.js']) {
vm.runInContext(code(script), context, { filename: script });
}
const extra = window.rustProxyExtraOverview, wide = window.rustProxyWideOverview;
const status = {
revision: 9,
system: { open_fds: 60, max_fds: 300 },
cache: { hits: 100, misses: 50, resident_bytes: 150, hot_limit_bytes: 300,
cold_queue_bytes: 25, cold_queue_limit_bytes: 100, cold_bytes: 400, cold_limit_bytes: 1000 },
metrics: { requests: 300, http1_requests: 200, http2_requests: 80, http3_requests: 20,
compression_gzip: 10, compression_br: 10, compression_zstd: 10, compression_deflate: 1 },
transform_metrics: { applied: 10, cache_hits: 10, skipped: 10, errors: 1 },
};
extra.render(status);
wide.render(status);
assert.equal(ids.get('overview-extra-extra_cache_memory-meter-fill').style.width, '50%');
assert.equal(ids.get('overview-extra-extra_cold_queue_memory-meter-fill').style.width, '25%');
assert.equal(ids.get('overview-extra-extra_cold_storage-meter-fill').style.width, '40%');
widgets.get('extra_cache_memory').hidden = false;
widgets.get('extra_cache_hit_rate').hidden = false;
widgets.get('extra_h2_share').hidden = false;
widgets.get('extra_h1_share').hidden = false;
widgets.get('extra_h3_share').hidden = false;
widgets.get('extra_process_fds').hidden = false;
extra.render(status);
assert.equal(ids.get('overview-extra-extra_process_fds-meter-fill').style.width, '20%');
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '-'); // first counter sample has no rate
assert.equal(ids.get('overview-wide-wide_transform_activity-summary').textContent, '-');
now = 4000;
status.cache.hits += 30;
status.cache.misses += 10;
status.metrics.requests += 40;
status.metrics.http1_requests += 10;
status.metrics.http2_requests += 20;
status.metrics.http3_requests += 10;
status.metrics.compression_gzip += 3;
status.metrics.compression_br += 6;
status.metrics.compression_zstd += 9;
status.metrics.compression_deflate += 12;
status.transform_metrics.applied += 15;
status.transform_metrics.cache_hits += 3;
status.transform_metrics.skipped += 6;
status.transform_metrics.errors += 1;
extra.render(status);
wide.render(status);
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '75.0%');
assert.equal(ids.get('extra-cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '75 25');
assert.equal(ids.get('extra-cache-ratio-miss-arc').getAttribute('stroke-dashoffset'), '-75');
assert.equal(ids.get('extra-cache-ratio-hit-count').textContent, '30');
assert.equal(ids.get('extra-cache-ratio-miss-count').textContent, '10');
assert.equal(ids.get('overview-extra-extra_h2_share-meter-fill').style.width, '50%');
assert.equal(ids.get('overview-wide-wide_transform_activity-summary').textContent, '8.3/s');
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '10.0/s');
for (const name of ['applied', 'cache_hits', 'skipped', 'errors'])
assert.ok(ids.get(`overview-wide-wide_transform_activity-${name}-line`).getAttribute('d')?.length);
for (const name of ['gzip', 'br', 'zstd', 'deflate'])
assert.ok(ids.get(`overview-wide-wide_compression_activity-${name}-line`).getAttribute('d')?.length);
// No hidden-tab spike or fake zero when a long interval is skipped.
now = 100000;
status.cache.hits += 300;
status.cache.misses += 200;
status.metrics.compression_gzip += 600;
extra.render(status);
wide.render(status);
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '-');
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '-');
now = 103000;
status.cache.hits += 5;
status.cache.misses += 5;
status.metrics.compression_gzip += 3;
extra.render(status);
wide.render(status);
assert.equal(ids.get('overview-extra-extra_cache_hit_rate').textContent, '50.0%');
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '1.0/s');
// Bounded metric with no configured budget remains unknown, not a made-up percentage.
status.cache.cold_limit_bytes = 0;
extra.render(status);
assert.equal(ids.get('overview-extra-extra_cold_storage').textContent, '400 B');
assert.equal(ids.get('overview-extra-extra_cold_storage-meter').getAttribute('aria-valuenow'), null);
assert.equal(ids.get('overview-extra-extra_cold_storage-meter-fill').style.width, '0%');
extra.donut('cache-ratio', 75, 25);
assert.equal(ids.get('cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '75 25');
assert.equal(ids.get('cache-ratio-chart').getAttribute('aria-label'), 'HIT 75, MISS 25');
extra.donut('cache-ratio', 0, 0);
assert.equal(ids.get('cache-ratio-hit-arc').getAttribute('stroke-dasharray'), '0 100');
assert.ok(code('app/runtime.js').includes('rustProxyExtraOverview?.donut?.("cache-ratio"'),
'main HIT/MISS tile must share tested donut rendering');
console.log('PASS: bounded meters, COLD tiers, HIT/MISS rings, transform and compression rates, pause/no-limit');
@@ -1,176 +0,0 @@
#!/usr/bin/env python3
"""Regression: named Overview presets, responsive modal and browser actions.
Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences/API.
This is browser layout testing, not Rust server acceptance.
"""
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
PACKS = {
name: {'id': name, **json.loads((ROOT / 'langs' / (name + '.json')).read_text())}
for name in ('en_US', 'pl_pl')
}
def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
result = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
result.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
result.append(json.loads(line[:-1]))
return '\n'.join(result)
BUNDLE = app_bundle()
def load(page):
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=BUNDLE)
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyDashboardLayout')
page.evaluate('''packs => {
languagePacks = new Map(Object.entries(packs));
defaultLanguage = 'en_US';
language = 'pl_pl';
document.documentElement.lang = 'pl-PL';
document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
}''', PACKS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('overview-layout-unlock').hidden = false;
document.getElementById('overview-layout-open').hidden = false;
currentPanel = 'overview';
window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
window.rustProxyPreferences.load = async () => window.__savedDashboard;
window.rustProxyPreferences.current = () => window.__savedDashboard;
window.rustProxyPreferences.update = async patch => {
window.__savedDashboard = {...window.__savedDashboard, ...patch};
applyAdminPreferences(window.__savedDashboard);
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
language = 'pl_pl';
document.documentElement.lang = 'pl-PL';
document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
}''')
# Regression for the replacement named-presets modal, with a simulated SQLite API.
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 800, 1440):
context = browser.new_context(viewport={'width': width, 'height': 880})
page = context.new_page()
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
load(page)
page.evaluate('''() => {
const saved = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
window.__savedDashboard = saved;
window.__presets = new Map();
window.rustProxyPreferences.load = async () => window.__savedDashboard;
window.rustProxyPreferences.current = () => window.__savedDashboard;
window.rustProxyPreferences.update = async patch => {
window.__savedDashboard = {...window.__savedDashboard, ...patch};
applyAdminPreferences(window.__savedDashboard);
return window.__savedDashboard;
};
api = async (path, options={}) => {
const method = options.method || 'GET';
if (path === '/api/dashboard-presets' && method === 'GET')
return [...window.__presets.entries()].map(([name, data]) => ({ name, ...structuredClone(data) }));
if (path.startsWith('/api/dashboard-presets/')) {
const name = decodeURIComponent(path.split('/').slice(3).join('/'));
if (method === 'PUT') { window.__presets.set(name, JSON.parse(options.body)); return {}; }
if (method === 'DELETE') { window.__presets.delete(name); return {}; }
if (method === 'PATCH') {
const changed = JSON.parse(options.body).name;
if (window.__presets.has(changed)) throw new Error('already exists');
window.__presets.set(changed, window.__presets.get(name));
window.__presets.delete(name);
return {};
}
}
throw new Error('unexpected API: ' + method + ' ' + path);
};
appConfirm = async () => true;
applyAdminPreferences(window.__savedDashboard);
document.getElementById('overview-layout-open').hidden = false;
}''')
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
assert page.locator('#overview-presets-list .overview-presets-row').count() == 0
widths = page.evaluate('''() => {
const d = document.getElementById('overview-presets-dialog');
const r = d.getBoundingClientRect();
return {left:r.left,right:r.right,overflow:d.scrollWidth - d.clientWidth};
}''')
assert widths['left'] >= -1 and widths['right'] <= width+1 and widths['overflow'] <= 2, (width, widths)
# Persist the factory arrangement as a user preset.
page.locator('#overview-presets-name').fill('Daily')
page.locator('#overview-presets-save').click()
page.wait_for_function("window.__presets.has('Daily')")
assert page.locator('#overview-presets-list .overview-presets-row').count() == 1
assert page.evaluate("window.__presets.get('Daily').overview_samples") == 600
# Create another view, with customized widths, density and history.
page.evaluate('''async () => {
await window.rustProxyPreferences.update({
dashboard_hidden_widgets:['requests'], dashboard_widget_sizes:{requests:3},
dashboard_density:'ultra',
});
window.rustProxyOverviewSamples.setLimit(120, true);
}''')
page.locator('#overview-presets-name').fill('Detailed')
page.locator('#overview-presets-save').click()
page.wait_for_function("window.__presets.has('Detailed')")
assert page.evaluate('''() => {
const p=window.__presets.get('Detailed');
return [p.dashboard_widget_sizes.requests,p.dashboard_density,p.overview_samples];
}''') == [3,'ultra',120]
# Named load must restore full layout and history.
page.locator('.overview-presets-row').filter(has_text='Daily').locator('[data-preset-action="dashboardPresetsLoad"]').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert page.evaluate('''() => [window.__savedDashboard.dashboard_density,
window.__savedDashboard.dashboard_widget_sizes,
window.rustProxyOverviewSamples.getLimit()]''') == ['compact',None,600]
# Rename and delete remain administrator-scoped operations.
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-name').fill('Nightly')
page.locator('.overview-presets-row').filter(has_text='Detailed').locator('[data-preset-action="dashboardPresetsRename"]').click()
page.wait_for_function("window.__presets.has('Nightly')")
assert not page.evaluate("window.__presets.has('Detailed')")
page.locator('.overview-presets-row').filter(has_text='Nightly').locator('[data-preset-action="dashboardPresetsDelete"]').click()
page.wait_for_function("!window.__presets.has('Nightly')")
assert page.evaluate("window.__presets.has('Daily')")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert page.evaluate("window.__savedDashboard.dashboard_widgets") == json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())['dashboard_widgets']
assert not errors, (width, errors)
print('PASS named Dashboard presets save/load/rename/delete/factory and responsive modal:', width)
context.close()
browser.close()
print('PASS Overview preset modal (simulated SQLite API; no running Rust server)')
@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""All Overview widget labels and small values in simulated Chromium.
This is a DOM/CSS regression test, not a live RustProxy integration test.
"""
from pathlib import Path
import json
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
PACKS = {name: json.loads((ROOT / f'langs/{name}.json').read_text(encoding='utf-8'))['translations']
for name in ('en_US', 'pl_pl')}
CHECK = r'''({density, lang, translations}) => {
document.documentElement.dataset.dashboardDensity = density;
document.documentElement.lang = lang === 'pl_pl' ? 'pl-PL' : 'en-US';
const grid = document.getElementById('dashboard-overview-grid');
grid.querySelectorAll('[data-i18n]').forEach(node => {
if (Object.hasOwn(translations, node.dataset.i18n)) node.textContent = translations[node.dataset.i18n];
});
const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget'));
const font = value => getComputedStyle(value).fontFamily;
const probe = document.createElement('div');
grid.append(probe);
probe.style.fontFamily = 'var(--sans)'; const sans = font(probe);
probe.style.fontFamily = 'var(--mono)'; const mono = font(probe);
probe.remove();
const titleErrors = [];
const titles = [];
const labelErrors = [];
const numericErrors = [];
for (const card of cards) {
let title;
if (card.classList.contains('stat')) title = card.querySelector(':scope > span:first-child');
else if (card.classList.contains('system-card') || card.classList.contains('connection-card'))
title = card.querySelector('.overview-chart-heading');
else if (card.querySelector('.mini-heading > h3')) title = card.querySelector('.mini-heading > h3');
else title = card.querySelector('.card-heading h2');
if (!title) { titleErrors.push(`${card.dataset.dashboardWidget}: missing title`); continue; }
const style = getComputedStyle(title);
titles.push(title);
if (style.fontFamily !== sans || style.fontSize !== '12px' || style.textTransform !== 'none')
titleErrors.push(`${card.dataset.dashboardWidget}: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`);
if (title.textContent.trim() === title.textContent.trim().toLocaleUpperCase('pl') &&
/[a-ząćęłńóśżź]{4}/i.test(title.textContent) &&
!/^(?:HIT|MISS|HTTP|CPU|RAM|WWW|RX|TX)/.test(title.textContent.trim()))
titleErrors.push(`${card.dataset.dashboardWidget}: all-uppercase title ${title.textContent.trim()}`);
for (const node of card.querySelectorAll(':scope > small, .mini-metrics dt, .mini-metrics dd, .mini-live-chart-head, .memory-line, .chart-footer, .meta, .small-label')) {
if (font(node) !== mono) labelErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`);
}
const readings = card.classList.contains('stat')
? [...card.querySelectorAll(':scope > strong'), ...card.querySelectorAll(':scope > small')]
: [...card.querySelectorAll('.mini-metrics dd')];
for (const node of readings) if (font(node) !== mono)
numericErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`);
}
const nested = [...grid.querySelectorAll('.mini-live-chart > .mini-live-chart-head > span:first-child')];
for (const node of nested) {
const style = getComputedStyle(node);
if (style.fontFamily !== mono || style.fontSize !== '10px' || style.textTransform !== 'none')
labelErrors.push(`mini-live-chart: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`);
}
const systemNumeric = getComputedStyle(document.querySelector('#system-cpu'));
return {
cardCount: cards.length, titleCount: titles.length, secondaryCount: nested.length,
titleErrors, labelErrors, numericErrors,
systemNumericMono: systemNumeric.fontFamily === mono,
cpuLabel: document.querySelector('[data-dashboard-widget="system_chart"] .overview-chart-heading').textContent,
connectionsLabel: document.querySelector('[data-dashboard-widget="connection_chart"] .overview-chart-heading').textContent,
memoryLabel: document.querySelector('[data-dashboard-widget="memory"] > span:first-child').textContent,
backendLabel: document.querySelector('[data-dashboard-widget="backend_health"] > span:first-child').textContent,
diagnosticsTitle: document.querySelector('[data-dashboard-widget="process_diagnostics"] .mini-heading h3').textContent,
};
}'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': width, 'height': 900})
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
for density in ('normal', 'compact', 'ultra'):
for lang in ('en_US', 'pl_pl'):
result = page.evaluate(CHECK, {'density': density, 'lang': lang, 'translations': PACKS[lang]})
descriptor = (width, density, lang)
assert result['cardCount'] == result['titleCount'] >= 75, (descriptor, result)
assert result['secondaryCount'] >= 6, descriptor
assert not result['titleErrors'], (descriptor, result['titleErrors'][:10])
assert not result['labelErrors'], (descriptor, result['labelErrors'][:10])
assert not result['numericErrors'], (descriptor, result['numericErrors'][:10])
assert result['systemNumericMono'], descriptor
expected = ('System load', 'Connection activity') if lang == 'en_US' else ('Obciążenie systemu', 'Aktywność połączeń')
assert (result['cpuLabel'], result['connectionsLabel']) == expected, (descriptor, result)
assert result['memoryLabel'] and result['backendLabel'] and result['diagnosticsTitle'], descriptor
print(f'PASS all Overview titles and small readings at {width}px (3 densities, 2 languages)')
page.close()
browser.close()
@@ -1,37 +0,0 @@
#!/usr/bin/env python3
"""Offline SQLite regression for additive admin preset migration and 16-slot upsert."""
from pathlib import Path
import sqlite3
ROOT = Path(__file__).resolve().parents[2]
all_scripts = sorted((ROOT / 'migrations').glob('*.sql'))
assert [p.name for p in all_scripts][-1] == '0010_dashboard_presets.sql'
con = sqlite3.connect(':memory:')
con.execute('PRAGMA foreign_keys = ON')
for _ in range(2):
for script in all_scripts:
con.executescript(script.read_text())
con.execute("INSERT INTO administrator(id,username,password_hash,generation,created_at) VALUES(1,'owner','hash',1,0)")
con.execute("INSERT INTO admin_preferences(administrator_id,document) VALUES(1,'{}')")
upsert = '''
INSERT INTO dashboard_presets(administrator_id, name, document, updated_at)
SELECT a.id, ?, ?, ? FROM administrator a WHERE a.id = 1 AND a.username = ?
AND ((SELECT count(*) FROM dashboard_presets WHERE administrator_id = a.id) < 16
OR EXISTS(SELECT 1 FROM dashboard_presets WHERE administrator_id = a.id AND name = ?))
ON CONFLICT(administrator_id, name) DO UPDATE SET document = excluded.document, updated_at = excluded.updated_at
'''
for i in range(16):
name = f'Preset {i}'
assert con.execute(upsert, (name,'{}',i,'owner',name)).rowcount == 1
assert con.execute(upsert, ('Overflow','{}',16,'owner','Overflow')).rowcount == 0
assert con.execute(upsert, ('Preset 0','{"changed":true}',17,'owner','Preset 0')).rowcount == 1
assert con.execute(upsert, ('Other account','{}',18,'intruder','Other account')).rowcount == 0
assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 16
assert con.execute('SELECT document FROM dashboard_presets WHERE name="Preset 0"').fetchone()[0] == '{"changed":true}'
assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Preset 1','Preset 0')).rowcount == 0
assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Renamed','Preset 0')).rowcount == 1
con.execute('DELETE FROM administrator WHERE id=1')
assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 0
assert con.execute('SELECT count(*) FROM admin_preferences').fetchone()[0] == 0
print('PASS SQLite migrations repeated, named-preset CRUD, cap, account ownership and cascade')
@@ -1,200 +0,0 @@
#!/usr/bin/env python3
"""0.33.1 factory/default + unsaved layout regression against the full bundled JS.
The authenticated app bundle runs against simulated status/preferences; this is
not a Rust server integration test.
"""
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
PRESET = json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())
REGISTRY = json.loads((ROOT / 'src/admin/web/dashboard-widgets.json').read_text())
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
result = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
result.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
result.append(json.loads(line[:-1]))
return '\n'.join(result)
assert len(PRESET['dashboard_widgets']) == len(REGISTRY)
assert set(PRESET['dashboard_widgets']) == {w['id'] for w in REGISTRY}
assert len(set(PRESET['dashboard_hidden_widgets'])) == len(PRESET['dashboard_hidden_widgets'])
assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_widgets']))
assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600
assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
BUNDLE = app_bundle()
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
def load(page):
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=BUNDLE)
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyBackendDashboard')
def inspect(page, ids):
return page.evaluate('''ids => Object.fromEntries(ids.map(id => {
const node = document.querySelector(`[data-dashboard-widget="${id}"]`);
return [id, {hidden:node.hidden, display:getComputedStyle(node).display}];
}))''', ids)
def assert_visible(page, visible, hidden):
result = inspect(page, visible + hidden)
for widget in visible:
assert result[widget]['hidden'] is False and result[widget]['display'] != 'none', (widget, result)
for widget in hidden:
assert result[widget]['hidden'] is True and result[widget]['display'] == 'none', (widget, result)
# Test actual production CSS/JS in a browser with simulated preferences/status.
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in [320, 375, 800, 1440, 1920]:
context = browser.new_context(viewport={'width': width, 'height': 1000})
page = context.new_page()
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
load(page)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('overview-layout-unlock').hidden = false;
document.getElementById('overview-layout-open').hidden = false;
currentPanel = 'overview';
window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
window.rustProxyPreferences.load = async () => window.__savedDashboard;
window.rustProxyPreferences.current = () => window.__savedDashboard;
window.rustProxyPreferences.update = async patch => {
window.__savedDashboard = {...window.__savedDashboard, ...patch};
applyAdminPreferences(window.__savedDashboard);
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); };
appConfirm = async () => true;
}''')
# Unchanged previous accounts preserve historic grouped rows.
assert page.evaluate('''() => ({
legacy: document.getElementById('dashboard-overview-grid').dataset.overviewLayoutMode,
widths:window.__savedDashboard.dashboard_widget_sizes,
})''') == {'legacy':'legacy','widths':None}
page.locator('#overview-layout-unlock').click()
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
# Widen a number+sparkline card from 2 to 3 and then 4 columns.
selector = '[data-dashboard-widget="http_protocol"]'
old_width = page.locator(selector).evaluate('(node) => node.getBoundingClientRect().width')
page.locator(selector + ' .overview-inline-widen').click()
assert page.locator(selector).get_attribute('data-dashboard-span') == '3'
page.locator(selector + ' .overview-inline-widen').click()
assert page.locator(selector).get_attribute('data-dashboard-span') == '4'
assert page.locator(selector).evaluate('(node) => node.getBoundingClientRect().width') >= old_width * 1.7
assert page.evaluate('window.rustProxyInlineLayout.currentDraft().sizes.http_protocol') == 4
# Minimize a large multi-series chart to the new 3-column option.
for _ in range(2):
page.locator('[data-dashboard-widget="wide_transform_activity"] .overview-inline-narrow').click()
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3'
# Real UI controls allow crossing old groups: send mini-chart to the front
# with the upward arrow, then across the original stat/chart boundary.
# A direct drag onto the first card also uses the actual event handlers.
page.evaluate('''() => {
const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip');
const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip');
const transfer = new DataTransfer();
from.dispatchEvent(new DragEvent('dragstart', {bubbles:true, cancelable:true, dataTransfer:transfer}));
to.dispatchEvent(new DragEvent('drop', {bubbles:true, cancelable:true, dataTransfer:transfer, clientX:0}));
from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
}''')
assert page.evaluate('window.rustProxyInlineLayout.currentDraft().order[0]') == 'wide_transform_activity'
# Background status and unrelated preference changes cannot reset live sizes.
page.evaluate('''() => {
const status={revision:5,backend_pools:[],streams:{active:[],desired:[],metrics:{}}};
window.rustProxyBackendDashboard.render(status);
window.rustProxyStreamDashboard.render(status);
applyAdminPreferences({...window.__savedDashboard, chart_style:'soft'});
}''')
assert page.locator(selector).get_attribute('data-dashboard-span') == '4'
# Cancel should remove both size and layout changes.
page.locator('#overview-inline-cancel').click()
assert page.locator(selector).get_attribute('data-dashboard-span') == '2'
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '6'
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
# The actual unlocked dashboard remains the only layout editor.
page.locator('#overview-layout-unlock').click()
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
tile = '[data-dashboard-widget="wide_transform_activity"]'
page.locator(tile + ' .overview-inline-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '4'
page.locator(tile + ' .overview-inline-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '3'
page.evaluate('''() => {
const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip');
const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip');
const transfer = new DataTransfer();
from.dispatchEvent(new DragEvent('dragstart', {bubbles:true,cancelable:true,dataTransfer:transfer}));
to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer,clientX:0}));
from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
}''')
page.locator('#overview-inline-save').click()
page.wait_for_function('!window.rustProxyInlineLayout.isEditing()')
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes.wide_transform_activity') == 3
assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity'
# Reload account settings: new card width persists.
page.evaluate('applyAdminPreferences(window.__savedDashboard)')
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3'
assert page.evaluate('window.__savedDashboard.dashboard_density') == 'compact'
# Four charts in one row, set through the same layout function used by UI.
data = page.evaluate('''() => {
const ids=['wide_transform_activity','wide_compression_activity','http_protocol','websocket_protocol'];
const order=[...ids,...ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id))];
const hidden=[...ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id))];
const sizes=Object.fromEntries(ids.map(id => [id,3]));
applyDashboardLayout(order,hidden,sizes);
const grid=document.getElementById('dashboard-overview-grid');
return { gridWidth:grid.getBoundingClientRect().width,
template:getComputedStyle(grid).gridTemplateColumns.split(' ').length,
boxes:ids.map(id=>{const b=document.querySelector(`[data-dashboard-widget="${id}"]`).getBoundingClientRect();return {id,x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),scroll:b.width};})
};
}''')
if data['template'] == 12:
assert len(set(b['y'] for b in data['boxes'])) == 1, (width, data)
assert all(b['w'] > 150 for b in data['boxes']), (width,data)
assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2, (width, data)
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
assert page.evaluate('document.getElementById("dashboard-overview-grid").dataset.overviewLayoutMode') == 'legacy'
assert not errors, (width, errors)
print('PASS free-order, resize, live refresh, cancel/save, four chart grid', width, data['template'])
context.close()
browser.close()
print('PASS 0.33.3 resizable Overview browser regression')
@@ -1,115 +0,0 @@
import { renderAdminIndex } from './panel_html.mjs';
// Simulated DOM/status regression: no holes or artificial zeros after tab resume.
// This intentionally does not exercise the running Rust server or real browser.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
const html = renderAdminIndex(root);
class Element {
constructor() { this.attributes = new Map(); this.style = {}; this.textContent = ''; this.children = []; this.hidden = false; }
getAttribute(key) { return this.attributes.get(key) ?? null; }
setAttribute(key, value) { this.attributes.set(key, String(value)); }
removeAttribute(key) { this.attributes.delete(key); }
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
}
const ids = new Map([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()]));
const widgets = new Map([...html.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(([, id]) => [id, new Element()]));
const listeners = new Map();
const document = {
hidden: false,
addEventListener: (type, handler) => listeners.set(type, handler),
querySelector: selector => widgets.get(selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/)?.[1]) || null,
createElement: () => new Element(),
};
const samples = {
getLimit: () => 60,
trim: values => { if (values.length > 60) values.splice(0, values.length - 60); },
onChange: () => {},
};
const window = {
addEventListener() {}, rustProxyOverviewSamples: samples,
rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) },
};
let now = 1000;
const context = vm.createContext({
window, document, performance: { now: () => now }, currentPanel: 'overview', latestStatus: null,
$: id => ids.get(id) || null, fmt: (value, n = 0) => Number(value).toFixed(n),
fmtBytes: value => `${value} B`, t: value => value,
});
for (const file of ['app/rate-sampling.js', 'app/chart-scales.js', 'app/overview-wide-metrics.js', 'app/web-overview.js'])
vm.runInContext(code(file), context, { filename: file });
const web = {
requests: 200, status_2xx: 190, status_3xx: 2, status_4xx: 5, status_5xx: 3,
static_requests: 100, php_requests: 80, other_requests: 20,
methods: [160, 20, 10, 5, 5], selected_status: [0, 0, 0, 0, 0, 0, 0, 0],
response_headers_us: 300000, response_bytes: 1200000, active_headers: 2,
};
const php = {
metrics: { requests: 80, response_headers_us: 100000, response_bytes: 320000, failures: Array(9).fill(0) },
capacity_limit: 6, capacity_used: 1,
};
const status = {
revision: 4,
metrics: { requests: 200, http1_requests: 120, http2_requests: 60, http3_requests: 20,
compression_gzip: 10, compression_br: 20, compression_zstd: 5, compression_deflate: 8 },
cache: { hits: 50, misses: 30 },
transform_metrics: { applied: 10, cache_hits: 5, skipped: 1, errors: 0 },
web_server_metrics: { demo: { web, php } },
};
function advance(delta) {
for (const key of Object.keys(status.metrics)) status.metrics[key] += delta;
for (const key of ['hits', 'misses']) status.cache[key] += delta;
for (const key of Object.keys(status.transform_metrics)) status.transform_metrics[key] += delta;
for (const key of ['requests', 'status_2xx', 'static_requests', 'php_requests', 'response_headers_us', 'response_bytes']) web[key] += delta;
for (const key of ['requests', 'response_headers_us', 'response_bytes']) php.metrics[key] += delta;
}
function render() { window.rustProxyWideOverview.render(status); window.rustProxyWebOverview.render(status); }
const line = id => ids.get(id).getAttribute('d') || '';
const compression = 'overview-wide-wide_compression_activity-gzip-line';
const website = 'www_requests-all-line';
const phpLine = 'www_php_requests-line';
const starts = path => (path.match(/M/g) || []).length;
render(); // no first-interval rate: establish baseline only
now = 4000; advance(12); render();
const beforeWide = line(compression), beforeWeb = line(website);
const beforePhp = ids.get(phpLine).getAttribute('points');
assert.equal(starts(beforeWide), 1);
assert.equal(starts(beforeWeb), 1);
assert.ok(beforePhp);
// Background tab -> a new generation, regardless of counter increments.
document.hidden = true; listeners.get('visibilitychange')();
document.hidden = false;
now = 100000; advance(600); render();
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '-');
assert.equal(ids.get('www_requests-value').textContent, '-');
assert.equal(line(compression), beforeWide, 'wide rate chart must not receive a null point');
assert.equal(line(website), beforeWeb, 'WWW rate chart must not receive a null point');
assert.equal(ids.get(phpLine).getAttribute('points'), beforePhp, 'small PHP chart must preserve valid history');
// A fresh interval then connects to previous valid samples without a gap.
now = 103000; advance(9); render();
assert.equal(starts(line(compression)), 1, 'wide chart must have one connected path');
assert.equal(starts(line(website)), 1, 'WWW chart must have one connected path');
assert.ok(line(compression).includes('L'));
assert.ok(line(website).includes('L'));
// Real measured zeros must continue to be plotted, not treated as pauses.
now = 106000; render();
assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '0.0/s');
assert.equal(ids.get('www_requests-value').textContent, '0.0/s');
assert.equal(starts(line(compression)), 1);
assert.equal(starts(line(website)), 1);
assert.match(code('web-server.js'), /if \(record && rate !== null\) \{ previous\.push\(rate\)/,
'Web Server chart must also skip unavailable rates instead of plotting a zero dip');
console.log('PASS: resumed wide/WWW/PHP charts stay connected; first invalid rate is unavailable; measured zero persists');
@@ -1,129 +0,0 @@
#!/usr/bin/env python3
"""0.33.6 plot size/scale checks in simulated Chromium, not a running RustProxy."""
from pathlib import Path
from panel_html import render_admin_index
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALES = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
RATES = (ROOT / 'src/admin/web/app/rate-sampling.js').read_text(encoding='utf-8')
WIDE = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
WIDE_IDS = ('wide_protocol_rates', 'wide_cpu', 'wide_memory', 'wide_cache_activity',
'wide_connections', 'wide_backend_health', 'wide_latency',
'wide_transform_activity', 'wide_compression_activity',
'www_requests', 'www_latency', 'www_failures', 'www_traffic', 'www_status_trend')
with sync_playwright() as api:
browser = api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': width, 'height': 920})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading','app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('dashboard-overview-grid').classList.add('is-aligned');
for (const card of document.querySelectorAll('#dashboard-overview-grid>[data-dashboard-widget]'))
card.hidden = !['wide_protocol_rates','wide_cpu','wide_memory','wide_cache_activity',
'wide_connections','wide_backend_health','wide_latency',
'wide_transform_activity','wide_compression_activity',
'www_requests','www_latency','www_failures','www_traffic','www_status_trend'].includes(card.dataset.dashboardWidget);
window.currentPanel='overview';
window.$ = id => document.getElementById(id);
window.fmt = (n, digits=0) => Number(n).toFixed(digits);
window.fmtBytes = n => `${n} B`;
window.rustProxyOverviewSamples = {getLimit:()=>60,onChange:()=>{}};
window.rustProxyRefresh = {current:()=>({overview_seconds:3})};
window.__clock = 1000;
Object.defineProperty(performance,'now',{configurable:true,value:()=>window.__clock});
}''')
for script in (RATES, SCALES, WIDE):
page.add_script_tag(content=script)
for density in ('normal','compact','ultra'):
measured = page.evaluate('''density => {
document.documentElement.dataset.dashboardDensity=density;
const results={};
for (const card of document.querySelectorAll('#dashboard-overview-grid > .overview-wide-chart')) {
const svg=card.querySelector('svg.overview-wide-svg');
if (!svg) continue;
const r=svg.getBoundingClientRect();
results[card.dataset.dashboardWidget]={height:r.height, width:r.width,
grid:svg.querySelector('.chart-grid').getAttribute('d'),
contentWidth:Math.hypot(svg.getScreenCTM().a,svg.getScreenCTM().b)*600};
}
document.getElementById('panel-transform').hidden=false;
document.getElementById('panel-cache').hidden=false;
for(const id of ['transform-live-chart','cache-live-chart'])document.getElementById(id).open=true;
results.transformDetails=document.getElementById('transform-live-activity-chart').getBoundingClientRect().height;
results.cacheDetails=document.getElementById('cache-live-activity-chart').getBoundingClientRect().height;
return results;
}''', density)
assert abs(measured['transformDetails'] - 180) < 1, (width,density,measured)
assert abs(measured['cacheDetails'] - 180) < 1, (width,density,measured)
for name in WIDE_IDS:
data=measured[name]
assert abs(data['height']-125)<1, (width,density,name,data)
assert data['width']>50 and abs(data['width']-data['contentWidth'])<2, (width,density,name,data)
assert data['grid']=='M0 47H600 M0 77H600 M0 107H600', (width,density,name,data)
result = page.evaluate('''() => {
const c=window.rustProxyChartScale;
const first=c.peak('probe:rate','rate',[[.2,.7]]);
const count=c.peak('probe:count','count',[[0,1]]);
const latency=c.peak('probe:ms','ms',[[.1,1.5]]);
const memory=c.peak('probe:bytes','bytes',[[1000]]);
const high=c.peak('probe:rate','rate',[[101]]);
const held=c.peak('probe:rate','rate',[[.3]]);
c.reset('probe:');
const reset=c.peak('probe:rate','rate',[[.3]]);
const percent=c.peak('probe:cpu','percent',[[.1,500]]);
const blank=c.peak('probe:blank','rate',[[null,NaN,Infinity]]);
const before={revision:10,live_system:{cpu_percent:0.7,process_cpu_percent:0.2},
metrics:{compression_gzip:50,compression_br:100,compression_zstd:10,compression_deflate:5},
transform_metrics:{applied:2,cache_hits:10,skipped:1,errors:0}};
window.rustProxyWideOverview.render(before);
window.__clock=4000;
before.metrics.compression_gzip+=3;before.metrics.compression_br+=6;
before.metrics.compression_zstd+=0;before.metrics.compression_deflate+=0;
before.transform_metrics.applied+=6;before.transform_metrics.cache_hits+=3;
window.rustProxyWideOverview.render(before);
const get = (id,key) => document.getElementById(`overview-wide-${id}-${key}-line`).getAttribute('d');
const compressionLow=get('wide_compression_activity','gzip');
const transformLow=get('wide_transform_activity','applied');
const cpu=get('wide_cpu','system');
const lowLabel=document.getElementById('overview-wide-wide_compression_activity-gzip-value').textContent;
window.__clock=7000;
before.metrics.compression_gzip+=603; // now 201/s => ceiling expands to 500/s
before.transform_metrics.applied+=603;
window.rustProxyWideOverview.render(before);
const highLabel=document.getElementById('overview-wide-wide_compression_activity-gzip-value').textContent;
const compressionHigh=get('wide_compression_activity','gzip');
window.__clock=10000;
before.metrics.compression_gzip+=3;
before.transform_metrics.applied+=3;
window.rustProxyWideOverview.render(before);
const afterSpike=get('wide_compression_activity','gzip');
return {first,count,latency,memory,high,held,reset,percent,blank,
lowLabel,highLabel,compressionLow,transformLow,cpu,compressionHigh,afterSpike};
}''')
assert result['first']==100 and result['count']==10 and result['latency']==100, (width,result)
assert result['memory']>=64*1024*1024, (width,result)
assert (result['high'],result['held'],result['reset'],result['percent'],result['blank']) == (200,200,100,100,100), (width,result)
assert result['lowLabel']=='1.0/s' and result['highLabel']=='201.0/s', (width,result)
def yvalues(line): return [int(n) for n in re.findall(r'[ML]\d+ (\d+)',line)]
assert yvalues(result['compressionLow'])[-1]>=135, (width,result)
assert yvalues(result['transformLow'])[-1]>=130, (width,result)
assert yvalues(result['cpu'])==[136,136], (width,result)
assert yvalues(result['compressionHigh'])[-1]<100, (width,result)
assert yvalues(result['afterSpike'])[-1]>=135, (width,result)
assert not errors, (width,errors)
print('PASS all 14 overview-wide plot sizes/grids + full Cache/Transform charts + stable scales:', width)
page.close()
browser.close()
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""Simulated Chromium regression for unified subdued metric/chart titles.
Tests computed CSS in the assembled panel DOM; not a live Rust server.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
CHECK = r'''({theme, density}) => {
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.dashboardDensity = density;
const grid = document.getElementById('dashboard-overview-grid');
const probe = document.createElement('span');
probe.style.color = 'var(--muted)';
document.body.append(probe);
const expected = getComputedStyle(probe).color;
probe.style.color = 'var(--text)';
const primary = getComputedStyle(probe).color;
probe.remove();
const color = node => getComputedStyle(node).color;
const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget'));
const titles = cards.map(card => {
let node;
if (card.classList.contains('stat')) node = card.querySelector(':scope > span:first-child');
else if (card.matches('.system-card,.connection-card')) node = card.querySelector('.overview-chart-heading');
else node = card.querySelector('.mini-heading > h3') || card.querySelector('.card-heading h2');
return [card.dataset.dashboardWidget, node];
});
const otherSelectors = [
'#panel-cache .stats-grid .stat > span:first-child',
'#panel-transform .stats-grid .stat > span:first-child',
'#panel-traffic .stat > span:first-child',
'#panel-cache > .card .card-heading h2',
'#panel-transform > .card .card-heading h2',
'.traffic-chart-card > h3.subheading',
'.function-live-chart-heading > strong',
'.mini-live-chart-head > span:first-child',
'.vhost-chart-head > span:first-child',
'.vhost-live-metric > span',
];
// Include dynamically rendered Vhost/Streams chart and Vhost metric titles.
const container = document.getElementById('panel-vhosts');
const fixture = document.createElement('div');
fixture.innerHTML = `<div class="vhost-chart"><div class="vhost-chart-head">
<span class="small-label">Connections history</span><span class="fine">42/s</span>
</div></div><div class="vhost-live-metric"><span>Requests</span><strong>42</strong></div>`;
container.append(fixture);
const otherTitles = otherSelectors.flatMap(selector => [...document.querySelectorAll(selector)]
.map(node => [selector, node]));
const errors = [...titles, ...otherTitles].flatMap(([name, node]) => {
if (!node) return [`${name} missing title`];
if (color(node) !== expected) return [`${name}: ${color(node)} != ${expected} (${node.textContent.trim().slice(0, 35)})`];
return [];
});
const numeric = [
document.querySelector('#system-cpu'),
document.querySelector('#panel-overview .system-card .card-heading h2'),
document.querySelector('#panel-overview .connection-card .card-heading h2'),
document.querySelector('[data-dashboard-widget="requests"] > strong'),
fixture.querySelector('.vhost-live-metric strong'),
].map(node => ({node: node && node.outerHTML.slice(0, 80), color: node && color(node)}));
fixture.remove();
return {expected, primary, count: titles.length, otherCount: otherTitles.length,
errors, numeric};
}'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 768, 1440):
page = browser.new_page(viewport={'width': width, 'height': 900})
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
for theme in ('dark', 'light', 'ultra_dark'):
for density in ('normal', 'compact', 'ultra'):
result = page.evaluate(CHECK, {'theme': theme, 'density': density})
descriptor = width, theme, density
assert result['count'] == 78, (descriptor, result['count'])
assert result['otherCount'] >= 35, (descriptor, result['otherCount'])
assert result['primary'] != result['expected'], (descriptor, result)
assert not result['errors'], (descriptor, result['errors'][:25])
assert all(item['color'] == result['primary'] for item in result['numeric']), (descriptor, result['numeric'])
print(f'PASS muted titles, primary numeric readings at {width}px (3 themes, 3 densities)')
page.close()
browser.close()
print('PASS subdued metric/chart title CSS (simulated Chromium, no Rust runtime)')
@@ -1,116 +0,0 @@
import { renderAdminIndex } from './panel_html.mjs';
// Simulated browser/status contract: this does not exercise the Rust server.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const html = renderAdminIndex(root);
const controllerSource = read('src/admin/web/app/overview-samples.js');
const store = new Map();
assert.match(html, /id="overview-layout-unlock"/);
// History controls share the connected-core eyebrow row (not a separate
// dashboard row), and navigation shows them only on Overview.
const metaStart = html.indexOf('class="page-intro-meta-row"');
const metaEnd = html.indexOf('class="page-intro-title-row"');
const statusPos = html.indexOf('id="connection-label"');
const selectPos = html.indexOf('id="overview-live-samples"');
assert.ok(metaStart < statusPos && statusPos < selectPos && selectPos < metaEnd);
assert.ok(selectPos < html.indexOf('id="panel-overview"'));
assert.match(html, /id="overview-samples-toolbar" hidden role="group"/);
assert.match(read('src/admin/web/app/core.js'), /\$\("overview-samples-toolbar"\)\.hidden = name !== "overview"/);
assert.ok(html.indexOf('id="overview-live-samples"') < html.indexOf('id="overview-layout-unlock"'));
assert.ok(html.indexOf('id="overview-live-samples"') < html.indexOf('id="dashboard-overview-grid"'));
for (const sample of [60, 120, 300, 600]) assert.match(html, new RegExp(`<option value="${sample}">`));
class Element {
constructor() { this.listeners = {}; this.value = ''; this.dataset = {}; this.disabled = false; this.attributes = new Map(); this.textContent = ''; this.hidden = false; }
addEventListener(name, callback) { this.listeners[name] = callback; }
fire(name) { this.listeners[name](); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name); }
}
const nodes = new Map();
for (const id of ['overview-live-samples', 'overview-samples-save', 'overview-samples-state']) nodes.set(id, new Element());
const widgets = new Map([['wide_compression_activity', new Element()]]);
const document = {
readyState: 'complete', getElementById: id => nodes.get(id) || null,
querySelector: query => widgets.get(query.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null,
};
const localStorage = {
getItem: key => store.get(key) ?? null,
setItem: (key, value) => { store.set(key, value); },
};
let now = 1000;
const window = { rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) } };
const context = vm.createContext({ document, localStorage, window, queueMicrotask: callback => callback(),
t: key => key, performance: { now: () => now },
currentPanel: 'overview', latestStatus: null, $: id => { if (!nodes.has(id)) nodes.set(id, new Element()); return nodes.get(id); },
fmt: n => String(n), fmtBytes: n => `${n} B`,
});
vm.runInContext(controllerSource, context);
const samples = window.rustProxyOverviewSamples;
const select = document.getElementById('overview-live-samples');
const save = document.getElementById('overview-samples-save');
const state = document.getElementById('overview-samples-state');
assert.equal(samples.getLimit(), 60);
assert.equal(save.disabled, true);
select.value = '600'; select.fire('change');
assert.equal(samples.getLimit(), 600);
assert.equal(state.dataset.i18n, 'overviewSamplesTemporary');
assert.equal(store.size, 0, 'temporary samples must not touch localStorage');
assert.equal(save.disabled, false);
const range = Array.from({ length: 700 }, (_, i) => i);
let calls = 0;
samples.onChange(() => { samples.trim(range); calls++; });
select.value = '120'; select.fire('change');
assert.equal(calls, 1);
assert.equal(range.length, 120);
assert.equal(range[0], 580);
assert.equal(samples.setLimit(999), false);
assert.equal(samples.getLimit(), 120);
save.fire('click');
assert.equal(store.get('rustproxy.overview.liveChartSamples'), '120');
assert.equal(state.dataset.i18n, 'overviewSamplesSaved');
assert.equal(save.disabled, true);
// Fresh load reads only the saved choice, not a temporary override.
select.value = '300'; select.fire('change');
assert.equal(samples.getLimit(), 300);
const freshDocument = { readyState: 'loading', addEventListener() {} };
const fresh = vm.createContext({ document: freshDocument, localStorage, window: {}, t: key => key });
vm.runInContext(controllerSource, fresh);
assert.equal(fresh.window.rustProxyOverviewSamples.getLimit(), 120);
// Permanently saved choice survives a fresh load.
save.fire('click');
const again = vm.createContext({ document: freshDocument, localStorage, window: {}, t: key => key });
vm.runInContext(controllerSource, again);
assert.equal(again.window.rustProxyOverviewSamples.getLimit(), 300);
// Wide Overview charts must respect the same limit and redraw immediately when
// the limit shrinks, without recording an extra point or hitting the network.
vm.runInContext(read('src/admin/web/app/rate-sampling.js'), context);
vm.runInContext(read('src/admin/web/app/chart-scales.js'), context);
vm.runInContext(read('src/admin/web/app/overview-wide-metrics.js'), context);
select.value = '600'; select.fire('change');
const status = {
revision: 1, metrics: { compression_gzip: 0, compression_br: 0, compression_zstd: 0, compression_deflate: 0 },
};
context.latestStatus = status;
for (let i = 0; i <= 650; i++) {
now += 3000;
for (const key of ['compression_gzip', 'compression_br', 'compression_zstd', 'compression_deflate']) status.metrics[key] += i % 3 + 1;
window.rustProxyWideOverview.render(status);
}
const line = () => nodes.get('overview-wide-wide_compression_activity-gzip-line').getAttribute('d');
assert.equal((line().match(/ L/g) || []).length, 599, 'maximum 600 samples must be retained');
select.value = '120'; select.fire('change');
assert.equal((line().match(/ L/g) || []).length, 119, 'changing selection must trim and redraw without a new sample');
assert.equal(store.get('rustproxy.overview.liveChartSamples'), '300', 'temporary choice must preserve the saved limit');
assert.ok(!read('src/admin/web/app/overview-samples.js').includes('fetch('));
assert.ok(!read('src/admin/web/app/overview-samples.js').includes('setInterval('));
console.log('PASS: Overview samples 60/120/300/600, temporary vs persistent, immediate trim/redraw, no extra requests');
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more