Compare commits

...
28 Commits
Author SHA1 Message Date
Mateusz Gruszczyński 0cbe0566f5 v0.36.8 2026-11-08 23:42:50 +01:00
Mateusz Gruszczyński 0119b11005 v0.36.8 2026-11-08 23:23:21 +01:00
Mateusz Gruszczyński 1ac94213e0 v0.36.7 2026-11-08 21:26:30 +01:00
Mateusz Gruszczyński 213332d0b5 v0.36.6 2026-11-08 21:11:33 +01:00
Mateusz Gruszczyński e7143151bd v0.36.6 2026-11-08 12:32:50 +01:00
Mateusz Gruszczyński 993062520f v0.36.6 2026-11-08 12:00:38 +01:00
Mateusz Gruszczyński 3d3e0ea5b8 v0.36.6 2026-11-08 11:47:01 +01:00
Mateusz Gruszczyński 0cde2c8a18 v0.36.6 2026-11-08 11:16:41 +01:00
Mateusz Gruszczyński d7fb869783 v0.36.5 2026-11-08 09:55:44 +01:00
Mateusz Gruszczyński 6ef01212ec v0.36.5 2026-11-08 09:46:32 +01:00
Mateusz Gruszczyński 34490fd5fc 0.36.4 2026-11-08 07:11:46 +01:00
Mateusz Gruszczyński 17e314e159 v0.36.3 2026-11-08 06:20:03 +01:00
Mateusz Gruszczyński 5cb63f542c v0.36.2 2026-11-07 15:26:22 +01:00
Mateusz Gruszczyński 064772765b v0.36.2 2026-11-07 15:26:06 +01:00
Mateusz Gruszczyński 6d7f152a0b 0.36.1_hf2 2026-11-07 14:59:21 +01:00
Mateusz Gruszczyński da1fe0df55 v0.36.1_hf2 2026-11-07 14:21:13 +01:00
Mateusz Gruszczyński 4793af758f v0.36.1_hf1 2026-11-07 14:16:25 +01:00
Mateusz Gruszczyński 6f52ac3d00 v0.36.1 2026-11-07 13:56:11 +01:00
Mateusz Gruszczyński 672c84bc13 v0.36.0 2026-11-07 13:13:25 +01:00
Mateusz Gruszczyński 3a384baa6b 0.35.5_changelog 2026-11-07 11:05:30 +01:00
Mateusz Gruszczyński e7b4991316 0.35.5 2026-11-07 10:23:28 +01:00
Mateusz Gruszczyński ff42a13dbf 0.35.4 2026-11-07 08:45:14 +01:00
Mateusz Gruszczyński cd3c28d390 0.35.4 2026-11-07 08:16:11 +01:00
Mateusz Gruszczyński c038ad44b3 v0.35.3_gitignore 2026-11-06 23:40:49 +01:00
Mateusz Gruszczyński 51f22a72cc v0.35.3 2026-11-06 23:38:00 +01:00
Mateusz Gruszczyński f4606cce18 v0.35.2 2026-11-06 23:16:36 +01:00
gru 09414dfb09 Merge pull request 'Release/0.35.1' (#3) from release/0.35.1 into master
Reviewed-on: #3
2026-11-05 21:54:24 +01:00
gru eba9f89283 v0.35.1: performance, AGPL licensing and documentation overhaul
- Web Server reads static files inline; proxy middleware chain flattened
- About window with license summary and project links; AGPL-3.0-or-later
- Rewritten documentation: feature list, screenshots, performance guide
- Fix false spikes in per-Vhost request-rate charts
2026-11-05 18:29:56 +01:00
210 changed files with 8243 additions and 2415 deletions

No files matched your search

+1
View File
@@ -10,3 +10,4 @@ problems.txt
todo/ todo/
scripts/routeros-oci-build/.env scripts/routeros-oci-build/.env
dist dist
/*.*
+3 -3
View File
@@ -1,13 +1,13 @@
# RustProxy source and documentation maintenance # RustProxy source and documentation maintenance
Current source version: **0.35.0**. The Cargo package and binary are named `rust-proxy`. Current source version: **0.36.8**. The Cargo package and binary are named `rust-proxy`.
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. Before editing, read [README.md](README.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.
## Keep documentation in sync ## Keep documentation in sync
- 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. - 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. - Update the guide, [feature list](docs/FEATURES.md), [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. - 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. - 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. - 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.
+150 -12
View File
@@ -1,15 +1,153 @@
# Changelog # Changelog
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.35.0. Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in the latest release.
## 0.35.0 — encoded-slash proxy compatibility and release cleanup (source candidate) ## 0.36.8 — editor forms, toasts and CSP fixes
- **Editor forms:** the `pattern` attribute of generated ID fields (vhost, location, member, listener, stream ID, error fallback target, affinity cookie name) escaped the hyphen incorrectly for browsers that compile patterns with the `v` flag, which logged "Pattern attribute value ... is not a valid regular expression". The hyphen is now escaped.
- **Toasts:** the toast element could be removed together with a rebuilt dialog, after which every save/validation message threw `Cannot set properties of null (setting 'textContent')` and the form could not be saved. The toast is now recreated on demand.
- **Cancel new entries:** vhost, redirect, 404 host, stream and web service editors have a **Cancel** button next to "Discard changes". It closes the editor and drops the draft (with a confirmation when something was typed) instead of only minimizing it to the draft dock.
- **Backends overview:** more trend charts per row: a wider latency line, request bars (requests per refresh) next to the request count (a faint baseline is drawn for idle rows), and an IN / OUT traffic chart with the inbound and outbound rates on two lines, measured from the per-backend byte counters (the server-reported total still wins when present).
- **Tiny backends / Process simple:** the old backend card was forced visible whenever a proxy source existed, ignoring the dashboard layout, so it could not be hidden. Layout visibility is now respected (the "no proxy source" state uses a class instead of the `hidden` attribute).
- **Backends overview:** with a row expanded, the periodic refresh no longer scrolls the table back to the focused row (focus is restored without scrolling and the scroll position is kept).
- **CSP:** the cache outcomes bar no longer uses inline `style="width:0%"` attributes (blocked by `style-src 'self'`); the initial width comes from the stylesheet.
## 0.36.7 — Docker vhosts and cache panel polish
- **Backends table redesigned:** a richer replacement for the 14-column table. It takes over the existing `backend_table` slot (saved layouts keep its position, size and visibility); the previous compact table remains available as the optional **Tiny backends** (`backend_tiny`, hidden by default). Status badges with a coloured row edge, vhost and backend in one cell, error count with a share bar, latency with a short trend line, traffic per second and a "last event" column; filter chips (All / Up / Down / Other) with counts, search, sorting, column selection and "Manage vhosts" work as before and share the same saved sort, direction and column preferences. Client generation, generation requests/age, rotations and the last failure/recovery open under a chevron and stay open across refreshes. It follows the table density (normal, compact, ultra) and corner-style settings. The original table is still available as **Tiny backends**; the new card is visible in the factory layout (Tiny backends is hidden there), while existing saved layouts keep their current cards and can enable the new one in the layout editor.
- **Process card redesigned:** chips for platform, threads, start and uptime, Load rows with usage meters (CPU, RSS, file descriptors, cgroup), a Memory section with an anonymous/file-backed split bar, and a 2×2 grid of history charts (CPU, RSS, file descriptors, OS threads); cache memory is no longer shown there (see RAM cache). It takes over the existing `process_diagnostics` slot, so saved layouts keep its position, size and visibility. The previous card is still available as the optional **Process simple** (`process_simple`, hidden by default).
- **Docker vhosts:** the collapsible sections (Configure vhost, Protocols, Access control, timeouts, health checks) now align arrow, title and hint to the left with a common title column. Traffic statistics are expanded by default for every enabled app (a section you collapse stays collapsed). A new **Hide disabled** button hides all unpublished vhosts from the view at once; **Show hidden** brings them back.
- **404 hosts:** cards use the same host chips → response layout as redirects, with listeners and certificate on a quiet meta line; redirect and 404 cards are separated by the same gap as vhost cards.
- **Cache panel:** the pager follows the corner-style setting (outer corners only) and stays on one row (the heading wraps instead) with the arrow after "Next"; the "transformed" badge stays visible next to long object URLs (the URL is shortened instead). Decision counters show a thin share bar under each counter (relative to the largest) and a compact "+N" toggle; the pager no longer shows doubled arrows; the folded Decision counters ("+N") keep their open/closed state across automatic refreshes.
- **Dashboard cache outcomes:** the legend under the bar shows only the request counts; percentages are shown once, inside the bar and in the headline (tooltips give both).
- **Build:** `SHA256SUMS` uses repository-relative paths again.
## 0.36.6 — redirect priority, copy and SNI documentation
- **Deploy script:** `update.sh` / `install.sh` retry the GeoIP download (transient 502/503/504, timeouts) up to four times and, if it still fails, warn and continue with the installed GeoIP data instead of aborting the update with `HTTP Error 504`.
- **Redirect priority fix:** the most specific host name now wins between vhosts, redirects and 404 hosts. A wildcard redirect such as `*.gru.one.pl` (or `gru.one.pl`) no longer shadows an exact vhost like `autodiscover.gru.one.pl` on the same listener; the same applies to wildcard 404 hosts.
- **Redirect cards:** redirects are shown as host chips → target with a quiet meta line (rules, listeners, certificate) instead of nested boxes, and stack cleanly on narrow screens.
- **Certificate mismatch on save:** when the selected certificate does not cover the vhost names, saving now asks whether to force it (and enables "Force an incompatible certificate") instead of failing with a vague error; the server error names the certificate and explains the fix.
- **Certificates by SNI explained:** the vhost certificate hint and `docs/CERTIFICATES.md` now describe that a vhost left on "Global SNI / default certificate" gets the certificate per requested name from the stored certificates' SNI names (filled from their SAN entries), so one vhost can serve domains with different certificates. The editor also tells you when no single certificate covers all names but each name is covered. The "Legacy SNI names" field is now called "SNI names".
- **Copy:** vhosts, redirects, 404 hosts, streams and ACL profiles have a **Copy** button next to Edit. It opens a new form filled from the original with the ID and name cleared (the ID is generated from the new name or host again) and the values that must be unique cleared too: host names and certificate for vhosts, redirects and 404 hosts, the listen address for streams. Nothing is saved until you press Save.
- **Build without GitHub:** the official Swagger UI 5.33.1 files (`swagger-ui-dist`, Apache-2.0) are vendored in `third_party/swagger-ui-5.33.1/` and verified against `SHA256SUMS` on every build, so `bash dev.sh release` needs no network for Swagger UI. Setting `SWAGGER_UI_DOWNLOAD_URL` or `SWAGGER_UI_ARCHIVE_SHA256` still selects an explicit archive.
- **Build:** the pinned Swagger UI archive is now reused from a previous build directory (or `target/swagger-ui-cache`, or `SWAGGER_UI_CACHE_DIR`) instead of being downloaded again on every version bump, and a download is retried up to four times. A flaky GitHub connection no longer fails `bash dev.sh release` right after a version change.
## 0.36.5 — automatic certificate choice and clearer host conflicts
- **Certificate auto-selection:** when creating a vhost, redirect or 404 host, RustProxy picks the stored certificate that covers every host name you enter (an exact name beats a wildcard, the narrowest certificate wins). A hint names the chosen certificate and the selector still lets you pick another one or the global default. Existing entries are never changed.
- **Clearer host conflicts:** the "overlapping frontend bindings for hostname …" error now names the entry that already uses the host (`vhost`, `redirect` or `404 host` plus its ID) and the conflicting listener address.
- **Clearer validation errors:** duplicate or invalid IDs, repeated hostnames, missing hostnames or listeners, invalid location IDs, regular expressions, exact paths and duplicate location matchers now say which value or location is wrong and how to fix it.
## 0.36.4 — cache readability and editor UX polish
- **Cache → Domains and effectiveness:** decision counters are one compact line (`name count`, top three, the rest in a `+N` tooltip) instead of a wall of chips; the rest of the table keeps its 0.36.3 look.
- **Cache → Static response transformation:** the "Transformations by type" and "Vhost effectiveness" tables use the same presentation as Domains and effectiveness (right-aligned tabular numbers, emphasised vhost name, non-zero errors in red, reduction meter) and the skip reasons are compact `name count` lines.
- **Cache → Stored objects:** the tier is a badge in the same style as the other badges (follows the corner-radius setting), numeric columns are right-aligned, long targets and cache keys are truncated (full text on hover, click still copies), rows highlight on hover, and the Previous / range / Next pager is a single segmented control.
- **Docker vhosts:** the per-service link is now **Open** (same wording as in Vhosts); the expandable per-application forms and their protocol, ACL, timeout and health sections have a chevron, keyboard focus outline and a framed panel when open.
- **Automatic IDs:** when creating a vhost, web service, stream, redirect, 404 host or ACL profile, the ID is generated from the name (for vhosts, from the first host name) as a unique slug; the ID field comes after the name (after the hosts for vhosts) and is shown as a plain value with a **Change** link — the input only appears when you ask for it (or when validation needs it) — `example.com` becomes `example-com`, Polish and other accented letters are folded to ASCII, and `-2`, `-3`… is appended when the ID is taken. Typing in the ID field switches to a manual ID; a "Generate automatically" button (or clearing the field) restores it. Existing IDs are never changed.
- **Vhost, Web service, Stream, Redirect and ACL forms:** section tabs wrap onto a second line instead of scrolling horizontally, and use a readable 12 px underlined tab style with a clear active state and hover; form action bars are aligned consistently.
## 0.36.3 — settings, cache and dashboard layout polish
- **More chart styles:** Settings → Appearance → Chart style now offers eight additional presets: **Sunset**, **Aurora**, **Ocean**, **Forest**, **Pastel**, **Terminal** (green phosphor, square lines), **Blueprint** (thin technical lines on a blue tint) and **Colorblind safe** (Okabe–Ito palette with dashed grid). The `chart_style` preference accepts the new values (`sunset`, `aurora`, `ocean`, `forest`, `pastel`, `terminal`, `blueprint`, `colorblind`); existing values and the default are unchanged.
- **Cache → Domains and effectiveness:** numeric columns are right-aligned with tabular digits, HIT / MISS / BYPASS counts use the same colours as the Dashboard cache bar, the hit ratio has a small meter, domain names are emphasised and decision-counter chips are quieter.
- **Docker vhosts:** collapsed service rows are compact on desktop (publish switch, name with image/ports, and health with actions in one row instead of a stacked column).
- **Cache and Static response transformation pages:** the Cache → General tab is split into *Behavior* and *Memory and limits* groups (the fallback TTL and bypass options moved up next to the other behavior switches), switch descriptions sit directly under their checkbox, the memory/transform preset cards have a compact header, and numeric fields use a responsive grid (up to four per row) instead of two fixed columns.
- **Settings layout:** Settings → Appearance now uses one-setting-per-row layout (label and help on the left, control on the right, checkboxes on the right edge) instead of an uneven two-column grid, section headings no longer duplicate their first setting (`Charts`, `Tables and logs`) and the small eyebrow labels were dropped. Sub-tabs (Rules & headers, Cache, HTTP connections, Logging, …) are a horizontal tab bar on desktop instead of a vertical stack of full-width buttons, and the main settings menu has thin separators between its groups.
- **Compact stat cards:** Dashboard cards that consist of a title, a value and one secondary line (Requests, Active exchanges, Payload transferred, Errors, Process memory, HTTP/2 requests, backend and stream cards, …) now show that line to the right of the value, which makes them about 20 px lower. WebSocket tunnels shows a plain `H1 · H2 · H3` split (full names in the tooltip) HTTP/2 shows `H2 connections` and HTTP/3 now shows `H3 connections` (active HTTP/3 connections); the labels were shortened in `langs/en_US.json` and `langs/pl_pl.json`.
- **WebSocket tunnels card:** the *Established* count moved from its own text line to the right of the main value (it wraps below only when the card is too narrow), so the card is one line shorter.
- **Cache outcomes bar:** the HIT / MISS / BYPASS / OTHER segments of the Dashboard cache bar now show their percentage inside the bar (bar height 12 → 20 px with tighter margins, so the card keeps its height). A label that does not fit a narrow segment is hidden automatically and re-evaluated on resize; the legend below keeps the exact count and percentage.
- **CSS cleanup:** removed orphaned panel styles: the legacy `body[data-density=…]` rules (46 selectors across 7 modules; density is controlled only by `html[data-table-density]` and `html[data-dashboard-density]`), the unused `.badge` branch of the corner-style selector list, references to undefined custom properties (`--radius`, `--surface`, `--line-strong`; their fallbacks are now inlined) and duplicated `.path-permissions-*` blocks. A static audit found no other unused classes, ids, keyframes or custom properties. Computed styles of all panels at 402 px and 1280 px (several corner/density/icon settings) are identical before and after; the bundle is about 3 KB smaller.
- **Upgrade notes:** interface-only release; no configuration or database migration is required. New interface labels were added to `langs/en_US.json` and `langs/pl_pl.json` (`appearanceChartsTitle`, `appearanceTablesTitle`, `cacheSectionBehavior`, `cacheSectionMemory`, `h3ActiveConnections`, `chartStyleSunset` … `chartStyleColorblind`) and the WebSocket / HTTP/2 labels were shortened.
## 0.36.2 — icon semantics review and optional logo animation
- **SVG icon review:** every panel control was re-checked against its function. Fixes include **Unlock layout** (was the sign-out icon, now an open padlock), **Save and lock** (padlock), **Dashboard presets** (layers instead of edit), **Docker agents settings** (was a pin because "docker" matched "dock"), cache/transform memory presets (were the undo icon because "preset" matched "reset"), **Previous/Next** in Cache and Stored logs (chevrons instead of database/list), **Close** in the health and history dialogs (close instead of activity/history), revision history **Reload/Restore/Compare** (refresh/undo/compare), **Sign in** (sign-in instead of check), **Sign out all sessions** (sign-out instead of trash), **Run operation** (play), **Purge / Ban** and **Active BANs** (ban), **Clean cold cache** and **Clear** (eraser), **Download now** (download), token generation/rotation (key), **Repeated values / Count repeats** (hash), **Open Logs** (list), **Configure fields** (sliders), **About** (info), automatic renewal enable/disable (toggles) and Live logs **Pause/Resume** (pause/play). Expand/collapse toggles now follow their current state.
- **Distinct section icons:** Streams (transfer arrows), Listeners (plug), GeoIP updates (map pin) and Administration (users) no longer share icons with Vhosts, HTTP connections and Settings. The settings cog, pin, undo and sign-in/sign-out glyphs were redrawn to be more recognizable.
- **Icon fixes:** English labels whose translation equals the key (for example **Edit**) no longer fall back to the generic placeholder icon; buttons labelled with a single symbol (`×`, `−`, `↑`, `←` …) keep the native glyph instead of showing a duplicate SVG; protocol badges and history calendar days no longer receive icons.
- **Mobile/Safari chart fixes:** with SVG icons enabled, the **Hide** / **Show** buttons of the Traffic-by-interface charts made the Overview jump and flicker on every refresh. Root causes: the chart lookup (`row.querySelector("svg")`) could return the button's icon instead of the chart (axis labels were then drawn inside the button); the periodic label update rebuilt the button and its icon each time; and descendant chart rules (`.network-interface-row svg`, `.stat-chart svg`, `.system-card svg` …) stretched icons to full chart size. Charts are now addressed with `svg:not(.ui-icon)`, the axis renderer ignores icons, refreshes update only the text node, chart rules exclude `.ui-icon`, and small buttons use an 11 px icon so their height never changes. A measured 28-refresh run kept the container height constant (previously a 13 px swing) with zero icon rebuilds.
- **Badge consistency:** the upstream protocol badges in the Vhost policy row (for example `origin · H2 compatible → H2`) now use the same geometry as the other policy pills (9 px mono, 1 px letter-spacing, 4×8 padding, transparent background, square corners in the original corner style, 20 px height); only the colour differs (muted = not observed, green = observed). The **Transformed** marker in the Cache objects table now uses pill typography (1 px letter-spacing, square corners). ACL picker tags (selected countries/IPs) now use the same chip look as host chips (square corners, 10 px mono, same padding and remove button) instead of a rounded pill with a different font. A style comparison of every pill/badge/chip family and of controls sharing a row found no further deviations besides intentional ones (ACL rule action labels that mimic form fields, quick enable/disable toggles, the sort-direction arrow).
- **Spacing review:** a rendered scan of every panel, settings page and dialog (402 px and 1280 px wide) found descriptive text and blocks that touched the next element. Added spacing for TLS policy (description → TLS 1.2/1.3 switches, PQC help → documentation link, which is now styled as a link), Rules & headers, HTTP connections and Logging sub-tabs, Cache and Transform live-activity cards, empty-state notes under tables, Logs footnote, Dashboard presets footer, GeoIP and cache-cleanup field groups, htaccess/PHP help dialogs and several settings help paragraphs. The rules add space only where the layout had none.
- **Mobile layout:** the mobile **Menu** button no longer shows two hamburger icons, and **System uptime** wraps inside the System load card instead of overflowing its right edge.
- **Logo animation:** new **Settings → Appearance → Interface → Animate logo** option (account-scoped `logo_animation`, **off by default**) plays short, subtle random effects on the sidebar logo; it pauses in hidden tabs and respects reduced-motion preferences.
- **Upgrade notes:** no configuration or database migration is required; `logo_animation` is an additive preference field that defaults to `false` for existing accounts.
## 0.36.1 — interface icon polish and on-demand live logs
- **SVG icon polish:** HTTP/2 and HTTP/3 tabs now use the same protocol icon; listener HTTP/2/HTTP/3 limit links use the settings cog; **Apply preset** uses a preset/wand icon; the top **Logs** menu receives its missing icon. Compact Overview layout controls keep their native `+ / − / ← / → / ×` glyphs instead of receiving duplicate SVGs, and UI icon sizing is protected from chart SVG rules.
- **WebSocket tunnels:** **Active tunnels** is now a compact clickable badge without an SVG icon.
- **Overview metric cards:** removed the redundant **Recent samples / Ostatnie próbki** footer from the lower metric tiles; the footer is shown only when a metric has real detail text to present.
- **Appearance copy:** removed the experimental/default-state sentence from the SVG icon option help text. The option remains account-scoped and off by default.
- **On-demand live logs:** the panel status SSE uses `/api/events?...&logs=false`; `/api/logs` refreshes only while the Live Logs window is open or docked and actively following the bounded buffer. Closing, pausing or expanding the history view stops periodic log reads; reopening/resuming triggers an immediate refresh. Existing `/api/events` API consumers remain compatible because the new `logs` query parameter defaults to `true`.
- **OPNsense certificate deploy:** the source release now includes the previously referenced `rustproxy-acme.conf.example` and `actions_rustproxy_cert.conf`; the integration guide explicitly requires certificate scope plus `allow_deploy` for the deployment credential.
- **Preference persistence fix:** expanded the per-administrator UI preference document limit from 4 KiB to 64 KiB. Customized Overview layouts can exceed the old limit once widget widths are stored; affected saves previously surfaced as `auth.unavailable` even though authentication was healthy.
- **Upgrade notes:** the SQLite schema is migrated automatically to expand administrator preference storage; proxy configuration is unchanged.
## 0.36.0 — leaner administration polling
- **Experimental interface icons:** **Settings → Appearance → Interface → Show SVG interface icons** adds semantic inline SVG icons to navigation, action buttons, tabs and dialogs. The account-scoped option is **off by default**, keeps text labels as the accessible source of meaning, follows the current theme via `currentColor`, and can be disabled without changing the original control layout or behavior.
- **Polling API:** `GET /api/cache`, `GET /api/transform` and `GET /api/traffic` now accept `view=live` for lightweight periodic refreshes. The default response remains the complete backward-compatible payload. Live responses carry the configuration `revision`, and the panel automatically reloads static data when that revision changes.
- **Panel network load:** Cache and Transform polling no longer retransmit static settings/Vhost configuration on every refresh; Traffic polling omits the static Vhost list. Certificate API credentials are fetched only when the corresponding Settings tab is opened.
- **Refresh behavior:** configuration writes and operational actions refresh status without also forcing a full `/api/logs` snapshot. Initial load, explicit log refresh and the existing live-log/fallback path retain log coverage.
- **API contract:** OpenAPI documents the polling views and revision fields; regression tests cover compatibility, invalid views and the panel refresh contract.
- **Upgrade notes:** no configuration or database changes are required. Existing API clients that do not send `view=live` retain their previous response shapes, with the additive `revision` field on Cache, Transform and Traffic observations.
## 0.35.5 — Overview polish and cache build fix
- **Overview charts:** the cache-results heading now matches the other chart headings and its HIT/MISS/BYPASS legend stays on one line; system uptime is shown in the System load header; the backend count is aligned with Open connections; the redundant Requests per second footer was removed from the PHP requests tile.
- **PL/EN status labels:** the sidebar healthy-state label is now **SYSTEM OK**, and the application self-check uses the shorter **API** label.
**Upgrade notes:** no configuration or database changes are required.
## 0.35.4 — Dashboard status and control cleanup
- **Overview samples:** the history sample selector is right-aligned on desktop and mobile and now saves immediately in the browser; the separate Remember choice button is removed.
- **Application self-check:** the old runtime/control-plane sidebar block is replaced by a color-coded application status indicator. Clicking it shows checks for the control API, listener restart state, backend readiness and logging pipeline issues.
- **Dashboard footer:** removed the low-value dropped-log counter; API latency and update availability remain.
- **Navigation cleanup:** removed the static `RUNTIME / Rust / Tokio / Hyper / Axum / Tower / SQLite` text from the sidebar.
- **Dashboard layout editing:** blocks are now moved by dragging the ⠿ handle with a live drop placeholder (mouse, pen and touch), smooth movement of neighbouring blocks, edge auto-scroll and **Esc** to cancel; arrow keys on the handle move a block from the keyboard. Custom layouts no longer back-fill gaps automatically, so blocks stay where they were placed; the new **Fill gaps** action packs them on request. The edit toolbar is a single compact row, so the layout keeps its shape while unlocked.
- **Interface fixes:** the WebSocket tunnels title no longer overlaps its **Active tunnels** button; summary tiles on the Cache, Traffic and Transform pages fill each row instead of leaving empty columns; identifier fields (username, certificate, Docker agent and redirect IDs) no longer trigger an invalid-pattern error in current browsers; several layout-editor tooltips showed raw translation keys.
- **Cache under turnover:** returning freed memory to the operating system is now rate-limited (at most four times per second, plus the regular maintenance pass) instead of running after every eviction. With a full HOT cache and constant replacement this measured about 2.3× higher throughput with the default system allocator (2,900 → 7,000 req/s, one core) at about 7% higher resident memory.
- **Cache hot path:** popular objects no longer re-link the LRU list on every hit, HOT/COLD index lookups use the already-uniform cache digest instead of a second SipHash pass, and the request-collapsing bypass marker for uncacheable URLs keeps working when its table is full.
- **Per-response work:** configured `Server` and cache `Via` values are parsed once per worker instead of on every response, and cache diagnostic headers avoid temporary allocations.
- **Upgrade notes:** no configuration or database changes are required.
## 0.35.3 — Overview layout fixes
- **Standard Overview layout restored:** the quick-health tiles added in 0.35.2 (healthy backends, error share, requests rejected by limits, nearest certificate expiry) are now hidden by default and placed at the end of the Overview, so they no longer shift the standard block layout. Enable them in the layout editor when needed.
- **Fixed:** the "Saved default" status next to the history sample count was always visible and looked like a fresh save notification. It now appears only for an unsaved temporary choice or briefly after **Remember choice**.
- **Upgrade notes:** no configuration changes are required. If 0.35.2 already changed your Overview, use **Unlock layout → Reset layout** to restore the default arrangement, or hide the tiles in the layout editor.
## 0.35.2 — memory fix, health check options and Overview tiles
- **Memory use restored:** with a large HOT cache, 0.35.1 used about 25% more memory than 0.35.0 (for example 1.1–1.2 GiB instead of about 800 MiB with a 600 MiB cache). The default build is back to the 0.35.0 memory profile. The faster alternative allocator is available as an optional build feature for servers with spare RAM; the Performance guide explains the trade-off with measurements.
- **Health check port:** active TCP and HTTP health checks can probe a different port than the one receiving traffic, for example a status port 8079 for a backend serving on port 80. It can be set for a whole Vhost or per backend; host, TLS and address restrictions stay the same.
- **Expected response text:** HTTP health checks can require a text fragment in the response body, so a status page that answers 200 while reporting a degraded state marks the backend as down.
- **New Overview tiles:** healthy backends (with the names of failing ones), error share, requests rejected by limits and the nearest certificate expiry.
- **Certificate expiry metrics:** Prometheus now exports the expiry date and remaining days of every installed certificate, including manually uploaded ones, for alerting.
- **Safari:** the Overview no longer jumps when you scroll near the Backends table while live values refresh.
- **Fixed:** network interface traffic below 1 KiB/s is shown as a whole number of bytes instead of a long decimal.
- **Upgrade notes:** no configuration changes are required. Existing Overview layouts receive the new tiles at the end; hide or move them in the layout editor.
## 0.35.1 — performance, licensing and documentation
- **Faster Web Server:** static files are read directly by the request instead of being handed to a background thread pool. Small and large files are served up to 1.8× faster with lower latency and CPU use.
- **Faster reverse proxy and cache:** lighter request processing, header handling and traffic statistics collection. Proxied requests are about 3–7% faster and cache hits about 11% faster on the same hardware.
- **Vhost charts:** the per-Vhost request-rate chart no longer shows a false spike right after the page opens or when two refreshes arrive at the same moment.
- **Performance guide:** new documentation page with the benchmark method, a comparison with a similarly configured Nginx and production tuning advice.
- **About window:** redesigned with the project description, copyright, a GNU AGPL v3.0-or-later license summary and links to the project website, source code, built-in documentation and the full license text.
- **License:** RustProxy is published under the GNU Affero General Public License v3.0 or later. The license text is included in the built-in documentation.
- **Documentation:** rewritten project overview with a complete feature list, panel screenshots and links to [rustproxy.linuxiarz.pl](https://rustproxy.linuxiarz.pl/). Container, API access, post-quantum TLS and integration notes were merged into the related guides. The built-in Reader now displays tables and bold text.
- **Upgrade notes:** no configuration changes are required.
## 0.35.0 — encoded-slash proxy compatibility and release cleanup
- **Encoded slash compatibility:** `%2F` and `%2f` are accepted in request paths. RustProxy decodes them to `/` only for routing/security decisions and applies the same duplicate-slash normalization as literal `/`. - **Encoded slash compatibility:** `%2F` and `%2f` are accepted in request paths. RustProxy decodes them to `/` only for routing/security decisions and applies the same duplicate-slash normalization as literal `/`.
- **Preserve-mode forwarding:** when a location does not rewrite the path, the original encoded request target is retained for the upstream, including query strings. Applications that use encoded slashes inside path parameters therefore receive the same target the client sent. - **Preserve-mode forwarding:** when a location does not rewrite the path, the original encoded request target is retained for the upstream, including query strings. Applications that use encoded slashes inside path parameters therefore receive the same target the client sent.
- **Path hardening retained:** encoded `..` traversal after slash normalization, encoded backslash, `%`, `?`, `#`, `;`, controls, malformed percent escapes and invalid UTF-8 remain rejected. - **Path hardening retained:** encoded `..` traversal after slash normalization, encoded backslash, `%`, `?`, `#`, `;`, controls, malformed percent escapes and invalid UTF-8 remain rejected.
- **Release gate cleanup:** source/tests are adjusted for the current Rust/Clippy gate without weakening runtime security checks. - **Release gate cleanup:** source/tests are adjusted for the current Rust/Clippy gate without weakening runtime security checks.
## 0.34.16 — optional OIDC central login (source candidate) ## 0.34.16 — optional OIDC central login
- **Configuration size fix:** increased the total serialized configuration limit from 256 KiB to 2 MiB. The administration request limit, configuration file import, SQLite/history loading and NPM migration utility use the same expanded bound; the existing limits on Vhosts, backends and locations remain unchanged. - **Configuration size fix:** increased the total serialized configuration limit from 256 KiB to 2 MiB. The administration request limit, configuration file import, SQLite/history loading and NPM migration utility use the same expanded bound; the existing limits on Vhosts, backends and locations remain unchanged.
- **Vhost → Authentication:** separates **Identity provider** (Tinyauth, Authelia, authentik, Pocket ID, custom OIDC, HTTP Basic) from **Integration method** (Forward Auth, OIDC, HTTP Basic). Pocket ID/custom support OIDC; Tinyauth/Authelia/authentik support Forward Auth or OIDC. Only one method can be active per Vhost. An optional `{ "authentication": { "provider": "authelia", "method": "oidc" } }` selector is validated against the existing provider settings. Omitted selectors retain legacy `*.enabled` behavior, including OIDC without an explicitly named vendor. Existing passwords, application callbacks, sessions and stored secrets retain their original keys and behavior. - **Vhost → Authentication:** separates **Identity provider** (Tinyauth, Authelia, authentik, Pocket ID, custom OIDC, HTTP Basic) from **Integration method** (Forward Auth, OIDC, HTTP Basic). Pocket ID/custom support OIDC; Tinyauth/Authelia/authentik support Forward Auth or OIDC. Only one method can be active per Vhost. An optional `{ "authentication": { "provider": "authelia", "method": "oidc" } }` selector is validated against the existing provider settings. Omitted selectors retain legacy `*.enabled` behavior, including OIDC without an explicitly named vendor. Existing passwords, application callbacks, sessions and stored secrets retain their original keys and behavior.
@@ -18,7 +156,7 @@ Release history focused on **features, settings, default behavior, compatibility
- **Session policy:** HTTPS-only, HttpOnly, SameSite=Lax cookies; an independent random session secret per Vhost; one-hour default lifetime; callback at `/_rustproxy/oidc/callback` and local logout at `/_rustproxy/oidc/logout`. Invalid logins, expired cookies and unavailable issuer responses never authorize upstream access. - **Session policy:** HTTPS-only, HttpOnly, SameSite=Lax cookies; an independent random session secret per Vhost; one-hour default lifetime; callback at `/_rustproxy/oidc/callback` and local logout at `/_rustproxy/oidc/logout`. Invalid logins, expired cookies and unavailable issuer responses never authorize upstream access.
- **Administration:** new OIDC fields in the Vhost panel, matching OpenAPI schema and English/Polish labels. All OIDC values are disabled by default. Source candidate: the Rust build and live OIDC compatibility checks have not been performed in the packaging environment. - **Administration:** new OIDC fields in the Vhost panel, matching OpenAPI schema and English/Polish labels. All OIDC values are disabled by default. Source candidate: the Rust build and live OIDC compatibility checks have not been performed in the packaging environment.
## 0.34.15 — optional Authelia and authentik forward auth (source candidate) ## 0.34.15 — optional Authelia and authentik forward auth
- **Provider-neutral refactor:** Extracted `auth_provider::Provider` with unified selected-provider dispatch, verified identity/cookie handling and one `error_auth_provider.html` template. Removed three Tinyauth-only HTML template files; 502/504 titles still identify Tinyauth, Authelia or authentik and failure category. Shared `forward_auth` transport continues to be reused without embedding vendor-specific branching in the request pipeline. - **Provider-neutral refactor:** Extracted `auth_provider::Provider` with unified selected-provider dispatch, verified identity/cookie handling and one `error_auth_provider.html` template. Removed three Tinyauth-only HTML template files; 502/504 titles still identify Tinyauth, Authelia or authentik and failure category. Shared `forward_auth` transport continues to be reused without embedding vendor-specific branching in the request pipeline.
- **Vhost → Authentication:** two additional, independently configurable choices: Authelia (`/api/authz/forward-auth`) and authentik (`/outpost.goauthentik.io/auth/nginx`), alongside unchanged Tinyauth and HTTP Basic Auth. Exactly one method can be active per Vhost; both new methods default off, with old configurations remaining compatible. Settings can be edited through the panel and vhosts configuration API. - **Vhost → Authentication:** two additional, independently configurable choices: Authelia (`/api/authz/forward-auth`) and authentik (`/outpost.goauthentik.io/auth/nginx`), alongside unchanged Tinyauth and HTTP Basic Auth. Exactly one method can be active per Vhost; both new methods default off, with old configurations remaining compatible. Settings can be edited through the panel and vhosts configuration API.
@@ -26,14 +164,14 @@ Release history focused on **features, settings, default behavior, compatibility
- **authentik:** supports forward auth (single application) with each protected host's reserved `/outpost.goauthentik.io` route automatically proxied to the configured outpost. The protected URL is passed to the outpost for SSO login; request cookies, identity headers and session-refresh `Set-Cookie` values are propagated with limits. Different parent domains can use one central authentik instance with distinct per-app proxy providers. - **authentik:** supports forward auth (single application) with each protected host's reserved `/outpost.goauthentik.io` route automatically proxied to the configured outpost. The protected URL is passed to the outpost for SSO login; request cookies, identity headers and session-refresh `Set-Cookie` values are propagated with limits. Different parent domains can use one central authentik instance with distinct per-app proxy providers.
- **Fail closed:** verifier transport/invalid-response errors return 502; timeout returns 504; unauthenticated/denied requests never reach application origins. Provider identity is never accepted from incoming client headers. Cache sharing is disabled for protected requests; fail-closed auth responses are kept out of custom-error interception. - **Fail closed:** verifier transport/invalid-response errors return 502; timeout returns 504; unauthenticated/denied requests never reach application origins. Provider identity is never accepted from incoming client headers. Cache sharing is disabled for protected requests; fail-closed auth responses are kept out of custom-error interception.
## 0.34.14 — Tinyauth gateway error pages (source candidate) ## 0.34.14 — Tinyauth gateway error pages
- **Tinyauth outage:** when RustProxy cannot reach the Tinyauth verifier (connect, DNS, TLS or transport error), it returns a built-in **502 Tinyauth connection error** HTML page rather than plain `502 Bad Gateway`. The page is local and self-contained; no failed authentication is bypassed and no backend address, certificate detail or transport error is revealed to the caller. - **Tinyauth outage:** when RustProxy cannot reach the Tinyauth verifier (connect, DNS, TLS or transport error), it returns a built-in **502 Tinyauth connection error** HTML page rather than plain `502 Bad Gateway`. The page is local and self-contained; no failed authentication is bypassed and no backend address, certificate detail or transport error is revealed to the caller.
- **Tinyauth compatibility patch:** the Nginx verifier now receives one consistent auth-request header set. RustProxy removes ForwardAuth module markers (`X-Forwarded-Host`, `X-Forwarded-URI`) while preserving the verified client IP and original request URL. This resolves the `conflicting auth module headers` rejection introduced in Tinyauth 5.2.0 without disabling anti-spoofing checks. Added regression tests for conflicting module headers and forged client identity. Use the internal `/api/auth/nginx` endpoint. - **Tinyauth compatibility patch:** the Nginx verifier now receives one consistent auth-request header set. RustProxy removes ForwardAuth module markers (`X-Forwarded-Host`, `X-Forwarded-URI`) while preserving the verified client IP and original request URL. This resolves the `conflicting auth module headers` rejection introduced in Tinyauth 5.2.0 without disabling anti-spoofing checks. Added regression tests for conflicting module headers and forged client identity. Use the internal `/api/auth/nginx` endpoint.
- **Tinyauth timeout:** a verifier request that exceeds the configured deadline returns a distinct **504 Tinyauth timeout** page. Invalid verifier responses, unexpected HTTP statuses and oversized identity/response data return **502 Tinyauth verification error**. All retain no-store and the authenticated-request isolation marker, so Vhost/global error-page overrides do not mask Tinyauth's diagnostic category. - **Tinyauth timeout:** a verifier request that exceeds the configured deadline returns a distinct **504 Tinyauth timeout** page. Invalid verifier responses, unexpected HTTP statuses and oversized identity/response data return **502 Tinyauth verification error**. All retain no-store and the authenticated-request isolation marker, so Vhost/global error-page overrides do not mask Tinyauth's diagnostic category.
## 0.34.13 — Global security, section-based configuration API and panel UX (source candidate) ## 0.34.13 — Global security, section-based configuration API and panel UX
- **GeoIP:** on-demand data refresh moved from the isolated `POST /api/geoip/update` to `POST /api/config/geoip/update`, colocated with the revisioned GeoIP settings. This action does not change the config revision; the UI and RO/RW token allowlist use the new URL. Section paths now consistently use kebab-case, including `not-found-hosts` (the JSON property remains `not_found_hosts`). Old action/section paths are removed. - **GeoIP:** on-demand data refresh moved from the isolated `POST /api/geoip/update` to `POST /api/config/geoip/update`, colocated with the revisioned GeoIP settings. This action does not change the config revision; the UI and RO/RW token allowlist use the new URL. Section paths now consistently use kebab-case, including `not-found-hosts` (the JSON property remains `not_found_hosts`). Old action/section paths are removed.
- **API:** all 23 supported configuration sections now have `GET /api/config/{section}` returning `{revision, section, value}` and `PUT /api/config/{section}` accepting `{expected_revision, value}`. PUT still returns the updated full `{revision, config}` envelope so clients can refresh all sections. Both methods use the same section registry. Unknown/removed sections are 404; stale writes return 409. Writes remain full section replacements, validated and committed atomically; there are no partial PATCH semantics. - **API:** all 23 supported configuration sections now have `GET /api/config/{section}` returning `{revision, section, value}` and `PUT /api/config/{section}` accepting `{expected_revision, value}`. PUT still returns the updated full `{revision, config}` envelope so clients can refresh all sections. Both methods use the same section registry. Unknown/removed sections are 404; stale writes return 409. Writes remain full section replacements, validated and committed atomically; there are no partial PATCH semantics.
@@ -45,7 +183,7 @@ Release history focused on **features, settings, default behavior, compatibility
- **Config/API:** additive `global_security` configuration object, backwards-compatible defaults, atomic `PUT /api/config/global-security` payload containing the complete nested `global_security` object (including `user_agent_blocking`). Country detection uses trusted client IP/GeoIP; global DNS ACL rules use the existing safe background resolver. English and Polish panel strings, OpenAPI and guides updated. - **Config/API:** additive `global_security` configuration object, backwards-compatible defaults, atomic `PUT /api/config/global-security` payload containing the complete nested `global_security` object (including `user_agent_blocking`). Country detection uses trusted client IP/GeoIP; global DNS ACL rules use the existing safe background resolver. English and Polish panel strings, OpenAPI and guides updated.
- **Panel layout:** separated static cookie-cache advice from response-header toggles, added space between transform numeric controls and minification checkboxes, and moved certificate access-permission guidance next to the generated command preview. The **Copy commands** button remains hidden until commands are generated. - **Panel layout:** separated static cookie-cache advice from response-header toggles, added space between transform numeric controls and minification checkboxes, and moved certificate access-permission guidance next to the generated command preview. The **Copy commands** button remains hidden until commands are generated.
## 0.34.12 — cache diagnostics and optional chart axes (source candidate) ## 0.34.12 — cache diagnostics and optional chart axes
- Origin-provided `Cache-Status`, `X-Cache`, `X-Cache-Hits`, `X-Cache-Reason`, `X-Cache-Resident-Age` and `X-Cache-Status` are retained on forwarded and cached responses; the HOT/COLD cache and conditional `304` refresh no longer erase these values from stored metadata. Cache-disabled and HIT-only display modes leave origin headers untouched. - Origin-provided `Cache-Status`, `X-Cache`, `X-Cache-Hits`, `X-Cache-Reason`, `X-Cache-Resident-Age` and `X-Cache-Status` are retained on forwarded and cached responses; the HOT/COLD cache and conditional `304` refresh no longer erase these values from stored metadata. Cache-disabled and HIT-only display modes leave origin headers untouched.
- `Cache-Status` follows RFC 9211's ordered member model: preserve upstream members and append the RustProxy member if enabled. RustProxy reports `hit; ttl=...` for a local cache hit, `fwd=miss`/`fwd=bypass` for forwarded requests, and `fwd=stale; fwd-status=304` when revalidation used a `304`. The existing fixed reason goes into `detail="..."`; transformation metadata applies to RustProxy's member only. No non-standard `backend=` parameter is generated. - `Cache-Status` follows RFC 9211's ordered member model: preserve upstream members and append the RustProxy member if enabled. RustProxy reports `hit; ttl=...` for a local cache hit, `fwd=miss`/`fwd=bypass` for forwarded requests, and `fwd=stale; fwd-status=304` when revalidation used a `304`. The existing fixed reason goes into `detail="..."`; transformation metadata applies to RustProxy's member only. No non-standard `backend=` parameter is generated.
@@ -68,7 +206,7 @@ Release history focused on **features, settings, default behavior, compatibility
- **Web Server:** generate document-root permission commands directly below its path, updating them for input, edits, resets and presets. Include parent traversal, read access to the served tree and default ACLs for new content without granting RustProxy write access. The permission walk does not follow child symlinks. PHP-FPM and systemd/MAC policies remain separately managed. - **Web Server:** generate document-root permission commands directly below its path, updating them for input, edits, resets and presets. Include parent traversal, read access to the served tree and default ACLs for new content without granting RustProxy write access. The permission walk does not follow child symlinks. PHP-FPM and systemd/MAC policies remain separately managed.
- **API and documentation:** add a session-authenticated, CSRF-protected certificate inspection endpoint with matching OpenAPI/access regressions; update English/Polish panel text and filesystem guidance. General administration tokens and certificate-only tokens cannot use the inspection endpoint. Package and lockfile are aligned to **0.34.10**; native Cargo and protocol acceptance are still required for this source candidate. - **API and documentation:** add a session-authenticated, CSRF-protected certificate inspection endpoint with matching OpenAPI/access regressions; update English/Polish panel text and filesystem guidance. General administration tokens and certificate-only tokens cannot use the inspection endpoint. Package and lockfile are aligned to **0.34.10**; native Cargo and protocol acceptance are still required for this source candidate.
## 0.34.9 — experimental TLS post-quantum key exchange (source candidate) ## 0.34.9 — experimental TLS post-quantum key exchange
- **Dashboard source maintenance:** panel styles are organized into eight modules with consolidated responsive conditions and a deterministic build step. Login and the dashboard continue to load one fingerprinted stylesheet; rebuilding the binary applies changes. Source checks and browser regression tooling cover the module order and UI cascade. Native Rust validation remains required for this source candidate. - **Dashboard source maintenance:** panel styles are organized into eight modules with consolidated responsive conditions and a deterministic build step. Login and the dashboard continue to load one fingerprinted stylesheet; rebuilding the binary applies changes. Source checks and browser regression tooling cover the module order and UI cascade. Native Rust validation remains required for this source candidate.
- **CSS build preflight fix:** ship local `scripts`/`build` package markers so unrelated installed Python packages cannot hide `build.panel_css` or the application scripts. This fixes `dev.sh` startup/repair, the CSS audit and packaging imports on affected environments; generated styles remain unchanged. - **CSS build preflight fix:** ship local `scripts`/`build` package markers so unrelated installed Python packages cannot hide `build.panel_css` or the application scripts. This fixes `dev.sh` startup/repair, the CSS audit and packaging imports on affected environments; generated styles remain unchanged.
@@ -91,7 +229,7 @@ Release history focused on **features, settings, default behavior, compatibility
- Only client hard-reload `Cache-Control: no-cache`, `max-age=0` and `Pragma: no-cache` are ignored when considering a **fresh HIT**, retaining `Age` and hit counters. Kept the original `Cache-Control: no-store` bypass, `only-if-cached` 504 on cache MISS and no background origin refresh on HIT, and `min-fresh`/`max-stale`/positive `max-age` freshness constraints (including explicit `max-stale=0` during grace). Original request headers, Vary, access/privacy rules and Web Server handling remain intact. - Only client hard-reload `Cache-Control: no-cache`, `max-age=0` and `Pragma: no-cache` are ignored when considering a **fresh HIT**, retaining `Age` and hit counters. Kept the original `Cache-Control: no-store` bypass, `only-if-cached` 504 on cache MISS and no background origin refresh on HIT, and `min-fresh`/`max-stale`/positive `max-age` freshness constraints (including explicit `max-stale=0` during grace). Original request headers, Vary, access/privacy rules and Web Server handling remain intact.
- Source-level regression cases cover hard reload, fallback TTL, validator revalidation, no-store/private/Surrogate protections, zero-TTL stale-if-error, min-fresh/max-stale, and `only-if-cached` for proxy and Web Server. Native Rust compilation and runtime acceptance are required before a production release claim. This remains a Varnish-inspired policy subset, not a VCL clone. - Source-level regression cases cover hard reload, fallback TTL, validator revalidation, no-store/private/Surrogate protections, zero-TTL stale-if-error, min-fresh/max-stale, and `only-if-cached` for proxy and Web Server. Native Rust compilation and runtime acceptance are required before a production release claim. This remains a Varnish-inspired policy subset, not a VCL clone.
## 0.34.6 — Docker live logs, neutral Publish switch and Vhost Content-Type rules (source candidate) ## 0.34.6 — Docker live logs, neutral Publish switch and Vhost Content-Type rules
- **Security / ordinary Vhosts:** optional per-vhost list of up to 64 blocked request media types (`multipart/form-data`, `application/json`, `image/*`). Matches case-insensitively, ignores MIME parameters, returns 403 before any proxy, Web Server/PHP-FPM, Custom location or cache response. Missing `Content-Type` is permitted; malformed/ambiguous headers are refused while filtering is enabled. Docker editor is unchanged. Default off for existing and new Vhosts. - **Security / ordinary Vhosts:** optional per-vhost list of up to 64 blocked request media types (`multipart/form-data`, `application/json`, `image/*`). Matches case-insensitively, ignores MIME parameters, returns 403 before any proxy, Web Server/PHP-FPM, Custom location or cache response. Missing `Content-Type` is permitted; malformed/ambiguous headers are refused while filtering is enabled. Docker editor is unchanged. Default off for existing and new Vhosts.
- **Interface:** Vhosts → Security has a dedicated Content-Type subgroup with enable checkbox and multiline patterns. The Docker Publish toggle is smaller, unboxed and neutral with standard theme colors; instant save, rollback and live logs remain. - **Interface:** Vhosts → Security has a dedicated Content-Type subgroup with enable checkbox and multiline patterns. The Docker Publish toggle is smaller, unboxed and neutral with standard theme colors; instant save, rollback and live logs remain.
@@ -100,7 +238,7 @@ Release history focused on **features, settings, default behavior, compatibility
- **Open app URL:** use the selected listener's actual `binding.tls` and `binding.address` so HTTPS and nonstandard public ports are reflected in generated app links. - **Open app URL:** use the selected listener's actual `binding.tls` and `binding.address` so HTTPS and nonstandard public ports are reflected in generated app links.
- **Release:** Cargo package and lockfile, embedded OpenAPI version, Polish/English UI hints and documentation aligned to 0.34.6. This is a source-only release candidate requiring native compilation and integration testing before deployment. Blocking `Content-Type` does not replace security updates for PHP or the application. - **Release:** Cargo package and lockfile, embedded OpenAPI version, Polish/English UI hints and documentation aligned to 0.34.6. This is a source-only release candidate requiring native compilation and integration testing before deployment. Blocking `Content-Type` does not replace security updates for PHP or the application.
## 0.34.5 — Administration UX improvements (source candidate) ## 0.34.5 — Administration UX improvements
- **Docker vhosts traffic view:** added the ordinary Vhost status, cumulative requests/active/errors, EWMA latency, traffic/s and lifetime-rate chart in a collapsed-by-default disclosure below each published Docker vhost's configuration editor. One toolbar button expands/collapses all active publications; individual disclosure state survives live refreshes. Values use generated Vhost counters from the existing status stream. The browser chart begins collecting when the vhost is observed and resets on sign-out; backend/configuration behavior is unchanged. - **Docker vhosts traffic view:** added the ordinary Vhost status, cumulative requests/active/errors, EWMA latency, traffic/s and lifetime-rate chart in a collapsed-by-default disclosure below each published Docker vhost's configuration editor. One toolbar button expands/collapses all active publications; individual disclosure state survives live refreshes. Values use generated Vhost counters from the existing status stream. The browser chart begins collecting when the vhost is observed and resets on sign-out; backend/configuration behavior is unchanged.
@@ -123,7 +261,7 @@ Release history focused on **features, settings, default behavior, compatibility
- **Corner-style consistency:** additional Vhost dependent-field groups, cache/legend and metric-info controls, selected admin cards and the self-contained authenticated documentation page follow the saved Square/Subtle/Rounded/Extra corner preference. Circular status indicators/spinners remain circular by design; Original retains its legacy styling. - **Corner-style consistency:** additional Vhost dependent-field groups, cache/legend and metric-info controls, selected admin cards and the self-contained authenticated documentation page follow the saved Square/Subtle/Rounded/Extra corner preference. Circular status indicators/spinners remain circular by design; Original retains its legacy styling.
- Updated English and Polish labels, documentation and UI regression checks. This is a UI-only release; it does not change backend execution or configuration semantics. Native Rust builds and target-system acceptance remain required. - Updated English and Polish labels, documentation and UI regression checks. This is a UI-only release; it does not change backend execution or configuration semantics. Native Rust builds and target-system acceptance remain required.
## 0.34.4 — Multi-profile ACLs, certificate override and mobile Docker details (source candidate) ## 0.34.4 — Multi-profile ACLs, certificate override and mobile Docker details
- ACL profile picker corners (dropdown, counter, rule cards, policy badges, selected chips and remove buttons) follow the dashboard corner-style setting (Square, Subtle, Rounded, Extra rounded); Original keeps its prior shape. ACL selection and rule evaluation are unchanged. - ACL profile picker corners (dropdown, counter, rule cards, policy badges, selected chips and remove buttons) follow the dashboard corner-style setting (Square, Subtle, Rounded, Extra rounded); Original keeps its prior shape. ACL selection and rule evaluation are unchanged.
- Docker agent details now wrap certificate SHA-256 fingerprints on narrow screens. - Docker agent details now wrap certificate SHA-256 fingerprints on narrow screens.
@@ -133,7 +271,7 @@ Release history focused on **features, settings, default behavior, compatibility
- Docker publication editor shows the certificate/domain mismatch consent immediately below **Backend host IP**, with a small vertical gap. - Docker publication editor shows the certificate/domain mismatch consent immediately below **Backend host IP**, with a small vertical gap.
- The source release needs native Rust compilation and target-system acceptance tests before production use. - The source release needs native Rust compilation and target-system acceptance tests before production use.
## 0.34.3 — QUIC socket buffering, live HTTP/3 settings and cache outcome mix (source candidate) ## 0.34.3 — QUIC socket buffering, live HTTP/3 settings and cache outcome mix
- QUIC now opens the UDP listener socket with per-listener receive/send buffer targets (4 MiB each by default; `udp_receive_buffer_bytes`, `udp_send_buffer_bytes`; `0` uses OS defaults). Reports the effective buffer sizes and warns if kernel limits clamp them. Linux `SO_RCVBUF` / `SO_SNDBUF` accounting is accommodated, so observed `ss` values correspond to the configured effective target when permitted. UDP buffer modifications require restart, not silent hot application. Existing configs without these fields load the new defaults. - QUIC now opens the UDP listener socket with per-listener receive/send buffer targets (4 MiB each by default; `udp_receive_buffer_bytes`, `udp_send_buffer_bytes`; `0` uses OS defaults). Reports the effective buffer sizes and warns if kernel limits clamp them. Linux `SO_RCVBUF` / `SO_SNDBUF` accounting is accommodated, so observed `ss` values correspond to the configured effective target when permitted. UDP buffer modifications require restart, not silent hot application. Existing configs without these fields load the new defaults.
- HTTP/3 `Alt-Svc` follows newly saved listener lifetime immediately on H1/H2 TLS responses for an already-bound H3 socket; disabling H3 publishes `Alt-Svc: clear`. New QUIC handshakes use saved stream/idle limits and reduced connection caps. Listener binding/TLS/H1-H2 changes, a new/disabled UDP listener or expanding startup capacity still need restart; UI restart indicator reflects this. - HTTP/3 `Alt-Svc` follows newly saved listener lifetime immediately on H1/H2 TLS responses for an already-bound H3 socket; disabling H3 publishes `Alt-Svc: clear`. New QUIC handshakes use saved stream/idle limits and reduced connection caps. Listener binding/TLS/H1-H2 changes, a new/disabled UDP listener or expanding startup capacity still need restart; UI restart indicator reflects this.
Generated
+20 -1
View File
@@ -1330,6 +1330,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "libproc" name = "libproc"
version = "0.14.11" version = "0.14.11"
@@ -1427,6 +1436,15 @@ dependencies = [
"windows", "windows",
] ]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -1925,7 +1943,7 @@ dependencies = [
[[package]] [[package]]
name = "rust-proxy" name = "rust-proxy"
version = "0.35.0" version = "0.36.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -1949,6 +1967,7 @@ dependencies = [
"ipnet", "ipnet",
"libc", "libc",
"metrics-process", "metrics-process",
"mimalloc",
"quinn", "quinn",
"rand 0.10.3", "rand 0.10.3",
"rcgen", "rcgen",
+10 -1
View File
@@ -1,9 +1,13 @@
[package] [package]
name = "rust-proxy" name = "rust-proxy"
version = "0.35.0" version = "0.36.8"
edition = "2021" edition = "2021"
rust-version = "1.99.1" rust-version = "1.99.1"
description = "RustProxy - independent reverse proxy with local administration" description = "RustProxy - independent reverse proxy with local administration"
authors = ["Mateusz Gruszczyński"]
license = "AGPL-3.0-or-later"
homepage = "https://rustproxy.linuxiarz.pl/"
repository = "https://git.linuxiarz.pl/gru/RustProxy"
publish = false publish = false
[dependencies] [dependencies]
@@ -13,6 +17,7 @@ ipnet = "2.11"
rpassword = "7.4" rpassword = "7.4"
zeroize = "1.8" zeroize = "1.8"
anyhow = "1.0" anyhow = "1.0"
mimalloc = { version = "0.1", default-features = false, optional = true }
libc = "0.2" libc = "0.2"
arc-swap = "1.7" arc-swap = "1.7"
axum = { version = "0.8.9", default-features = false, features = ["http1", "http2", "json", "query", "tokio", "ws"] } axum = { version = "0.8.9", default-features = false, features = ["http1", "http2", "json", "query", "tokio", "ws"] }
@@ -67,6 +72,10 @@ systemstat = "=0.2.7"
# Swagger assets are prepared by build.rs and embedded behind the admin guard. # Swagger assets are prepared by build.rs and embedded behind the admin guard.
# No adapter build script, implicit version fallback or Cargo-config dependency. # No adapter build script, implicit version fallback or Cargo-config dependency.
[features]
default = []
mimalloc = ["dep:mimalloc"]
[build-dependencies] [build-dependencies]
serde_json = "1.0" serde_json = "1.0"
+55 -130
View File
@@ -1,150 +1,75 @@
# RustProxy 0.35.0 # RustProxy
RustProxy combines a reverse proxy, static/PHP web server, certificate management and traffic monitoring in one service with a browser administration panel. It supports several domains and backends without requiring a separate proxy configuration language. **RustProxy** is a reverse proxy, web server, certificate manager and traffic monitor in a single service, managed from a built-in browser panel. It publishes many domains and backends without a separate configuration language: every setting is validated before it is applied and can be changed live from the panel or the API.
## Changes in 0.35.0 - Website and packages: [rustproxy.linuxiarz.pl](https://rustproxy.linuxiarz.pl/)
- Source code: [git.linuxiarz.pl/gru/RustProxy](https://git.linuxiarz.pl/gru/RustProxy)
- Documentation: [docs/README.md](docs/README.md) (also built into the panel at `/docs`)
- License: [GNU AGPL v3.0 or later](LICENSE)
- Accept encoded path separators `%2F` / `%2f` for reverse-proxy requests. Routing, ACLs and other security policy evaluate the normalized path with real `/` separators, while Preserve-mode forwarding keeps the client request target encoded for the upstream. ![RustProxy Overview dashboard](docs/images/overview.png)
- Keep path hardening fail-closed: encoded backslash, percent, query/fragment delimiters, semicolon, control bytes, malformed escapes, invalid UTF-8 and dot-segment traversal remain rejected.
- Refresh release-gate compatibility with the supported Rust/Clippy toolchain and retain the complete test suite as a required release check.
## Changes in 0.34.16 ## Highlights
- Separated **identity provider** from **integration method** in Vhost Authentication: Tinyauth, Authelia or authentik can use Forward Auth or OIDC; Pocket ID and compatible custom providers use OIDC. HTTP Basic remains its own method. The `authentication` field stores the pair, with legacy Vhost JSON still supported. | Area | Capabilities |
- Added optional OIDC Authorization Code + PKCE for a central HTTPS identity provider used by applications on unrelated domains.
- OIDC uses discovery, optional internal IdP routing, JWKS signature validation, nonce/state checks, encrypted host-only sessions and an application-host callback at `/_rustproxy/oidc/callback`. Other authentication integrations are unchanged.
- This is a source candidate; Rust build and real IdP integration tests are required before production deployment. An IdP also needs an OIDC client registered with the exact HTTPS application callback; ordinary Forward Auth configuration is not enough.
## Changes in 0.34.15
- Added **Authelia** and **authentik** as separate, disabled-by-default Vhost authentication methods alongside existing Tinyauth and HTTP Basic Auth. Each Vhost selects at most one provider; older Vhost JSON imports keep their settings.
- Authelia integration uses `/api/authz/forward-auth` and validates login redirects against the configured public portal. authentik integration uses `/outpost.goauthentik.io/auth/nginx`, redirects unauthenticated clients to the protected hostname's outpost and forwards that reserved path to the configured outpost server.
- Authentication checks fail closed on provider outages, malformed responses and timeouts. Verifier-returned identity headers are selected/limited, client-forged identity headers are stripped, and successful sessions remain private/no-store. Protected application domains may differ when each provider is configured appropriately.
## Changes in 0.34.14
- Preserves Tinyauth Nginx auth-request compatibility with old and new versions supporting `/api/auth/nginx`.
- Uses a separate, locally embedded **Tinyauth connection error (502)**, **Tinyauth timeout (504)** or **Tinyauth verification error (502)** page for failed authorization checks, without allowing access to protected origins. The existing built-in **Bad Gateway (502)** page and all standard HTTP error templates stay unchanged. No extra configuration is needed; HTTP status codes retain their existing meaning (502 for verifier errors, 504 for verifier timeouts).
## Main capabilities
| Area | Available features |
|---|---| |---|---|
| Reverse proxy | HTTP/HTTPS origins, HTTP/1.1 and HTTP/2 upstream modes, health checks, load balancing, connection reuse, WebSockets, path-based routing, retries and error fallbacks | | Reverse proxy | HTTP/HTTPS backends, HTTP/1.1, HTTP/2 and H2C upstreams, load balancing, health checks, connection reuse, WebSockets, custom locations, retries and error fallbacks |
| Frontends | HTTP, HTTPS, HTTP/2, optional HTTP/3 (QUIC), named listeners, domain and wildcard routing, redirects, TLS certificates | | Frontends | HTTP, HTTPS, HTTP/2, HTTP/3 (QUIC), named listeners, exact and wildcard domains, redirects, optional post-quantum TLS key exchange |
| Web Server | Static content, downloads, directory/index policy, rewrite and `try_files`, selected `.htaccess` rules, optional PHP-FPM, application presets | | Web Server | Static sites and downloads, `try_files`, rewrites, `.htaccess` subset, byte ranges, precompressed files, PHP-FPM |
| Access | IP/CIDR and DNS/DDNS rules, country restrictions, reusable ACLs, rate limits, method rules, Basic Auth, Tinyauth/Authelia/authentik via Forward Auth, and optional OIDC with Authelia/authentik/Pocket ID/compatible providers, security headers | | Certificates | PEM import, ACME HTTP-01 and DNS-01 (Cloudflare, OVH, RFC 2136), automatic renewal, external Certbot files, hot reload |
| Delivery | Optional shared HOT/RAM and COLD/disk cache, cache invalidation, compression, response transformations, static-file optimizations | | Access | IP/CIDR, DNS/DDNS and country rules, ACL profiles, rate limits, method and Content-Type policies, Basic Auth, Tinyauth, Authelia, authentik, OIDC |
| Integrations | Docker discovery via separate agents, TCP/UDP Streams, ACME certificate providers, Prometheus, Grafana/Nagios examples and administration API | | Delivery | Shared memory and disk cache, `Cache-Status`, purge/ban, gzip/Brotli/Zstandard compression, asset minification |
| Operations | Dashboard presets, traffic analytics, live/stored logs, configuration history, encrypted backups and certificate reloads | | Monitoring | Live dashboard, per-Vhost statistics, live and stored logs, Prometheus exporter, JSON metrics, Grafana and Nagios |
| Operations | Configuration history and restore, encrypted backups, Docker discovery, Nginx Proxy Manager import, REST API with OpenAPI |
Features and default settings vary by component; configure only the capabilities you need. The [documentation index](docs/README.md) covers each workflow, including prerequisites, security boundaries, troubleshooting and API access. The complete list is in [Feature list](docs/FEATURES.md); a visual tour is in [Screenshots](docs/SCREENSHOTS.md).
## 0.34.12 — upstream cache diagnostics and RFC 9211
- **Web Admin** cleanups and
- **API** refactor
## 0.34.12 — upstream cache diagnostics and RFC 9211
- **Cache and upstream headers:** retain origin-supplied `Cache-Status` and `X-Cache*` diagnostics on BYPASS, MISS and HOT/COLD HIT; no longer silently strip them when cache diagnostics are disabled or limited to HIT. Origin `Cache-Status` members remain in order; an enabled RustProxy `Cache-Status` member is appended with its own `hit`, `ttl`, `fwd` and `detail` parameters rather than overwriting earlier members. On collisions with the non-standard legacy `X-Cache*` headers, RustProxy keeps its existing names and preserves upstream values as `X-Upstream-Cache*`. These options affect only RustProxy's own diagnostic output. See [Cache](docs/CACHE.md) for examples and RFC 9211 semantics.
- **Appearance:** **Settings → Appearance → Charts → Show X/Y scales** adds optional, off-by-default numeric chart scales to live SVG plots. The existing lines, colors and dynamic Y limits (including adaptive Vhost/Docker rate scales) are unchanged; Y uses the exact plotted range and X shows sample numbers, not inferred minutes. The setting is account-scoped and reversible. See [Dashboard](docs/DASHBOARD.md).
## 0.34.11 — adaptive live request-rate charts
- **Vhosts and Docker Vhosts:** the lifetime request-rate sparkline now rescales with recent traffic. Isolated extreme peaks no longer flatten ordinary request activity; sustained traffic growth expands the scale and it shrinks again as traffic drops. Samples above the visible range are capped at the top of the plot, while the text legend retains the real recorded maximum. Hover the value to see the current scale range. Existing statistics collection, request counters and metrics APIs are unchanged. See [Vhosts](docs/VHOSTS.md) and [Docker discovery](docs/DOCKER_DISCOVERY.md).
## 0.34.10 — filesystem permissions and certificate names
- **Certificates → Existing files:** permission commands are generated from the entered external certificate/key paths and displayed **below both path fields**. The command block keeps the previous panel styling, with account selection and guidance under **Options and help**. Commands grant directory traversal and read access to the resolved files for the `rustproxy` service account. After the public certificate can be read, its DNS SAN names automatically fill the legacy SNI field; manual edits are preserved, and **Read names from certificate** retries after permissions are corrected. See [Certificates](docs/ACME_PROVIDERS.md).
- **Web Server:** the document-root field now has generated permission commands directly below it, updated as the path changes. The helper covers parent traversal and read/traversal of the served tree. Review and run the commands as an administrator on the server; the panel does not execute them. See [Web Server](docs/WEB_SERVER.md).
## 0.34.9 — experimental PQC (source candidate)
- **Settings → TLS policy:** opt-in experimental TLS 1.3 hybrid post-quantum key exchange (X25519MLKEM768), using aws-lc-rs only for enabled frontend TLS policies. Classic key-exchange groups remain enabled for client compatibility. Off by default; cipher-suite choices remain separate. HTTP/3 and per-vhost TLS overrides follow the selected policy. Details and rollback: [TLS PQC guide](docs/TLS_POST_QUANTUM.md) (also in the authenticated panel Reader).
## 0.34.8
- **Docker vhosts:** automatic hostname uses the container name, without image or port suffix; prior auto-generated names are updated while custom names and aliases are preserved. Docker Agent **1.0.1**.
## 0.34.7
- **Settings → Cache:** Varnish-inspired cache policy (on by default) ignores hard-reload `no-cache` / `max-age=0` / `Pragma: no-cache` for fresh HITs, without disabling `only-if-cached`, `no-store`, `max-stale` or `min-fresh`. Public origin `no-cache` responses can use an explicit fallback TTL; other responses retain revalidation, `stale-if-error`, Surrogate-Control vetoes and privacy protections. The behavior is explained by an **i** icon next to the setting. See [Cache](docs/CACHE.md) for examples and safety implications.
## 0.34.6
- **Docker vhosts:** **Live logs** beside **Open app** opens the existing HTTP access-log viewer, filtered to the selected generated Docker vhost and its aliases. These are RustProxy request logs, not container stdout/stderr. Open app honors HTTPS and nonstandard listener ports.
- **Publish switch:** a compact neutral on/off control in the existing panel theme, without a pill container or green fill. It saves immediately and rolls back on failure.
- **Ordinary Vhosts → Security:** optional request **Content-Type** denylist with exact MIME types and `type/*` wildcards. Matching requests receive **403 before PHP-FPM, Web Server, reverse proxy, custom locations or cache**. Docker vhost configuration remains unchanged.
## 0.34.5
- Administration UX refinements: clearer grouping for Web Server delivery and PHP-FPM controls, Stream backend and health-check settings, and Redirect/404 host identity versus frontend certificate options.
- Stream backend probe settings are hidden while health checks are off, and disabled Web Server fields now have a consistent muted appearance. Both UI languages are supported.
- UX follow-up: Vhost transform/cache overrides now place checkboxes directly above their textareas; inline Vhost IP rules live with ACL profiles under Security. Web Server preset hardening controls and refresh interval grids have clearer alignment, and System information keeps the statistics source without the separate process block.
- More controls (including the authenticated documentation/Swagger page) follow the saved corner style. Configuration keys, API payloads and proxy behavior are unchanged.
- Published Docker vhosts now expose collapsed, Vhost-style traffic statistics and a single toolbar button to expand or collapse the statistics for all published Docker services.
## 0.34.4
- Docker vhosts: SHA-256 certificate fingerprints wrap correctly in mobile agent details.
- Ordinary and Docker vhosts support up to eight ACL profiles with deterministic explicit deny priority and merged explicit allows, while keeping standalone ACL layers restrictive. Existing singular ACL configuration remains supported.
- Certificate-to-domain mismatch can be explicitly accepted per ordinary or Docker vhost (with a warning/confirmation); normal validation stays enforced without this override, and TLS clients may still reject the certificate.
- Editor layout: SSL certificates and TLS settings are separate; Vhost traffic/security, WebSockets, balancing and location access options are grouped by purpose. Docker certificate overrides appear under the backend IP choice, and common controls respect the dashboard corner style.
## 0.34.3
- HTTP/3 now explicitly requests **4 MiB effective UDP receive and send buffers** per QUIC listener (configurable as `udp_receive_buffer_bytes` and `udp_send_buffer_bytes` in Settings > HTTP/3). On Linux the kernel can cap them using `net.core.rmem_max` / `net.core.wmem_max`; RustProxy logs the effective values and a warning if the target is not met. Setting either value to `0` leaves the OS default. Changing UDP buffers requires service restart.
- HTTP/3 Alt-Svc response headers follow saved policy immediately; turning off an already-bound H3 service emits `Alt-Svc: clear`. New QUIC handshakes pick up modified stream/idle limits without socket rebinding. Increasing the connection cap beyond the startup bound or changing the UDP endpoint still requires restart. QUIC bulk-transfer diagnostic snapshots are available at debug log level.
- The Overview has an **optional, hidden-by-default cumulative cache-outcome bar** showing HIT, MISS, BYPASS and OTHER request counts and percentages **since service start**. Enable it in the dashboard layout editor when needed. This complements the existing cumulative HIT/MISS donut and separate recent cache trend chart. OTHER denotes HTTP requests without a cache decision. Percentages use all HTTP requests, unlike the HIT/MISS donut. The upper-right counter displays only the lifetime HTTP request count; its full meaning is available on hover or to assistive technology.
## 0.34.2
- Added read-only pull-based JSON monitoring at `/api/metrics/json` (admin session or General API token) and at the enabled Prometheus exporter path with `.json` suffix (same exporter bearer/CIDR policy). Includes Overview-style metrics and all existing Prometheus sample families without SSE. Compact precomputed dashboard metrics are available at `/api/metrics/summary`, under `summary` in the full JSON, and at the exporter path with `.summary.json` suffix. This includes readiness counts, RPS, transfer/second and system/cache percentages.
- **Settings → Manual config** displays the active configuration storage mode, its path and the SQLite history location for file-backed mode.
- Documented optional systemd `--config /etc/rustproxy/config.json` deployment with file/directory ACLs, write access for atomic panel saves and reload instructions.
- Public Web Server errors reuse embedded application-style HTML templates (or a matching fallback for statuses without a dedicated page). Proxy-generated non-template errors use standard HTTP status text; neither leaks internal `RustProxy:` diagnostics. Origin error bodies and custom error pages remain configurable.
## 0.34.1
- An explicit `--config /absolute/path/config.json` (or `RUST_PROXY_CONFIG`) makes an existing JSON document the active configuration source. Panel saves and configuration-history restores write back to that file, with SQLite retaining revision/audit data. Out-of-band file changes require an explicit reload; concurrent edits are rejected.
- `rust-proxy reload` / `rust-proxy config reload` and the example systemd `ExecReload` perform configuration and certificate refresh without terminating the service; listener changes can still require a restart.
- Panel-managed, one-time-displayed administration API Bearer tokens with `ro`/`rw` permissions and mandatory source-IP/CIDR restrictions. Browser login remains available; certificate-only tokens are separate.
- Bulk assignment of Vhosts to a certificate; a single **Save and lock** control for unlocked Overview layout.
- Panel-managed Prometheus token issuance, rotation and revocation with one-time display. Existing environment-token configuration remains a compatibility fallback until a generated credential or revocation record replaces it.
## Installation ## Installation
Use the maintained packages where available: | Platform | Method |
|---|---|
| Debian, Ubuntu, RHEL, Rocky, Alma, Fedora | DEB/RPM packages from [rustproxy.linuxiarz.pl](https://rustproxy.linuxiarz.pl/) |
| OPNsense | Plugin from [rustproxy-opn.linuxiarz.pl](https://rustproxy-opn.linuxiarz.pl/) |
| Debian 13+ from source | `install-debian13.sh` bootstrap script |
| Any Linux with systemd | `cargo build --release`, then `install.sh` |
| Docker / OCI | Included `Dockerfile` |
- Linux DEB/RPM: https://rustproxy.linuxiarz.pl/ Quick start from source on Debian 13+:
- OPNsense: https://rustproxy-opn.linuxiarz.pl/
- Docker hosts (optional agent, Linux DEB/RPM): install `rustproxy-docker-agent` independently; see [Docker discovery](docs/DOCKER_DISCOVERY.md).
For Debian 13+ source installation, manual compilation, managed updates or Docker, see [Installation and updates](docs/INSTALL.md). ```bash
wget -qO- https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/install-debian13.sh | bash
sudo -u rustproxy /opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy admin setup-token
ssh -L 9090:127.0.0.1:9090 operator@server # then open http://127.0.0.1:9090
```
Typical managed Linux defaults: HTTP on `:80`, HTTPS on `:443`, local administration at `http://127.0.0.1:9090`, private data in `/var/lib/rustproxy`. The initial public certificate is self-signed. **There is no default administrator password.** Initialize administration locally and assign trusted certificates before public use. Default managed installation: HTTP on `:80`, HTTPS on `:443` with a self-signed certificate, administration on `http://127.0.0.1:9090`, data in `/var/lib/rustproxy`. There is **no default administrator password**; the first account is created with a one-time setup token. Details: [Installation and updates](docs/INSTALL.md).
Updated Linux service templates allow **read-only** access to static website ## First steps
roots under `/home`, `/var/www` and `/srv` using Unix groups or ACLs without
per-directory systemd overrides. The service still runs unprivileged with
`ProtectSystem=strict`; external certificate paths have stricter trust/owner
checks. See [Linux filesystem access](docs/INSTALL.md#linux-debrpm-read-only-access-to-external-paths).
## First configuration 1. Create the administrator and sign in.
2. Review **Settings → Listeners** and assign a trusted certificate (**Certificates**).
3. Add a **Vhost** with your domain and a backend URL, or a **Web Service** for static files.
4. Check health, access rules and routing on **Overview** and **Logs**.
5. Enable cache, compression, authentication or monitoring when needed.
1. Install and start the service, then initialize the administrator using the local CLI. ## Performance
2. Open the panel and review **Settings → Listeners**, administration access and certificates.
3. Create a **Vhost** with a domain name and a reachable HTTP backend or **Web Service**.
4. Confirm frontend TLS, backend health, access permissions and routing.
5. Enable additional cache, compression, Docker or monitoring functions as needed; inspect **Overview**, **Logs** and **Cache** for results.
## Guides RustProxy streams requests and responses asynchronously on Tokio and Hyper. On one CPU core it serves roughly 15,000 proxied and 26,000 static requests per second with full statistics and logging enabled, and it is faster than a comparably configured Nginx for large proxied responses. Benchmark method, the full comparison and tuning advice are in [Performance](docs/PERFORMANCE.md).
- [Installation](docs/INSTALL.md) · [Settings](docs/CONFIGURATION.md) · [Vhosts](docs/VHOSTS.md) · [Web Server](docs/WEB_SERVER.md) · [PHP-FPM](docs/PHP_FPM.md) ## Building
- [Certificates](docs/ACME_PROVIDERS.md) · [Cache](docs/CACHE.md) · [Response delivery](docs/RESPONSE_DELIVERY.md) · [Docker discovery](docs/DOCKER_DISCOVERY.md) · [Monitoring](docs/MONITORING.md)
- [Security](docs/SECURITY.md) · [API](docs/API.md) · [Troubleshooting](docs/TROUBLESHOOTING.md)
- [Changelog](CHANGELOG.md) · [Roadmap](ROADMAP.md)
The authenticated **`/docs`** page includes the guides and a separate interactive **OpenAPI/Swagger** tab. ```bash
./dev.sh check # cargo check for all targets
./dev.sh test # complete test suite
./dev.sh release # optimized binary in target/release/rust-proxy
```
Rust stable (see `rust-version` in `Cargo.toml`), a C compiler and Python 3.10+ are required. The build embeds the panel, the documentation and Swagger UI, so the binary has no runtime web dependencies.
Optional memory allocator: `cargo build --release --features mimalloc` handles heavy HOT cache turnover faster but uses about 25% more memory; see [Performance](docs/PERFORMANCE.md#memory-allocator).
## License
Copyright © 2026 Mateusz Gruszczyński.
RustProxy is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It is distributed in the hope that it will be useful, but **without any warranty**. If you run a modified version as a network service, you must offer its users the corresponding source code. See [LICENSE](LICENSE) for the full text.
-11
View File
@@ -1,11 +0,0 @@
# RustProxy roadmap
## Current release validation
- Keep the 0.35.0 source tree reproducible and keep documentation, OpenAPI and tests in sync with runtime behavior.
- Complete the Rust build, test and Clippy gates with the supported toolchain before production release.
- Validate the 0.35.0 OIDC flow against real supported identity providers before production deployment.
## Future work
No additional feature work is committed in this source candidate. Add future items here only when their scope and verification requirements are defined.
+579 -551
View File
File diff suppressed because it is too large. Load diff
Regular → Executable
View File
File mode changed.
+10 -2
View File
@@ -70,9 +70,17 @@ fn swagger_assets(
_ => vec!["python3".into(), "python".into()], _ => vec!["python3".into(), "python".into()],
}; };
for interpreter in interpreters { for interpreter in interpreters {
match Command::new(&interpreter).arg(&helper).arg("--out-dir").arg(out).status() { match Command::new(&interpreter)
.arg(&helper)
.arg("--out-dir")
.arg(out)
.status()
{
Ok(status) if status.success() => return Ok(()), Ok(status) if status.success() => return Ok(()),
Ok(_) => return Err("Swagger UI asset preparation failed; see the diagnostic above and docs/API_ACCESS.md".into()), Ok(_) => return Err(
"Swagger UI asset preparation failed; see the diagnostic above and docs/INSTALL.md"
.into(),
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
} }
+1 -1
View File
@@ -1,7 +1,7 @@
# Example Linux service # Example Linux service
`rustproxy.service` is an operator-reviewed deployment example, not an installed or `rustproxy.service` is an operator-reviewed deployment example, not an installed or
tested service. With RustProxy 0.34.2, the example includes `ExecReload` so `systemctl reload rustproxy` uses the authenticated CLI soft reload. If using `RUST_PROXY_CONFIG`, configure it in the service EnvironmentFile so both `ExecStart` and `ExecReload` share the same file source. It runs the compiled executable as a dedicated `rustproxy` account, tested service. The example includes `ExecReload` so `systemctl reload rustproxy` uses the authenticated CLI soft reload. If using `RUST_PROXY_CONFIG`, configure it in the service EnvironmentFile so both `ExecStart` and `ExecReload` share the same file source. It runs the compiled executable as a dedicated `rustproxy` account,
uses the public administration profile shown below and grants only the capability needed uses the public administration profile shown below and grants only the capability needed
for low-port binding. It does not run the proxy as root or grant Docker socket access. for low-port binding. It does not run the proxy as root or grant Docker socket access.
+1 -2
View File
@@ -28,8 +28,7 @@ install -d -o rustproxy-docker-agent -g docker -m 0750 /var/lib/rustproxy-docker
if [[ ! -f /etc/rustproxy/docker-agent.env ]]; then if [[ ! -f /etc/rustproxy/docker-agent.env ]]; then
install -m 0600 docker-agent.env.example /etc/rustproxy/docker-agent.env install -m 0600 docker-agent.env.example /etc/rustproxy/docker-agent.env
fi fi
# The Go CLI is the single implementation for both manual and packaged # Provisioning is delegated to the Go CLI, which keeps existing credentials.
# provisioning; it preserves existing credentials, certificate and key.
/usr/local/bin/rustproxy-docker-agent init /usr/local/bin/rustproxy-docker-agent init
install -m 0644 rustproxy-docker-agent.service /etc/systemd/system/rustproxy-docker-agent.service install -m 0644 rustproxy-docker-agent.service /etc/systemd/system/rustproxy-docker-agent.service
systemctl daemon-reload systemctl daemon-reload
+5 -6
View File
@@ -2,8 +2,7 @@
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. 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.
## Global block / allow policy (0.34.13) ## Global block / allow policy
**Settings → Rules & headers → Global block / allow** applies to public HTTP traffic across all vhosts, redirects and not-found hosts before upstream access. In addition to the existing User-Agent deny patterns, use the global country block/allow list (ISO two-letter codes), client IP/CIDR and allow-only DNS ACL, HTTP method policy, request Content-Type denylist and shared per-IP rate limit. All are disabled by default except previously configured User-Agent blocking. An explicit IP allow does **not** bypass the country policy: both must permit a request. Missing GeoIP country is allowed for country block mode and denied for country allow mode. **Settings → Rules & headers → Global block / allow** applies to public HTTP traffic across all vhosts, redirects and not-found hosts before upstream access. In addition to the existing User-Agent deny patterns, use the global country block/allow list (ISO two-letter codes), client IP/CIDR and allow-only DNS ACL, HTTP method policy, request Content-Type denylist and shared per-IP rate limit. All are disabled by default except previously configured User-Agent blocking. An explicit IP allow does **not** bypass the country policy: both must permit a request. Missing GeoIP country is allowed for country block mode and denied for country allow mode.
Global restrictions add to per-vhost and path rules. Rate-limit requests are counted for each client IP **across** vhosts. Prefer a conservative shared ceiling to avoid blocking shared NAT clients. A global method denial returns 405, the content-type/ACL/UA policy returns 403, and the shared rate limiter returns 429 with `Retry-After`. Admin and TCP/UDP stream listeners are outside this HTTP scope. For global response headers, use the adjacent **Security headers** subtab; see [Security](SECURITY.md). Global restrictions add to per-vhost and path rules. Rate-limit requests are counted for each client IP **across** vhosts. Prefer a conservative shared ceiling to avoid blocking shared NAT clients. A global method denial returns 405, the content-type/ACL/UA policy returns 403, and the shared rate limiter returns 429 with `Retry-After`. Admin and TCP/UDP stream listeners are outside this HTTP scope. For global response headers, use the adjacent **Security headers** subtab; see [Security](SECURITY.md).
@@ -39,11 +38,11 @@ New Vhosts store an explicit `authentication` choice; old Vhosts with only `tiny
- **Basic Auth:** protect selected Vhosts with configured credentials. Use HTTPS so credentials are not sent over plaintext. - **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 automatically uses `/api/auth/nginx` (or set that path explicitly). This Nginx auth-request integration sends `X-Original-URL` and `X-Original-Method`, with a validated `X-Forwarded-For` and `X-Real-IP` for Tinyauth IP ACLs. It does **not** send `X-Forwarded-Host` or `X-Forwarded-URI` to the verifier, avoiding the `conflicting auth module headers` rejection introduced in Tinyauth 5.2.0 while keeping compatibility with earlier versions that implement the Nginx endpoint. Tinyauth has its own timeout and private-backend policy. - **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 automatically uses `/api/auth/nginx` (or set that path explicitly). This Nginx auth-request integration sends `X-Original-URL` and `X-Original-Method`, with a validated `X-Forwarded-For` and `X-Real-IP` for Tinyauth IP ACLs. It does **not** send `X-Forwarded-Host` or `X-Forwarded-URI` to the verifier, avoiding the `conflicting auth module headers` rejection introduced in Tinyauth 5.2.0 while keeping compatibility with earlier versions that implement the Nginx endpoint. Tinyauth has its own timeout and private-backend policy.
- **Provider-neutral gateway error pages (0.34.15):** One embedded `src/admin/web/error_auth_provider.html` template renders a self-contained HTML page for **Tinyauth, Authelia, authentik, and future providers**, using only the safe provider display name and the failure category. It replaces the three vendor-specific `error_tinyauth_*.html` source files while preserving the familiar Tinyauth response titles and statuses. For example: `502 Tinyauth connection error` if its HTTP client cannot reach Tinyauth (DNS/TCP/TLS/transport), `504 Tinyauth timeout` if the auth deadline expires, or `502 Tinyauth verification error` for unexpected verifier status or invalid/oversized data. The page always retains the HTTP error status, `Content-Type: text/html; charset=utf-8`, `Cache-Control: private, no-store` and `X-Content-Type-Options: nosniff`. Backend URLs, protocol errors and identity data are not shown to visitors. All of these cases **deny access** to the protected origin. HTTP `401`/`403` and configured login redirects remain unchanged. These auth-specific pages take precedence over custom Vhost/general HTTP error pages so the failure remains distinguishable. The generic failure marker also protects Basic Auth denials and provider login redirects from being replaced by Vhost pages. HTTPS-verifier transport errors are classified as connection failures, truncated/oversized/invalid verifier responses as verification failures, and verification deadlines as timeouts. - **Provider-neutral gateway error pages:** One embedded `src/admin/web/error_auth_provider.html` template renders a self-contained HTML page for **Tinyauth, Authelia, authentik, and future providers**, using only the safe provider display name and the failure category. It replaces the three vendor-specific `error_tinyauth_*.html` source files while preserving the familiar Tinyauth response titles and statuses. For example: `502 Tinyauth connection error` if its HTTP client cannot reach Tinyauth (DNS/TCP/TLS/transport), `504 Tinyauth timeout` if the auth deadline expires, or `502 Tinyauth verification error` for unexpected verifier status or invalid/oversized data. The page always retains the HTTP error status, `Content-Type: text/html; charset=utf-8`, `Cache-Control: private, no-store` and `X-Content-Type-Options: nosniff`. Backend URLs, protocol errors and identity data are not shown to visitors. All of these cases **deny access** to the protected origin. HTTP `401`/`403` and configured login redirects remain unchanged. These auth-specific pages take precedence over custom Vhost/general HTTP error pages so the failure remains distinguishable. The generic failure marker also protects Basic Auth denials and provider login redirects from being replaced by Vhost pages. HTTPS-verifier transport errors are classified as connection failures, truncated/oversized/invalid verifier responses as verification failures, and verification deadlines as timeouts.
- Point the verifier URL **directly** at the private Tinyauth service (for example `http://tinyauth:3000/api/auth/nginx`), rather than routing it through a second public proxy that might inject conflicting headers. Do not disable Tinyauth's header-spoofing protections. Confirm that Tinyauth trusts only the actual RustProxy address/network when enabling Tinyauth IP-based access controls (`TINYAUTH_AUTH_TRUSTEDPROXIES`). - Point the verifier URL **directly** at the private Tinyauth service (for example `http://tinyauth:3000/api/auth/nginx`), rather than routing it through a second public proxy that might inject conflicting headers. Do not disable Tinyauth's header-spoofing protections. Confirm that Tinyauth trusts only the actual RustProxy address/network when enabling Tinyauth IP-based access controls (`TINYAUTH_AUTH_TRUSTEDPROXIES`).
- **Authelia (0.34.15):** select **Authelia → Forward Auth** under **Vhost → Authentication**. Enter the internal service origin (for example `http://authelia:9091`) and the public HTTPS login portal (for example `https://auth.example.com`); RustProxy calls `/api/authz/forward-auth`. It forwards trusted `X-Forwarded-*`, `X-Original-URL` and `X-Original-Method` metadata, verifies the provider result, returns provider-approved `302/303` login redirects only to the configured portal origin, and forwards configured `Remote-*` identity headers after stripping client-forged values. A `200` verifier response alone grants access. `401`/`403` deny; transport failures return 502 and timeouts 504. The Authelia `session.cookies` domains and `authelia_url` are defined in Authelia itself: protecting **unrelated registered domains** generally requires a login URL/cookie configuration within each domain, not one cookie from a single unrelated site. - **Authelia:** select **Authelia → Forward Auth** under **Vhost → Authentication**. Enter the internal service origin (for example `http://authelia:9091`) and the public HTTPS login portal (for example `https://auth.example.com`); RustProxy calls `/api/authz/forward-auth`. It forwards trusted `X-Forwarded-*`, `X-Original-URL` and `X-Original-Method` metadata, verifies the provider result, returns provider-approved `302/303` login redirects only to the configured portal origin, and forwards configured `Remote-*` identity headers after stripping client-forged values. A `200` verifier response alone grants access. `401`/`403` deny; transport failures return 502 and timeouts 504. The Authelia `session.cookies` domains and `authelia_url` are defined in Authelia itself: protecting **unrelated registered domains** generally requires a login URL/cookie configuration within each domain, not one cookie from a single unrelated site.
- **authentik (0.34.15):** select **authentik → Forward Auth** and enter the private outpost origin (for example `http://authentik:9000`), not an `/if/flow` login URL. RustProxy calls `/outpost.goauthentik.io/auth/nginx`, forwards verified `X-Authentik-*` identity headers, and responds to a `401` with a same-origin login start at `/outpost.goauthentik.io/start?rd=...`. RustProxy reserves `/outpost.goauthentik.io` (and descendants) on the protected application hostname and forwards them to the configured outpost **without checking authentication again**, after ordinary global/Vhost IP/country/rate rules. No manual Custom Location is needed for that path. In authentik create a separate **Proxy Provider → Forward auth (single application)** for each protected external application URL, assign it to the serving outpost, then enable that Vhost's integration. Multiple unrelated domains can share the same authentik installation; they do **not** share a browser cookie across domains. - **authentik:** select **authentik → Forward Auth** and enter the private outpost origin (for example `http://authentik:9000`), not an `/if/flow` login URL. RustProxy calls `/outpost.goauthentik.io/auth/nginx`, forwards verified `X-Authentik-*` identity headers, and responds to a `401` with a same-origin login start at `/outpost.goauthentik.io/start?rd=...`. RustProxy reserves `/outpost.goauthentik.io` (and descendants) on the protected application hostname and forwards them to the configured outpost **without checking authentication again**, after ordinary global/Vhost IP/country/rate rules. No manual Custom Location is needed for that path. In authentik create a separate **Proxy Provider → Forward auth (single application)** for each protected external application URL, assign it to the serving outpost, then enable that Vhost's integration. Multiple unrelated domains can share the same authentik installation; they do **not** share a browser cookie across domains.
- **OIDC (0.34.16, opt-in):** choose **Authelia → OIDC**, **authentik → OIDC**, **Pocket ID → OIDC** or **Other OIDC provider → OIDC** for the protected Vhost. This switches to the shared OIDC implementation instead of the vendor's Forward Auth adapter. Provide the exact HTTPS **Issuer**, Client ID and confidential Client Secret, a strong unique Session Secret (`openssl rand -hex 32`), and `https://YOUR-EXACT-APP-HOST/_rustproxy/oidc/callback` as the **Redirect URL**. Register that exact callback as an allowed redirect URI in the identity provider. The issuer can be `https://auth.example.com.cc` while the protected hostname is `app.linuxiarz.pl`; browser cookies do not cross between them. The issuer's discovery `/.well-known/openid-configuration`, authorization, token and JWKS endpoints must resolve from RustProxy and use the issuer's HTTPS origin with trusted TLS. Use split DNS or the private-address opt-in if the issuer resolves to internal addresses; do not disable HTTPS certificate validation. OIDC currently requires a confidential client using `client_secret_basic`, authorization-code flow, and an RS256 or ES256 signing key. Public PKCE-only clients are not supported. - **OIDC (opt-in):** choose **Authelia → OIDC**, **authentik → OIDC**, **Pocket ID → OIDC** or **Other OIDC provider → OIDC** for the protected Vhost. This switches to the shared OIDC implementation instead of the vendor's Forward Auth adapter. Provide the exact HTTPS **Issuer**, Client ID and confidential Client Secret, a strong unique Session Secret (`openssl rand -hex 32`), and `https://YOUR-EXACT-APP-HOST/_rustproxy/oidc/callback` as the **Redirect URL**. Register that exact callback as an allowed redirect URI in the identity provider. The issuer can be `https://auth.example.com.cc` while the protected hostname is `app.linuxiarz.pl`; browser cookies do not cross between them. The issuer's discovery `/.well-known/openid-configuration`, authorization, token and JWKS endpoints must resolve from RustProxy and use the issuer's HTTPS origin with trusted TLS. Use split DNS or the private-address opt-in if the issuer resolves to internal addresses; do not disable HTTPS certificate validation. OIDC currently requires a confidential client using `client_secret_basic`, authorization-code flow, and an RS256 or ES256 signing key. Public PKCE-only clients are not supported.
- **OIDC session and identity:** RustProxy generates short-lived transaction state with S256 PKCE and nonce; requires a signed ID token with exact issuer, audience, validity and nonce; and encrypts a separate `__Host-` session cookie for the application host (default 3600 seconds, max 86400). The session is **not continuously revalidated with the issuer**; removing a user at the issuer does not invalidate an already-issued RustProxy session before its expiry or local logout (`/_rustproxy/oidc/logout`). Successful login injects `Remote-Sub`, `Remote-Issuer`, `Remote-User` and optional `Remote-Email`, `Remote-Name`, `Remote-Groups`; use `(Remote-Issuer, Remote-Sub)` for stable account identity and do not auto-link accounts solely by email or username. The backend must not be directly accessible from untrusted clients. Non-GET/HEAD requests without a valid session receive 401, and authentication endpoint failures return 502/504. The provider URL must not itself be protected by this Vhost's OIDC integration. For Authelia, enabling its Forward Auth portal alone is insufficient: also configure Authelia as an OIDC issuer under `identity_providers.oidc`, with at least one signing key, and register a confidential client for every callback URL (or separate clients per application). OIDC does not require any special support from the application being proxied, only from the remote identity provider. - **OIDC session and identity:** RustProxy generates short-lived transaction state with S256 PKCE and nonce; requires a signed ID token with exact issuer, audience, validity and nonce; and encrypts a separate `__Host-` session cookie for the application host (default 3600 seconds, max 86400). The session is **not continuously revalidated with the issuer**; removing a user at the issuer does not invalidate an already-issued RustProxy session before its expiry or local logout (`/_rustproxy/oidc/logout`). Successful login injects `Remote-Sub`, `Remote-Issuer`, `Remote-User` and optional `Remote-Email`, `Remote-Name`, `Remote-Groups`; use `(Remote-Issuer, Remote-Sub)` for stable account identity and do not auto-link accounts solely by email or username. The backend must not be directly accessible from untrusted clients. Non-GET/HEAD requests without a valid session receive 401, and authentication endpoint failures return 502/504. The provider URL must not itself be protected by this Vhost's OIDC integration. For Authelia, enabling its Forward Auth portal alone is insufficient: also configure Authelia as an OIDC issuer under `identity_providers.oidc`, with at least one signing key, and register a confidential client for every callback URL (or separate clients per application). OIDC does not require any special support from the application being proxied, only from the remote identity provider.
- **Adding another provider:** Register the vendor and supported methods in `auth_provider::choice::IdentityProvider` and in the Vhost editor. OIDC-compatible vendors reuse the shared `oidc` integration and may need no new Rust transport adapter. New Forward Auth adapters belong in `src/<provider>/mod.rs` with tests and may reuse `forward_auth::Adapter`; their compiled implementation implements the object-safe `auth_provider::Provider` trait. Runtime request handling continues through one `CompiledFrontend.auth_provider` slot and provider-neutral `error_auth_provider.html`. Preserve legacy Vhost configuration keys; add new providers without copying OIDC protocol code or HTML error templates. - **Adding another provider:** Register the vendor and supported methods in `auth_provider::choice::IdentityProvider` and in the Vhost editor. OIDC-compatible vendors reuse the shared `oidc` integration and may need no new Rust transport adapter. New Forward Auth adapters belong in `src/<provider>/mod.rs` with tests and may reuse `forward_auth::Adapter`; their compiled implementation implements the object-safe `auth_provider::Provider` trait. Runtime request handling continues through one `CompiledFrontend.auth_provider` slot and provider-neutral `error_auth_provider.html`. Preserve legacy Vhost configuration keys; add new providers without copying OIDC protocol code or HTML error templates.
+29 -5
View File
@@ -20,23 +20,47 @@ The current bundled `src/admin/openapi.json` declares these operations (among ot
| `POST /api/config/geoip/update` | Run a GeoIP download/check/reload immediately using already-saved GeoIP settings; does not change revision. | | `POST /api/config/geoip/update` | Run a GeoIP download/check/reload immediately using already-saved GeoIP settings; does not change revision. |
| `GET /api/config/history` · `POST /api/config/history/restore` | Review/restore retained configuration snapshots. | | `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/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/cache` · `POST /api/cache/purge` · `POST /api/cache/invalidate` | Cache observations and authenticated invalidation. `GET` supports the lightweight `view=live` polling view. |
| `GET /api/transform` | Transform observations and resource usage. Supports the lightweight `view=live` polling view. |
| `GET /api/logs` · `GET /api/live-logs` | Recent and streaming request logs. | | `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/file-logs` · `GET /api/file-logs/query` | Stored-log exploration. |
| `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. | | `GET /api/traffic` · `GET /api/system-info` | Traffic and system observations. Traffic supports the lightweight `view=live` polling view. |
| `GET /api/metrics/json` · `GET /api/metrics/summary` | Full JSON snapshot (includes all Prometheus samples and `summary`) or compact computed `summary` only, both pull-only. [Monitoring](MONITORING.md). | | `GET /api/metrics/json` · `GET /api/metrics/summary` | Full JSON snapshot (includes all Prometheus samples and `summary`) or compact computed `summary` only, both pull-only. [Monitoring](MONITORING.md). |
| `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. | | `GET /api/docker` · `POST /api/docker/refresh` | Docker-agent observation and refresh. |
| `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. | | `GET /api/certificates` · `POST /api/certificates/upload` | Certificate inventory/import. |
| `POST /api/certificates/inspect` | Read DNS SAN coverage from a managed/external public PEM path for the editor; admin browser session only. | | `POST /api/certificates/inspect` | Read DNS SAN coverage from a managed/external public PEM path for the editor; admin browser session only. |
| `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. | | `GET /api/acme` · `POST /api/acme/issue` · `POST /api/acme/renew` | Managed certificate jobs. |
| `GET/PUT /api/preferences` | Personal UI settings. | | `GET/PUT /api/preferences` | Personal UI settings, including theme/layout and optional `interface_icons` and `logo_animation` (both default `false`). |
| `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. | | `GET /api/dashboard-presets` and named preset operations | Saved Overview presets. |
| `GET /docs/openapi.json` | Protected machine-readable API specification. | | `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. 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.
For browser polling, `GET /api/cache?view=live`, `GET /api/transform?view=live` and `GET /api/traffic?view=live` omit static response members that do not need to be retransmitted on every interval. Their `revision` identifies the active configuration; when it changes, clients should fetch the corresponding endpoint without `view=live` and refresh their cached static fields. Requests without `view=live` remain the complete compatibility form. Cache live responses contain metrics/dashboard/bans, Transform live responses contain resources/metrics, and Traffic live responses retain current settings/GeoIP/analytics while omitting the static Vhost list. Unknown `view` values are rejected.
Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed file or an absolute external path and returns `{ "cert_file": "...", "covered_hosts": ["example.com"] }`. It applies the existing certificate-file trust and size checks and reads no private key. It makes no configuration or permission changes. This POST requires the signed-in administrator's normal Origin, request-marker and CSRF checks; general RO/RW API tokens and certificate-only credentials cannot call it. Certificate inspection accepts `{ "cert_file": "filename.pem" }` for a managed file or an absolute external path and returns `{ "cert_file": "...", "covered_hosts": ["example.com"] }`. It applies the existing certificate-file trust and size checks and reads no private key. It makes no configuration or permission changes. This POST requires the signed-in administrator's normal Origin, request-marker and CSRF checks; general RO/RW API tokens and certificate-only credentials cannot call it.
## Access and authentication
The administration listener is HTTP on loopback (`127.0.0.1:9090`) by default and is separate from the public Vhost listeners. No administrator password is bundled: complete first-time setup with the local CLI (`admin setup-token` or `admin init`). For remote access prefer an SSH tunnel; for direct access enable administration HTTPS with a trusted certificate and restrict the network before binding outside loopback.
| Credential | Scope |
|---|---|
| Browser session | Full administration. HttpOnly, SameSite=Strict cookie (Secure on HTTPS), absolute lifetime 7–30 days. Writes require matching Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1`. |
| General API token (`ro` / `rw`) | Created in **Settings → Security → API tokens**. Displayed once, stored as a hash, bound to a mandatory IP/CIDR allowlist. Sent as `Authorization: Bearer <token>`. `ro` reads resources and sections; `rw` also writes configuration sections and selected operations. Cannot manage authentication, backups, tokens or certificate credentials. |
| Certificate API key | Key ID + Bearer token limited to specific certificate IDs and actions (reload by default, deploy opt-in). |
| Prometheus token | Scrape-only credential for the separate exporter; see [Monitoring](MONITORING.md). |
IP checks use the transport peer, never forwarded headers. Outside loopback, Bearer credentials require administration HTTPS.
### `/docs` Reader and Swagger
The authenticated `/docs` page has two tabs: **Reader** with the guides embedded at build time, and **API** with the bundled OpenAPI schema rendered by Swagger UI. All assets are served locally; no CDN is used. Swagger uses the current session, executes read calls directly and asks for confirmation before any write.
### Publishing the panel behind a reverse proxy
This is a special-risk topology. Configure a fixed HTTPS public origin, an allowed hostname and a trusted forwarding boundary. Ordinary public Vhosts never forward administration cookies to an upstream. Re-test CSRF/Origin validation and cookie security after any topology change.
## Authentication and safe writes ## Authentication and safe writes
- Ordinary API calls require an administrator browser/session cookie on the admin listener, or a scoped general API Bearer token for explicitly allowlisted API methods/paths. Browser-session writes require the matching Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1`, in addition to Host validation. Scoped Bearer-token requests are exempt from browser CSRF requirements; the server enforces the token permission, source IP allowlist and HTTPS for non-loopback access instead. - Ordinary API calls require an administrator browser/session cookie on the admin listener, or a scoped general API Bearer token for explicitly allowlisted API methods/paths. Browser-session writes require the matching Origin, `X-CSRF-Token` and `X-RustProxy-Request: 1`, in addition to Host validation. Scoped Bearer-token requests are exempt from browser CSRF requirements; the server enforces the token permission, source IP allowlist and HTTPS for non-loopback access instead.
@@ -79,7 +103,7 @@ The section name in the URL and its corresponding JSON member in a full configur
| `logging` | `logging`, `error_log` | | `logging` | `logging`, `error_log` |
| `global-security` | `global_security`, including `user_agent_blocking` | | `global-security` | `global_security`, including `user_agent_blocking` |
Every GET returns a `value` object or array whose shape can be sent unchanged to the matching PUT. For the three grouped sections the `value` contains exactly the named members; for the other sections it is the serialized value of the matching configuration member. Saving does not alter unrelated sections. In 0.34.16 an ordinary Vhost may configure mutually exclusive `oidc`, `authelia` and `authentik` objects alongside existing `tinyauth`/`basic_auth` settings; every optional provider defaults to disabled. OIDC uses `/_rustproxy/oidc/callback` on an exact Vhost HTTPS hostname and requires issuer/client/session secrets. See [Access](ACCESS.md). Every GET returns a `value` object or array whose shape can be sent unchanged to the matching PUT. For the three grouped sections the `value` contains exactly the named members; for the other sections it is the serialized value of the matching configuration member. Saving does not alter unrelated sections. An ordinary Vhost may configure mutually exclusive `oidc`, `authelia` and `authentik` objects alongside existing `tinyauth`/`basic_auth` settings; every optional provider defaults to disabled. OIDC uses `/_rustproxy/oidc/callback` on an exact Vhost HTTPS hostname and requires issuer/client/session secrets. See [Access](ACCESS.md).
### Complete section-update example ### Complete section-update example
@@ -130,4 +154,4 @@ Authorization: Bearer <secret>
X-RustProxy-Certificate-Key: <key-id> X-RustProxy-Certificate-Key: <key-id>
``` ```
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). 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 [Certificates](CERTIFICATES.md).
-35
View File
@@ -1,35 +0,0 @@
# Administration authentication and API access
## Default exposure and setup
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.
## Session security
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.
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.
## `/docs` Reader and Swagger
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.
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.
## Publishing the panel behind a reverse proxy
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.
## General administration API tokens (0.34.1 DEV)
Settings → **Security → API tokens (RO/RW)** generates a one-time-displayed opaque Bearer credential, with `ro` (read) or `rw` (read/write) permission and a **mandatory allowlist of client IPs or CIDRs**. The token is stored only as a hash; save it securely at creation or generate a replacement. Deleting a token revokes it immediately. The existing browser session workflow is unchanged; this is not a browser login/password substitute.
Send `Authorization: Bearer <token>` to the administration listener over HTTPS outside loopback. IP checks use the transport peer, not spoofable forwarded headers. Only the narrowly allowlisted `/api/*` resource routes are usable: RO is read-only, RW adds selected configuration and operational operations. Tokens cannot access authentication, backups, documentation, other token creation/revocation, certificate API management, or unrestricted administrator endpoints. The separate certificate-only API credentials work independently. The **Settings → Administration** page includes a direct link to token management.
## Certificate-only credentials
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.
## Offline build assets
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.
+4 -7
View File
@@ -2,8 +2,7 @@
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. 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.
### Cache controls in Settings (0.34.13) ### Cache controls in Settings
The static-extension cookie-bypass warning sits with its extension list, separate from **Cache response headers** options. Only enable this optimization for files whose bytes do not depend on the user's session. `Cookie` can then be omitted from the shared cache key for the configured extensions; responses with `Set-Cookie`, `private`, `no-store` or `Vary: Cookie` remain uncacheable. The static-extension cookie-bypass warning sits with its extension list, separate from **Cache response headers** options. Only enable this optimization for files whose bytes do not depend on the user's session. `Cookie` can then be omitted from the shared cache key for the configured extensions; responses with `Set-Cookie`, `private`, `no-store` or `Vary: Cookie` remain uncacheable.
## Enable caching safely ## Enable caching safely
@@ -14,8 +13,7 @@ The static-extension cookie-bypass warning sits with its extension list, separat
4. Optionally enable HIT-only diagnostics. Send repeated identical requests using `curl`, then inspect **Cache → Domains / Stored objects** for `MISS → HIT`. 4. Optionally enable HIT-only diagnostics. Send repeated identical requests using `curl`, then inspect **Cache → Domains / Stored objects** for `MISS → HIT`.
5. Review **Varnish-compatible cache policy**, enabled by default, then check TTL, revalidation and invalidation after updating the origin's content or configuration. 5. Review **Varnish-compatible cache policy**, enabled by default, then check TTL, revalidation and invalidation after updating the origin's content or configuration.
## Varnish-compatible cache policy (0.34.7) ## Varnish-compatible cache policy
**Settings → Cache → Varnish-compatible cache policy** (`cache.varnish_compatible`, **enabled by default**) avoids needless cache replacement on browser hard reloads. On a fresh object it ignores only the client's `Cache-Control: no-cache`, `max-age=0` and `Pragma: no-cache` for cache **lookup/freshness**. It does not remove or modify these request headers when fetching from the origin, and it does not alter cache identity or request conditionals. **Settings → Cache → Varnish-compatible cache policy** (`cache.varnish_compatible`, **enabled by default**) avoids needless cache replacement on browser hard reloads. On a fresh object it ignores only the client's `Cache-Control: no-cache`, `max-age=0` and `Pragma: no-cache` for cache **lookup/freshness**. It does not remove or modify these request headers when fetching from the origin, and it does not alter cache identity or request conditionals.
Set **Default grace (seconds)** in Settings → Cache (default **60**, range 0..604800). This affects only eligible objects in compatibility mode and never overwrites explicit origin `stale-while-revalidate`, including `=0`. Use the adjacent **i** information icon for the short operator explanation (PL/EN). Disabling the option restores the original strict client reload semantics. Changing modes invalidates currently stored representations across HOT/COLD so they are refilled under one consistent policy; no process restart is needed. Existing JSON configurations with no field inherit `true`. The setting also applies to eligible resources of the embedded Web Server. Set **Default grace (seconds)** in Settings → Cache (default **60**, range 0..604800). This affects only eligible objects in compatibility mode and never overwrites explicit origin `stale-while-revalidate`, including `=0`. Use the adjacent **i** information icon for the short operator explanation (PL/EN). Disabling the option restores the original strict client reload semantics. Changing modes invalidates currently stored representations across HOT/COLD so they are refilled under one consistent policy; no process restart is needed. Existing JSON configurations with no field inherit `true`. The setting also applies to eligible resources of the embedded Web Server.
@@ -84,8 +82,7 @@ done
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. 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.
## Cache response headers and upstream diagnostics (0.34.12) ## Cache response headers and upstream diagnostics
**RFC 9211:** `Cache-Status` is a structured list of cache hops, ordered from the origin-side cache toward the client. RustProxy **does not replace or suppress** an upstream `Cache-Status`. When **Settings → Cache → Expose Cache-Status** is enabled and RustProxy makes a cache decision, it appends its own `RustProxy` member to the existing list; multiple HTTP header fields are an equivalent wire representation of the same list. There is no `backend=hit/miss` parameter: the upstream's own members already describe its cache decisions. **RFC 9211:** `Cache-Status` is a structured list of cache hops, ordered from the origin-side cache toward the client. RustProxy **does not replace or suppress** an upstream `Cache-Status`. When **Settings → Cache → Expose Cache-Status** is enabled and RustProxy makes a cache decision, it appends its own `RustProxy` member to the existing list; multiple HTTP header fields are an equivalent wire representation of the same list. There is no `backend=hit/miss` parameter: the upstream's own members already describe its cache decisions.
For example, with an upstream HIT and a RustProxy HIT: For example, with an upstream HIT and a RustProxy HIT:
@@ -134,7 +131,7 @@ The initial request can show `RustProxy; fwd=miss`; an eligible repeat should sh
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. 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.
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. 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.
## Troubleshooting ## Troubleshooting
@@ -11,8 +11,7 @@ ancestor even though it can host a Web Server document root. See
[Installation: external paths](INSTALL.md#linux-debrpm-read-only-access-to-external-paths) [Installation: external paths](INSTALL.md#linux-debrpm-read-only-access-to-external-paths)
for scoped ACLs and Certbot renewal handling. for scoped ACLs and Certbot renewal handling.
### Certificate file permission command preview (0.34.13) ### Certificate file permission command preview
The permissions explanation now appears next to generated read/traversal ACL commands, rather than alongside the path inputs. The **Copy commands** action is offered only after a valid path selection produces a command preview. These commands grant read access to the certificate/private key and traversal of parent directories; they do not grant write permission. The permissions explanation now appears next to generated read/traversal ACL commands, rather than alongside the path inputs. The **Copy commands** action is offered only after a valid path selection produces a command preview. These commands grant read access to the certificate/private key and traversal of parent directories; they do not grant write permission.
## Supported certificate methods ## Supported certificate methods
@@ -37,7 +36,7 @@ HTTP-01 cannot create wildcard certificates; use DNS-01. A staging certificate i
5. Wait for the job result; inspect errors under Certificates and the service logs. 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. 6. Assign the issued/imported certificate to the intended Vhost/listener and verify a real TLS handshake with the public hostname.
In 0.34.1 DEV, expand a certificate in the Certificates inventory and use **Assign Vhosts** to check/uncheck multiple Vhosts before applying one revisioned Vhost-section save. This updates Vhost certificate mappings, not the PEM files themselves. Verify frontend listener and certificate hostname coverage separately. Expand a certificate in the Certificates inventory and use **Assign Vhosts** to check/uncheck multiple Vhosts before applying one revisioned Vhost-section save. This updates Vhost certificate mappings, not the PEM files themselves. Verify frontend listener and certificate hostname coverage separately.
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. 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.
@@ -62,6 +61,17 @@ When another service owns the certificate, add a manual certificate with the act
/etc/letsencrypt/live/example.com/privkey.pem /etc/letsencrypt/live/example.com/privkey.pem
``` ```
### One vhost, several certificates (SNI selection)
A vhost normally uses one certificate for all of its names. When a single vhost serves domains that have **different** certificates (for example several `autodiscover.<domain>` names, each with its own certificate), leave the vhost certificate on **Global SNI / default certificate**. RustProxy then picks the certificate for every TLS connection by the requested server name (SNI) from the stored certificates whose **SNI names** include it. Wildcard names match one label (`*.example.com` matches `a.example.com`, not `a.b.example.com`).
1. Open **Certificates**, edit the certificate and open **Existing files / advanced**.
2. Check **SNI names**: it is filled automatically from the certificate's SAN entries and can be edited.
3. Repeat for each certificate and keep the vhost on **Global SNI / default certificate**.
While you add host names to a new vhost, the editor selects a stored certificate automatically when one certificate covers every name. When no single certificate does but each name is covered by some stored certificate, it tells you to keep Global SNI. Selecting a certificate explicitly in the vhost overrides the SNI names for that vhost's hosts; if it does not cover them, saving asks whether to force it.
### Permission commands and automatic SNI names ### Permission commands and automatic SNI names
Select **External files (absolute paths)** and enter both paths. A permission-command block is generated **below the certificate and private-key fields** and changes as the paths change. It uses the supplied paths, resolves symlink targets on the server when run, removes group/other write permission on the resolved PEM files and other-user read permission on the private key, then grants traversal of the relevant parent directories and read access to the files for `rustproxy`. Install the Linux `acl` tools if needed and review/run the commands as an administrator on that server. The panel generates commands; it does not execute them or change filesystem ownership. Select **External files (absolute paths)** and enter both paths. A permission-command block is generated **below the certificate and private-key fields** and changes as the paths change. It uses the supplied paths, resolves symlink targets on the server when run, removes group/other write permission on the resolved PEM files and other-user read permission on the private key, then grants traversal of the relevant parent directories and read access to the files for `rustproxy`. Install the Linux `acl` tools if needed and review/run the commands as an administrator on that server. The panel generates commands; it does not execute them or change filesystem ownership.
@@ -70,7 +80,7 @@ The paths must be absolute, without `.`/`..` components or control characters, a
The existing trusted-path rules still apply: real PEM files and ancestors must be owned by root or the RustProxy service user, ancestors must not be group/world-writable, and a private key must not be world-readable or group/world-writable. ACLs alone do not make an untrusted path acceptable. Container mounts and mandatory access-control rules need matching deployment settings; see [Installation](INSTALL.md#linux-debrpm-read-only-access-to-external-paths). The existing trusted-path rules still apply: real PEM files and ancestors must be owned by root or the RustProxy service user, ancestors must not be group/world-writable, and a private key must not be world-readable or group/world-writable. ACLs alone do not make an untrusted path acceptable. Container mounts and mandatory access-control rules need matching deployment settings; see [Installation](INSTALL.md#linux-debrpm-read-only-access-to-external-paths).
After a public certificate becomes readable, the editor automatically fills **Legacy SNI names** from its DNS Subject Alternative Name (SAN) entries. Wildcard SANs are retained; filenames and a certificate's Common Name are not used to invent DNS names. Inspection applies to managed certificate filenames and external paths, reads only the public PEM, and does not activate or save the certificate. The certificate/key pair is still validated when applied. After a public certificate becomes readable, the editor automatically fills **SNI names** from its DNS Subject Alternative Name (SAN) entries. Wildcard SANs are retained; filenames and a certificate's Common Name are not used to invent DNS names. Inspection applies to managed certificate filenames and external paths, reads only the public PEM, and does not activate or save the certificate. The certificate/key pair is still validated when applied.
Automatic reads preserve manually edited names, intentional clearing and existing saved mappings. Results from an earlier path or form are ignored. Use **Read names from certificate** to retry after fixing filesystem permissions or to deliberately replace the current field with the certificate's names. Prefer explicit certificate assignment in the intended Vhost; auto-filled legacy SNI names do not create Vhosts or replace their assignments. Automatic reads preserve manually edited names, intentional clearing and existing saved mappings. Results from an earlier path or form are ignored. Use **Read names from certificate** to retry after fixing filesystem permissions or to deliberately replace the current field with the certificate's names. Prefer explicit certificate assignment in the intended Vhost; auto-filled legacy SNI names do not create Vhosts or replace their assignments.
+4 -5
View File
@@ -2,7 +2,7 @@
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. 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.
> Cache compatibility: **Settings → Cache → Varnish-compatible cache policy** (`cache.varnish_compatible`) defaults to `true` in 0.34.7, also for configs without the field. Turning it off restores legacy client freshness handling. Implicit grace defaults to **60 seconds** (`cache.varnish_grace_seconds`, editable in Settings → Cache). See [HTTP cache](CACHE.md#varnish-compatible-cache-policy-0347) for semantics and safety exceptions. > Cache compatibility: **Settings → Cache → Varnish-compatible cache policy** (`cache.varnish_compatible`) defaults to `true`, also for configurations without the field. Turning it off restores legacy client freshness handling. Implicit grace defaults to **60 seconds** (`cache.varnish_grace_seconds`, editable in Settings → Cache). See [HTTP cache](CACHE.md#varnish-compatible-cache-policy) for semantics and safety exceptions.
## Configuration storage: SQLite or JSON file ## Configuration storage: SQLite or JSON file
@@ -64,13 +64,12 @@ ACLs support IP/CIDR and **allow-only** DNS/DDNS hostname rules, plus reusable p
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. 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.
### Global HTTP block / allow and security headers (0.34.13) ### Global HTTP block / allow and security headers
Under **Settings → Rules & headers**, choose **Global block / allow** to configure rules applying to every public HTTP vhost, redirect and 404 host. Independent controls cover **country block/allow**, global **User-Agent deny patterns**, **IP/CIDR and allow-only DNS ACL**, **HTTP method allow/block**, **request Content-Type denylist** and a **shared per-client-IP rate limit**. Disabled policies are neutral. If country allow mode is selected, an unknown GeoIP country is denied; block mode permits unknown countries. Global IP and country checks are cumulative, as are global rules and more restrictive per-vhost policies. Verify the trusted client-IP and GeoIP configuration before enabling country or rate controls. The global rate limit uses the same bucket for a given client across all public vhosts. It does not affect TCP/UDP streams or the separate administration listener. Under **Settings → Rules & headers**, choose **Global block / allow** to configure rules applying to every public HTTP vhost, redirect and 404 host. Independent controls cover **country block/allow**, global **User-Agent deny patterns**, **IP/CIDR and allow-only DNS ACL**, **HTTP method allow/block**, **request Content-Type denylist** and a **shared per-client-IP rate limit**. Disabled policies are neutral. If country allow mode is selected, an unknown GeoIP country is denied; block mode permits unknown countries. Global IP and country checks are cumulative, as are global rules and more restrictive per-vhost policies. Verify the trusted client-IP and GeoIP configuration before enabling country or rate controls. The global rate limit uses the same bucket for a given client across all public vhosts. It does not affect TCP/UDP streams or the separate administration listener.
The adjacent **Security headers** subtab applies a global response baseline: **HSTS** (HTTPS only), **nosniff**, **X-Frame-Options**, **Referrer-Policy**, **Content-Security-Policy** and **Permissions-Policy**. Explicit per-vhost security-header values take precedence; absence of a vhost value does not cancel a global header. Test HSTS and CSP against all sites before enabling. **Global custom headers** remains a distinct subtab for arbitrary request/response header rules. The adjacent **Security headers** subtab applies a global response baseline: **HSTS** (HTTPS only), **nosniff**, **X-Frame-Options**, **Referrer-Policy**, **Content-Security-Policy** and **Permissions-Policy**. Explicit per-vhost security-header values take precedence; absence of a vhost value does not cancel a global header. Test HSTS and CSP against all sites before enabling. **Global custom headers** remains a distinct subtab for arbitrary request/response header rules.
In the updated **0.34.13** API, global User-Agent blocking is nested at `config.global_security.user_agent_blocking`; it is no longer a separate root-level setting. Example (only the relevant fragment): Global User-Agent blocking is nested at `config.global_security.user_agent_blocking`; it is no longer a separate root-level setting. Example (only the relevant fragment):
```json ```json
{ {
@@ -87,7 +86,7 @@ On upgrade, older JSON, database rows, backups and revision history containing a
## Cache and transforms ## 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). 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 selectively invalidate only the affected cached responses. 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. 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.
-38
View File
@@ -1,38 +0,0 @@
# Docker / OCI deployment
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 and start
Run from the repository root:
```bash
docker build -t rustproxy:0.34.2 .
docker volume create rustproxy-data
docker run -d --name rustproxy \
-p 8080:8080 \
-v rustproxy-data:/var/lib/rustproxy \
rustproxy:0.34.2
```
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
```
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.
## Persistent data and ports
- 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.2 development source candidate is not itself a production-tested OCI image.
+36 -1
View File
@@ -14,10 +14,45 @@ page has its own stylesheet.
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. 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.
In 0.34.1, the unlocked Overview layout exposes a single **Save and lock** action (plus Cancel), removing the redundant lock-and-save button. Optional quick-health tiles (since 0.35.2, hidden by default; enable them in the layout editor or **Settings → Appearance → Dashboard layout**):
| Tile | Shows |
|---|---|
| **Healthy backends** | Monitored backend members passing health checks as `up / total`, with the names of failing members. |
| **Error share** | Percentage of HTTP requests that ended with an error during the last sampling interval. |
| **Rejected by limits / s** | Requests and connections refused by `--max-connections` / `--max-inflight`; sustained values mean the limits are too low for the load. |
| **Nearest certificate expiry** | Days until the earliest installed certificate expires; the bar is full at 90 days or more. |
Enabled tiles are added at the end of the Overview, so the standard block layout does not change.
The Overview history sample count is saved in the browser immediately when the selection changes; no separate confirmation button is required.
The sidebar application-status indicator runs a lightweight self-check from the live status already used by the dashboard. Green means healthy, amber means attention is needed, and red means a problem was detected; click it to see API, listener, backend and logging details.
The unlocked Overview layout exposes a single **Save and lock** action (plus Cancel), removing the redundant lock-and-save button.
### Arranging blocks
After **Unlock layout**, every block shows a compact toolbar: the **⠿** handle, width **− / +**, and **×** to hide it (wider blocks also show **← / →**).
- **Drag:** grab the ⠿ handle (mouse, pen or touch). The block lifts and follows the pointer, and a dashed frame of the same width shows exactly where it will land; neighbouring blocks glide aside instead of jumping. Release to drop, or press **Esc** to return the block to its previous place. Dragging near the top or bottom edge of the window scrolls the page.
- **Keyboard:** focus the ⠿ handle and use the arrow keys to move the block one position.
- **Fill gaps:** a saved custom layout keeps blocks in exactly the order you arranged them, so a wide block that does not fit at the end of a row leaves a visible gap instead of other blocks silently moving into it. Use **Fill gaps** to pack blocks into empty space once; nothing is stored until **Save and lock**.
**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. **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.
### SVG interface icons
Under **Settings → Appearance → Interface**, enable **Show SVG interface icons** to add semantic line icons to the main navigation, action buttons, tabs and dialogs. The preference is stored per administrator as `interface_icons: true|false` in `/api/preferences` and is **off by default** for both new and upgraded accounts.
The icons are inline SVGs bundled with RustProxy; no web font, CDN or external icon library is loaded. They use `currentColor`, so the active theme, danger/primary states and disabled-state colors remain authoritative. Text labels and existing ARIA labels remain the source of accessible meaning; the SVGs are `aria-hidden`. Dynamic controls are reclassified when their state changes, so actions such as sort direction, save/loading, show/hide and refresh keep a matching icon. Turning the option off removes the injected icons and restores the original text-only presentation without restarting the panel.
Controls whose label is already a single symbol (`×`, `−`, `+`, `←`, `→`, `↑`, `↓`) keep that glyph and do not receive a second SVG. Verbs take precedence over the surrounding feature: a **Close** button in the health or history dialog shows a close icon, **Previous/Next** use chevrons, and **Unlock layout** uses an open padlock.
### Logo animation
Under **Settings → Appearance → Interface**, enable **Animate logo** to let the sidebar `r/p` logo play a short, subtle effect (rotating slash, letter flip, wave, glow, sheen, glitch or wobble) at random intervals of roughly 10–25 seconds and when the pointer moves over it. The preference is stored per administrator as `logo_animation: true|false` in `/api/preferences` and is **off by default**. Animations pause while the browser tab is hidden and are suppressed entirely when the operating system or browser requests reduced motion.
## Editing configuration safely ## Editing configuration safely
1. Open the relevant workspace and inspect existing state before editing. 1. Open the relevant workspace and inspect existing state before editing.
+108
View File
@@ -0,0 +1,108 @@
# Feature list
RustProxy is a single service that combines a reverse proxy, a static/PHP web server, certificate management, an HTTP cache and traffic monitoring, all managed from a browser panel or an API. Optional features are disabled by default; enable only what you need.
![Overview dashboard](images/overview.png)
## Reverse proxy
| Feature | Details |
|---|---|
| Vhosts | Exact and wildcard hostnames, aliases, several listeners per Vhost, per-Vhost HTTP/2 and HTTP/3 switches |
| Backends | HTTP and HTTPS origins, HTTP/1.1, automatic ALPN (H2/H1), strict HTTP/2 and H2C, optional upstream certificate verification with custom CA and server name |
| Load balancing | Least connections (default), round robin, least traffic, adaptive and IP hash; member weights, backup and draining members, sticky cookie or IP affinity |
| Health checks | Active TCP or HTTP probes with thresholds, optional separate probe port, expected response text and live health status per member |
| Connection reuse | Bounded keep-alive pools with idle and age limits, pool recycling and protocol telemetry |
| Custom locations | Prefix, exact and regex paths, path rewriting, per-location backends, Web Services, local responses, redirects and internal error fallbacks |
| WebSockets | HTTP/1.1 Upgrade, RFC 8441 (HTTP/2) and RFC 9220 (HTTP/3) Extended CONNECT |
| Forwarding headers | `X-Forwarded-*`, `Forwarded`, `X-Real-IP`, trusted proxy ranges, Cloudflare client IP and country |
| Redirects and pages | Redirect hosts, canonical host and HTTPS redirects, 404 hosts, custom error pages and direct-IP pages |
| TCP/UDP Streams | Layer-4 forwarding with backend pools, health checks, session limits and idle timeouts |
## Frontends and TLS
- HTTP/1.1, HTTPS with HTTP/2, optional HTTP/3 (QUIC) with `Alt-Svc` management and configurable UDP buffers.
- Named listeners with per-listener protocols; Vhosts bind to listener IDs.
- TLS 1.2/1.3 policy with cipher-suite allowlist and per-Vhost overrides.
- Optional hybrid post-quantum key exchange (X25519MLKEM768).
- SNI certificate selection, hot certificate reload without restart.
## Web Server
- Static sites and downloads served from reusable **Web Services** shared by several Vhosts.
- Index files, optional directory listing, MIME mapping and attachment extensions.
- `try_files`, regex rewrites and a safe subset of Apache `.htaccess` rules.
- Conditional requests (`ETag`, `Last-Modified`), byte ranges, precompressed `.br`/`.gz` sidecars and optional Linux `sendfile`.
- Symlink policy, attack-probe blocking and header hardening presets.
- PHP through external PHP-FPM with shared profiles, per-application pools and resource limits.
- Presets for single-page apps, WordPress and Roundcube.
![Web Server editor](images/web-server.png)
## Certificates
- Self-signed certificates, PEM import and externally managed certificate paths (for example Certbot).
- Managed ACME with HTTP-01 and DNS-01 (Cloudflare, OVH, RFC 2136), automatic renewal and job history.
- Certificate inventory with expiry, coverage check and bulk assignment to Vhosts.
- Restricted certificate API keys for deploy/reload from external systems such as OPNsense.
- Prometheus metrics for the expiry date and remaining days of every installed certificate, ready for alerting.
![Certificates](images/certificates.png)
## Access and security
- IP/CIDR and DNS/DDNS allow/deny rules, reusable ACL profiles (up to eight per Vhost).
- Country allow/deny lists with automatic GeoIP database updates.
- Request rate limiting per Vhost and globally, with burst and `Retry-After`.
- HTTP method policy, request Content-Type denylist and User-Agent blocking.
- Security headers: HSTS, CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy, `nosniff`.
- HTTP Basic authentication with Argon2 hashes and brute-force limits.
- Forward Auth with Tinyauth, Authelia and authentik; OIDC (Authorization Code + PKCE) with Authelia, authentik, Pocket ID and compatible providers.
- Fail-closed authentication errors, identity header stripping and private/no-store responses for authenticated content.
- Protection against private/loopback/control-plane upstream destinations unless explicitly allowed.
## Cache and delivery
- Shared HTTP cache with a memory tier (HOT) and an optional disk tier (COLD).
- RFC 9111 freshness, validators and `Vary`; stale-while-revalidate, stale-if-error and request coalescing.
- Varnish-compatible policy option, forced caching rules and per-location cache switches.
- RFC 9211 `Cache-Status` and optional `X-Cache` diagnostics.
- Purge and ban from the panel, the API or HTTP PURGE/BAN from allowlisted addresses.
- Live compression with gzip, Brotli, Zstandard and deflate.
- Optional minification of JavaScript, CSS, HTML, SVG and JSON with memory budgets.
![Cache](images/cache.png)
## Monitoring and logs
- Overview dashboard with requests per second, bandwidth, status codes, error share, cache hit ratio, protocol mix, system load, backend health, requests rejected by limits and nearest certificate expiry.
- Customizable dashboard layouts (drag-and-drop with a live drop placeholder, keyboard moves, per-block width, explicit gap filling) and saved presets.
- Per-Vhost, per-backend, per-Web-Service and per-Stream statistics.
- Live request log in the browser and in the terminal (`--livelogs`).
- Stored JSON access logs with search syntax, per-Vhost log files and an error log.
- Prometheus exporter with token/CIDR protection, JSON metrics and a compact summary endpoint.
- Lightweight revision-aware polling views for Cache, Traffic and Transform keep the administration panel responsive without repeatedly transferring static configuration.
- Grafana dashboard and Nagios checks included.
![Live logs](images/logs.png)
## Operations
- Configuration stored in SQLite or in a JSON file, with validation before activation and hot reload.
- Revision history with diff and one-click restore.
- Encrypted full backups and validated offline restore; encrypted Secret Store for credentials.
- Docker discovery through a separate agent: publish containers as Vhosts with one switch.
- Nginx Proxy Manager import with a dry-run plan.
- Managed install/update scripts with rollback, DEB/RPM packages, Docker image and an OPNsense plugin.
- Panel in English and Polish, light and dark themes, adjustable density and corner style.
- Optional account-scoped semantic SVG icons for navigation, actions, tabs and dialogs; off by default.
## Administration and API
- Panel served on a separate, loopback-only listener by default; optional HTTPS for remote administration.
- Single administrator with Argon2 password, session revocation and CSRF/Origin protection.
- Complete REST API described by a bundled OpenAPI schema with Swagger UI at `/docs`.
- Scoped `ro`/`rw` API tokens bound to IP allowlists.
- Command-line tools for setup, configuration, certificates, backups and live logs.
See the [screenshots](SCREENSHOTS.md) for a visual tour and the [documentation index](README.md) for detailed guides.
+30 -11
View File
@@ -11,7 +11,7 @@ Use maintained packages whenever possible. For custom installations, use the scr
| OPNsense plugin | https://rustproxy-opn.linuxiarz.pl/ | | OPNsense plugin | https://rustproxy-opn.linuxiarz.pl/ |
| Debian 13+ source bootstrap | `install-debian13.sh` from the project repository | | Debian 13+ source bootstrap | `install-debian13.sh` from the project repository |
| Manual Linux/systemd install | Compile the application; use `install.sh` and `update.sh` | | Manual Linux/systemd install | Compile the application; use `install.sh` and `update.sh` |
| OCI container | Included `Dockerfile`; see [Container](CONTAINER.md) | | OCI container | Included `Dockerfile`; see [Docker / OCI container](#docker--oci-container) |
Package/plugin instructions and maintenance policies are maintained separately from the source archive. The optional Docker agent is packaged independently of RustProxy's main service for **Linux amd64/x86_64 only**, not FreeBSD pkg. On a Docker host, the agent can also be installed manually from `docker-agent/install.sh`; the installer and packages preserve existing tokens and TLS keys. See [Docker discovery](DOCKER_DISCOVERY.md) and [agent operations](../docker-agent/README.md). Package/plugin instructions and maintenance policies are maintained separately from the source archive. The optional Docker agent is packaged independently of RustProxy's main service for **Linux amd64/x86_64 only**, not FreeBSD pkg. On a Docker host, the agent can also be installed manually from `docker-agent/install.sh`; the installer and packages preserve existing tokens and TLS keys. See [Docker discovery](DOCKER_DISCOVERY.md) and [agent operations](../docker-agent/README.md).
@@ -31,7 +31,7 @@ The script installs dependencies, builds with `cargo build --release --locked` a
## Build from the source ## 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: The minimum Rust version is declared in `Cargo.toml` (`rust-version`); `rust-toolchain.toml` selects the `stable` channel. 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 ```bash
bash dev.sh doctor bash dev.sh doctor
@@ -40,7 +40,7 @@ bash dev.sh test
bash dev.sh release bash dev.sh release
``` ```
`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/`. `dev.sh release` builds the application; it is not equivalent to a successful production rollout. Cargo dependencies must be available to the build environment; Swagger UI 5.33.1 is vendored in `third_party/swagger-ui-5.33.1/` and needs no download (set `SWAGGER_UI_DOWNLOAD_URL` or `SWAGGER_UI_ARCHIVE_SHA256` to build from an explicit archive instead). An example systemd unit is in `deploy/`.
For a first Linux/systemd installation: For a first Linux/systemd installation:
@@ -89,8 +89,7 @@ Then visit `http://127.0.0.1:9090` on your workstation. For a directly reachable
## Linux DEB/RPM: read-only access to external paths ## Linux DEB/RPM: read-only access to external paths
New Linux packages (package-builder filesystem-access policy, revision 5) and Linux packages and the maintained manual service template use `ProtectHome=read-only` rather than
the maintained manual service template use `ProtectHome=read-only` rather than
`ProtectHome=true`. This makes document roots under `/home`, `/var/www` and `ProtectHome=true`. This makes document roots under `/home`, `/var/www` and
`/srv` readable if the service account has **ordinary Unix group or ACL `/srv` readable if the service account has **ordinary Unix group or ACL
permissions**. `ProtectSystem=strict` remains enabled: the service cannot permissions**. `ProtectSystem=strict` remains enabled: the service cannot
@@ -98,7 +97,7 @@ modify these files merely because a group or ACL permits it. No systemd
drop-in or extra `ReadOnlyPaths=` entry is needed to **read** site content or drop-in or extra `ReadOnlyPaths=` entry is needed to **read** site content or
an external certificate. This also applies when paths are stored in the panel. an external certificate. This also applies when paths are stored in the panel.
In **0.34.10**, the panel generates Linux permission commands from the actual The panel generates Linux permission commands from the actual
paths you enter: below the two external certificate/key fields, and directly paths you enter: below the two external certificate/key fields, and directly
below a Web Server's document root. The commands target the standard below a Web Server's document root. The commands target the standard
`rustproxy` account and resolve symlink targets when run. Review them and run `rustproxy` account and resolve symlink targets when run. Review them and run
@@ -160,7 +159,7 @@ After granting certificate read access, return to the form and use
**Read names from certificate**. The editor reads the public certificate's **Read names from certificate**. The editor reads the public certificate's
DNS SAN entries into the legacy SNI field; this does not read the private key DNS SAN entries into the legacy SNI field; this does not read the private key
or save the certificate. Manual name edits are preserved by background reads. or save the certificate. Manual name edits are preserved by background reads.
See [Certificates](ACME_PROVIDERS.md#permission-commands-and-automatic-sni-names). See [Certificates](CERTIFICATES.md#permission-commands-and-automatic-sni-names).
To diagnose unresolved errors, run `systemctl cat rustproxy` and To diagnose unresolved errors, run `systemctl cat rustproxy` and
`systemctl show rustproxy -p ProtectHome -p ProtectSystem -p User -p Group`, `systemctl show rustproxy -p ProtectHome -p ProtectSystem -p User -p Group`,
@@ -170,7 +169,7 @@ check parent permissions with `namei -l /path/to/file`, and examine
unit and is **not** automatically removed by an upgrade. On SELinux/AppArmor unit and is **not** automatically removed by an upgrade. On SELinux/AppArmor
hosts, additional mandatory access-control policy may be required; POSIX ACLs hosts, additional mandatory access-control policy may be required; POSIX ACLs
cannot bypass it. See [Web Server](WEB_SERVER.md) and cannot bypass it. See [Web Server](WEB_SERVER.md) and
[Certificates](ACME_PROVIDERS.md). [Certificates](CERTIFICATES.md).
**Write access is intentionally different.** An app that needs to upload or **Write access is intentionally different.** An app that needs to upload or
modify files under an arbitrary document root needs a separately planned modify files under an arbitrary document root needs a separately planned
@@ -233,6 +232,28 @@ This is **optional**. Without `--config`, RustProxy keeps its active configurati
For the alternate environment-variable method, set `RUST_PROXY_CONFIG=/etc/rustproxy/config.json` in the service's protected EnvironmentFile. Do not set **both** unrelated config paths. See [Configuration](CONFIGURATION.md) and the [deployment example](../deploy/README.md). For the alternate environment-variable method, set `RUST_PROXY_CONFIG=/etc/rustproxy/config.json` in the service's protected EnvironmentFile. Do not set **both** unrelated config paths. See [Configuration](CONFIGURATION.md) and the [deployment example](../deploy/README.md).
## Docker / OCI container
The included `Dockerfile` compiles RustProxy in a Rust build image and runs it on Debian as the unprivileged `rustproxy` account (UID 10001). The image contains Python, Certbot and DNS plugins for certificate automation. The main container does **not** need the Docker socket.
```bash
docker build -t rustproxy:latest .
docker volume create rustproxy-data
docker run -d --name rustproxy \
-p 8080:8080 \
-v rustproxy-data:/var/lib/rustproxy \
rustproxy:latest
docker exec rustproxy rust-proxy admin setup-token
```
Inside the image the HTTP frontend binds `0.0.0.0:8080` and administration binds `127.0.0.1:9090`, so publishing port 9090 alone does not expose the panel. For remote administration, enable administration HTTPS with a certificate and an explicit non-loopback listener first, and restrict access with firewall rules.
- Mount `/var/lib/rustproxy` persistently: it holds SQLite, private keys, certificate profiles and the optional COLD cache.
- Publish the HTTP/HTTPS TCP ports you use, and the matching **UDP** port when HTTP/3 is enabled.
- The image runs without root; map low host ports with port forwarding or deliberate capabilities.
- To discover containers on other hosts, install the separate [Docker agent](DOCKER_DISCOVERY.md).
- Upgrade by building a new image, keeping the volume and taking an encrypted backup first.
## Managed update ## Managed update
Test the new build first. Then execute: Test the new build first. Then execute:
@@ -246,10 +267,8 @@ Managed updates preserve the saved configuration, create a private backup, switc
## ACME prerequisites ## ACME prerequisites
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 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](CERTIFICATES.md).
## After installation ## After installation
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). 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).
**Release verification:** RustProxy 0.34.2 is a source package; complete a Rust 1.99+ build and target-platform HTTP/systemd/security acceptance tests before Internet-facing production use.
-24
View File
@@ -1,24 +0,0 @@
# 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 | Existing Nginx auth-request support remains unchanged, with fail-closed 502/504 errors. [Access](ACCESS.md). |
| **Authelia** | Forward auth for selected Vhosts | Select it in Vhost Authentication, set internal service origin and trusted public HTTPS login URL, and configure the portal's per-domain cookie rules. [Access](ACCESS.md). |
| **OIDC integration method (Authelia, Pocket ID, authentik and compatible issuers)** | Central OAuth/OIDC login for protected apps across different parent domains | Select the identity provider, then OIDC. Set HTTPS issuer discovery, Client ID/Secret, explicit app callback and encrypted local session. Requires an OIDC client on the chosen provider. [Access](ACCESS.md). |
| **authentik** | Forward auth for selected Vhosts, including unrelated application domains | Configure one authentik single-application proxy provider per public application and its serving outpost; enter the outpost's internal origin. RustProxy handles the reserved outpost path automatically. [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.
+2
View File
@@ -4,6 +4,8 @@ RustProxy has several independent logging paths. Choose the one matching the que
## Live Logs ## Live Logs
The administration panel does not fetch `/api/logs` while Live Logs is closed. Opening or docking the Live Logs window starts the bounded live snapshot refresh; closing it stops that polling. Pausing or expanding the history buffer also suspends periodic `/api/logs` reads until live viewing resumes. The main `/api/events` status stream is kept independent by using `logs=false` in the panel.
Use **Logs → Live Logs** in the authenticated panel, or locally: Use **Logs → Live Logs** in the authenticated panel, or locally:
```bash ```bash
+3 -3
View File
@@ -1,6 +1,6 @@
# Upgrades and migration # Upgrades and migration
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). This guide covers upgrades that need operator action and the included Nginx Proxy Manager importer. For ordinary package upgrades, first follow [Installation and updates](INSTALL.md).
## Before changing versions ## Before changing versions
@@ -16,8 +16,8 @@ A managed install uses `update.sh`; an unmanaged source-tree install must follow
| Change area | What to review | | 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. | | **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. | | **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.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. | | **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. | | **Certificate API grants** | Older broad/reload-only credentials can exist after additive migration; replace them with explicit certificate-ID allowlists and least privilege. |
+9 -1
View File
@@ -24,7 +24,7 @@ Overview allows account-specific card layout and history controls; see [Dashboar
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: 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:
In 0.34.1, use the separate **Prometheus token** controls in Settings to generate, rotate or revoke a 32+ character Bearer credential. Its clear value appears only once; copy it directly into your monitoring secrets store. The database keeps only its hash, and rotation/revocation takes effect without reprinting the previous secret. The older environment-variable credential remains supported as a fallback *only until* a generated credential or explicit revocation entry exists. Setting a new token does not reconfigure the exporter listener or its enable switch. Use the separate **Prometheus token** controls in Settings to generate, rotate or revoke a 32+ character Bearer credential. Its clear value appears only once; copy it directly into your monitoring secrets store. The database keeps only its hash, and rotation/revocation takes effect without reprinting the previous secret. The older environment-variable credential remains supported as a fallback *only until* a generated credential or explicit revocation entry exists. Setting a new token does not reconfigure the exporter listener or its enable switch.
- `listen`, `path`, `allowed_cidrs` and an optional environment-variable **name** `bearer_token_env` (the secret value is supplied separately to the service). - `listen`, `path`, `allowed_cidrs` and an optional environment-variable **name** `bearer_token_env` (the secret value is supplied separately to the service).
@@ -44,6 +44,14 @@ scrape_configs:
- targets: ['127.0.0.1:9191'] - targets: ['127.0.0.1:9191']
``` ```
Certificate expiry is exported for every installed certificate, including manually uploaded ones: `rust_proxy_certificate_not_after_timestamp_seconds{certificate="…"}` and `rust_proxy_certificate_days_remaining{certificate="…"}` (negative after expiry). Values are read from the certificate files and refreshed at most every five minutes. Example alert:
```yaml
- alert: RustProxyCertificateExpiresSoon
expr: rust_proxy_certificate_days_remaining < 14
for: 1h
```
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. 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.
## JSON metrics API for external dashboards ## JSON metrics API for external dashboards
+109
View File
@@ -0,0 +1,109 @@
# Performance
RustProxy runs on Tokio and Hyper with an asynchronous, streaming request path. This guide describes how performance is measured, where RustProxy stands compared with a similarly configured Nginx, and which settings matter in production.
## What is measured
Results are only comparable when both servers do the same work. A stock Nginx installation does very little per request, while RustProxy always records request statistics, traffic analytics and access logs and adds forwarding headers. The comparison therefore uses an Nginx configuration with a similar feature set:
| Feature | Nginx | RustProxy |
|---|---|---|
| Access log | `log_format` with timing fields, 64 KiB buffer | Built-in access log and live statistics (default) |
| Forwarding headers | `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Real-IP` | `Forwarded`, `X-Forwarded-*`, `X-Real-IP`, `X-Request-Id` (default) |
| Response headers | `X-Request-ID`, `X-Content-Type-Options` | Request ID and default security headers |
| Upstream keep-alive | `keepalive 64`, HTTP/1.1 | Connection pool (default) |
| Cache | `proxy_cache` with `proxy_cache_lock` | HOT memory cache with request collapsing |
| Compression | `gzip on` | gzip/Brotli/Zstandard negotiation (default) |
| Static files | `root`, `etag on` | Web Service with validators (default) |
## Method
- Machine: 2 vCPU virtual machine, Linux 6.18, loopback networking.
- Each server is pinned to one CPU core (`worker_processes 1` for Nginx, `taskset` for RustProxy). The load generator and the origin share the second core.
- Load: `wrk -t1 -c50 -d8s`, HTTP/1.1 keep-alive, two or three runs per scenario, average reported.
- Origin: Nginx serving static files with `Cache-Control: public, max-age=600`.
- Nginx 1.24 and RustProxy release builds (`lto = "thin"`, `codegen-units = 1`).
## Results
Requests per second on one CPU core (higher is better):
| Scenario | Nginx | RustProxy 0.35.0 | RustProxy 0.35.2 | 0.35.2 vs 0.35.0 |
|---|---:|---:|---:|---:|
| Reverse proxy, 1 KiB response | 61,400 | 14,700 | 15,100 | +3% |
| Reverse proxy, 100 KiB response | 10,600 | 14,500 | 15,500 | +7% |
| Cache hit, 1 KiB response | 53,300 | 22,500 | 25,000 | +11% |
| Web Server, 1 KiB file | 85,000 | 15,200 | 26,000 | +71% |
| Web Server, 100 KiB file | 25,900 | 9,200 | 16,900 | +84% |
Reading the results:
- **Large proxied responses:** RustProxy streams the body directly between connections and is faster than Nginx in its default buffering mode, which spools larger upstream responses to temporary files.
- **Small requests:** Nginx remains ahead, because its fixed per-request cost is lower. In RustProxy this cost is dominated by per-request accounting (live statistics, traffic analytics, request IDs and logs) and by the asynchronous runtime. For most deployments it is far below the capacity of the backends behind the proxy.
- **Static files:** since 0.35.1 RustProxy reads files directly on the request task instead of a background thread pool, which nearly doubles static throughput.
- **Why small requests and cache hits trail Nginx:** each proxied request crosses the loopback network twice (client and backend), and on a VM about half of the CPU time goes to the kernel for those writes and the wake-ups of the other side. Most of the remaining time is RustProxy's per-request work that Nginx does not do in the same way: request IDs, live per-Vhost statistics, traffic analytics and structured access-log entries, plus the asynchronous runtime. A cache hit saves the backend hop but still pays for this accounting, so its gap is similar. No single function dominates the profile; closing the gap further would require architectural changes rather than tuning.
- **CPU per request:** at the 1 KiB proxy scenario RustProxy uses about 70 µs of CPU time per request on the test machine, Nginx about 17 µs. A single core therefore sustains well over 10,000 proxied requests per second.
Throughput scales with additional CPU cores; RustProxy uses all available cores automatically.
## Memory allocator
Release builds use the operating system's allocator (glibc on most Linux distributions). An alternative allocator, [mimalloc](https://github.com/microsoft/mimalloc), can be compiled in as an optional feature:
```bash
cargo build --release --features mimalloc
```
It is off by default because of how it behaves with a large HOT cache. Measured on the test machine with a 300 MiB HOT cache under constant turnover (random objects of 1–260 KiB, continuous evictions):
| Build | Process memory (RSS) | Throughput under cache turnover |
|---|---:|---:|
| System allocator (default) | 614 MiB | 5,700 req/s |
| mimalloc | 760 MiB (+24%) | 20,300 req/s (3.5×) |
| mimalloc with `MIMALLOC_PURGE_DELAY=0` | 720 MiB (+17%) | 18,600 req/s |
In the standard scenarios from the results table (proxy, cache hits, static files) the two allocators performed within measurement noise. On a production server with a 600 MiB HOT cache the same difference showed as about 800 MiB versus 1.1–1.2 GiB of process memory.
Choose mimalloc only when all of these apply:
- the HOT cache holds many objects that are replaced constantly (high eviction rate on the **Cache** page);
- throughput under that turnover matters more than memory;
- the server has at least 30% RAM headroom above the configured HOT cache size.
Otherwise keep the default. The system allocator returns memory from evicted cache entries to the operating system shortly after eviction, so process memory stays close to the configured cache size.
**Since 0.35.4** the default build returns freed memory to the operating system at most four times per second (plus the regular cache maintenance pass) instead of after every eviction. Most of the system allocator's slowdown under turnover came from these repeated trims, so the gap to mimalloc is now much smaller. Measured on the same 2 vCPU machine, one core, 256 MiB HOT cache filled with 100 KiB objects and every request a new URL:
| Build | RSS after load | Throughput under cache turnover |
|---|---:|---:|
| 0.35.3, system allocator | 595 MiB | 2,900 req/s |
| 0.35.4, system allocator | 639 MiB (+7%) | 7,000 req/s (2.4×) |
The mimalloc rows above were measured with 0.35.2 and a different object mix; re-measure on your own workload before choosing the optional build.
## Production tuning
| Setting | Recommendation |
|---|---|
| Cache | Enable the HOT cache for cacheable content; hits avoid the backend completely. |
| Compression | Keep it enabled for text content. Use precompressed `.br`/`.gz` files for static sites. |
| Upstream protocol | Prefer HTTP/1.1 keep-alive or HTTP/2 to backends; avoid backends that close every connection. |
| Health checks | Use intervals of several seconds; frequent probes consume capacity on large installations. |
| Logging | Keep stored log files on fast local storage and use retention limits on busy servers. |
| Limits | `--max-connections` (default 512) and `--max-inflight` (default 256) cap concurrency. Raise them on busy servers with enough memory. |
| `sendfile` | Optional for large cleartext static files on Linux. On the test machine it gave no measurable gain, so keep `off` unless your own measurements show otherwise. |
| Memory allocator | Keep the default system allocator. See [Memory allocator](#memory-allocator) for when the optional `mimalloc` build is worth its extra memory. |
| TLS | TLS 1.3 with session resumption keeps handshake cost low; HTTP/2 or HTTP/3 reduce connection setup for browsers. |
## Reproducing the test
1. Start an origin (any static HTTP server) with a 1 KiB and a 100 KiB file.
2. Configure RustProxy with one Vhost pointing to the origin and one Web Service serving the same directory.
3. Configure Nginx with the equivalent features from the table above.
4. Pin each server to the same number of cores and run `wrk` from a different core:
```bash
taskset -c 1 wrk -t1 -c50 -d8s -H "Host: proxy.example" http://127.0.0.1:8080/1k.txt
```
Always compare matched configurations on the same machine, and repeat each run. Results on physical hardware and real networks differ from loopback measurements.
+52 -29
View File
@@ -1,44 +1,67 @@
# RustProxy documentation # RustProxy documentation
Practical guides for **RustProxy 0.35.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. Guides for operating RustProxy: installation, publishing services, security, certificates, monitoring and automation. The same pages are available in the administration panel under **`/docs → Reader`**, next to the interactive **API** reference.
- Project website and packages: [rustproxy.linuxiarz.pl](https://rustproxy.linuxiarz.pl/)
- Source code: [git.linuxiarz.pl/gru/RustProxy](https://git.linuxiarz.pl/gru/RustProxy)
- License: [GNU AGPL v3.0 or later](../LICENSE)
## Overview
- [Feature list](FEATURES.md) — everything RustProxy can do, grouped by area.
- [Screenshots](SCREENSHOTS.md) — a tour of the administration panel.
- [Performance](PERFORMANCE.md) — benchmark method, comparison with Nginx and tuning.
## Getting started ## Getting started
- [Installation and updates](INSTALL.md) — packages, build, first administrator, optional JSON/systemd permissions and updates. - [Installation and updates](INSTALL.md) — packages, source build, Docker image, first administrator, systemd and updates.
- [Container deployment](CONTAINER.md) — image, persistent state, ports and isolation. - [Configuration](CONFIGURATION.md) — SQLite or JSON configuration source, configuration map and changes that need a restart.
- [Settings and configuration](CONFIGURATION.md) — active SQLite/file storage source and which changes need restart. - [Troubleshooting](TROUBLESHOOTING.md) — symptom-based checks and a rollout checklist.
- [Troubleshooting](TROUBLESHOOTING.md) — symptom-based checks and release validation.
## Publishing services ## Publishing services
- [Vhosts and backends](VHOSTS.md) — domains, listener bindings, request Content-Type filtering, balancing, health and redirects. - [Vhosts and backends](VHOSTS.md) — domains, listeners, backends, balancing, health checks and redirects.
- [Custom locations](CUSTOM_LOCATIONS.md) — path routing, rewrites, local responses and error fallbacks. - [Custom locations](CUSTOM_LOCATIONS.md) — path routing, rewrites, local responses and error fallbacks.
- [Access and authentication](ACCESS.md) — ACL, country/DDNS, Basic Auth, Tinyauth, Authelia, authentik and rate limits. - [Web Server](WEB_SERVER.md) — static sites, downloads, `try_files`, rewrites, `.htaccess` subset and error pages.
- [Static Web Server](WEB_SERVER.md) — document roots, generated filesystem permission commands, downloads, rules, built-in error pages and file delivery. - [PHP-FPM](PHP_FPM.md) — PHP profiles, pool isolation and application examples.
- [PHP-FPM](PHP_FPM.md) — PHP service profiles, security and application examples. - [HTTP transport and TLS](TRANSPORT.md) — HTTPS, HTTP/2, HTTP/3, WebSockets, backend protocols and post-quantum key exchange.
- [Cache](CACHE.md) — HOT/COLD storage, privacy, TTL, RFC 9211 `Cache-Status`, origin diagnostic headers and invalidation. - [TCP/UDP Streams](STREAMS.md) — non-HTTP forwarding.
- [Response delivery](RESPONSE_DELIVERY.md) — compression, static transformations and response headers.
- [HTTP transport](TRANSPORT.md) — HTTPS/H2/H3, WebSockets, origin protocols and timeouts.
- [Experimental TLS post-quantum key exchange](TLS_POST_QUANTUM.md) — opt-in X25519MLKEM768, compatibility, rollback and validation.
- [TCP/UDP Streams](STREAMS.md) — non-HTTP forwarding and its security boundaries.
The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11. Version 0.34.12 preserves that adaptive range and adds a disabled-by-default X/Y scale display option under **Settings → Appearance → Charts**. Release 0.34.13 adds global HTTP security policies and UI layout improvements, groups the global User-Agent policy under `global_security`, provides consistent section-based GET/PUT APIs, and moves manual GeoIP refresh to `POST /api/config/geoip/update`. See [Dashboard](DASHBOARD.md). Release 0.34.14 keeps the Tinyauth Nginx-header compatibility patch and dedicated fail-closed 502/504 pages. Release 0.34.15 adds optional Authelia and authentik forward-auth integrations, a shared provider runtime and a single provider-neutral HTML error template. Release 0.34.16 adds optional OIDC central login across unrelated domains. Release 0.35.0 accepts encoded `/` (`%2F` / `%2f`) in proxied paths while keeping normalized routing/security decisions separate from the original Preserve-mode upstream request target. See [Access](ACCESS.md) and [Troubleshooting](TROUBLESHOOTING.md). ## Security and access
## Integrations and operations - [Access and authentication](ACCESS.md) — ACLs, countries, rate limits, Basic Auth, Forward Auth and OIDC.
- [Certificates](CERTIFICATES.md) — PEM import, ACME HTTP-01/DNS-01, external Certbot files and automatic renewal.
- [Security model](SECURITY.md) — trust boundaries and deployment recommendations.
- [Certificates and automatic renewal](ACME_PROVIDERS.md) — PEM, automatic SNI names, path permission commands, HTTP-01, DNS-01, Certbot and external certificates. ## Performance and delivery
- [Docker discovery](DOCKER_DISCOVERY.md) — optional Linux agent DEB/RPM or manual installation, TLS/token CLI, networking, generated Vhosts and their per-service live HTTP logs.
- [Other integrations](INTEGRATIONS.md) — Tinyauth, NPM migration, OPNsense and monitoring tools.
- [Dashboard](DASHBOARD.md) — metrics panels, HIT/MISS/BYPASS/OTHER request mix, presets, configuration history and applying stylesheet updates.
- [Monitoring](MONITORING.md) — Prometheus, full JSON and compact `summary` API, 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.
## Release information - [Cache](CACHE.md) — HOT (memory) and COLD (disk) cache, freshness, `Cache-Status` and invalidation.
- [Response delivery](RESPONSE_DELIVERY.md) — compression, transformations and response headers.
[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. ## Operations
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. - [Dashboard](DASHBOARD.md) — Overview cards, layouts, presets and configuration history.
- [Monitoring](MONITORING.md) — Prometheus exporter, JSON metrics, Grafana and Nagios.
- [Logs](LOGS.md) — live, stored and error logs.
- [Backup and recovery](BACKUP.md) — encrypted backups, restore and the Secret Store.
- [Migration](MIGRATION.md) — upgrades and Nginx Proxy Manager import.
- [Docker discovery](DOCKER_DISCOVERY.md) — Docker agent and generated Vhosts.
- [Administration API](API.md) — authentication, tokens, endpoints, lightweight polling views and configuration sections.
## Integrations
| Integration | Purpose | Guide |
|---|---|---|
| Docker agent | Discover containers on Docker hosts and publish them as Vhosts | [Docker discovery](DOCKER_DISCOVERY.md) |
| Tinyauth, Authelia, authentik | Forward Auth for selected Vhosts | [Access](ACCESS.md) |
| OIDC (Authelia, authentik, Pocket ID, others) | Central login across unrelated domains | [Access](ACCESS.md) |
| ACME / Certbot | Certificates via HTTP-01 or DNS-01 (Cloudflare, OVH, RFC 2136) | [Certificates](CERTIFICATES.md) |
| Prometheus and Grafana | Metrics scraping and a ready dashboard | [Monitoring](MONITORING.md) |
| Nagios | Service checks for API, backends, latency and queues | [Monitoring](MONITORING.md) |
| Nginx Proxy Manager | Import of existing proxy hosts | [Migration](MIGRATION.md) |
| OPNsense | Certificate deployment from the os-acme-client plugin | `integrations/opnsense-acme/README.md` |
| MikroTik RouterOS | OCI image build for RouterOS containers | `integrations/routeros-oci-build/README.md` |
## Release notes
See the [changelog](../CHANGELOG.md).
+73
View File
@@ -0,0 +1,73 @@
# Screenshots
A tour of the RustProxy administration panel. The panel runs on the separate administration listener (by default `http://127.0.0.1:9090`) and is available in English and Polish, with light and dark themes.
## Sign-in
Local administrator sign-in. The first account is created with a one-time setup token; there is no default password.
![Sign-in](images/login.png)
## Overview dashboard
Live requests per second, bandwidth, status codes, cache hit ratio, protocol mix, system resources and backend health. Cards can be rearranged, resized, hidden and saved as presets.
![Overview dashboard](images/overview.png)
## Vhosts
Every published domain with its listeners, backends, health, request rate and security options. The editor groups frontend, backends, balancing, security, cache and custom locations.
![Vhost list](images/vhosts.png)
![Vhost editor](images/vhost-editor.png)
## Web Server
Reusable Web Services for static sites and PHP applications, with presets, delivery options and generated filesystem permission commands.
![Web Server](images/web-server.png)
## Certificates
Certificate inventory with expiry, covered hostnames, assigned Vhosts and managed ACME renewal.
![Certificates](images/certificates.png)
## Traffic
Per-Vhost traffic analytics: top paths, clients, user agents, countries, methods and status codes.
![Traffic analytics](images/traffic.png)
## Cache
HOT and COLD cache usage, hit ratio, stored objects and purge/ban tools.
![Cache](images/cache.png)
## Logs
Live and stored request logs with filters, search syntax and record details.
![Logs](images/logs.png)
## Settings
Listeners, TLS policy, security, logging, cache, compression, monitoring, administration and appearance.
![Settings](images/settings.png)
## Documentation and API
The built-in Reader with all guides, and an interactive OpenAPI reference powered by Swagger UI. Both work offline and use the administrator session.
![Documentation Reader](images/documentation.png)
![API reference](images/api.png)
## About
Version, author, license summary and project links.
![About](images/about.png)
+1 -1
View File
@@ -45,4 +45,4 @@ The **Security headers** subtab controls an HTTPS HSTS policy and optional globa
## Release and operational validation ## 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. Before exposing a new version at an Internet edge, test it on the target OS with your own configuration: TLS/H2/H3/WebSocket handling, access rules and cache privacy, backup/restore, logging and failure recovery. Keep monitored off-host backups and a rollback plan.
-51
View File
@@ -1,51 +0,0 @@
# Experimental post-quantum TLS key exchange (PQC)
## What it does
Post-quantum cryptography (PQC) aims to protect key exchange from attackers with future quantum computing capabilities. **X25519MLKEM768** combines a conventional X25519 ephemeral key agreement with post-quantum **ML-KEM-768** in one TLS 1.3 *hybrid key-exchange group*. Both contributions protect the derived traffic secret. This is **not a TLS cipher suite**: AES-GCM or ChaCha20-Poly1305 and ordinary X.509 RSA/ECDSA certificates remain in use. It does not change certificate signatures to post-quantum algorithms, and does not make all parts of TLS quantum-safe.
When enabled, RustProxy uses the `aws-lc-rs` cryptography provider for selected incoming TLS policies, offering X25519MLKEM768 first and classical key-exchange groups as fallback. When disabled it keeps the existing `ring` TLS provider. The selected key exchange depends on client support and negotiation; turning the switch on does not imply every connection uses PQC. It does **not** change outbound/backend, update-check, certificate-automation or other HTTP client TLS settings.
For implementation details see [rustls defaults and PQC](https://rustls.dev/docs/rustls/manual/_05_defaults/index.html) and [X25519MLKEM768 rustls API](https://docs.rs/rustls/0.23.45/rustls/crypto/aws_lc_rs/kx_group/static.X25519MLKEM768.html).
## Enable
1. Make a configuration backup; ensure that **TLS 1.3** is enabled.
2. Open **Settings → TLS policy** and enable **Experimental post-quantum key exchange (PQC)**. Keep your current **cipher suite** list unless you have a specific reason to change it. The KEX group is enabled by the switch, **not** by adding X25519MLKEM768 to the cipher-suite textarea.
3. Select **Validate, save and apply**. The TLS status reports the configured key-exchange groups, not the group used by each individual connection.
4. Test HTTPS, HTTP/1.1, HTTP/2 and (if enabled) HTTP/3 on staging with a client that supports hybrid KEX, and with older clients that use classical KEX.
`post_quantum` is stored on the global policy (the default is false):
```json
{"tls": {"policy": {"tls12": true, "tls13": true, "cipher_suites": [], "post_quantum": true}}}
```
This fragment illustrates the `tls` field; do not replace your full configuration with it.
### Per-vhost behavior
A Vhost **inherits** the global TLS policy by default. If it has an explicit `tls_policy` override, the override may disable PQC, but it cannot enable PQC while the global setting is disabled. Existing overrides that do not contain `post_quantum` are treated as `false`, even after you enable PQC globally. HTTP/3 Vhosts require their override, if any, to match the global TLS policy exactly, including PQC. Update or remove conflicting overrides before enabling PQC on H3.
The administration TLS listener also uses the global policy when administration HTTPS is enabled. Open connections keep their negotiated keys; only **new handshakes** use the updated policy.
## Test negotiated group
Requires **OpenSSL 3.5+** with `X25519MLKEM768` support:
```bash
openssl s_client -connect example.com:443 -servername example.com \
-tls1_3 -groups X25519MLKEM768 -brief
```
Check for `Protocol version: TLSv1.3` and `Negotiated TLS1.3 group: X25519MLKEM768` (format depends on OpenSSL). Compare with a classic-only client using `-groups X25519` and test TLS 1.2 if it is enabled. For HTTP/3, also use an actual QUIC/H3 client and test with UDP blocked to verify fallback to H2/H1; `openssl s_client` does not test QUIC.
If your TLS connection ends at a CDN, load balancer or TLS-terminating proxy, the test shows **that intermediary**, not RustProxy. To check RustProxy, connect to its listener directly while preserving SNI, and verify its actual certificate.
## Caveats and rollback
- Hybrid handshakes are larger and can hit MTU/middlebox limits, especially over QUIC. Measure connection errors, handshake latency, memory and CPU under expected load. Some legacy clients and devices may reject hybrid negotiation.
- Enabling the switch changes the incoming TLS cryptographic provider to `aws-lc-rs`; some uncommon certificates, algorithms or hardware platforms may behave differently. `aws-lc-rs` requires a native C/C++ build toolchain; CMake may be needed for some build modes (non-FIPS commonly does not require it). The `ring` provider remains used where the switch is off and for existing outbound integrations.
- A non-empty cipher-suite allowlist must contain suites supported by the selected provider; invalid combinations are rejected. PQC requires TLS 1.3.
- For immediate rollback, **uncheck PQC in Settings → TLS policy and save**, and if necessary remove per-Vhost overrides that require PQC. Confirm new connections negotiate classic X25519. Existing sessions cannot renegotiate in place; close/reconnect them to retest. Keep the prior binary and backups available for a full rollback.
- **Release verification:** the PQC implementation inherited from 0.34.9 still requires native Cargo build, TLS handshake, QUIC interoperability and production load acceptance. Verify the provisionally updated `Cargo.lock` with `cargo metadata --locked` (and refresh it with Cargo if needed), then run `cargo check --all-targets`, `cargo test --all-targets`, `cargo build --release`, and protocol tests before deployment. See the release changelog and source verification notes.
+23 -5
View File
@@ -18,8 +18,7 @@ Transport settings attach to **named listeners**, and a Vhost must select the ap
- **For new QUIC connections only:** updates to `max_concurrent_bidistream`, `max_concurrent_unistream`, `max_idle_timeout` and reductions to `max_concurrent_connections` are applied without restart. Existing QUIC connections keep their negotiated limits. If no existing UDP socket matches the saved listener address, no H3 is advertised or accepted on the old address. - **For new QUIC connections only:** updates to `max_concurrent_bidistream`, `max_concurrent_unistream`, `max_idle_timeout` and reductions to `max_concurrent_connections` are applied without restart. Existing QUIC connections keep their negotiated limits. If no existing UDP socket matches the saved listener address, no H3 is advertised or accepted on the old address.
- **Restart needed:** UDP listener enable/disable, changing IP/port/TLS properties, UDP socket buffer sizes, changes to H1/H2 listener settings and increasing `max_concurrent_connections` above the startup bound. The live listener status continues to show `restart_required` for these cases. After a restart, the new connection cap can take effect. - **Restart needed:** UDP listener enable/disable, changing IP/port/TLS properties, UDP socket buffer sizes, changes to H1/H2 listener settings and increasing `max_concurrent_connections` above the startup bound. The live listener status continues to show `restart_required` for these cases. After a restart, the new connection cap can take effect.
### QUIC UDP socket buffers
### QUIC UDP socket buffers (0.34.3)
The HTTP/3 settings of each TLS listener include: The HTTP/3 settings of each TLS listener include:
@@ -41,7 +40,6 @@ sudo ss -uapm | grep -A1 rust-proxy
Read the `HTTP/3 UDP effective socket buffers` startup log message. `ss` should show approximately `rb4194304,tb4194304` (or larger if the OS adjusts values) when the requested size is granted. Changes to the OS maxima alone do not resize an existing socket; the configured buffer size is applied when a QUIC listener starts. High default/maxima for every program are not recommended as a substitute for explicit per-socket configuration. No specific throughput gain is guaranteed; compare H2/H3 at matched traffic before/after. Read the `HTTP/3 UDP effective socket buffers` startup log message. `ss` should show approximately `rb4194304,tb4194304` (or larger if the OS adjusts values) when the requested size is granted. Changes to the OS maxima alone do not resize an existing socket; the configured buffer size is applied when a QUIC listener starts. High default/maxima for every program are not recommended as a substitute for explicit per-socket configuration. No specific throughput gain is guaranteed; compare H2/H3 at matched traffic before/after.
## Backend protocol selection ## Backend protocol selection
| Backend mode | HTTPS origin | Plain HTTP origin | | Backend mode | HTTPS origin | Plain HTTP origin |
@@ -55,10 +53,30 @@ WebSockets normally use HTTP/1.1 Upgrade. HTTP/2/HTTP/3 client requests can stil
## TLS and trust ## TLS and trust
**Experimental (0.34.9):** Settings → TLS policy has a disabled-by-default **Post-quantum key exchange (PQC)** switch for hybrid TLS 1.3 X25519MLKEM768. This is a TLS key-exchange group, not a cipher suite. See [Post-quantum TLS](TLS_POST_QUANTUM.md) for prerequisites, fallback, HTTP/3 and rollback.
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. 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.
## Post-quantum TLS key exchange (optional)
**Settings → TLS policy → Post-quantum key exchange** enables the hybrid TLS 1.3 group **X25519MLKEM768** (X25519 combined with ML-KEM-768) for incoming TLS. It is a key-exchange group, not a cipher suite: certificates and AES-GCM/ChaCha20-Poly1305 ciphers stay unchanged, and classic groups remain available as a fallback. The setting is off by default and requires TLS 1.3.
When enabled, incoming TLS uses the `aws-lc-rs` provider; otherwise `ring` is used. Outbound connections (backends, ACME, update checks) are not affected. The setting is stored as `tls.policy.post_quantum`:
```json
{"tls": {"policy": {"tls12": true, "tls13": true, "cipher_suites": [], "post_quantum": true}}}
```
- Vhosts inherit the global policy. A per-Vhost `tls_policy` override can disable PQC but cannot enable it when the global switch is off. HTTP/3 Vhosts need an override that matches the global policy exactly.
- Only new handshakes use the updated policy; open connections keep their keys.
- Hybrid handshakes are larger and may hit MTU or middlebox limits, especially over QUIC; some legacy clients reject them.
- Roll back by unchecking the switch and saving.
Verify the negotiated group with OpenSSL 3.5 or newer, directly against the RustProxy listener (not a CDN in front of it):
```bash
openssl s_client -connect example.com:443 -servername example.com \
-tls1_3 -groups X25519MLKEM768 -brief
```
## Timeouts, request bodies and streaming ## Timeouts, request bodies and streaming
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. 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.
+11 -16
View File
@@ -54,8 +54,7 @@ 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. 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.
## HTTP/3 throughput and CPU diagnostics (0.34.3 source candidate) ## HTTP/3 throughput and CPU diagnostics
Do not assume that low H3 throughput is a CPU bottleneck from the overall VM percentage alone. A 50% VM reading can mean one saturated vCPU on a 2-vCPU machine. Quinn uses UDP datagrams, per-packet QUIC/TLS, congestion control and OS/virtual NIC offloads; H1/H2 over TCP can behave differently under the same load. Quinn enables UDP GSO by default when the platform supports it, but some VM/network stacks fall back to per-datagram sends. Do not assume that low H3 throughput is a CPU bottleneck from the overall VM percentage alone. A 50% VM reading can mean one saturated vCPU on a 2-vCPU machine. Quinn uses UDP datagrams, per-packet QUIC/TLS, congestion control and OS/virtual NIC offloads; H1/H2 over TCP can behave differently under the same load. Quinn enables UDP GSO by default when the platform supports it, but some VM/network stacks fall back to per-datagram sends.
1. Run **four matched tests** against the same OpenSpeedTest/large streaming endpoint: direct backend H1, proxied HTTPS H2, proxied HTTPS H3 download, proxied H3 upload. Record client speed, per-process CPU, **per-thread** CPU, and whether the problem persists with a larger number of simultaneous clients. 1. Run **four matched tests** against the same OpenSpeedTest/large streaming endpoint: direct backend H1, proxied HTTPS H2, proxied HTTPS H3 download, proxied H3 upload. Record client speed, per-process CPU, **per-thread** CPU, and whether the problem persists with a larger number of simultaneous clients.
@@ -69,27 +68,23 @@ Do not assume that low H3 throughput is a CPU bottleneck from the overall VM per
``` ```
3. If available, use `perf top -p "$(pidof rust-proxy)"` briefly during the benchmark, and inspect `%sys` / `%soft` in `mpstat -P ALL 1`. Check whether guest CPU has `aes` instruction support with `lscpu`. 3. If available, use `perf top -p "$(pidof rust-proxy)"` briefly during the benchmark, and inspect `%sys` / `%soft` in `mpstat -P ALL 1`. Check whether guest CPU has `aes` instruction support with `lscpu`.
4. For the 0.34.3 source candidate, set `RUST_LOG=rust_proxy::http3=debug` in a **test** deployment and inspect `HTTP/3 bulk transfer transport snapshot` logs: `quic_lost_packets`, `quic_congestion_events`, `quic_rtt_ms`, `quic_cwnd_bytes`, `quic_path_mtu`, `quic_udp_tx_datagrams` and `quic_udp_tx_ios`. Those Quinn counters are **connection-wide cumulative snapshots**, not stream-specific deltas; compare differences for one isolated connection or across successive samples. 4. Set `RUST_LOG=rust_proxy::http3=debug` in a **test** deployment and inspect `HTTP/3 bulk transfer transport snapshot` logs: `quic_lost_packets`, `quic_congestion_events`, `quic_rtt_ms`, `quic_cwnd_bytes`, `quic_path_mtu`, `quic_udp_tx_datagrams` and `quic_udp_tx_ios`. Those Quinn counters are **connection-wide cumulative snapshots**, not stream-specific deltas; compare differences for one isolated connection or across successive samples.
5. Run `ss -uapm | grep -A1 rust-proxy` during a transfer; check `skmem rb`, `tb` and whether dropped-datagram `d` increments. RustProxy 0.34.3 defaults to 4 MiB per direction and logs the actual SO_RCVBUF/SO_SNDBUF effective values on bind. If Linux clamps them, inspect `sysctl net.core.rmem_max net.core.wmem_max`; saving new socket buffer targets in the panel requires a listener restart. See [HTTP transport](TRANSPORT.md#quic-udp-socket-buffers-0343). 5. Run `ss -uapm | grep -A1 rust-proxy` during a transfer; check `skmem rb`, `tb` and whether dropped-datagram `d` increments. RustProxy defaults to 4 MiB per direction and logs the actual SO_RCVBUF/SO_SNDBUF effective values on bind. If Linux clamps them, inspect `sysctl net.core.rmem_max net.core.wmem_max`; saving new socket buffer targets in the panel requires a listener restart. See [HTTP transport](TRANSPORT.md#quic-udp-socket-buffers).
A rising loss/retransmission count or shrinking congestion window with low CPU suggests packet pacing, network path, UDP buffer/offload or VM issues. With negligible loss and one saturated worker thread, investigate QUIC encryption, UDP send/receive and application streaming costs using `perf` before changing window sizes. HTTP/3 overhead can also affect large file transfers, uploads and long-lived H3 streams; speedtest is only one way to expose it. No throughput improvement is claimed without a production-equivalent QUIC benchmark. A rising loss/retransmission count or shrinking congestion window with low CPU suggests packet pacing, network path, UDP buffer/offload or VM issues. With negligible loss and one saturated worker thread, investigate QUIC encryption, UDP send/receive and application streaming costs using `perf` before changing window sizes. HTTP/3 overhead can also affect large file transfers, uploads and long-lived H3 streams; speedtest is only one way to expose it. No throughput improvement is claimed without a production-equivalent QUIC benchmark.
## Rollout gate for RustProxy 0.34.0 ## Release rollout checklist
The latest source optimizations were **not all compiled or performance-tested** in the evidence supplied with this release. Before deploying: 1. Verify HTTP/1.1, HTTPS, HTTP/2, HTTP/3 (if enabled), WebSockets and multi-backend failover against a real origin.
2. Test private origin constraints, ACL/authentication, rate limits and protected cache content.
3. Test Web Server static files, PHP-FPM, cache revalidation/invalidation and `sendfile=auto` if enabled.
4. Exercise certificate renewal/reload and a backup **restore** on staging.
5. Compare performance only with matched configurations and repeatable traffic; see [Performance](PERFORMANCE.md).
1. Build using the declared Rust toolchain and run the project's available regression suite on the target platform. For persistent problems, use the feature-specific guide in [Documentation](README.md) and keep a redacted error example with the version and request ID.
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.
## OIDC central login (0.34.16)
## OIDC central login
- **Login does not start / 502:** verify that the HTTPS Issuer URL is the exact `issuer` from `/.well-known/openid-configuration`, including any path or trailing slash. RustProxy must resolve that address and connect to it with a trusted certificate, or you must configure an explicit internal backend origin for server-side OIDC requests. Discovery, authorization, token, UserInfo and JWKS endpoints must have the same HTTPS origin as the issuer. - **Login does not start / 502:** verify that the HTTPS Issuer URL is the exact `issuer` from `/.well-known/openid-configuration`, including any path or trailing slash. RustProxy must resolve that address and connect to it with a trusted certificate, or you must configure an explicit internal backend origin for server-side OIDC requests. Discovery, authorization, token, UserInfo and JWKS endpoints must have the same HTTPS origin as the issuer.
- **Redirect URI mismatch:** register the exact `https://APP-HOST/_rustproxy/oidc/callback` value with the identity provider. The callback hostname must be explicitly listed in the protected Vhost hosts and be accessed over HTTPS. Don't protect the IdP's own login Vhost using this OIDC setting. - **Redirect URI mismatch:** register the exact `https://APP-HOST/_rustproxy/oidc/callback` value with the identity provider. The callback hostname must be explicitly listed in the protected Vhost hosts and be accessed over HTTPS. Don't protect the IdP's own login Vhost using this OIDC setting.
- **Invalid code, nonce or signature:** ensure that the provider issues an `id_token` using RS256 or ES256 and that a confidential client uses `client_secret_basic` with authorization code and PKCE S256. Repeated callback requests or overwritten browser transaction cookies are refused. A single browser login flow per protected host avoids cookie conflicts. - **Invalid code, nonce or signature:** ensure that the provider issues an `id_token` using RS256 or ES256 and that a confidential client uses `client_secret_basic` with authorization code and PKCE S256. Repeated callback requests or overwritten browser transaction cookies are refused. A single browser login flow per protected host avoids cookie conflicts.
+3 -1
View File
@@ -58,13 +58,15 @@ Sample documents in `examples/vhosts-private.json` and `examples/vhosts-balanced
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). 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).
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. Connection reuse is bounded by configured idle-pool/recycle limits. The default H1 upstream idle pool applies to 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.
## Load balancing and health ## Load balancing and health
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. 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 monitoring may use TCP or HTTP checks with a path, Host, interval and thresholds. Avoid write endpoints; use a lightweight `/health` or equivalent. - Health monitoring may use TCP or HTTP checks with a path, Host, interval and thresholds. Avoid write endpoints; use a lightweight `/health` or equivalent.
- **Health check port** (`health_check.port`) probes a different port than the one receiving traffic, like `check port` in HAProxy. Example: a backend serving traffic on `http://10.0.0.5:80` with a status endpoint on port `8079` uses `"health_check": {"mode": "http", "path": "/status", "port": 8079}`. The probe keeps the backend host, scheme, TLS settings and address restrictions; only the port changes. Leave it empty to probe the traffic port. The port can be set for the whole pool or per backend.
- **Expected response text** (`health_check.expect_body`) makes an HTTP probe fail unless the first 64 KiB of the response body contain the given text, like `http-check expect string` in HAProxy. Use it for status pages that answer `200` even when the application is degraded, for example `"expect_body": "\"status\":\"ok\""`. Failed matches appear as `http_body` in backend health details.
- 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. - 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. - 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. - `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.
+2 -3
View File
@@ -46,9 +46,8 @@ For PHP applications, the separate **PHP-FPM pool user** needs its own access to
| **Symlinks** | `deny` by default; `inside_root` is an explicit less restrictive policy. | | **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. | | **Hardening** | Optional checks for common sensitive/attack paths and removal of selected server-identifying headers; not a full WAF. |
### Static delivery (`sendfile`) — 0.34.0 ### Static delivery (`sendfile`)
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. Measure on your own hardware before enabling it; see [Performance](PERFORMANCE.md).
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.
## Rewrites and application presets ## Rewrites and application presets
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+2 -2
View File
@@ -12,7 +12,7 @@ latency, backend health/EWMA latency/active work, traffic, probe age and upstrea
variables **Docker agent** and **Docker service** filter only this section, which keeps variables **Docker agent** and **Docker service** filter only this section, which keeps
multi-agent installations readable without changing the ordinary vhost panels. multi-agent installations readable without changing the ordinary vhost panels.
The **0.33.0 Web Server** row adds header-ready request rates, status-class and The **Web Server** row adds header-ready request rates, status-class and
static/PHP/other donut breakdowns, error percentage, response-header latency, static/PHP/other donut breakdowns, error percentage, response-header latency,
payload byte rates, and downstream compression by codec. **PHP-FPM / FastCGI** payload byte rates, and downstream compression by codec. **PHP-FPM / FastCGI**
adds request activity, RustProxy PHP permit capacity, and errors grouped by adds request activity, RustProxy PHP permit capacity, and errors grouped by
@@ -29,7 +29,7 @@ classes, cache outcomes, capture-state gauges and configured-route traffic. Raw
are intentionally absent from Prometheus labels. are intentionally absent from Prometheus labels.
Import the JSON in Grafana and select a Prometheus datasource when prompted. The Import the JSON in Grafana and select a Prometheus datasource when prompted. The
dashboard expects RustProxy 0.33.0 for its newest Web Server metric names dashboard expects a RustProxy version that exports the Web Server metric names
(and also retains the older panels); a scrape interval around 15s is suitable. (and also retains the older panels); a scrape interval around 15s is suitable.
Collectors can be disabled in RustProxy settings; panels for disabled collectors Collectors can be disabled in RustProxy settings; panels for disabled collectors
will simply show no series. will simply show no series.
+5 -12
View File
@@ -106,18 +106,14 @@ run_builder() {
run_builder git clone --depth 1 "$REPO_URL" "$work/RustProxy" run_builder git clone --depth 1 "$REPO_URL" "$work/RustProxy"
# The builder, not root, owns the toolchain. Never update a system-wide # The unprivileged builder owns its rustup toolchain; system compilers are left untouched.
# apt-managed compiler or change the user's default rustup toolchain.
# rustup is needed even when an older system-installed cargo exists.
if ! run_builder bash -c 'command -v rustup >/dev/null 2>&1'; then if ! run_builder bash -c 'command -v rustup >/dev/null 2>&1'; then
echo "Installing rustup for build user: $builder" echo "Installing rustup for build user: $builder"
run_builder env RUSTUP_INIT_SKIP_PATH_CHECK=yes bash -c \ run_builder env RUSTUP_INIT_SKIP_PATH_CHECK=yes bash -c \
'set -o pipefail; wget -qO- --https-only --secure-protocol=TLSv1_2 https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path --default-toolchain none' 'set -o pipefail; wget -qO- --https-only --secure-protocol=TLSv1_2 https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path --default-toolchain none'
fi fi
# Read the actual requirements from the checked-out sources, not a hardcoded # Read the required Rust version and channel from the checked-out sources.
# Rust version. The project may inherit rust-version from [workspace.package].
# The channel from rust-toolchain.toml (or legacy rust-toolchain) takes precedence.
rust_settings=$(python3 - "$work/RustProxy" <<'PY_RUST' rust_settings=$(python3 - "$work/RustProxy" <<'PY_RUST'
import pathlib import pathlib
import re import re
@@ -175,8 +171,7 @@ if [[ -z "$installed_rust" ]] || ! rust_version_at_least "$installed_rust" "$req
run_builder rustup update "$build_toolchain" run_builder rustup update "$build_toolchain"
;; ;;
*) *)
# A fixed toolchain must be respected. Do not silently override a pin # Never silently override a pinned toolchain older than rust-version.
# that is older than the manifest's minimum supported Rust version.
if [[ -n "$installed_rust" ]]; then if [[ -n "$installed_rust" ]]; then
echo "Pinned toolchain '$build_toolchain' is older than rust-version $required_rust; fix rust-toolchain.toml in the repository." >&2 echo "Pinned toolchain '$build_toolchain' is older than rust-version $required_rust; fix rust-toolchain.toml in the repository." >&2
exit 1 exit 1
@@ -193,8 +188,7 @@ fi
run_builder rustup run "$build_toolchain" cargo --version run_builder rustup run "$build_toolchain" cargo --version
echo "Building with Rust $installed_rust ($build_toolchain); required >= $required_rust." echo "Building with Rust $installed_rust ($build_toolchain); required >= $required_rust."
# Keep source builds reliable on small Debian VPSes. The repository release # Lighter release settings keep builds within RAM limits on small VPSes.
# profile may use LTO/codegen settings that can make rustc exceed RAM limits.
BUILD_JOBS="${RUSTPROXY_BUILD_JOBS:-1}" BUILD_JOBS="${RUSTPROXY_BUILD_JOBS:-1}"
if ! [[ "$BUILD_JOBS" =~ ^[1-9][0-9]*$ ]]; then if ! [[ "$BUILD_JOBS" =~ ^[1-9][0-9]*$ ]]; then
echo "RUSTPROXY_BUILD_JOBS must be a positive integer." >&2 echo "RUSTPROXY_BUILD_JOBS must be a positive integer." >&2
@@ -230,8 +224,7 @@ if [[ "$MODE" == install ]]; then
printf 'Administration is loopback-only by default. Use an SSH tunnel or enable Administration HTTPS before remote exposure.\n' printf 'Administration is loopback-only by default. Use an SSH tunnel or enable Administration HTTPS before remote exposure.\n'
fi fi
else else
# update.sh reads the saved installation record and therefore preserves the # update.sh preserves the saved data directory, binds and service account.
# existing data directory, HTTP/TLS/admin binds and service account.
bash update.sh \ bash update.sh \
--binary target/release/rust-proxy \ --binary target/release/rust-proxy \
--prefix "$PREFIX" --prefix "$PREFIX"
+5 -3
View File
@@ -5,7 +5,7 @@ A simple mechanism for automatically sending a certificate generated by `os-acme
## Files ## Files
- `opnsense-rustproxy-cert-deploy.sh` - sends the certificate and private key to RustProxy. - `opnsense-rustproxy-cert-deploy.sh` - sends the certificate and private key to RustProxy.
- `rustproxy-acme.conf` - connection settings, API token, and certificate file paths. - `rustproxy-acme.conf.example` - example connection settings, API token, and certificate file paths.
- `actions_rustproxy_cert.conf` - optional `configd` action that allows the script to be executed from Cron in OPNsense. - `actions_rustproxy_cert.conf` - optional `configd` action that allows the script to be executed from Cron in OPNsense.
## 1. Installation ## 1. Installation
@@ -50,13 +50,15 @@ KEY_FILE='/path/to/private.key'
## 3. RustProxy API ## 3. RustProxy API
Create a certificate API key in RustProxy and enter its values in the configuration: Create a certificate API key in **Settings -> Certificate API** and enter its values in the configuration:
```text ```text
RUSTPROXY_KEY_ID RUSTPROXY_KEY_ID
RUSTPROXY_TOKEN RUSTPROXY_TOKEN
``` ```
The credential must explicitly allow `CERTIFICATE_ID` and **Also allow deploy** (`allow_deploy`) must be enabled. A reload-only credential will be rejected with HTTP 403.
The script sends the certificate to: The script sends the certificate to:
```text ```text
@@ -88,7 +90,7 @@ cp actions_rustproxy_cert.conf /usr/local/opnsense/service/conf/actions.d/
service configd restart service configd restart
``` ```
Then go to **System -> Settings -> Cron** and add a scheduled execution of the `Deploy ACME certificate to RustProxy` action. Verify the action first with `configctl rustproxy_cert deploy`. Then go to **System -> Settings -> Cron** and add a scheduled execution of the `Deploy ACME certificate to RustProxy` action.
The script deliberately reconciles on every run instead of trusting a local cache. The server is the source of truth: an identical RustProxy-owned certificate generation is a no-op, a missing id is created, and changed material is rotated atomically. This also repairs drift when a certificate entry or generation was removed outside OPNsense. The script deliberately reconciles on every run instead of trusting a local cache. The server is the source of truth: an identical RustProxy-owned certificate generation is a no-op, a missing id is created, and changed material is rotated atomically. This also repairs drift when a certificate entry or generation was removed outside OPNsense.
@@ -0,0 +1,6 @@
[deploy]
command:/usr/local/sbin/opnsense-rustproxy-cert-deploy.sh
parameters:
type:script_output
message:deploy ACME certificate to RustProxy
description:Deploy ACME certificate to RustProxy
@@ -1,8 +1,7 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
# Deploy an externally issued OPNsense/os-acme-client certificate to RustProxy 0.23.x. # Deploy an OPNsense os-acme-client certificate to RustProxy; settings live in a separate file.
# Configuration is kept separately so the API token does not need to live in this script.
CONFIG_FILE="${RUSTPROXY_ACME_CONFIG:-/usr/local/etc/rustproxy-acme.conf}" CONFIG_FILE="${RUSTPROXY_ACME_CONFIG:-/usr/local/etc/rustproxy-acme.conf}"
@@ -62,10 +61,7 @@ KEY_PUB=$(
"$OPENSSL" x509 -in "$CERT_FILE" -checkend 0 -noout >/dev/null 2>&1 \ "$OPENSSL" x509 -in "$CERT_FILE" -checkend 0 -noout >/dev/null 2>&1 \
|| fail "Certificate is already expired" || fail "Certificate is already expired"
# Always reconcile with RustProxy. The deploy endpoint is idempotent: an identical # The deploy endpoint is idempotent, so always reconcile instead of trusting local state.
# RustProxy-owned generation is a server-side no-op, while a missing certificate id
# is created and a changed manual certificate is rotated atomically. This avoids
# trusting stale local state when the RustProxy configuration was changed separately.
umask 077 umask 077
TMP_JSON=$(mktemp "${TMPDIR:-/tmp}/rustproxy-cert.XXXXXX") || fail "mktemp failed" TMP_JSON=$(mktemp "${TMPDIR:-/tmp}/rustproxy-cert.XXXXXX") || fail "mktemp failed"
@@ -73,8 +69,7 @@ TMP_CURL=$(mktemp "${TMPDIR:-/tmp}/rustproxy-curl.XXXXXX") || { rm -f "$TMP_JSON
TMP_RESPONSE=$(mktemp "${TMPDIR:-/tmp}/rustproxy-response.XXXXXX") || { rm -f "$TMP_JSON" "$TMP_CURL"; fail "mktemp failed"; } TMP_RESPONSE=$(mktemp "${TMPDIR:-/tmp}/rustproxy-response.XXXXXX") || { rm -f "$TMP_JSON" "$TMP_CURL"; fail "mktemp failed"; }
trap 'rm -f "$TMP_JSON" "$TMP_CURL" "$TMP_RESPONSE"' EXIT HUP INT TERM trap 'rm -f "$TMP_JSON" "$TMP_CURL" "$TMP_RESPONSE"' EXIT HUP INT TERM
# PEM is line-oriented Base64 plus BEGIN/END markers. Escape it as a JSON string # Escape PEM as a JSON string without jq/python.
# without needing jq/python on OPNsense.
pem_json() { pem_json() {
awk '{ awk '{
gsub(/\\/, "\\\\"); gsub(/\\/, "\\\\");
@@ -114,8 +109,7 @@ if [ -n "${RUSTPROXY_CACERT:-}" ]; then
printf 'cacert = "%s"\n' "$RUSTPROXY_CACERT" >> "$TMP_CURL" printf 'cacert = "%s"\n' "$RUSTPROXY_CACERT" >> "$TMP_CURL"
fi fi
# Optional curl --resolve equivalent, e.g. rustproxy.lan:8443:10.10.10.20. # Optional curl --resolve value, e.g. rustproxy.lan:8443:10.10.10.20.
# This lets the URL use the certificate DNS name while traffic goes to a fixed LAN IP.
if [ -n "${RUSTPROXY_RESOLVE:-}" ]; then if [ -n "${RUSTPROXY_RESOLVE:-}" ]; then
printf 'resolve = "%s"\n' "$RUSTPROXY_RESOLVE" >> "$TMP_CURL" printf 'resolve = "%s"\n' "$RUSTPROXY_RESOLVE" >> "$TMP_CURL"
fi fi
@@ -0,0 +1,21 @@
# RustProxy administration endpoint reachable from OPNsense.
RUSTPROXY_URL='https://rustproxy.example.lan:9090'
# Certificate API credential created in RustProxy Settings -> Certificate API.
# The credential must allow this CERTIFICATE_ID and have deploy permission enabled.
RUSTPROXY_KEY_ID='opnsense-acme'
RUSTPROXY_TOKEN='REPLACE_WITH_CERTIFICATE_API_TOKEN'
# Stable RustProxy certificate id to create/update.
CERTIFICATE_ID='shared-cert'
# os-acme-client certificate material. Replace CERT_UUID with the actual UUID.
CERT_FILE='/var/etc/acme-client/certs/CERT_UUID/fullchain.pem'
KEY_FILE='/var/etc/acme-client/keys/CERT_UUID/private.key'
# Optional: trust a private CA used by the RustProxy administration listener.
# RUSTPROXY_CACERT='/usr/local/etc/ssl/rustproxy-ca.pem'
# Optional: connect to a fixed address while keeping TLS verification for the URL hostname.
# Format: hostname:port:address
# RUSTPROXY_RESOLVE='rustproxy.example.lan:9090:192.0.2.20'
+2 -2
View File
@@ -77,10 +77,10 @@ ENV_FILE=integrations/routeros-oci-build/.env \
bash integrations/routeros-oci-build/build-routeros-oci.sh push bash integrations/routeros-oci-build/build-routeros-oci.sh push
``` ```
With RustProxy version `0.25.0` and the defaults this publishes: For example, with RustProxy version `0.36.8` and the defaults this publishes:
```text ```text
zot.example.net/network/rustproxy:0.25.0 zot.example.net/network/rustproxy:0.36.8
zot.example.net/network/rustproxy:latest zot.example.net/network/rustproxy:latest
zot.example.net/network/rustproxy:routeros zot.example.net/network/rustproxy:routeros
``` ```
@@ -1,9 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
# RustProxy multi-arch OCI builder for RouterOS. # RustProxy multi-arch OCI builder for RouterOS (cross-compiled, no QEMU rustc).
# Rust compilation runs on BUILDPLATFORM. Target binaries are cross-compiled,
# so rustc/cargo do not run through QEMU for arm64 builds.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -74,8 +72,7 @@ VERSION="$(awk '
' "$PROJECT_ROOT/Cargo.toml")" ' "$PROJECT_ROOT/Cargo.toml")"
[[ -n "$VERSION" ]] || { echo "Cannot determine package version from Cargo.toml" >&2; exit 1; } [[ -n "$VERSION" ]] || { echo "Cannot determine package version from Cargo.toml" >&2; exit 1; }
# Registry/image. Preferred format: REGISTRY + IMAGE_NAME. # Image: REGISTRY + IMAGE_NAME (or OCI_IMAGE shorthand).
# OCI_IMAGE is kept as a compatible shorthand.
if [[ -n "${OCI_IMAGE:-}" ]]; then if [[ -n "${OCI_IMAGE:-}" ]]; then
IMAGE_REPO="${OCI_IMAGE%/}" IMAGE_REPO="${OCI_IMAGE%/}"
REGISTRY="${OCI_REGISTRY:-${IMAGE_REPO%%/*}}" REGISTRY="${OCI_REGISTRY:-${IMAGE_REPO%%/*}}"
+131 -25
View File
@@ -253,7 +253,8 @@
"certificateSafety": "Certificates secure TLS connections, not routing. Assign a certificate to a vhost. Changes affect new handshakes.", "certificateSafety": "Certificates secure TLS connections, not routing. Assign a certificate to a vhost. Changes affect new handshakes.",
"wsActive": "WebSocket tunnels", "wsActive": "WebSocket tunnels",
"h2Requests": "HTTP/2 requests", "h2Requests": "HTTP/2 requests",
"h2ActiveConnections": "Active H2 connections", "h2ActiveConnections": "H2 connections",
"h3ActiveConnections": "H3 connections",
"tlsFailures": "TLS handshake errors", "tlsFailures": "TLS handshake errors",
"tlsListener": "TLS listener", "tlsListener": "TLS listener",
"notEnabled": "Disabled", "notEnabled": "Disabled",
@@ -395,6 +396,7 @@
"vhImport": "Load into editor", "vhImport": "Load into editor",
"vhMigrationHint": "Migration is saved atomically. Routes sharing one host and one backend are grouped; ambiguous host ownership is rejected.", "vhMigrationHint": "Migration is saved atomically. Routes sharing one host and one backend are grouped; ambiguous host ownership is rejected.",
"vhEditor": "Vhost configuration", "vhEditor": "Vhost configuration",
"vhCancel": "Cancel",
"vhReset": "Discard changes", "vhReset": "Discard changes",
"vhSafetyHint": "Private IP binding is not authentication. Keep firewall/ACL rules. HSTS preload is opt-in; affinity cookies are not login sessions.", "vhSafetyHint": "Private IP binding is not authentication. Keep firewall/ACL rules. HSTS preload is opt-in; affinity cookies are not login sessions.",
"vhId": "Vhost ID", "vhId": "Vhost ID",
@@ -457,6 +459,9 @@
"vhHttp": "HTTP status + response time", "vhHttp": "HTTP status + response time",
"vhHealthPath": "HTTP check path", "vhHealthPath": "HTTP check path",
"vhHealthHost": "HTTP Host override (optional)", "vhHealthHost": "HTTP Host override (optional)",
"vhHealthPort": "Health check port (optional)",
"vhHealthPortPlaceholder": "Backend port",
"vhHealthExpectBody": "Expected text in HTTP response (optional)",
"vhInterval": "Interval (ms)", "vhInterval": "Interval (ms)",
"vhTimeout": "Timeout (ms)", "vhTimeout": "Timeout (ms)",
"vhSlow": "Slow threshold (ms; 0 = off)", "vhSlow": "Slow threshold (ms; 0 = off)",
@@ -464,7 +469,7 @@
"vhRise": "Successes before recovery", "vhRise": "Successes before recovery",
"vhStatusMin": "Minimum HTTP status", "vhStatusMin": "Minimum HTTP status",
"vhStatusMax": "Maximum HTTP status", "vhStatusMax": "Maximum HTTP status",
"vhHealthNote": "HTTP probes use HTTP/1.1 and measure time to headers, not body duration. When Host override is blank, RustProxy automatically uses the first exact DNS hostname of the vhost; if none exists it falls back to the backend host. Use TCP for h2c-only services. New backends wait for successful probes. Active health is independent from passive health.", "vhHealthNote": "HTTP probes use HTTP/1.1 and measure time to headers, not body duration. When Host override is blank, RustProxy automatically uses the first exact DNS hostname of the vhost; if none exists it falls back to the backend host. Use TCP for h2c-only services. New backends wait for successful probes. Active health is independent from passive health. Health check port probes a different port than traffic (for example a status port 8079 for a backend on port 80); host, TLS and address restrictions stay the same. Expected text marks the backend as down unless the first 64 KiB of the HTTP probe response contain it.",
"vhPassiveHealth": "Passive Backend Health", "vhPassiveHealth": "Passive Backend Health",
"vhPassiveEnabled": "Allow normal request failures to mark a backend unhealthy", "vhPassiveEnabled": "Allow normal request failures to mark a backend unhealthy",
"vhPassiveHelp": "Off by default. Errors are still logged and counted, but normal user traffic cannot change backend health until this is explicitly enabled.", "vhPassiveHelp": "Off by default. Errors are still logged and counted, but normal user traffic cannot change backend health until this is explicitly enabled.",
@@ -634,13 +639,13 @@
"certCheckDomains": "Domains to verify (comma-separated)", "certCheckDomains": "Domains to verify (comma-separated)",
"certAcmeLater": "Issued certificates are imported into the library and assigned per vhost.", "certAcmeLater": "Issued certificates are imported into the library and assigned per vhost.",
"certAdvanced": "Existing files / advanced", "certAdvanced": "Existing files / advanced",
"certLegacyNames": "Legacy SNI names (optional; prefer per-vhost assignment)", "certLegacyNames": "SNI names (optional; used when a vhost is set to Global SNI / default certificate)",
"certAssigned": "Assigned vhosts", "certAssigned": "Assigned vhosts",
"certUnassigned": "Not assigned", "certUnassigned": "Not assigned",
"certInUse": "Certificate is in use. Reassign vhosts and change the default before removal.", "certInUse": "Certificate is in use. Reassign vhosts and change the default before removal.",
"certDuplicate": "This certificate ID exists. Use a new ID for safe rotation.", "certDuplicate": "This certificate ID exists. Use a new ID for safe rotation.",
"certManage": "Manage certificate library", "certManage": "Manage certificate library",
"certSelectHint": "Select an imported certificate. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.", "certSelectHint": "Select an imported certificate to use it for every name of this vhost. Leave \"Global SNI / default certificate\" to let RustProxy pick the certificate per requested name (SNI) from the stored certificates whose SNI names or SAN entries include it — useful when one vhost serves domains with different certificates. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.",
"vhTinyauth": "Authentication", "vhTinyauth": "Authentication",
"vhTabAuth": "Authentication", "vhTabAuth": "Authentication",
"taUrl": "Tinyauth URL / verification endpoint", "taUrl": "Tinyauth URL / verification endpoint",
@@ -942,6 +947,7 @@
"docsWrites": "Enable write operations", "docsWrites": "Enable write operations",
"docsWriteConfirm": "This sends a real change to RustProxy. Continue?", "docsWriteConfirm": "This sends a real change to RustProxy. Continue?",
"docsExpired": "Session ended. Sign in again.", "docsExpired": "Session ended. Sign in again.",
"docsImage": "Screenshot",
"docsError": "Documentation could not be loaded.", "docsError": "Documentation could not be loaded.",
"docsAccount": "Signed in as", "docsAccount": "Signed in as",
"docsReadOnly": "Read-only execution by default. SSE streams and login/setup are not executed here.", "docsReadOnly": "Read-only execution by default. SSE streams and login/setup are not executed here.",
@@ -1673,6 +1679,7 @@
"dockerSearchPlaceholder": "Search service, host, alias or agent...", "dockerSearchPlaceholder": "Search service, host, alias or agent...",
"dockerShowHidden": "Show Hidden", "dockerShowHidden": "Show Hidden",
"dockerHideHidden": "Hide Hidden", "dockerHideHidden": "Hide Hidden",
"dockerHideDisabled": "Hide disabled",
"dockerHideVhost": "Hide from view", "dockerHideVhost": "Hide from view",
"dockerUnhideVhost": "Show in view", "dockerUnhideVhost": "Show in view",
"dockerCertificate": "Frontend certificate", "dockerCertificate": "Frontend certificate",
@@ -1721,7 +1728,16 @@
"dockerAliasesShort": "aliases", "dockerAliasesShort": "aliases",
"dockerAgentDetails": "Agent details", "dockerAgentDetails": "Agent details",
"dockerServices": "Services", "dockerServices": "Services",
"dockerOpenApp": "Open app", "dockerOpenApp": "Open",
"autoIdHint": "Generated automatically from the name. Edit it to set your own ID.",
"autoIdReset": "Generate automatically",
"autoIdChange": "Change",
"autoIdPending": "Generated from the name",
"autoIdBadge": "auto",
"vhCertificateUncoveredPrompt": "Certificate \"{certificate}\" does not cover: {hosts}.\n\nSave anyway and force this certificate for these names? Browsers will show a name-mismatch warning. Choose Cancel to pick a matching certificate instead.",
"copyEntry": "Copy",
"autoCertSniHint": "No single certificate covers all names, but each name is covered by a stored certificate. Keep \"Global SNI / default certificate\" and RustProxy will pick the right certificate for each name (SNI).",
"autoCertHint": "Certificate \"{id}\" was selected automatically because it covers all host names. You can choose another one.",
"dockerLiveAccessLogsHelp": "Live HTTP access logs for this Docker vhost, not the container stdout/stderr.", "dockerLiveAccessLogsHelp": "Live HTTP access logs for this Docker vhost, not the container stdout/stderr.",
"networkInterfacesHistory": "Traffic by interface", "networkInterfacesHistory": "Traffic by interface",
"networkInterfaceRate": "RX / TX", "networkInterfaceRate": "RX / TX",
@@ -1873,9 +1889,9 @@
"tunnelKillReasonDefault": "admin panel", "tunnelKillReasonDefault": "admin panel",
"listenerH2c": "h2c cleartext HTTP/2 (prior knowledge + HTTP/1.1 Upgrade)", "listenerH2c": "h2c cleartext HTTP/2 (prior knowledge + HTTP/1.1 Upgrade)",
"vhWebsocketRfc8441": "RFC 8441 Extended CONNECT over HTTP/2", "vhWebsocketRfc8441": "RFC 8441 Extended CONNECT over HTTP/2",
"wsH1": "H1 Upgrade", "wsH1": "H1",
"wsH2Rfc8441": "H2 RFC 8441", "wsH2Rfc8441": "H2",
"wsH3Rfc9220": "H3 RFC 9220", "wsH3Rfc9220": "H3",
"wsEstablished": "Established", "wsEstablished": "Established",
"httpProtocolMix": "HTTP protocol mix", "httpProtocolMix": "HTTP protocol mix",
"wsProtocolMix": "WebSocket protocol mix", "wsProtocolMix": "WebSocket protocol mix",
@@ -2250,11 +2266,21 @@
"about": "About", "about": "About",
"aboutTitle": "About RustProxy", "aboutTitle": "About RustProxy",
"aboutAuthor": "Author", "aboutAuthor": "Author",
"aboutLicenseText": "Full license text",
"aboutDocumentation": "Documentation and API reference",
"aboutLicensePointWarranty": "The software is provided without any warranty.",
"aboutLicensePointNetwork": "If you run a modified version as a network service, its users must be able to obtain the modified source code.",
"aboutLicensePointShare": "Distributed copies and modified versions must stay under the same license and include their source code.",
"aboutLicensePointUse": "Use it for any purpose, including commercial deployments.",
"aboutLicenseTitle": "License summary",
"aboutSpdx": "SPDX identifier",
"aboutCopyright": "Copyright",
"aboutTagline": "Reverse proxy, web server, certificate manager and traffic monitor in a single service with a built-in administration panel.",
"aboutLicense": "License", "aboutLicense": "License",
"aboutLicenseValue": "GNU AGPL v3", "aboutLicenseValue": "GNU AGPL v3.0 or later",
"aboutOpenSourceText": "RustProxy is an open-source project. Its source code is publicly available for inspection, development and community contribution.", "aboutOpenSourceText": "RustProxy is free and open-source software. You may use, study, modify and redistribute it under the terms of the GNU Affero General Public License, version 3 or any later version.",
"aboutRepository": "Official source repository", "aboutRepository": "Source code",
"aboutPackagesRepository": "Official package repository", "aboutPackagesRepository": "Website and packages",
"aboutPythonRuntime": "Certificate Python runtime", "aboutPythonRuntime": "Certificate Python runtime",
"runtimePython": "Python", "runtimePython": "Python",
"runtimePip": "pip", "runtimePip": "pip",
@@ -2266,6 +2292,28 @@
"vhPoolRecycleSeconds": "Maximum upstream pool age (seconds, 0 = off)", "vhPoolRecycleSeconds": "Maximum upstream pool age (seconds, 0 = off)",
"vhHttp1MaxBuffer": "HTTP/1 upstream max read buffer (bytes)", "vhHttp1MaxBuffer": "HTTP/1 upstream max read buffer (bytes)",
"vhPoolRecycleHelp": "Recycling replaces only the Hyper client generation for each backend; active exchanges continue normally. Defaults bound long-lived pool/TLS buffer retention without reloading the vhost or cache. Request recycling accepts 0 or 1000..10000000; age accepts 0 or 60..604800.", "vhPoolRecycleHelp": "Recycling replaces only the Hyper client generation for each backend; active exchanges continue normally. Defaults bound long-lived pool/TLS buffer retention without reloading the vhost or cache. Request recycling accepts 0 or 1000..10000000; age accepts 0 or 60..604800.",
"backendTinyTitle": "Tiny backends",
"backendTinyDescription": "Compact backend diagnostics table with search and sorting (the original 14-column view).",
"backendOverviewTitle": "Backends",
"backendOverviewDescription": "Backend health with status badges, error share, latency trend and expandable client-generation details; search, sorting, filters and column selection.",
"backendFilterAll": "All",
"backendFilterOther": "Other",
"backendFilterLabel": "Filter backends by status",
"backendSummary": "{up} of {total} up",
"backendLastEvent": "Last event",
"backendEventFailed": "Failed",
"backendEventRecovered": "Recovered",
"backendEventNone": "No failures",
"backendAgo": "{value} ago",
"backendDetailsToggle": "Show client generation details",
"processOverviewTitle": "Process",
"processOverviewDescription": "Process CPU, file descriptors, memory split and CPU/RSS history (cache memory is on the RAM cache card).",
"processClassicTitle": "Process simple",
"processLoadTitle": "Load",
"processMemoryTitle": "Memory",
"processFdHistory": "File descriptors history",
"processThreadHistory": "OS threads history",
"backendRequestsTrend": "Requests per refresh",
"backendSearch": "Search backends", "backendSearch": "Search backends",
"backendSearchPlaceholder": "Search vhost, backend or state", "backendSearchPlaceholder": "Search vhost, backend or state",
"backendSortBy": "Sort by", "backendSortBy": "Sort by",
@@ -2346,6 +2394,14 @@
"chartStyleMonochrome": "Monochrome", "chartStyleMonochrome": "Monochrome",
"chartStyleWarm": "Warm", "chartStyleWarm": "Warm",
"chartStyleCool": "Cool", "chartStyleCool": "Cool",
"chartStyleSunset": "Sunset",
"chartStyleAurora": "Aurora",
"chartStyleOcean": "Ocean",
"chartStyleForest": "Forest",
"chartStylePastel": "Pastel",
"chartStyleTerminal": "Terminal",
"chartStyleBlueprint": "Blueprint",
"chartStyleColorblind": "Colorblind safe",
"settingsSearch": "Find settings", "settingsSearch": "Find settings",
"settingsSearchPlaceholder": "Search settings, sections and options...", "settingsSearchPlaceholder": "Search settings, sections and options...",
"settingsSearchResults": "Matching sections", "settingsSearchResults": "Matching sections",
@@ -2354,6 +2410,10 @@
"appearanceInterfaceSection": "INTERFACE", "appearanceInterfaceSection": "INTERFACE",
"appearanceInterfaceTitle": "Interface", "appearanceInterfaceTitle": "Interface",
"appearanceChartsSection": "CHARTS", "appearanceChartsSection": "CHARTS",
"cacheSectionBehavior": "Behavior",
"cacheSectionMemory": "Memory and limits",
"appearanceChartsTitle": "Charts",
"appearanceTablesTitle": "Tables and logs",
"appearanceDataSection": "TABLES / LOGS", "appearanceDataSection": "TABLES / LOGS",
"appearanceDashboardSection": "DASHBOARD", "appearanceDashboardSection": "DASHBOARD",
"resetSection": "Reset section", "resetSection": "Reset section",
@@ -2388,6 +2448,10 @@
"buttonStyleNormal": "Normal", "buttonStyleNormal": "Normal",
"buttonStyleCompact": "Compact", "buttonStyleCompact": "Compact",
"buttonStyleHelp": "Normal uses the refreshed controls; Compact restores the smaller source-style buttons across all actions and dialog tabs.", "buttonStyleHelp": "Normal uses the refreshed controls; Compact restores the smaller source-style buttons across all actions and dialog tabs.",
"interfaceIcons": "Show SVG interface icons",
"interfaceIconsHelp": "Adds semantic SVG icons to navigation, actions, tabs and dialogs.",
"logoAnimation": "Animate logo",
"logoAnimationHelp": "Plays short, subtle animations of the sidebar logo from time to time and on hover.",
"entityEnableHint": "Controls whether this item is active.", "entityEnableHint": "Controls whether this item is active.",
"setupCommandExample": "Standard service installation example:", "setupCommandExample": "Standard service installation example:",
"certificateRuntimeTitle": "ACME runtime", "certificateRuntimeTitle": "ACME runtime",
@@ -2627,7 +2691,7 @@
"backendWidgetInFlight": "In flight", "backendWidgetInFlight": "In flight",
"backendWidgetErrorsRate": "Recent errors/s", "backendWidgetErrorsRate": "Recent errors/s",
"backendWidgetErrorPercent": "Recent failed requests", "backendWidgetErrorPercent": "Recent failed requests",
"backendWidgetProbeCount": "Probed healthy origins", "backendWidgetProbeCount": "Health Origins",
"backendWidgetH2c": "H2C origins", "backendWidgetH2c": "H2C origins",
"backendWidgetModeUnknown": "Unknown", "backendWidgetModeUnknown": "Unknown",
"vhProtocolCompatibilityWarning": "Compatibility mode: saved HTTP/2 HTTPS origins retain the previous H2/H1 TLS fallback. Choose H2 only if HTTP/1.1 must be rejected.", "vhProtocolCompatibilityWarning": "Compatibility mode: saved HTTP/2 HTTPS origins retain the previous H2/H1 TLS fallback. Choose H2 only if HTTP/1.1 must be rejected.",
@@ -2735,7 +2799,7 @@
"overviewLayoutTypeFilter": "Filter blocks by type", "overviewLayoutTypeFilter": "Filter blocks by type",
"overviewLayoutUnlock": "Unlock layout", "overviewLayoutUnlock": "Unlock layout",
"overviewLayoutLock": "Lock and save", "overviewLayoutLock": "Lock and save",
"overviewLayoutInlineHint": "Layout unlocked: reorder blocks within groups of similar size, hide with ×, then save or cancel. The grid aligns card heights automatically.", "overviewLayoutInlineHint": "Layout unlocked: grab a block by its ⠿ handle and drag it — the dashed frame shows where it will land (Esc cancels). Resize with − / +; arrow keys on the handle move a block from the keyboard. “Fill gaps” packs blocks into empty space once.",
"overviewLayoutInlineDrag": "Drag to move", "overviewLayoutInlineDrag": "Drag to move",
"overviewLayoutInlineSave": "Save and lock", "overviewLayoutInlineSave": "Save and lock",
"overviewLayoutInlineCancel": "Cancel changes", "overviewLayoutInlineCancel": "Cancel changes",
@@ -2758,6 +2822,22 @@
"overviewExtraCacheMissRateDetail": "Rate of cache misses.", "overviewExtraCacheMissRateDetail": "Rate of cache misses.",
"overviewExtraBackendActive": "In-flight backend requests", "overviewExtraBackendActive": "In-flight backend requests",
"overviewExtraBackendActiveDetail": "Current HTTP upstream requests across backends.", "overviewExtraBackendActiveDetail": "Current HTTP upstream requests across backends.",
"overviewExtraBackendsHealthy": "Healthy backends",
"overviewExtraBackendsHealthyDescription": "Monitored backend members that currently pass health checks, with the names of failing members.",
"overviewExtraBackendsNone": "No monitored backends",
"overviewExtraBackendsDown": "Down",
"overviewExtraBackendsAllUp": "All monitored backends are up",
"overviewExtraErrorShare": "Error share",
"overviewExtraErrorShareDescription": "Share of HTTP requests that ended with an error in the last sampling interval.",
"overviewExtraErrorShareDetail": "Errors / requests",
"overviewExtraRejectedRate": "Rejected by limits / s",
"overviewExtraRejectedRateDescription": "Requests and connections rejected by concurrency limits. Sustained values suggest raising max-connections or max-inflight.",
"overviewExtraRejectedDetail": "Total requests / connections",
"overviewExtraCertExpiry": "Nearest certificate expiry",
"overviewExtraCertExpiryDescription": "Days until the earliest installed certificate expires; the bar is full at 90 days or more.",
"overviewExtraCertNone": "No certificates installed",
"overviewExtraCertCount": "certificates",
"overviewExtraDaysUnit": "days",
"overviewWideProtocolRates": "HTTP/1.1 · HTTP/2 · HTTP/3 traffic", "overviewWideProtocolRates": "HTTP/1.1 · HTTP/2 · HTTP/3 traffic",
"overviewWideProtocolRatesDetail": "Requests per second by frontend protocol.", "overviewWideProtocolRatesDetail": "Requests per second by frontend protocol.",
"overviewWideCpu": "System and process CPU", "overviewWideCpu": "System and process CPU",
@@ -2810,11 +2890,35 @@
"overviewTransformErrors": "Errors", "overviewTransformErrors": "Errors",
"overviewMeterNoLimit": "No configured limit", "overviewMeterNoLimit": "No configured limit",
"overviewCacheHitsMisses": "HIT {hits}, MISS {misses}", "overviewCacheHitsMisses": "HIT {hits}, MISS {misses}",
"overviewSamplesRemember": "Remember choice",
"overviewSamplesSaved": "Saved default",
"overviewSamplesTemporary": "For this visit only",
"overviewSamplesStorageUnavailable": "Could not save in this browser", "overviewSamplesStorageUnavailable": "Could not save in this browser",
"overviewSamplesTitle": "Overview chart samples", "overviewSamplesTitle": "Overview chart samples",
"appHealthTitle": "Application status",
"appHealthSelfCheck": "SELF-CHECK",
"appHealthChecking": "Checking application status",
"appHealthOk": "All systems operational",
"appHealthWarning": "Attention required",
"appHealthError": "Problem detected",
"appHealthSidebarChecking": "CHECKING",
"appHealthSidebarOk": "APPLICATION OK",
"appHealthSidebarWarning": "APPLICATION WARNING",
"appHealthSidebarError": "APPLICATION ERROR",
"appHealthUpdated": "Updated {time}",
"appHealthNoStatus": "No current status",
"appHealthApi": "API",
"appHealthApiOk": "Live application status is responding.",
"appHealthApiError": "No current status from the application.",
"appHealthListeners": "Listeners",
"appHealthListenersOk": "Active listeners match the desired configuration.",
"appHealthListenersRestart": "Listener changes are pending; restart RustProxy to apply them.",
"appHealthBackends": "Backends",
"appHealthBackendsNone": "No backend pools are configured.",
"appHealthBackendsOk": "All active backend pools are ready.",
"appHealthBackendsWarning": "{down} down, {checking} checking: {members}.",
"appHealthBackendsError": "Unavailable pools: {pools}.",
"appHealthBackendsAffected": "Affected backends: {members}.",
"appHealthLogging": "Logging",
"appHealthLoggingOk": "Logging pipeline reports no queue drops or write/send errors.",
"appHealthLoggingWarning": "{dropped} queued entries dropped, {errors} write/send errors since start.",
"webMetricStatus": "WWW HTTP status", "webMetricStatus": "WWW HTTP status",
"webMetricStatusHelp": "Completed Web Server status classes, all-time since reload.", "webMetricStatusHelp": "Completed Web Server status classes, all-time since reload.",
"webMetricKind": "Static vs PHP", "webMetricKind": "Static vs PHP",
@@ -2934,11 +3038,13 @@
"certificateInspectKept": "Certificate read; your SNI names were preserved.", "certificateInspectKept": "Certificate read; your SNI names were preserved.",
"certificateInspectNoNames": "Certificate contains no DNS SAN names.", "certificateInspectNoNames": "Certificate contains no DNS SAN names.",
"certificateInspectFailed": "Cannot read certificate. Check the file and its permissions, then read again.", "certificateInspectFailed": "Cannot read certificate. Check the file and its permissions, then read again.",
"pathPermissionsOptions": "Options and help" "pathPermissionsOptions": "Options and help",
}, "overviewLayoutNarrow": "Decrease card width",
"overviewLayoutNarrow": "Decrease card width", "overviewLayoutWiden": "Increase card width",
"overviewLayoutWiden": "Increase card width", "overviewLayoutWidth": "Card width in 12-column grid",
"overviewLayoutWidth": "Card width in 12-column grid", "dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again.",
"overviewLayoutInlineHint": "Layout unlocked: move any card anywhere; use − / + to resize (2, 3, 4, 6 or 12 columns). Save and lock or cancel to revert.", "overviewLayoutInlineDragging": "Dragging: drop the block on the dashed frame. Esc restores its previous position.",
"dashboardPresetsConflict": "Preset already exists or was removed. Refresh and try again." "overviewLayoutInlineCompact": "Fill gaps",
} "overviewLayoutInlineCompactHint": "Move blocks into empty grid space once (you can still cancel before saving)"
}
}
+131 -25
View File
@@ -253,7 +253,8 @@
"certificateSafety": "Certyfikaty samopodpisane służą do testów. Certyfikat nie tworzy trasy. Zmiana dotyczy nowych połączeń TLS.", "certificateSafety": "Certyfikaty samopodpisane służą do testów. Certyfikat nie tworzy trasy. Zmiana dotyczy nowych połączeń TLS.",
"wsActive": "Tunele WebSocket", "wsActive": "Tunele WebSocket",
"h2Requests": "Żądania HTTP/2", "h2Requests": "Żądania HTTP/2",
"h2ActiveConnections": "Aktywne połączenia H2", "h2ActiveConnections": "Połączenia H2",
"h3ActiveConnections": "Połączenia H3",
"tlsFailures": "Błędy handshake TLS", "tlsFailures": "Błędy handshake TLS",
"tlsListener": "Listener TLS", "tlsListener": "Listener TLS",
"notEnabled": "Wyłączony", "notEnabled": "Wyłączony",
@@ -395,6 +396,7 @@
"vhImport": "Wczytaj do edytora", "vhImport": "Wczytaj do edytora",
"vhMigrationHint": "Migracja jest atomowa. Trasy tych samych hostów i backendu są grupowane; niejednoznaczne przypisanie hostów jest odrzucane.", "vhMigrationHint": "Migracja jest atomowa. Trasy tych samych hostów i backendu są grupowane; niejednoznaczne przypisanie hostów jest odrzucane.",
"vhEditor": "Konfiguracja vhosta", "vhEditor": "Konfiguracja vhosta",
"vhCancel": "Anuluj",
"vhReset": "Odrzuć zmiany", "vhReset": "Odrzuć zmiany",
"vhSafetyHint": "Prywatny IP nie zastępuje uwierzytelniania. Zachowaj firewall i ACL. Preload HSTS jest opcjonalny; cookie affinity nie jest sesją logowania.", "vhSafetyHint": "Prywatny IP nie zastępuje uwierzytelniania. Zachowaj firewall i ACL. Preload HSTS jest opcjonalny; cookie affinity nie jest sesją logowania.",
"vhId": "Identyfikator vhosta", "vhId": "Identyfikator vhosta",
@@ -457,6 +459,9 @@
"vhHttp": "Status HTTP + czas odpowiedzi", "vhHttp": "Status HTTP + czas odpowiedzi",
"vhHealthPath": "Ścieżka testu HTTP", "vhHealthPath": "Ścieżka testu HTTP",
"vhHealthHost": "Własny Host testu HTTP (opcjonalny)", "vhHealthHost": "Własny Host testu HTTP (opcjonalny)",
"vhHealthPort": "Port testu zdrowia (opcjonalny)",
"vhHealthPortPlaceholder": "Port backendu",
"vhHealthExpectBody": "Oczekiwany tekst w odpowiedzi HTTP (opcjonalny)",
"vhInterval": "Interwał (ms)", "vhInterval": "Interwał (ms)",
"vhTimeout": "Limit czasu (ms)", "vhTimeout": "Limit czasu (ms)",
"vhSlow": "Próg wolnej odpowiedzi (ms; 0 = wył.)", "vhSlow": "Próg wolnej odpowiedzi (ms; 0 = wył.)",
@@ -464,7 +469,7 @@
"vhRise": "Sukcesy przed przywróceniem", "vhRise": "Sukcesy przed przywróceniem",
"vhStatusMin": "Minimalny status HTTP", "vhStatusMin": "Minimalny status HTTP",
"vhStatusMax": "Maksymalny status HTTP", "vhStatusMax": "Maksymalny status HTTP",
"vhHealthNote": "Sondy HTTP używają HTTP/1.1 i mierzą czas do nagłówków, nie czas całego body. Gdy pole Host jest puste, RustProxy automatycznie używa pierwszej dokładnej domeny DNS vhosta; jeśli jej nie ma, używa hosta backendu. Dla usług tylko h2c użyj TCP. Nowe backendy czekają na udaną sondę. Active health jest niezależny od passive health.", "vhHealthNote": "Sondy HTTP używają HTTP/1.1 i mierzą czas do nagłówków, nie czas całego body. Gdy pole Host jest puste, RustProxy automatycznie używa pierwszej dokładnej domeny DNS vhosta; jeśli jej nie ma, używa hosta backendu. Dla usług tylko h2c użyj TCP. Nowe backendy czekają na udaną sondę. Active health jest niezależny od passive health. Port testu zdrowia pozwala sprawdzać inny port niż ruch (np. port statusu 8079 dla backendu na porcie 80); host, TLS i ograniczenia adresów pozostają te same. Oczekiwany tekst oznacza backend jako niedostępny, jeśli pierwsze 64 KiB odpowiedzi testu HTTP go nie zawiera.",
"vhPassiveHealth": "Pasywne zdrowie backendu", "vhPassiveHealth": "Pasywne zdrowie backendu",
"vhPassiveEnabled": "Pozwól, aby błędy zwykłych requestów oznaczały backend jako niedostępny", "vhPassiveEnabled": "Pozwól, aby błędy zwykłych requestów oznaczały backend jako niedostępny",
"vhPassiveHelp": "Domyślnie wyłączone. Błędy nadal są logowane i zliczane, ale zwykły ruch użytkowników nie zmienia stanu backendu bez świadomego włączenia.", "vhPassiveHelp": "Domyślnie wyłączone. Błędy nadal są logowane i zliczane, ale zwykły ruch użytkowników nie zmienia stanu backendu bez świadomego włączenia.",
@@ -634,13 +639,13 @@
"certCheckDomains": "Domeny do sprawdzenia (po przecinku)", "certCheckDomains": "Domeny do sprawdzenia (po przecinku)",
"certAcmeLater": "Wydane certyfikaty trafiaja do biblioteki i sa przypisywane do vhostow.", "certAcmeLater": "Wydane certyfikaty trafiaja do biblioteki i sa przypisywane do vhostow.",
"certAdvanced": "Istniejące pliki / zaawansowane", "certAdvanced": "Istniejące pliki / zaawansowane",
"certLegacyNames": "Stare nazwy SNI (opcjonalne; zalecane przypisanie w vhoście)", "certLegacyNames": "Nazwy SNI (opcjonalne; używane, gdy vhost ma Globalne SNI / certyfikat domyślny)",
"certAssigned": "Przypisane vhosty", "certAssigned": "Przypisane vhosty",
"certUnassigned": "Nieprzypisany", "certUnassigned": "Nieprzypisany",
"certInUse": "Certyfikat jest używany. Zmień przypisania vhostów i certyfikat domyślny przed usunięciem.", "certInUse": "Certyfikat jest używany. Zmień przypisania vhostów i certyfikat domyślny przed usunięciem.",
"certDuplicate": "Ten identyfikator certyfikatu istnieje. Do bezpiecznej rotacji użyj nowego.", "certDuplicate": "Ten identyfikator certyfikatu istnieje. Do bezpiecznej rotacji użyj nowego.",
"certManage": "Zarządzaj biblioteką certyfikatów", "certManage": "Zarządzaj biblioteką certyfikatów",
"certSelectHint": "Wybierz zaimportowany certyfikat. Import i rotacja są w zakładce Certyfikaty. Przypisania są sprawdzane względem nazw vhosta.", "certSelectHint": "Wybierz zaimportowany certyfikat, aby użyć go dla wszystkich nazw tego vhosta. Zostaw \"Globalne SNI / certyfikat domyślny\", a RustProxy sam dobierze certyfikat do żądanej nazwy (SNI) spośród zapisanych certyfikatów, których nazwy SNI lub wpisy SAN ją zawierają — przydatne, gdy jeden vhost obsługuje domeny z różnymi certyfikatami. Import i rotacja są w zakładce Certyfikaty. Przypisania są sprawdzane względem nazw vhosta.",
"vhTinyauth": "Uwierzytelnianie", "vhTinyauth": "Uwierzytelnianie",
"vhTabAuth": "Uwierzytelnianie", "vhTabAuth": "Uwierzytelnianie",
"taUrl": "URL Tinyauth / punkt weryfikacji", "taUrl": "URL Tinyauth / punkt weryfikacji",
@@ -942,6 +947,7 @@
"docsWrites": "Włącz operacje zapisu", "docsWrites": "Włącz operacje zapisu",
"docsWriteConfirm": "Ta operacja naprawdę zmieni RustProxy. Kontynuować?", "docsWriteConfirm": "Ta operacja naprawdę zmieni RustProxy. Kontynuować?",
"docsExpired": "Sesja wygasła. Zaloguj się ponownie.", "docsExpired": "Sesja wygasła. Zaloguj się ponownie.",
"docsImage": "Zrzut ekranu",
"docsError": "Nie udało się wczytać dokumentacji.", "docsError": "Nie udało się wczytać dokumentacji.",
"docsAccount": "Zalogowano jako", "docsAccount": "Zalogowano jako",
"docsReadOnly": "Domyślnie wykonywane są tylko odczyty. Strumienie SSE oraz logowanie i setup nie są tu uruchamiane.", "docsReadOnly": "Domyślnie wykonywane są tylko odczyty. Strumienie SSE oraz logowanie i setup nie są tu uruchamiane.",
@@ -1673,6 +1679,7 @@
"dockerSearchPlaceholder": "Szukaj usługi, hosta, aliasu lub agenta...", "dockerSearchPlaceholder": "Szukaj usługi, hosta, aliasu lub agenta...",
"dockerShowHidden": "Pokaż ukryte", "dockerShowHidden": "Pokaż ukryte",
"dockerHideHidden": "Ukryj ukryte", "dockerHideHidden": "Ukryj ukryte",
"dockerHideDisabled": "Ukryj wyłączone",
"dockerHideVhost": "Ukryj z widoku", "dockerHideVhost": "Ukryj z widoku",
"dockerUnhideVhost": "Pokaż w widoku", "dockerUnhideVhost": "Pokaż w widoku",
"dockerCertificate": "Certyfikat frontendu", "dockerCertificate": "Certyfikat frontendu",
@@ -1721,7 +1728,16 @@
"dockerAliasesShort": "aliasów", "dockerAliasesShort": "aliasów",
"dockerAgentDetails": "Szczegóły agenta", "dockerAgentDetails": "Szczegóły agenta",
"dockerServices": "Usługi", "dockerServices": "Usługi",
"dockerOpenApp": "Otwórz aplikację", "dockerOpenApp": "Otwórz",
"autoIdHint": "Generowane automatycznie z nazwy. Zmień, aby ustawić własne ID.",
"autoIdReset": "Generuj automatycznie",
"autoIdChange": "Zmień",
"autoIdPending": "Generowane z nazwy",
"autoIdBadge": "auto",
"vhCertificateUncoveredPrompt": "Certyfikat \"{certificate}\" nie obejmuje: {hosts}.\n\nZapisać mimo to i wymusić ten certyfikat dla tych nazw? Przeglądarki pokażą ostrzeżenie o niezgodności nazwy. Wybierz Anuluj, aby wskazać pasujący certyfikat.",
"autoCertSniHint": "Żaden pojedynczy certyfikat nie obejmuje wszystkich nazw, ale każda nazwa jest objęta jakimś zapisanym certyfikatem. Zostaw \"Globalne SNI / certyfikat domyślny\", a RustProxy dobierze właściwy certyfikat dla każdej nazwy (SNI).",
"copyEntry": "Kopiuj",
"autoCertHint": "Certyfikat \"{id}\" został wybrany automatycznie, bo pokrywa wszystkie nazwy hostów. Możesz wybrać inny.",
"dockerLiveAccessLogsHelp": "Logi żądań HTTP dla tego Docker vhosta, nie stdout/stderr kontenera.", "dockerLiveAccessLogsHelp": "Logi żądań HTTP dla tego Docker vhosta, nie stdout/stderr kontenera.",
"networkInterfacesHistory": "Ruch według interfejsów", "networkInterfacesHistory": "Ruch według interfejsów",
"networkInterfaceRate": "RX / TX", "networkInterfaceRate": "RX / TX",
@@ -1873,9 +1889,9 @@
"tunnelKillReasonDefault": "panel administracyjny", "tunnelKillReasonDefault": "panel administracyjny",
"listenerH2c": "h2c HTTP/2 bez TLS (prior knowledge + Upgrade z HTTP/1.1)", "listenerH2c": "h2c HTTP/2 bez TLS (prior knowledge + Upgrade z HTTP/1.1)",
"vhWebsocketRfc8441": "RFC 8441 Extended CONNECT przez HTTP/2", "vhWebsocketRfc8441": "RFC 8441 Extended CONNECT przez HTTP/2",
"wsH1": "H1 Upgrade", "wsH1": "H1",
"wsH2Rfc8441": "H2 RFC 8441", "wsH2Rfc8441": "H2",
"wsH3Rfc9220": "H3 RFC 9220", "wsH3Rfc9220": "H3",
"wsEstablished": "Utworzone", "wsEstablished": "Utworzone",
"httpProtocolMix": "Miks protokołów HTTP", "httpProtocolMix": "Miks protokołów HTTP",
"wsProtocolMix": "Miks protokołów WebSocket", "wsProtocolMix": "Miks protokołów WebSocket",
@@ -2250,11 +2266,21 @@
"about": "O projekcie", "about": "O projekcie",
"aboutTitle": "O RustProxy", "aboutTitle": "O RustProxy",
"aboutAuthor": "Autor", "aboutAuthor": "Autor",
"aboutLicenseText": "Pełny tekst licencji",
"aboutDocumentation": "Dokumentacja i dokumentacja API",
"aboutLicensePointWarranty": "Oprogramowanie jest dostarczane bez jakiejkolwiek gwarancji.",
"aboutLicensePointNetwork": "Jeśli udostępniasz zmodyfikowaną wersję jako usługę sieciową, jej użytkownicy muszą mieć dostęp do zmodyfikowanego kodu źródłowego.",
"aboutLicensePointShare": "Rozpowszechniane kopie i wersje zmodyfikowane muszą pozostać na tej samej licencji i zawierać kod źródłowy.",
"aboutLicensePointUse": "Możesz go używać w dowolnym celu, także komercyjnie.",
"aboutLicenseTitle": "Licencja w skrócie",
"aboutSpdx": "Identyfikator SPDX",
"aboutCopyright": "Prawa autorskie",
"aboutTagline": "Reverse proxy, serwer WWW, menedżer certyfikatów i monitor ruchu w jednej usłudze z wbudowanym panelem administracyjnym.",
"aboutLicense": "Licencja", "aboutLicense": "Licencja",
"aboutLicenseValue": "GNU AGPL v3", "aboutLicenseValue": "GNU AGPL v3.0 lub nowsza",
"aboutOpenSourceText": "RustProxy jest projektem open source. Kod źródłowy jest publicznie dostępny do wglądu, rozwoju i współtworzenia.", "aboutOpenSourceText": "RustProxy jest wolnym oprogramowaniem o otwartym kodzie. Możesz go używać, analizować, modyfikować i rozpowszechniać na warunkach licencji GNU Affero General Public License w wersji 3 lub dowolnej późniejszej.",
"aboutRepository": "Oficjalne repozytorium źródłowe", "aboutRepository": "Kod źródłowy",
"aboutPackagesRepository": "Oficjalne repozytorium pakietów", "aboutPackagesRepository": "Strona i pakiety",
"aboutPythonRuntime": "Środowisko Python certyfikatów", "aboutPythonRuntime": "Środowisko Python certyfikatów",
"runtimePython": "Python", "runtimePython": "Python",
"runtimePip": "pip", "runtimePip": "pip",
@@ -2266,6 +2292,28 @@
"vhPoolRecycleSeconds": "Maks. wiek puli upstream (sekundy, 0 = wył.)", "vhPoolRecycleSeconds": "Maks. wiek puli upstream (sekundy, 0 = wył.)",
"vhHttp1MaxBuffer": "Maks. bufor odczytu HTTP/1 upstream (bajty)", "vhHttp1MaxBuffer": "Maks. bufor odczytu HTTP/1 upstream (bajty)",
"vhPoolRecycleHelp": "Odświeżanie podmienia tylko generację klienta Hyper danego backendu; aktywne wymiany kończą się normalnie. Domyślne limity ograniczają retencję pamięci puli/TLS bez przeładowania vhosta ani cache. Limit żądań: 0 lub 1000..10000000; limit czasu: 0 lub 60..604800.", "vhPoolRecycleHelp": "Odświeżanie podmienia tylko generację klienta Hyper danego backendu; aktywne wymiany kończą się normalnie. Domyślne limity ograniczają retencję pamięci puli/TLS bez przeładowania vhosta ani cache. Limit żądań: 0 lub 1000..10000000; limit czasu: 0 lub 60..604800.",
"backendTinyTitle": "Backendy (kompaktowe)",
"backendTinyDescription": "Kompaktowa tabela diagnostyki backendów z wyszukiwaniem i sortowaniem (dotychczasowy widok 14 kolumn).",
"backendOverviewTitle": "Backendy",
"backendOverviewDescription": "Stan backendów ze znacznikami statusu, udziałem błędów, trendem opóźnień i rozwijanymi szczegółami generacji klienta; wyszukiwanie, sortowanie, filtry i wybór kolumn.",
"backendFilterAll": "Wszystkie",
"backendFilterOther": "Inne",
"backendFilterLabel": "Filtruj backendy według statusu",
"backendSummary": "{up} z {total} działa",
"backendLastEvent": "Ostatnie zdarzenie",
"backendEventFailed": "Awaria",
"backendEventRecovered": "Przywrócony",
"backendEventNone": "Brak awarii",
"backendAgo": "{value} temu",
"backendDetailsToggle": "Pokaż szczegóły generacji klienta",
"processOverviewTitle": "Proces",
"processOverviewDescription": "Obciążenie procesu, deskryptory plików, podział pamięci oraz historia CPU i RSS (pamięć cache jest w karcie RAM cache).",
"processClassicTitle": "Proces prosty",
"processLoadTitle": "Obciążenie",
"processMemoryTitle": "Pamięć",
"processFdHistory": "Historia deskryptorów",
"processThreadHistory": "Historia wątków systemowych",
"backendRequestsTrend": "Żądania w kolejnych odświeżeniach",
"backendSearch": "Szukaj backendów", "backendSearch": "Szukaj backendów",
"backendSearchPlaceholder": "Szukaj vhosta, backendu lub stanu", "backendSearchPlaceholder": "Szukaj vhosta, backendu lub stanu",
"backendSortBy": "Sortuj według", "backendSortBy": "Sortuj według",
@@ -2346,6 +2394,14 @@
"chartStyleMonochrome": "Monochromatyczny", "chartStyleMonochrome": "Monochromatyczny",
"chartStyleWarm": "Ciepły", "chartStyleWarm": "Ciepły",
"chartStyleCool": "Chłodny", "chartStyleCool": "Chłodny",
"chartStyleSunset": "Zachód słońca",
"chartStyleAurora": "Zorza",
"chartStyleOcean": "Ocean",
"chartStyleForest": "Las",
"chartStylePastel": "Pastelowy",
"chartStyleTerminal": "Terminal",
"chartStyleBlueprint": "Rysunek techniczny",
"chartStyleColorblind": "Przyjazny daltonistom",
"settingsSearch": "Znajdź ustawienia", "settingsSearch": "Znajdź ustawienia",
"settingsSearchPlaceholder": "Szukaj ustawień, sekcji i opcji...", "settingsSearchPlaceholder": "Szukaj ustawień, sekcji i opcji...",
"settingsSearchResults": "Pasujące sekcje", "settingsSearchResults": "Pasujące sekcje",
@@ -2354,6 +2410,10 @@
"appearanceInterfaceSection": "INTERFEJS", "appearanceInterfaceSection": "INTERFEJS",
"appearanceInterfaceTitle": "Interfejs", "appearanceInterfaceTitle": "Interfejs",
"appearanceChartsSection": "WYKRESY", "appearanceChartsSection": "WYKRESY",
"cacheSectionBehavior": "Działanie",
"cacheSectionMemory": "Pamięć i limity",
"appearanceChartsTitle": "Wykresy",
"appearanceTablesTitle": "Tabele i logi",
"appearanceDataSection": "TABELE / LOGI", "appearanceDataSection": "TABELE / LOGI",
"appearanceDashboardSection": "DASHBOARD", "appearanceDashboardSection": "DASHBOARD",
"resetSection": "Resetuj sekcję", "resetSection": "Resetuj sekcję",
@@ -2388,6 +2448,10 @@
"buttonStyleNormal": "Normalny", "buttonStyleNormal": "Normalny",
"buttonStyleCompact": "Kompaktowy", "buttonStyleCompact": "Kompaktowy",
"buttonStyleHelp": "Normalny używa odświeżonych przycisków; Kompaktowy przywraca mniejsze przyciski ze źródłowego interfejsu we wszystkich akcjach i zakładkach modali.", "buttonStyleHelp": "Normalny używa odświeżonych przycisków; Kompaktowy przywraca mniejsze przyciski ze źródłowego interfejsu we wszystkich akcjach i zakładkach modali.",
"interfaceIcons": "Pokaż ikony SVG interfejsu",
"interfaceIconsHelp": "Dodaje semantyczne ikony SVG do nawigacji, akcji, zakładek i okien dialogowych.",
"logoAnimation": "Animuj logo",
"logoAnimationHelp": "Od czasu do czasu (i po najechaniu kursorem) odtwarza krótkie, subtelne animacje logo w panelu bocznym.",
"entityEnableHint": "Określa, czy ten element jest aktywny.", "entityEnableHint": "Określa, czy ten element jest aktywny.",
"setupCommandExample": "Przykład dla standardowej instalacji usługi:", "setupCommandExample": "Przykład dla standardowej instalacji usługi:",
"certificateRuntimeTitle": "Środowisko ACME", "certificateRuntimeTitle": "Środowisko ACME",
@@ -2627,7 +2691,7 @@
"backendWidgetInFlight": "W toku", "backendWidgetInFlight": "W toku",
"backendWidgetErrorsRate": "Błędy/s", "backendWidgetErrorsRate": "Błędy/s",
"backendWidgetErrorPercent": "Błędne żądania w ostatnim okresie", "backendWidgetErrorPercent": "Błędne żądania w ostatnim okresie",
"backendWidgetProbeCount": "Zdrowe backendy z sondami", "backendWidgetProbeCount": "Zdrowe backendy",
"backendWidgetH2c": "Backendy h2c", "backendWidgetH2c": "Backendy h2c",
"backendWidgetModeUnknown": "Nieznane", "backendWidgetModeUnknown": "Nieznane",
"vhProtocolCompatibilityWarning": "Tryb zgodności: zapisane wcześniej backendy HTTPS HTTP/2 zachowują fallback TLS H2/H1. Wybierz „Tylko H2”, jeżeli HTTP/1.1 ma być odrzucony.", "vhProtocolCompatibilityWarning": "Tryb zgodności: zapisane wcześniej backendy HTTPS HTTP/2 zachowują fallback TLS H2/H1. Wybierz „Tylko H2”, jeżeli HTTP/1.1 ma być odrzucony.",
@@ -2735,7 +2799,7 @@
"overviewLayoutTypeFilter": "Filtruj bloki według typu", "overviewLayoutTypeFilter": "Filtruj bloki według typu",
"overviewLayoutUnlock": "Odblokuj układ", "overviewLayoutUnlock": "Odblokuj układ",
"overviewLayoutLock": "Zablokuj i zapisz", "overviewLayoutLock": "Zablokuj i zapisz",
"overviewLayoutInlineHint": "Układ odblokowany: przesuwaj bloki w obrębie grup podobnych rozmiarów, ukrywaj przyciskiem ×, a potem zapisz lub anuluj. Siatka automatycznie wyrównuje wysokości.", "overviewLayoutInlineHint": "Układ odblokowany: chwyć blok za uchwyt ⠿ i przeciągnij — przerywana ramka pokazuje, gdzie wyląduje (Esc anuluje). Szerokość zmieniasz przyciskami − / +, strzałki na uchwycie przesuwają blok z klawiatury. „Uzupełnij luki” jednorazowo dosuwa bloki w puste miejsca.",
"overviewLayoutInlineDrag": "Przeciągnij, aby przesunąć", "overviewLayoutInlineDrag": "Przeciągnij, aby przesunąć",
"overviewLayoutInlineSave": "Zapisz i zablokuj", "overviewLayoutInlineSave": "Zapisz i zablokuj",
"overviewLayoutInlineCancel": "Anuluj zmiany", "overviewLayoutInlineCancel": "Anuluj zmiany",
@@ -2758,6 +2822,22 @@
"overviewExtraCacheMissRateDetail": "Tempo nietrafień w cache.", "overviewExtraCacheMissRateDetail": "Tempo nietrafień w cache.",
"overviewExtraBackendActive": "Żądania do backendów", "overviewExtraBackendActive": "Żądania do backendów",
"overviewExtraBackendActiveDetail": "Suma aktualnych żądań do backendów HTTP.", "overviewExtraBackendActiveDetail": "Suma aktualnych żądań do backendów HTTP.",
"overviewExtraBackendsHealthy": "Sprawne backendy",
"overviewExtraBackendsHealthyDescription": "Monitorowane backendy, które obecnie przechodzą testy zdrowia, wraz z nazwami niedziałających.",
"overviewExtraBackendsNone": "Brak monitorowanych backendów",
"overviewExtraBackendsDown": "Niedostępne",
"overviewExtraBackendsAllUp": "Wszystkie monitorowane backendy działają",
"overviewExtraErrorShare": "Udział błędów",
"overviewExtraErrorShareDescription": "Odsetek żądań HTTP zakończonych błędem w ostatnim interwale próbkowania.",
"overviewExtraErrorShareDetail": "Błędy / żądania",
"overviewExtraRejectedRate": "Odrzucone przez limity / s",
"overviewExtraRejectedRateDescription": "Żądania i połączenia odrzucone przez limity współbieżności. Stałe wartości oznaczają, że warto podnieść max-connections lub max-inflight.",
"overviewExtraRejectedDetail": "Łącznie żądania / połączenia",
"overviewExtraCertExpiry": "Najbliższe wygaśnięcie certyfikatu",
"overviewExtraCertExpiryDescription": "Dni do wygaśnięcia najwcześniej kończącego się certyfikatu; pasek jest pełny przy 90 dniach lub więcej.",
"overviewExtraCertNone": "Brak zainstalowanych certyfikatów",
"overviewExtraCertCount": "certyfikatów",
"overviewExtraDaysUnit": "dni",
"overviewWideProtocolRates": "Ruch HTTP/1.1 · HTTP/2 · HTTP/3", "overviewWideProtocolRates": "Ruch HTTP/1.1 · HTTP/2 · HTTP/3",
"overviewWideProtocolRatesDetail": "Tempo żądań według protokołu frontendu.", "overviewWideProtocolRatesDetail": "Tempo żądań według protokołu frontendu.",
"overviewWideCpu": "CPU systemu i procesu", "overviewWideCpu": "CPU systemu i procesu",
@@ -2810,11 +2890,35 @@
"overviewTransformErrors": "Błędy", "overviewTransformErrors": "Błędy",
"overviewMeterNoLimit": "Brak skonfigurowanego limitu", "overviewMeterNoLimit": "Brak skonfigurowanego limitu",
"overviewCacheHitsMisses": "HIT {hits}, MISS {misses}", "overviewCacheHitsMisses": "HIT {hits}, MISS {misses}",
"overviewSamplesRemember": "Zapamiętaj wybór",
"overviewSamplesSaved": "Ustawienie zapisane",
"overviewSamplesTemporary": "Tylko do odświeżenia strony",
"overviewSamplesStorageUnavailable": "Nie można zapisać w przeglądarce", "overviewSamplesStorageUnavailable": "Nie można zapisać w przeglądarce",
"overviewSamplesTitle": "Próbki wykresów na stronie głównej", "overviewSamplesTitle": "Próbki wykresów na stronie głównej",
"appHealthTitle": "Status aplikacji",
"appHealthSelfCheck": "SELF-CHECK",
"appHealthChecking": "Sprawdzanie statusu aplikacji",
"appHealthOk": "Wszystkie systemy działają",
"appHealthWarning": "Wymaga uwagi",
"appHealthError": "Wykryto problem",
"appHealthSidebarChecking": "SPRAWDZANIE",
"appHealthSidebarOk": "SYSTEM OK",
"appHealthSidebarWarning": "APLIKACJA: UWAGA",
"appHealthSidebarError": "APLIKACJA: BŁĄD",
"appHealthUpdated": "Aktualizacja: {time}",
"appHealthNoStatus": "Brak aktualnego statusu",
"appHealthApi": "API",
"appHealthApiOk": "Status live aplikacji odpowiada.",
"appHealthApiError": "Brak aktualnego statusu aplikacji.",
"appHealthListeners": "Listenery",
"appHealthListenersOk": "Aktywne listenery są zgodne z żądaną konfiguracją.",
"appHealthListenersRestart": "Zmiany listenerów czekają na zastosowanie; uruchom RustProxy ponownie.",
"appHealthBackends": "Backendy",
"appHealthBackendsNone": "Brak skonfigurowanych pul backendów.",
"appHealthBackendsOk": "Wszystkie aktywne pule backendów są gotowe.",
"appHealthBackendsWarning": "Niedostępne: {down}, sprawdzane: {checking}: {members}.",
"appHealthBackendsError": "Niedostępne pule: {pools}.",
"appHealthBackendsAffected": "Dotyczy backendów: {members}.",
"appHealthLogging": "Logowanie",
"appHealthLoggingOk": "Kolejki logowania bez odrzuceń i błędów zapisu/wysyłki.",
"appHealthLoggingWarning": "Odrzucone wpisy kolejki: {dropped}, błędy zapisu/wysyłki od startu: {errors}.",
"webMetricStatus": "Statusy HTTP WWW", "webMetricStatus": "Statusy HTTP WWW",
"webMetricStatusHelp": "Klasy odpowiedzi serwera WWW od ostatniego resetu.", "webMetricStatusHelp": "Klasy odpowiedzi serwera WWW od ostatniego resetu.",
"webMetricKind": "Static / PHP", "webMetricKind": "Static / PHP",
@@ -2934,11 +3038,13 @@
"certificateInspectKept": "Odczytano certyfikat; zachowano wpisane nazwy SNI.", "certificateInspectKept": "Odczytano certyfikat; zachowano wpisane nazwy SNI.",
"certificateInspectNoNames": "Certyfikat nie zawiera nazw DNS w SAN.", "certificateInspectNoNames": "Certyfikat nie zawiera nazw DNS w SAN.",
"certificateInspectFailed": "Nie można odczytać certyfikatu. Sprawdź plik i jego uprawnienia, a następnie ponów odczyt.", "certificateInspectFailed": "Nie można odczytać certyfikatu. Sprawdź plik i jego uprawnienia, a następnie ponów odczyt.",
"pathPermissionsOptions": "Opcje i pomoc" "pathPermissionsOptions": "Opcje i pomoc",
}, "overviewLayoutNarrow": "Zmniejsz szerokość karty",
"overviewLayoutNarrow": "Zmniejsz szerokość karty", "overviewLayoutWiden": "Zwiększ szerokość karty",
"overviewLayoutWiden": "Zwiększ szerokość karty", "overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn",
"overviewLayoutWidth": "Szerokość karty w siatce 12 kolumn", "dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie.",
"overviewLayoutInlineHint": "Układ odblokowany: przenoś dowolne karty i zmieniaj ich szerokość przyciskami − / + (2, 3, 4, 6 lub 12 kolumn). Zapisz lub anuluj zmiany.", "overviewLayoutInlineDragging": "Przeciąganie: upuść blok w miejscu ramki. Esc przywraca poprzednią pozycję.",
"dashboardPresetsConflict": "Preset już istnieje lub został usunięty. Odśwież listę i spróbuj ponownie." "overviewLayoutInlineCompact": "Uzupełnij luki",
} "overviewLayoutInlineCompactHint": "Dosuń bloki w puste miejsca siatki (jednorazowo, przed zapisaniem możesz to anulować)"
}
}
+3 -5
View File
@@ -13,9 +13,9 @@ from scripts.version import project_version
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
MANAGED = [ MANAGED = [
'src', 'tests', 'scripts', 'tools', 'integrations', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'grafana', 'src', 'tests', 'scripts', 'tools', 'integrations', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'grafana', 'third_party',
'.github', '.cargo', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'rust-toolchain.toml', 'UPDATE_CHECK.toml', '.github', '.cargo', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'rust-toolchain.toml', 'UPDATE_CHECK.toml',
'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'README.md', 'ROADMAP.md', 'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'README.md', 'LICENSE',
'CHANGELOG.md', 'AGENTS.md', '.env.example', '.gitignore', '.dockerignore', 'CHANGELOG.md', 'AGENTS.md', '.env.example', '.gitignore', '.dockerignore',
'Dockerfile', 'make_zip.py', 'Dockerfile', 'make_zip.py',
] ]
@@ -39,9 +39,7 @@ def files() -> list[Path]:
rel = file.relative_to(ROOT) rel = file.relative_to(ROOT)
if any(part in EXCLUDED for part in rel.parts) or file.suffix in ('.pyc', '.pyo', '.zip'): if any(part in EXCLUDED for part in rel.parts) or file.suffix in ('.pyc', '.pyo', '.zip'):
continue continue
# Never ship local environment files from recursively managed trees. # Never ship local .env files; templates such as .env.example are listed explicitly.
# Explicit templates such as .env.example remain safe because they are
# named in MANAGED and do not have the exact basename `.env`.
if rel.name == '.env': if rel.name == '.env':
continue continue
if rel.as_posix() == 'docker-agent/docker-agent': if rel.as_posix() == 'docker-agent/docker-agent':
+1 -1
View File
@@ -1,5 +1,5 @@
-- Named, administrator-scoped Overview presets. Keep this separate from the -- Named, administrator-scoped Overview presets. Keep this separate from the
-- existing 4096-byte appearance preferences document and proxy configuration. -- appearance preferences document and proxy configuration.
CREATE TABLE IF NOT EXISTS dashboard_presets ( CREATE TABLE IF NOT EXISTS dashboard_presets (
administrator_id INTEGER NOT NULL CHECK (administrator_id = 1), administrator_id INTEGER NOT NULL CHECK (administrator_id = 1),
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64), name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64),
@@ -0,0 +1,13 @@
-- Expand administrator UI preference storage. Current dashboard layouts can exceed
-- the original 4096-byte limit once per-widget widths are persisted.
CREATE TABLE admin_preferences_v2 (
administrator_id INTEGER PRIMARY KEY CHECK (administrator_id = 1),
document TEXT NOT NULL CHECK (length(document) BETWEEN 2 AND 65536),
FOREIGN KEY (administrator_id) REFERENCES administrator(id) ON DELETE CASCADE
);
INSERT INTO admin_preferences_v2(administrator_id, document)
SELECT administrator_id, document FROM admin_preferences;
DROP TABLE admin_preferences;
ALTER TABLE admin_preferences_v2 RENAME TO admin_preferences;
+1 -3
View File
@@ -99,9 +99,7 @@ def structural_inventory(paths: list[Path]) -> dict:
families = [tuple(sorted({family for selector in entry['selectors'] families = [tuple(sorted({family for selector in entry['selectors']
for family in VENDOR_SELECTOR.findall(selector)})) for family in VENDOR_SELECTOR.findall(selector)}))
for entry in entries] for entry in entries]
# A browser can reject an entire comma-separated selector list when it # Vendor pseudo-element families legitimately repeat one rule body.
# contains another engine's pseudo-element. Keep one body per compatible
# family, while still rejecting copies within the same family.
vendor_only = (any(families) and all(len(family) <= 1 for family in families) vendor_only = (any(families) and all(len(family) <= 1 for family in families)
and len(families) == len(set(families))) and len(families) == len(set(families)))
repeated_bodies.append({'scope': list(scope), 'rules': entries, repeated_bodies.append({'scope': list(scope), 'rules': entries,
Executable → Regular
+109 -2
View File
@@ -14,6 +14,7 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import re import re
import shutil
import sys import sys
import tempfile import tempfile
import time import time
@@ -25,6 +26,7 @@ import zlib
VERSION = "5.33.1" VERSION = "5.33.1"
DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip" DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip"
PREFIX = f"swagger-ui-{VERSION}/" PREFIX = f"swagger-ui-{VERSION}/"
VENDORED = Path(__file__).resolve().parents[2] / "third_party" / f"swagger-ui-{VERSION}"
MAX_ARCHIVE = 64 * 1024 * 1024 MAX_ARCHIVE = 64 * 1024 * 1024
ASSETS = { ASSETS = {
"swagger-ui-bundle.js": ("dist/swagger-ui-bundle.js", 16 * 1024 * 1024), "swagger-ui-bundle.js": ("dist/swagger-ui-bundle.js", 16 * 1024 * 1024),
@@ -33,7 +35,7 @@ ASSETS = {
LICENSE_MEMBERS = ("LICENSE", "NOTICE", "dist/swagger-ui-bundle.js.LICENSE.txt") LICENSE_MEMBERS = ("LICENSE", "NOTICE", "dist/swagger-ui-bundle.js.LICENSE.txt")
HELP = ("Install Python 3.10+ and allow HTTPS access to github.com/codeload.github.com, " HELP = ("Install Python 3.10+ and allow HTTPS access to github.com/codeload.github.com, "
"or set SWAGGER_UI_DOWNLOAD_URL=file:///absolute/path/swagger-ui-5.33.1.zip " "or set SWAGGER_UI_DOWNLOAD_URL=file:///absolute/path/swagger-ui-5.33.1.zip "
"to a reviewed copy of the same tag archive. See docs/API_ACCESS.md.") "to a reviewed copy of the same tag archive. See docs/INSTALL.md.")
class AssetError(ValueError): class AssetError(ValueError):
@@ -82,11 +84,29 @@ class HttpsRedirects(HTTPRedirectHandler):
def download(destination: Path) -> None: def download(destination: Path) -> None:
"""Download with a few retries; github.com occasionally times out or returns a gateway error."""
last: Exception | None = None
for attempt in range(4):
try:
_download_once(destination)
return
except AssetError:
raise
except (OSError, ValueError) as error: # URLError/HTTPError/timeouts are OSError subclasses
last = error
if attempt < 3:
delay = (3, 6, 12)[attempt]
print(f"Swagger download failed ({error}); retrying in {delay}s ({attempt + 1}/3).", file=sys.stderr)
time.sleep(delay)
raise AssetError(f"Swagger download failed after 4 attempts: {last}")
def _download_once(destination: Path) -> None:
"""TLS verification uses Python's default trust store; never executes a shell.""" """TLS verification uses Python's default trust store; never executes a shell."""
started = time.monotonic() started = time.monotonic()
opener = build_opener(HttpsRedirects()) opener = build_opener(HttpsRedirects())
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build"}) request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build"})
with opener.open(request, timeout=30) as response, destination.open("wb") as output: with opener.open(request, timeout=60) as response, destination.open("wb") as output:
length = response.headers.get("Content-Length") length = response.headers.get("Content-Length")
if length and int(length) > MAX_ARCHIVE: if length and int(length) > MAX_ARCHIVE:
raise AssetError("Swagger archive exceeds the 64 MiB limit.") raise AssetError("Swagger archive exceeds the 64 MiB limit.")
@@ -162,6 +182,75 @@ def atomic_write(path: Path, data: bytes) -> None:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
def cached_archives(out_dir: Path, cache: Path) -> list[Path]:
"""Previously downloaded copies of the pinned archive.
Cargo gives every package version its own OUT_DIR, so a version bump would download the same
pinned archive again. Reuse an archive from the shared cache or from another build directory,
after the usual validation below.
"""
candidates: list[Path] = []
override = os.environ.get("SWAGGER_UI_CACHE_DIR", "").strip()
if override:
candidates.append(Path(override) / cache.name)
parents = out_dir.parents
if len(parents) > 3 and parents[1].name == "build":
candidates.append(parents[3] / "swagger-ui-cache" / cache.name)
try:
siblings = sorted(parents[1].glob("*/out/" + cache.name), key=lambda item: item.stat().st_mtime, reverse=True)
except OSError:
siblings = []
candidates.extend(siblings)
return [item for item in dict.fromkeys(candidates) if item != cache and item.is_file()]
def remember_archive(out_dir: Path, cache: Path) -> None:
"""Best-effort copy of a validated archive into the shared cache directory."""
parents = out_dir.parents
if len(parents) > 3 and parents[1].name == "build":
shared = parents[3] / "swagger-ui-cache"
try:
shared.mkdir(parents=True, exist_ok=True)
if not (shared / cache.name).exists():
shutil.copyfile(cache, shared / cache.name)
except OSError:
pass
def read_vendored() -> tuple[dict[str, bytes], str, Path] | None:
"""Assets vendored in third_party/ (official swagger-ui-dist files); verified against SHA256SUMS.
Returns None when the directory is absent so the pinned download still works for older trees.
A present but inconsistent directory is an error, never a silent fallback.
"""
manifest = VENDORED / "SHA256SUMS"
if not manifest.is_file():
return None
expected: dict[str, str] = {}
for line in manifest.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
digest, _, name = line.partition(" ")
if not re.fullmatch(r"[0-9a-f]{64}", digest) or not name or "/" in name or name.startswith("."):
raise AssetError("Vendored Swagger SHA256SUMS is malformed.")
expected[name] = digest
files: dict[str, bytes] = {}
for name in ("swagger-ui-bundle.js", "swagger-ui.css", "LICENSE", "NOTICE", "swagger-ui-bundle.js.LICENSE.txt"):
path = VENDORED / name
if name not in expected or not path.is_file() or path.is_symlink():
raise AssetError(f"Vendored Swagger UI is incomplete: {name} is missing or not listed in SHA256SUMS.")
data = path.read_bytes()
if not 0 < len(data) <= 16 * 1024 * 1024 or hashlib.sha256(data).hexdigest() != expected[name]:
raise AssetError(f"Vendored Swagger UI file failed its SHA-256 check: {name}.")
data.decode("utf-8")
files[name] = data
package = {name: files[name] for name in ASSETS}
licenses = [label.encode() + b"\n" + b"-" * 40 + b"\n" + files[name] for label, name in (("LICENSE", "LICENSE"), ("NOTICE", "NOTICE"), ("dist/swagger-ui-bundle.js.LICENSE.txt", "swagger-ui-bundle.js.LICENSE.txt"))]
assets = {"swagger-ui-bundle.js": package["swagger-ui-bundle.js"], "swagger-ui.css": package["swagger-ui.css"],
"licenses.txt": b"Swagger UI " + VERSION.encode() + b"\n\n" + b"\n\n".join(licenses)}
return assets, hashlib.sha256(manifest.read_bytes()).hexdigest(), manifest
def prepare(out_dir: Path, source: str | Path, checksum: str | None = None, def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
*, offline: bool = False) -> list[Path]: *, offline: bool = False) -> list[Path]:
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
@@ -169,10 +258,27 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
if isinstance(source, Path): if isinstance(source, Path):
archive = source archive = source
assets, digest = read_assets(archive, checksum) assets, digest = read_assets(archive, checksum)
elif source == DOWNLOAD_URL and checksum is None and (vendored := read_vendored()) is not None:
assets, digest, archive = vendored
print(f"Using the vendored Swagger UI {VERSION} from {VENDORED}.", file=sys.stderr)
elif source == DOWNLOAD_URL: elif source == DOWNLOAD_URL:
archive = cache archive = cache
reused = None
if cache.exists(): if cache.exists():
assets, digest = read_assets(cache, checksum) assets, digest = read_assets(cache, checksum)
else:
for candidate in cached_archives(out_dir, cache):
try:
reused = (read_assets(candidate, checksum), candidate)
break
except (OSError, ValueError, KeyError, RuntimeError, zipfile.BadZipFile, zlib.error):
continue
if reused is not None:
(assets, digest), candidate = reused
shutil.copyfile(candidate, cache)
print(f"Reusing the cached Swagger UI archive from {candidate}.", file=sys.stderr)
elif cache.exists():
pass
else: else:
if offline: if offline:
raise AssetError("Swagger assets are not cached and CARGO_NET_OFFLINE is set. " + HELP) raise AssetError("Swagger assets are not cached and CARGO_NET_OFFLINE is set. " + HELP)
@@ -185,6 +291,7 @@ def prepare(out_dir: Path, source: str | Path, checksum: str | None = None,
os.replace(temporary, cache) # Cache only an already-validated archive. os.replace(temporary, cache) # Cache only an already-validated archive.
finally: finally:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
remember_archive(out_dir, cache)
else: else:
raise AssetError("Unrecognized Swagger source.") raise AssetError("Unrecognized Swagger source.")
destination = out_dir / "swagger-ui" destination = out_dir / "swagger-ui"
Executable → Regular
+27 -1
View File
@@ -28,6 +28,7 @@ import subprocess
import sys import sys
import time import time
import uuid import uuid
import urllib.error
import urllib.request import urllib.request
import urllib.parse import urllib.parse
@@ -41,6 +42,12 @@ GEOIP_SOURCES = {
'user-country-ipv6.csv': 'https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv', 'user-country-ipv6.csv': 'https://github.com/sapics/ip-location-db/releases/download/latest/user-country-ipv6.csv',
} }
GEOIP_MAX_BYTES = 256 * 1024 * 1024 GEOIP_MAX_BYTES = 256 * 1024 * 1024
GEOIP_FETCH_ATTEMPTS = 4
GEOIP_RETRY_DELAYS = (3, 6, 12)
class GeoipUnavailable(RuntimeError):
"""The GeoIP CSV download failed after retries; the deployment itself can continue."""
def run(args: list[str], **kwargs) -> subprocess.CompletedProcess: def run(args: list[str], **kwargs) -> subprocess.CompletedProcess:
@@ -93,7 +100,7 @@ def digest(path: Path) -> str:
return result.hexdigest() return result.hexdigest()
def fetch_https(url: str, max_bytes: int) -> bytes: def _fetch_https_once(url: str, max_bytes: int) -> bytes:
if urllib.parse.urlparse(url).scheme != 'https': if urllib.parse.urlparse(url).scheme != 'https':
raise ValueError('GeoIP downloads require HTTPS.') raise ValueError('GeoIP downloads require HTTPS.')
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer'}) request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer'})
@@ -114,6 +121,25 @@ def fetch_https(url: str, max_bytes: int) -> bytes:
return bytes(result) return bytes(result)
def fetch_https(url: str, max_bytes: int) -> bytes:
"""Download with retries; transient gateway/network failures (502/503/504, timeouts) are retried."""
last: BaseException | None = None
for attempt in range(GEOIP_FETCH_ATTEMPTS):
try:
return _fetch_https_once(url, max_bytes)
except urllib.error.HTTPError as error:
last = error
if error.code not in (408, 425, 429) and error.code < 500:
break
except (urllib.error.URLError, TimeoutError, ConnectionError, ssl.SSLError) as error:
last = error
if attempt + 1 < GEOIP_FETCH_ATTEMPTS:
delay = GEOIP_RETRY_DELAYS[min(attempt, len(GEOIP_RETRY_DELAYS) - 1)]
print(f'GeoIP download failed ({last}); retrying in {delay}s ({attempt + 1}/{GEOIP_FETCH_ATTEMPTS - 1}).', file=sys.stderr)
time.sleep(delay)
raise GeoipUnavailable(f'GeoIP download from {url} failed: {last}')
def acquire_geoip_update_lock(data: Path, uid: int | None = None, gid: int | None = None) -> int: def acquire_geoip_update_lock(data: Path, uid: int | None = None, gid: int | None = None) -> int:
"""Serialize installer/manual GeoIP refresh with the in-process worker.""" """Serialize installer/manual GeoIP refresh with the in-process worker."""
path = data / '.geoip-update.lock' path = data / '.geoip-update.lock'
Executable → Regular
+14 -12
View File
@@ -211,9 +211,7 @@ def admin_health_endpoint(record: dict) -> tuple[str, bool, str]:
if isinstance(values, list): if isinstance(values, list):
allowed_hosts = [str(value).strip() for value in values if str(value).strip()] allowed_hosts = [str(value).strip() for value in values if str(value).strip()]
except (OSError, sqlite3.Error, ValueError, TypeError, json.JSONDecodeError): except (OSError, sqlite3.Error, ValueError, TypeError, json.JSONDecodeError):
# Old releases and damaged/uninitialized databases used the startup HTTP # Fall back to the startup listener for old or uninitialized databases.
# listener. The real startup diagnostics remain authoritative if this
# compatibility read cannot resolve persisted settings.
pass pass
host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1)) host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1))
@@ -234,9 +232,7 @@ def healthy(record: dict, version: str, attempts: int = 30) -> bool:
host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1)) host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1))
for _ in range(attempts): for _ in range(attempts):
if tls: if tls:
# Local deployment health is process identity/liveness, not PKI validation. # Local liveness probe only; the admin certificate may be self-signed.
# The configured admin certificate may intentionally be self-signed or for
# a DNS name while we connect to the local socket.
conn = http.client.HTTPSConnection(host, int(port), timeout=2, context=ssl._create_unverified_context()) conn = http.client.HTTPSConnection(host, int(port), timeout=2, context=ssl._create_unverified_context())
else: else:
conn = http.client.HTTPConnection(host, int(port), timeout=2) conn = http.client.HTTPConnection(host, int(port), timeout=2)
@@ -362,16 +358,19 @@ def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: boo
# Repair missing or stale installer-owned shell setup on upgrades. # Repair missing or stale installer-owned shell setup on upgrades.
install_shell_path(prefix) install_shell_path(prefix)
check_shell_path(prefix) check_shell_path(prefix)
# Repair stale root:root/0700 metadata before staging/network work and before # Repair stale ownership before stopping the running service.
# stopping the currently healthy service.
ensure_service_data_access(data, account) ensure_service_data_access(data, account)
release, version = stage(binary, prefix, record['user'], sha) release, version = stage(binary, prefix, record['user'], sha)
geoip_names: list[str] = [] geoip_names: list[str] = []
if refresh_geoip: if refresh_geoip:
# Keep the same lock for checksum/download staging and publication. The # Shared lock with the runtime worker prevents concurrent upgrades.
# runtime worker uses this file too, so manual upgrades cannot race it.
geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid) geoip_lock_fd = acquire_geoip_update_lock(data, account.pw_uid, account.pw_gid)
geoip_stage, geoip_names = prepare_geoip_update(data, prefix) try:
geoip_stage, geoip_names = prepare_geoip_update(data, prefix)
except GeoipUnavailable as error:
# GeoIP is refreshed best-effort: the update must not be blocked by a flaky download.
print(f'WARNING: GeoIP refresh skipped, keeping the installed country data: {error}', file=sys.stderr)
geoip_stage, geoip_names = None, []
active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0 active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0
stop_for_managed_update(data) stop_for_managed_update(data)
try: try:
@@ -440,7 +439,10 @@ def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bo
prefix.mkdir(parents=True, mode=0o755, exist_ok=True); os.chmod(prefix, 0o755) prefix.mkdir(parents=True, mode=0o755, exist_ok=True); os.chmod(prefix, 0o755)
data.mkdir(parents=True, mode=0o700, exist_ok=True); os.chmod(data, 0o700); os.chown(data, account.pw_uid, account.pw_gid) data.mkdir(parents=True, mode=0o700, exist_ok=True); os.chmod(data, 0o700); os.chown(data, account.pw_uid, account.pw_gid)
if install_geoip: if install_geoip:
download_geoip(data, account.pw_uid, account.pw_gid) try:
download_geoip(data, account.pw_uid, account.pw_gid)
except GeoipUnavailable as error:
print(f'WARNING: GeoIP download skipped ({error}). Country allow/block lists will use data fetched later by the GeoIP updater, or rerun update.sh.', file=sys.stderr)
release, version = stage(binary, prefix, record['user'], sha) release, version = stage(binary, prefix, record['user'], sha)
record.update(release=release, version=version, sha256=sha) record.update(release=release, version=version, sha256=sha)
switch(prefix, release) switch(prefix, release)
+2 -7
View File
@@ -13,7 +13,7 @@ from scripts.version import project_version
MANAGED = ["build.rs", "langs", "deploy", "debian", "grafana", "make_zip.py", "src", "tests", "scripts", "tools", "integrations", "nagios", "docker-agent", "migrations", "examples", "docs", ".github", ".cargo", MANAGED = ["build.rs", "langs", "deploy", "debian", "grafana", "make_zip.py", "src", "tests", "scripts", "tools", "integrations", "nagios", "docker-agent", "migrations", "examples", "docs", ".github", ".cargo",
"Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "dev.sh", "upgrade.sh", "install.sh", "update.sh", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "dev.sh", "upgrade.sh", "install.sh", "update.sh",
"README.md", "ROADMAP.md", "CHANGELOG.md", "AGENTS.md", "SHA256SUMS", "README.md", "LICENSE", "CHANGELOG.md", "AGENTS.md", "SHA256SUMS",
".env.example", ".gitignore"] ".env.example", ".gitignore"]
@@ -72,12 +72,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
elif new.is_file(): elif new.is_file():
installed.append(name) installed.append(name)
shutil.copy2(new, old) shutil.copy2(new, old)
# Old renewal observations were diagnostic scheduler state only; # Obsolete renewal sidecar: validate, record the manifest, then remove it last.
# certificates, accounts and renewal profiles live elsewhere. 0.19.1
# has no runtime JSON importer. Validate the obsolete sidecar now,
# write the completed source-upgrade manifest, then remove the file
# as the final fallible operation. SQLite observations are rebuilt by
# the normal renewal scheduler. No second legacy copy is retained.
runtime_data = (data_dir or (target / "data")).resolve() runtime_data = (data_dir or (target / "data")).resolve()
certs_dir = runtime_data / "certs" certs_dir = runtime_data / "certs"
if certs_dir.is_symlink(): if certs_dir.is_symlink():
+1 -1
View File
@@ -132,7 +132,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
flat = module.parent.with_suffix(".rs") flat = module.parent.with_suffix(".rs")
if flat.is_file(): if flat.is_file():
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.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/mod.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/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.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/mod.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"]: 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/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.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/mod.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/icons.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/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.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", "LICENSE", "docs/FEATURES.md", "docs/PERFORMANCE.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.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/INSTALL.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file(): if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}") raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}")
for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"): for path in ("scripts/__init__.py", "scripts/build/__init__.py", "scripts/build/panel_css.py", "src/admin/web/css/manifest.json"):
+7 -15
View File
@@ -39,8 +39,7 @@ PROVIDER_FIELDS = {
def credential(value: object) -> str: def credential(value: object) -> str:
# All supported API keys are ASCII tokens. Reject whitespace/newlines and # API keys are ASCII tokens; reject anything that could inject INI content.
# INI metacharacters; provider input must never add options or sections.
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_./+\-=]{8,4096}", value): if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_./+\-=]{8,4096}", value):
raise AcmeError("acme.invalid_api_credential") raise AcmeError("acme.invalid_api_credential")
return value return value
@@ -196,8 +195,7 @@ def certbot_command(value: dict, work: Path, credential_file: Path, command_pref
def read_generated(work: Path, name: str) -> bytes: def read_generated(work: Path, name: str) -> bytes:
# Certbot live entries are symlinks into its private archive. Resolve only # Resolve live symlinks only inside Certbot's archive.
# within that archive, then open without following a second/final symlink.
archive = (work / "config" / "archive").resolve(strict=True) archive = (work / "config" / "archive").resolve(strict=True)
path = (work / "config" / "live" / "rustproxy" / name).resolve(strict=True) path = (work / "config" / "live" / "rustproxy" / name).resolve(strict=True)
if not path.is_relative_to(archive): if not path.is_relative_to(archive):
@@ -220,8 +218,7 @@ def issue(raw: dict, certificate_directory: Path, executable: str | None = None)
private_directory(certificate_directory) private_directory(certificate_directory)
root = certificate_directory / "acme" root = certificate_directory / "acme"
private_directory(root) private_directory(root)
# Each identity/environment has isolated CA accounts, DNS credentials and # Each profile has isolated CA accounts, credentials and lineages.
# Certbot lineages. Both profile IDs and directory paths are operator-owned.
suffix = "" if value["provider"] == "rfc2136" else "-" + value["provider"] suffix = "" if value["provider"] == "rfc2136" else "-" + value["provider"]
work = root / (value["environment"] + "-" + value["certificate_id"] + suffix) work = root / (value["environment"] + "-" + value["certificate_id"] + suffix)
private_directory(work) private_directory(work)
@@ -236,9 +233,7 @@ def issue(raw: dict, certificate_directory: Path, executable: str | None = None)
raise AcmeError("acme.profile_busy") from None raise AcmeError("acme.profile_busy") from None
for folder in ["config", "work", "logs"]: for folder in ["config", "work", "logs"]:
private_directory(work / folder) private_directory(work / folder)
# DNS credentials are supplied by RustProxy's encrypted Secret Store. # Secret Store credentials exist as a plaintext file only while Certbot runs.
# Certbot needs a file path, but the plaintext file exists only while the
# provider command runs and is deleted before this worker returns.
credential_file = work / (value["provider"] + ".ini") credential_file = work / (value["provider"] + ".ini")
if value["provider"] == "http01": if value["provider"] == "http01":
webroot = certificate_directory / "acme-http01" webroot = certificate_directory / "acme-http01"
@@ -265,8 +260,7 @@ def issue(raw: dict, certificate_directory: Path, executable: str | None = None)
credential_file.unlink(missing_ok=True) credential_file.unlink(missing_ok=True)
cert = read_generated(work, "fullchain.pem") cert = read_generated(work, "fullchain.pem")
key = read_generated(work, "privkey.pem") key = read_generated(work, "privkey.pem")
# Immutable copies allow validation/publication without changing the # Work on immutable copies so the active certificate never changes mid-run.
# active certificate during CA work or a concurrent configuration edit.
stem = "acme-" + uuid.uuid4().hex stem = "acme-" + uuid.uuid4().hex
cert_path = certificate_directory / (stem + ".pem") cert_path = certificate_directory / (stem + ".pem")
key_path = certificate_directory / (stem + ".key") key_path = certificate_directory / (stem + ".key")
@@ -392,8 +386,7 @@ def renew(raw: dict, certificate_directory: Path, executable: str | None = None)
if not isinstance(names, list) or not 1 <= len(names) <= 16: if not isinstance(names, list) or not 1 <= len(names) <= 16:
raise AcmeError("acme.invalid_domains") raise AcmeError("acme.invalid_domains")
names = [domain(n, wildcard=value["provider"] != "http01") for n in names] names = [domain(n, wildcard=value["provider"] != "http01") for n in names]
# The renewal config persists, but provider credentials do not. Recreate # Credentials are recreated only for the duration of this invocation.
# the exact path referenced by Certbot only for this invocation.
read_private(work / "config" / "renewal" / "rustproxy.conf", 65536) read_private(work / "config" / "renewal" / "rustproxy.conf", 65536)
credential_file = work / (value["provider"] + ".ini") credential_file = work / (value["provider"] + ".ini")
if value["provider"] != "http01": if value["provider"] != "http01":
@@ -436,8 +429,7 @@ def renew(raw: dict, certificate_directory: Path, executable: str | None = None)
return output return output
if dates["not_after_unix"] < old_dates["not_after_unix"]: if dates["not_after_unix"] < old_dates["not_after_unix"]:
raise AcmeError("acme.renewal_older_certificate") raise AcmeError("acme.renewal_older_certificate")
# Content-addressed immutable staging makes a retry after a publication # Content-addressed staging keeps retries idempotent.
# failure idempotent (no additional orphan PEMs each time).
stem = "acme-" + hashlib.sha256(cert + b"\0" + key).hexdigest()[:48] stem = "acme-" + hashlib.sha256(cert + b"\0" + key).hexdigest()[:48]
paths = [(certificate_directory / (stem + ".pem"), cert), (certificate_directory / (stem + ".key"), key)] paths = [(certificate_directory / (stem + ".pem"), cert), (certificate_directory / (stem + ".key"), key)]
if len(list(certificate_directory.glob("acme-*.key"))) >= 1024 and not paths[1][0].exists(): if len(list(certificate_directory.glob("acme-*.key"))) >= 1024 and not paths[1][0].exists():
-1
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Start two TCP and two UDP mock backends for RustProxy Stream tests. # Start two TCP and two UDP mock backends for RustProxy Stream tests.
# Usage: ./start_stream_backends.sh [bind-host] # Usage: ./start_stream_backends.sh [bind-host]
# Defaults to loopback for safety. Use 0.0.0.0 only on a controlled test network.
set -euo pipefail set -euo pipefail
DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+12 -9
View File
@@ -35,17 +35,20 @@ async fn execute(
.map_err(|_| "acme.validation_worker_failed".to_owned())? .map_err(|_| "acme.validation_worker_failed".to_owned())?
} }
pub async fn challenge( const CHALLENGE_PREFIX: &str = "/.well-known/acme-challenge/";
axum::extract::State(state): axum::extract::State<Arc<App>>,
request: axum::extract::Request, pub fn is_challenge_path(path: &str) -> bool {
next: axum::middleware::Next, path.starts_with(CHALLENGE_PREFIX)
) -> axum::response::Response { }
pub async fn challenge(state: Arc<App>, request: axum::extract::Request) -> axum::response::Response {
use axum::{body::Body, response::IntoResponse}; use axum::{body::Body, response::IntoResponse};
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
const PREFIX: &str = "/.well-known/acme-challenge/"; let token = request
let Some(token) = request.uri().path().strip_prefix(PREFIX) else { .uri()
return next.run(request).await; .path()
}; .strip_prefix(CHALLENGE_PREFIX)
.unwrap_or_default();
let denied = || { let denied = || {
( (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
+5 -6
View File
@@ -11,10 +11,11 @@ macro_rules! reader_doc {
pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("README.md", "project"), reader_doc!("README.md", "project"),
reader_doc!("CHANGELOG.md", "project"), reader_doc!("CHANGELOG.md", "project"),
reader_doc!("ROADMAP.md", "project"), reader_doc!("LICENSE", "project"),
reader_doc!("docs/README.md", "guides"), reader_doc!("docs/README.md", "guides"),
reader_doc!("docs/FEATURES.md", "guides"),
reader_doc!("docs/SCREENSHOTS.md", "guides"),
reader_doc!("docs/INSTALL.md", "guides"), reader_doc!("docs/INSTALL.md", "guides"),
reader_doc!("docs/CONTAINER.md", "guides"),
reader_doc!("docs/CONFIGURATION.md", "guides"), reader_doc!("docs/CONFIGURATION.md", "guides"),
reader_doc!("docs/VHOSTS.md", "guides"), reader_doc!("docs/VHOSTS.md", "guides"),
reader_doc!("docs/CUSTOM_LOCATIONS.md", "guides"), reader_doc!("docs/CUSTOM_LOCATIONS.md", "guides"),
@@ -24,11 +25,9 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("docs/CACHE.md", "guides"), reader_doc!("docs/CACHE.md", "guides"),
reader_doc!("docs/RESPONSE_DELIVERY.md", "guides"), reader_doc!("docs/RESPONSE_DELIVERY.md", "guides"),
reader_doc!("docs/TRANSPORT.md", "guides"), reader_doc!("docs/TRANSPORT.md", "guides"),
reader_doc!("docs/TLS_POST_QUANTUM.md", "guides"),
reader_doc!("docs/STREAMS.md", "guides"), reader_doc!("docs/STREAMS.md", "guides"),
reader_doc!("docs/ACME_PROVIDERS.md", "guides"), reader_doc!("docs/CERTIFICATES.md", "guides"),
reader_doc!("docs/DOCKER_DISCOVERY.md", "guides"), reader_doc!("docs/DOCKER_DISCOVERY.md", "guides"),
reader_doc!("docs/INTEGRATIONS.md", "guides"),
reader_doc!("docs/DASHBOARD.md", "guides"), reader_doc!("docs/DASHBOARD.md", "guides"),
reader_doc!("docs/MONITORING.md", "guides"), reader_doc!("docs/MONITORING.md", "guides"),
reader_doc!("docs/LOGS.md", "guides"), reader_doc!("docs/LOGS.md", "guides"),
@@ -36,6 +35,6 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("docs/MIGRATION.md", "guides"), reader_doc!("docs/MIGRATION.md", "guides"),
reader_doc!("docs/SECURITY.md", "guides"), reader_doc!("docs/SECURITY.md", "guides"),
reader_doc!("docs/API.md", "guides"), reader_doc!("docs/API.md", "guides"),
reader_doc!("docs/API_ACCESS.md", "guides"), reader_doc!("docs/PERFORMANCE.md", "guides"),
reader_doc!("docs/TROUBLESHOOTING.md", "guides"), reader_doc!("docs/TROUBLESHOOTING.md", "guides"),
]; ];
+1 -1
View File
@@ -12,7 +12,7 @@ mod tests {
assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md")); assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md"));
assert!(documents assert!(documents
.iter() .iter()
.any(|doc| doc["path"] == "docs/TLS_POST_QUANTUM.md")); .any(|doc| doc["path"] == "docs/FEATURES.md"));
assert!(documents.iter().all(|doc| { assert!(documents.iter().all(|doc| {
doc["path"].as_str().is_some_and(|value| !value.is_empty()) doc["path"].as_str().is_some_and(|value| !value.is_empty())
&& doc["title"].as_str().is_some_and(|value| !value.is_empty()) && doc["title"].as_str().is_some_and(|value| !value.is_empty())
+62 -23
View File
@@ -171,6 +171,7 @@ async fn metrics_summary(State(state): State<Arc<App>>) -> Response {
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
struct EventsQuery { struct EventsQuery {
interval: Option<u64>, interval: Option<u64>,
logs: Option<bool>,
} }
async fn events( async fn events(
@@ -179,6 +180,7 @@ async fn events(
Query(query): Query<EventsQuery>, Query(query): Query<EventsQuery>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let interval = query.interval.unwrap_or(3); let interval = query.interval.unwrap_or(3);
let include_logs = query.logs.unwrap_or(true);
if ![1, 2, 3, 5, 10].contains(&interval) { if ![1, 2, 3, 5, 10].contains(&interval) {
return Err(ApiError( return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
@@ -198,8 +200,8 @@ async fn events(
) )
})?; })?;
let stream = futures_util::stream::unfold( let stream = futures_util::stream::unfold(
(state, secret, permit, false, interval), (state, secret, permit, false, interval, include_logs),
|(state, secret, permit, delayed, interval)| async move { |(state, secret, permit, delayed, interval, include_logs)| async move {
if delayed { if delayed {
tokio::time::sleep(Duration::from_secs(interval)).await; tokio::time::sleep(Duration::from_secs(interval)).await;
} }
@@ -218,13 +220,16 @@ async fn events(
Ok(Some(_)) => {} Ok(Some(_)) => {}
_ => return None, _ => return None,
} }
let payload = let payload = if include_logs {
json!({"status":live_status_payload(&state), "logs":logs_payload(&state)}) json!({"status":live_status_payload(&state), "logs":logs_payload(&state)})
.to_string(); } else {
json!({"status":live_status_payload(&state)})
}
.to_string();
let event = Event::default().event("snapshot").data(payload); let event = Event::default().event("snapshot").data(payload);
Some(( Some((
Ok::<Event, Infallible>(event), Ok::<Event, Infallible>(event),
(state, secret, permit, true, interval), (state, secret, permit, true, interval, include_logs),
)) ))
}, },
); );
@@ -237,11 +242,22 @@ async fn events(
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
struct TrafficQuery { struct TrafficQuery {
vhost: Option<String>, vhost: Option<String>,
view: Option<String>,
} }
async fn traffic_status( async fn traffic_status(
State(state): State<Arc<App>>, State(state): State<Arc<App>>,
Query(query): Query<TrafficQuery>, Query(query): Query<TrafficQuery>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let live = match query.view.as_deref() {
None => false,
Some("live") => true,
_ => {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid traffic view".into(),
));
}
};
let snapshot = state.config.load_full(); let snapshot = state.config.load_full();
if query.vhost.as_ref().is_some_and(|id| { if query.vhost.as_ref().is_some_and(|id| {
id != "*" id != "*"
@@ -258,14 +274,8 @@ async fn traffic_status(
)); ));
} }
let scope = query.vhost.as_deref().unwrap_or("*"); let scope = query.vhost.as_deref().unwrap_or("*");
let vhosts = snapshot let mut payload = json!({
.envelope "revision": snapshot.envelope.revision,
.config
.vhosts
.iter()
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
.collect::<Vec<_>>();
Ok(Json(json!({
"scope": scope, "scope": scope,
"since": "process_start", "since": "process_start",
"settings": { "settings": {
@@ -273,9 +283,19 @@ async fn traffic_status(
"capture_country": snapshot.envelope.config.logging.capture_country "capture_country": snapshot.envelope.config.logging.capture_country
}, },
"geoip": state.geoip.load().view(), "geoip": state.geoip.load().view(),
"analytics": state.traffic.view(Some(scope)), "analytics": state.traffic.view(Some(scope))
"vhosts": vhosts });
}))) if live {
return Ok(Json(payload));
}
payload["vhosts"] = json!(snapshot
.envelope
.config
.vhosts
.iter()
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
.collect::<Vec<_>>());
Ok(Json(payload))
} }
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
@@ -284,11 +304,22 @@ struct CacheQuery {
vhost: Option<String>, vhost: Option<String>,
offset: Option<usize>, offset: Option<usize>,
limit: Option<usize>, limit: Option<usize>,
view: Option<String>,
} }
async fn cache_status( async fn cache_status(
State(state): State<Arc<App>>, State(state): State<Arc<App>>,
Query(query): Query<CacheQuery>, Query(query): Query<CacheQuery>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let live = match query.view.as_deref() {
None => false,
Some("live") => true,
_ => {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid cache view".into(),
));
}
};
let snapshot = state.config.load_full(); let snapshot = state.config.load_full();
if query if query
.vhost .vhost
@@ -300,16 +331,24 @@ async fn cache_status(
"unknown vhost cache scope".into(), "unknown vhost cache scope".into(),
)); ));
} }
let scopes: Vec<_> = snapshot.envelope.config.vhosts.iter().map(|vhost| {
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
}).collect();
let dashboard = state.cache.dashboard( let dashboard = state.cache.dashboard(
query.vhost.as_deref(), query.vhost.as_deref(),
query.offset.unwrap_or(0).min(16384), query.offset.unwrap_or(0).min(16384),
query.limit.unwrap_or(50), query.limit.unwrap_or(50),
); );
Ok(Json( let mut payload = json!({
json!({"settings":snapshot.envelope.config.cache, "metrics":state.cache.view(), "vhosts":scopes, "dashboard":dashboard, "bans":state.cache.bans()}), "revision": snapshot.envelope.revision,
)) "metrics": state.cache.view(),
"dashboard": dashboard,
"bans": state.cache.bans()
});
if live {
return Ok(Json(payload));
}
payload["settings"] = json!(snapshot.envelope.config.cache);
payload["vhosts"] = json!(snapshot.envelope.config.vhosts.iter().map(|vhost| {
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
}).collect::<Vec<_>>());
Ok(Json(payload))
} }
+294 -43
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
"title": "RustProxy Administration API", "title": "RustProxy Administration API",
"version": "0.35.0", "version": "0.36.8",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only." "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
}, },
"servers": [ "servers": [
@@ -443,10 +443,31 @@
"type": "object", "type": "object",
"description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.", "description": "Optional explicit provider and integration method. Absent means infer the existing active Basic/Tinyauth/Authelia/authentik/OIDC configuration for backward compatibility. The selected method must match the one enabled legacy settings object; unknown, inconsistent or unsupported pairs are rejected.",
"additionalProperties": false, "additionalProperties": false,
"required": ["provider", "method"], "required": [
"provider",
"method"
],
"properties": { "properties": {
"provider": {"type": "string", "enum": ["basic", "tinyauth", "authelia", "authentik", "pocket_id", "custom"]}, "provider": {
"method": {"type": "string", "enum": ["basic", "forward_auth", "oidc"], "description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."} "type": "string",
"enum": [
"basic",
"tinyauth",
"authelia",
"authentik",
"pocket_id",
"custom"
]
},
"method": {
"type": "string",
"enum": [
"basic",
"forward_auth",
"oidc"
],
"description": "Basic supports basic only; Tinyauth/Authelia/authentik support forward_auth or oidc; Pocket ID/custom support oidc only."
}
} }
}, },
"OidcConfig": { "OidcConfig": {
@@ -454,20 +475,76 @@
"description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.", "description": "Opt-in OpenID Connect Authorization Code integration method for an identity provider selected by authentication.provider. Uses issuer discovery, S256 PKCE, nonce/state cookies, JWKS RS256/ES256 ID token verification and encrypted host-only application sessions. Existing standalone oidc.enabled configurations remain supported.",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"enabled": {"type": "boolean", "default": false}, "enabled": {
"issuer_url": {"type": "string", "format": "uri", "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"}, "type": "boolean",
"backend_url": {"type": "string", "format": "uri", "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"}, "default": false
"redirect_url": {"type": "string", "format": "uri", "description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"}, },
"client_id": {"type": "string", "maxLength": 256}, "issuer_url": {
"client_secret": {"type": "string", "maxLength": 2048, "description": "Confidential OIDC client secret; stored in Vhost configuration"}, "type": "string",
"session_secret": {"type": "string", "minLength": 32, "maxLength": 4096, "description": "High-entropy secret for encrypted session and temporary state cookies"}, "format": "uri",
"scopes": {"type": "string", "default": "openid profile email"}, "description": "Exact HTTPS OIDC issuer; /.well-known/openid-configuration is discovered at this URL"
"allow_private": {"type": "boolean", "default": false}, },
"address_allowlist": {"type": "array", "items": {"type": "string"}}, "backend_url": {
"tls_ca_pem": {"type": "string", "nullable": true}, "type": "string",
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5}, "format": "uri",
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10}, "description": "Optional internal HTTP(S) origin for server-side discovery/token/JWKS/UserInfo requests; public issuer and JWT validation remain unchanged"
"session_ttl_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "default": 3600} },
"redirect_url": {
"type": "string",
"format": "uri",
"description": "HTTPS URL for an exact hostname in this Vhost and path /_rustproxy/oidc/callback"
},
"client_id": {
"type": "string",
"maxLength": 256
},
"client_secret": {
"type": "string",
"maxLength": 2048,
"description": "Confidential OIDC client secret; stored in Vhost configuration"
},
"session_secret": {
"type": "string",
"minLength": 32,
"maxLength": 4096,
"description": "High-entropy secret for encrypted session and temporary state cookies"
},
"scopes": {
"type": "string",
"default": "openid profile email"
},
"allow_private": {
"type": "boolean",
"default": false
},
"address_allowlist": {
"type": "array",
"items": {
"type": "string"
}
},
"tls_ca_pem": {
"type": "string",
"nullable": true
},
"connect_timeout_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"default": 5
},
"timeout_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 60,
"default": 10
},
"session_ttl_seconds": {
"type": "integer",
"minimum": 60,
"maximum": 86400,
"default": 3600
}
} }
}, },
"ForwardAuthConfig": { "ForwardAuthConfig": {
@@ -475,17 +552,70 @@
"description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.", "description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"enabled": {"type": "boolean", "default": false}, "enabled": {
"url": {"type": "string", "maxLength": 2048, "description": "Internal service/outpost origin or the provider's verifier endpoint"}, "type": "boolean",
"login_url": {"type": "string", "maxLength": 2048, "description": "Required public HTTPS portal URL for Authelia; ignored for authentik"}, "default": false
"allow_private": {"type": "boolean", "default": false}, },
"address_allowlist": {"type": "array", "maxItems": 32, "items": {"type": "string"}}, "url": {
"tls_ca_pem": {"type": "string", "nullable": true}, "type": "string",
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5}, "maxLength": 2048,
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10}, "description": "Internal service/outpost origin or the provider's verifier endpoint"
"request_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"}, },
"response_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"}, "login_url": {
"redirect_forbidden": {"type": "boolean", "default": false, "description": "Authelia only: accept trusted portal Location on explicit 403 if provided"} "type": "string",
"maxLength": 2048,
"description": "Required public HTTPS portal URL for Authelia; ignored for authentik"
},
"allow_private": {
"type": "boolean",
"default": false
},
"address_allowlist": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string"
}
},
"tls_ca_pem": {
"type": "string",
"nullable": true
},
"connect_timeout_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 30,
"default": 5
},
"timeout_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 60,
"default": 10
},
"request_headers": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 128
},
"description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"
},
"response_headers": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 128
},
"description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"
},
"redirect_forbidden": {
"type": "boolean",
"default": false,
"description": "Authelia only: accept trusted portal Location on explicit 403 if provided"
}
} }
}, },
"Vhost": { "Vhost": {
@@ -591,8 +721,13 @@
"$ref": "#/components/schemas/ForwardAuthConfig", "$ref": "#/components/schemas/ForwardAuthConfig",
"description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia." "description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia."
}, },
"oidc": {"$ref": "#/components/schemas/OidcConfig"}, "oidc": {
"authentication": {"$ref": "#/components/schemas/AuthenticationChoice", "description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."}, "$ref": "#/components/schemas/OidcConfig"
},
"authentication": {
"$ref": "#/components/schemas/AuthenticationChoice",
"description": "Optional provider/method selector. Set to null or omit for legacy Vhost data. It does not replace the existing provider-specific settings objects."
},
"cache": { "cache": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1618,6 +1753,11 @@
"Cache": { "Cache": {
"type": "object", "type": "object",
"properties": { "properties": {
"revision": {
"type": "integer",
"format": "int64",
"description": "Configuration revision used for this snapshot."
},
"settings": { "settings": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
@@ -3434,6 +3574,19 @@
"nullable": true, "nullable": true,
"description": "Optional HTTP Host override. When null for an HTTP vhost probe, RustProxy uses the first exact vhost DNS hostname; if none exists it falls back to the backend host." "description": "Optional HTTP Host override. When null for an HTTP vhost probe, RustProxy uses the first exact vhost DNS hostname; if none exists it falls back to the backend host."
}, },
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"nullable": true,
"description": "Optional port for active probes. When null or equal to the backend port, probes use the backend address; otherwise TCP and HTTP probes connect to the same host on this port with the same TLS settings and address restrictions."
},
"expect_body": {
"type": "string",
"nullable": true,
"maxLength": 256,
"description": "Optional text that must appear in the first 64 KiB of an HTTP probe response body. A response with an accepted status but without this text counts as a failed probe (result http_body)."
},
"interval_ms": { "interval_ms": {
"type": "integer", "type": "integer",
"minimum": 250, "minimum": 250,
@@ -3659,7 +3812,15 @@
"neon", "neon",
"monochrome", "monochrome",
"warm", "warm",
"cool" "cool",
"sunset",
"aurora",
"ocean",
"forest",
"pastel",
"terminal",
"blueprint",
"colorblind"
], ],
"default": "default", "default": "default",
"description": "Account-scoped visual presentation preset applied consistently to administration charts." "description": "Account-scoped visual presentation preset applied consistently to administration charts."
@@ -3669,6 +3830,16 @@
"default": false, "default": false,
"description": "Account-scoped optional X/Y scales on sampled time-series charts. Shows the same Y range used to draw each SVG and X sample indices rather than inferred elapsed time; does not change adaptive scaling." "description": "Account-scoped optional X/Y scales on sampled time-series charts. Shows the same Y range used to draw each SVG and X sample indices rather than inferred elapsed time; does not change adaptive scaling."
}, },
"interface_icons": {
"type": "boolean",
"default": false,
"description": "Account-scoped administration UI preference. When enabled, semantic inline SVG icons are added to navigation, actions, tabs and dialogs while preserving text labels and accessibility."
},
"logo_animation": {
"type": "boolean",
"default": false,
"description": "Account-scoped administration UI preference. When enabled, the sidebar logo plays short, subtle animations at random intervals and on hover. Paused while the tab is hidden and suppressed when the browser requests reduced motion."
},
"button_style": { "button_style": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -3766,6 +3937,10 @@
"extra_h3_share", "extra_h3_share",
"extra_cache_miss_rate", "extra_cache_miss_rate",
"extra_backend_active", "extra_backend_active",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"wide_protocol_rates", "wide_protocol_rates",
"wide_cpu", "wide_cpu",
"wide_memory", "wide_memory",
@@ -3778,8 +3953,10 @@
"connection_chart", "connection_chart",
"cache_diagnostics", "cache_diagnostics",
"process_diagnostics", "process_diagnostics",
"process_simple",
"network_diagnostics", "network_diagnostics",
"backend_table", "backend_table",
"backend_tiny",
"extra_cold_queue_memory", "extra_cold_queue_memory",
"extra_cold_storage", "extra_cold_storage",
"wide_transform_activity", "wide_transform_activity",
@@ -3847,6 +4024,10 @@
"extra_h3_share", "extra_h3_share",
"extra_cache_miss_rate", "extra_cache_miss_rate",
"extra_backend_active", "extra_backend_active",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"wide_protocol_rates", "wide_protocol_rates",
"wide_cpu", "wide_cpu",
"wide_memory", "wide_memory",
@@ -3859,8 +4040,10 @@
"connection_chart", "connection_chart",
"cache_diagnostics", "cache_diagnostics",
"process_diagnostics", "process_diagnostics",
"process_simple",
"network_diagnostics", "network_diagnostics",
"backend_table" "backend_table",
"backend_tiny"
], ],
"description": "Account-scoped order for configurable Overview telemetry cards." "description": "Account-scoped order for configurable Overview telemetry cards."
}, },
@@ -3935,6 +4118,10 @@
"extra_h3_share", "extra_h3_share",
"extra_cache_miss_rate", "extra_cache_miss_rate",
"extra_backend_active", "extra_backend_active",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"wide_protocol_rates", "wide_protocol_rates",
"wide_cpu", "wide_cpu",
"wide_memory", "wide_memory",
@@ -3947,8 +4134,10 @@
"connection_chart", "connection_chart",
"cache_diagnostics", "cache_diagnostics",
"process_diagnostics", "process_diagnostics",
"process_simple",
"network_diagnostics", "network_diagnostics",
"backend_table", "backend_table",
"backend_tiny",
"extra_cold_queue_memory", "extra_cold_queue_memory",
"extra_cold_storage", "extra_cold_storage",
"wide_transform_activity", "wide_transform_activity",
@@ -4004,6 +4193,10 @@
"extra_h3_share", "extra_h3_share",
"extra_cache_miss_rate", "extra_cache_miss_rate",
"extra_backend_active", "extra_backend_active",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"wide_protocol_rates", "wide_protocol_rates",
"wide_cpu", "wide_cpu",
"wide_memory", "wide_memory",
@@ -4761,6 +4954,11 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"revision": {
"type": "integer",
"format": "int64",
"description": "Configuration revision used for this snapshot."
},
"settings": { "settings": {
"$ref": "#/components/schemas/TransformSettings" "$ref": "#/components/schemas/TransformSettings"
}, },
@@ -6165,6 +6363,11 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"revision": {
"type": "integer",
"format": "int64",
"description": "Configuration revision used for this snapshot."
},
"scope": { "scope": {
"type": "string" "type": "string"
}, },
@@ -9521,7 +9724,7 @@
} }
} }
}, },
"description": "Maximum body 16 KiB. Staging by default. Production requires explicit consent. Provider fields depend on the selected provider; see docs/ACME_PROVIDERS.md. Poll GET /api/acme.", "description": "Maximum body 16 KiB. Staging by default. Production requires explicit consent. Provider fields depend on the selected provider; see docs/CERTIFICATES.md. Poll GET /api/acme.",
"parameters": [ "parameters": [
{ {
"name": "Origin", "name": "Origin",
@@ -9843,14 +10046,14 @@
}, },
"/api/cache": { "/api/cache": {
"get": { "get": {
"summary": "Cache metrics, domains and paginated objects", "summary": "Cache metrics, domains and paginated objects; optional lightweight live view",
"tags": [ "tags": [
"Cache" "Cache"
], ],
"operationId": "get_api_cache", "operationId": "get_api_cache",
"responses": { "responses": {
"200": { "200": {
"description": "Successful response", "description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, live metrics, dashboard objects/domains and BAN rules.",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -9931,6 +10134,18 @@
"minimum": 1, "minimum": 1,
"default": 50 "default": 50
} }
},
{
"name": "view",
"in": "query",
"required": false,
"description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.",
"schema": {
"type": "string",
"enum": [
"live"
]
}
} }
], ],
"security": [ "security": [
@@ -9945,14 +10160,14 @@
}, },
"/api/transform": { "/api/transform": {
"get": { "get": {
"summary": "Transformation runtime metrics and per-vhost effectiveness", "summary": "Transformation runtime metrics and per-vhost effectiveness; optional lightweight live view",
"tags": [ "tags": [
"Transform" "Transform"
], ],
"operationId": "get_api_transform", "operationId": "get_api_transform",
"responses": { "responses": {
"200": { "200": {
"description": "Successful response", "description": "Full response by default. view=live omits static settings/vhost configuration while retaining revision, resources and live metrics.",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -10002,6 +10217,20 @@
} }
} }
}, },
"parameters": [
{
"name": "view",
"in": "query",
"required": false,
"description": "Set to live for polling. Omits static settings and vhost configuration; refetch the full view when revision changes.",
"schema": {
"type": "string",
"enum": [
"live"
]
}
}
],
"security": [ "security": [
{ {
"AdminSession": [] "AdminSession": []
@@ -10344,7 +10573,7 @@
}, },
"/api/events": { "/api/events": {
"get": { "get": {
"summary": "Live SSE status and log snapshots", "summary": "Live SSE status snapshots with optional logs",
"tags": [ "tags": [
"Monitoring" "Monitoring"
], ],
@@ -10401,7 +10630,7 @@
} }
} }
}, },
"description": "SSE snapshot events contain compact volatile status and bounded logs. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.", "description": "SSE snapshot events always contain compact volatile status. Bounded recent logs are included by default for backward compatibility and can be disabled per viewer with logs=false; the administration panel disables them until Live Logs is open/docked and polls /api/logs only while that view is active. Session validity is checked before each event. Native reconnect and keepalive apply. Each viewer selects 1/2/3/5/10 second pacing; invalid values return 422.",
"parameters": [ "parameters": [
{ {
"name": "interval", "name": "interval",
@@ -10419,6 +10648,16 @@
], ],
"default": 3 "default": 3
} }
},
{
"name": "logs",
"in": "query",
"required": false,
"description": "Include the bounded live log snapshot in each SSE event. Defaults to true for API compatibility; the panel uses false and fetches logs only while Live Logs is active.",
"schema": {
"type": "boolean",
"default": true
}
} }
] ]
} }
@@ -12855,7 +13094,7 @@
}, },
"/api/traffic": { "/api/traffic": {
"get": { "get": {
"summary": "Bounded per-vhost traffic analytics", "summary": "Bounded per-vhost traffic analytics; optional lightweight live view",
"tags": [ "tags": [
"Traffic" "Traffic"
], ],
@@ -12870,11 +13109,23 @@
"type": "string", "type": "string",
"default": "*" "default": "*"
} }
},
{
"name": "view",
"in": "query",
"required": false,
"description": "Set to live for polling. Omits the static vhost list; refetch the full view when revision changes.",
"schema": {
"type": "string",
"enum": [
"live"
]
}
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "Successful response", "description": "Full response by default. view=live omits the static vhost list while retaining revision, current settings, GeoIP state and analytics.",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -14364,4 +14615,4 @@
} }
}, },
"tags": [] "tags": []
} }
+34 -5
View File
@@ -1,6 +1,34 @@
async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> { #[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct TransformQuery {
view: Option<String>,
}
async fn transform_status(
State(state): State<Arc<App>>,
Query(query): Query<TransformQuery>,
) -> Result<Json<Value>, ApiError> {
let live = match query.view.as_deref() {
None => false,
Some("live") => true,
_ => {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid transform view".into(),
));
}
};
let snapshot = state.config.load_full(); let snapshot = state.config.load_full();
let global = &snapshot.envelope.config.transform; let global = &snapshot.envelope.config.transform;
let resources = crate::transform::resource_usage(global);
let metrics = state.transform_metrics.view();
if live {
return Ok(Json(json!({
"revision": snapshot.envelope.revision,
"resources": resources,
"metrics": metrics
})));
}
let vhosts: Vec<_> = snapshot let vhosts: Vec<_> = snapshot
.envelope .envelope
.config .config
@@ -14,12 +42,13 @@ async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
}) })
}) })
.collect(); .collect();
Json(json!({ Ok(Json(json!({
"revision": snapshot.envelope.revision,
"settings": global, "settings": global,
"resources": crate::transform::resource_usage(global), "resources": resources,
"metrics": state.transform_metrics.view(), "metrics": metrics,
"vhosts": vhosts "vhosts": vhosts
})) })))
} }
#[derive(Default, Deserialize)] #[derive(Default, Deserialize)]
+14
View File
@@ -118,6 +118,10 @@ struct AdminPreferences {
chart_style: String, chart_style: String,
#[serde(default)] #[serde(default)]
chart_axes: bool, chart_axes: bool,
#[serde(default)]
interface_icons: bool,
#[serde(default)]
logo_animation: bool,
#[serde(default = "default_admin_button_style")] #[serde(default = "default_admin_button_style")]
button_style: String, button_style: String,
#[serde(default = "default_admin_corner_style")] #[serde(default = "default_admin_corner_style")]
@@ -180,6 +184,8 @@ impl Default for AdminPreferences {
time_format: default_admin_time_format(), time_format: default_admin_time_format(),
chart_style: default_admin_chart_style(), chart_style: default_admin_chart_style(),
chart_axes: false, chart_axes: false,
interface_icons: false,
logo_animation: false,
button_style: default_admin_button_style(), button_style: default_admin_button_style(),
corner_style: default_admin_corner_style(), corner_style: default_admin_corner_style(),
table_density: default_admin_table_density(), table_density: default_admin_table_density(),
@@ -297,6 +303,14 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
| "monochrome" | "monochrome"
| "warm" | "warm"
| "cool" | "cool"
| "sunset"
| "aurora"
| "ocean"
| "forest"
| "pastel"
| "terminal"
| "blueprint"
| "colorblind"
) { ) {
return Err(ApiError( return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
+50
View File
@@ -18,6 +18,7 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
"wide_compression_activity", "wide_compression_activity",
"cache", "cache",
"backend_table", "backend_table",
"process_diagnostics",
] { ] {
assert!(order.contains(&id.to_owned()), "not registered: {id}"); assert!(order.contains(&id.to_owned()), "not registered: {id}");
assert!( assert!(
@@ -28,6 +29,8 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
for id in [ for id in [
"bandwidth", "bandwidth",
"backend_health", "backend_health",
"backend_tiny",
"process_simple",
"extra_process_cpu", "extra_process_cpu",
"stream_traffic", "stream_traffic",
"wide_cache_activity", "wide_cache_activity",
@@ -41,6 +44,34 @@ fn overview_factory_preset_matches_registry_and_operator_visibility() {
assert!(hidden.iter().all(|id| order.contains(id))); assert!(hidden.iter().all(|id| order.contains(id)));
assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok()); assert!(validate_admin_preferences(&AdminPreferences::default()).is_ok());
assert!(!AdminPreferences::default().chart_axes); assert!(!AdminPreferences::default().chart_axes);
assert!(!AdminPreferences::default().interface_icons);
assert!(!AdminPreferences::default().logo_animation);
}
#[test]
fn optional_logo_animation_preference_is_backward_compatible_and_off_by_default() {
let legacy: AdminPreferences = serde_json::from_str(r#"{"theme":"light"}"#).unwrap();
assert!(!legacy.logo_animation);
let mut enabled = legacy.clone();
enabled.logo_animation = true;
assert!(validate_admin_preferences(&enabled).is_ok());
let stored = serde_json::to_string(&enabled).unwrap();
assert!(stored.contains("\"logo_animation\":true"));
let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap();
assert!(recovered.logo_animation);
}
#[test]
fn optional_interface_icons_preference_is_backward_compatible_and_off_by_default() {
let legacy: AdminPreferences = serde_json::from_str(r#"{"theme":"light"}"#).unwrap();
assert!(!legacy.interface_icons);
let mut enabled = legacy.clone();
enabled.interface_icons = true;
assert!(validate_admin_preferences(&enabled).is_ok());
let stored = serde_json::to_string(&enabled).unwrap();
assert!(stored.contains("\"interface_icons\":true"));
let recovered: AdminPreferences = serde_json::from_str(&stored).unwrap();
assert!(recovered.interface_icons);
} }
#[test] #[test]
@@ -278,3 +309,22 @@ fn dashboard_width_preferences_preserve_legacy_and_allow_custom_grids() {
.contains_key("not_a_card")); .contains_key("not_a_card"));
assert!(validate_admin_preferences(&prefs).is_ok()); assert!(validate_admin_preferences(&prefs).is_ok());
} }
#[test]
fn customized_dashboard_preferences_fit_persistent_storage_limit() {
let mut prefs = AdminPreferences {
dashboard_widget_sizes: Some(std::collections::BTreeMap::new()),
..Default::default()
};
for widget in DASHBOARD_WIDGETS.iter() {
prefs
.dashboard_widget_sizes
.as_mut()
.unwrap()
.insert(widget.id.clone(), 12);
}
assert!(validate_admin_preferences(&prefs).is_ok());
let document = serde_json::to_string(&prefs).unwrap();
assert!(document.len() > 4096);
assert!(document.len() <= crate::db::ADMIN_PREFERENCES_MAX_BYTES);
}
+2
View File
@@ -57,6 +57,8 @@ fn admin_app_javascript() -> &'static str {
");", ");",
include_str!("web/app/overview-samples.js"), include_str!("web/app/overview-samples.js"),
include_str!("web/app/core.js"), include_str!("web/app/core.js"),
include_str!("web/app/icons.js"),
include_str!("web/app/logo-animation.js"),
include_str!("web/app/path-permissions.js"), include_str!("web/app/path-permissions.js"),
include_str!("web/app/session-resume.js"), include_str!("web/app/session-resume.js"),
include_str!("web/app/refresh.js"), include_str!("web/app/refresh.js"),
+106 -2
View File
@@ -1043,8 +1043,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// GET returns the same standalone section shape accepted by PUT, at the // GET returns the same section shape that PUT accepts.
// matching revision and without unrequested configuration fields.
let read = router let read = router
.clone() .clone()
.oneshot( .oneshot(
@@ -2147,6 +2146,8 @@ mod tests {
assert_eq!(initial["time_format"], "24h"); assert_eq!(initial["time_format"], "24h");
assert_eq!(initial["chart_style"], "default"); assert_eq!(initial["chart_style"], "default");
assert_eq!(initial["chart_axes"], false); assert_eq!(initial["chart_axes"], false);
assert_eq!(initial["interface_icons"], false);
assert_eq!(initial["logo_animation"], false);
assert_eq!(initial["button_style"], "normal"); assert_eq!(initial["button_style"], "normal");
assert_eq!(initial["corner_style"], "default"); assert_eq!(initial["corner_style"], "default");
assert_eq!( assert_eq!(
@@ -2184,6 +2185,8 @@ mod tests {
assert_eq!(legacy["time_format"], "24h"); assert_eq!(legacy["time_format"], "24h");
assert_eq!(legacy["chart_style"], "default"); assert_eq!(legacy["chart_style"], "default");
assert_eq!(legacy["chart_axes"], false); assert_eq!(legacy["chart_axes"], false);
assert_eq!(legacy["interface_icons"], false);
assert_eq!(legacy["logo_animation"], false);
assert_eq!(legacy["button_style"], "normal"); assert_eq!(legacy["button_style"], "normal");
assert_eq!(legacy["corner_style"], "default"); assert_eq!(legacy["corner_style"], "default");
assert_eq!( assert_eq!(
@@ -2206,6 +2209,8 @@ mod tests {
"time_format":"12h", "time_format":"12h",
"chart_style":"neon", "chart_style":"neon",
"chart_axes":true, "chart_axes":true,
"interface_icons":true,
"logo_animation":true,
"button_style":"compact", "button_style":"compact",
"corner_style":"rounded", "corner_style":"rounded",
"table_density":"ultra", "table_density":"ultra",
@@ -3516,6 +3521,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
let bad_sse = router let bad_sse = router
.clone()
.oneshot( .oneshot(
request("/api/events?interval=4") request("/api/events?interval=4")
.header("cookie", &cookie) .header("cookie", &cookie)
@@ -3525,6 +3531,104 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
let bad_sse_logs = router
.oneshot(
request("/api/events?logs=maybe")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(bad_sse_logs.status(), StatusCode::BAD_REQUEST);
store.close().await;
}
#[tokio::test]
async fn polling_views_omit_static_configuration_and_reject_unknown_modes() {
let (router, store, _dir, secret, _) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
for (path, omitted, retained) in [
(
"/api/cache?view=live",
&["settings", "vhosts"][..],
&["revision", "metrics", "dashboard", "bans"][..],
),
(
"/api/transform?view=live",
&["settings", "vhosts"][..],
&["revision", "resources", "metrics"][..],
),
(
"/api/traffic?view=live",
&["vhosts"][..],
&["revision", "settings", "geoip", "analytics"][..],
),
] {
let response = router
.clone()
.oneshot(
request(path)
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
let value = json_body(response).await;
for &field in omitted {
assert!(
value.get(field).is_none(),
"{path} unexpectedly contains {field}"
);
}
for &field in retained {
assert!(value.get(field).is_some(), "{path} is missing {field}");
}
}
for (path, required) in [
("/api/cache", &["settings", "vhosts"][..]),
("/api/transform", &["settings", "vhosts"][..]),
("/api/traffic", &["vhosts"][..]),
] {
let response = router
.clone()
.oneshot(
request(path)
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
let value = json_body(response).await;
assert!(value.get("revision").is_some());
for &field in required {
assert!(value.get(field).is_some(), "{path} is missing {field}");
}
}
for path in [
"/api/cache?view=unknown",
"/api/transform?view=unknown",
"/api/traffic?view=unknown",
] {
let response = router
.clone()
.oneshot(
request(path)
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "GET {path}");
}
store.close().await; store.close().await;
} }
+2 -2
View File
@@ -45,7 +45,7 @@ window.rustProxyBackendDashboard = (() => {
for (const id of ids) { for (const id of ids) {
if (!visible(id)) continue; if (!visible(id)) continue;
set(`backend-dashboard-${id.slice("backend_".length)}`, "-"); set(`backend-dashboard-${id.slice("backend_".length)}`, "-");
set(`backend-dashboard-${id.slice("backend_".length)}-detail`, t("backendWidgetUnavailable")); set(`backend-dashboard-${id.slice("backend_".length)}-detail`, id === "backend_latency" ? `${t("backendWidgetProbeCount")} -` : t("backendWidgetUnavailable"));
} }
for (const id of ["backend-dashboard-in-rate", "backend-dashboard-out-rate"]) set(id, "-"); for (const id of ["backend-dashboard-in-rate", "backend-dashboard-out-rate"]) set(id, "-");
for (const id of ["backend-dashboard-active-line", "backend-dashboard-error-line", "backend-dashboard-latency-line", "backend-dashboard-in-line", "backend-dashboard-out-line", "backend-dashboard-request-line", "backend-dashboard-capacity-line", "backend-dashboard-5xx-line", "backend-dashboard-timeout-line", "backend-dashboard-network-line", "backend-dashboard-retry-line", "backend-dashboard-queue-line"]) for (const id of ["backend-dashboard-active-line", "backend-dashboard-error-line", "backend-dashboard-latency-line", "backend-dashboard-in-line", "backend-dashboard-out-line", "backend-dashboard-request-line", "backend-dashboard-capacity-line", "backend-dashboard-5xx-line", "backend-dashboard-timeout-line", "backend-dashboard-network-line", "backend-dashboard-retry-line", "backend-dashboard-queue-line"])
@@ -191,7 +191,7 @@ window.rustProxyBackendDashboard = (() => {
} }
if (visible("backend_latency")) { if (visible("backend_latency")) {
set("backend-dashboard-latency", latency === null ? "-" : `${fmt(latency, 1)} ms`); set("backend-dashboard-latency", latency === null ? "-" : `${fmt(latency, 1)} ms`);
set("backend-dashboard-latency-detail", `${t("backendWidgetProbeCount")}: ${probes.length}`); set("backend-dashboard-latency-detail", `${t("backendWidgetProbeCount")} ${probes.length}`);
paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency)); paintSeries("latency", "backend-dashboard-latency-line", Math.max(1, ...samples.latency));
} }
if (visible("backend_traffic")) { if (visible("backend_traffic")) {
+2 -2
View File
@@ -115,7 +115,7 @@ window.rustProxyChartScale = (() => {
{ top = 0.08, bottom = 0.92, clipped = false, seriesScales = null } = {}) { { top = 0.08, bottom = 0.92, clipped = false, seriesScales = null } = {}) {
const node = typeof target === "string" ? document.getElementById(target) : target; const node = typeof target === "string" ? document.getElementById(target) : target;
const svg = node?.tagName?.toLowerCase() === "svg" ? node : node?.ownerSVGElement; const svg = node?.tagName?.toLowerCase() === "svg" ? node : node?.ownerSVGElement;
if (!svg) return; if (!svg || svg.classList.contains("ui-icon")) return;
svg.rustProxyAxisData = { svg.rustProxyAxisData = {
peak: Number.isFinite(peakValue) && peakValue > 0 ? peakValue : 1, peak: Number.isFinite(peakValue) && peakValue > 0 ? peakValue : 1,
unit, count: Number.isInteger(count) && count > 0 ? count : 0, unit, count: Number.isInteger(count) && count > 0 ? count : 0,
@@ -127,7 +127,7 @@ window.rustProxyChartScale = (() => {
} }
function refreshAxes() { function refreshAxes() {
const show = document.documentElement.dataset.chartAxes === "true"; const show = document.documentElement.dataset.chartAxes === "true";
for (const svg of document.querySelectorAll("svg")) { for (const svg of document.querySelectorAll("svg:not(.ui-icon)")) {
if (show && svg.rustProxyAxisData) renderAxes(svg); if (show && svg.rustProxyAxisData) renderAxes(svg);
else if (!show && svg.parentElement?.classList.contains("rp-chart-axis-host")) { else if (!show && svg.parentElement?.classList.contains("rp-chart-axis-host")) {
const host = svg.parentElement; const host = svg.parentElement;
+1 -2
View File
@@ -21,8 +21,7 @@ function syncConfigHistoryRestoreButton() {
button.hidden = configHistorySelectedRevision === null; button.hidden = configHistorySelectedRevision === null;
button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision); button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision);
} }
// Compare leaf fields, not serialized object/array blobs. Object IDs are used // Leaf-level diff keyed by object IDs, so reordering is not a change.
// for stable array paths so reordering vhosts does not become a huge fake diff.
function configDiff(before, after, path = "$", output = [], depth = 0) { function configDiff(before, after, path = "$", output = [], depth = 0) {
if (output.length >= 200 || Object.is(before, after)) return output; if (output.length >= 200 || Object.is(before, after)) return output;
if (depth >= 24) { if (depth >= 24) {
+218 -15
View File
@@ -44,7 +44,7 @@ async function loadLanguages() {
$("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; })); $("language").replaceChildren(automatic, ...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
$("language").value = languagePreference === "auto" ? "auto" : language; $("language").value = languagePreference === "auto" ? "auto" : language;
} }
let latestStatus = null, certificateInventory = new Map(); let latestStatus = null, certificateInventory = new Map(), appHealthUpdatedAt = null;
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null; let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
function translate() { function translate() {
clearTimeout(toastTimer); hideToast(); clearTimeout(toastTimer); hideToast();
@@ -59,16 +59,25 @@ function translate() {
$("connection-label").textContent = t(connected ? "connected" : "disconnected"); $("connection-label").textContent = t(connected ? "connected" : "disconnected");
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs); if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
syncSettingsDirtyIndicators(); syncSettingsDirtyIndicators();
window.rustProxyIcons?.refresh?.();
} }
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; } function toastNode() {
let node = document.getElementById("toast");
if (!node) { // the node lived inside a dialog that was rebuilt; recreate it instead of failing every later toast
node = document.createElement("div"); node.id = "toast"; node.className = "toast"; node.hidden = true;
node.setAttribute("role", "status"); node.setAttribute("aria-live", "polite"); node.setAttribute("popover", "manual"); document.body.append(node);
}
return node;
}
function hideToast() { const node = toastNode(); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
function raiseToast() { function raiseToast() {
const node = $("toast"); if (node.hidden) return; const node = toastNode(); if (node.hidden) return;
const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body; const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body;
if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover();
if (node.parentElement !== parent) parent.append(node); if (node.parentElement !== parent) parent.append(node);
if (typeof node.showPopover === "function") node.showPopover(); if (typeof node.showPopover === "function") node.showPopover();
} }
function toast(message, error = false) { clearTimeout(toastTimer); const node = $("toast"); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); } function toast(message, error = false) { clearTimeout(toastTimer); const node = toastNode(); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); }
function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); } function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); }
function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; } function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; }
function clearFieldError(target) { function clearFieldError(target) {
@@ -133,7 +142,7 @@ function apiErrorMessage(path, response, value) {
const area = apiArea(path); const area = apiArea(path);
return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail; return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail;
} }
new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = $("toast"); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] }); new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = toastNode(); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] });
async function api(path, options = {}) { async function api(path, options = {}) {
const started = performance.now(), trackLatency = path.startsWith("/api/"); const started = performance.now(), trackLatency = path.startsWith("/api/");
try { try {
@@ -165,7 +174,7 @@ function setPanel(name, navigate = true) {
if (name === "filelogs") void window.rustProxyFileLogs?.refresh(); if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); });
} }
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } } function bytes(value) { if (value < 1024) return `${Math.round(value)} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
function uptime(value) { function uptime(value) {
let seconds = Math.max(0, Math.floor(Number(value) || 0)); let seconds = Math.max(0, Math.floor(Number(value) || 0));
if (seconds < 60) return `${seconds}s`; if (seconds < 60) return `${seconds}s`;
@@ -179,6 +188,68 @@ function uptime(value) {
} }
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } } function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; } function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
function appHealthChecks(status) {
if (!connected || !status) return [{ level: "error", label: t("appHealthApi"), detail: t("appHealthApiError") }];
const checks = [{ level: "ok", label: t("appHealthApi"), detail: t("appHealthApiOk") }];
if (status.listeners?.restart_required) {
checks.push({ level: "warning", label: t("appHealthListeners"), detail: t("appHealthListenersRestart") });
} else {
checks.push({ level: "ok", label: t("appHealthListeners"), detail: t("appHealthListenersOk") });
}
const pools = Array.isArray(status.backend_pools) ? status.backend_pools : [];
const members = pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member })));
const down = members.filter(member => member.status === "down");
const checking = members.filter(member => member.status === "checking");
const unavailablePools = pools.filter(pool => pool.ready === false && (pool.members || []).some(member => ["up", "down", "checking"].includes(member.status))).map(pool => pool.id).filter(Boolean);
const affected = [...down, ...checking].slice(0, 5).map(member => `${member.pool}/${member.id}`).join(", ");
if (unavailablePools.length) {
let detail = t("appHealthBackendsError").replace("{pools}", unavailablePools.slice(0, 5).join(", ") + (unavailablePools.length > 5 ? " …" : ""));
if (affected) detail += ` ${t("appHealthBackendsAffected").replace("{members}", affected + (down.length + checking.length > 5 ? " …" : ""))}`;
checks.push({ level: "error", label: t("appHealthBackends"), detail });
} else if (down.length || checking.length) {
checks.push({ level: "warning", label: t("appHealthBackends"), detail: t("appHealthBackendsWarning").replace("{down}", String(down.length)).replace("{checking}", String(checking.length)).replace("{members}", affected + (down.length + checking.length > 5 ? " …" : "")) });
} else {
checks.push({ level: "ok", label: t("appHealthBackends"), detail: pools.length ? t("appHealthBackendsOk") : t("appHealthBackendsNone") });
}
const sink = status.logging?.sinks || {}, errorFile = status.logging?.error_file || {};
const loggingDropped = (Number(sink.queue_dropped) || 0) + (Number(errorFile.queue_dropped) || 0);
const loggingErrors = (Number(sink.errors) || 0) + (Number(errorFile.errors) || 0);
if (loggingDropped || loggingErrors) {
checks.push({ level: "warning", label: t("appHealthLogging"), detail: t("appHealthLoggingWarning").replace("{dropped}", String(loggingDropped)).replace("{errors}", String(loggingErrors)) });
} else {
checks.push({ level: "ok", label: t("appHealthLogging"), detail: t("appHealthLoggingOk") });
}
return checks;
}
function appHealthLevel(checks) {
if (!checks.length) return "checking";
if (checks.some(check => check.level === "error")) return "error";
if (checks.some(check => check.level === "warning")) return "warning";
return "ok";
}
function setAppHealthClass(node, level) {
if (!node) return;
node.classList.remove("is-checking", "is-ok", "is-warning", "is-error");
node.classList.add(`is-${level}`);
}
function renderAppHealth(status = latestStatus) {
const checks = appHealthChecks(status), level = appHealthLevel(checks);
const labelKeys = { checking: "appHealthSidebarChecking", ok: "appHealthSidebarOk", warning: "appHealthSidebarWarning", error: "appHealthSidebarError" };
const summaryKeys = { checking: "appHealthChecking", ok: "appHealthOk", warning: "appHealthWarning", error: "appHealthError" };
const button = $("app-health-open"), label = $("app-health-label"), summaryRow = $("app-health-summary-row"), summary = $("app-health-summary");
setAppHealthClass(button, level); setAppHealthClass(summaryRow, level);
if (label) { label.dataset.i18n = labelKeys[level]; label.textContent = t(labelKeys[level]); }
if (summary) { summary.dataset.i18n = summaryKeys[level]; summary.textContent = t(summaryKeys[level]); }
const updated = $("app-health-updated");
if (updated) updated.textContent = appHealthUpdatedAt ? t("appHealthUpdated").replace("{time}", appHealthUpdatedAt.toLocaleTimeString(locale())) : t("appHealthNoStatus");
const list = $("app-health-checks");
if (list) list.replaceChildren(...checks.map(check => {
const row = el("div", undefined, `app-health-check is-${check.level}`), dot = el("i"), copy = el("div");
dot.setAttribute("aria-hidden", "true"); copy.append(el("strong", check.label), el("p", check.detail, "fine")); row.append(dot, copy); return row;
}));
}
function enhanceEnabledControl(input, hintKey = "entityEnableHint") { function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
const label = input?.closest("label"); if (!label) return () => { }; const label = input?.closest("label"); if (!label) return () => { };
label.classList.add("vh-enable-control", "vh-wide"); label.classList.add("vh-enable-control", "vh-wide");
@@ -232,7 +303,23 @@ window.rustProxyOverviewSamples.onChange(() => {
} }
redrawOverviewHistoryCharts(); redrawOverviewHistoryCharts();
}); });
function renderStatus(s, systemSample = false) { // Safari has no CSS scroll anchoring: keep the element under the viewport centre in place
// when a live refresh changes the height of content above it.
const nativeScrollAnchoring = typeof CSS !== "undefined" && CSS.supports?.("overflow-anchor", "auto");
function preserveScrollAnchor(render) {
if (nativeScrollAnchoring || window.scrollY <= 0) return render();
const probe = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
const anchor = probe?.closest?.("article, tr, .mini-live-chart, .network-interface-row") || probe;
const before = anchor?.isConnected ? anchor.getBoundingClientRect().top : null;
const result = render();
if (before !== null && anchor.isConnected) {
const shift = anchor.getBoundingClientRect().top - before;
if (Math.abs(shift) >= 1) window.scrollBy(0, shift);
}
return result;
}
function renderStatus(s, systemSample = false) { return preserveScrollAnchor(() => renderStatusNow(s, systemSample)); }
function renderStatusNow(s, systemSample = false) {
const firstStatus = latestStatus === null; const firstStatus = latestStatus === null;
latestStatus = { latestStatus = {
...(latestStatus || {}), ...s, ...(latestStatus || {}), ...s,
@@ -241,7 +328,7 @@ function renderStatus(s, systemSample = false) {
logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging logging: s.logging ? { ...(latestStatus?.logging || {}), ...s.logging } : latestStatus?.logging
}; };
s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings(); s = latestStatus; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics; connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); $("app-health-slot").hidden = false; appHealthUpdatedAt = new Date(); renderAppHealth(s); const m = s.metrics;
$("metric-ws").textContent = m.websocket_active || 0; $("metric-ws").textContent = m.websocket_active || 0;
$("metric-ws-h1").textContent = m.websocket_h1_active || 0; $("metric-ws-h1").textContent = m.websocket_h1_active || 0;
$("metric-ws-h2").textContent = m.websocket_h2_active || 0; $("metric-ws-h2").textContent = m.websocket_h2_active || 0;
@@ -283,7 +370,7 @@ function renderStatus(s, systemSample = false) {
function renderFooter(s) { function renderFooter(s) {
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`; const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
const footer = $("footer-status"); const footer = $("footer-status");
footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`)); footer.replaceChildren(document.createTextNode(apiLatency));
const update = s.software_update; const update = s.software_update;
if (update?.update_available && update.latest_version) { if (update?.update_available && update.latest_version) {
footer.append(document.createTextNode(" / ")); footer.append(document.createTextNode(" / "));
@@ -303,7 +390,7 @@ let lastLogs = null;
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); } function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
async function refresh(force = false) { async function refresh(force = false) {
if (!authenticated) return; if (!authenticated) return;
if (force) return Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback")]); if (force) return rustProxyRefresh.manual("status-full");
return rustProxyRefresh.manual("status-fallback"); return rustProxyRefresh.manual("status-fallback");
} }
function renderLiveStatus(value) { renderStatus(value); } function renderLiveStatus(value) { renderStatus(value); }
@@ -317,15 +404,16 @@ function startLive() {
if (liveEvents && liveInterval === seconds) return; if (liveEvents && liveInterval === seconds) return;
stopLive(); stopLive();
liveInterval = seconds; liveInterval = seconds;
const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}`); const epoch = authEpoch, stream = new EventSource(`/api/events?interval=${seconds}&logs=false`);
liveEvents = stream; liveEvents = stream;
stream.addEventListener("snapshot", event => { stream.addEventListener("snapshot", event => {
if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return; if (!authenticated || epoch !== authEpoch || stream !== liveEvents) return;
try { try {
const payload = JSON.parse(event.data); const payload = JSON.parse(event.data);
if (!payload.status || !payload.logs) throw new Error("Invalid SSE payload"); if (!payload.status) throw new Error("Invalid SSE payload");
liveConnected = true; liveFallback = false; liveConnected = true; liveFallback = false;
renderLiveStatus(payload.status); renderLogs(payload.logs); renderLiveStatus(payload.status);
if (payload.logs) renderLogs(payload.logs);
window.rustProxyRefresh?.sync(); window.rustProxyRefresh?.sync();
} catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); } } catch (error) { liveConnected = false; liveFallback = true; window.rustProxyRefresh?.sync(); }
}); });
@@ -405,12 +493,12 @@ function lock(notify = true) {
window.rustProxyInlineLayout?.cancel?.(); window.rustProxyInlineLayout?.cancel?.();
window.rustProxyRefresh?.stop(); window.rustProxyRefresh?.stop();
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:"); const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage(""); pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; $("app-health-slot").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); window.rustProxyBackendDashboard?.reset(); window.rustProxyExtraOverview?.reset(); window.rustProxyWideOverview?.reset(); window.rustProxyWebOverview?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; configStorage = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; websocketProtocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); } for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset()); $("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; }); $("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); }); document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); });
renderSession(); syncAuthForm(); appHealthUpdatedAt = null; renderAppHealth(null); renderSession(); syncAuthForm();
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); } if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
} }
@@ -490,6 +578,8 @@ function stopTunnelRefresh() { window.rustProxyRefresh?.sync(); }
function startTunnelRefresh() { window.rustProxyRefresh?.sync(); } function startTunnelRefresh() { window.rustProxyRefresh?.sync(); }
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } }); $("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } }); $("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
$("app-health-open")?.addEventListener("click", () => { renderAppHealth(connected ? latestStatus : null); const dialog = $("app-health-dialog"); if (!dialog.open) dialog.showModal(); });
$("app-health-close")?.addEventListener("click", () => $("app-health-dialog").close());
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); }); $("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
$("about-close").addEventListener("click", () => $("about-dialog").close()); $("about-close").addEventListener("click", () => $("about-dialog").close());
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar"); const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
@@ -586,3 +676,116 @@ $("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor")); $("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } }); $("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); $("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
// Auto-generated identifiers: a new entry's ID follows its name (or first host) as a unique slug until the user edits the ID by hand.
const rustProxyAutoId = (() => {
const FOLD = { "ł": "l", "đ": "d", "ø": "o", "ß": "ss", "æ": "ae", "œ": "oe", "þ": "th", "ð": "d" };
const states = new WeakMap();
function slugify(value, max = 64) {
let slug = String(value ?? "").trim().toLowerCase().replace(/^\*\./, "").replace(/[łđøßæœþð]/g, ch => FOLD[ch]).normalize("NFKD").replace(/[̀-ͯ]/g, "");
slug = slug.replace(/[^a-z0-9_-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-_]+/, "").slice(0, max).replace(/[-_]+$/, "");
return slug;
}
function unique(base, taken) {
if (!base) return "";
const used = new Set([...taken].map(value => String(value).toLowerCase()));
if (!used.has(base)) return base;
for (let n = 2; n < 10000; n++) { const suffix = `-${n}`, candidate = base.slice(0, 64 - suffix.length).replace(/[-_]+$/, "") + suffix; if (!used.has(candidate)) return candidate; }
return base;
}
const locked = input => input.readOnly || input.disabled;
function suggestion(state) { return unique(slugify(state.text()), state.taken()); }
// The ID is shown as a plain value with a "Change" link; the input only appears after the user asks for it (or when validation needs it).
function paint(input, state) {
const isLocked = locked(input), wanted = suggestion(state), value = input.value.trim(), compact = !state.open || isLocked;
state.holder.classList.toggle("is-compact", compact);
state.value.textContent = value || t("autoIdPending");
state.value.classList.toggle("is-pending", !value);
state.badge.hidden = !state.auto || !value; state.badge.textContent = t("autoIdBadge");
state.change.hidden = isLocked; state.change.textContent = t("autoIdChange");
state.row.hidden = !compact;
state.hint.hidden = compact || !state.auto; state.hint.textContent = t("autoIdHint");
state.reset.hidden = compact || state.auto || state.resettable === false || !wanted || wanted === value; state.reset.textContent = t("autoIdReset");
}
function regenerate(input, state) { if (!state.auto || locked(input)) return; input.value = suggestion(state); input.setCustomValidity?.(""); paint(input, state); }
// options: { elements: inputs whose changes drive the ID, text: () => source string, taken: () => existing IDs }
function attach(input, options) {
let state = states.get(input);
if (!state) {
state = { auto: true, open: false, holder: input.closest("label") || input.parentElement, hint: document.createElement("small"), reset: document.createElement("button"), row: document.createElement("div"), value: document.createElement("code"), badge: document.createElement("span"), change: document.createElement("button") };
state.holder.classList.add("auto-id-field");
state.hint.className = "fine auto-id-hint"; state.reset.type = "button"; state.reset.className = "quiet auto-id-reset";
state.row.className = "auto-id-row"; state.value.className = "auto-id-value"; state.badge.className = "auto-id-badge"; state.change.type = "button"; state.change.className = "quiet auto-id-change";
state.row.append(state.value, state.badge, state.change); state.holder.append(state.row, state.hint, state.reset);
input.addEventListener("input", event => { if (!event.isTrusted) return; state.auto = input.value.trim() === ""; if (state.auto) regenerate(input, state); else paint(input, state); });
input.addEventListener("invalid", () => { if (!state.open) { state.open = true; paint(input, state); } });
input.addEventListener("blur", () => { if (input.value.trim() && input.validity.valid) { state.open = false; paint(input, state); } });
state.change.addEventListener("click", () => { state.open = true; paint(input, state); input.focus(); input.select?.(); });
state.reset.addEventListener("mousedown", event => event.preventDefault());
state.reset.addEventListener("click", () => { state.auto = true; regenerate(input, state); input.dispatchEvent(new Event("input", { bubbles: true })); });
states.set(input, state);
for (const source of options.elements || []) source.addEventListener("input", () => { const current = states.get(input); if (current) regenerate(input, current); });
}
state.text = options.text || (() => (options.elements || []).map(node => node.value).find(value => value.trim()) || "");
state.taken = options.taken || (() => []); state.resettable = options.resettable;
state.auto = !locked(input) && input.value.trim() === ""; state.open = false;
paint(input, state);
return { refresh() { const current = states.get(input); if (current) regenerate(input, current); } };
}
return { slugify, unique, attach };
})();
window.rustProxyAutoId = rustProxyAutoId;
// Automatic certificate choice: for a new entry, pick the stored certificate that covers every host name (exact names beat wildcards); the user can still choose another one.
const rustProxyAutoCertificate = (() => {
const states = new WeakMap();
const covers = (pattern, host) => {
pattern = String(pattern).toLowerCase(); host = String(host).toLowerCase();
if (pattern === host) return true;
if (host.startsWith("*.") || !pattern.startsWith("*.")) return false;
const suffix = pattern.slice(1);
return host.length > suffix.length && host.endsWith(suffix) && !host.slice(0, -suffix.length).includes(".");
};
function suggest(hosts, certificates) {
const names = [...new Set((hosts || []).map(host => String(host).trim().toLowerCase()).filter(Boolean))];
if (!names.length) return "";
let best = "", bestScore = -Infinity;
for (const cert of certificates || []) {
const coverage = certificateInventory.get(cert.id)?.covered_hosts?.length ? certificateInventory.get(cert.id).covered_hosts : (cert.covered_hosts || []);
let score = 0, all = true;
for (const host of names) {
if (coverage.some(pattern => String(pattern).toLowerCase() === host)) score += 2;
else if (coverage.some(pattern => covers(pattern, host))) score += 1;
else { all = false; break; }
}
if (!all) continue;
score -= coverage.length / 1000;
if (score > bestScore) { best = cert.id; bestScore = score; }
}
return best;
}
// options: { hosts: () => host names, certificates: () => certificate objects, auto: boolean, anchor: element after which the hint is placed }
function attach(select, options) {
let state = states.get(select);
if (!state) {
state = { hint: document.createElement("small") };
state.hint.className = "fine auto-cert-hint"; state.hint.hidden = true;
(options.anchor || select).after(state.hint);
select.addEventListener("change", event => { if (!event.isTrusted) return; const current = states.get(select); current.auto = false; current.hint.hidden = true; });
states.set(select, state);
}
state.options = options; state.auto = options.auto !== false && !select.value; state.hint.hidden = true;
const refresh = hosts => {
const current = states.get(select);
if (!current.auto) return;
const list = hosts || current.options.hosts();
const best = suggest(list, current.options.certificates());
if (select.value !== best) { select.value = best; select.dispatchEvent(new Event("change", { bubbles: true })); }
const certificates = current.options.certificates(), splitCoverage = !best && list.length > 1 && list.every(host => certificates.some(cert => suggest([host], [cert])));
current.hint.hidden = !best && !splitCoverage; current.hint.textContent = best ? t("autoCertHint").replace("{id}", best) : (splitCoverage ? t("autoCertSniHint") : "");
};
return { refresh };
}
return { attach, suggest, covers };
})();
window.rustProxyAutoCertificate = rustProxyAutoCertificate;
+176 -33
View File
@@ -9,9 +9,9 @@ window.rustProxyInlineLayout = (() => {
const hiddenList = $("overview-inline-hidden-list"); const hiddenList = $("overview-inline-hidden-list");
const hiddenCount = $("overview-inline-hidden-count"); const hiddenCount = $("overview-inline-hidden-count");
const modalButton = $("overview-layout-open"); const modalButton = $("overview-layout-open");
const compactButton = $("overview-inline-compact");
let draft = null; let draft = null;
let original = null; let original = null;
let dragging = null;
let opening = false; let opening = false;
let saving = false; let saving = false;
@@ -72,10 +72,7 @@ window.rustProxyInlineLayout = (() => {
if (widen) widen.disabled = at === widths.length - 1; if (widen) widen.disabled = at === widths.length - 1;
} }
} }
function clearDrag() { function clearDrag() { if (pointerDrag) settle(false); }
dragging = null;
for (const node of nodes()) node.classList.remove("is-overview-dragging", "is-overview-drop-before", "is-overview-drop-after");
}
function move(id, target, after = false) { function move(id, target, after = false) {
if (!draft || id === target) return; if (!draft || id === target) return;
if (!visibleIds().includes(id) || !visibleIds().includes(target)) return; if (!visibleIds().includes(id) || !visibleIds().includes(target)) return;
@@ -118,7 +115,7 @@ window.rustProxyInlineLayout = (() => {
const title = label(id); const title = label(id);
const bar = el("div", undefined, "overview-inline-toolbar"); const bar = el("div", undefined, "overview-inline-toolbar");
const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { }); const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { });
grip.draggable = true; grip.setAttribute("aria-describedby", "overview-inline-hint");
grip.dataset.overviewDragHandle = id; grip.dataset.overviewDragHandle = id;
const sizeControls = el("span", undefined, "overview-inline-width"); const sizeControls = el("span", undefined, "overview-inline-width");
const widthValue = el("span", "", "overview-inline-width-value"); const widthValue = el("span", "", "overview-inline-width-value");
@@ -142,6 +139,7 @@ window.rustProxyInlineLayout = (() => {
actions.hidden = false; actions.hidden = false;
hint.hidden = false; hint.hidden = false;
hint.textContent = t("overviewLayoutInlineHint"); hint.textContent = t("overviewLayoutInlineHint");
if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint");
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; } if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
if (modalButton) modalButton.disabled = true; if (modalButton) modalButton.disabled = true;
for (const node of nodes()) toolbar(node); for (const node of nodes()) toolbar(node);
@@ -195,40 +193,185 @@ window.rustProxyInlineLayout = (() => {
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; } finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
} }
function cancel() { if (draft && !saving) finish(false); } function cancel() { if (draft && !saving) finish(false); }
function dropTarget(event) { // Pointer-driven sorting with a live placeholder. The grid never back-fills
const widget = event.target?.closest?.("[data-dashboard-widget]"); // holes while a block is lifted, so the placeholder shows exactly where the
return widget && grid.contains(widget) && !widget.hidden && visibleIds().includes(widget.dataset.dashboardWidget) ? widget : null; // block lands; neighbours glide (FLIP) instead of jumping.
const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90;
const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
let pointerDrag = null;
function flip(mutate) {
const before = new Map(nodes().filter(node => !node.hidden && node !== pointerDrag?.node)
.map(node => [node, node.getBoundingClientRect()]));
mutate();
if (reducedMotion()) return;
for (const [node, rect] of before) {
const after = node.getBoundingClientRect();
const dx = rect.left - after.left, dy = rect.top - after.top;
if (!dx && !dy) continue;
node.animate?.([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
{ duration: 160, easing: "cubic-bezier(.2, .7, .3, 1)" });
}
} }
function positionAfter(widget, event) { function placeholderTarget(x, y) {
const rect = widget.getBoundingClientRect(); const drag = pointerDrag;
return event.clientX >= rect.left + rect.width / 2; const hit = document.elementFromPoint(x, y);
const widget = hit?.closest?.("[data-dashboard-widget]");
if (widget && grid.contains(widget) && widget !== drag.node && !widget.hidden) return { node: widget };
if (hit === drag.placeholder || drag.placeholder.contains(hit)) return null;
if (hit !== grid) return null;
// Empty grid area (gap or hole at a row end): attach to the nearest block in that row.
const candidates = nodes().filter(node => node !== drag.node && !node.hidden);
let best = null, bestDistance = Infinity;
for (const node of candidates) {
const rect = node.getBoundingClientRect();
if (y < rect.top - 8 || y > rect.bottom + 8) continue;
const distance = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
if (distance < bestDistance) { bestDistance = distance; best = { node, after: x > rect.left + rect.width / 2 }; }
}
if (best) return best;
const last = candidates.at(-1);
if (last && y > last.getBoundingClientRect().bottom) return { node: last, after: true };
return null;
} }
grid?.addEventListener("dragstart", event => { function updatePlaceholder(x, y) {
const drag = pointerDrag;
if (performance.now() < drag.cooldownUntil) return;
const target = placeholderTarget(x, y);
if (!target) return;
const children = [...grid.children];
const placeholderIndex = children.indexOf(drag.placeholder), targetIndex = children.indexOf(target.node);
// Entering a block swaps the placeholder past it; this is stable and never oscillates.
const after = target.after ?? placeholderIndex < targetIndex;
const reference = after ? target.node.nextSibling : target.node;
if (reference === drag.placeholder || (after ? target.node.nextSibling === drag.placeholder : target.node.previousSibling === drag.placeholder)) return;
flip(() => grid.insertBefore(drag.placeholder, reference));
drag.cooldownUntil = performance.now() + MOVE_COOLDOWN_MS;
}
function autoScroll() {
const drag = pointerDrag;
if (!drag?.active) return;
const height = window.innerHeight;
let speed = 0;
if (drag.y < EDGE) speed = -Math.ceil((EDGE - drag.y) / 4);
else if (drag.y > height - EDGE) speed = Math.ceil((drag.y - (height - EDGE)) / 4);
if (speed) {
const scroller = document.scrollingElement || document.documentElement;
const previous = scroller.scrollTop;
scroller.scrollTop += speed;
if (scroller.scrollTop !== previous) updatePlaceholder(drag.x, drag.y);
}
drag.frame = requestAnimationFrame(autoScroll);
}
function liftBlock() {
const drag = pointerDrag;
enterFreeLayout();
paint();
const rect = drag.node.getBoundingClientRect();
const placeholder = el("div", undefined, "overview-drop-placeholder");
placeholder.dataset.dashboardSpan = drag.node.dataset.dashboardSpan || "2";
placeholder.style.minHeight = `${Math.round(rect.height)}px`;
placeholder.setAttribute("aria-hidden", "true");
drag.origin = drag.node.nextSibling;
drag.placeholder = placeholder;
drag.offsetX = drag.startX - rect.left;
drag.offsetY = drag.startY - rect.top;
drag.width = rect.width;
drag.height = rect.height;
drag.active = true;
drag.cooldownUntil = 0;
grid.insertBefore(placeholder, drag.node);
grid.classList.add("is-overview-sorting");
drag.node.classList.add("is-overview-lifted");
Object.assign(drag.node.style, {
position: "fixed", left: "0px", top: "0px", width: `${rect.width}px`, height: `${rect.height}px`,
zIndex: "60", pointerEvents: "none", margin: "0",
});
moveLifted();
hint.textContent = t("overviewLayoutInlineDragging");
drag.frame = requestAnimationFrame(autoScroll);
}
function moveLifted() {
const drag = pointerDrag;
drag.node.style.transform = `translate(${drag.x - drag.offsetX}px, ${drag.y - drag.offsetY}px) rotate(.6deg) scale(1.015)`;
}
function settle(commit) {
const drag = pointerDrag;
pointerDrag = null;
if (!drag) return;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerCancel);
window.removeEventListener("keydown", onDragKey, true);
if (!drag.active) return;
cancelAnimationFrame(drag.frame);
for (const key of ["position", "left", "top", "width", "height", "zIndex", "pointerEvents", "margin", "transform"]) drag.node.style[key] = "";
drag.node.classList.remove("is-overview-lifted");
grid.classList.remove("is-overview-sorting");
if (commit) drag.placeholder.replaceWith(drag.node);
else { drag.placeholder.remove(); grid.insertBefore(drag.node, drag.origin?.parentNode === grid ? drag.origin : null); }
hint.textContent = t("overviewLayoutInlineHint");
if (commit) {
const domOrder = nodes().map(node => node.dataset.dashboardWidget).filter(id => !draft.hidden.has(id));
const iter = domOrder.values();
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
}
paint();
drag.node.querySelector(".overview-inline-grip")?.focus({ preventScroll: true });
drag.node.animate?.([{ boxShadow: "0 0 0 3px var(--accent)" }, { boxShadow: "0 0 0 0 transparent" }], { duration: 450 });
}
function onPointerMove(event) {
const drag = pointerDrag;
if (!drag || event.pointerId !== drag.pointerId) return;
drag.x = event.clientX; drag.y = event.clientY;
if (!drag.active) {
if (Math.hypot(drag.x - drag.startX, drag.y - drag.startY) < DRAG_THRESHOLD) return;
liftBlock();
}
event.preventDefault();
moveLifted();
updatePlaceholder(drag.x, drag.y);
}
function onPointerUp(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(true); }
function onPointerCancel(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(false); }
function onDragKey(event) { if (event.key === "Escape" && pointerDrag) { event.preventDefault(); event.stopPropagation(); settle(false); } }
grid?.addEventListener("pointerdown", event => {
const handle = event.target?.closest?.("[data-overview-drag-handle]"); const handle = event.target?.closest?.("[data-overview-drag-handle]");
if (!draft || !handle || !grid.contains(handle)) { event.preventDefault(); return; } if (!draft || saving || pointerDrag || !handle || !grid.contains(handle) || event.button !== 0) return;
dragging = handle.dataset.overviewDragHandle; const node = nodeFor(handle.dataset.overviewDragHandle);
event.dataTransfer?.setData("text/plain", dragging); if (!node) return;
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
nodeFor(dragging)?.classList.add("is-overview-dragging");
});
grid?.addEventListener("dragover", event => {
if (!draft || !dragging) return;
const target = dropTarget(event);
if (!target || target.dataset.dashboardWidget === dragging) return;
event.preventDefault(); event.preventDefault();
for (const node of nodes()) node.classList.remove("is-overview-drop-before", "is-overview-drop-after"); pointerDrag = { node, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, active: false };
target.classList.add(positionAfter(target, event) ? "is-overview-drop-after" : "is-overview-drop-before"); window.addEventListener("pointermove", onPointerMove, { passive: false });
if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerCancel);
window.addEventListener("keydown", onDragKey, true);
}); });
grid?.addEventListener("drop", event => { grid?.addEventListener("keydown", event => {
if (!draft || !dragging) return; const handle = event.target?.closest?.("[data-overview-drag-handle]");
if (!draft || !handle) return;
const delta = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key];
if (!delta) return;
event.preventDefault(); event.preventDefault();
const target = dropTarget(event), from = dragging; nudge(handle.dataset.overviewDragHandle, delta);
clearDrag(); nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus();
if (target && target.dataset.dashboardWidget !== from)
move(from, target.dataset.dashboardWidget, positionAfter(target, event));
}); });
grid?.addEventListener("dragend", clearDrag); function compact() {
// Explicit, one-off gap filling: reads the dense packing the browser would
// produce and adopts it as the new order, so nothing moves unexpectedly later.
if (!draft) return;
enterFreeLayout();
paint();
flip(() => {
grid.style.gridAutoFlow = "row dense";
const placed = nodes().filter(node => !node.hidden).map(node => ({ id: node.dataset.dashboardWidget, rect: node.getBoundingClientRect() }));
grid.style.gridAutoFlow = "";
placed.sort((a, b) => Math.round(a.rect.top) - Math.round(b.rect.top) || a.rect.left - b.rect.left);
const iter = placed.map(item => item.id).values();
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
paint();
});
}
compactButton?.addEventListener("click", compact);
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); }); unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
saveButton?.addEventListener("click", () => { void save(); }); saveButton?.addEventListener("click", () => { void save(); });
cancelButton?.addEventListener("click", cancel); cancelButton?.addEventListener("click", cancel);
+2 -4
View File
@@ -81,7 +81,7 @@ window.rustProxyDashboardLayout = (() => {
if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") { if (ADMIN_DASHBOARD_BY_ID.get(id)?.category === "panels") {
const card = el("article", undefined, "overview-layout-panel-preview"); const card = el("article", undefined, "overview-layout-panel-preview");
card.append(el("strong", label(id))); card.append(el("strong", label(id)));
const chart = id === "backend_table" ? null : original.querySelector?.("svg"); const chart = id === "backend_tiny" || id === "backend_table" ? null : original.querySelector?.("svg");
if (chart) { if (chart) {
const graphic = chart.cloneNode(true); const graphic = chart.cloneNode(true);
for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id"); for (const node of [graphic, ...graphic.querySelectorAll("[id]")]) node.removeAttribute("id");
@@ -144,9 +144,7 @@ window.rustProxyDashboardLayout = (() => {
widen.addEventListener("click", () => resize(id, 1)); widen.addEventListener("click", () => resize(id, 1));
const widthValue = el("span", `${width}/12`, "overview-layout-width-value"); const widthValue = el("span", `${width}/12`, "overview-layout-width-value");
widthValue.title = t("overviewLayoutWidth"); widthValue.title = t("overviewLayoutWidth");
// The visual editor intentionally keeps uniformly sized previews. // Size and order controls are grouped separately so they wrap in narrow dialogs.
// Group sizing separately from ordering/hiding so both sets wrap
// inside a narrow dialog rather than spilling into adjacent tiles.
const sizeActions = el("span", undefined, "overview-layout-size-actions"); const sizeActions = el("span", undefined, "overview-layout-size-actions");
sizeActions.append(narrow, widthValue, widen); sizeActions.append(narrow, widthValue, widen);
const orderActions = el("span", undefined, "overview-layout-order-actions"); const orderActions = el("span", undefined, "overview-layout-order-actions");
+310
View File
@@ -0,0 +1,310 @@
const UI_ICON_NS = "http://www.w3.org/2000/svg";
const UI_ICON_SHAPES = Object.freeze({
activity: [["path", { d: "M3 12h4l2-6 4 12 2-6h6" }]],
alert: [["path", { d: "M12 3 2.8 20h18.4L12 3Z" }], ["path", { d: "M12 9v4" }], ["circle", { cx: "12", cy: "17", r: ".7", fill: "currentColor", stroke: "none" }]],
archive: [["rect", { x: "3", y: "5", width: "18", height: "4", rx: "1" }], ["path", { d: "M5 9v10h14V9M9 13h6" }]],
ban: [["circle", { cx: "12", cy: "12", r: "9" }], ["path", { d: "m5.6 5.6 12.8 12.8" }]],
book: [["path", { d: "M4 4.5h5.5A2.5 2.5 0 0 1 12 7v13a3 3 0 0 0-3-3H4V4.5ZM20 4.5h-5.5A2.5 2.5 0 0 0 12 7v13a3 3 0 0 1 3-3h5V4.5Z" }]],
box: [["path", { d: "m12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z" }], ["path", { d: "m4 7.5 8 4.5 8-4.5M12 12v9" }]],
certificate: [["rect", { x: "4", y: "3", width: "16", height: "14", rx: "2" }], ["path", { d: "M8 7h8M8 10h5M9 17l-1 4 4-2 4 2-1-4" }]],
check: [["path", { d: "m5 12 4 4L19 6" }]],
chevronDown: [["path", { d: "m6 9 6 6 6-6" }]],
chevronLeft: [["path", { d: "m15 6-6 6 6 6" }]],
chevronRight: [["path", { d: "m9 6 6 6-6 6" }]],
chevronUp: [["path", { d: "m6 15 6-6 6 6" }]],
code: [["path", { d: "m9 7-5 5 5 5M15 7l5 5-5 5M13 5l-2 14" }]],
command: [["rect", { x: "4", y: "4", width: "16", height: "16", rx: "4" }], ["path", { d: "M8 12h8M12 8v8" }]],
collapse: [["path", { d: "M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7" }]],
compare: [["rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }], ["path", { d: "M12 4v16M6 9h3M6 13h3M15 11h3M15 15h3" }]],
copy: [["rect", { x: "8", y: "8", width: "11", height: "11", rx: "2" }], ["path", { d: "M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" }]],
cpu: [["rect", { x: "7", y: "7", width: "10", height: "10", rx: "2" }], ["path", { d: "M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4M10 10h4v4h-4z" }]],
database: [["ellipse", { cx: "12", cy: "5", rx: "7", ry: "3" }], ["path", { d: "M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" }]],
download: [["path", { d: "M12 3v12m0 0-4-4m4 4 4-4M4 19h16" }]],
edit: [["path", { d: "M4 20h4l11-11-4-4L4 16v4ZM13.5 6.5l4 4" }]],
eraser: [["path", { d: "m7 20-3.3-3.3a1 1 0 0 1 0-1.4l9.6-9.6a1 1 0 0 1 1.4 0l5.6 5.6a1 1 0 0 1 0 1.4L13 20H7ZM21 20H7M5.5 11.5l7 7" }]],
expand: [["path", { d: "M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" }]],
external: [["path", { d: "M14 4h6v6M20 4l-9 9" }], ["path", { d: "M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" }]],
eye: [["path", { d: "M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6S2.5 12 2.5 12Z" }], ["circle", { cx: "12", cy: "12", r: "2.5" }]],
eyeOff: [["path", { d: "M3 3l18 18M10.6 6.2A10 10 0 0 1 12 6c6 0 9.5 6 9.5 6a15 15 0 0 1-3 3.6M6.1 6.2C3.8 8 2.5 12 2.5 12s3.5 6 9.5 6a10 10 0 0 0 3.2-.5M9.9 9.9a3 3 0 0 0 4.2 4.2" }]],
file: [["path", { d: "M6 3h8l4 4v14H6V3Z" }], ["path", { d: "M14 3v5h5" }]],
fileX: [["path", { d: "M6 3h8l4 4v14H6V3ZM14 3v5h5M9 13l6 6M15 13l-6 6" }]],
filter: [["path", { d: "M3 5h18l-7 8v6l-4 2v-8L3 5Z" }]],
globe: [["circle", { cx: "12", cy: "12", r: "9" }], ["path", { d: "M3 12h18M12 3c2.5 2.7 3.5 5.7 3.5 9S14.5 18.3 12 21M12 3C9.5 5.7 8.5 8.7 8.5 12S9.5 18.3 12 21" }]],
grid: [["rect", { x: "4", y: "4", width: "6", height: "6", rx: "1" }], ["rect", { x: "14", y: "4", width: "6", height: "6", rx: "1" }], ["rect", { x: "4", y: "14", width: "6", height: "6", rx: "1" }], ["rect", { x: "14", y: "14", width: "6", height: "6", rx: "1" }]],
hash: [["path", { d: "M4 9h16M4 15h16M10 3 8 21M16 3l-2 18" }]],
history: [["path", { d: "M4 8V4m0 0h4M4 4l3 3a8 8 0 1 1-2.2 7" }], ["path", { d: "M12 8v5l3 2" }]],
info: [["circle", { cx: "12", cy: "12", r: "9" }], ["path", { d: "M12 11v5" }], ["circle", { cx: "12", cy: "7.8", r: ".8", fill: "currentColor", stroke: "none" }]],
key: [["circle", { cx: "8", cy: "12", r: "4" }], ["path", { d: "M12 12h9M18 12v3M15 12v2" }]],
layers: [["path", { d: "m12 3 9 5-9 5-9-5 9-5Z" }], ["path", { d: "m3 12.5 9 5 9-5M3 16.5l9 5 9-5" }]],
list: [["path", { d: "M9 6h11M9 12h11M9 18h11" }], ["circle", { cx: "4.5", cy: "6", r: ".8", fill: "currentColor", stroke: "none" }], ["circle", { cx: "4.5", cy: "12", r: ".8", fill: "currentColor", stroke: "none" }], ["circle", { cx: "4.5", cy: "18", r: ".8", fill: "currentColor", stroke: "none" }]],
loader: [["path", { d: "M20 12a8 8 0 1 1-2.3-5.7" }]],
lock: [["rect", { x: "5", y: "10", width: "14", height: "10", rx: "2" }], ["path", { d: "M8 10V7a4 4 0 0 1 8 0v3" }]],
login: [["path", { d: "M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 8l4 4-4 4M14 12H3" }]],
logout: [["path", { d: "M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3M16 8l4 4-4 4M20 12H9" }]],
mapPin: [["path", { d: "M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11Z" }], ["circle", { cx: "12", cy: "10", r: "2.5" }]],
menu: [["path", { d: "M4 7h16M4 12h16M4 17h16" }]],
network: [["rect", { x: "9", y: "3", width: "6", height: "5", rx: "1" }], ["rect", { x: "3", y: "16", width: "6", height: "5", rx: "1" }], ["rect", { x: "15", y: "16", width: "6", height: "5", rx: "1" }], ["path", { d: "M12 8v4M6 16v-4h12v4" }]],
palette: [["path", { d: "M12 3a9 9 0 1 0 0 18h1.5a2 2 0 0 0 0-4H12a2 2 0 0 1 0-4h4a5 5 0 0 0 0-10h-4Z" }], ["circle", { cx: "7", cy: "10", r: "1", fill: "currentColor", stroke: "none" }], ["circle", { cx: "9", cy: "6.5", r: "1", fill: "currentColor", stroke: "none" }], ["circle", { cx: "14", cy: "6", r: "1", fill: "currentColor", stroke: "none" }]],
pause: [["path", { d: "M8 5v14M16 5v14" }]],
pin: [["path", { d: "M8 3h8M10 3v5l-3 4h10l-3-4V3M12 12v9" }]],
play: [["path", { d: "m8 5 11 7-11 7V5Z" }]],
plug: [["path", { d: "M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-12 0V8ZM12 17v4" }]],
plus: [["path", { d: "M12 5v14M5 12h14" }]],
refresh: [["path", { d: "M20 7v5h-5M4 17v-5h5" }], ["path", { d: "M18.5 9A7 7 0 0 0 6.2 6.2L4 8M5.5 15A7 7 0 0 0 17.8 17.8L20 16" }]],
route: [["circle", { cx: "6", cy: "6", r: "2" }], ["circle", { cx: "18", cy: "18", r: "2" }], ["path", { d: "M8 6h4a4 4 0 0 1 4 4v0a4 4 0 0 1-4 4H8a2 2 0 0 0-2 2v0" }]],
save: [["path", { d: "M5 4h12l2 2v14H5V4Z" }], ["path", { d: "M8 4v6h8V4M8 20v-6h8v6" }]],
search: [["circle", { cx: "10.5", cy: "10.5", r: "6.5" }], ["path", { d: "m15.5 15.5 5 5" }]],
settings: [["path", { d: "M18.9 9.9L21.3 10.3L21.3 13.7L18.9 14.1L18.4 15.4L19.8 17.4L17.4 19.8L15.4 18.4L14.1 18.9L13.7 21.3L10.3 21.3L9.9 18.9L8.6 18.4L6.6 19.8L4.2 17.4L5.6 15.4L5.1 14.1L2.7 13.7L2.7 10.3L5.1 9.9L5.6 8.6L4.2 6.6L6.6 4.2L8.6 5.6L9.9 5.1L10.3 2.7L13.7 2.7L14.1 5.1L15.4 5.6L17.4 4.2L19.8 6.6L18.4 8.6Z" }], ["circle", { cx: "12", cy: "12", r: "3" }]],
shield: [["path", { d: "M12 3 20 6v6c0 5-3.3 8-8 9-4.7-1-8-4-8-9V6l8-3Z" }], ["path", { d: "m8.5 12 2.2 2.2 4.8-5" }]],
sliders: [["path", { d: "M4 7h6M14 7h6M4 17h10M18 17h2M10 4v6M14 14v6" }]],
sortDown: [["path", { d: "M8 5v14M5 16l3 3 3-3M14 8h5M14 12h4M14 16h3" }]],
sortUp: [["path", { d: "M8 19V5M5 8l3-3 3 3M14 8h3M14 12h4M14 16h5" }]],
transfer: [["path", { d: "M4 8h15m0 0-4-4m4 4-4 4M20 16H5m0 0 4-4m-4 4 4 4" }]],
trash: [["path", { d: "M4 7h16M9 3h6l1 4H8l1-4ZM7 7l1 14h8l1-14M10 11v6M14 11v6" }]],
toggleOff: [["rect", { x: "3", y: "7", width: "18", height: "10", rx: "5" }], ["circle", { cx: "8", cy: "12", r: "3", fill: "currentColor", stroke: "none" }]],
toggleOn: [["rect", { x: "3", y: "7", width: "18", height: "10", rx: "5" }], ["circle", { cx: "16", cy: "12", r: "3", fill: "currentColor", stroke: "none" }]],
undo: [["path", { d: "M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 0 11H11" }]],
unlock: [["rect", { x: "5", y: "10", width: "14", height: "10", rx: "2" }], ["path", { d: "M8 10V7a4 4 0 0 1 7.8-1.2" }]],
upload: [["path", { d: "M12 16V4m0 0-4 4m4-4 4 4M4 20h16" }]],
users: [["circle", { cx: "9", cy: "9", r: "3" }], ["path", { d: "M3 20v-2a5 5 0 0 1 10 0v2M16 8a3 3 0 0 1 0 6M16 15a5 5 0 0 1 5 5" }]],
wand: [["path", { d: "m4 20 12-12 3 3L7 23 4 20ZM14 4l1-3 1 3 3 1-3 1-1 3-1-3-3-1 3-1ZM19 14l.7-2 .8 2 2 .8-2 .7-.8 2-.7-2-2-.7 2-.8Z" }]],
x: [["path", { d: "M6 6l12 12M18 6 6 18" }]],
});
const UI_PANEL_ICONS = Object.freeze({
overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box",
redirects: "route", notfound: "fileX", streams: "transfer", "acl-profiles": "shield",
tls: "certificate", traffic: "activity", cache: "database", transform: "wand", settings: "settings",
});
const UI_SETTINGS_TAB_ICONS = Object.freeze({
appearance: "palette", listeners: "plug", connections: "globe", "docker-agents": "box",
policies: "shield", cache: "database", transform: "wand", forwarding: "route", pages: "file",
logging: "list", geoip: "mapPin", prometheus: "activity", administration: "users",
"system-info": "cpu", "certificate-api": "key", "tls-policy": "lock", "manual-config": "code", security: "shield",
});
const UI_VHOST_TAB_ICONS = Object.freeze({
details: "sliders", backends: "network", health: "activity", locations: "route", auth: "key", ssl: "lock",
cache: "database", compression: "archive", transform: "wand", security: "shield", pages: "file", advanced: "code",
});
const UI_TRANSLATED_ICON_KEYS = Object.freeze([
["trash", ["remove", "delete", "draftDiscard", "cacheRemoveBan", "cachePurgeUrl", "certificateApiRevoke", "certificateApiRevokeAll", "apiTokenRevoke", "revokeAll"]],
["x", ["close", "cancelAction", "overviewLayoutInlineCancel"]],
["save", ["certAssignApply", "saveCertificate", "savePassword", "dashboardPresetsSave", "overviewLayoutInlineSave"]],
["check", ["apply", "applyFilters", "savePolicy", "continueAction", "dockerApplyService"]],
["plus", ["addCertificate", "vhNew", "webServiceNew", "redirectNew", "notfoundNew", "streamNew", "aclProfileNew", "phpProfileAdd", "redirectAddRule", "streamAddListener", "aclProfileAddIpException", "apiTokenCreate"]],
["refresh", ["refresh", "refreshNow", "reload", "reloadAudit", "reloadCertificates", "tunnelsReload", "dockerRefresh", "cacheRefresh", "sessionRetry"]],
["undo", ["configHistoryRestoreAction", "dashboardPresetsFactory", "draftRestore"]],
["eyeOff", ["hideMetric", "overviewLayoutEditorHide"]],
["edit", ["edit", "certificateApiEdit"]],
["key", ["certificateApiRegenerate"]],
["download", ["export"]],
["upload", ["acmeImport", "aclProfileImportRules"]],
["eye", ["certificateReadNames", "dockerShowHidden", "overviewLayoutEditorShow"]],
["settings", ["settings", "vhManage", "dockerAgentsSettings"]],
["book", ["webServiceHtaccessDocs", "webServicePhpDocsButton"]],
["pause", ["logPause"]],
["pin", ["logPin"]],
["history", ["configHistory"]],
["chevronLeft", ["previous"]],
["chevronRight", ["next"]],
]);
function uiIconSvg(name) {
const definition = UI_ICON_SHAPES[name] || UI_ICON_SHAPES.command;
const svg = document.createElementNS(UI_ICON_NS, "svg");
svg.classList.add("ui-icon"); svg.dataset.uiIcon = name; svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false");
svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round");
for (const [tag, attrs] of definition) {
const node = document.createElementNS(UI_ICON_NS, tag);
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
svg.append(node);
}
return svg;
}
function uiIconStrings(target) {
return [target.textContent, target.getAttribute("aria-label"), target.title].filter(Boolean).map(value => String(value).trim().toLocaleLowerCase());
}
function uiIconMatchesTranslation(target, keys) {
if (typeof t !== "function") return false;
const strings = uiIconStrings(target); if (!strings.length) return false;
for (const key of keys) {
const raw = String(t(key) || "").trim(), translated = raw.toLocaleLowerCase();
// t() returns the key itself when a translation is missing; compare case-sensitively
// so an English label such as "Edit" for the key "edit" still counts as translated.
if (!translated || raw === key) continue;
if (strings.some(value => value === translated || value.startsWith(`${translated} `) || value.startsWith(`${translated}:`))) return true;
}
return false;
}
// Explicit choices for controls whose ids/i18n keys would otherwise collide with
// broader keyword rules (for example "unlock" vs "lock", "presets" vs "reset",
// "docker" vs "dock", or a domain noun such as "cache" before a verb such as "next").
const UI_ID_ICONS = Object.freeze({
"about-open": "info", "login-submit": "login",
"overview-layout-unlock": "unlock", "overview-inline-save": "lock", "overview-inline-compact": "grid",
"overview-layout-open": "layers", "overview-layout-open-settings": "layers",
"overview-presets-close": "x", "overview-presets-save": "save", "overview-presets-factory": "undo",
"docker-settings-open": "settings", "settings-vhost-open": "network", "settings-file-logs-open": "list",
"log-format-open": "sliders", "file-log-tab-counts": "hash", "refresh-reset": "undo",
"acme-submit": "certificate", "geoip-update-now": "download",
"cache-panel-cleanup": "eraser", "cache-invalidation-open": "ban", "cache-bans-open": "ban",
"prom-token-generate": "key", "certificate-api-save": "key", "revoke-sessions": "logout",
"acl-profile-reset": "eraser", "reset-certificate": "eraser",
"config-history-compare-open": "compare", "logs-load-history": "history",
});
const UI_I18N_ICONS = Object.freeze({
apply: "check", fileLogsCountRepeats: "hash", cacheRunInvalidation: "play",
previous: "chevronLeft", next: "chevronRight", cachePrevious: "chevronLeft", cacheNext: "chevronRight",
renewalEnable: "toggleOn", renewalDisable: "toggleOff", tunnelKill: "ban", certAssignVhosts: "network",
});
// Buttons whose visible label is already a single symbol (×, −, +, ←, →) keep the
// native glyph instead of receiving a second, competing SVG.
const UI_GLYPH_LABEL = /^[×✕−+←→↑↓⠿]$/;
function uiIconForTarget(target) {
const manual = target.dataset.uiIconName;
if (manual === "none") return null;
if (manual && UI_ICON_SHAPES[manual]) return manual;
if (target.matches(".chart-style-choice")) return null;
if (target.matches(".overview-inline-grip, .overview-inline-narrow, .overview-inline-widen, .overview-inline-up, .overview-inline-down, .overview-inline-hide, .overview-layout-narrow, .overview-layout-widen, .overview-layout-up, .overview-layout-down, .overview-layout-hide")) return null;
if (target.matches("#tunnels-open, .vhost-backend-protocol-badge, .config-history-day, .draft-dock-toggle, .mobile-menu-toggle")) return null;
if (UI_GLYPH_LABEL.test(String(target.textContent || "").trim())) return null;
if (UI_ID_ICONS[target.id]) return UI_ID_ICONS[target.id];
if (target.matches("#web-service-preset-apply, [data-cache-memory-preset], [data-cache-cold-preset], [data-transform-preset]")) return "wand";
if (target.matches("#logs-pause")) return uiIconMatchesTranslation(target, ["logResume"]) ? "play" : "pause";
if (target.matches("#docker-metrics-toggle-all, #dashboard-widget-expand")) return target.getAttribute("aria-expanded") === "true" ? "collapse" : "expand";
if (target.matches(".docker-open-logs")) return "list";
if (target.matches(".docker-view-toggle")) return uiIconMatchesTranslation(target, ["dockerUnhideVhost"]) ? "eye" : "eyeOff";
if (target.matches(".vhost-quick-toggle.is-enable")) return "toggleOn";
if (target.matches(".vhost-quick-toggle.is-disable")) return "toggleOff";
if (target.matches(".listener-h2-settings-link, .listener-h3-settings-link")) return "settings";
if (target.dataset.panel && UI_PANEL_ICONS[target.dataset.panel]) return UI_PANEL_ICONS[target.dataset.panel];
if (target.dataset.settingsTab && UI_SETTINGS_TAB_ICONS[target.dataset.settingsTab]) return UI_SETTINGS_TAB_ICONS[target.dataset.settingsTab];
if (target.dataset.tab && UI_VHOST_TAB_ICONS[target.dataset.tab]) return UI_VHOST_TAB_ICONS[target.dataset.tab];
if (target.dataset.certificateTab) return ({ installed: "certificate", renewal: "refresh", generate: "plus" })[target.dataset.certificateTab] || "certificate";
if (target.dataset.trafficTab) return ({ overview: "grid", clients: "users", requests: "list", delivery: "activity" })[target.dataset.trafficTab] || "activity";
if (target.dataset.httpSettingsTab) return "network";
if (target.dataset.policySettingsTab) return target.dataset.policySettingsTab === "headers" || target.dataset.policySettingsTab === "response" ? "list" : "shield";
if (target.dataset.cacheSettingsTab) return ({ general: "database", resilience: "shield", cold: "archive", diagnostics: "activity" })[target.dataset.cacheSettingsTab] || "database";
if (target.dataset.loggingSettingsTab) return ({ outputs: "list", format: "code", errors: "alert" })[target.dataset.loggingSettingsTab] || "list";
if (target.dataset.dashboardWidgetAction === "toggle") return target.classList.contains("is-hidden") ? "eyeOff" : "eye";
if (target.dataset.dashboardWidgetAction === "up") return "chevronLeft";
if (target.dataset.dashboardWidgetAction === "down") return "chevronRight";
const i18nKey = target.dataset.i18n || target.dataset.action || target.dataset.presetAction || "";
if (UI_I18N_ICONS[i18nKey]) return UI_I18N_ICONS[i18nKey];
for (const [key, icon] of Object.entries(UI_I18N_ICONS)) if (uiIconMatchesTranslation(target, [key]) && !["previous", "next", "apply"].includes(key)) return icon;
const signature = [target.id, target.dataset.i18n, target.dataset.i18nAria, target.dataset.action, target.className]
.filter(Boolean).join(" ").toLocaleLowerCase();
const aria = String(target.getAttribute("aria-label") || "").toLocaleLowerCase();
if (/mobile-menu|menu-toggle/.test(signature)) return "menu";
if (/is-saving|saving/.test(signature)) return "loader";
if (/sort-direction|sort-button/.test(signature)) return /descending|desc/.test(aria) ? "sortDown" : "sortUp";
if (/scroll-prev|move-earlier|layout-up|-prev\b/.test(signature)) return "chevronLeft";
if (/scroll-next|move-later|layout-down|-next\b/.test(signature)) return "chevronRight";
// Verbs win over domain nouns: a "Close" button inside the health or history
// dialog is still a close action.
if (/close|cancel/.test(signature)) return "x";
if (/sign-in|login/.test(signature)) return "login";
if (/logout|sign-out/.test(signature)) return "logout";
if (/unlock/.test(signature)) return "unlock";
if (/collapse|narrow/.test(signature)) return "collapse";
if (/expand|widen/.test(signature)) return "expand";
if (/compare|diff/.test(signature)) return "compare";
if (/health/.test(signature) && !/certificate/.test(signature)) return "activity";
if (/copy/.test(signature)) return "copy";
if (/export|download/.test(signature)) return "download";
if (/import|upload/.test(signature)) return "upload";
if (/search|find/.test(signature)) return "search";
if (/filter/.test(signature)) return "filter";
if (/inspect|preview|read-names|readnames|show-hidden|visibility|view-toggle/.test(signature)) return "eye";
if (/restore|factory/.test(signature)) return "undo";
if (/(^|[^p])reset/.test(signature)) return "undo";
if (/refresh|reload|retry|renew|update-now|updatenow/.test(signature)) return "refresh";
if (/history|revision/.test(signature)) return "history";
if (/revoke|remove|delete|discard|purge|kill/.test(signature)) return "trash";
if (/preset/.test(signature)) return "layers";
if (/new|add|create|generate|issue/.test(signature)) return "plus";
if (/save/.test(signature)) return "save";
if (/apply|submit|continue/.test(signature)) return "check";
if (/edit/.test(signature)) return "edit";
if (/pause/.test(signature)) return "pause";
if (/logs-pin|logpin|(^|[\s-])pin($|[\s-])|(^|[\s-])dock($|[\s-])/.test(signature)) return "pin";
if (/start|run-operation|cleanup-start/.test(signature)) return "play";
if (/(^|[\s-])lock($|[\s-])/.test(signature)) return "lock";
if (/certificate-api|api-token|token|secret|key/.test(signature)) return "key";
if (/certificate|acme|tls/.test(signature)) return "certificate";
if (/security|acl|policy|audit/.test(signature)) return "shield";
if (/settings|manage|configuration/.test(signature)) return "settings";
if (/docker/.test(signature)) return "box";
if (/vhost|backend|stream|tunnel|listener/.test(signature)) return "network";
if (/redirect|forward/.test(signature)) return "route";
if (/notfound|404/.test(signature)) return "fileX";
if (/cache|cold/.test(signature)) return "database";
if (/transform|minif/.test(signature)) return "wand";
if (/traffic|metric|prometheus/.test(signature)) return "activity";
if (/log/.test(signature)) return "list";
if (/docs|help|documentation/.test(signature)) return "book";
if (/php|json|advanced|code/.test(signature)) return "code";
if (/layout|dashboard|overview/.test(signature)) return "grid";
if (/about|info/.test(signature)) return "info";
if (target.matches("a[href]")) return target.href?.startsWith(location.origin) ? "file" : "external";
for (const [icon, keys] of UI_TRANSLATED_ICON_KEYS) if (uiIconMatchesTranslation(target, keys)) return icon;
if (aria && typeof t === "function") {
if (aria.startsWith(String(t("remove")).toLocaleLowerCase())) return "trash";
if (aria.startsWith(String(t("copyValue")).toLocaleLowerCase())) return "copy";
if (aria === String(t("dashboardWidgetsMoveEarlier")).toLocaleLowerCase()) return "chevronLeft";
if (aria === String(t("dashboardWidgetsMoveLater")).toLocaleLowerCase()) return "chevronRight";
}
return "command";
}
const UI_ICON_TARGET_SELECTOR = "button, a.primary, a.outline, a.quiet, #logs-menu > summary";
function uiIconTargetIsEligible(target) {
return target instanceof Element && target.matches(UI_ICON_TARGET_SELECTOR);
}
function uiDecorateIconTarget(target) {
if (!uiIconTargetIsEligible(target)) return;
const existing = target.querySelector(":scope > svg.ui-icon");
if (document.documentElement.dataset.interfaceIcons !== "true") { existing?.remove(); return; }
const name = uiIconForTarget(target);
if (!name) { existing?.remove(); return; }
if (existing?.dataset.uiIcon === name) return;
existing?.remove(); target.prepend(uiIconSvg(name));
}
function refreshUiIcons(root = document) {
if (root instanceof Element && uiIconTargetIsEligible(root)) uiDecorateIconTarget(root);
root.querySelectorAll?.(UI_ICON_TARGET_SELECTOR).forEach(uiDecorateIconTarget);
}
let uiIconObserverActive = false;
function setUiIconsEnabled(value) {
const enabled = value === true;
document.documentElement.dataset.interfaceIcons = String(enabled);
if (enabled) {
if (!uiIconObserverActive) {
uiIconObserver.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "aria-label", "aria-expanded", "aria-pressed", "data-i18n", "data-action"] });
uiIconObserverActive = true;
}
refreshUiIcons();
} else {
if (uiIconObserverActive) { uiIconObserver.disconnect(); uiIconObserverActive = false; }
document.querySelectorAll("svg.ui-icon").forEach(node => node.remove());
}
const input = $("ui-interface-icons"); if (input) input.checked = enabled;
return enabled;
}
const uiIconObserver = new MutationObserver(records => {
if (document.documentElement.dataset.interfaceIcons !== "true") return;
const targets = new Set();
for (const record of records) {
const element = record.target instanceof Element ? record.target : record.target.parentElement;
const action = element?.closest?.(UI_ICON_TARGET_SELECTOR); if (action) targets.add(action);
for (const node of record.addedNodes) {
if (!(node instanceof Element)) continue;
if (uiIconTargetIsEligible(node)) targets.add(node);
node.querySelectorAll?.(UI_ICON_TARGET_SELECTOR).forEach(item => targets.add(item));
}
}
targets.forEach(uiDecorateIconTarget);
});
window.rustProxyIcons = { refresh: refreshUiIcons, setEnabled: setUiIconsEnabled, iconFor: uiIconForTarget };
+55
View File
@@ -0,0 +1,55 @@
// Optional, account-scoped subtle animation of the sidebar r/p logo
// (Settings → Appearance → Interface → Animate logo). Off by default.
// Effects are short (≤1.2 s), run at random intervals, pause while the tab is
// hidden and are skipped entirely when the system asks for reduced motion.
const LOGO_ANIMATION_EFFECTS = Object.freeze(["spin-slash", "flip", "wave", "glow", "sheen", "glitch", "wobble"]);
const LOGO_ANIMATION_DURATION_MS = 1400;
let logoAnimationTimer = null, logoAnimationReset = null, logoAnimationLast = "", logoAnimationHoverBound = false;
function logoAnimationMark() { return document.querySelector(".sidebar a.brand > .brand-mark") || document.querySelector("a.brand > .brand-mark"); }
function logoAnimationReducedMotion() { return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; }
function prepareLogoAnimationMark(mark) {
if (mark.dataset.logoPrepared === "true") return;
// Wrap the bare "r" and "p" text nodes so each letter can be transformed on its own.
for (const node of [...mark.childNodes]) {
if (node.nodeType !== Node.TEXT_NODE || !node.textContent.trim()) continue;
const letter = document.createElement("b"); letter.className = "brand-letter"; letter.textContent = node.textContent.trim();
node.replaceWith(letter);
}
mark.dataset.logoPrepared = "true";
}
function playLogoAnimation(effect) {
const mark = logoAnimationMark();
if (!mark || document.documentElement.dataset.logoAnimation !== "true" || document.hidden || logoAnimationReducedMotion() || mark.dataset.logoEffect) return;
prepareLogoAnimationMark(mark);
const choices = LOGO_ANIMATION_EFFECTS.filter(name => name !== logoAnimationLast);
const next = LOGO_ANIMATION_EFFECTS.includes(effect) ? effect : choices[Math.floor(Math.random() * choices.length)];
logoAnimationLast = next; mark.dataset.logoEffect = next;
clearTimeout(logoAnimationReset);
logoAnimationReset = setTimeout(() => { delete mark.dataset.logoEffect; }, LOGO_ANIMATION_DURATION_MS);
}
function scheduleLogoAnimation() {
clearTimeout(logoAnimationTimer);
if (document.documentElement.dataset.logoAnimation !== "true") return;
const delay = 9000 + Math.random() * 14000;
logoAnimationTimer = setTimeout(() => { playLogoAnimation(); scheduleLogoAnimation(); }, delay);
}
function setLogoAnimationEnabled(value) {
const enabled = value === true;
document.documentElement.dataset.logoAnimation = String(enabled);
const input = $("ui-logo-animation"); if (input) input.checked = enabled;
const mark = logoAnimationMark();
if (!enabled) {
clearTimeout(logoAnimationTimer); clearTimeout(logoAnimationReset); logoAnimationTimer = null;
if (mark) delete mark.dataset.logoEffect;
return false;
}
if (mark) prepareLogoAnimationMark(mark);
if (!logoAnimationHoverBound) {
mark?.closest("a.brand")?.addEventListener("mouseenter", () => playLogoAnimation());
logoAnimationHoverBound = !!mark;
}
if (!logoAnimationTimer) { scheduleLogoAnimation(); setTimeout(() => playLogoAnimation(), 600); }
return true;
}
document.addEventListener("visibilitychange", () => { if (!document.hidden && document.documentElement.dataset.logoAnimation === "true") scheduleLogoAnimation(); });
window.rustProxyLogoAnimation = { setEnabled: setLogoAnimationEnabled, play: playLogoAnimation, effects: LOGO_ANIMATION_EFFECTS };
+44 -3
View File
@@ -8,10 +8,12 @@ window.rustProxyExtraOverview = (() => {
"extra_response_latency", "extra_backend_unhealthy", "extra_response_latency", "extra_backend_unhealthy",
"extra_total_rps", "extra_active_exchanges", "extra_h1_share", "extra_h3_share", "extra_total_rps", "extra_active_exchanges", "extra_h1_share", "extra_h3_share",
"extra_cache_miss_rate", "extra_backend_active", "extra_cache_miss_rate", "extra_backend_active",
"extra_backends_healthy", "extra_error_share", "extra_rejected_rate", "extra_cert_expiry",
]; ];
const bounded = new Set([ const bounded = new Set([
"extra_process_fds", "extra_cache_memory", "extra_cold_queue_memory", "extra_cold_storage", "extra_process_fds", "extra_cache_memory", "extra_cold_queue_memory", "extra_cold_storage",
"extra_h1_share", "extra_h2_share", "extra_h3_share", "extra_h1_share", "extra_h2_share", "extra_h3_share",
"extra_backends_healthy", "extra_cert_expiry",
]); ]);
const histories = new Map(), lastCounter = new Map(), lastValue = new Map(); const histories = new Map(), lastCounter = new Map(), lastValue = new Map();
const lastUnits = new Map(), lastSegments = new Map(); const lastUnits = new Map(), lastSegments = new Map();
@@ -98,11 +100,36 @@ window.rustProxyExtraOverview = (() => {
unit: "count", detail: "" unit: "count", detail: ""
}; };
} }
if (id === "extra_backends_healthy") {
const members = Array.isArray(status.backend_pools)
? status.backend_pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member }))) : null;
if (!members) return { value: null, unit: "count", detail: "" };
const monitored = members.filter(member => member.health_check_enabled !== false && member.health_mode !== "off");
const down = monitored.filter(member => member.status === "down" || member.healthy === false);
const up = monitored.length - down.length;
return {
value: monitored.length ? `${fmt(up)} / ${fmt(monitored.length)}` : null, unit: "text",
meterPercent: monitored.length ? up / monitored.length * 100 : null,
detail: !monitored.length ? t("overviewExtraBackendsNone")
: down.length ? `${t("overviewExtraBackendsDown")}: ${down.slice(0, 3).map(member => `${member.pool}/${member.id}`).join(", ")}${down.length > 3 ? " …" : ""}`
: t("overviewExtraBackendsAllUp"),
};
}
if (id === "extra_cert_expiry") {
const inventory = typeof certificateInventory !== "undefined" && certificateInventory instanceof Map ? [...certificateInventory.values()] : [];
const dated = inventory.filter(cert => Number.isFinite(Number(cert?.days_remaining)))
.sort((a, b) => Number(a.days_remaining) - Number(b.days_remaining));
if (!dated.length) return { value: null, unit: "days", detail: t("overviewExtraCertNone") };
const nearest = dated[0], days = Number(nearest.days_remaining);
const name = (Array.isArray(nearest.covered_hosts) && nearest.covered_hosts[0]) || (Array.isArray(nearest.hosts) && nearest.hosts[0]) || nearest.id;
return { value: days, unit: "days", meterPercent: Math.max(0, Math.min(100, days / 90 * 100)), detail: `${name} · ${fmt(dated.length)} ${t("overviewExtraCertCount")}` };
}
if (id === "extra_cache_memory") return ramBudget(c.resident_bytes, c.hot_limit_bytes); if (id === "extra_cache_memory") return ramBudget(c.resident_bytes, c.hot_limit_bytes);
if (id === "extra_cold_queue_memory") return ramBudget(c.cold_queue_bytes, c.cold_queue_limit_bytes); if (id === "extra_cold_queue_memory") return ramBudget(c.cold_queue_bytes, c.cold_queue_limit_bytes);
if (id === "extra_cold_storage") return ramBudget(c.cold_bytes, c.cold_limit_bytes); if (id === "extra_cold_storage") return ramBudget(c.cold_bytes, c.cold_limit_bytes);
if (!["extra_tls_error_rate", "extra_request_error_rate", "extra_cache_hit_rate", "extra_h2_share", if (!["extra_tls_error_rate", "extra_request_error_rate", "extra_cache_hit_rate", "extra_h2_share",
"extra_total_rps", "extra_h1_share", "extra_h3_share", "extra_cache_miss_rate"].includes(id)) "extra_total_rps", "extra_h1_share", "extra_h3_share", "extra_cache_miss_rate",
"extra_error_share", "extra_rejected_rate"].includes(id))
return { value: null, unit: "count", detail: "" }; return { value: null, unit: "count", detail: "" };
const fields = ({ const fields = ({
extra_tls_error_rate: [m.tls_handshake_errors], extra_tls_error_rate: [m.tls_handshake_errors],
@@ -113,8 +140,10 @@ window.rustProxyExtraOverview = (() => {
extra_h3_share: [m.http3_requests, m.requests], extra_h3_share: [m.http3_requests, m.requests],
extra_total_rps: [m.requests], extra_total_rps: [m.requests],
extra_cache_miss_rate: [c.misses], extra_cache_miss_rate: [c.misses],
extra_error_share: [m.errors, m.requests],
extra_rejected_rate: [m.rejected_requests, m.rejected_connections],
})[id].map(positive); })[id].map(positive);
const unit = ["extra_cache_hit_rate", "extra_h2_share", "extra_h1_share", "extra_h3_share"].includes(id) const unit = ["extra_cache_hit_rate", "extra_h2_share", "extra_h1_share", "extra_h3_share", "extra_error_share"].includes(id)
? "percent" : "rate"; ? "percent" : "rate";
if (fields.some(v => v === null)) { if (fields.some(v => v === null)) {
lastCounter.delete(id); lastCounter.delete(id);
@@ -124,6 +153,12 @@ window.rustProxyExtraOverview = (() => {
const result = elapsedDelta(id, fields, now); const result = elapsedDelta(id, fields, now);
if (!result) return { value: null, unit, detail: "", slices: null }; if (!result) return { value: null, unit, detail: "", slices: null };
const { deltas, seconds } = result; const { deltas, seconds } = result;
if (id === "extra_rejected_rate") return {
value: (deltas[0] + deltas[1]) / seconds, unit, detail: `${t("overviewExtraRejectedDetail")}: ${fmt(fields[0])} / ${fmt(fields[1])}`
};
if (id === "extra_error_share") return {
value: deltas[1] > 0 ? Math.min(100, deltas[0] / deltas[1] * 100) : 0, unit, detail: `${t("overviewExtraErrorShareDetail")}: ${fmt(deltas[0])} / ${fmt(deltas[1])}`
};
if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" }; if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" };
const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1]; const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1];
return { return {
@@ -137,6 +172,8 @@ window.rustProxyExtraOverview = (() => {
if (unit === "bytes") return fmtBytes(value); if (unit === "bytes") return fmtBytes(value);
if (unit === "ms") return `${fmt(value, 1)} ms`; if (unit === "ms") return `${fmt(value, 1)} ms`;
if (unit === "rate") return `${fmt(value, 1)}/s`; if (unit === "rate") return `${fmt(value, 1)}/s`;
if (unit === "days") return `${fmt(value)} ${t("overviewExtraDaysUnit")}`;
if (unit === "text") return String(value);
return fmt(value); return fmt(value);
} }
function points(id, history, unit) { function points(id, history, unit) {
@@ -182,7 +219,11 @@ window.rustProxyExtraOverview = (() => {
donut("extra-cache-ratio", slices?.[0], slices?.[1]); donut("extra-cache-ratio", slices?.[0], slices?.[1]);
} }
const detail = $(`overview-extra-${id}-detail`); const detail = $(`overview-extra-${id}-detail`);
if (detail && id !== "extra_cache_hit_rate") detail.textContent = metric.detail || t("overviewExtraRecentSamples"); if (detail && id !== "extra_cache_hit_rate") {
const detailText = metric.detail || "";
detail.textContent = detailText;
detail.hidden = !detailText;
}
} }
} }
function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); lastSegments.clear(); window.rustProxyChartScale.reset("extra:"); } function reset() { histories.clear(); lastCounter.clear(); lastValue.clear(); lastUnits.clear(); lastSegments.clear(); window.rustProxyChartScale.reset("extra:"); }
+8 -23
View File
@@ -2,55 +2,40 @@ window.rustProxyOverviewSamples = (() => {
const allowed = new Set([60, 120, 300, 600]); const allowed = new Set([60, 120, 300, 600]);
const storageKey = "rustproxy.overview.liveChartSamples"; const storageKey = "rustproxy.overview.liveChartSamples";
const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60; const factory = typeof ADMIN_DASHBOARD_DEFAULTS === "object" ? Number(ADMIN_DASHBOARD_DEFAULTS.overview_samples) : 60;
let saved = allowed.has(factory) ? factory : 60; let limit = allowed.has(factory) ? factory : 60;
try { try {
const stored = Number(localStorage.getItem(storageKey)); const stored = Number(localStorage.getItem(storageKey));
if (allowed.has(stored)) saved = stored; if (allowed.has(stored)) limit = stored;
} catch { /* Private browsing or storage policy: temporary selection still works. */ } } catch { /* Private browsing or storage policy: selection still works for this visit. */ }
let limit = saved;
const callbacks = new Set(); const callbacks = new Set();
const getLimit = () => limit; const getLimit = () => limit;
const trim = array => { const trim = array => {
if (Array.isArray(array) && array.length > limit) array.splice(0, array.length - limit); if (Array.isArray(array) && array.length > limit) array.splice(0, array.length - limit);
return array; return array;
}; };
function sync(messageKey = limit === saved ? "overviewSamplesSaved" : "overviewSamplesTemporary") { function sync() {
const select = document.getElementById("overview-live-samples"); const select = document.getElementById("overview-live-samples");
if (select) select.value = String(limit); if (select) select.value = String(limit);
const state = document.getElementById("overview-samples-state");
if (state) {
state.dataset.i18n = messageKey;
state.textContent = t(messageKey);
}
const save = document.getElementById("overview-samples-save");
if (save) save.disabled = limit === saved;
} }
function setLimit(value, persist = false) { function setLimit(value, persist = false) {
const count = Number(value); const count = Number(value);
if (!allowed.has(count)) return false; if (!allowed.has(count)) return false;
const changed = count !== limit;
limit = count;
if (persist) { if (persist) {
try { try {
localStorage.setItem(storageKey, String(count)); localStorage.setItem(storageKey, String(count));
saved = count;
} catch { } catch {
const changed = count !== limit; if (typeof toast === "function") toast(t("overviewSamplesStorageUnavailable"), true);
limit = count;
if (changed) for (const callback of callbacks) callback(limit);
sync("overviewSamplesStorageUnavailable");
return false;
} }
} }
const changed = count !== limit;
limit = count;
if (changed) for (const callback of callbacks) callback(limit); if (changed) for (const callback of callbacks) callback(limit);
sync(); sync();
return true; return true;
} }
function init() { function init() {
const select = document.getElementById("overview-live-samples"); const select = document.getElementById("overview-live-samples");
const save = document.getElementById("overview-samples-save"); select?.addEventListener("change", () => setLimit(select.value, true));
select?.addEventListener("change", () => setLimit(select.value));
save?.addEventListener("click", () => setLimit(select.value, true));
sync(); sync();
} }
if (document.readyState !== "complete") document.addEventListener("DOMContentLoaded", init, { once: true }); if (document.readyState !== "complete") document.addEventListener("DOMContentLoaded", init, { once: true });
+25 -7
View File
@@ -1,6 +1,6 @@
const ADMIN_THEMES = new Set(["dark", "ultra_dark", "light", "cream"]); const ADMIN_THEMES = new Set(["dark", "ultra_dark", "light", "cream"]);
const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]); const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]);
const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]); const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool", "sunset", "aurora", "ocean", "forest", "pastel", "terminal", "blueprint", "colorblind"]);
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]); const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]); const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
const ADMIN_CORNER_STYLES = new Set(["default", "square", "subtle", "rounded", "extra"]); const ADMIN_CORNER_STYLES = new Set(["default", "square", "subtle", "rounded", "extra"]);
@@ -13,10 +13,10 @@ const ADMIN_DEFAULT_HIDDEN_WIDGETS = Object.freeze([...new Set([
])]); ])]);
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({ const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default", theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
chart_axes: false, chart_axes: false, interface_icons: false, logo_animation: false,
button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null, button_style: "normal", corner_style: "default", table_density: "normal", dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_DEFAULT_HIDDEN_WIDGETS], dashboard_widget_sizes: null,
}); });
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" }); const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool", sunset: "chartStyleSunset", aurora: "chartStyleAurora", ocean: "chartStyleOcean", forest: "chartStyleForest", pastel: "chartStylePastel", terminal: "chartStyleTerminal", blueprint: "chartStyleBlueprint", colorblind: "chartStyleColorblind" });
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false; let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; } function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
@@ -123,6 +123,8 @@ function applyAdminChartAxes(value) {
window.rustProxyChartScale?.refreshAxes?.(); window.rustProxyChartScale?.refreshAxes?.();
return enabled; return enabled;
} }
function applyAdminInterfaceIcons(value) { return window.rustProxyIcons?.setEnabled?.(value === true) ?? false; }
function applyAdminLogoAnimation(value) { return window.rustProxyLogoAnimation?.setEnabled?.(value === true) ?? false; }
function applyAdminButtonStyle(value) { function applyAdminButtonStyle(value) {
const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style; const style = normalizeAdminButtonStyle(value); document.documentElement.dataset.buttonStyle = style;
const select = $("ui-button-style"); if (select) select.value = style; return style; const select = $("ui-button-style"); if (select) select.value = style; return style;
@@ -287,7 +289,7 @@ function applyAdminPreferences(value, cache = true) {
...value, ...value,
refresh: normalizeRefreshPreferences(value.refresh), refresh: normalizeRefreshPreferences(value.refresh),
theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache), theme: applyAdminTheme(value.theme, cache), language: applyAdminLanguage(value.language, cache, false), ui_scale: applyAdminUiScale(value.ui_scale, cache),
time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density), time_format: applyAdminTimeFormat(value.time_format), chart_style: applyAdminChartStyle(value.chart_style), chart_axes: applyAdminChartAxes(value.chart_axes), interface_icons: applyAdminInterfaceIcons(value.interface_icons), logo_animation: applyAdminLogoAnimation(value.logo_animation), button_style: applyAdminButtonStyle(value.button_style), corner_style: applyAdminCornerStyle(value.corner_style, cache), table_density: applyAdminTableDensity(value.table_density),
dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes, dashboard_density: applyAdminDashboardDensity(value.dashboard_density), dashboard_widgets: savedOrder, dashboard_hidden_widgets: savedHidden, dashboard_widget_sizes: savedSizes,
}; };
adminPreferences = normalized; adminPreferences = normalized;
@@ -335,7 +337,7 @@ function updateAdminPreferences(patch) {
const next = { ...current, ...patch }; const next = { ...current, ...patch };
next.refresh = normalizeRefreshPreferences(next.refresh); next.refresh = normalizeRefreshPreferences(next.refresh);
next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale); next.theme = normalizeAdminTheme(next.theme); next.language = normalizeAdminLanguage(next.language); next.ui_scale = normalizeAdminUiScale(next.ui_scale);
next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density); next.time_format = normalizeAdminTimeFormat(next.time_format); next.chart_style = normalizeAdminChartStyle(next.chart_style); next.chart_axes = next.chart_axes === true; next.interface_icons = next.interface_icons === true; next.logo_animation = next.logo_animation === true; next.button_style = normalizeAdminButtonStyle(next.button_style); next.corner_style = normalizeAdminCornerStyle(next.corner_style); next.table_density = normalizeAdminDensity(next.table_density);
next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes); next.dashboard_density = normalizeAdminDensity(next.dashboard_density, "compact"); next.dashboard_widgets = normalizeDashboardWidgets(next.dashboard_widgets); next.dashboard_hidden_widgets = normalizeHiddenDashboardWidgets(next.dashboard_hidden_widgets); next.dashboard_widget_sizes = normalizeDashboardWidgetSizes(next.dashboard_widget_sizes);
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) }); const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
if (!authenticated || epoch !== authEpoch) return adminPreferences; if (!authenticated || epoch !== authEpoch) return adminPreferences;
@@ -343,7 +345,7 @@ function updateAdminPreferences(patch) {
}); });
adminPreferencesWrite = task.catch(() => undefined); return task; adminPreferencesWrite = task.catch(() => undefined); return task;
} }
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); } function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesUnavailable = false; adminPreferencesWrite = Promise.resolve(); applyAdminInterfaceIcons(false); applyAdminLogoAnimation(false); window.rustProxyRefresh?.update(REFRESH_DEFAULTS); }
function renderRefreshPreferences(value = REFRESH_DEFAULTS) { function renderRefreshPreferences(value = REFRESH_DEFAULTS) {
const refresh = normalizeRefreshPreferences(value); const refresh = normalizeRefreshPreferences(value);
const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled; const switcher = $("refresh-live-enabled"); if (switcher) switcher.checked = refresh.live_enabled;
@@ -419,6 +421,22 @@ $("ui-chart-axes")?.addEventListener("change", () => {
applyAdminChartAxes(previous); applyAdminChartAxes(previous);
}); });
}); });
$("ui-interface-icons")?.addEventListener("change", () => {
const control = $("ui-interface-icons"), previous = adminPreferences?.interface_icons === true, next = control.checked;
applyAdminInterfaceIcons(next); if (adminPreferences) adminPreferences.interface_icons = next;
void saveAppearancePreference({ interface_icons: next }, control, () => {
if (adminPreferences) adminPreferences.interface_icons = previous;
applyAdminInterfaceIcons(previous);
});
});
$("ui-logo-animation")?.addEventListener("change", () => {
const control = $("ui-logo-animation"), previous = adminPreferences?.logo_animation === true, next = control.checked;
applyAdminLogoAnimation(next); if (adminPreferences) adminPreferences.logo_animation = next;
void saveAppearancePreference({ logo_animation: next }, control, () => {
if (adminPreferences) adminPreferences.logo_animation = previous;
applyAdminLogoAnimation(previous);
});
});
$("ui-button-style")?.addEventListener("change", () => { $("ui-button-style")?.addEventListener("change", () => {
const control = $("ui-button-style"), previous = adminPreferences?.button_style || "normal", next = normalizeAdminButtonStyle(control.value); const control = $("ui-button-style"), previous = adminPreferences?.button_style || "normal", next = normalizeAdminButtonStyle(control.value);
applyAdminButtonStyle(next); if (adminPreferences) adminPreferences.button_style = next; void saveAppearancePreference({ button_style: next }, control, () => { if (adminPreferences) adminPreferences.button_style = previous; applyAdminButtonStyle(previous); }); applyAdminButtonStyle(next); if (adminPreferences) adminPreferences.button_style = next; void saveAppearancePreference({ button_style: next }, control, () => { if (adminPreferences) adminPreferences.button_style = previous; applyAdminButtonStyle(previous); });
@@ -450,7 +468,7 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
} }
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => { document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
const patches = { const patches = {
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" }, interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", interface_icons: false, logo_animation: false, button_style: "normal", corner_style: "default" }, charts: { chart_style: "default", chart_axes: false }, tables: { table_density: "normal" },
dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null }, dashboard: { dashboard_density: ADMIN_DASHBOARD_DEFAULTS.dashboard_density, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets], dashboard_widget_sizes: null },
}; };
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control); void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
+5 -3
View File
@@ -1,6 +1,4 @@
// One continuity policy for all browser-side counter-derived rates. // Shared rate sampling: rebase after background-tab pauses instead of plotting a false spike.
// Background tabs cannot supply a valid instantaneous rate for an interval
// spanning the pause. Keep the plotted history and rebase on the next sample.
const rustProxyRateSampling = (() => { const rustProxyRateSampling = (() => {
let generation = 0; let generation = 0;
const pause = () => { generation += 1; }; const pause = () => { generation += 1; };
@@ -19,6 +17,10 @@ const rustProxyRateSampling = (() => {
at - previous.at <= Math.max(10000, seconds * 3000)); at - previous.at <= Math.max(10000, seconds * 3000));
} }
function difference(previous, values, at, intervalSeconds = 3) { function difference(previous, values, at, intervalSeconds = 3) {
if (previous && previous.generation === generation && Number.isFinite(at) &&
Number.isFinite(previous.at) && at >= previous.at && at - previous.at < 250) {
return { next: previous, deltas: null, seconds: null, rapid: true };
}
const next = { ...stamp(at), values: [...values] }; const next = { ...stamp(at), values: [...values] };
if (!continuous(previous, at, intervalSeconds) || if (!continuous(previous, at, intervalSeconds) ||
values.some((value, i) => !Number.isFinite(value) || value < 0 || values.some((value, i) => !Number.isFinite(value) || value < 0 ||
+4 -4
View File
@@ -167,10 +167,10 @@ const rustProxyRefresh = (() => {
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
const value = await api("/api/status?view=live", { signal }); const value = await api("/api/status?view=live", { signal });
if (isCurrent()) renderLiveStatus(value); if (isCurrent()) renderLiveStatus(value);
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); } }, onError: error => { if (error?.status !== 401) { connected = false; $("connection-label").textContent = t("disconnected"); $("signal").classList.add("status-error"); $("app-health-slot").hidden = false; renderAppHealth(null); } }
}); });
register("logs-fallback", { register("logs-fallback", {
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => { interval: () => options.overview_seconds, active: () => !!window.rustProxyLogs?.active?.(), run: async ({ signal, isCurrent }) => {
const value = await api("/api/logs", { signal }); const value = await api("/api/logs", { signal });
if (isCurrent()) renderLogs(value); if (isCurrent()) renderLogs(value);
} }
@@ -182,7 +182,7 @@ const rustProxyRefresh = (() => {
const value = await api("/api/system-info", { signal }); const value = await api("/api/system-info", { signal });
if (!isCurrent()) return; if (!isCurrent()) return;
latestStatus = { ...(latestStatus || {}), ...value }; latestStatus = { ...(latestStatus || {}), ...value };
if (latestStatus.metrics) renderDiagnostics(latestStatus, false, true); if (latestStatus.metrics) preserveScrollAnchor(() => renderDiagnostics(latestStatus, false, true));
window.rustProxySettings?.render(); window.rustProxySettings?.render();
} }
}); });
@@ -199,6 +199,6 @@ const rustProxyRefresh = (() => {
window.rustProxyRefresh = rustProxyRefresh; window.rustProxyRefresh = rustProxyRefresh;
$("overview-refresh")?.addEventListener("click", () => { $("overview-refresh")?.addEventListener("click", () => {
void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("logs-fallback"), rustProxyRefresh.manual("system-info")]); void Promise.all([rustProxyRefresh.manual("status-full"), rustProxyRefresh.manual("system-info")]);
}); });
$("system-info-refresh")?.addEventListener("click", () => void rustProxyRefresh.manual("system-info")); $("system-info-refresh")?.addEventListener("click", () => void rustProxyRefresh.manual("system-info"));
+107 -9
View File
@@ -48,7 +48,7 @@ function syncCertificateFileSource() {
for (const input of [cert, key]) { for (const input of [cert, key]) {
input.maxLength = external ? 4096 : 96; input.maxLength = external ? 4096 : 96;
if (external) input.removeAttribute("pattern"); if (external) input.removeAttribute("pattern");
else input.pattern = "[A-Za-z0-9_.-]+"; else input.pattern = "[A-Za-z0-9_.\\-]+";
} }
cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem"; cert.placeholder = external ? "/etc/letsencrypt/live/example.com/fullchain.pem" : "app.pem";
key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key"; key.placeholder = external ? "/etc/letsencrypt/live/example.com/privkey.pem" : "app.key";
@@ -226,7 +226,7 @@ $("reset-certificate").addEventListener("click", () => { $("certificate-form").r
syncCertificateFileSource(); syncCertificateFileSource();
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, lastCacheOutcomeRates = null, let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, lastCacheOutcomeRates = null,
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set(); processCpuSamples = [], processRssSamples = [], processFdSamples = [], processThreadSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
const hiddenNetworkInterfaces = (() => { const hiddenNetworkInterfaces = (() => {
try { try {
const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]"); const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]");
@@ -257,6 +257,68 @@ function setOptionalMetric(id, supported, text) {
const row = node.closest("div"); if (row) row.hidden = !supported; const row = node.closest("div"); if (row) row.hidden = !supported;
if (supported) node.textContent = text; if (supported) node.textContent = text;
} }
function setProcessMeter(id, used, limit) {
const meter = $(id)?.closest("div")?.querySelector(".pr-meter"); if (!meter) return;
const usedNumber = Number(used), limitNumber = Number(limit);
const ok = used != null && limit != null && Number.isFinite(usedNumber) && Number.isFinite(limitNumber) && limitNumber > 0;
meter.hidden = !ok; if (!ok) return;
const percent = Math.max(0, Math.min(100, usedNumber / limitNumber * 100));
const bar = meter.firstElementChild; bar.style.width = `${percent}%`; bar.classList.toggle("is-high", percent >= 85);
}
function renderProcessOverview(d, status, live, systemSample) {
if (!$("po-cpu")) return;
if (systemSample) {
const memory = status.memory_accounting || {};
const platform = [d.platform, d.environment].filter(Boolean).join(" / ");
setOptionalMetric("po-platform", !!d.platform && d.platform !== "Unsupported", platform);
setOptionalMetric("po-threads", d.process_threads != null, fmt(d.process_threads));
setOptionalMetric("po-start", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : formatDateTime(Number(d.process_start_time_seconds) * 1000));
setOptionalMetric("po-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
setOptionalMetric("po-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`);
setProcessMeter("po-cpu", d.process_cpu_percent, 100);
setOptionalMetric("po-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
setProcessMeter("po-rss-total", d.process_rss_bytes, d.cgroup_memory_max_bytes);
const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited"));
setOptionalMetric("po-fds", fds != null, fds);
setProcessMeter("po-fds", d.open_fds, d.max_fds);
const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
setOptionalMetric("po-cgroup", cgroupMemory != null, cgroupMemory);
setProcessMeter("po-cgroup", d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes);
setOptionalMetric("po-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
setOptionalMetric("po-rss-file", rssFile != null, rssFile);
setOptionalMetric("po-non-cache", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
setOptionalMetric("po-virtual", virtualMemory != null, virtualMemory);
const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value));
setOptionalMetric("po-data-swap", dataSwap != null, dataSwap);
const split = $("po-rss-split"), anon = Number(d.process_rss_anon_bytes), file = Number(d.process_rss_file_bytes);
const known = d.process_rss_anon_bytes != null && d.process_rss_file_bytes != null && anon + file > 0;
split.hidden = !known;
if (known) { split.firstElementChild.style.width = `${anon / (anon + file) * 100}%`; split.lastElementChild.style.width = `${file / (anon + file) * 100}%`; }
}
$("po-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
window.rustProxyChartScale.axes("po-cpu-sparkline", Math.max(1, ...processCpuSamples), "percent",
processCpuSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("po-cpu-chart-value"), live.process_cpu_percent == null ? "" : `${fmt(live.process_cpu_percent, 1)}%`);
$("po-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
window.rustProxyChartScale.axes("po-rss-sparkline", Math.max(1, ...processRssSamples), "bytes",
processRssSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("po-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
$("po-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
$("po-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
const openFdsNow = live.open_fds ?? d.open_fds, threadsNow = live.process_threads ?? d.process_threads;
$("po-fd-sparkline").setAttribute("points", sparklinePoints(processFdSamples));
window.rustProxyChartScale.axes("po-fd-sparkline", Math.max(1, ...processFdSamples), "count",
processFdSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("po-fd-chart-value"), openFdsNow == null ? "" : fmt(openFdsNow));
$("po-fd-sparkline").closest(".mini-live-chart").hidden = openFdsNow == null;
$("po-thread-sparkline").setAttribute("points", sparklinePoints(processThreadSamples));
window.rustProxyChartScale.axes("po-thread-sparkline", Math.max(1, ...processThreadSamples), "count",
processThreadSamples.length, { top: 5 / 60, bottom: 55 / 60 });
setChartText($("po-thread-chart-value"), threadsNow == null ? "" : fmt(threadsNow));
$("po-thread-sparkline").closest(".mini-live-chart").hidden = threadsNow == null;
}
function knownPair(left, right, format, zeroRight = null) { function knownPair(left, right, format, zeroRight = null) {
if (left == null && right == null) return null; if (left == null && right == null) return null;
if (left == null) return format(right); if (left == null) return format(right);
@@ -282,6 +344,15 @@ function svgNode(tag, attrs = {}) {
function setChartText(node, value) { function setChartText(node, value) {
const text = String(value); const text = String(value);
// Controls decorated by the optional SVG icons hold [svg.ui-icon, text]; keep the icon
// and update only the text node so periodic refreshes do not rebuild the button.
const icon = node.querySelector(":scope > svg.ui-icon");
if (icon) {
const last = node.lastChild;
if (last?.nodeType === 3) { if (last.data !== text) last.data = text; }
else node.append(text);
return;
}
if (node.firstChild?.nodeType === 3 && node.firstChild === node.lastChild) { if (node.firstChild?.nodeType === 3 && node.firstChild === node.lastChild) {
if (node.firstChild.data !== text) node.firstChild.data = text; if (node.firstChild.data !== text) node.firstChild.data = text;
} else if (node.textContent !== text) { } else if (node.textContent !== text) {
@@ -346,9 +417,12 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
setChartText(row.querySelector(".network-interface-hide"), t("hideMetric")); setChartText(row.querySelector(".network-interface-hide"), t("hideMetric"));
row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55"); row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55");
row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55"); row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55");
window.rustProxyChartScale.axes(row.querySelector("svg"), peak, "bytes/s", history.rx.length, // The Hide button precedes the chart and may carry an injected svg.ui-icon:
// address the chart explicitly so axes/labels never attach to the icon.
const chart = row.querySelector("svg:not(.ui-icon)");
window.rustProxyChartScale.axes(chart, peak, "bytes/s", history.rx.length,
{ top: 5 / 60, bottom: 55 / 60 }); { top: 5 / 60, bottom: 55 / 60 });
row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`); chart.setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
children.push(row); children.push(row);
} }
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name)); const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
@@ -395,15 +469,33 @@ function cacheOutcomeMix(totals) {
const counts = [hits, misses, bypasses, requests - cacheTotal]; const counts = [hits, misses, bypasses, requests - cacheTotal];
return { counts, percentages: counts.map(value => value / requests * 100), requests }; return { counts, percentages: counts.map(value => value / requests * 100), requests };
} }
// Percent labels live inside the bar segments; a label that does not fit is hidden
// (the legend carries the request counts; tooltips give both).
function fitCacheMixLabels() {
const bar = $("cache-mix-bar");
if (!bar) return;
bar.querySelectorAll(":scope > span").forEach(segment => {
segment.classList.remove("is-tight");
segment.classList.toggle("is-tight", segment.textContent !== "" && segment.scrollWidth > segment.clientWidth);
});
if (!bar.dataset.fitObserved && typeof ResizeObserver === "function") {
bar.dataset.fitObserved = "1";
new ResizeObserver(() => fitCacheMixLabels()).observe(bar);
}
}
function renderCacheMix(mix, totalRequests) { function renderCacheMix(mix, totalRequests) {
const names = ["hit", "miss", "bypass", "other"]; const names = ["hit", "miss", "bypass", "other"];
const unavailable = t(totalRequests === 0 ? "cacheMixWaiting" : "cacheMixInvalid"); const unavailable = t(totalRequests === 0 ? "cacheMixWaiting" : "cacheMixInvalid");
names.forEach((name, i) => { names.forEach((name, i) => {
const pct = mix ? mix.percentages[i] : 0; const pct = mix ? mix.percentages[i] : 0;
$(`cache-mix-${name}`).style.width = `${pct}%`; const segment = $(`cache-mix-${name}`);
segment.style.width = `${pct}%`;
segment.textContent = mix && pct > 0 ? `${fmt(pct, pct < 10 ? 1 : 0)}%` : "";
const detail = mix ? `${name.toUpperCase()}: ${fmt(mix.counts[i])} (${fmt(pct, 1)}%)` : "";
$(`cache-mix-${name}-count`).textContent = mix ? fmt(mix.counts[i]) : "-"; $(`cache-mix-${name}-count`).textContent = mix ? fmt(mix.counts[i]) : "-";
$(`cache-mix-${name}-percent`).textContent = mix ? `${fmt(pct, 1)}%` : "-"; segment.title = detail; $(`cache-mix-${name}-key`).title = detail;
}); });
fitCacheMixLabels();
$("cache-mix-headline").textContent = mix ? `${fmt(mix.percentages[0], 1)}% HIT` : "-"; $("cache-mix-headline").textContent = mix ? `${fmt(mix.percentages[0], 1)}% HIT` : "-";
const caption = $("cache-mix-caption"); const caption = $("cache-mix-caption");
const validTotal = Number.isSafeInteger(totalRequests) && totalRequests >= 0; const validTotal = Number.isSafeInteger(totalRequests) && totalRequests >= 0;
@@ -424,6 +516,7 @@ function renderCacheMix(mix, totalRequests) {
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) { function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
const d = status.system || {}, live = status.live_system || d, c = status.cache || {}, m = status.metrics || {}; const d = status.system || {}, live = status.live_system || d, c = status.cache || {}, m = status.metrics || {};
$("metric-h3").textContent = fmt(m.http3_requests); $("metric-h3").textContent = fmt(m.http3_requests);
$("metric-h3-active").textContent = fmt(m.http3_active_connections || 0);
const cacheHits = c.hits == null ? null : Number(c.hits), cacheMisses = c.misses == null ? null : Number(c.misses); const cacheHits = c.hits == null ? null : Number(c.hits), cacheMisses = c.misses == null ? null : Number(c.misses);
const cacheEvents = cacheHits !== null && cacheMisses !== null ? cacheHits + cacheMisses : 0; const cacheEvents = cacheHits !== null && cacheMisses !== null ? cacheHits + cacheMisses : 0;
$("metric-cache-ratio").textContent = cacheEvents > 0 ? `${fmt(cacheHits / cacheEvents * 100, 1)}%` : "-"; $("metric-cache-ratio").textContent = cacheEvents > 0 ? `${fmt(cacheHits / cacheEvents * 100, 1)}%` : "-";
@@ -435,8 +528,6 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("process-summary").hidden = d.process_threads == null; $("process-summary").hidden = d.process_threads == null;
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`; if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
setOptionalMetric("system-cpu", d.cpu_percent != null, `${fmt(d.cpu_percent, 1)}%`); setOptionalMetric("system-cpu", d.cpu_percent != null, `${fmt(d.cpu_percent, 1)}%`);
const sourceSupported = !!d.source && d.source !== "unsupported";
$("system-source").hidden = !sourceSupported; if (sourceSupported) $("system-source").textContent = t("realSample");
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value)); const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
setOptionalMetric("system-memory", systemMemory != null, systemMemory); setOptionalMetric("system-memory", systemMemory != null, systemMemory);
const ramMeter = $("memory-meter"), ramFill = $("memory-meter-fill"); const ramMeter = $("memory-meter"), ramFill = $("memory-meter-fill");
@@ -457,7 +548,9 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
setOptionalMetric("system-swap", systemSwap != null, systemSwap); setOptionalMetric("system-swap", systemSwap != null, systemSwap);
const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0; const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0;
setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : ""); setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : "");
setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : uptime(d.system_uptime_seconds)); const systemUptime = $("system-uptime"), systemUptimeMeta = systemUptime?.closest(".system-uptime-meta");
if (systemUptimeMeta) systemUptimeMeta.hidden = d.system_uptime_seconds == null;
if (systemUptime && d.system_uptime_seconds != null) systemUptime.textContent = uptime(d.system_uptime_seconds);
} }
$("system-chart").hidden = live.cpu_percent == null; if (appendSample && live.cpu_percent != null) { systemSamples.push(Number(live.cpu_percent)); systemSamples = systemSamples.slice(-window.rustProxyOverviewSamples.getLimit()); } $("system-chart").hidden = live.cpu_percent == null; if (appendSample && live.cpu_percent != null) { systemSamples.push(Number(live.cpu_percent)); systemSamples = systemSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" ")); $("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
@@ -497,6 +590,9 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
if (appendSample) { if (appendSample) {
if (live.process_cpu_percent != null) { processCpuSamples.push(Number(live.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-window.rustProxyOverviewSamples.getLimit()); } if (live.process_cpu_percent != null) { processCpuSamples.push(Number(live.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
if (live.process_rss_bytes != null) { processRssSamples.push(Number(live.process_rss_bytes)); processRssSamples = processRssSamples.slice(-window.rustProxyOverviewSamples.getLimit()); } if (live.process_rss_bytes != null) { processRssSamples.push(Number(live.process_rss_bytes)); processRssSamples = processRssSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
const openFds = live.open_fds ?? d.open_fds, threadCount = live.process_threads ?? d.process_threads;
if (openFds != null) { processFdSamples.push(Number(openFds)); processFdSamples = processFdSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
if (threadCount != null) { processThreadSamples.push(Number(threadCount)); processThreadSamples = processThreadSamples.slice(-window.rustProxyOverviewSamples.getLimit()); }
} }
const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples); const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples);
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak)); $("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
@@ -568,6 +664,7 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes)); setChartText($("process-rss-chart-value"), live.process_rss_bytes == null ? "" : fmtBytes(live.process_rss_bytes));
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null; $("process-cpu-sparkline").closest(".mini-live-chart").hidden = live.process_cpu_percent == null;
$("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null; $("process-rss-sparkline").closest(".mini-live-chart").hidden = live.process_rss_bytes == null;
renderProcessOverview(d, status, live, systemSample);
updateCacheMemorySizing(); updateCacheMemorySizing();
const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null; const networkRateSupported = live.network_rx_bytes_per_second != null && live.network_tx_bytes_per_second != null;
if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`); if (systemSample) setOptionalMetric("system-network", d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null, `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}`);
@@ -597,6 +694,7 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("system-sample-age").hidden = age == null; $("system-sample-age").hidden = age == null;
if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`; if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`;
$("system-sample-age").classList.toggle("status-error", age != null && age > 10); $("system-sample-age").classList.toggle("status-error", age != null && age > 10);
if ($("po-sample-age")) { $("po-sample-age").hidden = age == null; if (age != null) $("po-sample-age").textContent = `${fmt(age)} s`; $("po-sample-age").classList.toggle("status-error", age != null && age > 10); }
const l = status.listeners || {}; const l = status.listeners || {};
$("listener-pending").hidden = !l.restart_required; $("listener-pending").hidden = !l.restart_required;
$("listener-pending").textContent = t("restartPending"); $("listener-pending").textContent = t("restartPending");
+43 -7
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
(() => { (() => {
let loading = false, offset = 0, generation = 0; let loading = false, offset = 0, generation = 0, staticData = null, staticRevision = null;
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null; let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
const liveChartSamples = { hits: [], misses: [], bypasses: [] }; const liveChartSamples = { hits: [], misses: [], bypasses: [] };
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
@@ -67,6 +67,22 @@
return groups.filter(row => cacheEnabledFor(data, row) === wanted); return groups.filter(row => cacheEnabledFor(data, row) === wanted);
}; };
const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; }; const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; };
// Decision counters: label + count + share bar; top three visible, the rest folded into a details toggle.
// Folded counters keep their open/closed state across auto-refresh re-renders.
const openReasonRows = new Set();
function reasonsCell(reasons, rowKey = "") {
const td = el("td", undefined, "cache-reasons"), entries = Object.entries(reasons || {}).sort((a, b) => b[1] - a[1]);
if (!entries.length) { td.append(el("span", "-", "cache-reason-none")); return td; }
const max = entries[0][1] || 1, line = ([key, count]) => {
const item = el("div", undefined, "cache-reason-line"), bar = el("i"); bar.style.width = `${Math.max(4, Math.round(count / max * 100))}%`;
const track = el("span", undefined, "cache-reason-track"); track.append(bar);
item.append(el("span", reason(key), "cache-reason-name"), el("b", number(count), "cache-reason-count"), track); item.title = key; return item;
};
entries.slice(0, 3).forEach(entry => td.append(line(entry)));
if (entries.length > 3) { const more = el("details", undefined, "cache-reason-more"); more.open = openReasonRows.has(rowKey); more.addEventListener("toggle", () => { if (more.open) openReasonRows.add(rowKey); else openReasonRows.delete(rowKey); }); more.append(el("summary", `+${entries.length - 3}`)); entries.slice(3).forEach(entry => more.append(line(entry))); td.append(more); }
return td;
}
function resetLiveChart(selected = scope()) { function resetLiveChart(selected = scope()) {
liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null; liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null;
for (const values of Object.values(liveChartSamples)) values.length = 0; for (const values of Object.values(liveChartSamples)) values.length = 0;
@@ -162,8 +178,13 @@
$("cp-expired").textContent = `${number(m.evictions)} / ${number(m.expired)}`; $("cp-expired").textContent = `${number(m.evictions)} / ${number(m.expired)}`;
$("cp-rejected").textContent = `${t("cacheRejected")}: ${number(m.rejected_fills)} · ${t("cacheMemoryPressureShort")}: ${number(m.memory_pressure_rejections)} · ${t("cacheNukeLimitShort")}: ${number(m.hot_eviction_limited)}`; $("cp-rejected").textContent = `${t("cacheRejected")}: ${number(m.rejected_fills)} · ${t("cacheMemoryPressureShort")}: ${number(m.memory_pressure_rejections)} · ${t("cacheNukeLimitShort")}: ${number(m.hot_eviction_limited)}`;
$("cache-domain-rows").replaceChildren(...shownGroups.map(row => { $("cache-domain-rows").replaceChildren(...shownGroups.map(row => {
const tr = el("tr"); for (const value of [row.vhost || t("cacheLegacy"), row.domain, number(row.hot_entries), number(row.cold_entries), fmtBytes(row.resident_bytes), fmtBytes(row.cold_bytes), `${number(row.hits)} / ${number(row.misses)} / ${number(row.bypasses)}`, `${fmt((row.hit_ratio || 0) * 100, 1)}%`]) tr.append(el("td", value)); const tr = el("tr"); for (const value of [row.vhost || t("cacheLegacy"), row.domain, number(row.hot_entries), number(row.cold_entries), fmtBytes(row.resident_bytes), fmtBytes(row.cold_bytes)]) tr.append(el("td", value));
const td = el("td", undefined, "cache-reasons"); for (const [key, count] of Object.entries(row.reasons || {}).sort((a, b) => b[1] - a[1])) { const tag = el("span", `${reason(key)}: ${number(count)}`, "cache-reason"); tag.title = key; td.append(tag); } tr.append(td); return tr; // HIT / MISS / BYPASS: coloured counts that match the dashboard cache bar, plus a hit-ratio meter.
const outcomes = el("td", undefined, "cache-outcomes-cell");
[[row.hits, "cache-outcome-hit"], [row.misses, "cache-outcome-miss"], [row.bypasses, "cache-outcome-bypass"]].forEach(([count, cls], index) => { if (index) outcomes.append(el("span", "/", "cache-outcome-sep")); outcomes.append(el("span", number(count), cls)); });
const ratioPercent = Math.max(0, Math.min(100, (row.hit_ratio || 0) * 100)), ratio = el("td", undefined, "cache-ratio-cell"), meter = el("span", undefined, "cache-ratio-meter"), fill = el("i"); fill.style.width = `${ratioPercent}%`; meter.append(fill); ratio.append(el("span", `${fmt(ratioPercent, 1)}%`, "cache-ratio-value"), meter);
tr.append(outcomes, ratio);
tr.append(reasonsCell(row.reasons, `${row.vhost || ""}\u0000${row.domain || ""}`)); return tr;
})); }));
const domainEmpty = $("cache-domains-empty"); const domainEmpty = $("cache-domains-empty");
domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains"); domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains");
@@ -172,11 +193,17 @@
const tr = el("tr"), resident = Number(row.resident_bytes) || 0, disk = Number(row.storage_bytes) || 0, body = Number(row.body_bytes) || 0, storage = resident || disk; const tr = el("tr"), resident = Number(row.resident_bytes) || 0, disk = Number(row.storage_bytes) || 0, body = Number(row.body_bytes) || 0, storage = resident || disk;
tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain)); tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain));
const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue); const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue);
if (row.transformed) target.append(el("span", t("transformBadge"), "cache-transform-badge")); if (row.transformed) {
// Keep the badge outside the ellipsised text so a long URL never clips it.
const wrap = el("div", undefined, "cache-target-wrap");
wrap.append(el("span", targetValue, "cache-target-text"), el("span", t("transformBadge"), "cache-transform-badge"));
target.replaceChildren(wrap);
}
tr.append(target); tr.append(target);
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`; const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
tr.append(makeCopyCell(keyTags)); tr.append(makeCopyCell(keyTags));
for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value)); tr.append(el("td", undefined)); tr.lastChild.append(el("span", row.tier === "cold" ? t("cacheCold") : t("cacheHot"), `cache-tier-pill ${row.tier === "cold" ? "is-cold" : "is-hot"}`));
for (const value of [fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr; const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr;
})); }));
const bans = data.bans || []; const bans = data.bans || [];
@@ -229,7 +256,16 @@
const query = new URLSearchParams({ offset: String(offset), limit: "50" }); const query = new URLSearchParams({ offset: String(offset), limit: "50" });
if (scope() !== "*") query.set("vhost", scope()); if (scope() !== "*") query.set("vhost", scope());
try { try {
const data = await api(`/api/cache?${query}`, { signal }); const base = `/api/cache?${query}`;
if (staticData) query.set("view", "live");
let data = await api(`/api/cache?${query}`, { signal });
if (staticData && data.revision !== staticRevision) data = await api(base, { signal });
if (data.settings && Array.isArray(data.vhosts)) {
staticData = { settings: data.settings, vhosts: data.vhosts };
staticRevision = data.revision;
} else if (staticData) {
data = { ...staticData, ...data };
}
if (isCurrent() && token === generation) render(data); if (isCurrent() && token === generation) render(data);
} catch (error) { } catch (error) {
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) { if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
@@ -343,7 +379,7 @@
$("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync()); $("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync());
window.rustProxyCachePanel = { window.rustProxyCachePanel = {
refresh, configure, reason, reset() { refresh, configure, reason, reset() {
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans(); generation++; offset = 0; staticData = null; staticRevision = null; resetLiveChart(); closeInvalidation(); closeBans();
$("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren(); $("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren();
$("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status"); $("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status");
} }
-1
View File
@@ -243,5 +243,4 @@ curl --fail --show-error --request POST \
} }
}; };
renderCertificateApi(); renderCertificateApi();
loadCertificateApi();
})(); })();
+209 -17
View File
@@ -1,6 +1,4 @@
/* Theme palettes and appearance tokens. Runtime preference variables remain writable by JavaScript. /* Theme palettes and appearance tokens */
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
:root { :root {
color-scheme: dark; color-scheme: dark;
@@ -259,20 +257,6 @@ html[data-theme=cream] {
--scrollbar-thumb-hover: #978a76; --scrollbar-thumb-hover: #978a76;
} }
body[data-density=dense] {
--ui-gap: 4px;
--ui-card: 7px;
--ui-font: 11.5px;
--ui-control: 25px;
}
body[data-density=comfortable] {
--ui-gap: 13px;
--ui-card: 17px;
--ui-font: 14px;
--ui-control: 36px;
}
html[data-button-style=compact] body { html[data-button-style=compact] body {
--button-padding: 3px 7px; --button-padding: 3px 7px;
--button-font-size: 10px; --button-font-size: 10px;
@@ -335,6 +319,62 @@ html[data-button-style=compact] body {
--sw4: #70d7ff; --sw4: #70d7ff;
} }
.chart-style-choice[data-chart-preset=sunset] {
--sw1: #ff7a59;
--sw2: #ffb347;
--sw3: #e8509b;
--sw4: #9b6bff;
}
.chart-style-choice[data-chart-preset=aurora] {
--sw1: #4ff0b0;
--sw2: #6ec8ff;
--sw3: #b78cff;
--sw4: #ff7ad9;
}
.chart-style-choice[data-chart-preset=ocean] {
--sw1: #3fa7ff;
--sw2: #2fd0c0;
--sw3: #6f8cff;
--sw4: #9be7ff;
}
.chart-style-choice[data-chart-preset=forest] {
--sw1: #7bc96f;
--sw2: #d6c45a;
--sw3: #4fae9a;
--sw4: #c98f5c;
}
.chart-style-choice[data-chart-preset=pastel] {
--sw1: #f4b6c2;
--sw2: #b5e0c2;
--sw3: #b4c8f5;
--sw4: #e3c8f0;
}
.chart-style-choice[data-chart-preset=terminal] {
--sw1: #6bff7a;
--sw2: #e5ff6b;
--sw3: #4fd0a0;
--sw4: #ffb347;
}
.chart-style-choice[data-chart-preset=blueprint] {
--sw1: #cfe6ff;
--sw2: #8fc4ff;
--sw3: #ffffff;
--sw4: #7aa8e0;
}
.chart-style-choice[data-chart-preset=colorblind] {
--sw1: #e69f00;
--sw2: #56b4e9;
--sw3: #009e73;
--sw4: #cc79a7;
}
html[data-chart-style=default] { html[data-chart-style=default] {
--chart-accent: var(--accent); --chart-accent: var(--accent);
--chart-secondary: var(--green); --chart-secondary: var(--green);
@@ -487,6 +527,158 @@ html[data-chart-style=warm] {
--chart-svg-filter: saturate(1.1) brightness(1.04); --chart-svg-filter: saturate(1.1) brightness(1.04);
} }
html[data-chart-style=sunset] {
--chart-linecap: round;
--chart-linejoin: round;
--chart-accent: #ff7a59;
--chart-secondary: #ffb347;
--chart-tertiary: #e8509b;
--chart-quaternary: #9b6bff;
--chart-quinary: #ffd479;
--chart-senary: #ff9fb2;
--chart-septenary: #c08ad8;
--chart-octonary: #8f6aa8;
--chart-surface: color-mix(in srgb, var(--panel2) 90%, #3a1428 10%);
--chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #2e1022 8%);
--chart-line-width: 2.5;
--chart-grid-opacity: .36;
--chart-grid-dash: 1 0;
--chart-svg-filter: saturate(1.12) brightness(1.04);
}
html[data-chart-style=aurora] {
--chart-linecap: round;
--chart-linejoin: round;
--chart-accent: #4ff0b0;
--chart-secondary: #6ec8ff;
--chart-tertiary: #b78cff;
--chart-quaternary: #ff7ad9;
--chart-quinary: #c8ff7a;
--chart-senary: #7affea;
--chart-septenary: #8fa0ff;
--chart-octonary: #d0a8ff;
--chart-surface: color-mix(in srgb, var(--panel2) 91%, #0a2630 9%);
--chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #082028 8%);
--chart-line-width: 2.4;
--chart-grid-opacity: .4;
--chart-grid-dash: 2 5;
--chart-svg-filter: saturate(1.2) brightness(1.08);
}
html[data-chart-style=ocean] {
--chart-linecap: round;
--chart-linejoin: round;
--chart-accent: #3fa7ff;
--chart-secondary: #2fd0c0;
--chart-tertiary: #6f8cff;
--chart-quaternary: #9be7ff;
--chart-quinary: #52e0a8;
--chart-senary: #78b8ff;
--chart-septenary: #4a7fb5;
--chart-octonary: #5f9ea8;
--chart-surface: color-mix(in srgb, var(--panel2) 90%, #061c33 10%);
--chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #05182b 8%);
--chart-line-width: 2.2;
--chart-grid-opacity: .4;
--chart-grid-dash: 2 5;
--chart-svg-filter: saturate(1.05);
}
html[data-chart-style=forest] {
--chart-linecap: round;
--chart-linejoin: round;
--chart-accent: #7bc96f;
--chart-secondary: #d6c45a;
--chart-tertiary: #4fae9a;
--chart-quaternary: #c98f5c;
--chart-quinary: #a4d86a;
--chart-senary: #e0a84f;
--chart-septenary: #7aa86a;
--chart-octonary: #8c7a5a;
--chart-surface: color-mix(in srgb, var(--panel2) 91%, #11230f 9%);
--chart-surface-alt: color-mix(in srgb, var(--panel) 92%, #0e1d0d 8%);
--chart-line-width: 2.3;
--chart-grid-opacity: .34;
--chart-grid-dash: 1 0;
--chart-svg-filter: saturate(.96);
}
html[data-chart-style=pastel] {
--chart-linecap: round;
--chart-linejoin: round;
--chart-accent: #f4b6c2;
--chart-secondary: #b5e0c2;
--chart-tertiary: #b4c8f5;
--chart-quaternary: #e3c8f0;
--chart-quinary: #f6dba4;
--chart-senary: #a8dcdc;
--chart-septenary: #d2b8e8;
--chart-octonary: #b9c2cc;
--chart-surface: color-mix(in srgb, var(--panel2) 90%, #fff 10%);
--chart-surface-alt: color-mix(in srgb, var(--panel) 93%, #fff 7%);
--chart-line-width: 3;
--chart-grid-opacity: .44;
--chart-grid-dash: 1 0;
--chart-svg-filter: saturate(.82) brightness(1.08);
}
html[data-chart-style=terminal] {
--chart-linecap: square;
--chart-linejoin: miter;
--chart-accent: #6bff7a;
--chart-secondary: #e5ff6b;
--chart-tertiary: #4fd0a0;
--chart-quaternary: #ffb347;
--chart-quinary: #a6ff9a;
--chart-senary: #d4ff9a;
--chart-septenary: #58b868;
--chart-octonary: #7a8f58;
--chart-surface: color-mix(in srgb, var(--panel2) 88%, #031a07 12%);
--chart-surface-alt: color-mix(in srgb, var(--panel) 90%, #021405 10%);
--chart-line-width: 1.6;
--chart-grid-opacity: .7;
--chart-grid-dash: 1 3;
--chart-svg-filter: saturate(1.1) contrast(1.1);
}
html[data-chart-style=blueprint] {
--chart-linecap: square;
--chart-linejoin: miter;
--chart-accent: #cfe6ff;
--chart-secondary: #8fc4ff;
--chart-tertiary: #ffffff;
--chart-quaternary: #7aa8e0;
--chart-quinary: #a8d0ff;
--chart-senary: #e8f2ff;
--chart-septenary: #6a90c0;
--chart-octonary: #506c94;
--chart-surface: color-mix(in srgb, var(--panel2) 84%, #0a2a5c 16%);
--chart-surface-alt: color-mix(in srgb, var(--panel) 88%, #08224a 12%);
--chart-line-width: 1.3;
--chart-grid-opacity: .78;
--chart-grid-dash: 4 4;
--chart-svg-filter: saturate(.9) contrast(1.06);
}
html[data-chart-style=colorblind] {
--chart-linecap: round;
--chart-linejoin: round;
--chart-accent: #e69f00;
--chart-secondary: #56b4e9;
--chart-tertiary: #009e73;
--chart-quaternary: #cc79a7;
--chart-quinary: #f0e442;
--chart-senary: #0072b2;
--chart-septenary: #d55e00;
--chart-octonary: #bdbdbd;
--chart-surface: color-mix(in srgb, var(--panel2) 94%, #000 6%);
--chart-surface-alt: color-mix(in srgb, var(--panel) 94%, #000 6%);
--chart-line-width: 2.6;
--chart-grid-opacity: .7;
--chart-grid-dash: 3 4;
--chart-svg-filter: contrast(1.1);
}
html[data-chart-style] { html[data-chart-style] {
--traffic-h1: var(--chart-accent); --traffic-h1: var(--chart-accent);
--traffic-h2: var(--chart-secondary); --traffic-h2: var(--chart-secondary);
+132 -52
View File
@@ -1,6 +1,4 @@
/* Shared reset, typography, controls, forms, tables, cards and overlay primitives. /* Shared reset, typography, controls, forms, tables, cards and overlay primitives */
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -89,6 +87,53 @@ button:disabled {
cursor: wait; cursor: wait;
} }
.ui-icon {
display: inline-block;
width: 14px;
height: 14px;
margin-inline-end: 6px;
vertical-align: -2px;
flex: 0 0 auto;
pointer-events: none;
}
html[data-interface-icons="true"] svg.ui-icon {
width: 14px;
height: 14px;
}
.nav>.ui-icon {
width: 16px;
height: 16px;
margin-inline-end: 0;
}
html[data-interface-icons="true"] .nav>svg.ui-icon {
width: 16px;
height: 16px;
}
.backend-sort-direction>.ui-icon,
.dashboard-widget-scroll-button>.ui-icon,
.host-chip button>.ui-icon,
.acl-picker-remove>.ui-icon,
.draft-dock-remove>.ui-icon,
.vhost-backend-popup-close>.ui-icon,
.dashboard-widget-toggle>.ui-icon {
margin-inline-end: 0;
}
button.is-saving>.ui-icon[data-ui-icon="loader"] {
animation: ui-icon-spin .8s linear infinite;
}
@keyframes ui-icon-spin {
to {
transform: rotate(360deg);
}
}
html[data-interface-icons="true"] .nav>span:first-of-type,
[hidden], [hidden],
#file-log-view-counts[hidden], #file-log-view-counts[hidden],
#file-log-view-entries[hidden], #file-log-view-entries[hidden],
@@ -713,7 +758,8 @@ progress::-moz-progress-bar {
text-align: left; text-align: left;
} }
#pool-search { #pool-search,
#bo-search {
width: calc(min(250px, 30vw)); width: calc(min(250px, 30vw));
min-width: 170px; min-width: 170px;
} }
@@ -970,6 +1016,8 @@ dialog .vh-form,
} }
#vhost-list, #vhost-list,
#redirect-list,
#notfound-list,
#web-service-list, #web-service-list,
#stream-list, #stream-list,
.vh-www-choices, .vh-www-choices,
@@ -1022,43 +1070,10 @@ dialog .vh-form,
align-items: start; align-items: start;
} }
body[data-density=compact] {
--ui-gap: 2px;
--ui-card: 5px;
--ui-font: 10.75px;
--ui-control: 22px;
line-height: 1.32;
}
body[data-density=compact] main {
padding: 0 10px;
}
body[data-density=compact] .card-heading {
margin-bottom: 5px;
}
body[data-density=compact] .card-heading h2,
html[data-dashboard-density=compact] #panel-overview .dashboard-overview-grid>.card .card-heading h2 { html[data-dashboard-density=compact] #panel-overview .dashboard-overview-grid>.card .card-heading h2 {
font-size: 14px; font-size: 14px;
} }
body[data-density=compact] .outline,
body[data-density=compact] .primary,
body[data-density=compact] .quiet,
body[data-density=compact] button,
body[data-density=compact] input:not([type=checkbox]):not([type=radio]),
body[data-density=compact] select,
body[data-density=compact] textarea {
padding: 3px 7px;
font-size: 10px;
}
body[data-density=compact] .page-footer {
padding: 7px 0;
margin-top: 4px;
}
.network-interface-row-head { .network-interface-row-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1106,7 +1121,7 @@ body[data-density=compact] .page-footer {
font: 10px var(--mono); font: 10px var(--mono);
} }
.network-interface-row svg { .network-interface-row svg:not(.ui-icon) {
display: block; display: block;
width: 100%; width: 100%;
height: 60px; height: 60px;
@@ -1206,6 +1221,29 @@ body[data-density=compact] .page-footer {
min-height: 24px; min-height: 24px;
padding: 3px 7px; padding: 3px 7px;
font: 9px var(--mono); font: 9px var(--mono);
display: inline-flex;
align-items: center;
white-space: nowrap;
}
/* Small controls: a smaller icon must not change the button height when the
optional SVG icons are toggled or re-applied during a refresh (Safari jumped). */
.network-interface-hide>.ui-icon,
.network-interface-hidden .quiet>.ui-icon {
width: 11px;
height: 11px;
margin-inline-end: 4px;
vertical-align: 0;
}
html[data-interface-icons="true"] .network-interface-hide>svg.ui-icon,
html[data-interface-icons="true"] .network-interface-hidden .quiet>svg.ui-icon {
width: 11px;
height: 11px;
}
.network-interface-charts {
overflow-anchor: none;
} }
.network-interface-row+.network-interface-row, .network-interface-row+.network-interface-row,
@@ -1304,8 +1342,9 @@ body[data-density=compact] .page-footer {
} }
.about-dialog { .about-dialog {
width: calc(min(560px, 100vw - 24px)); width: calc(min(640px, 100vw - 24px));
overflow: hidden; max-height: calc(100vh - 24px);
overflow: auto;
} }
.about-dialog .window-heading button { .about-dialog .window-heading button {
@@ -1370,6 +1409,38 @@ body[data-density=compact] .page-footer {
line-height: 1.65; line-height: 1.65;
} }
.about-license {
display: grid;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--line);
background: var(--panel);
}
.about-license h3 {
margin: 0;
}
.about-license p {
margin: 0;
line-height: 1.6;
}
.about-license ul {
display: grid;
gap: 4px;
margin: 0;
padding-inline-start: 18px;
color: var(--muted);
line-height: 1.5;
}
.about-links {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.about-repository { .about-repository {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@@ -1378,10 +1449,19 @@ body[data-density=compact] .page-footer {
text-align: left; text-align: left;
} }
.about-repository span:first-child {
margin-right: auto;
text-align: left;
}
.about-repository span:last-child { .about-repository span:last-child {
margin-left: auto;
color: var(--muted); color: var(--muted);
font: 10px var(--mono); font: 10px var(--mono);
overflow-wrap: anywhere; text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
html[data-table-density=compact] :is(table th, table td) { html[data-table-density=compact] :is(table th, table td) {
@@ -1479,19 +1559,10 @@ html[data-table-density=ultra] :is(table th, table td) {
align-items: center; align-items: center;
} }
/* Keep generated ACL instructions in the original metrics-example code style. */
.path-permissions { .path-permissions {
min-width: 0; min-width: 0;
} }
.path-permissions-commands {
max-height: 180px;
}
.path-permissions-actions {
margin-top: 0;
}
.path-permissions-options>summary { .path-permissions-options>summary {
cursor: pointer; cursor: pointer;
} }
@@ -1506,7 +1577,6 @@ html[data-table-density=ultra] :is(table th, table td) {
max-width: 260px; max-width: 260px;
} }
/* Permission guidance belongs to its generated command block, not the path inputs. */
#certificate-external-acl-example.path-permissions { #certificate-external-acl-example.path-permissions {
margin-top: 20px; margin-top: 20px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@@ -1514,6 +1584,7 @@ html[data-table-density=ultra] :is(table th, table td) {
} }
.path-permissions-commands { .path-permissions-commands {
max-height: 180px;
margin-bottom: 0; margin-bottom: 0;
} }
@@ -1523,4 +1594,13 @@ html[data-table-density=ultra] :is(table th, table td) {
.path-permissions-actions { .path-permissions-actions {
margin-top: 10px; margin-top: 10px;
} }
/* Safety net: whatever chart/card rule is added later, injected UI icons stay inline,
fixed-size and out of layout flow (they used to stretch inside chart cards on mobile Safari). */
html[data-interface-icons="true"] :is(button, a, summary)>svg.ui-icon {
display: inline-block;
position: static;
flex: 0 0 auto;
max-width: none;
overflow: visible;
}
+254 -40
View File
@@ -1,6 +1,4 @@
/* Application startup, authentication, navigation, page chrome and draft dock. /* Application startup, authentication, navigation, page chrome and draft dock */
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
html.app-loading .shell { html.app-loading .shell {
visibility: hidden; visibility: hidden;
@@ -177,6 +175,37 @@ nav {
align-items: center; align-items: center;
} }
.app-health-button {
width: 100%;
border: 0;
background: transparent;
padding: 6px 0;
text-align: left;
cursor: pointer;
}
.app-health-button i {
flex: 0 0 auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.app-health-button.is-checking,
.app-health-summary.is-checking { color: var(--muted); }
.app-health-button.is-ok,
.app-health-summary.is-ok,
.app-health-check.is-ok { color: var(--green); }
.app-health-button.is-warning,
.app-health-summary.is-warning,
.app-health-check.is-warning { color: var(--notice-text); }
.app-health-button.is-error,
.app-health-summary.is-error,
.app-health-check.is-error { color: var(--red); }
.app-health-button:hover { opacity: .82; }
.nav { .nav {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -218,11 +247,71 @@ nav {
padding-top: 32px; padding-top: 32px;
} }
.sidebar-bottom p { .app-health-dialog {
font-size: 11px; width: min(620px, calc(100vw - 24px));
line-height: 1.9; }
color: var(--text-subtle);
margin: 13px 0 26px; .app-health-card {
display: grid;
gap: 14px;
}
.app-health-summary {
display: flex;
align-items: flex-start;
gap: 10px;
}
.app-health-summary>i {
flex: 0 0 auto;
width: 7px;
height: 7px;
margin-top: 6px;
border-radius: 50%;
background: currentColor;
}
.app-health-summary strong {
color: currentColor;
font-size: 15px;
}
.app-health-summary .fine {
margin: 3px 0 0;
}
.app-health-checks {
display: grid;
gap: 7px;
}
.app-health-check {
display: grid;
grid-template-columns: 9px minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 9px 10px;
border: 1px solid var(--line);
background: var(--neutral-soft);
}
.app-health-check>i {
flex: 0 0 auto;
width: 7px;
height: 7px;
margin-top: 5px;
border-radius: 50%;
background: currentColor;
}
.app-health-check strong {
color: var(--text);
font-size: 12px;
}
.app-health-check .fine {
margin: 2px 0 0;
color: var(--muted);
} }
main { main {
@@ -515,37 +604,6 @@ main {
padding-top: 4px; padding-top: 4px;
} }
body[data-density=compact] .shell {
grid-template-columns: 164px minmax(0, 1fr);
}
body[data-density=compact] .sidebar {
padding: 14px 9px;
}
body[data-density=compact] .nav,
body[data-density=compact] .stat {
padding: 6px 7px;
}
body[data-density=compact] .topbar {
height: 42px;
}
body[data-density=compact] .page-intro {
padding: 7px 0 6px;
gap: 7px;
}
body[data-density=compact] .page-intro h1 {
font-size: 20px;
}
body[data-density=compact] .page-intro .lead {
font-size: 10px;
margin-top: 2px;
}
.top-log-menu>summary, .top-log-menu>summary,
.backend-column-picker>summary, .backend-column-picker>summary,
.file-log-search-help>summary { .file-log-search-help>summary {
@@ -780,4 +838,160 @@ body[data-density=compact] .page-intro .lead {
to { to {
visibility: visible; visibility: visible;
} }
} }
/* Optional subtle logo animation (Settings → Appearance → Animate logo). */
.brand-mark .brand-letter {
font: inherit;
color: inherit;
}
html[data-logo-animation="true"] a.brand>.brand-mark {
overflow: hidden;
transition: box-shadow .4s ease;
}
html[data-logo-animation="true"] a.brand>.brand-mark>.brand-letter,
html[data-logo-animation="true"] a.brand>.brand-mark>span {
display: inline-block;
transform-origin: 50% 55%;
will-change: transform;
}
a.brand>.brand-mark[data-logo-effect="spin-slash"]>span {
animation: brand-logo-spin 1.1s cubic-bezier(.55, 0, .25, 1);
}
a.brand>.brand-mark[data-logo-effect="flip"]>.brand-letter {
animation: brand-logo-flip .9s ease-in-out;
}
a.brand>.brand-mark[data-logo-effect="flip"]>.brand-letter:last-child {
animation-delay: .18s;
}
a.brand>.brand-mark[data-logo-effect="wave"]>* {
animation: brand-logo-wave .7s ease-in-out;
}
a.brand>.brand-mark[data-logo-effect="wave"]>:nth-child(2) {
animation-delay: .1s;
}
a.brand>.brand-mark[data-logo-effect="wave"]>:nth-child(3) {
animation-delay: .2s;
}
a.brand>.brand-mark[data-logo-effect="glow"] {
animation: brand-logo-glow 1.3s ease-in-out;
}
a.brand>.brand-mark[data-logo-effect="sheen"]::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(105deg, transparent 30%, color-mix(in srgb, var(--logo-accent, var(--accent)) 28%, transparent) 50%, transparent 70%);
animation: brand-logo-sheen 1s ease-in-out;
}
a.brand>.brand-mark[data-logo-effect="glitch"]>* {
animation: brand-logo-glitch .45s steps(2, end);
}
a.brand>.brand-mark[data-logo-effect="wobble"]>span {
animation: brand-logo-wobble .9s ease-in-out;
}
@keyframes brand-logo-spin {
to {
transform: rotate(360deg);
}
}
@keyframes brand-logo-flip {
to {
transform: rotateY(360deg);
}
}
@keyframes brand-logo-wave {
0%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-3px);
}
}
@keyframes brand-logo-glow {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
50% {
box-shadow: 0 0 12px 1px color-mix(in srgb, var(--logo-accent, var(--accent)) 45%, transparent);
}
}
@keyframes brand-logo-sheen {
from {
transform: translateX(-110%);
}
to {
transform: translateX(110%);
}
}
@keyframes brand-logo-glitch {
0%,
100% {
transform: none;
text-shadow: none;
}
25% {
transform: translateX(-1px);
text-shadow: 1px 0 var(--logo-secondary, var(--brand-secondary)), -1px 0 var(--logo-accent, var(--accent));
}
60% {
transform: translateX(1px);
text-shadow: -1px 0 var(--logo-secondary, var(--brand-secondary)), 1px 0 var(--logo-accent, var(--accent));
}
}
@keyframes brand-logo-wobble {
0%,
100% {
transform: rotate(0);
}
25% {
transform: rotate(-16deg);
}
55% {
transform: rotate(10deg);
}
80% {
transform: rotate(-4deg);
}
}
@media (prefers-reduced-motion: reduce) {
a.brand>.brand-mark[data-logo-effect],
a.brand>.brand-mark[data-logo-effect]>*,
a.brand>.brand-mark[data-logo-effect]::after {
animation: none !important;
}
}
+466 -107
View File
@@ -1,6 +1,4 @@
/* Overview metrics, charts, dashboard density, inline layout controls and presets. /* Overview metrics, charts, dashboard density, inline layout controls and presets */
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
html[data-theme=ultra_dark] :is(.card, .stat, dialog) { html[data-theme=ultra_dark] :is(.card, .stat, dialog) {
box-shadow: 0 6px 24px rgba(0, 0, 0, .18); box-shadow: 0 6px 24px rgba(0, 0, 0, .18);
@@ -17,6 +15,16 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
margin-bottom: var(--ui-gap); margin-bottom: var(--ui-gap);
} }
.stats-grid.cache-stats {
display: flex;
flex-wrap: wrap;
}
.stats-grid.cache-stats>.stat {
flex: 1 1 190px;
min-width: 0;
}
.stat { .stat {
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--panel); background: var(--panel);
@@ -76,8 +84,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
stroke-dasharray: var(--chart-grid-dash, 2 5); stroke-dasharray: var(--chart-grid-dash, 2 5);
} }
/* Optional X/Y scale readouts do not change SVG coordinates or chart ranges.
* Labels are HTML, not distorted SVG text under preserveAspectRatio="none". */
.rp-chart-axis-host { .rp-chart-axis-host {
position: relative; position: relative;
display: flow-root; display: flow-root;
@@ -85,7 +91,10 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
min-width: 0; min-width: 0;
} }
.rp-chart-axis-host>svg { display: block; vertical-align: top; } .rp-chart-axis-host>svg {
display: block;
vertical-align: top;
}
.rp-chart-axis-y { .rp-chart-axis-y {
position: absolute; position: absolute;
@@ -106,9 +115,20 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
background: color-mix(in srgb, var(--panel) 82%, transparent); background: color-mix(in srgb, var(--panel) 82%, transparent);
} }
.rp-chart-axis-top { top: var(--rp-axis-top, 8%); transform: translateY(-30%); } .rp-chart-axis-top {
.rp-chart-axis-middle { top: 50%; transform: translateY(-50%); } top: var(--rp-axis-top, 8%);
.rp-chart-axis-bottom { top: var(--rp-axis-bottom, 92%); transform: translateY(-70%); } transform: translateY(-30%);
}
.rp-chart-axis-middle {
top: 50%;
transform: translateY(-50%);
}
.rp-chart-axis-bottom {
top: var(--rp-axis-bottom, 92%);
transform: translateY(-70%);
}
.rp-chart-axis-x { .rp-chart-axis-x {
display: grid; display: grid;
@@ -124,12 +144,29 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
align-items: center; align-items: center;
} }
.rp-chart-axis-x .rp-chart-axis-start { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .rp-chart-axis-x .rp-chart-axis-start {
.rp-chart-axis-x .rp-chart-axis-end { min-width: 0; text-align: right; } min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.rp-chart-axis-x .rp-chart-axis-end {
min-width: 0;
text-align: right;
}
.rp-chart-axis-compact .rp-chart-axis-y, .rp-chart-axis-compact .rp-chart-axis-y,
.rp-chart-axis-multiple .rp-chart-axis-y, .rp-chart-axis-multiple .rp-chart-axis-y,
.rp-chart-axis-series[hidden] { display: none; visibility: hidden; } .rp-chart-axis-series[hidden] {
.rp-chart-axis-compact .rp-chart-axis-x { font-size: 8px; gap: 2px; } display: none;
visibility: hidden;
}
.rp-chart-axis-compact .rp-chart-axis-x {
font-size: 8px;
gap: 2px;
}
.rp-chart-axis-series { .rp-chart-axis-series {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -141,8 +178,13 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.rp-chart-axis-y .rp-chart-axis-middle { display: none; } .rp-chart-axis-y .rp-chart-axis-middle {
.rp-chart-axis-x { font-size: 8px; } display: none;
}
.rp-chart-axis-x {
font-size: 8px;
}
} }
.chart-footer { .chart-footer {
@@ -238,7 +280,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
text-align: right; text-align: right;
} }
.mini-live-chart svg { .mini-live-chart svg:not(.ui-icon) {
display: block; display: block;
width: 100%; width: 100%;
height: 60px; height: 60px;
@@ -282,13 +324,19 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
min-width: 640px; min-width: 640px;
} }
.pool-table td:nth-child(n/**/+4) { .pool-table td:nth-child(n
/**/
+4) {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right; text-align: right;
} }
[dir=rtl] table, [dir=rtl] table,
.pool-table th:nth-child(n/**/+4), .pool-table th:nth-child(n
/**/
+4),
.logging-builder-action .small-label { .logging-builder-action .small-label {
text-align: right; text-align: right;
} }
@@ -313,11 +361,11 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
font-size: 10px; font-size: 10px;
} }
.activity-card svg { .activity-card svg:not(.ui-icon) {
height: 105px; height: 105px;
} }
.system-card svg { .system-card svg:not(.ui-icon) {
height: 80px; height: 80px;
} }
@@ -335,11 +383,6 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
max-height: 205px; max-height: 205px;
} }
body[data-density=dense] .stat strong {
font-size: 22px;
margin: 2px 0;
}
.traffic-chart-grid, .traffic-chart-grid,
.logging-grid { .logging-grid {
display: grid; display: grid;
@@ -420,7 +463,7 @@ body[data-density=dense] .stat strong {
padding: 7px 10px 0; padding: 7px 10px 0;
} }
.vhost-chart svg { .vhost-chart svg:not(.ui-icon) {
display: block; display: block;
width: 100%; width: 100%;
height: 54px; height: 54px;
@@ -502,37 +545,12 @@ body[data-density=dense] .stat strong {
font-weight: 600; font-weight: 600;
} }
body[data-density=compact] .stat strong {
font-size: 18px;
margin: 1px 0;
}
body[data-density=compact] .stat p,
body[data-density=compact] .stat small,
html[data-dashboard-density=normal] #panel-overview .stat small, html[data-dashboard-density=normal] #panel-overview .stat small,
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-header>span, html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-header>span,
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-caption { html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-caption {
font-size: 9px; font-size: 9px;
} }
body[data-density=compact] .activity-card svg {
height: 84px;
}
body[data-density=compact] .system-card svg {
height: 66px;
}
body[data-density=compact] .pool-table td,
body[data-density=compact] .pool-table th {
padding: 3px 5px;
font-size: 10px;
}
body[data-density=compact] .pool-card .table-scroll {
max-height: 170px;
}
.network-interface-charts { .network-interface-charts {
display: grid; display: grid;
max-height: 260px; max-height: 260px;
@@ -552,11 +570,71 @@ body[data-density=compact] .pool-card .table-scroll {
margin: 0; margin: 0;
} }
#panel-overview .stat-with-action>span:first-child {
display: block;
padding-right: 104px;
}
#panel-overview .stat-with-action .stat-corner-action {
max-width: 96px;
padding: 3px 7px;
font-size: 11px;
line-height: 1.25;
white-space: normal;
text-align: center;
}
#panel-overview .stat-with-action .stat-corner-badge {
min-height: 0;
border-radius: 999px;
border-color: var(--line);
background: transparent;
color: var(--muted);
font-size: 9px;
font-weight: 400;
letter-spacing: 0;
cursor: pointer;
text-transform: none;
}
#panel-overview .stat-with-action .stat-corner-badge:hover,
#panel-overview .stat-with-action .stat-corner-badge:focus-visible {
border-color: var(--line);
background: var(--panel2);
color: var(--text);
}
#panel-overview .dashboard-overview-grid.is-unlocked .stat-with-action .stat-corner-action {
top: 44px;
}
#panel-overview .backend-latency-stat>span:first-child {
display: block;
padding-right: 104px;
}
#panel-overview .backend-latency-stat .backend-health-origins {
position: absolute;
top: 10px;
right: 10px;
margin: 0;
max-width: 100px;
color: var(--muted);
font: 9px var(--mono);
line-height: 1.25;
text-align: right;
white-space: nowrap;
}
#panel-overview .dashboard-overview-grid.is-unlocked .backend-latency-stat .backend-health-origins {
top: 44px;
}
.stat-chart strong { .stat-chart strong {
margin-bottom: 3px; margin-bottom: 3px;
} }
.stat-chart svg { .stat-chart svg:not(.ui-icon) {
display: block; display: block;
width: 100%; width: 100%;
height: 26px; height: 26px;
@@ -661,7 +739,7 @@ body[data-density=compact] .pool-card .table-scroll {
padding: 14px 15px 12px; padding: 14px 15px 12px;
} }
.function-live-chart svg { .function-live-chart svg:not(.ui-icon) {
display: block; display: block;
width: 100%; width: 100%;
height: 180px; height: 180px;
@@ -743,7 +821,7 @@ body[data-density=compact] .pool-card .table-scroll {
font-weight: 400; font-weight: 400;
} }
.connection-card svg { .connection-card svg:not(.ui-icon) {
display: block; display: block;
width: 100%; width: 100%;
height: 100px; height: 100px;
@@ -944,7 +1022,7 @@ body[data-density=compact] .pool-card .table-scroll {
border-radius: 0; border-radius: 0;
} }
:is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg, :is(.stat-chart, .activity-card, .system-card, .function-live-chart) svg:not(.ui-icon),
:is(.traffic-donut, .traffic-bar-chart span) { :is(.traffic-donut, .traffic-bar-chart span) {
filter: var(--chart-svg-filter); filter: var(--chart-svg-filter);
} }
@@ -976,6 +1054,10 @@ html[data-dashboard-density=compact] #panel-overview .stat>span {
line-height: 1.35; line-height: 1.35;
} }
html[data-dashboard-density=compact] #panel-overview .stat-cache-outcomes .cache-mix-header>span {
font-size: 11px;
}
html[data-dashboard-density=compact] #panel-overview .stat strong { html[data-dashboard-density=compact] #panel-overview .stat strong {
font-size: 22px; font-size: 22px;
margin: 3px 0 2px; margin: 3px 0 2px;
@@ -1000,6 +1082,10 @@ html[data-dashboard-density=ultra] #panel-overview .stat>span {
line-height: 1.08; line-height: 1.08;
} }
html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-mix-header>span {
line-height: 1.08;
}
html[data-dashboard-density=ultra] #panel-overview .stat strong { html[data-dashboard-density=ultra] #panel-overview .stat strong {
font-size: 18px; font-size: 18px;
margin: 2px 0 1px; margin: 2px 0 1px;
@@ -1011,15 +1097,15 @@ html[data-dashboard-density=ultra] #panel-overview .stat small {
line-height: 1.12; line-height: 1.12;
} }
html[data-dashboard-density=ultra] #panel-overview .stat-chart svg { html[data-dashboard-density=ultra] #panel-overview .stat-chart svg:not(.ui-icon) {
height: 24px; height: 24px;
} }
html[data-dashboard-density=normal] #panel-overview .stat-chart svg { html[data-dashboard-density=normal] #panel-overview .stat-chart svg:not(.ui-icon) {
height: 34px; height: 34px;
} }
html[data-dashboard-density=compact] #panel-overview .stat-chart svg { html[data-dashboard-density=compact] #panel-overview .stat-chart svg:not(.ui-icon) {
height: 28px; height: 28px;
} }
@@ -1157,44 +1243,92 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
position: relative; position: relative;
outline: 1px dashed var(--accent); outline: 1px dashed var(--accent);
outline-offset: -2px; outline-offset: -2px;
padding-top: 101px; padding-top: 40px !important;
} }
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-dragging { #panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:hover,
opacity: .45; #panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget]:focus-within {
outline-style: solid;
} }
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-before { #panel-overview .dashboard-overview-grid.is-overview-sorting {
box-shadow: inset 4px 0 var(--accent); grid-auto-flow: row !important;
user-select: none;
cursor: grabbing;
} }
#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-after { #panel-overview .dashboard-overview-grid>.overview-drop-placeholder {
box-shadow: inset -4px 0 var(--accent); grid-column: auto/span 2;
box-sizing: border-box;
border: 2px dashed var(--accent);
border-radius: var(--rp-radius-surface, 9px);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="3"] {
grid-column: auto/span 3;
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="4"] {
grid-column: auto/span 4;
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="6"] {
grid-column: auto/span 6;
}
#panel-overview .dashboard-overview-grid>.overview-drop-placeholder[data-dashboard-span="12"] {
grid-column: 1/-1;
}
#panel-overview .dashboard-overview-grid>.is-overview-lifted,
#panel-overview .dashboard-overview-grid>.is-overview-lifted * {
pointer-events: none !important;
}
#panel-overview .dashboard-overview-grid>.is-overview-lifted {
box-shadow: 0 18px 40px rgb(0 0 0 / .28), 0 0 0 2px var(--accent);
opacity: .94;
transition: none;
will-change: transform;
overflow: hidden;
} }
.overview-inline-toolbar { .overview-inline-toolbar {
position: absolute; position: absolute;
top: 3px; top: 5px;
left: 4px; left: 5px;
right: 4px; right: 5px;
z-index: 4; z-index: 4;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
overflow: hidden;
gap: 3px; gap: 3px;
pointer-events: auto; pointer-events: auto;
} }
#panel-overview .overview-inline-toolbar button { #panel-overview .overview-inline-toolbar button {
min-width: 28px; min-width: 26px;
min-height: 27px; min-height: 26px;
padding: 2px 7px; padding: 2px 6px;
font-size: 12px; font-size: 12px;
flex: 0 0 auto;
}
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar :is(.overview-widget-type, .overview-inline-up, .overview-inline-down),
#panel-overview .dashboard-overview-grid>[data-dashboard-span="3"] .overview-inline-toolbar :is(.overview-inline-up, .overview-inline-down) {
display: none;
}
#panel-overview .dashboard-overview-grid>[data-dashboard-span="2"] .overview-inline-toolbar .overview-inline-hide {
margin-left: auto;
} }
#panel-overview .overview-inline-toolbar .overview-inline-grip { #panel-overview .overview-inline-toolbar .overview-inline-grip {
cursor: grab; cursor: grab;
user-select: none; user-select: none;
touch-action: none;
} }
#panel-overview .overview-inline-toolbar .overview-inline-grip:active { #panel-overview .overview-inline-toolbar .overview-inline-grip:active {
@@ -1573,7 +1707,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
border-radius: var(--rp-radius-surface) !important; border-radius: var(--rp-radius-surface) !important;
} }
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(button:not(.nav):not(.file-log-tab):not(.vhost-backend-trigger), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-restore), html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(button:not(.nav):not(.file-log-tab):not(.vhost-backend-trigger), input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea, .pill, .vhost-backend-protocol-badge, .overview-widget-type, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, .overview-inline-restore),
html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(.nav, .file-log-tab, .vhost-backend-trigger, .backend-column-options, .docker-service-quick-actions a, .docker-service-quick-actions button, .docker-view-toggle, .provider-picker, .renewal-meta-item) { html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-style=rounded], [data-corner-style=extra]) :is(.nav, .file-log-tab, .vhost-backend-trigger, .backend-column-options, .docker-service-quick-actions a, .docker-service-quick-actions button, .docker-view-toggle, .provider-picker, .renewal-meta-item) {
border-radius: var(--rp-radius-control) !important; border-radius: var(--rp-radius-control) !important;
} }
@@ -1693,26 +1827,13 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
flex-wrap: wrap; flex-wrap: wrap;
max-width: 100%; max-width: 100%;
margin: 0 0 0 auto; margin: 0 0 0 auto;
flex: 0 0 auto;
} }
.page-intro .overview-samples-toolbar .function-live-chart-samples select { .page-intro .overview-samples-toolbar .function-live-chart-samples select {
min-width: 100px; min-width: 100px;
} }
.page-intro .overview-samples-toolbar button {
width: auto;
flex: none;
min-height: 28px;
padding: 5px 10px;
white-space: nowrap;
}
.page-intro .overview-samples-toolbar .fine {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
#panel-overview .stat-donut .overview-donut-info { #panel-overview .stat-donut .overview-donut-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1825,7 +1946,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
} }
#panel-overview .dashboard-overview-grid.is-aligned[data-overview-layout-mode=custom] { #panel-overview .dashboard-overview-grid.is-aligned[data-overview-layout-mode=custom] {
grid-auto-flow: row dense; grid-auto-flow: row;
} }
#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="2"] { #panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget][data-dashboard-span="2"] {
@@ -1923,7 +2044,7 @@ html:is([data-corner-style=square], [data-corner-style=subtle], [data-corner-sty
gap: 8px; gap: 8px;
padding: 9px; padding: 9px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--rp-radius-surface, var(--radius, 8px)); border-radius: var(--rp-radius-surface, 8px);
background: var(--panel); background: var(--panel);
} }
@@ -2041,7 +2162,12 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
color: var(--muted); color: var(--muted);
font-size: 11px; font-family: var(--sans);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
letter-spacing: normal;
text-transform: none;
} }
#panel-overview .stat-cache-outcomes .cache-mix-caption { #panel-overview .stat-cache-outcomes .cache-mix-caption {
@@ -2057,8 +2183,8 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
#panel-overview .stat-cache-outcomes .cache-mix-bar { #panel-overview .stat-cache-outcomes .cache-mix-bar {
display: flex; display: flex;
width: 100%; width: 100%;
height: 12px; height: 20px;
margin: 7px 0 9px; margin: 3px 0 4px;
border-radius: var(--rp-progress-radius); border-radius: var(--rp-progress-radius);
background: var(--rp-progress-track); background: var(--rp-progress-track);
overflow: hidden; overflow: hidden;
@@ -2068,6 +2194,17 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
display: block; display: block;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
padding: 0 2px;
overflow: hidden;
color: #0c0e11;
font: 600 10px/20px var(--mono, ui-monospace, monospace);
text-align: center;
white-space: nowrap;
user-select: none;
}
#panel-overview .cache-mix-bar>span.is-tight {
color: transparent;
} }
#panel-overview .cache-mix-other { #panel-overview .cache-mix-other {
@@ -2076,16 +2213,18 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
} }
#panel-overview .cache-mix-legend { #panel-overview .cache-mix-legend {
display: grid; display: flex;
grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: center;
gap: 5px 8px; justify-content: space-between;
gap: 7px;
line-height: 1.25; line-height: 1.25;
white-space: nowrap;
} }
#panel-overview .cache-mix-legend>span { #panel-overview .cache-mix-legend>span {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 3px;
min-width: 0; min-width: 0;
} }
@@ -2108,17 +2247,237 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
} }
#panel-overview .cache-mix-legend b { #panel-overview .cache-mix-legend b {
display: flex; margin-left: 1px;
flex-direction: column;
align-items: flex-end;
margin-left: auto;
color: var(--text); color: var(--text);
font: 500 10px var(--mono); font: 500 9px var(--mono);
overflow-wrap: anywhere;
text-align: right;
} }
#panel-overview .cache-mix-legend .cache-mix-percent { #panel-overview .system-card .system-uptime-meta,
color: var(--muted); #panel-overview .connection-card .connection-backends-meta {
font: 400 10px var(--mono); white-space: nowrap;
} }
#panel-overview .system-card .system-uptime-meta b,
#panel-overview .connection-card .connection-backends-meta b {
color: var(--text);
font-weight: 650;
}
#panel-overview .connection-card .connection-heading-main {
width: 100%;
min-width: 0;
}
#panel-overview .connection-card .connection-current-main {
display: inline-flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
#panel-overview .connection-card .connection-backends-meta {
margin-left: auto;
line-height: inherit;
}
.mini-live-chart-head> :last-child,
.network-interface-row-head .network-interface-head-actions {
flex: 0 0 auto;
white-space: nowrap;
}
.mini-live-chart-head>span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* WebSocket tunnels: "Established" sits to the right of the main value instead of a third text line. */
#panel-overview .stat .stat-value-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: space-between;
column-gap: 8px;
min-width: 0;
}
#panel-overview .stat .stat-value-row>strong {
flex: 0 0 auto;
}
#panel-overview .stat .stat-value-row>.stat-side-value {
display: block;
margin: 0 0 0 auto;
color: var(--muted);
font-size: 10px;
line-height: 1.3;
text-align: right;
white-space: nowrap;
}
#panel-overview .stat .stat-value-row>.stat-side-value b {
color: var(--text);
font-family: var(--mono);
font-size: 13px;
font-weight: 450;
}
/* Compact stat cards: a card made only of title, value and one secondary line shows that line
beside the value (right-aligned) instead of on a line of its own. */
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small)) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 10px;
align-items: baseline;
align-content: start;
}
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>span:first-child {
grid-column: 1 / -1;
}
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>strong {
grid-column: 1;
min-width: 0;
}
#panel-overview .stats-grid>.stat:is(:not(.stat-donut, .stat-meter, .stat-with-action, .stat-chart):not(:has(>:not(span, strong, small))):has(>small))>small {
grid-column: 2;
max-width: 14em;
margin: 0;
text-align: right;
overflow-wrap: anywhere;
}
/* Backends overview card (0.36.7): follows table density (global table rules), corner style tokens and interface colours */
#panel-overview .backend-overview-card.is-no-proxy,
#panel-overview #backend-diagnostics-card.is-no-proxy { display: none !important; }
.bo-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; min-width: 0; }
.bo-summary { color: var(--muted); font: 11px var(--mono); }
.bo-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; }
.bo-chip { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); background: transparent; color: var(--muted); font: 500 12px/1 var(--sans); cursor: pointer; }
.bo-chip:hover { color: var(--text); border-color: var(--border-hover); }
.bo-chip[aria-pressed=true] { color: var(--text); background: var(--nav-active-bg); border-color: var(--pill-line); }
.bo-chip b { color: var(--text-subtle); font: 600 11px var(--mono); }
.bo-chip[aria-pressed=true] b { color: var(--accent); }
.bo-chip.is-down b:not(.is-zero) { color: var(--red); }
.backend-overview-card .table-scroll { max-height: 520px; overflow: auto; border-top: 1px solid var(--line); }
.bo-table { width: 100%; min-width: 1300px; border-collapse: collapse; font-size: 12px; }
.bo-table th { position: sticky; top: 0; z-index: 1; padding: 8px 10px; background: var(--panel); color: var(--muted); font: 600 10px/1.2 var(--sans); letter-spacing: .05em; text-align: left; text-transform: uppercase; white-space: nowrap; }
.bo-table td { padding: 9px 10px; border-top: 1px solid var(--row-line); vertical-align: middle; white-space: nowrap; }
.bo-table .is-num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
.bo-table th.is-num { font-family: var(--sans); }
.bo-table .is-zero { color: var(--text-subtle); }
.bo-row:hover>td { background: var(--panel2); }
.bo-row.is-disabled>td:not(:first-child) { opacity: .6; }
.bo-row>td:first-child { box-shadow: inset 3px 0 0 var(--line); }
.bo-row.is-up>td:first-child { box-shadow: inset 3px 0 0 var(--pill-live-line); }
.bo-row.is-down>td:first-child { box-shadow: inset 3px 0 0 var(--red); }
.bo-row.is-warn>td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
.bo-expand { width: 24px; height: 24px; min-width: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--muted); cursor: pointer; line-height: 1; }
.bo-expand::before { content: "›"; display: inline-block; font-size: 15px; transition: transform .15s ease; }
.bo-expand[aria-expanded=true]::before { transform: rotate(90deg); }
.bo-expand:hover { color: var(--text); border-color: var(--field-line); }
.bo-identity { max-width: 360px; }
.bo-name { overflow: hidden; color: var(--text); font-weight: 600; text-overflow: ellipsis; }
.bo-sub { margin-top: 2px; overflow: hidden; color: var(--muted); font: .88em var(--mono); text-overflow: ellipsis; }
.bo-badge { display: inline-flex; align-items: center; gap: 7px; padding: 2px 8px; border: 1px solid var(--field-line); border-radius: var(--rp-radius-control, 0); color: var(--muted); font: .82em var(--mono); letter-spacing: 1px; text-transform: uppercase; }
.bo-badge>i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-subtle); }
.bo-badge.is-up { border-color: var(--pill-live-line); color: var(--green); }
.bo-badge.is-up>i { background: var(--green); }
.bo-badge.is-down { border-color: var(--danger-line-strong); color: var(--red); }
.bo-badge.is-down>i { background: var(--red); }
.bo-badge.is-warn { border-color: var(--warn-line); color: var(--accent); }
.bo-badge.is-warn>i { background: var(--accent); }
.bo-errors { min-width: 150px; }
.bo-meter-top { display: flex; justify-content: space-between; gap: 8px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.bo-meter-top small { color: var(--text-subtle); font-size: .9em; }
.bo-meter { height: 2px; margin-top: 5px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
.bo-meter>i { display: block; height: 100%; background: var(--accent); border-radius: var(--rp-progress-radius, 0); }
.bo-meter>i.is-high { background: var(--red); }
.bo-latency { min-width: 200px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.bo-latency>span { display: inline-block; min-width: 62px; }
.bo-spark { height: 22px; margin-left: 6px; vertical-align: middle; flex: 0 0 auto; }
.bo-spark-latency { width: 120px; }
.bo-spark-traffic { width: 96px; margin-left: 0; }
.bo-spark-bars { width: 84px; margin-left: 0; }
.bo-spark polyline { fill: none; stroke: var(--green); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.bo-row.is-down .bo-spark polyline { stroke: var(--red); }
.bo-row.is-warn .bo-spark polyline { stroke: var(--accent); }
.bo-event small { display: block; margin-top: 1px; color: var(--text-subtle); font-size: .9em; }
.bo-event .is-failed, .bo-detail-item .is-failed { color: var(--red); }
.bo-event .is-recovered, .bo-detail-item .is-recovered { color: var(--green); }
.bo-detail-row>td { padding: 0 10px 10px 40px; border-top: 0; white-space: normal; }
.bo-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; padding: 10px 12px; border: 1px solid var(--row-line); border-radius: var(--rp-radius-surface, 0); background: var(--bg); }
.bo-detail-item span { display: block; margin-bottom: 3px; color: var(--text-subtle); font-size: 11px; }
.bo-detail-item b { font: 500 12px var(--mono); overflow-wrap: anywhere; }
html[data-table-density=normal] .bo-table td { padding-top: 10px; padding-bottom: 10px; }
html[data-table-density=ultra] .bo-table th { padding: 4px 8px; }
html[data-table-density=ultra] .bo-table td { padding-left: 8px; padding-right: 8px; }
html[data-table-density=ultra] .bo-sub { display: none; }
html[data-table-density=ultra] .bo-spark { height: 14px; }
html[data-table-density=ultra] .bo-spark-latency { width: 80px; }
html[data-table-density=ultra] .bo-spark-traffic { width: 64px; }
html[data-table-density=ultra] .bo-spark-bars { width: 56px; }
html[data-table-density=ultra] .bo-meter { margin-top: 2px; }
html[data-table-density=ultra] .bo-event small { display: inline; margin-left: 6px; }
html[data-table-density=ultra] .bo-badge { padding: 0 6px; }
html[data-table-density=ultra] .bo-chip { padding: 4px 8px; font-size: 11px; }
html[data-table-density=compact] .bo-spark { height: 18px; }
html[data-table-density=compact] .bo-spark-latency { width: 100px; }
html[data-table-density=compact] .bo-spark-traffic { width: 80px; }
html[data-table-density=compact] .bo-spark-bars { width: 70px; }
html[data-table-density=compact] .bo-chip { padding: 5px 9px; }
html[data-table-density=compact] .bo-meter { margin-top: 3px; }
/* Process overview card (process_diagnostics) */
[data-dashboard-widget="process_diagnostics"] { display: flex; flex-direction: column; min-height: 560px; }
.pr-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; }
.pr-chip { display: inline-flex; align-items: baseline; gap: 7px; margin: 0; padding: 3px 9px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); font: 11px var(--mono); }
.pr-chip dt { color: var(--text-subtle); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
.pr-chip dd { margin: 0; font-weight: 500; }
.pr-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 22px; margin-top: 6px; }
.pr-title { margin: 10px 0 4px; color: var(--muted); font: 600 10px var(--sans); letter-spacing: .06em; text-transform: uppercase; }
.pr-list { margin: 0; }
.pr-row { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 10px; padding: 7px 2px; border-top: 1px solid var(--row-line); }
.pr-row dt { color: var(--muted); font-size: 11px; }
.pr-row dd { margin: 0 0 0 auto; font: 12px var(--mono); font-variant-numeric: tabular-nums; text-align: end; overflow-wrap: anywhere; }
.pr-meter { flex: 0 0 100%; height: 2px; margin-top: 3px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
.pr-meter b { display: block; width: 0; height: 100%; background: var(--accent); border-radius: var(--rp-progress-radius, 0); }
.pr-meter b.is-high { background: var(--red); }
.pr-split { display: flex; height: 6px; margin: 2px 0 4px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
.pr-split .a { display: block; background: var(--accent); }
.pr-split .f { display: block; background: var(--blue); }
.pr-charts { flex: 1 1 auto; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 10px; }
.pr-charts .mini-live-chart { display: flex; flex-direction: column; margin-top: 0; }
.pr-charts .mini-live-chart svg:not(.ui-icon) { flex: 1 1 0; height: 0; min-height: 56px; }
.diagnostic-line.pr-line-blue { stroke: var(--blue); }
.diagnostic-line.pr-line-muted { stroke: var(--muted); }
html[data-table-density=compact] .pr-row { padding-top: 6px; padding-bottom: 6px; }
html[data-table-density=ultra] .pr-row { padding-top: 3px; padding-bottom: 3px; }
html[data-table-density=ultra] .pr-chip { padding: 1px 7px; }
@media (max-width: 720px) {
[data-dashboard-widget="process_diagnostics"] { min-height: 0; }
.pr-cols, .pr-charts { grid-template-columns: 1fr; }
}
/* initial width of the cache outcome segments (set from JS via CSSOM; inline style attributes are blocked by the CSP) */
.cache-mix-hit, .cache-mix-miss, .cache-mix-bypass, .cache-mix-other { width: 0; }
/* Backends overview: trend charts in the requests, latency and traffic cells */
.bo-chartcell { display: flex; align-items: center; justify-content: flex-end; gap: 10px; font: 12px var(--mono); font-variant-numeric: tabular-nums; }
.bo-requests .bo-chartcell>span { min-width: 44px; text-align: right; }
.bo-spark-bars rect { fill: var(--accent); opacity: .75; }
.bo-spark .bo-base { stroke: var(--line); stroke-width: 1.5; stroke-linecap: round; }
.bo-spark-latency .bo-base { stroke-dasharray: 2 4; }
.bo-row .bo-spark-traffic polyline.bo-line-in { stroke: var(--blue); }
.bo-row .bo-spark-traffic polyline.bo-line-out { stroke: var(--green); }
.bo-traffic { min-width: 230px; }
.bo-traffic-text { min-width: 118px; font: 11px/1.55 var(--mono); font-variant-numeric: tabular-nums; }
.bo-traffic-text>div { display: flex; justify-content: space-between; gap: 8px; }
.bo-traffic-text span { color: var(--text-subtle); }
.bo-traffic-text .bo-in b { color: var(--blue); font-weight: 500; }
.bo-traffic-text .bo-out b { color: var(--green); font-weight: 500; }
.bo-traffic-text b.is-zero { color: var(--text-subtle); }
html[data-table-density=ultra] .bo-traffic-text { line-height: 1.25; }
+115 -21
View File
@@ -1,6 +1,4 @@
/* Shared service editors: Vhosts, backends, Web Server, Streams, routing and ACL pickers. /* Shared service editors: Vhosts, backends, Web Server, Streams, routing and ACL pickers */
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
.host-chip-editor.field-invalid, .host-chip-editor.field-invalid,
.listener-choices.field-invalid { .listener-choices.field-invalid {
@@ -31,7 +29,7 @@
.traffic-chart-card, .traffic-chart-card,
#panel-overview .backend-mode-bar span, #panel-overview .backend-mode-bar span,
#panel-overview .stats-grid>.stat, #panel-overview .stats-grid>.stat,
#panel-overview .dashboard-overview-grid>.activity-card svg, #panel-overview .dashboard-overview-grid>.activity-card svg:not(.ui-icon),
#panel-overview .dashboard-overview-grid>[data-dashboard-widget], #panel-overview .dashboard-overview-grid>[data-dashboard-widget],
.dashboard-density-setting>.fine, .dashboard-density-setting>.fine,
.dashboard-density-setting>select, .dashboard-density-setting>select,
@@ -1142,7 +1140,7 @@ html[data-dashboard-density=ultra] #panel-overview .dashboard-overview-grid>.car
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 11px; gap: 11px;
background: var(--surface, transparent); background: transparent;
} }
.vh-backend-health-editor h3 { .vh-backend-health-editor h3 {
@@ -1485,18 +1483,22 @@ html[data-dashboard-density=ultra] #panel-overview .dashboard-overview-grid>.car
grid-row: 1; grid-row: 1;
} }
/* Same geometry and typography as .pill so protocol indicators sit evenly in the
Vhost policy row; only the colour (muted = not observed, green = observed) differs. */
.vhost-backend-protocol-badge { .vhost-backend-protocol-badge {
display: inline-flex; display: inline-flex;
gap: 4px; gap: 4px;
align-items: baseline; align-items: center;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
border: 1px solid var(--pill-line); border: 1px solid var(--pill-line);
border-radius: var(--rp-radius-control, 4px); border-radius: var(--rp-radius-control, 0);
background: var(--panel2); background: transparent;
color: var(--muted); color: var(--muted);
padding: 3px 7px; min-height: 0;
font: 10px var(--mono); padding: 4px 8px;
font: 9px/10px var(--mono);
letter-spacing: 1px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
text-align: left; text-align: left;
} }
@@ -1506,6 +1508,10 @@ html.app-loading,
background: var(--bg); background: var(--bg);
} }
.vhost-policy-pills>.vhost-backend-protocol-badge {
align-self: center;
}
.vhost-backend-protocol-badge[data-observed=true] { .vhost-backend-protocol-badge[data-observed=true] {
color: var(--green); color: var(--green);
border-color: var(--pill-live-line); border-color: var(--pill-live-line);
@@ -1790,12 +1796,12 @@ html.app-loading,
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
max-width: 100%; max-width: 100%;
gap: 7px; gap: 5px;
padding: 2px 4px 2px 9px; padding: 4px 5px 4px 8px;
border-radius: var(--rp-radius-control, 15px); border-radius: var(--rp-radius-control, 0);
border: 1px solid var(--pill-live-line); border: 1px solid var(--pill-live-line);
background: var(--nav-selected-bg); background: var(--panel);
font-size: 11px; font: 10px var(--mono);
} }
.acl-picker-tag>span { .acl-picker-tag>span {
@@ -1805,13 +1811,12 @@ html.app-loading,
.acl-picker-tag .acl-picker-remove { .acl-picker-tag .acl-picker-remove {
border: none; border: none;
border-radius: var(--rp-radius-control, 50%); border-radius: var(--rp-radius-control, 0);
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
min-width: 20px; font-size: 15px;
height: 20px; line-height: 1;
font-size: 16px; padding: 0 3px;
padding: 0;
cursor: pointer; cursor: pointer;
} }
@@ -1942,4 +1947,93 @@ html.app-loading,
.web-service-preset-toggles>.check { .web-service-preset-toggles>.check {
margin: 0 !important; margin: 0 !important;
min-width: 0; min-width: 0;
} }
/* Spacing review (0.36.2): descriptive text and blocks that used to touch the next
element. Rules only add space where the layout had none; grid/flex gaps are untouched. */
.fine+:is(.switch-grid, .settings-tabs, .table-scroll, .soft-card, .appearance-settings-grid, .runtime-information-grid, details),
.form-actions+.fine,
.table-scroll+:is(.empty, .fine),
.empty+.file-logs-pager {
margin-top: 10px;
}
:is(.card-heading, .window-heading)+.fine,
h2+.fine {
margin-top: 6px;
}
.location-help-content :is(p, .fine)+:is(p, .fine) {
margin-top: 8px;
}
.location-help-content :is(p, .fine, ul, ol, table, pre)+:is(h3, h4) {
margin-top: 16px;
}
.cache-toolbar+.function-live-chart,
.function-live-chart+.stats-grid,
.soft-card+.soft-card,
.field-grid+.field-grid {
margin-top: 12px;
}
.function-live-chart-summary+.function-live-chart-body {
margin-top: 8px;
}
.tls-pqc-control>:is(.fine, a) {
margin-block: 0;
}
.tls-pqc-control {
display: grid;
gap: 6px;
margin-top: 6px;
}
.tls-pqc-control>a {
justify-self: start;
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
font-size: 12px;
}
#settings-page-tls-policy .switch-grid {
margin-bottom: 12px;
}
#panel-settings .fine+.settings-tabs.sub-tabs,
.fine+.field-grid,
.vh-grid+.fine,
.logs-card+.log-footnote,
.overview-presets-content+.overview-layout-footer {
margin-top: 10px;
}
#settings-page-policies>.edit-form+div {
margin-top: 12px;
}
@media (min-width: 761px) {
#panel-settings .settings-tabs.sub-tabs+:is(div, form) {
margin-top: 12px;
}
}
/* Redirect cards (0.36.5): host chips → target flow plus a quiet meta line, no nested boxes */
@media (min-width: 761px) { .redirect-card .vhost-card-head { align-items: center; } }
.redirect-flow { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.4fr); gap: 12px; align-items: center; margin-top: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--rp-radius-surface, 0); background: var(--bg); }
.redirect-flow-from, .redirect-flow-to { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; align-items: center; }
.redirect-flow-to { flex-direction: column; align-items: flex-start; gap: 3px; }
.redirect-flow-arrow { color: var(--muted); font-size: 16px; line-height: 1; }
.redirect-host-chip { max-width: 100%; padding: 2px 6px; border: 1px solid var(--line); border-radius: var(--rp-radius-control, 0); font: 11px/1.45 var(--mono); color: var(--text); overflow-wrap: anywhere; }
.redirect-target { font: 12px/1.45 var(--mono); font-weight: 600; color: var(--green); overflow-wrap: anywhere; min-width: 0; }
.redirect-target-more { font-size: 11px; color: var(--muted); }
.redirect-meta { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-top: 10px; font-size: 11px; color: var(--muted); }
.redirect-meta-item { display: inline-flex; gap: 6px; min-width: 0; }
.redirect-meta-item b { color: var(--text); font-weight: 600; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.redirect-flow { grid-template-columns: minmax(0, 1fr); gap: 6px; }
.redirect-flow-arrow { transform: rotate(90deg); justify-self: start; }
}
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more