This commit is contained in:
Mateusz Gruszczyński committed 2026-10-21 09:38:21 +02:00
1 parent 3776edf4fc
commit 7cfc3a6da1
35 files changed
+1246 -744

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# RustProxy source and documentation maintenance
Current source version: **0.34.4**. The Cargo package and binary are named `rust-proxy`.
Current source version: **0.34.5**. 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.
+16 -1
View File
@@ -6,7 +6,20 @@
# 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.34.4.
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.34.5.
## 0.34.5 — Administration UX improvements (source candidate)
- **Web Server → Delivery:** separated static-file delivery, dynamic compression/codecs and precompressed `.br`/`.gz` sidecar controls into individually labelled settings groups. Existing options, defaults and configuration keys remain unchanged.
- **Web Server → PHP-FPM:** grouped profile and endpoint selection, request behavior, timeouts/capacity, and FastCGI/php.ini overrides. Disabled inputs, selectors and textareas now share a clear inactive appearance when PHP is off or controlled by a shared profile.
- **Streams → Backends:** split backend routing/capacity from health checks. Probe timing and UDP payload fields appear only when a health-check mode is selected; hidden controls retain their values and cannot block form validation.
- **Redirects and 404 hosts:** separated identity/domains from frontend listener and certificate selection, with more consistent checkbox alignment and spacing.
- **Vhost → Transform:** the file-extension override checkbox now sits directly above its textarea, separated from format selectors. The three similar Vhost cache overrides (static extensions, bypass prefixes and unsafe force targets) are stacked consistently; disabled overrides no longer appear editable. Existing saved field names and policy behavior remain unchanged.
- **Vhost access:** relocated the inline IP/CIDR ACL editor to **Security → Traffic policy**, alongside reusable ACL profiles. **Authentication** now contains Basic Auth and Tinyauth alone. An inline ACL remains an independent, cumulative restriction (it cannot be bypassed by a profile allow).
- **Web Server presets:** the two hardening checkboxes appear together below preset selection/action rather than beside them. **Settings → Appearance → Data refresh** shows the master refresh checkbox on its own line, with four equally sized refresh selectors in a balanced two-column grid (one column on mobile).
- **Settings → System information:** removed the dedicated RustProxy process block from this screen while preserving **Statistics source** with the host details. Status/metrics API fields and backend monitoring are unchanged.
- **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.
## 0.34.4 — Multi-profile ACLs, certificate override and mobile Docker details (source candidate)
@@ -14,6 +27,8 @@ Release history focused on **features, settings, default behavior, compatibility
- Docker agent details now wrap certificate SHA-256 fingerprints on narrow screens.
- Ordinary Vhosts and Docker publications may attach up to eight reusable ACL profiles. Legacy `acl_profile_id` remains loadable; new assignments use `acl_profile_ids`. Among selected profiles, an explicit IP deny or blocked country always denies, explicit IP/country allows are unioned, and unmatched defaults must all permit access. Single-profile behavior is unchanged. Inline Vhost/location/global controls are still additional (AND) restrictions, not bypassed. Docker assignments override inherited agent profiles when they select their own profiles.
- `allow_certificate_mismatch` permits a deliberate, confirmed exception per named certificate assignment on an ordinary Vhost or Docker publication. TLS identity/key checks remain intact. Only explicitly selected hostname/certificate pairs skip RustProxy DNS SAN binding verification. Client hostname validation still fails/warns for a mismatched certificate; do not use as a permanent replacement for a matching certificate.
- Refined Vhost editor layout: separate **SSL certificates** (selection and explicit mismatch consent) from **SSL settings** (TLS policy), and split Traffic policy into ACL, method, IP/country/User-Agent rate limits. Vhost security now separates HSTS, response headers and User-Agent blocking; Custom location access, balancing controls and WebSocket settings have their own groups. Checkbox/button alignment was adjusted in toolbars, dialogs and configuration cards; ordinary UI surface corners follow the selected dashboard rounding style where previously fixed. Backend settings and API semantics are unchanged.
- 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.
## 0.34.3 — QUIC socket buffering, live HTTP/3 settings and cache outcome mix (source candidate)
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.34.4"
version = "0.34.5"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.34.4"
version = "0.34.5"
edition = "2021"
rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration"
+10 -2
View File
@@ -1,4 +1,4 @@
# RustProxy 0.34.4
# RustProxy 0.34.5
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.
@@ -16,11 +16,19 @@ RustProxy combines a reverse proxy, static/PHP web server, certificate managemen
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.
## 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.
## 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
@@ -74,4 +82,4 @@ The authenticated **`/docs`** page includes the guides and a separate interactiv
## Status of this source archive
Version **0.34.4** is provided as a **source release candidate**. Configuration, ACL and TLS changes require native compilation and integration testing on the target systems before a production deployment is declared. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md).
Version **0.34.5** is provided as a **source release candidate**. Configuration, ACL and TLS changes require native compilation and integration testing on the target systems before a production deployment is declared. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md).
+35 -28
View File
@@ -1,12 +1,12 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
ad2dc4d62a754442b775ee92f725ee39c68795ff75052cb75d797a95420b7fa2 AGENTS.md
296249d11082fa03f00d2075755892ada315b190dc88b9dc0e02cc35b716a387 CHANGELOG.md
9076d0c433202e776b22c667d3ba694f5110a182bbea65931026fc8fe7bc7a13 Cargo.lock
9709ce361cf2855ff2aa9ad099764b276e81e87f04feb045160b10f575b04f0c Cargo.toml
c85d98468358b1b6acfae1025db0de42858f4b80418a5e8d6072e9c059f684d2 AGENTS.md
0370b6470b855ad901c74a8afe1461987ba64fc020f5c71f441f41d5431b334c CHANGELOG.md
32a94804157a1bdf05335f2eb9fb1fa3c3507511997ca3aef51235884a1300ec Cargo.lock
f716baecb16304e87bc0627a0d70c5a4a3d1a7e8847987f5a60f1ae1b77f4008 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
f9f9466a7f3dbe89e6d538bfd6a23a3b9e53fae41db34947672d5b3854ede03d README.md
b2435a90dd36e4a531b23711794bae7fee3c877edf84ee78064e199ac87fccbd README.md
ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
@@ -27,31 +27,32 @@ b7d0466f70b541a705d234fc8a88b99b6197fdd5e682d3a288a78c76764358cf docker-agent/m
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
d363e19e27e3d044939a87a58b08a83e9e7999291fdd0f9da59c028386b442e4 docs/ACCESS.md
30214fb5082c9de0ef90e1f4271825b5bf4c9898dd396ef797daf0a7abb37834 docs/ACCESS.md
64dacde6ce787302b6586f780eae7ef3e30bf81f8072c0b38020ec7a10dc3db0 docs/ACME_PROVIDERS.md
9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md
7e6a7b9421541c94181a712d17f6166a328e7e28c5a1d629e7d335c0ae5aa1de docs/CACHE.md
e6d9feabc7a4fcb9a56a2028accbab19ca44479ee67deeefd0ab37f29c45cf4a docs/CONFIGURATION.md
0305f4e464cae51d50ed6a171571515505aab3bc3aacdcfaa632f252460cd4a8 docs/CACHE.md
c81c6ee84e6af6507926144f8caf1e94daf497a8a0159449a3808088a11ea045 docs/CONFIGURATION.md
80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md
ddaebc028e42f85652bf0c1fa0d88eb30346e543c66ebba64f22c0e54cd87678 docs/CSS_AUDIT.md
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
9fb2d0bcac70d1883dd16557622851a1f0527b3d3569a4b5177f5e6dedd3c74d docs/DASHBOARD.md
c0b7f122de42c2db9fc1791d1aa2dd5322a9ad2efcf6c9cbda93aabcc7caf31d docs/DOCKER_DISCOVERY.md
8a97377f04a12c28ca219accd921e439e67130fed0d87b51495c0e15a626cbc0 docs/DOCKER_DISCOVERY.md
dbae1a739874ac7b61e276c1ad6fd7c3465acc95afa4db3751224c8426ad6569 docs/INSTALL.md
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
e4ffd659be1e6c445b582a76643a46408699ef1e929d3a4c6e741d8146ded031 docs/MONITORING.md
140b95ad778261455f269f2144a1c61c2315bd3b85300fb613822eacddf3cfc2 docs/MONITORING.md
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
14039f58802eddd023bd3ff91aae63cd45aa37685e040d77c271d0bc8dc43aa0 docs/README.md
7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md
932cdc186b86bb87df63f243fb3238b93ed3ecd77fe0bdc960d6059039eb9909 docs/README.md
490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md
701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md
519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md
bbef60ff1771da9f48af812987766e1e479408873ae2f079b13f1d5c62ecc6ff docs/STREAMS.md
5bedc7d8b9fcaa80cacea225b1f6750101a7ac1b34eb9ce58310ec60329e380d docs/TRANSPORT.md
8542c4a5c9d5dfe474459099546ae590b4c4d3f363f0bba9721c6107643c2d3a docs/TROUBLESHOOTING.md
7c8d40210a2f70258c261e05eeb41955d4f65fcafb3159be8cc127d4cf0b5a81 docs/VHOSTS.md
f80fe921f0e93f1833ade5740b102c548210519b3b792d386762a57b92ea54cc docs/WEB_SERVER.md
2fb3da550ff021456fdd5efb869869288cce1f027dafe72be07903fad6a6d1fe docs/VHOSTS.md
a47b394fbe87b312c89a59b17a58310595ebdaa1f55cfa603bd33e28653ed90f docs/WEB_SERVER.md
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/config.json
@@ -76,8 +77,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
62288f4a3a0f2fccee695560f15fdc0fc0f86d2feb4f567e4d3bcf47f05af999 langs/en_US.json
17c4009784455aaaa8d62d3dd0f7ba70631af2364b54684bacc53b76acb07e31 langs/pl_pl.json
973795e8af5240a73df8e1c36325f2c023b3d2c40accf1d1894b8a180f93832e langs/en_US.json
8c9ad51f6624d6ce6f3d0dca7b69fe3e6899c704fb326ee26e8b10d9861be6a5 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -97,6 +98,7 @@ ee50f38c037765fab742e888d4b9fc4d874c31c785c6064d4eca3d2c4d24d944 nagios/check_r
6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues
7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml
bd313cacfc1b450a90368b30ae39ab48b877c8bed3c2db8bbd4e4a1f40865c9e scripts/README.md
f550ffab868c5241ff5d43d9ae569e19f6842b324b80ecefdc7034642125ed9b scripts/audit_css_usage.py
31953ddf3fda1c9b47fb5de7f85b91a23782b4ccc9555b8423a965659a0a8786 scripts/build/swagger_assets.py
e4d15aa6aa864d04b9b30aca33826b833632af02ca643f8b23e219a5ea1298dd scripts/deploy/deploy.py
4853dbb3385ede933dcf66431eaca3bcfdd091c0f265dd8b34609505d29f0b58 scripts/deploy/deploy_core.py
@@ -142,7 +144,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs
8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs
ef072f5af05e712750128f791a4ae45c0d741e3144751ec4c0848b4992e839de src/admin/openapi.json
10a6da3e3d0f0e4ac0c4504a985b52f014c2901235bcaaf9d99d3b244e1cbac4 src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -186,10 +188,10 @@ f1a764691f3e2cdb7c6c43e37dae78249561c017a1b74e973a378cb780b72835 src/admin/web/
560a8e047182fbcee08aa4f8d999400a8a5ddca0b6a4640efd46f6b5c34fde91 src/admin/web/dashboard-defaults.json
14213011cfb6ac334c8711b76bbdde97588a7b7ba4eb2592a1e387f877d6cbe0 src/admin/web/dashboard-widgets.json
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
0c010eb6d463b34f13f98a647ee763ea087112042c1486f577ba6e7025ebf342 src/admin/web/docker.js
d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css
9c43c583513ca6707847e11e25f056b32b41758f9804425f5eb80bcdc66bb0df src/admin/web/docker.js
1d5f8fbf81394a6a8c3c2eff78b48408ef7790c0613905c2c7f584f1548862c6 src/admin/web/docs.css
aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html
6adb291fcc98d516fd9745d9673db16e408854a32cbf41335ae956bda11d46de src/admin/web/docs.js
24b6d5676c2e0cd3862851a395c753dfd7e6fb022f7c69267c1e02b728f1d06f src/admin/web/docs.js
c52c430b9a6e584012e6014709e1e16ae7e775b2a5aedff8eb767fa867c1cbdd src/admin/web/drafts.js
007c24189ad7b497d4da024fc52e6c51393f91ee135f525a356e1f14882b7632 src/admin/web/error_400.html
232b766f278ae4424705af351910677fcd7ac9a20bb8320d9a12b763c91219a2 src/admin/web/error_401.html
@@ -206,7 +208,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
a6d073e94d7fff31b5d590759a5b390558da95e7e37353bf308dbbf302228cc9 src/admin/web/index.html
289ba997f960b4bb9321c32521c9064bf11f093b2f563b94112f86132cf9a225 src/admin/web/index.html
7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
@@ -216,17 +218,18 @@ c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/
a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/partials/overview-metrics.html
3928afce2ed6d5f2d3e29475dd29e4c3bd3c6df180ad5119beaff0c7a58e3b3b src/admin/web/prometheus-token.js
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
17784875e475d6df42964d7ecfe50ca82b74bad0e942342bc17537e727224796 src/admin/web/settings.js
b52db71e9efac0ea0a14fe3c332a6debf28cf762765327be007bf44849704db4 src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
2a3c6382a3a65625654c5117f205b062a50e6c6a2b7e0bbaea7b009d786db63c src/admin/web/style.css
02dc0b1d86046c5f6515e8ca7f4891dd8373a436e479dc575cedaf2f8ef0c80b src/admin/web/streams.js
7776fa7c002a079f781748097538c6983031623fff714d0ad58c55a196e29c22 src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
fde265c3cdc902f81b6400193d9679e738539f64dee29bb857801b13cbeed8fd src/admin/web/vhosts/core.js
7a124b972a43b19184cf060ff85244fc9f7c4e0ef9b1bf28dbcb7c9b6dff391f src/admin/web/vhosts/editor.js
2e323f3fbcd60d606bbeae88dfd024e243727b069c82d9017cfa47acd130bcb0 src/admin/web/unused.css
ad2a127f83145792d5c658b9e9a3fae42e26bd5ec5339599feed34711133720e src/admin/web/vhosts/core.js
4f87802573f2e5abebcade4928ab460b0de78b991a96a391485cb36336d61812 src/admin/web/vhosts/editor.js
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js
8c3da49973c62333559b0e3588c02beb0b425c025eac5cfa82a1bb968f70d9dd src/admin/web/vhosts/rates.js
69c8848159dcc7c4d93f49de3be75553cbecd0731d64302e2b644ae61196e704 src/admin/web/vhosts/render.js
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
fd476445a88eb40270e706814eee50cbfbba4069a26dd843c095367e55a147a1 src/admin/web/web-server.js
@@ -505,11 +508,15 @@ dc3a18c14c3cc28519103e860b0f42a59d163db15df3f68b76778cdfa6786e46 src/web_server
cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_picker_corner_styles.cjs
2467a6c2295d3fb2ad84cdb4a85176372953034fef2f4d95dc3bf58f019efe21 tests/acl_picker_dom.cjs
8bfee0027ce317f3427ecdf9193549d4a52db58a7b2b99db7cdeebc458d7d4c0 tests/cache_outcomes.cjs
09d856ae357dfc5b717b806b2fd2f8a6dd67aab76c2bebeb49d13219beb68f9d tests/css_usage_audit.cjs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs
7a28cfdb43a3cecf90136cf8898c060c382af557ce4ff168a6672391a0e5c26b tests/transport_config.rs
38a1a0594c4aece0bb31332dffcd37c4080410c75b571f124bd906b12d7c1409 tests/ux_0345.cjs
ba40e1109d9689c9bf844f228a4632f4c64b1004f4ceccc3b16ee373f4d351d1 tests/ux_0345_followup.cjs
3421dc1baeb2a9882907b09e816213b375767089c79fce710248c61b9c3e1ae2 tests/vhost_ui_layout.cjs
eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
+2
View File
@@ -13,6 +13,8 @@ Access control can be configured globally, on a Vhost and on selected Custom loc
Default deny plus explicit allow exceptions is usually safer for private admin applications. Apply rules to a staging client before enforcing them on a live business-critical service.
In the **Vhost → Security** editor, Traffic policy groups reusable ACL profiles and the separate **inline Vhost IP/CIDR access list** together, followed by HTTP methods, per-IP rates, country policy, country-rate and User-Agent-rate controls. The inline list is not a second version of an assigned profile: both layers remain independently enforced; an allow in one does not cancel a deny in the other. The **Vhost → Authentication** tab is for Basic Auth and Tinyauth only. HSTS, security headers and User-Agent blocking are separate Security groups. **Custom locations → Access** separates its ACL profile, HTTP method, country and inline IP/CIDR fields. These are only layout changes; restriction scopes still combine as described above.
## HTTP methods and rate limits
Method policies allow, block or leave methods unrestricted. A denied method returns `405`, while typical ACL/country denials return `403`. Per-client rate limits can configure request count, window and burst with optional country overrides; exceeding the limit returns `429` with `Retry-After`. Limits are application controls, not a replacement for upstream DDoS protection.
+4
View File
@@ -10,6 +10,10 @@ RustProxy provides an optional shared HTTP response cache with a bounded in-memo
4. Optionally enable HIT-only diagnostics. Send repeated identical requests using `curl`, then inspect **Cache → Domains / Stored objects** for `MISS → HIT`.
5. Check TTL, revalidation and invalidation after updating the origin's content or configuration.
## Vhost editor options
In **Vhosts → Cache**, overrides for static extensions, bypass path prefixes and unsafe force targets each place the enable/override checkbox immediately above the associated multiline list. The fields are disabled while their individual override is off; their saved values and cache policy are unchanged. Never enable unsafe forced caching for personalized responses.
## Stored object rules
- Reuse is scoped by frontend scheme, Host, exact client URL, Vhost/content policy and eligible `Vary` dimensions. HTTP and HTTPS objects do not cross-share.
+1 -1
View File
@@ -68,7 +68,7 @@ Response transformations are optional and apply only to configured eligible MIME
## Administration preferences
Appearance settings, Dashboard Presets and Overview cards belong to the administrator profile. Up to 16 named presets can store card visibility, order, widths, density and sample count. Changing the dashboard does not restart proxy listeners or rewrite routing rules. Source sections under `config` have a revision guard to prevent accidental overwrite by a stale editor.
Appearance settings, Dashboard Presets and Overview cards belong to the administrator profile. **Settings → Appearance → Data refresh** keeps the automatic-refresh toggle separate from four intervals arranged in two equal columns (one on narrow screens). The saved corner style (Square, Subtle, Rounded or Extra) applies to controls and surfaces in the main panel and the authenticated Reader/OpenAPI page. **Original** keeps the legacy appearance. Up to 16 named presets can store card visibility, order, widths, density and sample count. Changing the dashboard does not restart proxy listeners or rewrite routing rules. Source sections under `config` have a revision guard to prevent accidental overwrite by a stale editor.
## Sample files and safe changes
+1 -1
View File
@@ -79,7 +79,7 @@ In **Docker vhosts**, select the service and set:
| **Cache / compression / transforms** | Optional; obey the global limits and privacy policy of the application. |
| **Health / ACL / timeouts** | Select up to eight reusable ACL profiles per app or inherit the agent's defaults. A searchable multi-select previews the profile ID, default action, IP rules and country filters; selected profiles appear as removable badges. Optional inline ACL remains independently enforced. Explicit denies and country blocks beat merged allows. Test allowed and blocked clients. |
To deliberately assign a domain not covered by the agent's **named frontend certificate**, enable **Accept certificate/domain mismatch** in the top-right TLS section of the per-service editor and confirm the risk. This records `allow_certificate_mismatch: true` on that publication only. Without consent, the server rejects the incompatible mapping. The client browser may still reject the TLS handshake; a matching SAN certificate is strongly preferred. The agent's own outbound TLS fingerprint validation is unaffected.
To deliberately assign a domain not covered by the agent's **named frontend certificate**, enable **Accept certificate/domain mismatch** immediately below **Backend host IP** in the per-service editor and confirm the risk. This records `allow_certificate_mismatch: true` on that publication only. Without consent, the server rejects the incompatible mapping. The client browser may still reject the TLS handshake; a matching SAN certificate is strongly preferred. The agent's own outbound TLS fingerprint validation is unaffected.
Use **Publish** and test the resulting hostname. Edits to an already published service use its own **Apply changes** action; unpublishing does not stop its Docker container. Hidden rows in the page are only a browser view preference, not a security policy.
+2
View File
@@ -16,6 +16,8 @@ RustProxy has operational views in the authenticated panel and an optional stand
Overview allows account-specific card layout and history controls; see [Dashboard](DASHBOARD.md). GeoIP/country analytics depend on the installed/update database and whether country capture is enabled.
**Settings → System information** shows the operating-system/hardware snapshot and **Statistics source**. The old dedicated process-information block is no longer displayed there; this is a panel presentation change only. Process CPU/RAM and related measurements continue to be available in the Overview and authenticated metrics APIs where supported. **Settings → Appearance → Data refresh** stores the per-account refresh intervals; switching off automatic refresh does not disable manual refresh.
## Prometheus exporter
The exporter is **disabled by default** and uses a separate HTTP listener, normally `127.0.0.1:9191`, at `/metrics`. It is not protected by the admin browser cookie; it has its **own** client-network and bearer-token policy. In **Settings → Prometheus** enable the endpoint and configure:
+1 -1
View File
@@ -1,6 +1,6 @@
# RustProxy documentation
Practical guides for **RustProxy 0.34.4**. 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.
Practical guides for **RustProxy 0.34.5**. 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.
## Getting started
+1 -1
View File
@@ -20,7 +20,7 @@ The Web Server can additionally use precompressed `.br`/`.gz` file sidecars when
## Static response transformations
Under **Settings → Transform**, the engine is disabled by default. Even when enabled globally, **each Vhost must opt in**. The default globally enabled transform types, once the engine is turned on, are JavaScript and CSS. HTML, SVG and JSON transformations are individually optional. A Vhost may override the allowed types and extensions within global restrictions.
Under **Settings → Transform**, the engine is disabled by default. Even when enabled globally, **each Vhost must opt in**. The default globally enabled transform types, once the engine is turned on, are JavaScript and CSS. HTML, SVG and JSON transformations are individually optional. A Vhost may override the allowed types and extensions within global restrictions. In **Vhosts → Transform**, the optional custom extension list sits immediately below its override checkbox rather than next to the transform-format selectors. The list remains read-only until its override is enabled.
The available resource controls include:
+2
View File
@@ -12,6 +12,8 @@ The **Streams** workspace forwards non-HTTP TCP or UDP traffic through dedicated
The source model supports a legacy single-upstream configuration or a backend list (up to the validated per-host limit), with pool-based load balancing and health observations. Do not assume HTTP health-check response codes can be applied to a UDP-only application: use the probe options actually supported by the Stream type.
The backend editor groups connection/capacity settings separately from active health-check options. When **Health checks** is Off, probe tuning fields are hidden; selecting TCP connect/UDP probe reveals them without erasing their values. Hidden fields are excluded from browser validation.
## Behavior and boundaries
- **TCP** forwards a bidirectional byte stream. The proxy does not interpret application messages or terminate application TLS.
+8
View File
@@ -44,6 +44,8 @@ Backend pools support scheduling/weighting, standby members, draining and option
- `backup` members act as fallbacks; `drain` prevents new normal selection while letting existing work complete within normal lifetimes. Explicitly pinned backends need careful policy review.
- Concurrency and queue limits protect overloaded origins. Retries are restricted; do not assume non-idempotent requests are automatically replayed.
The **Redirects** and **404 hosts** editors distinguish host identity/domains from frontend listeners and certificate selection. The configuration options have not changed.
## Hostnames, redirects and errors
- `force_ssl` redirects matching plaintext traffic to HTTPS using `308`, preserving path/query; configure both HTTP and HTTPS frontends.
@@ -56,6 +58,12 @@ Backend pools support scheduling/weighting, standby members, draining and option
Keep Vhost IDs stable for monitoring/history unless renaming is intentional. Renaming may reset the runtime identity and invalidate incompatible cache objects. Confirm the deployed scheme and Host, HTTPS trust, method/access restrictions, health status and logs after saving. For per-path behavior see [Custom locations](CUSTOM_LOCATIONS.md); for policies see [Access](ACCESS.md); for caching see [Cache](CACHE.md).
## Editor layout
Within the Vhost editor, **SSL certificates** contains the named certificate selection and the explicit certificate/domain mismatch exception. The next **SSL settings** section contains the optional override of the global TLS 1.2/1.3 and cipher policy. Turning off the TLS override retains the inherited global policy. These two sections are on the same **SSL** tab.
The **Security** tab groups Traffic policy by reusable ACL profiles, HTTP methods, per-IP limits, country access, country-specific limits and User-Agent limits. It also separates HSTS, other security response headers and per-Vhost User-Agent blocking. Within **Custom locations → Access**, profile, HTTP method, country and inline IP/CIDR rules are likewise separated. **Details** separates base Vhost properties from WebSocket options; **Backends** separates balancing strategy, retry behavior and keepalive/connection reuse. The visual grouping does not change saved configuration keys or access precedence.
## Several ACL profiles and intentional TLS mismatch
Assign up to eight ACL profiles using the Vhost security tab. Explicit deny/blocked country wins, matching explicit allows combine across those profiles, and unmatched defaults must all allow. The legacy single `acl_profile_id` field still works. Inline, location and global policies are independently enforced. See [Access](ACCESS.md) for precedence and safe examples.
+5 -1
View File
@@ -34,12 +34,16 @@ In **Web Server → Edit service → Delivery**, `sendfile` supports `off` (defa
## Rewrites and application presets
The editor supports common website presets to fill suggested fields; review their document root, `try_files`, MIME and PHP values before saving. Presets do not install WordPress, Roundcube or other applications. For a single-page app, a fallback similar to `$uri`, `$uri/`, `/index.html` serves the SPA entry for routes without an existing file. For PHP frameworks, use the appropriate front controller; see [PHP-FPM](PHP_FPM.md).
The editor supports common website presets to fill suggested fields; review their document root, `try_files`, MIME and PHP values before saving. The two independent hardening checkboxes (block common attack probes and remove fingerprinting headers) are directly below the preset selection and Apply action; the chosen values remain editable. Presets do not install WordPress, Roundcube or other applications. For a single-page app, a fallback similar to `$uri`, `$uri/`, `/index.html` serves the SPA entry for routes without an existing file. For PHP frameworks, use the appropriate front controller; see [PHP-FPM](PHP_FPM.md).
## `.htaccess` compatibility
An optional bounded Apache-style `.htaccess` compatibility layer supports a **subset** of rules useful for migrations, not arbitrary Apache modules or dynamic directive execution. It can scan configured paths and enforce stricter handling of unsupported expressions. Prefer explicit `try_files`/rewrite/header controls when possible and test migrated sites. A real Apache configuration cannot be pasted unchanged into RustProxy.
## Admin editor layout
In **Web Server → Edit → Delivery**, static file delivery, dynamic compression and precompressed assets have separate sections. In **PHP-FPM**, profile/endpoint, request options, resource limits and FastCGI/php.ini overrides are grouped. Disabled local controls indicate that PHP is off or a shared profile owns those settings; selection and saved values behave as before.
## PHP and error responses
A Web Service may enable PHP via a shared PHP-FPM profile or its own configuration. PHP is executed by the FPM service, not RustProxy, and must not be exposed as a raw Internet port. Use dedicated pools for different applications. See [PHP-FPM](PHP_FPM.md).
+26 -1
View File
@@ -6,6 +6,16 @@
"direction": "ltr"
},
"translations": {
"webServiceStaticDeliverySection": "Static file delivery",
"webServiceDynamicCompressionSection": "Dynamic compression",
"webServiceSidecarsSection": "Precompressed assets",
"webServicePhpSourceSection": "PHP-FPM source and endpoint",
"webServicePhpRequestSection": "Request handling",
"webServicePhpLimitsSection": "Timeouts and capacity",
"webServicePhpOverridesSection": "FastCGI and php.ini overrides",
"hostIdentitySection": "Host and domains",
"streamBackendConnectionSection": "Backend and capacity",
"streamBackendHealthSection": "Health checks",
"fileLogsSortAscending": "Sort ascending",
"fileLogsSortDescending": "Sort descending",
"policyRulesTitle": "Rules & headers",
@@ -586,7 +596,7 @@
"certDuplicate": "This certificate ID exists. Use a new ID for safe rotation.",
"certManage": "Manage certificate library",
"certSelectHint": "Select an imported certificate. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.",
"vhTinyauth": "Authentication and IP access",
"vhTinyauth": "Authentication",
"vhTabAuth": "Authentication",
"taEnable": "Enable Tinyauth protection",
"taUrl": "Tinyauth URL / verification endpoint",
@@ -874,6 +884,20 @@
"advanced": "Advanced",
"docsLicenses": "Swagger UI licenses",
"vhTrafficPolicy": "Traffic policy",
"vhGeneralDetailsSection": "General settings",
"vhWebsocketOptionsSection": "WebSocket options",
"vhBalancerAlgorithmSection": "Load balancing strategy",
"vhBalancerRetrySection": "Retry policy",
"vhBalancerKeepaliveSection": "Keepalive and connection reuse",
"vhSecurityHstsSection": "HSTS",
"vhSecurityHeadersSection": "Security headers",
"vhUaBlockSection": "User-Agent blocking",
"vhLocationIpAclSection": "Local IP/CIDR rules",
"vhSslSettings": "SSL settings",
"vhTrafficMethodsSection": "HTTP methods",
"vhTrafficRateSection": "Per-client IP rate limiting",
"vhTrafficCountrySection": "Country access",
"vhTrafficCountryRatesSection": "Country-specific rate limits",
"vhHttp3Enabled": "Enable HTTP/3 for this vhost",
"vhCloudflareClientIp": "Use verified Cloudflare client IP",
"vhTimingHeaders": "Expose Server-Timing diagnostics",
@@ -1071,6 +1095,7 @@
"vhAclProfile": "ACL profiles",
"vhAclProfileNone": "No reusable profile",
"vhAclProfileHelp": "Select up to eight reusable ACL profiles. Hover or open the selector to preview their default action, IP rules and country restrictions. Inline Vhost ACLs and other restrictions still apply separately.",
"vhInlineAclHelp": "Inline IP/CIDR rules apply independently of reusable ACL profiles and can further restrict access. An allow in one layer does not override a deny in another.",
"multiAclWarning": "Multiple ACL profiles: explicit IP deny and blocked country take priority over allows in every selected profile. Explicit allows are combined (OR); if no rule matches, all profile defaults must allow. Vhost inline ACL, location ACL and global host ACL still apply separately (AND). Avoid mixing allow-all policies with denies: it is easy to expose or accidentally block access.",
"vhCertificateMismatchForce": "Force an incompatible certificate (explicit risk acceptance)",
"vhCertificateMismatchWarning": "This allows RustProxy to publish this name with a certificate that does not cover it. Browsers and TLS clients will still reject or warn about the hostname mismatch. Use only when necessary; obtain a matching certificate whenever possible.",
+26 -1
View File
@@ -6,6 +6,16 @@
"direction": "ltr"
},
"translations": {
"webServiceStaticDeliverySection": "Dostarczanie plików statycznych",
"webServiceDynamicCompressionSection": "Kompresja dynamiczna",
"webServiceSidecarsSection": "Gotowe pliki skompresowane",
"webServicePhpSourceSection": "Źródło konfiguracji i połączenie PHP-FPM",
"webServicePhpRequestSection": "Obsługa żądań",
"webServicePhpLimitsSection": "Limity czasu i obciążenia",
"webServicePhpOverridesSection": "Parametry FastCGI i ustawienia php.ini",
"hostIdentitySection": "Host i domeny",
"streamBackendConnectionSection": "Backend i obciążenie",
"streamBackendHealthSection": "Kontrola dostępności",
"fileLogsSortAscending": "Sortuj rosnąco",
"fileLogsSortDescending": "Sortuj malejąco",
"policyRulesTitle": "Reguły i nagłówki",
@@ -586,7 +596,7 @@
"certDuplicate": "Ten identyfikator certyfikatu istnieje. Do bezpiecznej rotacji użyj nowego.",
"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.",
"vhTinyauth": "Uwierzytelnianie i dostęp IP",
"vhTinyauth": "Uwierzytelnianie",
"vhTabAuth": "Uwierzytelnianie",
"taEnable": "Włącz ochronę Tinyauth",
"taUrl": "URL Tinyauth / punkt weryfikacji",
@@ -874,6 +884,20 @@
"advanced": "Zaawansowane",
"docsLicenses": "Licencje Swagger UI",
"vhTrafficPolicy": "Polityka ruchu",
"vhGeneralDetailsSection": "Ustawienia podstawowe",
"vhWebsocketOptionsSection": "Opcje WebSocket",
"vhBalancerAlgorithmSection": "Strategia równoważenia ruchu",
"vhBalancerRetrySection": "Ponawianie żądań",
"vhBalancerKeepaliveSection": "Keepalive i ponowne użycie połączeń",
"vhSecurityHstsSection": "HSTS",
"vhSecurityHeadersSection": "Nagłówki bezpieczeństwa",
"vhUaBlockSection": "Blokowanie User-Agent",
"vhLocationIpAclSection": "Lokalne reguły IP/CIDR",
"vhSslSettings": "Ustawienia SSL",
"vhTrafficMethodsSection": "Metody HTTP",
"vhTrafficRateSection": "Limity zapytań dla adresów IP",
"vhTrafficCountrySection": "Dostęp według kraju",
"vhTrafficCountryRatesSection": "Limity zapytań dla krajów",
"vhHttp3Enabled": "Włącz HTTP/3 dla tego vhosta",
"vhCloudflareClientIp": "Używaj zweryfikowanego IP klienta z Cloudflare",
"vhTimingHeaders": "Udostępniaj diagnostykę Server-Timing",
@@ -1071,6 +1095,7 @@
"vhAclProfile": "Profile ACL",
"vhAclProfileNone": "Bez wspólnego profilu",
"vhAclProfileHelp": "Wybierz maksymalnie 8 profili ACL. Rozwiń listę, by zobaczyć domyślną akcję, reguły IP i ograniczenia krajów. Lokalne ACL Vhosta i pozostałe ograniczenia nadal działają niezależnie.",
"vhInlineAclHelp": "Lokalne reguły IP/CIDR są niezależne od profili ACL i również ograniczają dostęp. Zezwolenie w jednym miejscu nie znosi blokady w drugim.",
"multiAclWarning": "Wiele profili ACL: jawne blokady IP i zablokowane kraje mają pierwszeństwo przed zezwoleniami we wszystkich profilach. Jawne zezwolenia są łączone (LUB); gdy żadna reguła nie pasuje, wszystkie wartości domyślne muszą zezwalać. Lokalne ACL vhosta, lokalizacje i globalne ACL pozostają dodatkowymi ograniczeniami (ORAZ). Nie zaleca się łączenia reguł „zezwól wszystkim” i „blokuj” — łatwo przypadkowo otworzyć albo zablokować dostęp.",
"vhCertificateMismatchForce": "Wymuś niedopasowany certyfikat (świadoma akceptacja ryzyka)",
"vhCertificateMismatchWarning": "RustProxy pozwoli przypisać domenę do certyfikatu, który jej nie obejmuje. Przeglądarki i klienci TLS nadal mogą odrzucić połączenie z powodu niezgodności nazwy. Zalecany jest prawidłowy certyfikat.",
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Conservative CSS reference audit for the RustProxy administration UI.
Requires: pip install tinycss2 cssselect2 (audit-only tooling, not runtime deps).
Output is an inventory, NOT permission to remove dynamically generated styles.
Selectors are considered dead only when a required class/ID token is absent
from all application templates and scripts; :not() is not treated as a requirement.
The Swagger UI bundle emits its own markup, so docs.css is reviewed separately.
"""
from __future__ import annotations
import argparse
from collections import Counter
import json
from pathlib import Path
import re
try:
import tinycss2
from cssselect2 import parser as css_parser
except ImportError as exc:
raise SystemExit("Install audit dependencies: python3 -m pip install tinycss2 cssselect2") from exc
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / 'src/admin/web'
STYLES = ('style.css', 'docs.css')
SOURCE_SUFFIXES = {'.js', '.html', '.json', '.rs'}
UNSUPPORTED_PSEUDO_ELEMENTS = re.compile(r'::(?:-webkit-[\w-]+|-moz-[\w-]+|backdrop)\b')
WORD_PATTERN = re.compile(r'(?<![\w-])[a-zA-Z_][\w-]*(?![\w-])')
def source_words(scope: str) -> set[str]:
paths = list(WEB.rglob('*.js')) + list(WEB.rglob('*.html'))
if scope == 'panel':
paths += list(WEB.rglob('*.json')) + list((ROOT / 'src').rglob('*.rs'))
else:
paths = [WEB / 'docs.html', WEB / 'docs.js']
words: set[str] = set()
for path in paths:
words.update(WORD_PATTERN.findall(path.read_text(encoding='utf-8', errors='replace')))
return words
def selector_parts(tokens: list) -> list[str]:
parts: list[list] = [[]]
for tok in tokens:
if tok.type == 'literal' and tok.value == ',':
parts.append([])
else:
parts[-1].append(tok)
return [tinycss2.serialize(part).strip() for part in parts if tinycss2.serialize(part).strip()]
def rules(stylesheet: str):
def walk(items):
for item in items:
if item.type == 'qualified-rule':
yield item.source_line, selector_parts(item.prelude)
elif item.type == 'at-rule' and item.lower_at_keyword in ('media', 'container', 'supports', 'layer') and item.content:
yield from walk(tinycss2.parse_rule_list(item.content, skip_whitespace=True, skip_comments=True))
return list(walk(tinycss2.parse_stylesheet(stylesheet, skip_whitespace=True, skip_comments=True)))
def token_refs(node) -> list[tuple[str, str]]:
if isinstance(node, css_parser.ClassSelector):
return [('class', node.class_name)]
if isinstance(node, css_parser.IDSelector):
return [('id', node.ident)]
if isinstance(node, css_parser.AttributeSelector) and node.operator in ('=', '~=') and node.value:
if node.name in ('class', 'id'):
return [(node.name, node.value)]
return []
out = []
for field in ('parsed_tree', 'left', 'right', 'simple_selectors', 'selector', 'selector_list'):
value = getattr(node, field, None)
for child in (value if isinstance(value, (tuple, list)) else [value]):
if child is not None:
out.extend(token_refs(child))
return out
def possibly_matches(node, words: set[str]) -> bool:
# Deliberately conservative: class/id absence proves a positive selector
# impossible but does NOT make a selector in :not() impossible.
if isinstance(node, css_parser.ClassSelector):
return node.class_name in words
if isinstance(node, css_parser.IDSelector):
return node.ident in words
if isinstance(node, css_parser.AttributeSelector):
if node.name in ('class', 'id') and node.operator in ('=', '~=') and node.value:
return node.value in words
return True
if isinstance(node, css_parser.NegationSelector):
return True
if isinstance(node, (css_parser.MatchesAnySelector, css_parser.SpecificityAdjustmentSelector, css_parser.RelationalSelector)):
return any(possibly_matches(child, words) for child in node.selector_list)
if isinstance(node, (css_parser.CombinedSelector, css_parser.CompoundSelector)):
children = ([node.left, node.right] if isinstance(node, css_parser.CombinedSelector)
else node.simple_selectors)
return all(possibly_matches(child, words) for child in children)
if isinstance(node, css_parser.Selector):
return possibly_matches(node.parsed_tree, words)
if isinstance(node, css_parser.RelativeSelector):
return possibly_matches(node.selector, words)
# Pseudo-classes, type selectors, vendor syntax and other unknowns: KEEP.
return True
def audit_file(path: Path, words: set[str], scope: str) -> dict:
raw = path.read_text(encoding='utf-8')
entries = []
all_classes: set[str] = set()
unknown = []
for line, selectors in rules(raw):
parts = []
for selector in selectors:
text = UNSUPPORTED_PSEUDO_ELEMENTS.sub('', selector)
try:
parsed = list(css_parser.parse(text))
references = set().union(*(set(token_refs(item)) for item in parsed))
all_classes.update(word for typ, word in references if typ == 'class')
dead = bool(parsed) and not any(possibly_matches(item, words) for item in parsed)
# docs.css targets classes emitted by the third-party Swagger UI bundle.
external = scope == 'docs' and '.swagger-ui' in selector
if external:
dead = False
missing = sorted({f'{typ}:{word}' for typ, word in references if word not in words})
parts.append({'selector': selector, 'dead': dead, 'missing_tokens': missing,
'external': external})
except css_parser.SelectorError as exc:
parts.append({'selector': selector, 'dead': False, 'missing_tokens': [], 'unparsed': str(exc)})
unknown.append({'line': line, 'selector': selector, 'error': str(exc)})
entries.append({'line': line, 'parts': parts})
dead_parts = [p for e in entries for p in e['parts'] if p['dead']]
kept_parts = [p for e in entries for p in e['parts'] if not p['dead']]
return {
'file': str(path.relative_to(ROOT)), 'bytes_before': len(raw.encode('utf-8')),
'total_rules': len(entries), 'total_selectors': len(dead_parts) + len(kept_parts),
'referenced_classes': sorted(all_classes & words),
'unreferenced_classes': sorted(all_classes - words),
'unused_selector_count': len(dead_parts), 'active_selector_count': len(kept_parts),
'unparsed': unknown, 'entries': entries,
}
def main():
cli = argparse.ArgumentParser(description=__doc__)
cli.add_argument('--json', type=Path, help='Write complete selector audit JSON to this file')
args = cli.parse_args()
results = [audit_file(WEB / filename, source_words('docs' if filename == 'docs.css' else 'panel'),
'docs' if filename == 'docs.css' else 'panel') for filename in STYLES]
for r in results:
print(f"{r['file']}: {r['total_rules']} rules, {r['total_selectors']} selector branches, "
f"{len(r['referenced_classes'])} referenced classes, {len(r['unreferenced_classes'])} unreferenced classes, "
f"{r['unused_selector_count']} provably dead selector branches, {len(r['unparsed'])} unparsed")
print(' Unreferenced:', ', '.join(r['unreferenced_classes']) or '(none)')
for e in r['entries']:
for p in e['parts']:
if p['dead']:
print(f" {e['line']}: {p['selector'][:170]} ({', '.join(p['missing_tokens'])})")
if args.json:
args.json.write_text(json.dumps(results, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
if __name__ == '__main__':
main()
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.4",
"version": "0.34.5",
"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": [
+4 -2
View File
@@ -342,6 +342,8 @@
mismatchLabel.title = t(certMismatch.disabled ? "dockerCertMismatchUnavailable" : "dockerCertMismatchWarning");
const certificateBar = el("div", undefined, "docker-service-certificate-bar");
certificateBar.append(mismatchLabel);
const backendIpSettings = el("div", undefined, "docker-backend-ip-settings");
backendIpSettings.append(labeled("dockerBackendIp", target), certificateBar);
const h2Enabled = input("", "checkbox", "docker-http2-enabled"); h2Enabled.checked = assignment?.http2_enabled !== false;
// New publications default to H3 off; legacy assignments keep their saved behavior.
@@ -353,8 +355,8 @@
h1.append(el("strong", "HTTP/1.1"), el("small", t("dockerHttp1Always"), "fine"));
protocolOptions.append(h1, checkboxField("dockerHttp2Enabled", h2Enabled), checkboxField("dockerHttp3Enabled", h3Enabled));
protocolBox.append(protocolSummary, protocolOptions);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
editor.append(certificateBar, route, protocolBox, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, backendIpSettings, labeled("dockerBackendScheme", scheme), switches);
editor.append(route, protocolBox, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
const actions = el("div", undefined, "docker-service-actions"), apply = el("button", t("dockerApplyService"), "outline docker-apply"); apply.type = "button"; apply.disabled = !publish.checked; apply.addEventListener("click", async () => { apply.disabled = true; try { await saveAssignment(agent.id, row, true); } catch (error) { toast(error.message, true); } finally { if (row.isConnected) apply.disabled = !publish.checked; } }); actions.append(apply); editor.append(actions); row.append(editor);
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel} · H1${h2Enabled.checked ? "/H2" : ""}${h3Enabled.checked ? "/H3" : ""}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
for (const control of [host, aliases.input, target, scheme]) { control.addEventListener("input", updateHint); control.addEventListener("change", updateHint); }
+40 -1
View File
@@ -89,7 +89,7 @@ main {
.swagger-ui input,
.swagger-ui textarea,
.swagger-ui .dialog-ux .modal-ux {
border-radius: 0
border-radius: var(--rp-docs-radius-surface, 0)
}
.swagger-ui .opblock .opblock-summary {
@@ -344,3 +344,42 @@ main {
padding: 18px
}
}
/* Authenticated documentation uses the same stored corner preference as the
main panel, while retaining its original flat shape for "Original". */
html[data-corner-style=square] {
--rp-docs-radius-surface: 0px;
--rp-docs-radius-control: 0px;
}
html[data-corner-style=subtle] {
--rp-docs-radius-surface: 5px;
--rp-docs-radius-control: 4px;
}
html[data-corner-style=rounded] {
--rp-docs-radius-surface: 12px;
--rp-docs-radius-control: 8px;
}
html[data-corner-style=extra] {
--rp-docs-radius-surface: 18px;
--rp-docs-radius-control: 12px;
}
.docs-session,
.docs-api-controls,
.reader-sidebar,
.markdown-reader,
.docs-confirm,
#docs-error {
border-radius: var(--rp-docs-radius-surface, 0);
}
.docs-bar button,
.docs-confirm-actions button,
.docs-tabs button,
.reader-sidebar input,
.reader-list button,
.swagger-ui .btn,
.swagger-ui select,
.swagger-ui input,
.swagger-ui textarea {
border-radius: var(--rp-docs-radius-control, 0);
}
+5
View File
@@ -1,6 +1,11 @@
"use strict";
(() => {
const element = id => document.getElementById(id);
const corners = new Set(["default", "square", "subtle", "rounded", "extra"]);
try {
const savedCorner = localStorage.getItem("rp-corner-style");
document.documentElement.dataset.cornerStyle = corners.has(savedCorner) ? savedCorner : "default";
} catch { document.documentElement.dataset.cornerStyle = "default"; }
let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
let documents = [], docsByPath = new Map(), currentDocument = null, activeTab = "reader";
let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { }
+135 -68
View File
@@ -1922,7 +1922,7 @@
</div>
<p class="fine" data-i18n="refreshHelp">Per-account refresh intervals. Off pauses
all automatic sources; manual refresh remains available.</p>
<div class="appearance-settings-grid">
<div class="appearance-settings-grid refresh-settings-grid">
<label class="check"><input type="checkbox" checked id="refresh-live-enabled"
data-refresh-preference="live_enabled" />
<span data-i18n="refreshLiveEnabled">Automatic refresh
@@ -2920,19 +2920,6 @@
id="runtime-system-memory">-</code></div>
<div><span class="small-label" data-i18n="systemUptime">System uptime</span><code
id="runtime-system-uptime">-</code></div>
</div>
<h3 data-i18n="systemProcessHeading">RustProxy process</h3>
<div class="runtime-information-grid">
<div><span class="small-label" data-i18n="systemAppVersion">RustProxy
version</span><code id="runtime-version">-</code></div>
<div><span class="small-label" data-i18n="systemProcessUptime">Process
uptime</span><code id="runtime-process-uptime">-</code></div>
<div><span class="small-label" data-i18n="systemProcessMemory">Process RAM
(RSS)</span><code id="runtime-process-memory">-</code></div>
<div><span class="small-label" data-i18n="systemProcessThreads">Process
threads</span><code id="runtime-process-threads">-</code></div>
<div><span class="small-label" data-i18n="systemOpenFds">Open file
descriptors</span><code id="runtime-open-fds">-</code></div>
<div><span class="small-label" data-i18n="statisticsSource">Statistics
source</span><code id="runtime-statistics-source">-</code></div>
</div>
@@ -3550,11 +3537,13 @@
<div class="vh-field"><span class="small-label" data-i18n="webServicePresetAction">Preset
action</span><button class="outline" data-i18n="webServicePresetApply"
id="web-service-preset-apply" type="button">Apply preset</button></div>
<div class="vh-wide web-service-preset-toggles">
<label class="check"><input id="web-service-hardening-attacks" type="checkbox" /><span
data-i18n="webServiceHardeningAttacks">Block common attack probes</span></label>
<label class="check"><input id="web-service-hardening-headers" type="checkbox" /><span
data-i18n="webServiceHardeningHeaders">Strip unnecessary fingerprint
headers</span></label>
</div>
<p class="fine vh-wide" data-i18n="webServicePresetHelp" id="web-service-preset-summary">
Presets fill normal Web Service settings and remain fully editable. Document root,
service ID/name and PHP-FPM endpoint are not replaced.</p>
@@ -3629,20 +3618,42 @@
responses are never stored.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
<label class="vh-wide"><span data-i18n="webServiceSendfile">Linux sendfile (zero-copy)</span>
<section class="vh-wide vh-setting-section web-service-setting-section">
<h3 data-i18n="webServiceStaticDeliverySection">Static file delivery</h3>
<div class="vh-grid">
<label class="vh-wide"><span data-i18n="webServiceSendfile">Linux sendfile
(zero-copy)</span>
<select id="web-service-sendfile">
<option value="off" data-i18n="notEnabled">Disabled</option>
<option value="auto" data-i18n="webServiceSendfileAuto">Auto (eligible plain HTTP/1.1)
<option value="auto" data-i18n="webServiceSendfileAuto">Auto (eligible plain
HTTP/1.1)
</option>
</select><small class="fine" data-i18n="webServiceSendfileHelp">Opt-in for static GET files at
least 64 KiB on Linux HTTP/1.1 without TLS, cache, compression or transform. Other requests
</select><small class="fine" data-i18n="webServiceSendfileHelp">Opt-in for static GET
files at
least 64 KiB on Linux HTTP/1.1 without TLS, cache, compression or transform. Other
requests
use safe streaming; switching this setting never clears the cache.</small></label>
<label class="vh-wide"><span data-i18n="webServiceDownloadExtensions">Download
extensions</span><textarea id="web-service-download-extensions" maxlength="4096"
rows="5" spellcheck="false" placeholder="zip&#10;pdf&#10;tar"></textarea><small
class="fine" data-i18n="webServiceDownloadExtensionsHelp">Files with these
extensions receive
Content-Disposition: attachment.</small></label>
<p class="fine vh-wide" data-i18n="webServiceRangeHelp">Static and precompressed
representations
keep validators, single-range requests, 206/416 and streaming delivery.</p>
</div>
</section>
<section class="vh-wide vh-setting-section web-service-setting-section">
<h3 data-i18n="webServiceDynamicCompressionSection">Dynamic compression</h3>
<div class="vh-grid">
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select
id="web-service-compression">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select><small class="fine" data-i18n="webServiceCompressionHelp">Inherits the vhost/global
</select><small class="fine" data-i18n="webServiceCompressionHelp">Inherits the
vhost/global
policy by default. Codecs can be filtered per service; globally disabled codecs stay
unavailable.</small></label>
<div class="vh-wide vh-grid" id="web-service-compression-options">
@@ -3658,7 +3669,8 @@
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
<label><span data-i18n="compressionGzip">gzip</span><select id="web-service-compression-gzip">
<label><span data-i18n="compressionGzip">gzip</span><select
id="web-service-compression-gzip">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
@@ -3670,26 +3682,31 @@
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
</div>
</div>
</section>
<section class="vh-wide vh-setting-section web-service-setting-section">
<h3 data-i18n="webServiceSidecarsSection">Precompressed assets</h3>
<div class="vh-grid">
<label class="check"><input id="web-service-precompressed" type="checkbox" /><span
data-i18n="webServicePrecompressed">Serve precompressed sidecars</span></label>
<label class="check"><input id="web-service-precompressed-br" type="checkbox" /><span
data-i18n="webServicePrecompressedBr">Brotli (.br)</span></label>
<label class="check"><input id="web-service-precompressed-gzip" type="checkbox" /><span
data-i18n="webServicePrecompressedGzip">Gzip (.gz)</span></label>
<label class="vh-wide"><span data-i18n="webServiceDownloadExtensions">Download
extensions</span><textarea id="web-service-download-extensions" maxlength="4096" rows="5"
spellcheck="false" placeholder="zip&#10;pdf&#10;tar"></textarea><small class="fine"
data-i18n="webServiceDownloadExtensionsHelp">Files with these extensions receive
Content-Disposition: attachment.</small></label>
<p class="fine vh-wide" data-i18n="webServiceRangeHelp">Static and precompressed representations
keep validators, single-range requests, 206/416 and streaming delivery.</p>
</div>
</section>
</div>
<div class="vh-grid web-service-page" data-web-service-page="php" hidden>
<label class="vh-wide"><span data-i18n="phpProfileSelect">PHP-FPM configuration source</span>
<section class="vh-wide vh-setting-section web-service-setting-section">
<h3 data-i18n="webServicePhpSourceSection">PHP-FPM source and endpoint</h3>
<div class="vh-grid">
<label class="vh-wide"><span data-i18n="phpProfileSelect">PHP-FPM configuration
source</span>
<select id="web-service-php-profile">
<option value="" data-i18n="phpProfileLocal">Local configuration</option>
</select>
<small class="fine" data-i18n="phpProfileHelp">Select a shared profile or edit PHP-FPM locally.
<small class="fine" data-i18n="phpProfileHelp">Select a shared profile or edit PHP-FPM
locally.
Profile changes apply to all attached Web Services.</small>
</label>
<div class="vh-wide"><button type="button" class="outline" id="web-service-php-profiles"
@@ -3698,10 +3715,12 @@
id="web-service-php-enabled" type="checkbox" /><span
data-i18n="webServicePhpEnabled">Enable PHP-FPM / FastCGI</span></label>
<details class="metric-info php-field-info">
<summary data-i18n-aria="phpHintEnabled" aria-label="About enabling PHP-FPM">i</summary>
<summary data-i18n-aria="phpHintEnabled" aria-label="About enabling PHP-FPM">i
</summary>
<div class="metric-info-popover">
<p class="fine" data-i18n="phpHintEnabled">Enables execution of configured PHP
extensions via the PHP-FPM socket. The pool must already be running; RustProxy does
extensions via the PHP-FPM socket. The pool must already be running;
RustProxy does
not start PHP. Other files remain static.</p>
</div>
</details>
@@ -3714,17 +3733,26 @@
addresses.</small></label>
<label><span data-i18n="webServicePhpExtensions">PHP extensions</span><input
id="web-service-php-extensions" maxlength="512" placeholder="php, phtml" /><small
class="fine" data-i18n="phpHintExtensions">Comma-separated extensions without dots (e.g.
class="fine" data-i18n="phpHintExtensions">Comma-separated extensions without dots
(e.g.
php, phtml). Only listed types are executed as PHP.</small></label>
</div>
</section>
<section class="vh-wide vh-setting-section web-service-setting-section">
<h3 data-i18n="webServicePhpRequestSection">Request handling</h3>
<div class="vh-grid">
<div class="vh-wide php-switch-row"><label class="check php-switch-label"><input
id="web-service-php-pass-authorization" type="checkbox" /><span
data-i18n="webServicePhpPassAuthorization">Forward Authorization header</span></label>
data-i18n="webServicePhpPassAuthorization">Forward Authorization
header</span></label>
<details class="metric-info php-field-info">
<summary data-i18n-aria="phpHintPassAuthorization"
aria-label="Authorization forwarding information">i</summary>
<div class="metric-info-popover">
<p class="fine" data-i18n="phpHintPassAuthorization">Passes the incoming Authorization
header to PHP as HTTP_AUTHORIZATION. Enable for API/Bearer/Basic authentication;
<p class="fine" data-i18n="phpHintPassAuthorization">Passes the incoming
Authorization
header to PHP as HTTP_AUTHORIZATION. Enable for API/Bearer/Basic
authentication;
disable if the application does not need it.</p>
</div>
</details>
@@ -3733,64 +3761,93 @@
id="web-service-php-path-info" type="checkbox" /><span
data-i18n="webServicePhpPathInfo">Enable PHP PATH_INFO splitting</span></label>
<details class="metric-info php-field-info">
<summary data-i18n-aria="phpHintPathInfo" aria-label="PHP PATH_INFO information">i</summary>
<summary data-i18n-aria="phpHintPathInfo" aria-label="PHP PATH_INFO information">i
</summary>
<div class="metric-info-popover">
<p class="fine" data-i18n="phpHintPathInfo">Allows /static.php/skins/file.css to run the
existing static.php script with PATH_INFO=/skins/file.css. Needed by Roundcube
<p class="fine" data-i18n="phpHintPathInfo">Allows /static.php/skins/file.css to
run the
existing static.php script with PATH_INFO=/skins/file.css. Needed by
Roundcube
static.php routes; usually not required for WordPress. Off by default.</p>
</div>
</details>
</div>
</div>
</section>
<section class="vh-wide vh-setting-section web-service-setting-section">
<h3 data-i18n="webServicePhpLimitsSection">Timeouts and capacity</h3>
<div class="vh-grid">
<label><span data-i18n="webServicePhpConnectTimeout">Connect timeout (ms)</span><input
id="web-service-php-connect-timeout" min="50" max="60000" step="1" type="number" /><small
class="fine" data-i18n="phpHintConnectTimeout">How long to wait for the FPM socket
id="web-service-php-connect-timeout" min="50" max="60000" step="1"
type="number" /><small class="fine" data-i18n="phpHintConnectTimeout">How long to
wait for the FPM socket
connection; e.g. 1000 = 1 s (50–60000).</small></label>
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout (ms)</span><input
id="web-service-php-request-timeout" min="100" max="3600000" step="1" type="number" /><small
class="fine" data-i18n="phpHintRequestTimeout">Time for the complete FastCGI exchange; e.g.
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout
(ms)</span><input id="web-service-php-request-timeout" min="100" max="3600000"
step="1" type="number" /><small class="fine" data-i18n="phpHintRequestTimeout">Time
for the complete FastCGI exchange; e.g.
30000 = 30 s (100–3600000).</small></label>
<label><span data-i18n="webServicePhpConcurrency">Max concurrent PHP requests</span><input
id="web-service-php-concurrency" min="1" max="4096" step="1" type="number" /><small
class="fine" data-i18n="phpHintConcurrency">Max simultaneous PHP requests for this service;
class="fine" data-i18n="phpHintConcurrency">Max simultaneous PHP requests for this
service;
e.g. 64. Exhaustion returns 503 (1–4096).</small></label>
<label><span data-i18n="webServicePhpBodyLimit">PHP request body limit (bytes, 0 =
unlimited)</span><input id="web-service-php-body-limit" min="0" max="8589934592" step="1"
type="number" /><small class="fine" data-i18n="phpHintBodyLimit">Bytes, not MB: 67108864 =
unlimited)</span><input id="web-service-php-body-limit" min="0" max="8589934592"
step="1" type="number" /><small class="fine" data-i18n="phpHintBodyLimit">Bytes, not
MB: 67108864 =
64 MiB; 0 = no proxy-side limit. PHP post_max_size is separate.</small></label>
<label><span data-i18n="webServicePhpHeaderLimit">Max FastCGI response header bytes</span><input
id="web-service-php-header-limit" min="4096" max="1048576" step="1" type="number" /><small
class="fine" data-i18n="phpHintHeaderLimit">Max PHP response-header size in bytes; default
<label><span data-i18n="webServicePhpHeaderLimit">Max FastCGI response header
bytes</span><input id="web-service-php-header-limit" min="4096" max="1048576"
step="1" type="number" /><small class="fine" data-i18n="phpHintHeaderLimit">Max PHP
response-header size in bytes; default
65536 = 64 KiB (4096–1048576).</small></label>
<label><span data-i18n="webServicePhpStderrLimit">Max PHP-FPM stderr log bytes</span><input
id="web-service-php-stderr-limit" min="0" max="65536" step="1" type="number" /><small
class="fine" data-i18n="phpHintStderrLimit">Max FastCGI STDERR bytes kept per request; e.g.
id="web-service-php-stderr-limit" min="0" max="65536" step="1"
type="number" /><small class="fine" data-i18n="phpHintStderrLimit">Max FastCGI
STDERR bytes kept per request; e.g.
4096. 0 disables capture.</small></label>
</div>
</section>
<section class="vh-wide vh-setting-section web-service-setting-section">
<h3 data-i18n="webServicePhpOverridesSection">FastCGI and php.ini overrides</h3>
<div class="vh-grid">
<label class="vh-wide"><span data-i18n="webServicePhpCustomParams">Custom FastCGI
parameters</span><textarea id="web-service-php-custom-params" maxlength="65536" rows="6"
spellcheck="false" placeholder="APP_ENV = production&#10;APP_DEBUG = 0"></textarea><small
class="fine" data-i18n="webServicePhpCustomParamsHelp">One NAME = value entry per line.
parameters</span><textarea id="web-service-php-custom-params" maxlength="65536"
rows="6" spellcheck="false"
placeholder="APP_ENV = production&#10;APP_DEBUG = 0"></textarea><small class="fine"
data-i18n="webServicePhpCustomParamsHelp">One NAME = value entry per line.
Static trusted values only; core CGI, HTTP_* and PHP_* control names are
reserved. Example: APP_ENV = production; no variable interpolation.</small></label>
<label class="vh-wide"><span data-i18n="webServicePhpValues">PHP INI overrides
(PHP_VALUE)</span><textarea id="web-service-php-values" maxlength="65536" rows="6"
spellcheck="false"
placeholder="memory_limit = 256M&#10;upload_max_filesize = 64M&#10;post_max_size = 64M"></textarea><small
class="fine" data-i18n="webServicePhpValuesHelp">One php.ini directive = value per line.
class="fine" data-i18n="webServicePhpValuesHelp">One php.ini directive = value per
line.
Examples: memory_limit = 256M, upload_max_filesize = 64M, post_max_size = 64M,
max_execution_time = 30. Use PHP_ADMIN_VALUE for operator-only settings.</small></label>
<label class="vh-wide"><span data-i18n="phpProfileAdminValues">PHP administrator INI overrides
(PHP_ADMIN_VALUE)</span><textarea id="web-service-php-admin-values" maxlength="65536"
rows="6" spellcheck="false"
max_execution_time = 30. Use PHP_ADMIN_VALUE for operator-only
settings.</small></label>
<label class="vh-wide"><span data-i18n="phpProfileAdminValues">PHP administrator INI
overrides
(PHP_ADMIN_VALUE)</span><textarea id="web-service-php-admin-values"
maxlength="65536" rows="6" spellcheck="false"
placeholder="open_basedir = /var/www/webmail/:/tmp/&#10;display_errors = Off"></textarea><small
class="fine" data-i18n="phpHintAdminValues">Admin-only directives, e.g. open_basedir =
/var/www/webmail/:/tmp/. For isolation use a dedicated PHP-FPM pool/socket; dynamic INI
class="fine" data-i18n="phpHintAdminValues">Admin-only directives, e.g. open_basedir
=
/var/www/webmail/:/tmp/. For isolation use a dedicated PHP-FPM pool/socket; dynamic
INI
overrides can persist across requests sharing a worker.</small></label>
<div class="vh-wide form-actions"><button class="outline" data-i18n="webServicePhpDocsButton"
id="web-service-php-help" type="button">FastCGI syntax and safety</button></div>
<div class="vh-wide form-actions"><button class="outline"
data-i18n="webServicePhpDocsButton" id="web-service-php-help" type="button">FastCGI
syntax and safety</button></div>
<p class="fine vh-wide" data-i18n="webServicePhpHelp">RustProxy speaks FastCGI directly to a
long-running PHP-FPM pool. PHP is not spawned per request and PHP source is never served when
long-running PHP-FPM pool. PHP is not spawned per request and PHP source is never served
when
PHP handling is selected.</p>
</div>
</section>
</div>
<div class="vh-grid web-service-page" data-web-service-page="security" hidden>
<label><span data-i18n="webServiceSymlinkPolicy">Symlink policy</span><select
id="web-service-symlink-policy">
@@ -3978,6 +4035,8 @@ log_errors = On</code></pre>
id="redirect-editing">NEW</span><button class="quiet" data-i18n="close" id="redirect-close"
type="button">Close</button>
</div>
<fieldset class="vh-group editor-identity-section">
<legend data-i18n="hostIdentitySection">Host and domains</legend>
<div class="vh-grid">
<label><span data-i18n="redirectId">Redirect ID</span><input id="redirect-id" maxlength="64"
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
@@ -3986,15 +4045,18 @@ log_errors = On</code></pre>
<label class="check"><input id="redirect-enabled" type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<div class="vh-wide" id="redirect-hosts-editor"></div>
<label><span data-i18n="vhCertificate">Certificate</span><select
id="redirect-certificate"></select></label>
</div>
</fieldset>
<section class="vh-group">
<div class="card-heading">
<div><span class="small-label" data-i18n="redirectFrontend">FRONTEND</span>
<h3 data-i18n="redirectListeners">Listeners</h3>
</div>
</div>
<div class="vh-grid editor-frontend-certificate">
<label><span data-i18n="vhCertificate">Certificate</span><select
id="redirect-certificate"></select></label>
</div>
<div class="redirect-listeners" id="redirect-listeners"></div>
<div class="protocol-toggle-row"><label class="check"><input id="redirect-http2" type="checkbox" /><span
data-i18n="vhHttp2Enabled">Enable HTTP/2 for this host</span></label><label
@@ -4027,6 +4089,8 @@ log_errors = On</code></pre>
id="notfound-editing">NEW</span><button class="quiet" data-i18n="close" id="notfound-close"
type="button">Close</button>
</div>
<fieldset class="vh-group editor-identity-section">
<legend data-i18n="hostIdentitySection">Host and domains</legend>
<div class="vh-grid">
<label><span data-i18n="notfoundId">Host ID</span><input id="notfound-id" maxlength="64"
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
@@ -4035,15 +4099,18 @@ log_errors = On</code></pre>
<label class="check"><input id="notfound-enabled" type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<div class="vh-wide" id="notfound-hosts-editor"></div>
<label><span data-i18n="vhCertificate">Certificate</span><select
id="notfound-certificate"></select></label>
</div>
</fieldset>
<section class="vh-group">
<div class="card-heading">
<div><span class="small-label" data-i18n="notfoundFrontend">FRONTEND</span>
<h3 data-i18n="redirectListeners">Listeners</h3>
</div>
</div>
<div class="vh-grid editor-frontend-certificate">
<label><span data-i18n="vhCertificate">Certificate</span><select
id="notfound-certificate"></select></label>
</div>
<div id="notfound-listeners"></div>
<div class="protocol-toggle-row"><label class="check"><input id="notfound-http2" type="checkbox" /><span
data-i18n="vhHttp2Enabled">Enable HTTP/2 for this host</span></label><label
-9
View File
@@ -3,8 +3,6 @@
const buttons = [...document.querySelectorAll("[data-settings-tab]")];
const search = $("settings-search"), searchStatus = $("settings-search-status");
const legacyTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "appearance";
// Explicit URL tabs win. Otherwise reopen the last chosen Settings tab,
// falling back to Appearance for new browsers and invalid stored values.
let rememberedTab = null;
try { rememberedTab = localStorage.getItem("rp-settings-last-tab"); } catch { }
const defaultTab = legacyTab === "manual-config" ? legacyTab : (rememberedTab || "appearance");
@@ -72,8 +70,6 @@
const target = $("api-tokens-card");
if (target) { target.scrollIntoView({ block: "start" }); target.focus({ preventScroll: true }); }
}
// Preserve a real deep link (including opening in a new tab), while switching
// tabs without reloading the panel for ordinary left-click navigation.
$("api-tokens-security-link")?.addEventListener("click", event => {
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault();
@@ -222,7 +218,6 @@
const ram = hasMetric(system.memory_used_bytes) || hasMetric(system.memory_total_bytes)
? `${humanBytes(system.memory_used_bytes)} / ${humanBytes(system.memory_total_bytes)}` : t("unavailable");
for (const [id, value] of [
["runtime-version", latestStatus?.version],
["runtime-platform", runtime.platform],
["runtime-operating-system", runtime.operating_system],
["runtime-kernel-version", runtime.kernel_version],
@@ -231,10 +226,6 @@
["runtime-cpu-usage", percent(system.cpu_percent)],
["runtime-system-memory", ram],
["runtime-system-uptime", humanUptime(system.system_uptime_seconds)],
["runtime-process-uptime", humanUptime(latestStatus?.uptime_seconds)],
["runtime-process-memory", humanBytes(system.process_rss_bytes)],
["runtime-process-threads", humanCount(system.process_threads)],
["runtime-open-fds", humanCount(system.open_fds)],
["runtime-statistics-source", runtime.statistics_source],
["runtime-data-directory", runtime.data_directory],
["runtime-database-path", runtime.database_path]
+32 -15
View File
@@ -61,25 +61,42 @@
if (backendList?.children.length >= 32) { toast(t("streamBackendLimit"), true); return; }
const row = el("fieldset", undefined, "stream-backend-row vh-wide"); row.append(el("legend", backend.id || t("streamBackend")));
const controls = el("div", undefined, "vh-grid"); row.append(controls);
field(controls, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
field(controls, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
field(controls, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
field(controls, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
field(controls, "backend-max", "streamBackendMaxSessions", backend.max_sessions ?? "", "number", { min: 1, max: 65535 });
field(controls, "backend-enabled", "vhEnabled", backend.enabled !== false, "checkbox");
field(controls, "backend-drain", "streamBackendDrain", backend.drain === true, "checkbox");
const backendSection = key => {
const section = el("section", undefined, "vh-wide vh-setting-section stream-backend-section");
section.append(text("h3", key));
const grid = el("div", undefined, "vh-grid"); section.append(grid); controls.append(section);
return grid;
};
const connectionFields = backendSection("streamBackendConnectionSection");
field(connectionFields, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
field(connectionFields, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
field(connectionFields, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
field(connectionFields, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
field(connectionFields, "backend-max", "streamBackendMaxSessions", backend.max_sessions ?? "", "number", { min: 1, max: 65535 });
field(connectionFields, "backend-enabled", "vhEnabled", backend.enabled !== false, "checkbox");
field(connectionFields, "backend-drain", "streamBackendDrain", backend.drain === true, "checkbox");
const detail = backend.metrics_enabled == null ? "inherit" : String(backend.metrics_enabled);
field(controls, "backend-metrics", "streamBackendDetailMetrics", detail, "select", { values: [["inherit", "streamMetricsFromStream"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
field(connectionFields, "backend-metrics", "streamBackendDetailMetrics", detail, "select", { values: [["inherit", "streamMetricsFromStream"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
const hc = backend.health || backendDefaults().health;
field(controls, "backend-health-mode", "streamHealthMode", hc.mode || "off", "select", { values: [["off", "streamHealthOff"], [protocol === "tcp" ? "tcp_connect" : "udp_probe", protocol === "tcp" ? "streamHealthTcp" : "streamHealthUdp"]] });
field(controls, "backend-health-interval", "streamHealthInterval", hc.interval_seconds ?? 10, "number", { min: 1, max: 3600, required: true });
field(controls, "backend-health-timeout", "streamHealthTimeout", hc.timeout_ms ?? 1000, "number", { min: 50, max: 30000, required: true });
field(controls, "backend-health-rise", "streamHealthRise", hc.rise ?? 2, "number", { min: 1, max: 10, required: true });
field(controls, "backend-health-fall", "streamHealthFall", hc.fall ?? 2, "number", { min: 1, max: 10, required: true });
const healthFields = backendSection("streamBackendHealthSection");
const healthMode = field(healthFields, "backend-health-mode", "streamHealthMode", hc.mode || "off", "select", { values: [["off", "streamHealthOff"], [protocol === "tcp" ? "tcp_connect" : "udp_probe", protocol === "tcp" ? "streamHealthTcp" : "streamHealthUdp"]] });
const probeFields = el("div", undefined, "vh-wide vh-grid stream-health-probe-fields");
healthFields.append(probeFields);
const syncProbeFields = () => {
const inactive = healthMode.value === "off";
probeFields.hidden = inactive;
probeFields.querySelectorAll("input, select, textarea").forEach(input => { input.disabled = inactive; });
};
healthMode.addEventListener("change", syncProbeFields);
field(probeFields, "backend-health-interval", "streamHealthInterval", hc.interval_seconds ?? 10, "number", { min: 1, max: 3600, required: true });
field(probeFields, "backend-health-timeout", "streamHealthTimeout", hc.timeout_ms ?? 1000, "number", { min: 50, max: 30000, required: true });
field(probeFields, "backend-health-rise", "streamHealthRise", hc.rise ?? 2, "number", { min: 1, max: 10, required: true });
field(probeFields, "backend-health-fall", "streamHealthFall", hc.fall ?? 2, "number", { min: 1, max: 10, required: true });
if (protocol === "udp") {
field(controls, "backend-health-payload", "streamHealthPayload", hc.payload_hex || "", "text", { maxLength: 256 });
field(controls, "backend-health-expected", "streamHealthExpected", hc.expect_hex || "", "text", { maxLength: 256 });
field(probeFields, "backend-health-payload", "streamHealthPayload", hc.payload_hex || "", "text", { maxLength: 256 });
field(probeFields, "backend-health-expected", "streamHealthExpected", hc.expect_hex || "", "text", { maxLength: 256 });
}
syncProbeFields();
const remove = button("remove", () => { if (backendList.children.length > 1) { row.remove(); changed(); } });
row.append(remove); backendList.append(row);
}
+333 -530
View File
File diff suppressed because it is too large. Load diff
+21 -9
View File
@@ -20,10 +20,18 @@ const field = (parent, name, key, value, type = "text", options = {}) => {
};
const group = (key, advanced = false) => {
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhCompression: "compression", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhSslSettings: "ssl", vhCache: "cache", vhCompression: "compression", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
};
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
// Presentation-only subgroup: all existing inputs remain inside their original form.
const subsection = (parent, key) => {
const box = el("section", undefined, "vh-wide vh-setting-section");
box.append(text("h3", key));
const body = el("div", undefined, "vh-grid");
box.append(body); parent.append(box);
return body;
};
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
const str = (name, scope) => read(name, scope).value.trim();
@@ -403,17 +411,21 @@ function locationsEditor(parent, values) {
note(handlingBody, "locationRewriteHelp");
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra);
const locationProfile = field(accessBody, "location-acl-profile", "locationAclProfile", value.acl_profile_id || "", "select");
const locationProfiles = subsection(accessBody, "vhAclProfile");
const locationProfile = field(locationProfiles, "location-acl-profile", "locationAclProfile", value.acl_profile_id || "", "select");
const locationNoProfile = text("option", "vhAclProfileNone"); locationNoProfile.value = "";
locationProfile.append(locationNoProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; }));
locationProfile.value = value.acl_profile_id || "";
field(accessBody, "location-method-mode", "locationMethodMode", value.method_policy?.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
field(accessBody, "location-method-list", "locationMethodList", (value.method_policy?.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(accessBody, "locationMethodHelp");
field(accessBody, "location-country-mode", "locationCountryMode", value.country_access?.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
field(accessBody, "location-country-list", "locationCountryList", (value.country_access?.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(accessBody, "locationCountryHelp");
const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
const locationMethods = subsection(accessBody, "vhTrafficMethodsSection");
field(locationMethods, "location-method-mode", "locationMethodMode", value.method_policy?.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
field(locationMethods, "location-method-list", "locationMethodList", (value.method_policy?.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(locationMethods, "locationMethodHelp");
const locationCountries = subsection(accessBody, "vhTrafficCountrySection");
field(locationCountries, "location-country-mode", "locationCountryMode", value.country_access?.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
field(locationCountries, "location-country-list", "locationCountryList", (value.country_access?.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(locationCountries, "locationCountryHelp");
const locationAcl = subsection(accessBody, "vhLocationIpAclSection");
const acl = aclEditor(locationAcl, value.acl, sync); row.readAcl = () => acl.read();
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
+99 -64
View File
@@ -37,23 +37,25 @@ function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
let g = group("vhEditor"); const basicFields = subsection(g, "vhGeneralDetailsSection");
field(basicFields, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(basicFields, "vhIdRenameHelp");
let refreshCanonical = () => { };
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
const enabledControl = field(basicFields, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
const enabledLabel = enabledControl.closest("label");
enabledLabel?.querySelector(".vh-enable-hint")?.remove();
enabledLabel?.classList.add("vh-enable-no-hint");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const mainSource = field(basicFields, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(basicFields, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
note(g, "sourceHelp");
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
note(basicFields, "sourceHelp");
const websocketFields = subsection(g, "vhWebsocketOptionsSection");
const websocketEnabled = field(websocketFields, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
note(g, "vhWebsocketTransportHelp");
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
note(g, "vhWebsocketLegacySupportHelp");
const websocketMode = field(websocketFields, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
note(websocketFields, "vhWebsocketTransportHelp");
const websocketLegacy = field(websocketFields, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
note(websocketFields, "vhWebsocketLegacySupportHelp");
g = group("vhFrontends");
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
@@ -182,21 +184,25 @@ function load(value = defaults(), migrationIds = [], show = true) {
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
note(g, "vhProxyTimeoutsHelp");
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
note(g, "vhRetryHelp");
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 64, "number", { required: true, min: 1, max: 64 });
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(g, "pool-recycle-requests", "vhPoolRecycleRequests", keepalive.pool_recycle_requests ?? 25000, "number", { required: true, min: 0, max: 10000000 });
field(g, "pool-recycle-seconds", "vhPoolRecycleSeconds", keepalive.pool_recycle_seconds ?? 3600, "number", { required: true, min: 0, max: 604800 });
field(g, "http1-max-buffer", "vhHttp1MaxBuffer", keepalive.http1_max_buffer_bytes ?? 131072, "number", { required: true, min: 8192, max: 1048576 });
note(g, "vhPoolRecycleHelp");
g = group("vhBalancing");
const schedulingFields = subsection(g, "vhBalancerAlgorithmSection");
field(schedulingFields, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
field(schedulingFields, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
const retryFields = subsection(g, "vhBalancerRetrySection");
field(retryFields, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
field(retryFields, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
field(retryFields, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
field(retryFields, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
note(retryFields, "vhRetryHelp");
const keepaliveFields = subsection(g, "vhBalancerKeepaliveSection");
field(keepaliveFields, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
field(keepaliveFields, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(keepaliveFields, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 64, "number", { required: true, min: 1, max: 64 });
field(keepaliveFields, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(keepaliveFields, "pool-recycle-requests", "vhPoolRecycleRequests", keepalive.pool_recycle_requests ?? 25000, "number", { required: true, min: 0, max: 10000000 });
field(keepaliveFields, "pool-recycle-seconds", "vhPoolRecycleSeconds", keepalive.pool_recycle_seconds ?? 3600, "number", { required: true, min: 0, max: 604800 });
field(keepaliveFields, "http1-max-buffer", "vhHttp1MaxBuffer", keepalive.http1_max_buffer_bytes ?? 131072, "number", { required: true, min: 8192, max: 1048576 });
note(keepaliveFields, "vhPoolRecycleHelp");
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
@@ -234,36 +240,60 @@ function load(value = defaults(), migrationIds = [], show = true) {
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
g = group("vhTrafficPolicy");
// Policies share their original form/data model; the boundaries are presentational only.
const aclFields = subsection(g, "vhAclProfile");
const selectedProfiles = new Set([value.acl_profile_id, ...(value.acl_profile_ids || [])].filter(Boolean));
const profileBox = el("div", undefined, "vh-wide vh-acl-profile-picker");
profileBox.append(text("strong", "vhAclProfile"));
const choices = window.rustProxyAclPicker.create([...selectedProfiles], "vh-acl-profile-choices");
profileBox.append(choices);
const multiWarning = text("p", "multiAclWarning", "fine acl-stack-warning");
const syncMultiWarning = () => { multiWarning.hidden = choices.querySelectorAll('input:checked').length < 2; };
choices.addEventListener("change", syncMultiWarning); syncMultiWarning();
profileBox.append(multiWarning); g.append(profileBox); note(g, "vhAclProfileHelp");
field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhMethodHelp");
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); uaRatesReader = userAgentRateEditor(g, rate.user_agent_limits || []); note(g, "vhRateHelp");
profileBox.append(multiWarning); aclFields.append(profileBox); note(aclFields, "vhAclProfileHelp");
const inlineAclFields = subsection(g, "vhIpAccess");
aclControl = aclEditor(inlineAclFields, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
note(inlineAclFields, "vhInlineAclHelp");
const methodFields = subsection(g, "vhTrafficMethodsSection");
field(methodFields, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
field(methodFields, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(methodFields, "vhMethodHelp");
const rateFields = subsection(g, "vhTrafficRateSection");
field(rateFields, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
field(rateFields, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(rateFields, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(rateFields, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
note(rateFields, "vhRateHelp");
const countryFields = subsection(g, "vhTrafficCountrySection");
field(countryFields, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
field(countryFields, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(countryFields, "vhCountryPolicyHelp");
const countryRateFields = subsection(g, "vhTrafficCountryRatesSection");
countryRatesReader = countryRateEditor(countryRateFields, rate.country_limits || []);
const uaRateFields = subsection(g, "vhUaRateTitle");
uaRatesReader = userAgentRateEditor(uaRateFields, rate.user_agent_limits || [], false);
const security = value.security_headers || {}, hsts = security.hsts || {};
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
g = group("vhSecurity");
const hstsFields = subsection(g, "vhSecurityHstsSection");
field(hstsFields, "hsts", "vhHsts", hsts.enabled, "checkbox");
field(hstsFields, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
field(hstsFields, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox");
field(hstsFields, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox");
const headersFields = subsection(g, "vhSecurityHeadersSection");
field(headersFields, "nosniff", "vhNosniff", security.nosniff, "checkbox");
field(headersFields, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
field(headersFields, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
field(headersFields, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
field(headersFields, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
const uaBlock = value.user_agent_blocking || {};
field(g, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
field(g, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
note(g, "vhUaBlockHelp");
const uaBlockingFields = subsection(g, "vhUaBlockSection");
field(uaBlockingFields, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
field(uaBlockingFields, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
note(uaBlockingFields, "vhUaBlockHelp");
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
@@ -273,13 +303,16 @@ function load(value = defaults(), migrationIds = [], show = true) {
note(g, "vhErrorFallbackHelp");
locationsReader = locationsEditor(g, value.routes || []);
g = group("vhSsl");
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
const certificateRow = el("div", undefined, "vh-wide vh-certificate-select-row"); g.append(certificateRow);
const selector = field(certificateRow, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
certificateRow.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); }));
note(g, "certSelectHint");
const mismatch = field(g, "allow-certificate-mismatch", "vhCertificateMismatchForce", value.allow_certificate_mismatch === true, "checkbox");
const mismatchHint = note(g, "vhCertificateMismatchWarning");
const syncMismatch = () => { mismatch.disabled = !selector.value; if (!selector.value) mismatch.checked = false; mismatchHint.hidden = !mismatch.checked; };
selector.addEventListener("change", syncMismatch); mismatch.addEventListener("change", syncMismatch); syncMismatch();
g = group("vhSslSettings");
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
@@ -288,15 +321,11 @@ function load(value = defaults(), migrationIds = [], show = true) {
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
g = group("vhTinyauth"); const auth = value.tinyauth || {};
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
note(g, "authModeHelp");
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
aclBody.append(text("h3", "vhIpAccess"));
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); g.append(taBody, basicBody);
taBody.append(text("h3", "authTinyauth"));
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
@@ -361,16 +390,21 @@ function load(value = defaults(), migrationIds = [], show = true) {
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
const staticExtensionFields = el("div", undefined, "vh-wide vh-override-fields"); cacheOptions.append(staticExtensionFields);
const customStatic = field(staticExtensionFields, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
const staticExtensions = field(staticExtensionFields, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(staticExtensionFields, "vhCacheStaticHelp");
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age); tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits);
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox");
const forceTargets = field(cacheOptions, "vh-cache-force-targets", "vhCacheForceTargets", (cache.unsafe_force?.targets || []).join("\n"), "textarea", { rows: 3, maxLength: 65536 });
const bypassFields = el("div", undefined, "vh-wide vh-override-fields"); cacheOptions.append(bypassFields);
const customCachePrefixes = field(bypassFields, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
const bypassPrefixes = field(bypassFields, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 });
const syncBypassPrefixes = () => { bypassPrefixes.disabled = !customCachePrefixes.checked; };
customCachePrefixes.addEventListener("change", syncBypassPrefixes); syncBypassPrefixes(); note(bypassFields, "vhCacheHint");
const unsafeCacheFields = el("div", undefined, "vh-wide vh-override-fields"); cacheOptions.append(unsafeCacheFields);
const forceCache = field(unsafeCacheFields, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox");
const forceTargets = field(unsafeCacheFields, "vh-cache-force-targets", "vhCacheForceTargets", (cache.unsafe_force?.targets || []).join("\n"), "textarea", { rows: 3, maxLength: 65536 });
const syncForceCache = () => { forceTargets.disabled = !forceCache.checked; }; forceCache.addEventListener("change", async () => { if (forceCache.checked && !(await appConfirm(t("vhCacheForceConfirm"), { danger: true }))) forceCache.checked = false; syncForceCache(); }); syncForceCache();
note(cacheOptions, "vhCacheForceHelp");
note(unsafeCacheFields, "vhCacheForceHelp");
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
const syncCacheVisibility = () => {
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
@@ -381,8 +415,9 @@ function load(value = defaults(), migrationIds = [], show = true) {
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const transformExtensionFields = el("div", undefined, "vh-wide vh-override-fields"); transformOptions.append(transformExtensionFields);
const customTransformExtensions = field(transformExtensionFields, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
const transformExtensions = field(transformExtensionFields, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncTransform = () => {
transformOptions.hidden = !transformEnabled.checked;
transformExtensions.disabled = !customTransformExtensions.checked;
+2 -2
View File
@@ -1,6 +1,6 @@
"use strict";
function userAgentRateEditor(parent, values = []) {
parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide"));
function userAgentRateEditor(parent, values = [], showHeading = true) {
if (showHeading) parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide"));
const rows = el("div", undefined, "vh-wide vh-ua-rates"); parent.append(rows);
const add = (value = {}) => {
if (rows.children.length >= 64) { toast(t("vhUaRateMax"), true); return; }
+3 -2
View File
@@ -456,7 +456,9 @@ mod tests {
assert!(blocked.forced_mismatches.is_empty());
vhost.allow_certificate_mismatch = true;
let forced = for_vhosts(&settings, &config.vhosts).unwrap();
assert!(forced.forced_mismatches.contains(&("named".into(), "other.example.test".into())));
assert!(forced
.forced_mismatches
.contains(&("named".into(), "other.example.test".into())));
assert!(compile(&forced, Some(dir.path())).is_ok());
let saved = serde_json::to_value(&forced).unwrap();
assert!(saved.get("forced_mismatches").is_none());
@@ -472,5 +474,4 @@ mod tests {
let partial = for_vhosts(&settings, &config.vhosts).unwrap();
assert!(compile(&partial, Some(dir.path())).is_err());
}
}
+46
View File
@@ -0,0 +1,46 @@
// Run with: node tests/css_usage_audit.cjs
// Ensure the review archive stays unreferenced and critical live styles survive.
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const active = read('src/admin/web/style.css');
const unused = read('src/admin/web/unused.css');
const docs = read('src/admin/web/docs.css');
const archived = [
'auth-ip-card', 'certificate-dialog', 'config-columns', 'overview-grid',
'overview-inline-hidden-toggle', 'overview-layout-body', 'overview-layout-filters',
'overview-layout-grid', 'overview-layout-hidden-list', 'overview-layout-section-heading',
'settings-grid', 'stream-dashboard-card', 'table-container', 'transport-grid', 'www-service-heading',
];
for (const name of archived) {
const cssClass = new RegExp(`\\.${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![-\\w])`);
assert.ok(!cssClass.test(active), `${name} must not remain in the active stylesheet`);
assert.ok(cssClass.test(unused), `${name} must be reviewable in unused.css`);
}
for (const name of ['vh-setting-section', 'dashboard-overview-grid', 'vh-override-fields',
'refresh-settings-grid', 'docker-backend-ip-settings', 'acl-profile-layout']) {
assert.ok(active.includes(`.${name}`), `active class ${name} was removed`);
}
for (const file of [
'src/admin/web/index.html', 'src/admin/web/login.html', 'src/admin/web/docs.html',
'src/admin/panel_assets.rs', 'src/admin/documentation.rs',
]) assert.ok(!read(file).includes('unused.css'), `${file} must not load or serve unused.css`);
assert.ok(!/@import[^;]*unused\.css/.test(active));
assert.match(unused, /NOT INCLUDED by the admin panel/);
assert.match(docs, /\.swagger-ui \.opblock/);
for (const style of ['square', 'subtle', 'rounded', 'extra']) {
assert.ok(active.includes(`html[data-corner-style=${style}]`));
assert.ok(docs.includes(`html[data-corner-style=${style}]`));
}
for (const name of ['app-startup-spin', 'app-startup-reveal']) {
assert.ok(active.includes(`@keyframes ${name}`));
assert.ok(!unused.includes(`@keyframes ${name}`));
}
assert.ok(!active.includes('--backdrop-soft'));
assert.ok(!active.includes('var(--panel-strong)'));
assert.ok(!active.includes('var(--border)'));
assert.match(read('CHANGELOG.md'), /CSS maintenance:/);
console.log('CSS used/unused segregation, asset isolation and corner-style regression: PASS');
+70
View File
@@ -0,0 +1,70 @@
// Run with: node tests/ux_0345.cjs
// Prevent editor grouping regressions without changing saved input names or IDs.
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const assert = require("node:assert/strict");
const source = file => fs.readFileSync(path.resolve(__dirname, "..", file), "utf8");
const html = source("src/admin/web/index.html");
const streams = source("src/admin/web/streams.js");
const css = source("src/admin/web/style.css");
const php = html.split('data-web-service-page="php" hidden>')[1].split('data-web-service-page="security"')[0];
const delivery = html.split('data-web-service-page="delivery" hidden>')[1].split('data-web-service-page="php"')[0];
function assertGroups(page, groups) {
let prev = -1;
for (const [key, ids] of groups) {
const heading = page.indexOf(`data-i18n="${key}"`);
assert.ok(heading > prev, `section ${key} must exist in order`);
const nextHeading = groups[groups.findIndex(item => item[0] === key) + 1];
const end = nextHeading ? page.indexOf(`data-i18n="${nextHeading[0]}"`) : page.length;
for (const id of ids) {
const pos = page.indexOf(`id="${id}"`);
assert.ok(pos > heading && pos < end, `${id} must belong to ${key}`);
assert.equal(page.split(`id="${id}"`).length - 1, 1, `${id} must occur once`);
}
prev = heading;
}
}
assertGroups(delivery, [
["webServiceStaticDeliverySection", ["web-service-sendfile", "web-service-download-extensions"]],
["webServiceDynamicCompressionSection", ["web-service-compression", "web-service-compression-options", "web-service-compression-zstd", "web-service-compression-br", "web-service-compression-gzip", "web-service-compression-deflate"]],
["webServiceSidecarsSection", ["web-service-precompressed", "web-service-precompressed-br", "web-service-precompressed-gzip"]]
]);
assertGroups(php, [
["webServicePhpSourceSection", ["web-service-php-profile", "web-service-php-profiles", "web-service-php-enabled", "web-service-php-endpoint", "web-service-php-extensions"]],
["webServicePhpRequestSection", ["web-service-php-pass-authorization", "web-service-php-path-info"]],
["webServicePhpLimitsSection", ["web-service-php-connect-timeout", "web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit"]],
["webServicePhpOverridesSection", ["web-service-php-custom-params", "web-service-php-values", "web-service-php-admin-values", "web-service-php-help"]]
]);
for (const name of ["redirect", "notfound"]) {
const form = html.split(`id="${name}-form"`)[1].split("</form>")[0];
const identity = form.indexOf('data-i18n="hostIdentitySection"');
const cert = form.indexOf(`id="${name}-certificate"`);
const listener = form.indexOf(`id="${name}-listeners"`);
assert.ok(identity >= 0 && cert > identity && listener > cert, `${name}: separate identity and frontend certificate/listeners`);
assert.equal(form.split(`id="${name}-certificate"`).length - 1, 1);
}
for (const needle of [
'backendSection("streamBackendConnectionSection")',
'backendSection("streamBackendHealthSection")',
'healthMode.addEventListener("change", syncProbeFields)',
'probeFields.hidden = inactive;',
'input.disabled = inactive;',
'field(probeFields, "backend-health-timeout"',
'field(connectionFields, "backend-address"',
'get(row, "backend-health-mode").value',
'get(row, "backend-address").value.trim()'
]) assert.ok(streams.includes(needle), `Streams behavior must retain: ${needle}`);
assert.match(css, /\.vh-setting-section\s*\{[^}]*border-radius:\s*var\(--rp-radius-surface/);
assert.match(css, /\.stream-health-probe-fields\[hidden\][\s\S]*?display:\s*none !important/);
for (const locale of ["en_US", "pl_pl"]) {
const pack = JSON.parse(source(`langs/${locale}.json`)).translations;
for (const key of ["webServiceStaticDeliverySection", "webServiceDynamicCompressionSection", "webServiceSidecarsSection", "webServicePhpSourceSection", "webServicePhpRequestSection", "webServicePhpLimitsSection", "webServicePhpOverridesSection", "hostIdentitySection", "streamBackendConnectionSection", "streamBackendHealthSection"]) {
assert.ok(pack[key]?.trim(), `${locale} missing ${key}`);
}
}
assert.match(source("Cargo.toml"), /version = "0\.34\.5"/);
assert.equal(JSON.parse(source("src/admin/openapi.json")).info.version, "0.34.5");
assert.match(source("CHANGELOG.md"), /## 0\.34\.5 — Administration UX improvements/);
console.log("0.34.5 UI layout, inactive probe controls, localization and version: PASS");
+87
View File
@@ -0,0 +1,87 @@
// Run with: node tests/ux_0345_followup.cjs
// Regression guard for the second 0.34.5 UX pass (without needing Chromium).
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const source = file => fs.readFileSync(path.resolve(__dirname, '..', file), 'utf8');
const editor = source('src/admin/web/vhosts/editor.js');
const html = source('src/admin/web/index.html');
const css = source('src/admin/web/style.css');
const docsJs = source('src/admin/web/docs.js');
const docsCss = source('src/admin/web/docs.css');
const settings = source('src/admin/web/settings.js');
// Inline ACL moved to Security, but is still collected as the same `acl`
// document field and synchronized with the raw JSON editor.
const traffic = editor.indexOf('g = group("vhTrafficPolicy")');
const profile = editor.indexOf('subsection(g, "vhAclProfile")', traffic);
const inline = editor.indexOf('subsection(g, "vhIpAccess")', traffic);
const methods = editor.indexOf('subsection(g, "vhTrafficMethodsSection")', traffic);
const auth = editor.indexOf('g = group("vhTinyauth")', traffic);
assert.ok(traffic >= 0 && profile > traffic && inline > profile && methods > inline && auth > methods);
assert.match(editor.slice(inline, methods), /aclControl = aclEditor\(inlineAclFields, value\.acl/);
assert.match(editor.slice(inline, methods), /read\("acl"\)\.value = JSON\.stringify/);
assert.ok(!editor.slice(auth, editor.indexOf('g = group("vhResponsePolicy")', auth)).includes('aclEditor('));
assert.match(editor, /acl, headers,/);
assert.match(source('src/admin/web/vhosts/core.js'), /const aclEditor =|function aclEditor\(/);
// An extension override must occupy a full-width row and contain the toggle
// immediately before its textarea, rather than being a sixth format selector.
const transform = editor.slice(editor.indexOf('g = group("vhTransform")'), editor.indexOf('g = group("vhHeaders")'));
assert.match(transform, /"vh-wide vh-override-fields"/);
assert.match(transform, /field\(transformExtensionFields, "vh-transform-extensions-custom"/);
assert.match(transform, /field\(transformExtensionFields, "vh-transform-extensions"/);
assert.match(transform, /transformExtensions\.disabled = !customTransformExtensions\.checked/);
const cache = editor.slice(editor.indexOf('g = group("vhCache")'), editor.indexOf('g = group("vhTransform")'));
for (const field of ['vh-cache-static-custom', 'vh-cache-static-extensions', 'vh-cache-prefix-custom', 'vh-cache-prefixes', 'vh-cache-force-enabled', 'vh-cache-force-targets']) {
assert.ok(cache.includes(field), `Vhost cache option ${field} remains present`);
}
assert.match(cache, /bypassPrefixes\.disabled = !customCachePrefixes\.checked/);
assert.match(css, /\.vh-override-fields\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\)/);
// The web service keeps its original checkbox IDs and serialized fields.
const preset = html.slice(html.indexOf('data-i18n="webServicePresetTitle"'), html.indexOf('id="web-service-id"'));
assert.match(preset, /id="web-service-preset"/);
assert.match(preset, /id="web-service-preset-apply"/);
assert.match(preset, /class="vh-wide web-service-preset-toggles"/);
assert.ok(preset.indexOf('id="web-service-preset-apply"') < preset.indexOf('id="web-service-hardening-attacks"'));
assert.ok(preset.indexOf('id="web-service-hardening-attacks"') < preset.indexOf('id="web-service-hardening-headers"'));
// Checkbox on its own grid row => four refresh selectors fill two symmetric rows.
const refresh = html.slice(html.indexOf('id="refresh-preferences"'), html.indexOf('id="appearance-reset"'));
assert.match(refresh, /class="appearance-settings-grid refresh-settings-grid"/);
for (const id of ['refresh-live-enabled', 'refresh-overview-seconds', 'refresh-traffic-seconds', 'refresh-cache-seconds', 'refresh-system-info-seconds']) {
assert.equal(refresh.split(`id="${id}"`).length - 1, 1);
}
assert.match(css, /\.refresh-settings-grid > \.check\s*\{[^}]*grid-column:\s*1 \/ -1/);
// Remove only presentation of the process group, not the stat source or API.
const system = html.slice(html.indexOf('id="settings-page-system-info"'), html.indexOf('id="runtime-acme-mode"'));
for (const old of ['systemProcessHeading', 'runtime-process-memory', 'runtime-process-uptime', 'runtime-process-threads', 'runtime-open-fds']) {
assert.ok(!system.includes(old), `process card must not show ${old}`);
}
assert.match(system, /id="runtime-statistics-source"/);
assert.match(settings, /\["runtime-statistics-source", runtime\.statistics_source\]/);
assert.ok(!settings.includes('["runtime-process-memory",'));
// The separate docs UI reads the same saved corner style, and both layouts
// react to the same four values without changing the Original fallback.
assert.match(docsJs, /localStorage\.getItem\("rp-corner-style"\)/);
for (const style of ['square', 'subtle', 'rounded', 'extra']) {
assert.ok(docsCss.includes(`html[data-corner-style=${style}]`));
assert.ok(css.includes(`html[data-corner-style=${style}]`));
}
assert.match(docsCss, /\.reader-sidebar,\s*\.markdown-reader,/);
assert.match(docsCss, /\.swagger-ui select,[\s\S]*?border-radius: var\(--rp-docs-radius-control/);
assert.match(css, /\.metric-info > summary\s*\{[^}]*var\(--rp-radius-control/);
assert.match(css, /#panel-overview \.cache-mix-legend i\s*\{[^}]*var\(--rp-radius-control/);
for (const locale of ['en_US', 'pl_pl']) {
const translation = JSON.parse(source(`langs/${locale}.json`)).translations;
assert.ok(translation.vhInlineAclHelp?.length > 30);
assert.ok(translation.vhTinyauth && !translation.vhTinyauth.includes('IP'));
}
assert.match(source('CHANGELOG.md'), /## 0\.34\.5 — Administration UX improvements/);
assert.match(source('Cargo.toml'), /version = "0\.34\.5"/);
console.log('0.34.5 UX follow-up layout, source IDs, access scopes and corner preferences: PASS');
+59
View File
@@ -0,0 +1,59 @@
// Run with: node tests/vhost_ui_layout.cjs
// Regression guard for purely presentational Vhost grouping and corner settings.
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const assert = require("node:assert/strict");
const root = path.resolve(__dirname, "..");
const source = file => fs.readFileSync(path.join(root, file), "utf8");
const core = source("src/admin/web/vhosts/core.js");
const editor = source("src/admin/web/vhosts/editor.js");
const rates = source("src/admin/web/vhosts/rates.js");
const docker = source("src/admin/web/docker.js");
const css = source("src/admin/web/style.css");
const keys = [
"vhGeneralDetailsSection", "vhWebsocketOptionsSection", "vhBalancerAlgorithmSection",
"vhBalancerRetrySection", "vhBalancerKeepaliveSection", "vhTrafficMethodsSection",
"vhTrafficRateSection", "vhTrafficCountrySection", "vhTrafficCountryRatesSection",
"vhSecurityHstsSection", "vhSecurityHeadersSection", "vhUaBlockSection",
"vhLocationIpAclSection", "vhSslSettings",
];
for (const locale of ["en_US", "pl_pl"]) {
const pack = JSON.parse(source(`langs/${locale}.json`));
for (const key of keys) {
assert.ok(pack.translations[key]?.trim(), `${locale}: ${key} must be translated`);
const occurrences = source(`langs/${locale}.json`).split("\n").filter(line => line.trimStart().startsWith(`"${key}":`));
assert.equal(occurrences.length, 1, `${locale}: ${key} must occur exactly once`);
}
}
assert.match(core, /const subsection = \(parent, key\) =>/);
assert.match(core, /"vh-wide vh-setting-section"/);
assert.match(core, /vhSslSettings: "ssl"/);
assert.match(core, /subsection\(accessBody, "vhAclProfile"\)/);
assert.match(core, /subsection\(accessBody, "vhTrafficMethodsSection"\)/);
assert.match(core, /subsection\(accessBody, "vhTrafficCountrySection"\)/);
assert.match(core, /subsection\(accessBody, "vhLocationIpAclSection"\)/);
for (const key of keys.filter(k => k !== "vhLocationIpAclSection" && k !== "vhSslSettings")) {
if (key === "vhTrafficCountryRatesSection" || key === "vhGeneralDetailsSection" || key.startsWith("vhBalancer") || key.startsWith("vhSecurity") || key === "vhUaBlockSection" || key.includes("Traffic") || key === "vhWebsocketOptionsSection") {
assert.ok(editor.includes(`subsection(g, "${key}")`), `missing editor subgroup ${key}`);
}
}
assert.match(editor, /g = group\("vhSsl"\);[\s\S]*?g = group\("vhSslSettings"\)/);
assert.match(editor, /"allow-certificate-mismatch"/);
assert.match(editor, /"tls-policy-custom"/);
assert.match(editor, /"ua-block-enabled"/);
assert.match(editor, /"ua-block-patterns"/);
assert.match(rates, /showHeading = true/);
assert.match(editor, /userAgentRateEditor\(uaRateFields, rate\.user_agent_limits \|\| \[\], false\)/);
assert.match(docker, /backendIpSettings\.append\(labeled\("dockerBackendIp", target\), certificateBar\)/);
assert.match(css, /\.docker-backend-ip-settings\s*\{[^}]*gap:\s*12px;/);
for (const selector of [".vh-setting-section", ".cache-memory-sizing", ".appearance-chart-preview", ".draft-dock"]) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const block = css.match(new RegExp(escaped + "\\s*\\{([^}]+)\\}"));
assert.ok(block, `missing CSS: ${selector}`);
assert.match(block[1], /border-radius:\s*var\(--rp-radius-surface/, `${selector} must use system corner setting`);
}
assert.match(css, /\.vh-form \.vh-grid>\.vh-check:not\(\.vh-enable-control\)/);
assert.match(css, /\.form-actions \.check/);
assert.match(css, /\.docker-service-certificate-bar>\.check\s*\{[^}]*margin:\s*0\s*!important/);
console.log("Vhost UI grouping, certificate controls, checkbox alignment and corner preset: PASS");