v0.34.6
This commit is contained in:
1 parent
e037190c14
commit
5cb3b37627
37 files changed
+994
-101
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# RustProxy source and documentation maintenance
|
||||
|
||||
Current source version: **0.34.5**. The Cargo package and binary are named `rust-proxy`.
|
||||
Current source version: **0.34.6**. 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.
|
||||
|
||||
|
||||
+12
-1
@@ -6,10 +6,21 @@
|
||||
|
||||
# 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.5.
|
||||
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.6.
|
||||
|
||||
## 0.34.6 — Docker live logs, neutral Publish switch and Vhost Content-Type rules (source candidate)
|
||||
|
||||
- **Security / ordinary Vhosts:** optional per-vhost list of up to 64 blocked request media types (`multipart/form-data`, `application/json`, `image/*`). Matches case-insensitively, ignores MIME parameters, returns 403 before any proxy, Web Server/PHP-FPM, Custom location or cache response. Missing `Content-Type` is permitted; malformed/ambiguous headers are refused while filtering is enabled. Docker editor is unchanged. Default off for existing and new Vhosts.
|
||||
- **Interface:** Vhosts → Security has a dedicated Content-Type subgroup with enable checkbox and multiline patterns. The Docker Publish toggle is smaller, unboxed and neutral with standard theme colors; instant save, rollback and live logs remain.
|
||||
- **Per-service Live logs:** a button beside **Open app** opens the existing authenticated live HTTP access-log dialog with the generated Docker vhost ID preselected. It covers all aliases of that publication and keeps the filter while waiting for its first entries. The action needs current Docker route metadata; it remains disabled until the status refresh supplies it. No new Docker socket permissions, agent endpoints or public log APIs were introduced. It displays RustProxy request logs, **not container stdout/stderr**.
|
||||
- **Publish switch:** replaced the plain Publish checkbox with an on/off slider matching panel themes, corner preferences and keyboard/screen-reader accessibility. Each change still saves immediately through the existing Docker assignment API, with pending/failed saves reflected in the control. Unpublishing does not stop the container; configuration and statistics sections retain their prior behavior.
|
||||
- **Open app URL:** use the selected listener's actual `binding.tls` and `binding.address` so HTTPS and nonstandard public ports are reflected in generated app links.
|
||||
- **Release:** Cargo package and lockfile, embedded OpenAPI version, Polish/English UI hints and documentation aligned to 0.34.6. This is a source-only release candidate requiring native compilation and integration testing before deployment. Blocking `Content-Type` does not replace security updates for PHP or the application.
|
||||
|
||||
## 0.34.5 — Administration UX improvements (source candidate)
|
||||
|
||||
- **Docker vhosts traffic view:** added the ordinary Vhost status, cumulative requests/active/errors, EWMA latency, traffic/s and lifetime-rate chart in a collapsed-by-default disclosure below each published Docker vhost's configuration editor. One toolbar button expands/collapses all active publications; individual disclosure state survives live refreshes. Values use generated Vhost counters from the existing status stream. The browser chart begins collecting when the vhost is observed and resets on sign-out; backend/configuration behavior is unchanged.
|
||||
|
||||
- **Linux deployment/package filesystem access:** packaged and manually generated
|
||||
systemd services now use `ProtectHome=read-only` rather than `true` so a
|
||||
Web Server can read `/home` and other external document roots using Unix
|
||||
|
||||
Generated
+1
-1
@@ -1881,7 +1881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.34.5"
|
||||
version = "0.34.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.34.5"
|
||||
version = "0.34.6"
|
||||
edition = "2021"
|
||||
rust-version = "1.99"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# RustProxy 0.34.5
|
||||
# RustProxy 0.34.6
|
||||
|
||||
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,12 +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.6
|
||||
|
||||
- **Docker vhosts:** **Live logs** beside **Open app** opens the existing HTTP access-log viewer, filtered to the selected generated Docker vhost and its aliases. These are RustProxy request logs, not container stdout/stderr. Open app honors HTTPS and nonstandard listener ports.
|
||||
- **Publish switch:** a compact neutral on/off control in the existing panel theme, without a pill container or green fill. It saves immediately and rolls back on failure.
|
||||
- **Ordinary Vhosts → Security:** optional request **Content-Type** denylist with exact MIME types and `type/*` wildcards. Matching requests receive **403 before PHP-FPM, Web Server, reverse proxy, custom locations or cache**. Docker vhost configuration remains unchanged.
|
||||
|
||||
## 0.34.5
|
||||
|
||||
- Administration UX refinements: clearer grouping for Web Server delivery and PHP-FPM controls, Stream backend and health-check settings, and Redirect/404 host identity versus frontend certificate options.
|
||||
- Stream backend probe settings are hidden while health checks are off, and disabled Web Server fields now have a consistent muted appearance. Both UI languages are supported.
|
||||
- UX follow-up: Vhost transform/cache overrides now place checkboxes directly above their textareas; inline Vhost IP rules live with ACL profiles under Security. Web Server preset hardening controls and refresh interval grids have clearer alignment, and System information keeps the statistics source without the separate process block.
|
||||
- More controls (including the authenticated documentation/Swagger page) follow the saved corner style. Configuration keys, API payloads and proxy behavior are unchanged.
|
||||
- Published Docker vhosts now expose collapsed, Vhost-style traffic statistics and a single toolbar button to expand or collapse the statistics for all published Docker services.
|
||||
|
||||
## 0.34.4
|
||||
|
||||
@@ -88,4 +95,4 @@ The authenticated **`/docs`** page includes the guides and a separate interactiv
|
||||
|
||||
## Status of this source archive
|
||||
|
||||
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).
|
||||
Version **0.34.6** 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).
|
||||
+36
-33
@@ -1,12 +1,12 @@
|
||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||
c85d98468358b1b6acfae1025db0de42858f4b80418a5e8d6072e9c059f684d2 AGENTS.md
|
||||
5f4a1d637a3450ccfb503dbe46d5a07bbfb752e882ec8c49849a5f9f8a5752c2 CHANGELOG.md
|
||||
32a94804157a1bdf05335f2eb9fb1fa3c3507511997ca3aef51235884a1300ec Cargo.lock
|
||||
f716baecb16304e87bc0627a0d70c5a4a3d1a7e8847987f5a60f1ae1b77f4008 Cargo.toml
|
||||
893ba606f52309e31b3d0612350a9cdeb36d0414325bc06e71e2f7ce15e755cb AGENTS.md
|
||||
72dc91c90b524db7571a8916a4409d0faad41fafc4da14987eecc396adc63290 CHANGELOG.md
|
||||
4865f5f0a9cbc64899b70acc0f6ad328bbfa40560a78d39874e4b9563d2f56b9 Cargo.lock
|
||||
fbba365b98bfc31b891d8ef240270b76987d67778b4d795552069e6b94f3f9ea Cargo.toml
|
||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||
82d522543c72fc26a15366e6d4ea708d3c8ca99ef47d5ed7019750c49b0add9f README.md
|
||||
fcd1ace66c47e43e13b8cfadd76a9ae02ad545af944fa684c489034227776432 README.md
|
||||
ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
||||
@@ -27,7 +27,7 @@ b7d0466f70b541a705d234fc8a88b99b6197fdd5e682d3a288a78c76764358cf docker-agent/m
|
||||
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
|
||||
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
|
||||
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
|
||||
30214fb5082c9de0ef90e1f4271825b5bf4c9898dd396ef797daf0a7abb37834 docs/ACCESS.md
|
||||
5664af2afde7f6c88d44e02aa8a9cd719a6019db15b570510185e4114c83799e docs/ACCESS.md
|
||||
30542f4c60dc2738ea6b7f8214c7d3dc493c470d632e302681225f208b2fea79 docs/ACME_PROVIDERS.md
|
||||
9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md
|
||||
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
|
||||
@@ -37,20 +37,20 @@ c81c6ee84e6af6507926144f8caf1e94daf497a8a0159449a3808088a11ea045 docs/CONFIGURA
|
||||
80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md
|
||||
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
|
||||
9fb2d0bcac70d1883dd16557622851a1f0527b3d3569a4b5177f5e6dedd3c74d docs/DASHBOARD.md
|
||||
8a97377f04a12c28ca219accd921e439e67130fed0d87b51495c0e15a626cbc0 docs/DOCKER_DISCOVERY.md
|
||||
d072d8cadd285d195e0f658e6a8a14ef6d49799ade9e453f01665d12a5135324 docs/DOCKER_DISCOVERY.md
|
||||
f1baabe34e8a1b204d7c78e2f558c019c156b03adfa6ddecb931b4b8a5501f78 docs/INSTALL.md
|
||||
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
|
||||
8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md
|
||||
694cb7b15c5bbdc94cecd15dc516c05dcb3467849322cdd125b872d36dee4699 docs/LOGS.md
|
||||
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
|
||||
140b95ad778261455f269f2144a1c61c2315bd3b85300fb613822eacddf3cfc2 docs/MONITORING.md
|
||||
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
|
||||
932cdc186b86bb87df63f243fb3238b93ed3ecd77fe0bdc960d6059039eb9909 docs/README.md
|
||||
f72a94114558f15341842352146c1743f79bb1f7fbca3b93b7ca89368a398961 docs/README.md
|
||||
490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md
|
||||
701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md
|
||||
e2b36152c13d65621a367c69dd400118dae1e4426a4d2b2139d2f63fa30442d6 docs/SECURITY.md
|
||||
bbef60ff1771da9f48af812987766e1e479408873ae2f079b13f1d5c62ecc6ff docs/STREAMS.md
|
||||
5bedc7d8b9fcaa80cacea225b1f6750101a7ac1b34eb9ce58310ec60329e380d docs/TRANSPORT.md
|
||||
8542c4a5c9d5dfe474459099546ae590b4c4d3f363f0bba9721c6107643c2d3a docs/TROUBLESHOOTING.md
|
||||
2fb3da550ff021456fdd5efb869869288cce1f027dafe72be07903fad6a6d1fe docs/VHOSTS.md
|
||||
25b32d0d906333969df9e6a0a8c1b399cd34ad177b601219ba8ade8e164d3887 docs/VHOSTS.md
|
||||
05efc113d33be361bac25c90064b1865cf2f574dabd0eaa73bdddda229b2946e docs/WEB_SERVER.md
|
||||
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
|
||||
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
|
||||
@@ -76,8 +76,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
|
||||
973795e8af5240a73df8e1c36325f2c023b3d2c40accf1d1894b8a180f93832e langs/en_US.json
|
||||
8c9ad51f6624d6ce6f3d0dca7b69fe3e6899c704fb326ee26e8b10d9861be6a5 langs/pl_pl.json
|
||||
15ae657fec02c5f81a41857ca73a6f4d3ed9f7ca977570aaff6420faba0416cc langs/en_US.json
|
||||
222049adfbdbf29f886bd61178341600292071f268aa1568d5c66489653e3e0a langs/pl_pl.json
|
||||
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
||||
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
|
||||
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
|
||||
@@ -143,7 +143,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
|
||||
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
||||
4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs
|
||||
8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs
|
||||
10a6da3e3d0f0e4ac0c4504a985b52f014c2901235bcaaf9d99d3b244e1cbac4 src/admin/openapi.json
|
||||
0f414605972a48944455a691b81a0b8a1db66d316a7841c44e5ff455e7ab00b5 src/admin/openapi.json
|
||||
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
|
||||
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
||||
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
||||
@@ -155,7 +155,7 @@ d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/oper
|
||||
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
|
||||
ae6cc6020b896a54b9ba2dcbc0fbed9f13d289ebd7ef8804008386fca8533e85 src/admin/router.rs
|
||||
0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs
|
||||
698dbe70871e599c98e8c54c226bfeba00f2ba6e2f71a0da13b4b502d7721685 src/admin/status.rs
|
||||
978f50b9ec93dc9bd6736f3dfd9a4c60ae732c9764a0cb232abcb1d42d9315d4 src/admin/status.rs
|
||||
6d3224349d08b1052ca66eb153aba513de53bcb666c6f57f00075509c6b6cc47 src/admin/status_live.rs
|
||||
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
|
||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||
@@ -166,7 +166,7 @@ c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/
|
||||
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js
|
||||
a085ee336e6669229f3ad2f74536f19bd088412ca79bcaff30dff293f1e1baf9 src/admin/web/app/chart-scales.js
|
||||
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
|
||||
ebe4cb5b4fbea9c01456589b5c8805bf5838bac78abdb349391af85b1b1cb6e1 src/admin/web/app/core.js
|
||||
c8e1d31a7bf77d9e4f4be979bf6a7474369aae3e1e46e6b30d1a76677fa3d762 src/admin/web/app/core.js
|
||||
622d34e1616fee9a480d208192fc5a84d62d84ef4233d6a7d95e2bfb8cef415b src/admin/web/app/dashboard-inline-layout.js
|
||||
851a4337127f332db91f6954f689363ee227fcddde2dda82e55f645940d0ce3b src/admin/web/app/dashboard-layout-editor.js
|
||||
a184c50e1ca6bb5a14954e31b15f09ff9978fb939064ca724851bbb4e661dc56 src/admin/web/app/dashboard-presets.js
|
||||
@@ -187,7 +187,7 @@ f1a764691f3e2cdb7c6c43e37dae78249561c017a1b74e973a378cb780b72835 src/admin/web/
|
||||
560a8e047182fbcee08aa4f8d999400a8a5ddca0b6a4640efd46f6b5c34fde91 src/admin/web/dashboard-defaults.json
|
||||
14213011cfb6ac334c8711b76bbdde97588a7b7ba4eb2592a1e387f877d6cbe0 src/admin/web/dashboard-widgets.json
|
||||
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
|
||||
9c43c583513ca6707847e11e25f056b32b41758f9804425f5eb80bcdc66bb0df src/admin/web/docker.js
|
||||
887f7b0b6b68cb0a0197297adcd02b932dcf1e8a7059f03ad39459ed1b38c064 src/admin/web/docker.js
|
||||
1d5f8fbf81394a6a8c3c2eff78b48408ef7790c0613905c2c7f584f1548862c6 src/admin/web/docs.css
|
||||
aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html
|
||||
24b6d5676c2e0cd3862851a395c753dfd7e6fb022f7c69267c1e02b728f1d06f src/admin/web/docs.js
|
||||
@@ -207,12 +207,12 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
|
||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||
7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js
|
||||
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
||||
a57546ff4b1443e0dc66396edf22f94d851045f3794812aae6f8d01a09f18bba src/admin/web/index.html
|
||||
bce1a1c7d8bc76d83511122c34d43e72bdad2b8ada3d987da07e91f383fbbe55 src/admin/web/index.html
|
||||
7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js
|
||||
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
|
||||
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
|
||||
c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js
|
||||
48de12a10260927c6598977ef921fe3f2e4234072960ab84aa61bc6329f762b6 src/admin/web/management.js
|
||||
8a6c9124c6eeeb5625af402e1e7dcae3bd18fc6f94b4b179b5cd4642b6d1b8a1 src/admin/web/management.js
|
||||
4481a592fcce9a2aa1db5e4a25619e19fd459c39039b86c0549dfc427eec1b45 src/admin/web/partials/overview-charts.html
|
||||
a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/partials/overview-metrics.html
|
||||
3928afce2ed6d5f2d3e29475dd29e4c3bd3c6df180ad5119beaff0c7a58e3b3b src/admin/web/prometheus-token.js
|
||||
@@ -220,11 +220,11 @@ a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/
|
||||
be9b78bee4ef0026608191d36a2aed831f93c1baaa5bc73da86bf09bc217ce76 src/admin/web/settings.js
|
||||
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
||||
6feb1996b1caa9d19e0dc56eae7ca5b48b7e7cdf0a93bb2e4b527fb8f13a2fe5 src/admin/web/streams.js
|
||||
13013666b75a3d3d56d25d7f0536067f8b814583087324cfd2b2d62c0ccd7e98 src/admin/web/style.css
|
||||
cbdff4ed9b14de933844ee68e09f1ecf86292309ebc2fae47e7a513cfa9b485a src/admin/web/style.css
|
||||
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
||||
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
|
||||
ad2a127f83145792d5c658b9e9a3fae42e26bd5ec5339599feed34711133720e src/admin/web/vhosts/core.js
|
||||
4f87802573f2e5abebcade4928ab460b0de78b991a96a391485cb36336d61812 src/admin/web/vhosts/editor.js
|
||||
a55f45dad5b540def419f8487161fb506b7e52744c76a5e661c7a5fa0a98a426 src/admin/web/vhosts/core.js
|
||||
73925be3157bae23cd0a4058a596ac5eb7311f3cd719f7006974e4731c2d4843 src/admin/web/vhosts/editor.js
|
||||
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
|
||||
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
|
||||
8c3da49973c62333559b0e3588c02beb0b425c025eac5cfa82a1bb968f70d9dd src/admin/web/vhosts/rates.js
|
||||
@@ -297,8 +297,8 @@ e0146bf5d2e2e33aedcccd9205e5bd457559c9d77dc7ce266f8c165d1b867f0f src/compressio
|
||||
56b3d37e7447995bec2fdda1c17dcc74b60958a63ca88b88ff57e56347d8b0b9 src/config/backend.rs
|
||||
cb882e390bc429df11a1ca7c3a1d4175ffbb70c4e821b168c5d7a11f55163d14 src/config/compile.rs
|
||||
a4bb70f4da2ae1fa890e65ec15f87e919e2288637dc0eb2c383234e8babbcb05 src/config/internal_routing.rs
|
||||
7f162241a424a570841832253f2f1a3726d69c7963eaa1728c4ae1800386c2df src/config/model.rs
|
||||
d2501ce56f7582ae462ac903db312054aa021827941104d0d698736032a6cb8d src/config/routing.rs
|
||||
8ab99c684da56a8efd7a44ab6e7de2877097150fa163b17e5cfedbf30bca5eeb src/config/model.rs
|
||||
75579910c1d521bef91f8e4229569c651d4b3b585f13140e5249e1b54b5f92a9 src/config/routing.rs
|
||||
48ecd9de5a54b0a4aa484f38a7e171f60a033b725e14b158d1ef07b67997f9eb src/config/snapshot.rs
|
||||
76af9d85af2bf16e98a02af25f818fc449e2aacba8c17689c2b7c3e59fe77b29 src/config/tests.rs
|
||||
f3c39dc4ac09b9aa7fa8ff01da933f67f25f5030c05f54eef1c7d1e064d1f3c1 src/db.rs
|
||||
@@ -378,7 +378,7 @@ cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forw
|
||||
0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs
|
||||
3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs
|
||||
bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs
|
||||
19ab40cab0a8cce0c750277a86ddc9e9a1af58a408f359b4f68768faf68429df src/proxy/forward_entry.rs
|
||||
ddf15ef6559b78818f5da67ae336b9b2ca6d1132be66083b2f547ac7c9d6cd49 src/proxy/forward_entry.rs
|
||||
9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs
|
||||
cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/headers.rs
|
||||
0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs
|
||||
@@ -388,7 +388,7 @@ cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/head
|
||||
2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs
|
||||
b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs
|
||||
6a46cc0245175db56384ad793065141dece2213657ca73620fcc66fbe53a4b1b src/proxy/tests/authority_tests.rs
|
||||
1aa9bbbfb2659facdaeaf185e11221b1c8cd5fe6e23c7c1dacd3aa55e3db4fcb src/proxy/tests/cache_fast_path_tests.rs
|
||||
91776c89a0ac3560902ec751846325ae5168289d2cd8c568808c89fa82aa5f69 src/proxy/tests/cache_fast_path_tests.rs
|
||||
0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs
|
||||
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs
|
||||
fecc1c7ffff740caa7168370e59234a17b9836a6dce6d08b770ee9648c22709b src/proxy/types.rs
|
||||
@@ -410,7 +410,7 @@ d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.r
|
||||
92b4b284f1b69dca58e4e3f76199f0aecc397eed26d10123b427af1bb8a4a57e src/runtime/docker.rs
|
||||
9015732f5592cb8d5503aee9199a0ead6c42ae20687d506e8752bd1065eafd82 src/runtime/external_config.rs
|
||||
ee2050b9f8efb45a209c91d76043a1ba29c4289978896aaf7e7d344d48fdc369 src/runtime/tests.rs
|
||||
54e9bd06230f726f7aa4e96153e00ea79a54bc47c0008d6b8b05f9ec33821e8e src/runtime/types.rs
|
||||
0a585647b205290d0f7e52f9fe83b7ece1403cd87ca0e726669e27a3fb9faaa9 src/runtime/types.rs
|
||||
78b734fc804bb3d673f6cc0ab0bc8862612072ca7f4423d7c9bdacbe78223fc2 src/runtime/updates.rs
|
||||
208a378b14ca80e1412ffa19ef1b140594182971e2e797ee0bb95bcdb8404da7 src/secret_store.rs
|
||||
6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs
|
||||
@@ -474,10 +474,10 @@ a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent
|
||||
6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs
|
||||
3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs
|
||||
6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs
|
||||
597ab87e69de8cc8ac3836903d18c699d788c165b46ff918d4a233a46b72d96e src/vhost/model.rs
|
||||
3c24678359cc6e2ec94c2a0e1cc399b591ef683adab9b965633c898c6241130f src/vhost/model.rs
|
||||
35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs
|
||||
9b16b5307e140db97363f56c9fac8319e56df846bb1c919d3bc06091ab6ce69c src/vhost/tests.rs
|
||||
7ce20fda4d36ac3415fc1599a098cd1b79be777f1db3043ca56f74723c97ed9e src/vhost/validation.rs
|
||||
94bb735627f2c4a0af5c4df45bb2b41299caf37b7e6e4d0d7a860d3890eb40e0 src/vhost/tests.rs
|
||||
e912d5dd09fa172032ca04d89572b845dd8f38ab5bb8598433b07629181f791d src/vhost/validation.rs
|
||||
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
|
||||
9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs
|
||||
bcaa1ae15134d85668b05a88d8abea01e5d188f535ad83cd4459732fd56f2f44 src/web_server/config/model.rs
|
||||
@@ -506,14 +506,17 @@ 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
|
||||
667352945e67032a26e9f246a3f89b20b431b2cab8f48bb77b11533d89b3f2a7 tests/css_usage_audit.cjs
|
||||
2e957134ae1b041ab1f1cac6149892a2704bd61a37fbe4afc0810e2911b5cedd tests/docker_vhost_actions.cjs
|
||||
c1b806e756a84913d0a1cd2ace0b0f94fb5f5817e0480870a65279b4d7fc5bbb tests/docker_vhost_metrics.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
|
||||
071d590439bba50b526e1b2344b5ee414210af8f4f6a318d1611640386de576f tests/ux_0345.cjs
|
||||
b312f9f5d5f4b3d30f935ab3f3c893fd8b8f2d571acc8198a1a89ed9ecd1f0ac tests/ux_0345_followup.cjs
|
||||
67353441435c6378780e4b6c5dd8732d7160f7fbce8df9cf4791cbd6037f5840 tests/vhost_content_type_ui.cjs
|
||||
3421dc1baeb2a9882907b09e816213b375767089c79fce710248c61b9c3e1ae2 tests/vhost_ui_layout.cjs
|
||||
eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
|
||||
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
|
||||
|
||||
+5
-1
@@ -13,7 +13,11 @@ 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.
|
||||
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, request Content-Type filtering, 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.
|
||||
|
||||
## Content-Type request denylist
|
||||
|
||||
Ordinary Vhosts can reject selected MIME types under **Vhosts → Security → Request Content-Type**. With the option enabled, an exact media type such as `multipart/form-data` (including parameters) or a family such as `application/*` is rejected with **403** before proxy/Web Server/PHP routing or cache handling. Multiple entries are supported; missing `Content-Type` is not rejected. Ambiguous/malformed headers are denied when filtering is enabled. Docker vhost controls do not expose this setting. See [Vhosts](VHOSTS.md#block-request-content-type-values-ordinary-vhosts) for a configuration example and limits.
|
||||
|
||||
## HTTP methods and rate limits
|
||||
|
||||
|
||||
@@ -81,7 +81,19 @@ In **Docker vhosts**, select the service and set:
|
||||
|
||||
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.
|
||||
Use the per-service **Publish** on/off switch to expose or remove the generated Vhost; toggling it saves immediately. Test the resulting hostname with **Open app**. Edits to an already published service use its own **Apply changes** action; switching Publish off removes the proxy publication but does not stop the Docker container. Hidden rows in the page are only a browser view preference, not a security policy.
|
||||
|
||||
### Live HTTP request logs
|
||||
|
||||
For each published Docker Vhost, **Live logs** appears directly beside **Open app**. It opens RustProxy's authenticated **Live access logs** dialog with this publication's generated Vhost ID already selected. Logs for every hostname alias of the same publication are shown together. The filter stays selected even when no requests have yet been recorded; you can switch back to **All vhosts** inside the dialog. The action waits until an agent status refresh has provided the server-generated Vhost ID, so it may be disabled briefly immediately after publishing.
|
||||
|
||||
These are **HTTP requests processed by RustProxy**, not the application's Docker container stdout/stderr. The viewer uses RustProxy's bounded in-memory live request history; requests appear when completed, not as a container terminal stream. Stored Docker stdout/stderr are not retrieved from the agent. The dialog is administrator-only, and no Docker log-reading endpoint is exposed.
|
||||
|
||||
### Per-vhost traffic statistics
|
||||
|
||||
Every published vhost on an enabled Docker agent has a **Traffic statistics** disclosure directly below **Configure vhost**. It starts collapsed, uses the same six status/requests/active/errors/latency/traffic-per-second tiles and **Lifetime request rate** chart as an ordinary Vhost, and can be opened individually. The **Expand all statistics / Collapse all statistics** toolbar button toggles every published Docker vhost at once (including rows hidden by search or the local Hide action). The chosen disclosure state survives live list refreshes during the current panel session; it resets on sign-out.
|
||||
|
||||
Counters come from RustProxy's generated Vhost metrics, not the Docker agent or just the upstream backend: they include requests handled at the proxy frontend, even those rejected before reaching a backend. The request-rate graph is sampled in the browser from cumulative counters **since the panel session started observing this host**; its history is not stored on disk. Traffic/s is the observed delta of ingress plus egress bytes. Missing metrics show `-`. Live updates follow the administration status stream/polling settings; expanding the statistics does not prevent Docker inventory refreshes.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ rust-proxy --data-dir /var/lib/rustproxy --livelogs
|
||||
|
||||
The CLI connects to the local administration listener and follows structured request events. It can filter by host, Vhost, backend, route, method, client, protocol, status, cache state and latency; use `filters`, `pause`, `resume`, `detail on` and `quit` in the interactive view. It adapts to a loopback administration HTTPS listener where configured.
|
||||
|
||||
For a published Docker application, **Docker vhosts → Live logs** (beside **Open app**) opens this same dialog filtered to the generated Vhost. It includes HTTP traffic for that publication and its aliases. It does **not** display Docker container stdout/stderr; the Docker agent remains inventory-only.
|
||||
|
||||
Live viewing is **bounded in memory**. Slow consumers may receive a gap/loss indication and are not allowed to block proxy traffic. Low-level malformed HTTP/TLS/H2/H3 protocol errors can be sent only to persistent sinks rather than appearing in the ordinary live request list. A request ID (`X-Request-ID`) helps correlate an application response with proxy records.
|
||||
|
||||
## Stored Logs
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# RustProxy documentation
|
||||
|
||||
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.
|
||||
Practical guides for **RustProxy 0.34.6**. 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
|
||||
|
||||
@@ -11,7 +11,7 @@ Practical guides for **RustProxy 0.34.5**. This index and the listed pages are a
|
||||
|
||||
## Publishing services
|
||||
|
||||
- [Vhosts and backends](VHOSTS.md) — domains, listener bindings, balancing, health and redirects.
|
||||
- [Vhosts and backends](VHOSTS.md) — domains, listener bindings, request Content-Type filtering, balancing, health and redirects.
|
||||
- [Custom locations](CUSTOM_LOCATIONS.md) — path routing, rewrites, local responses and error fallbacks.
|
||||
- [Access and authentication](ACCESS.md) — ACL, country/DDNS, Basic Auth, Tinyauth and rate limits.
|
||||
- [Static Web Server](WEB_SERVER.md) — document roots, downloads, rules, built-in error pages and file delivery.
|
||||
@@ -24,7 +24,7 @@ Practical guides for **RustProxy 0.34.5**. This index and the listed pages are a
|
||||
## Integrations and operations
|
||||
|
||||
- [Certificates and automatic renewal](ACME_PROVIDERS.md) — PEM, HTTP-01, DNS-01, Certbot and external certificates.
|
||||
- [Docker discovery](DOCKER_DISCOVERY.md) — optional Linux agent DEB/RPM or manual installation, TLS/token CLI, networking and generated Vhosts.
|
||||
- [Docker discovery](DOCKER_DISCOVERY.md) — optional Linux agent DEB/RPM or manual installation, TLS/token CLI, networking, generated Vhosts and their per-service live HTTP logs.
|
||||
- [Other integrations](INTEGRATIONS.md) — Tinyauth, NPM migration, OPNsense and monitoring tools.
|
||||
- [Dashboard](DASHBOARD.md) — metrics panels, HIT/MISS/BYPASS/OTHER request mix, presets and configuration history.
|
||||
- [Monitoring](MONITORING.md) — Prometheus, full JSON and compact `summary` API, traffic, backend and cache observations.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ RustProxy provides several security controls, but it is not a complete Web Appli
|
||||
|
||||
## Origin and request trust
|
||||
|
||||
- Enforce IP/country/DDNS policies, rate limits and HTTP method rules **before** connecting to backends. A Custom location cannot undo a Vhost-level denial.
|
||||
- Enforce IP/country/DDNS policies, rate limits, HTTP method rules and ordinary Vhost request **Content-Type** deny rules **before** connecting to backends. A Custom location cannot undo a Vhost-level denial. A Content-Type rule is based on the declared request header; a client can omit or mislabel it, so it does not substitute for application or PHP security patches.
|
||||
- Private and local backend addresses require deliberate exceptions. Routing to the local administration listener can expose secrets or create routing loops.
|
||||
- HTTPS to a backend is not proof of identity unless upstream certificate/hostname verification is enabled; current backend defaults do **not** enable verification automatically.
|
||||
- Honor forwarding identity headers only from correctly configured trusted peers. A client can forge arbitrary `X-Forwarded-*` and User-Agent headers.
|
||||
|
||||
@@ -13,6 +13,26 @@ A **Vhost** publishes one or more hostnames on named HTTP/HTTPS listeners. Its s
|
||||
|
||||
A Vhost may bind several frontends. Hostnames and selected listeners must match: the existence of a certificate or an upstream does not create DNS entries or a listening socket. Existing Vhosts retain their bindings when new listeners are created.
|
||||
|
||||
## Block request Content-Type values (ordinary Vhosts)
|
||||
|
||||
Go to **Vhosts → Edit → Security → Request Content-Type**. Enable **Block selected Content-Type values**, enter one MIME pattern per line, then **Save**. The policy is off by default and is available in the regular Vhost editor, not in the Docker vhost editor.
|
||||
|
||||
Example patterns:
|
||||
|
||||
```text
|
||||
multipart/form-data
|
||||
application/xml
|
||||
image/*
|
||||
```
|
||||
|
||||
- Accepts up to **64** unique patterns: exact `type/subtype` or a media family `type/*`. The policy matches case-insensitively and ignores parameters (`multipart/form-data; boundary=...` matches `multipart/form-data`). Whitespace outside a pattern is trimmed; invalid patterns and duplicates are rejected at save/validation time.
|
||||
- A matching request receives **HTTP 403** before reading the body, proxying to an upstream, serving a Web Server/PHP-FPM file, choosing a Custom location or returning a cached response. When the policy is enabled, malformed, repeated or ambiguous `Content-Type` values are also denied.
|
||||
- Requests with no `Content-Type` are **not blocked**. This is header filtering, not content sniffing, body inspection or a comprehensive WAF. Applications can accept unsafe input through other formats and routes.
|
||||
- Blocked types cannot be used for normal requests either: blocking `multipart/form-data` disables traditional file-upload forms for the selected Vhost. Evaluate legitimate traffic before enabling and test an allowed type and a denied type.
|
||||
- The policy is stored in the Vhost's `content_type_policy` object, for example `{"enabled":true,"blocked":["multipart/form-data"]}`. Existing Vhosts default to disabled. Changes are applied on the regular configuration publish cycle, without listener restart.
|
||||
|
||||
Check with `curl -i -H 'Content-Type: multipart/form-data; boundary=test' --data 'x' https://your-vhost.example/upload` (expected **403**) and a valid application request without the blocked type. Applying this filter is **not a fix for an underlying PHP/application vulnerability**: keep PHP, extensions and application code patched.
|
||||
|
||||
## Source and backends
|
||||
|
||||
- **Reverse Proxy:** one Vhost has a backend pool. Each member has a configured URL, ID, enabled flag, weight, optional backup/drain state, health settings and capacity limits.
|
||||
|
||||
@@ -894,6 +894,11 @@
|
||||
"vhUaBlockSection": "User-Agent blocking",
|
||||
"vhLocationIpAclSection": "Local IP/CIDR rules",
|
||||
"vhSslSettings": "SSL settings",
|
||||
"vhContentTypeSection": "Request Content-Type",
|
||||
"vhContentTypeBlock": "Block selected Content-Type values",
|
||||
"vhContentTypeList": "Blocked MIME types (one per line)",
|
||||
"vhContentTypeRequired": "Enter at least one MIME type to enable Content-Type blocking.",
|
||||
"vhContentTypeHelp": "Examples: multipart/form-data, application/json or image/*. Case-insensitive; parameters such as boundary are ignored. Requests with a matching Content-Type receive HTTP 403 before reaching PHP, Web Server or a backend. Invalid/ambiguous Content-Type headers are also rejected while enabled. Requests without Content-Type are not blocked. Uploads using blocked types will stop working.",
|
||||
"vhTrafficMethodsSection": "HTTP methods",
|
||||
"vhTrafficRateSection": "Per-client IP rate limiting",
|
||||
"vhTrafficCountrySection": "Country access",
|
||||
@@ -1635,10 +1640,14 @@
|
||||
"securityAuditOpen": "Administration audit",
|
||||
"securityAuditHint": "Recent administration changes are loaded only when this window is opened.",
|
||||
"dockerConfigureService": "Configure vhost",
|
||||
"dockerTrafficStatistics": "Traffic statistics",
|
||||
"dockerExpandAllMetrics": "Expand all statistics",
|
||||
"dockerCollapseAllMetrics": "Collapse all statistics",
|
||||
"dockerAliasesShort": "aliases",
|
||||
"dockerAgentDetails": "Agent details",
|
||||
"dockerServices": "Services",
|
||||
"dockerOpenApp": "Open app",
|
||||
"dockerLiveAccessLogsHelp": "Live HTTP access logs for this Docker vhost, not the container stdout/stderr.",
|
||||
"networkInterfacesHistory": "Traffic by interface",
|
||||
"networkInterfaceRate": "RX / TX",
|
||||
"networkNoInterfaces": "No non-loopback network interfaces are available.",
|
||||
|
||||
@@ -894,6 +894,11 @@
|
||||
"vhUaBlockSection": "Blokowanie User-Agent",
|
||||
"vhLocationIpAclSection": "Lokalne reguły IP/CIDR",
|
||||
"vhSslSettings": "Ustawienia SSL",
|
||||
"vhContentTypeSection": "Typ zawartości żądania (Content-Type)",
|
||||
"vhContentTypeBlock": "Blokuj wybrane wartości Content-Type",
|
||||
"vhContentTypeList": "Blokowane typy MIME (jeden w wierszu)",
|
||||
"vhContentTypeRequired": "Aby włączyć blokowanie Content-Type, podaj co najmniej jeden typ MIME.",
|
||||
"vhContentTypeHelp": "Przykłady: multipart/form-data, application/json lub image/*. Wielkość liter i parametry (np. boundary) nie mają znaczenia. Dopasowane żądania otrzymają HTTP 403 przed PHP, Web Serverem lub backendem. Błędne albo niejednoznaczne nagłówki Content-Type też będą odrzucane. Brak Content-Type nie jest blokowany. Blokowanie może uniemożliwić przesyłanie plików.",
|
||||
"vhTrafficMethodsSection": "Metody HTTP",
|
||||
"vhTrafficRateSection": "Limity zapytań dla adresów IP",
|
||||
"vhTrafficCountrySection": "Dostęp według kraju",
|
||||
@@ -1635,10 +1640,14 @@
|
||||
"securityAuditOpen": "Audyt administracyjny",
|
||||
"securityAuditHint": "Ostatnie operacje administracyjne są pobierane dopiero po otwarciu tego okna.",
|
||||
"dockerConfigureService": "Konfiguruj vhost",
|
||||
"dockerTrafficStatistics": "Statystyki ruchu",
|
||||
"dockerExpandAllMetrics": "Rozwiń wszystkie statystyki",
|
||||
"dockerCollapseAllMetrics": "Zwiń wszystkie statystyki",
|
||||
"dockerAliasesShort": "aliasów",
|
||||
"dockerAgentDetails": "Szczegóły agenta",
|
||||
"dockerServices": "Usługi",
|
||||
"dockerOpenApp": "Otwórz aplikację",
|
||||
"dockerLiveAccessLogsHelp": "Logi żądań HTTP dla tego Docker vhosta, nie stdout/stderr kontenera.",
|
||||
"networkInterfacesHistory": "Ruch według interfejsów",
|
||||
"networkInterfaceRate": "RX / TX",
|
||||
"networkNoInterfaces": "Brak dostępnych interfejsów sieciowych innych niż loopback.",
|
||||
|
||||
+22
-1
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.5",
|
||||
"version": "0.34.6",
|
||||
"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": [
|
||||
@@ -503,6 +503,27 @@
|
||||
"method_policy": {
|
||||
"$ref": "#/components/schemas/MethodPolicy"
|
||||
},
|
||||
"content_type_policy": {
|
||||
"type": "object",
|
||||
"description": "Ordinary Vhost request Content-Type denylist. Matching ignores case and MIME parameters; evaluated before Web Server/PHP and origin handling. Disabled by default. Missing Content-Type is allowed; ambiguous/invalid Content-Type is rejected when enabled.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"blocked": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"description": "MIME type/subtype or type/* (e.g. multipart/form-data, application/*)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
|
||||
@@ -63,6 +63,7 @@ fn status_payload(state: &App) -> Value {
|
||||
"rate_limit",
|
||||
"country_access",
|
||||
"method_policy",
|
||||
"content_type_policy",
|
||||
"location_access_policy",
|
||||
"configuration_history",
|
||||
"cloudflare_client_ip",
|
||||
|
||||
@@ -57,7 +57,7 @@ function translate() {
|
||||
window.rustProxyDrafts?.refresh();
|
||||
window.rustProxyPreferences?.refreshAppearanceUi?.();
|
||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
||||
if (envelope) renderConfig(); renderConfigStorage(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyDocker?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); window.rustProxyBackendDashboard?.render(latestStatus, null, false); window.rustProxyExtraOverview?.render(latestStatus, false); window.rustProxyWideOverview?.render(latestStatus, false); window.rustProxyWebOverview?.render(latestStatus, false); } if (lastLogs) renderLogs(lastLogs);
|
||||
syncSettingsDirtyIndicators();
|
||||
}
|
||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
@@ -267,7 +267,7 @@ function renderStatus(s, systemSample = false) {
|
||||
$("metric-ws-proto-h1").textContent = (Number(m.websocket_h1_active) || 0).toLocaleString(locale());
|
||||
$("metric-ws-proto-h2").textContent = (Number(m.websocket_h2_active) || 0).toLocaleString(locale());
|
||||
$("metric-ws-proto-h3").textContent = (Number(m.websocket_h3_active) || 0).toLocaleString(locale());
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s); window.rustProxyBackendDashboard?.render(s); window.rustProxyExtraOverview?.render(s); window.rustProxyWideOverview?.render(s); window.rustProxyWebOverview?.render(s); window.rustProxyWebServer?.renderLive?.(s);
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyDocker?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s); window.rustProxyBackendDashboard?.render(s); window.rustProxyExtraOverview?.render(s); window.rustProxyWideOverview?.render(s); window.rustProxyWebOverview?.render(s); window.rustProxyWebServer?.renderLive?.(s);
|
||||
}
|
||||
function renderFooter(s) {
|
||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||
|
||||
+121
-12
@@ -2,6 +2,7 @@
|
||||
(() => {
|
||||
const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"];
|
||||
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false };
|
||||
const expandedMetrics = new Set(), metricHistory = new Map();
|
||||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.docker_vhost_sort) ? preferences.docker_vhost_sort : "service", descending: preferences?.docker_vhost_descending === true }; }
|
||||
function syncSortDirection() { const control = $("docker-vhost-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
|
||||
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); }
|
||||
@@ -23,11 +24,11 @@
|
||||
function expandedHost(value, baseDomain) { const host = String(value || "").trim().replace(/\.$/, "").toLowerCase(); return host && host.includes(".") ? host : host ? `${host}.${baseDomain}` : ""; }
|
||||
function publicListener(agent) {
|
||||
const endpoints = envelope?.config?.listeners?.endpoints || [], wanted = new Set(agent?.listener_ids || []), candidates = endpoints.filter(item => item.enabled !== false && wanted.has(item.id));
|
||||
return candidates.find(item => item.tls) || candidates[0] || null;
|
||||
return candidates.find(item => item.binding?.tls === true) || candidates[0] || null;
|
||||
}
|
||||
function proxiedUrl(agent, hostname) {
|
||||
const host = expandedHost(hostname, agent.base_domain); if (!host) return "";
|
||||
const listener = publicListener(agent), secure = !!listener?.tls, scheme = secure ? "https" : "http", address = String(listener?.address || "");
|
||||
const listener = publicListener(agent), secure = !!listener?.binding?.tls, scheme = secure ? "https" : "http", address = String(listener?.binding?.address || "");
|
||||
const match = address.match(/:(\d+)$/), port = match ? Number(match[1]) : (secure ? 443 : 80), suffix = port === (secure ? 443 : 80) ? "" : `:${port}`;
|
||||
return `${scheme}://${host}${suffix}/`;
|
||||
}
|
||||
@@ -209,6 +210,83 @@
|
||||
const latency = Number(member.latency_ms || member.ewma_latency_ms || 0), metrics = [`${t("dockerHealthLatency")}: ${latency > 0 ? `${latency.toFixed(1)} ms` : "-"}`, `${t("dockerHealthResult")}: ${member.last_result || "-"}`, `${t("dockerHealthRequests")}: ${Number(member.requests || 0).toLocaleString()}`, `${t("dockerHealthErrors")}: ${Number(member.errors || 0).toLocaleString()}`, `${t("dockerHealthActive")}: ${Number(member.active || 0).toLocaleString()}`];
|
||||
wrap.append(el("span", metrics.join(" · "), "fine")); return wrap;
|
||||
}
|
||||
function syncMetricsToggle() {
|
||||
const control = $("docker-metrics-toggle-all"); if (!control) return;
|
||||
const sections = [...document.querySelectorAll("#docker-discovery-list .docker-service-metrics")];
|
||||
const allOpen = sections.length > 0 && sections.every(section => section.open);
|
||||
const key = allOpen ? "dockerCollapseAllMetrics" : "dockerExpandAllMetrics";
|
||||
control.disabled = sections.length === 0; control.dataset.i18n = key; control.textContent = t(key);
|
||||
control.setAttribute("aria-expanded", String(allOpen));
|
||||
}
|
||||
function statisticsSection(agent, serviceKey) {
|
||||
const section = el("details", undefined, "docker-service-editor docker-service-metrics");
|
||||
section.dataset.viewKey = viewKey(agent.id, serviceKey);
|
||||
section.dataset.agentId = agent.id; section.dataset.serviceKey = serviceKey;
|
||||
section.open = expandedMetrics.has(section.dataset.viewKey);
|
||||
const summary = el("summary"); summary.append(el("span", t("dockerTrafficStatistics"))); section.append(summary);
|
||||
const grid = el("div", undefined, "vhost-live-grid"), fields = {};
|
||||
for (const key of ["status", "vhRequests", "vhActive", "vhErrors", "vhLatency", "vhTraffic"]) {
|
||||
const item = el("div", undefined, "vhost-live-metric"), value = el("strong", "-");
|
||||
item.append(el("span", t(key)), value); fields[key] = value; grid.append(item);
|
||||
}
|
||||
section.append(grid);
|
||||
const chart = el("div", undefined, "vhost-chart"), head = el("div", undefined, "vhost-chart-head");
|
||||
const chartValue = el("span", "-", "fine"); head.append(el("span", t("vhLifetimeChart"), "small-label"), chartValue); chart.append(head);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.setAttribute("aria-label", t("vhLifetimeChart"));
|
||||
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); line.setAttribute("class", "vhost-chart-line");
|
||||
svg.append(line); chart.append(svg); section.append(chart);
|
||||
section.dockerMetricView = { fields, chartValue, line };
|
||||
section.addEventListener("toggle", () => {
|
||||
if (section.open) expandedMetrics.add(section.dataset.viewKey);
|
||||
else expandedMetrics.delete(section.dataset.viewKey);
|
||||
syncMetricsToggle();
|
||||
});
|
||||
return section;
|
||||
}
|
||||
function renderDockerMetrics(status = latestStatus) {
|
||||
const observed = status?.vhost_metrics || {};
|
||||
const livePools = new Map((status?.backend_pools || []).map(pool => [pool.id, pool]));
|
||||
const now = performance.now();
|
||||
for (const route of poolRoutes) {
|
||||
const value = observed[route.pool_id]; if (!value) continue;
|
||||
const history = metricHistory.get(route.pool_id) || { last: null, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
|
||||
if (history.snapshot !== value) {
|
||||
const requests = Number(value.requests), transferred = Number(value.bytes_in) + Number(value.bytes_out);
|
||||
const sample = rustProxyRateSampling.difference(history.last, [requests, transferred], now, rustProxyRateSampling.cadence("overview_seconds"));
|
||||
history.paused = history.last !== null && sample.deltas === null;
|
||||
history.last = sample.next; history.rate = sample.deltas ? sample.deltas[0] / sample.seconds : 0;
|
||||
history.traffic = sample.deltas ? sample.deltas[1] / sample.seconds : 0;
|
||||
if (!history.paused) {
|
||||
history.pendingSum += history.rate; history.pendingCount += 1;
|
||||
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
|
||||
}
|
||||
if (history.points.length >= 120) {
|
||||
const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2);
|
||||
history.points = compacted; history.bucketSize *= 2;
|
||||
}
|
||||
history.snapshot = value; history.metrics = value; metricHistory.set(route.pool_id, history);
|
||||
}
|
||||
}
|
||||
for (const section of document.querySelectorAll("#docker-discovery-list .docker-service-metrics")) {
|
||||
const [agentId, serviceKey] = [section.dataset.agentId, section.dataset.serviceKey];
|
||||
const route = poolRoutes.find(item => item.agent_id === agentId && item.service_key === serviceKey);
|
||||
const history = route && metricHistory.get(route.pool_id), metrics = history?.metrics;
|
||||
const view = section.dockerMetricView, runtime = route && (livePools.get(route.pool_id) || poolIndex.get(route.pool_id)), members = runtime?.members || [];
|
||||
view.fields.status.textContent = !runtime || !members.length ? "-" : runtime.health_mode === "off" ? t("vhHealthDisabled") : `${members.filter(member => member.status === "up").length}/${members.length} ${t("vhUp")}`;
|
||||
view.fields.vhRequests.textContent = metrics ? fmt(metrics.requests) : "-";
|
||||
view.fields.vhActive.textContent = metrics ? fmt(metrics.active) : "-";
|
||||
view.fields.vhErrors.textContent = metrics ? fmt(metrics.errors) : "-";
|
||||
view.fields.vhLatency.textContent = Number(metrics?.ewma_latency_ms) > 0 ? `${fmt(metrics.ewma_latency_ms, 1)} ms` : "-";
|
||||
view.fields.vhTraffic.textContent = !history || history.paused ? "-" : `${rateBytes(history.traffic)}/s`;
|
||||
if (!history) { view.chartValue.textContent = "-"; view.line.setAttribute("points", ""); continue; }
|
||||
const data = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points;
|
||||
const peak = Math.max(0, ...data), scale = Math.max(1, peak);
|
||||
const points = data.map((value, index) => `${index * 320 / Math.max(1, data.length - 1)},${50 - value / scale * 44}`).join(" ");
|
||||
view.line.setAttribute("points", points);
|
||||
view.chartValue.textContent = `${history.paused ? "-" : history.rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${peak.toFixed(1)}`;
|
||||
}
|
||||
}
|
||||
function selectHealthMode(value) { const control = el("select", undefined, "docker-health-mode"); for (const [name, key] of [["http", "dockerHealthHttp"], ["tcp", "dockerHealthTcp"], ["off", "dockerHealthOff"]]) { const option = el("option", t(key)); option.value = name; control.append(option); } control.value = value; return control; }
|
||||
function numberInput(value, cls, min, max, optional = false) { const node = input(value ?? "", "number", cls); node.min = min; node.max = max; if (!optional) node.required = true; return node; }
|
||||
function syncHealthFields(details) { const http = details.querySelector(".docker-health-mode")?.value === "http"; details.querySelectorAll("[data-http-only]").forEach(node => node.hidden = !http); }
|
||||
@@ -280,7 +358,6 @@
|
||||
return {
|
||||
service_key: row.dataset.key, hostname: row.querySelector(".docker-hostname").value.trim().toLowerCase(), aliases: row._readAliases?.() || [], target_ip: row.querySelector(".docker-target-ip").value || null,
|
||||
enabled: true, scheme: row.querySelector(".docker-scheme").value, preserve_host: row.querySelector(".docker-preserve-host").checked, force_ssl: row.querySelector(".docker-force-ssl").checked, websockets: row.querySelector(".docker-websockets").checked, http2_enabled: row.querySelector(".docker-http2-enabled").checked, http3_enabled: row.querySelector(".docker-http3-enabled").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, compression_enabled: row.querySelector(".docker-compression-enabled").checked,
|
||||
// Docker publications intentionally expose one compression switch only. Codec selection stays global.
|
||||
compression_zstd: null, compression_br: null, compression_gzip: null, compression_deflate: null,
|
||||
acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-acl-profile"),
|
||||
allow_certificate_mismatch: row.querySelector(".docker-cert-mismatch")?.checked === true,
|
||||
@@ -312,18 +389,35 @@
|
||||
backend: String(assignment?.target_ip || agent.default_target_ip || inventory?.public_ip || "").toLowerCase(), scheme: String(assignment?.scheme || "http"), published: assignment && assignment.enabled !== false ? 0 : 1,
|
||||
};
|
||||
const top = el("div", undefined, "docker-service-head"), publish = input("", "checkbox", "docker-publish"); publish.checked = !!assignment && assignment.enabled !== false;
|
||||
const publishLabel = el("label", undefined, "check docker-publish-field"); publishLabel.append(publish, el("span", t("dockerPublish")));
|
||||
publish.setAttribute("role", "switch");
|
||||
publish.setAttribute("aria-checked", String(publish.checked));
|
||||
const publishLabel = el("label", undefined, "docker-publish-field");
|
||||
publishLabel.append(el("span", t("dockerPublish"), "docker-publish-text"), publish);
|
||||
const meta = el("div", undefined, "docker-service-meta"), technical = service?.technical_name || assignment?.service_key || "-", bindings = serviceBindings(service).map(binding => `${binding.bind_ip || "*"}:${binding.public_port}`).join(", ");
|
||||
meta.append(el("b", technical), el("span", stale ? t("dockerServiceMissing") : `${service.container_name} · ${service.image}`), el("span", stale ? assignment.service_key : `${service.private_port}/tcp → ${bindings || "-"}`));
|
||||
const visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button";
|
||||
const syncVisibility = () => { visibility.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); }; syncVisibility(); visibility.addEventListener("click", () => { const key = row.dataset.viewKey; if (hiddenVhosts.has(key)) hiddenVhosts.delete(key); else hiddenVhosts.add(key); saveHidden(); syncVisibility(); applyViewFilters(); });
|
||||
const openApp = el("a", t("dockerOpenApp"), "outline docker-open-app"); openApp.target = "_blank"; openApp.rel = "noopener noreferrer";
|
||||
const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || ""; };
|
||||
syncOpenApp(legacyHostname(assignment));
|
||||
const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), openApp, visibility); top.append(publishLabel, meta, headActions); row.append(top);
|
||||
const liveLogs = el("button", t("liveLogs"), "outline docker-open-logs"); liveLogs.type = "button";
|
||||
liveLogs.title = t("dockerLiveAccessLogsHelp");
|
||||
liveLogs.addEventListener("click", () => {
|
||||
const route = poolRoutes.find(item => item.agent_id === agent.id && item.service_key === key);
|
||||
if (publish.checked && route?.pool_id) window.rustProxyLogs?.open(route.pool_id);
|
||||
});
|
||||
const syncOpenActions = hostname => {
|
||||
const url = proxiedUrl(agent, hostname);
|
||||
openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || "";
|
||||
liveLogs.hidden = !publish.checked;
|
||||
liveLogs.disabled = !publish.checked || !poolRoutes.some(item => item.agent_id === agent.id && item.service_key === key && item.pool_id);
|
||||
publish.setAttribute("aria-checked", String(publish.checked));
|
||||
};
|
||||
syncOpenActions(legacyHostname(assignment));
|
||||
const quickActions = el("div", undefined, "docker-service-quick-actions"); quickActions.append(openApp, liveLogs);
|
||||
const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), quickActions, visibility); top.append(publishLabel, meta, headActions); row.append(top);
|
||||
|
||||
const editor = el("details", undefined, "docker-service-editor"), editorSummary = el("summary"), editorTitle = el("span", t("dockerConfigureService")), editorHint = el("small", "", "fine docker-config-hint"); editorSummary.append(editorTitle, editorHint); editor.append(editorSummary);
|
||||
const route = el("div", undefined, "docker-route-grid"), host = input(legacyHostname(assignment) || service?.technical_name || "", "text", "docker-hostname"); host.maxLength = 253; host.placeholder = service?.technical_name || "app1";
|
||||
row.dockerSyncActions = () => syncOpenActions(host.value);
|
||||
let aliases; const aliasChanged = () => { updateHint?.(); row.dispatchEvent(new Event("input", { bubbles: true })); }; aliases = aliasEditor(assignment?.aliases || [], aliasChanged); row._readAliases = aliases.read;
|
||||
const target = el("select", undefined, "docker-target-ip"), auto = el("option", agent.default_target_ip ? `${t("dockerBackendIpAuto")} (${agent.default_target_ip})` : t("dockerBackendIpAuto")); auto.value = ""; target.append(auto); for (const [ip, label] of targetOptions(inventory, service, assignment, agent)) { const option = el("option", label); option.value = ip; target.append(option); } target.value = assignment?.target_ip || "";
|
||||
const scheme = el("select", undefined, "docker-scheme"); for (const name of ["http", "https"]) { const option = el("option", name.toUpperCase()); option.value = name; scheme.append(option); } scheme.value = assignment?.scheme || "http";
|
||||
@@ -346,7 +440,6 @@
|
||||
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.
|
||||
const h3Enabled = input("", "checkbox", "docker-http3-enabled"); h3Enabled.checked = assignment ? assignment.http3_enabled !== false : false;
|
||||
const protocolBox = el("details", undefined, "docker-protocol-editor"), protocolSummary = el("summary");
|
||||
protocolSummary.append(el("span", t("dockerFrontendProtocols")), el("small", t("dockerFrontendProtocolsHelp"), "fine"));
|
||||
@@ -358,15 +451,17 @@
|
||||
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);
|
||||
if (agent.enabled !== false && assignment && assignment.enabled !== false) row.append(statisticsSection(agent, key));
|
||||
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); }
|
||||
for (const control of [h2Enabled, h3Enabled]) control.addEventListener("change", updateHint);
|
||||
host.addEventListener("input", () => syncOpenApp(host.value));
|
||||
host.addEventListener("input", () => syncOpenActions(host.value));
|
||||
row.addEventListener("input", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); }); row.addEventListener("change", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); });
|
||||
publish.addEventListener("change", async () => {
|
||||
const wanted = publish.checked; publish.disabled = true; apply.disabled = true;
|
||||
syncOpenActions(host.value);
|
||||
try { const saved = await saveAssignment(agent.id, row, wanted); if (!saved) publish.checked = !wanted; } catch (error) { publish.checked = !wanted; toast(error.message, true); }
|
||||
finally { if (row.isConnected) { publish.disabled = false; apply.disabled = !publish.checked; row.classList.toggle("is-unpublished", !publish.checked); syncOpenApp(host.value); } }
|
||||
finally { publish.setAttribute("aria-checked", String(publish.checked)); if (row.isConnected) { publish.disabled = false; apply.disabled = !publish.checked; row.classList.toggle("is-unpublished", !publish.checked); syncOpenActions(host.value); } }
|
||||
});
|
||||
row.classList.toggle("is-unpublished", !publish.checked); return row;
|
||||
}
|
||||
@@ -390,6 +485,7 @@
|
||||
const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box);
|
||||
}
|
||||
applyViewFilters();
|
||||
renderDockerMetrics();
|
||||
}
|
||||
function sortServiceRows(card) {
|
||||
const holder = card.querySelector(".docker-service-list"); if (!holder) return;
|
||||
@@ -414,9 +510,10 @@
|
||||
card.hidden = rows.length ? visible === 0 : (!!query && !(card.dataset.searchText || "").includes(query));
|
||||
}
|
||||
const toggle = $("docker-show-hidden"); if (toggle) { const key = showHidden ? "dockerHideHidden" : "dockerShowHidden"; toggle.dataset.i18n = key; toggle.textContent = t(key); }
|
||||
syncMetricsToggle();
|
||||
}
|
||||
function syncLiveSwitch() { const control = $("docker-live-refresh"); if (control) control.checked = liveEnabled(); }
|
||||
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); }
|
||||
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor:not(.docker-service-metrics)[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); }
|
||||
let dockerLoading = null, dockerForcePending = false;
|
||||
async function refresh(force = false, context = null) {
|
||||
if (!authenticated) return;
|
||||
@@ -442,13 +539,25 @@
|
||||
if (!authenticated || epoch !== authEpoch || (context?.isCurrent && !context.isCurrent())) return;
|
||||
observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || [];
|
||||
poolIndex = new Map(pools.map(pool => [pool.id, pool]));
|
||||
document.querySelectorAll("#docker-discovery-list .docker-service-row").forEach(row => row.dockerSyncActions?.());
|
||||
renderSettings(); if (force || canLiveRender()) renderDiscovery();
|
||||
else renderDockerMetrics();
|
||||
} catch (error) {
|
||||
if (force && error?.name !== "AbortError" && authenticated && epoch === authEpoch) toast(error.message, true);
|
||||
} finally { if (button) button.disabled = false; }
|
||||
}
|
||||
|
||||
$("docker-refresh")?.addEventListener("click", () => void refresh(true));
|
||||
$("docker-metrics-toggle-all")?.addEventListener("click", () => {
|
||||
const sections = [...document.querySelectorAll("#docker-discovery-list .docker-service-metrics")];
|
||||
const expand = !sections.every(section => section.open);
|
||||
for (const section of sections) {
|
||||
section.open = expand;
|
||||
if (expand) expandedMetrics.add(section.dataset.viewKey);
|
||||
else expandedMetrics.delete(section.dataset.viewKey);
|
||||
}
|
||||
syncMetricsToggle();
|
||||
});
|
||||
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); rustProxyRefresh.sync(); });
|
||||
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
|
||||
$("docker-search")?.addEventListener("input", applyViewFilters);
|
||||
@@ -475,5 +584,5 @@
|
||||
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
|
||||
run: async context => refresh(false, context)
|
||||
});
|
||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, renderStatus: renderDockerMetrics, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); expandedMetrics.clear(); metricHistory.clear(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
})();
|
||||
@@ -622,7 +622,9 @@
|
||||
title="Refresh this view from RustProxy memory every 5 seconds"><input
|
||||
id="docker-live-refresh" type="checkbox" /><span data-i18n="dockerLiveView">Live
|
||||
view</span></label><button class="outline" data-i18n="dockerRefresh"
|
||||
id="docker-refresh" type="button">Refresh agents</button><button class="quiet"
|
||||
id="docker-refresh" type="button">Refresh agents</button><button class="outline"
|
||||
data-i18n="dockerExpandAllMetrics" id="docker-metrics-toggle-all" type="button"
|
||||
aria-expanded="false" disabled>Expand all statistics</button><button class="quiet"
|
||||
data-i18n="dockerAgentsSettings" id="docker-settings-open" type="button">Docker agents
|
||||
settings</button><button class="quiet" data-i18n="dockerShowHidden"
|
||||
id="docker-show-hidden" type="button">Show hidden</button></div>
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
if (data) mergeExpanded(data);
|
||||
if (!dialog.open) return;
|
||||
const selector = $("log-vhost"), selected = selector.value, ids = new Set([...(envelope?.config.vhosts || []).map(v => v.id), ...source().entries.map(e => e.vhost).filter(Boolean)]);
|
||||
if (selected) ids.add(selected);
|
||||
const all = el("option", t("logAllVhosts")); all.value = ""; selector.replaceChildren(all, ...[...ids].sort().map(id => { const o = el("option", id); o.value = id; return o; })); if (ids.has(selected)) selector.value = selected;
|
||||
const entries = filtered(), rows = entries.map(e => {
|
||||
const row = el("tr"); const hints = [];
|
||||
@@ -209,9 +210,6 @@
|
||||
window.rustProxyLogs = { render, open, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); } };
|
||||
})();
|
||||
|
||||
// Reusable ACL profiles. One profile is one access policy: the guided modes
|
||||
// make country and IP/CIDR exceptions read as a single OR expression, while
|
||||
// custom mode retains the full backend model for mixed allow/deny policies.
|
||||
(() => {
|
||||
let editing = null, dirty = false;
|
||||
const form = $("acl-profile-form");
|
||||
|
||||
+85
-2
@@ -5819,18 +5819,101 @@ dialog .window-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docker-service-head-actions .docker-runtime {
|
||||
flex: 1 1 auto;
|
||||
flex: 1 1 230px;
|
||||
}
|
||||
|
||||
.docker-service-head-actions .docker-view-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.docker-publish-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.docker-publish-field input[type=checkbox].docker-publish {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
position: relative;
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--field-line);
|
||||
border-radius: min(3px, var(--rp-radius-control, 3px));
|
||||
background: var(--field-bg);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 2px auto 2px 2px;
|
||||
width: 10px;
|
||||
background: var(--muted);
|
||||
border-radius: min(1px, var(--rp-radius-control, 1px));
|
||||
transition: transform .12s ease;
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish:checked {
|
||||
background: var(--field-bg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish:checked::before {
|
||||
transform: translateX(14px);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.docker-publish-field:has(.docker-publish:disabled) {
|
||||
opacity: .55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.docker-publish-field .docker-publish:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.docker-service-quick-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.docker-open-logs {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.docker-publish-field .docker-publish,
|
||||
.docker-publish-field .docker-publish::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.docker-service-editor {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -53,7 +53,7 @@ const defaults = () => ({
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl_profile_ids: [], acl: null, allow_certificate_mismatch: false, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, content_type_policy: { enabled: false, blocked: [] }, routes: [], headers: {}, acl_profile_id: null, acl_profile_ids: [], acl: null, allow_certificate_mismatch: false, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
|
||||
@@ -260,6 +260,15 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
field(methodFields, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(methodFields, "vhMethodHelp");
|
||||
|
||||
const mimePolicy = value.content_type_policy || {};
|
||||
const mimeFields = subsection(g, "vhContentTypeSection");
|
||||
const mimeEnabled = field(mimeFields, "content-type-block-enabled", "vhContentTypeBlock", mimePolicy.enabled === true, "checkbox");
|
||||
const mimeList = field(mimeFields, "content-type-block-list", "vhContentTypeList", (mimePolicy.blocked || []).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
mimeList.placeholder = "multipart/form-data\napplication/*";
|
||||
const syncMime = () => { mimeList.disabled = !mimeEnabled.checked; };
|
||||
mimeEnabled.addEventListener("change", syncMime); syncMime();
|
||||
note(mimeFields, "vhContentTypeHelp");
|
||||
|
||||
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 });
|
||||
@@ -486,12 +495,14 @@ function collect() {
|
||||
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
|
||||
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
|
||||
}
|
||||
if (yes("content-type-block-enabled") && !lines(str("content-type-block-list")).length) throw new Error(t("vhContentTypeRequired"));
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), allow_certificate_mismatch: yes("allow-certificate-mismatch"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: null,
|
||||
acl_profile_ids: window.rustProxyAclPicker.read(form.querySelector(".vh-acl-profile-choices")),
|
||||
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader(), user_agent_limits: uaRatesReader() },
|
||||
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
|
||||
content_type_policy: { enabled: yes("content-type-block-enabled"), blocked: lines(str("content-type-block-list")).map(value => value.toLowerCase()) },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
|
||||
@@ -253,6 +253,7 @@ pub struct CompiledFrontend {
|
||||
pub backend_timing_header: bool,
|
||||
pub rate_limit: crate::rate_limit::Settings,
|
||||
pub method_policy: crate::vhost::MethodPolicy,
|
||||
pub content_type_policy: crate::vhost::ContentTypePolicy,
|
||||
pub basic_auth: Option<crate::basic_auth::Compiled>,
|
||||
pub metrics: Arc<crate::metrics::VhostMetrics>,
|
||||
pub server_header: crate::settings::ServerHeader,
|
||||
|
||||
+18
-10
@@ -123,15 +123,19 @@ fn compile_routing(
|
||||
// This is important when the backend URL is an IP shared by several vhosts.
|
||||
if pool_settings.health_check.host.is_none()
|
||||
&& (pool_settings.health_check.mode == crate::balance::HealthMode::Http
|
||||
|| host.backends.iter().any(|member| member.health_check.as_ref()
|
||||
.is_some_and(|health| health.mode == crate::balance::HealthMode::Http
|
||||
&& health.host.is_none())))
|
||||
|| host.backends.iter().any(|member| {
|
||||
member.health_check.as_ref().is_some_and(|health| {
|
||||
health.mode == crate::balance::HealthMode::Http && health.host.is_none()
|
||||
})
|
||||
}))
|
||||
{
|
||||
pool_settings.health_check.host = host
|
||||
.frontend
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|name| !name.starts_with("*.") && name.parse::<std::net::IpAddr>().is_err())
|
||||
.find(|name| {
|
||||
!name.starts_with("*.") && name.parse::<std::net::IpAddr>().is_err()
|
||||
})
|
||||
.cloned();
|
||||
}
|
||||
let mut pool = crate::balance::Pool::new(
|
||||
@@ -173,15 +177,17 @@ fn compile_routing(
|
||||
.as_ref()
|
||||
.map(|base| crate::tls::variant(base, &tls_policy, false))
|
||||
.transpose()?;
|
||||
let profile_policies: Vec<crate::acl::ProfileAccess> = crate::acl::profile_ids(
|
||||
host.acl_profile_id.as_deref(), &host.acl_profile_ids,
|
||||
).map(|id| {
|
||||
let profile_policies: Vec<crate::acl::ProfileAccess> =
|
||||
crate::acl::profile_ids(host.acl_profile_id.as_deref(), &host.acl_profile_ids)
|
||||
.map(|id| {
|
||||
let (acl, countries) = acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
|
||||
})?;
|
||||
Ok(crate::acl::ProfileAccess { acl, countries })
|
||||
}).collect::<Result<_>>()?;
|
||||
let (profile_acl, profile_country_access) = profile_policies.first()
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let (profile_acl, profile_country_access) = profile_policies
|
||||
.first()
|
||||
.map(|entry| (entry.acl.clone(), entry.countries.clone()))
|
||||
.unwrap_or_else(|| (None, crate::rate_limit::CountryAccess::default()));
|
||||
let frontend = Arc::new(CompiledFrontend {
|
||||
@@ -205,6 +211,7 @@ fn compile_routing(
|
||||
backend_timing_header: host.backend_timing_header,
|
||||
rate_limit: host.rate_limit.clone(),
|
||||
method_policy: host.method_policy.clone(),
|
||||
content_type_policy: host.content_type_policy.clone(),
|
||||
basic_auth: host.basic_auth.compile()?,
|
||||
server_header: host
|
||||
.server_header
|
||||
@@ -360,7 +367,8 @@ fn compile_routing(
|
||||
}
|
||||
}
|
||||
if host.enabled && route.enabled {
|
||||
let (source, route_pool, backend) = if route.response.is_some() || route.redirect.is_some() {
|
||||
let (source, route_pool, backend) =
|
||||
if route.response.is_some() || route.redirect.is_some() {
|
||||
// Local return/redirect is handled before source dispatch and needs no origin.
|
||||
(CompiledSource::ReverseProxy, None, None)
|
||||
} else {
|
||||
|
||||
+36
-12
@@ -88,10 +88,7 @@ async fn forward_request(
|
||||
.get::<Arc<crate::config::Snapshot>>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state.config.load_full());
|
||||
let internal_dispatch = request
|
||||
.extensions()
|
||||
.get::<InternalFallbackRoute>()
|
||||
.cloned();
|
||||
let internal_dispatch = request.extensions().get::<InternalFallbackRoute>().cloned();
|
||||
// Retain the original URI's shared backing bytes independently of `request`.
|
||||
// A normalized, already-safe path can then be borrowed for the entire
|
||||
// forwarding operation, even after the request URI/headers are rewritten.
|
||||
@@ -115,6 +112,14 @@ async fn forward_request(
|
||||
// rerunning the observation middleware.
|
||||
snapshot.frontend(&host, local.0, tls)
|
||||
};
|
||||
// Host-level denylist: before cache, custom locations, redirects, local
|
||||
// Web Server/PHP-FPM, or any origin connection. No request body is parsed.
|
||||
if frontend.is_some_and(|item| item.content_type_policy.blocks(request.headers())) {
|
||||
return error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"RustProxy: request Content-Type denied by vhost policy\n",
|
||||
);
|
||||
}
|
||||
if snapshot.user_agent_blocking.blocks(request.headers())
|
||||
|| frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers()))
|
||||
{
|
||||
@@ -181,9 +186,9 @@ async fn forward_request(
|
||||
return response;
|
||||
}
|
||||
if !tls {
|
||||
if let Some(response) = frontend.and_then(|frontend| {
|
||||
force_ssl_redirect(frontend, &host, request.uri())
|
||||
}) {
|
||||
if let Some(response) =
|
||||
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -196,7 +201,9 @@ async fn forward_request(
|
||||
&original_host,
|
||||
request.uri(),
|
||||
frontend.is_some_and(|frontend| frontend.tinyauth.is_some()),
|
||||
request.extensions().get::<Arc<crate::forwarding::PeerFormatting>>()
|
||||
request
|
||||
.extensions()
|
||||
.get::<Arc<crate::forwarding::PeerFormatting>>()
|
||||
.map(Arc::as_ref),
|
||||
host_already_validated,
|
||||
) {
|
||||
@@ -237,7 +244,10 @@ async fn forward_request(
|
||||
let mut country: Option<String> = None;
|
||||
if let Some(frontend) = frontend {
|
||||
if capture_country
|
||||
|| frontend.profile_policies.iter().any(|profile| profile.countries.needs_geoip())
|
||||
|| frontend
|
||||
.profile_policies
|
||||
.iter()
|
||||
.any(|profile| profile.countries.needs_geoip())
|
||||
|| frontend.rate_limit.needs_geoip()
|
||||
{
|
||||
country = cloudflare_country
|
||||
@@ -271,7 +281,10 @@ async fn forward_request(
|
||||
&frontend.id,
|
||||
context.client_ip,
|
||||
country.as_deref(),
|
||||
request.headers().get(http::header::USER_AGENT).and_then(|value| value.to_str().ok()),
|
||||
request
|
||||
.headers()
|
||||
.get(http::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
&frontend.rate_limit,
|
||||
) {
|
||||
let mut response = error(
|
||||
@@ -295,7 +308,17 @@ async fn forward_request(
|
||||
);
|
||||
}
|
||||
if cache_http_invalidation_method(request.method()) {
|
||||
return cache_http_invalidation(&state, &snapshot, frontend, &host, request, context.client_ip, peer.0.ip(), context.scheme).await;
|
||||
return cache_http_invalidation(
|
||||
&state,
|
||||
&snapshot,
|
||||
frontend,
|
||||
&host,
|
||||
request,
|
||||
context.client_ip,
|
||||
peer.0.ip(),
|
||||
context.scheme,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some((_redirect, rule)) = redirect {
|
||||
let authority = original_host.to_str().unwrap_or(&host);
|
||||
@@ -434,7 +457,8 @@ async fn forward_request(
|
||||
)
|
||||
});
|
||||
};
|
||||
let fallback_body = match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
|
||||
let fallback_body =
|
||||
match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
|
||||
Ok(body) => body,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
@@ -86,6 +86,45 @@ mod cache_fast_path_tests {
|
||||
config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_type_policy_denies_before_local_web_server_and_cache_and_updates_live() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let mut config = web_config(&directory, true);
|
||||
config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy {
|
||||
enabled: true,
|
||||
blocked: vec!["multipart/form-data".into(), "image/*".into()],
|
||||
};
|
||||
let fixture = Fixture::new(config).await;
|
||||
// Warm the cache before testing a header-denied cache hit.
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK);
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK);
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "multipart/form-data; boundary=example")]).await.status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "IMAGE/PNG")]).await.status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(fixture.send(Method::POST, "/style.css", &[("content-type", "multipart/form-data; boundary=x")], Body::from("sample")).await.status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "text/plain")]).await.status, StatusCode::OK);
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "application/json, multipart/form-data")]).await.status, StatusCode::FORBIDDEN);
|
||||
fixture.change(|config| config.vhosts[0].content_type_policy.enabled = false).await;
|
||||
assert_eq!(fixture.get("/style.css", &[("content-type", "multipart/form-data")]).await.status, StatusCode::OK);
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_type_policy_denies_before_reverse_proxy_upstream_request() {
|
||||
let origin = origin().await;
|
||||
let mut config = config_for(origin.addr, false);
|
||||
config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy {
|
||||
enabled: true, blocked: vec!["multipart/form-data".into()],
|
||||
};
|
||||
let fixture = Fixture::new(config).await;
|
||||
let blocked = fixture.send(Method::POST, "/", &[("content-type", "Multipart/Form-Data; boundary=abc")], Body::from("--abc\r\n")).await;
|
||||
assert_eq!(blocked.status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 0);
|
||||
let allowed = fixture.get("/", &[("content-type", "application/json")]).await;
|
||||
assert_eq!(allowed.status, StatusCode::OK);
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_cache_disabled_keeps_get_head_range_conditionals_and_observability() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
|
||||
@@ -150,6 +150,7 @@ fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 {
|
||||
view.diagnostic_headers = None;
|
||||
view.rate_limit = Default::default();
|
||||
view.method_policy = Default::default();
|
||||
view.content_type_policy = Default::default();
|
||||
view.basic_auth = Default::default();
|
||||
view.tinyauth = Default::default();
|
||||
view.acl = None;
|
||||
|
||||
@@ -91,6 +91,109 @@ impl MethodPolicy {
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
/// Per-vhost request Content-Type filtering. This inspects only headers and
|
||||
/// deliberately never reads the body before deciding whether to reject it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ContentTypePolicy {
|
||||
pub enabled: bool,
|
||||
/// Exact media types (`multipart/form-data`) or type families (`image/*`).
|
||||
pub blocked: Vec<String>,
|
||||
}
|
||||
|
||||
/// MIME media type tokens use the HTTP tchar alphabet, excluding `*` here;
|
||||
/// `type/*` is the only accepted wildcard form in configured patterns.
|
||||
fn mime_token(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 127
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'+-.^_`|~".contains(&byte))
|
||||
}
|
||||
|
||||
impl ContentTypePolicy {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(
|
||||
self.blocked.len() <= 64,
|
||||
"Content-Type policy allows at most 64 blocked media types"
|
||||
);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for value in &mut self.blocked {
|
||||
*value = value.trim().to_ascii_lowercase();
|
||||
let Some((kind, subtype)) = value.split_once('/') else {
|
||||
anyhow::bail!("invalid blocked Content-Type: expected type/subtype");
|
||||
};
|
||||
ensure!(
|
||||
mime_token(kind)
|
||||
&& (subtype == "*" || mime_token(subtype))
|
||||
&& value.len() <= 255,
|
||||
"invalid blocked Content-Type: use type/subtype or type/* without parameters"
|
||||
);
|
||||
ensure!(
|
||||
seen.insert(value.clone()),
|
||||
"duplicate blocked Content-Type in policy"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
!self.enabled || !self.blocked.is_empty(),
|
||||
"enabled Content-Type policy requires at least one blocked media type"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject matching media types before body parsing, upstream routing, and cache lookup.
|
||||
/// Multiple or malformed Content-Type values are ambiguous, so reject them
|
||||
/// when filtering is enabled rather than let the backend interpret them differently.
|
||||
/// An absent Content-Type header is not matched (the policy is not a body WAF).
|
||||
pub fn blocks(&self, headers: &http::HeaderMap) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
let mut values = headers.get_all(http::header::CONTENT_TYPE).iter();
|
||||
let Some(raw) = values.next() else {
|
||||
return false;
|
||||
};
|
||||
if values.next().is_some() {
|
||||
return true;
|
||||
}
|
||||
let Ok(raw) = raw.to_str() else {
|
||||
return true;
|
||||
};
|
||||
// A comma outside a quoted parameter indicates a combined/ambiguous
|
||||
// header. RFC MIME parameters may legitimately contain quoted commas.
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
for byte in raw.bytes() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if quoted && byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
quoted = !quoted;
|
||||
} else if byte == b',' && !quoted {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if quoted || escaped {
|
||||
return true;
|
||||
}
|
||||
let media_type = raw.split(';').next().unwrap_or("").trim();
|
||||
let Some((kind, subtype)) = media_type.split_once('/') else {
|
||||
return true;
|
||||
};
|
||||
if !mime_token(kind) || !mime_token(subtype) {
|
||||
return true;
|
||||
}
|
||||
self.blocked.iter().any(|blocked| {
|
||||
if let Some(prefix) = blocked.strip_suffix("/*") {
|
||||
kind.eq_ignore_ascii_case(prefix)
|
||||
} else {
|
||||
media_type.eq_ignore_ascii_case(blocked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyTimeouts {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (name, value, max) in [
|
||||
@@ -248,6 +351,9 @@ pub struct Vhost {
|
||||
/// Optional allow/block policy for HTTP methods. Vhost and location policies stack.
|
||||
#[serde(default)]
|
||||
pub method_policy: MethodPolicy,
|
||||
/// Optional request Content-Type denylist (normal vhosts only in the panel).
|
||||
#[serde(default)]
|
||||
pub content_type_policy: ContentTypePolicy,
|
||||
#[serde(default)]
|
||||
pub basic_auth: crate::basic_auth::Settings,
|
||||
pub id: String,
|
||||
|
||||
@@ -82,6 +82,58 @@ mod default_listener_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_policy_defaults_off_and_normalizes_exact_and_wildcard_types() {
|
||||
let old: ContentTypePolicy = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(!old.enabled);
|
||||
assert!(!old.blocks(&http::HeaderMap::new()));
|
||||
|
||||
let mut policy: ContentTypePolicy = serde_json::from_value(json!({
|
||||
"enabled": true, "blocked": [" Multipart/Form-Data ", "IMAGE/*", "application/problem+json"]
|
||||
})).unwrap();
|
||||
policy.validate().unwrap();
|
||||
assert_eq!(policy.blocked, vec!["multipart/form-data", "image/*", "application/problem+json"]);
|
||||
let header = |value: &'static str| {
|
||||
let mut map = http::HeaderMap::new();
|
||||
map.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static(value));
|
||||
map
|
||||
};
|
||||
assert!(policy.blocks(&header("MULTIPART/FORM-DATA; boundary=abc123")));
|
||||
assert!(policy.blocks(&header("multipart/form-data;charset=utf-8")));
|
||||
assert!(policy.blocks(&header("multipart/form-data; boundary=\"a,b\"")));
|
||||
assert!(policy.blocks(&header("image/png")));
|
||||
assert!(policy.blocks(&header("application/problem+json")));
|
||||
assert!(!policy.blocks(&header("application/json; charset=utf-8")));
|
||||
assert!(!policy.blocks(&header("application/json; profile=\"a,b\"")));
|
||||
assert!(policy.blocks(&header("application/json; profile=unquoted,second")));
|
||||
assert!(policy.blocks(&header("application/json; profile=\"unterminated")));
|
||||
assert!(!policy.blocks(&header("text/plain")));
|
||||
assert!(!policy.blocks(&http::HeaderMap::new()));
|
||||
let mut double = header("application/json");
|
||||
double.append(http::header::CONTENT_TYPE, http::HeaderValue::from_static("multipart/form-data"));
|
||||
assert!(policy.blocks(&double));
|
||||
assert!(policy.blocks(&header("application/json, multipart/form-data")));
|
||||
assert!(policy.blocks(&header("application/json / text")));
|
||||
|
||||
policy.enabled = false;
|
||||
assert!(!policy.blocks(&header("multipart/form-data")));
|
||||
assert!(!policy.blocks(&double));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_policy_rejects_invalid_entries_and_empty_enabled_policy() {
|
||||
for entry in ["multipart", "*/form-data", "image/p*", "text/plain; charset=utf-8", "a/", "a//b", "image /png"] {
|
||||
let mut policy = ContentTypePolicy { enabled: true, blocked: vec![entry.into()] };
|
||||
assert!(policy.validate().is_err(), "accepted invalid pattern {entry}");
|
||||
}
|
||||
let mut empty = ContentTypePolicy { enabled: true, blocked: vec![] };
|
||||
assert!(empty.validate().is_err());
|
||||
let mut duplicate = ContentTypePolicy { enabled: true, blocked: vec!["TEXT/PLAIN".into(), " text/plain ".into()] };
|
||||
assert!(duplicate.validate().is_err());
|
||||
let mut many = ContentTypePolicy { enabled: true, blocked: vec!["text/plain".into(); 65] };
|
||||
assert!(many.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_policy_normalizes_and_enforces_allow_and_block_modes() {
|
||||
let mut allow: MethodPolicy = serde_json::from_value(json!({
|
||||
|
||||
@@ -99,6 +99,7 @@ impl Vhost {
|
||||
"certificate mismatch override requires an explicitly selected certificate"
|
||||
);
|
||||
self.method_policy.validate()?;
|
||||
self.content_type_policy.validate()?;
|
||||
ensure!(
|
||||
!self.frontend.listener_ids.is_empty(),
|
||||
"vhost requires at least one named listener"
|
||||
|
||||
@@ -7,7 +7,8 @@ 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');
|
||||
// Review-only archives are intentionally optional in reproducible source ZIPs.
|
||||
const unused = fs.existsSync(path.join(root, 'src/admin/web/unused.css')) ? read('src/admin/web/unused.css') : null;
|
||||
const docs = read('src/admin/web/docs.css');
|
||||
const archived = [
|
||||
'auth-ip-card', 'certificate-dialog', 'config-columns', 'overview-grid',
|
||||
@@ -18,7 +19,7 @@ const archived = [
|
||||
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`);
|
||||
if (unused !== null) 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']) {
|
||||
@@ -29,7 +30,7 @@ for (const file of [
|
||||
'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/);
|
||||
if (unused !== null) 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}]`));
|
||||
@@ -37,10 +38,10 @@ for (const style of ['square', 'subtle', 'rounded', 'extra']) {
|
||||
}
|
||||
for (const name of ['app-startup-spin', 'app-startup-reveal']) {
|
||||
assert.ok(active.includes(`@keyframes ${name}`));
|
||||
assert.ok(!unused.includes(`@keyframes ${name}`));
|
||||
if (unused !== null) 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');
|
||||
// The review-only CSS history is not a runtime or packaging dependency.
|
||||
console.log('CSS active/optional archive isolation and corner-style regression: PASS');
|
||||
@@ -0,0 +1,187 @@
|
||||
// Isolated DOM regression for per-Docker-vhost live proxy logs and Publish switch.
|
||||
// No native RustProxy process or browser is required for this check.
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const read = name => fs.readFileSync(path.join(root, name), 'utf8');
|
||||
const docker = read('src/admin/web/docker.js');
|
||||
const css = read('src/admin/web/style.css');
|
||||
const management = read('src/admin/web/management.js');
|
||||
const en = JSON.parse(read('langs/en_US.json')).translations;
|
||||
const pl = JSON.parse(read('langs/pl_pl.json')).translations;
|
||||
for (const pack of [en, pl]) {
|
||||
assert.ok(pack.dockerPublish && pack.liveLogs && pack.dockerLiveAccessLogsHelp);
|
||||
}
|
||||
assert.match(docker, /publish\.setAttribute\("role", "switch"\)/);
|
||||
assert.match(css, /\.docker-publish-field \.docker-publish:checked::before/);
|
||||
assert.match(css, /\.docker-publish-field \.docker-publish:focus-visible/);
|
||||
assert.match(css, /\.docker-service-quick-actions/);
|
||||
assert.match(management, /if \(selected\) ids\.add\(selected\)/);
|
||||
assert.match(read('Cargo.toml'), /^version = "0\.34\.6"$/m);
|
||||
assert.match(read('Cargo.lock'), /name = "rust-proxy"\nversion = "0\.34\.6"/);
|
||||
|
||||
class Node {
|
||||
constructor(tag) {
|
||||
this.tag = tag; this.children = []; this.parentElement = null; this.dataset = {};
|
||||
this.className = ''; this.listeners = {}; this.attributes = {};
|
||||
this.open = false; this.hidden = false; this.disabled = false;
|
||||
this.checked = false; this.value = ''; this._text = ''; this.isConnected = true;
|
||||
const classes = new Set();
|
||||
this.classList = {
|
||||
add: token => classes.add(token), remove: token => classes.delete(token),
|
||||
toggle: (token, state) => { const wanted = state === undefined ? !classes.has(token) : state; if (wanted) classes.add(token); else classes.delete(token); return wanted; },
|
||||
contains: token => classes.has(token),
|
||||
};
|
||||
}
|
||||
append(...children) { this.children.push(...children); children.forEach(child => { if (child instanceof Node) child.parentElement = this; }); }
|
||||
replaceChildren(...children) { this.children = []; this._text = ''; this.append(...children); }
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent ?? String(child)).join(''); }
|
||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
||||
setAttribute(key, value) { this.attributes[key] = String(value); }
|
||||
getAttribute(key) { return this.attributes[key]; }
|
||||
addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); }
|
||||
async emit(name) { for (const fn of this.listeners[name] || []) await fn({target:this}); }
|
||||
click() { return this.emit('click'); }
|
||||
}
|
||||
const controls = new Map();
|
||||
const visits = [], saves = [];
|
||||
let saveResult = true;
|
||||
const context = {
|
||||
window: {rustProxyLogs: {open(id) { visits.push(id); }}, rustProxyAclPicker: {}, rustProxyPreferences: {}},
|
||||
document: {createElementNS: (_ns,tag) => new Node(tag), querySelectorAll: () => []},
|
||||
$: id => controls.get(id) || null,
|
||||
el(tag, content, cls) { const node = new Node(tag); if (content !== undefined) node.textContent = content; if (cls) node.className = cls; return node; },
|
||||
t: key => en[key] || key,
|
||||
locale: () => 'en-US',
|
||||
localStorage: {getItem:() => null},
|
||||
rustProxyRefresh: {register(){}, sync(){}},
|
||||
envelope: {config: {docker_agents:{agents:[]}, listeners:{endpoints:[
|
||||
{id:'http', enabled:true, binding:{address:'0.0.0.0:80',tls:false}},
|
||||
{id:'https', enabled:true, binding:{address:'0.0.0.0:8443',tls:true}},
|
||||
]}}},
|
||||
latestStatus:null, performance:{now:()=>0}, authenticated:false, currentPanel:'overview',
|
||||
toast(message) { throw new Error('Unexpected toast: ' + message); },
|
||||
};
|
||||
vm.createContext(context);
|
||||
assert.ok(docker.trimEnd().endsWith('})();'));
|
||||
const injected = docker.trimEnd().slice(0, -5) + `
|
||||
// Replace the unrelated configuration editors and persistence with controlled stubs.
|
||||
aliasEditor = () => ({node:el('div'), input:el('input'), read:()=>[], count:()=>0});
|
||||
targetOptions = () => [];
|
||||
aclEditor = timeoutEditor = healthEditor = passiveHealthEditor = () => el('details');
|
||||
saveAssignment = async (agentId, row, enabled) => window._saveAssignment(agentId, row, enabled);
|
||||
window._dockerActions = {assignmentRow, proxiedUrl, setRoutes(routes) { poolRoutes = routes; }};
|
||||
})();`;
|
||||
context.window._saveAssignment = async (agentId, row, published) => { saves.push([agentId,row.dataset.key,published]); return saveResult; };
|
||||
vm.runInContext(injected, context);
|
||||
const actions = context.window._dockerActions;
|
||||
const agent = {id:'docker-1',name:'Docker 1',base_domain:'example.test',listener_ids:['http','https'],enabled:true};
|
||||
const service = {key:'container:app:8080/tcp',technical_name:'app-8080',container_name:'app',image:'myapp:v1',private_port:8080,bindings:[{bind_ip:'0.0.0.0',public_port:8080}]};
|
||||
const assignment = {service_key:service.key,hostname:'app',enabled:true};
|
||||
const logId = 'docker-agent-41a1b2c3d4e5';
|
||||
actions.setRoutes([{agent_id:agent.id,service_key:service.key,pool_id:logId}]);
|
||||
const row = actions.assignmentRow(agent,null,service,assignment,false);
|
||||
const [head,editor,metrics] = row.children;
|
||||
assert.equal(editor.tag,'details'); assert.equal(metrics.tag,'details', 'published route retains collapsed metrics');
|
||||
const [publishLabel,,headActions] = head.children;
|
||||
const publish = publishLabel.children[1];
|
||||
assert.equal(publishLabel.className,'docker-publish-field');
|
||||
assert.equal(publish.getAttribute('role'),'switch');
|
||||
assert.equal(publish.checked,true);
|
||||
assert.equal(publish.getAttribute('aria-checked'),'true');
|
||||
const [runtime,quickActions] = headActions.children;
|
||||
assert.equal(runtime.className, 'docker-runtime');
|
||||
const [openApp,openLogs] = quickActions.children;
|
||||
assert.equal(openApp.textContent,en.dockerOpenApp);
|
||||
assert.equal(openApp.href,'https://app.example.test:8443/', 'Open app honors nested listener TLS and port');
|
||||
assert.equal(openLogs.textContent,en.liveLogs);
|
||||
assert.equal(openLogs.title,en.dockerLiveAccessLogsHelp);
|
||||
assert.equal(openLogs.disabled,false);
|
||||
assert.equal(openLogs.hidden,false);
|
||||
function testLiveLogSelection() {
|
||||
// Execute the actual live log dialog controller independently from other admin modules.
|
||||
const start = management.indexOf('(() => {\n const dialog = $("logs-dialog");');
|
||||
assert.ok(start > 0);
|
||||
const end = management.indexOf('\n})();', start) + '\n})();'.length;
|
||||
const logController = management.slice(start, end);
|
||||
class LogNode extends Node {
|
||||
constructor(tag) { super(tag); this.value = ''; }
|
||||
replaceChildren(...children) {
|
||||
super.replaceChildren(...children);
|
||||
if (this.tag === 'select') this.value = this.children[0]?.value || '';
|
||||
}
|
||||
querySelectorAll() { return []; }
|
||||
show() { this.open = true; }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
}
|
||||
const logControls = new Map();
|
||||
const $ = id => {
|
||||
if (!logControls.has(id)) {
|
||||
const tag = ['log-vhost','log-method','log-status','log-outcome'].includes(id) ? 'select' : 'div';
|
||||
logControls.set(id, new LogNode(tag));
|
||||
}
|
||||
return logControls.get(id);
|
||||
};
|
||||
$('log-outcome').value = 'all';
|
||||
const logContext = {
|
||||
document:{createElement:tag => new LogNode(tag)},
|
||||
window:{rustProxyPreferences:{load:async()=>({live_log_columns:['time','vhost']})}},
|
||||
$, t:key => en[key] || key, el(tag,value,cls) {const node=new LogNode(tag); if(value!==undefined) node.textContent=value; if(cls)node.className=cls; return node;},
|
||||
authenticated:true, envelope:{config:{vhosts:[{id:'ordinary-vhost'}]}}, lastLogs:{entries:[]},
|
||||
formatTime:time=>String(time), toast(){throw Error('unexpected toast')},
|
||||
};
|
||||
vm.createContext(logContext);
|
||||
vm.runInContext(logController,logContext);
|
||||
logContext.window.rustProxyLogs.open('docker-agent-41a1b2c3d4e5');
|
||||
assert.equal($('log-vhost').value, 'docker-agent-41a1b2c3d4e5');
|
||||
const entry = (vhost) => ({vhost,time_ms:123,method:'GET',path:'/',host:'app.example.test',status:200,cache:'MISS',outcome:'ok'});
|
||||
// A refresh containing only requests from another Vhost must not remove the chosen ID.
|
||||
logContext.lastLogs = {entries:[entry('ordinary-vhost')]};
|
||||
logContext.window.rustProxyLogs.render();
|
||||
assert.equal($('log-vhost').value,'docker-agent-41a1b2c3d4e5');
|
||||
assert.match($('log-count').textContent, /^0 \/ 1/);
|
||||
logContext.lastLogs = {entries:[entry('ordinary-vhost'),entry('docker-agent-41a1b2c3d4e5')]};
|
||||
logContext.window.rustProxyLogs.render();
|
||||
assert.match($('log-count').textContent, /^1 \/ 2/);
|
||||
assert.equal($('log-vhost').value,'docker-agent-41a1b2c3d4e5');
|
||||
$('log-vhost').value = '';
|
||||
logContext.window.rustProxyLogs.render();
|
||||
assert.match($('log-count').textContent, /^2 \/ 2/, 'manual All vhosts selection still works');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
testLiveLogSelection();
|
||||
await openLogs.click();
|
||||
assert.deepEqual(visits,[logId], 'logs open for the returned generated ID, not the display hostname');
|
||||
publish.checked = false;
|
||||
await publish.emit('change');
|
||||
assert.deepEqual(saves.at(-1),[agent.id,service.key,false]);
|
||||
assert.equal(publish.getAttribute('aria-checked'),'false');
|
||||
assert.equal(openLogs.hidden,true);
|
||||
assert.equal(openApp.hidden,true);
|
||||
assert.equal(publish.disabled,false);
|
||||
publish.checked = true;
|
||||
await publish.emit('change');
|
||||
assert.deepEqual(saves.at(-1),[agent.id,service.key,true]);
|
||||
assert.equal(openLogs.hidden,false);
|
||||
assert.equal(openApp.hidden,false);
|
||||
assert.equal(publish.getAttribute('aria-checked'),'true');
|
||||
saveResult = false;
|
||||
publish.checked = false;
|
||||
await publish.emit('change');
|
||||
assert.equal(publish.checked,true, 'failed unpublish reverts state');
|
||||
assert.equal(publish.getAttribute('aria-checked'),'true', 'ARIA state rolls back');
|
||||
assert.equal(openLogs.hidden,false, 'related action rolls back');
|
||||
saveResult = true;
|
||||
actions.setRoutes([]);
|
||||
const missingRoute = actions.assignmentRow(agent,null,service,assignment,false);
|
||||
const missingLogs = missingRoute.children[0].children[2].children[1].children[1];
|
||||
assert.equal(missingLogs.disabled,true, 'no generated route ID disables logs rather than showing all vhosts');
|
||||
await missingLogs.click();
|
||||
assert.deepEqual(visits,[logId], 'missing ID never opens unfiltered logs');
|
||||
console.log('Docker publish switch, rollback, live log targeting, app links: PASS');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,139 @@
|
||||
// Run with: node tests/docker_vhost_metrics.cjs
|
||||
// Source contracts and isolated DOM/metric sampling regression for Docker vhosts.
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const source = name => fs.readFileSync(path.join(root, name), 'utf8');
|
||||
const docker = source('src/admin/web/docker.js');
|
||||
const core = source('src/admin/web/app/core.js');
|
||||
const html = source('src/admin/web/index.html');
|
||||
const css = source('src/admin/web/style.css');
|
||||
const en = JSON.parse(source('langs/en_US.json')).translations;
|
||||
const pl = JSON.parse(source('langs/pl_pl.json')).translations;
|
||||
|
||||
assert.match(html, /id="docker-metrics-toggle-all"/);
|
||||
assert.match(docker, /if \(agent\.enabled !== false && assignment && assignment\.enabled !== false\) row\.append\(statisticsSection\(agent, key\)\)/);
|
||||
assert.match(docker, /"docker-service-editor docker-service-metrics"/);
|
||||
assert.ok(docker.indexOf('row.append(editor);') < docker.indexOf('row.append(statisticsSection(agent, key))'));
|
||||
for (const key of ['status', 'vhRequests', 'vhActive', 'vhErrors', 'vhLatency', 'vhTraffic', 'vhLifetimeChart']) {
|
||||
assert.ok(docker.includes(`"${key}"`), `missing ordinary Vhost metric: ${key}`);
|
||||
}
|
||||
for (const key of ['dockerTrafficStatistics', 'dockerExpandAllMetrics', 'dockerCollapseAllMetrics']) {
|
||||
assert.ok(en[key] && pl[key], `missing translated label: ${key}`);
|
||||
}
|
||||
for (const className of ['vhost-live-grid', 'vhost-live-metric', 'vhost-chart', 'vhost-chart-line']) {
|
||||
assert.ok(css.includes(`.${className}`), `existing ordinary Vhost style missing: ${className}`);
|
||||
}
|
||||
assert.match(docker, /status\?\.vhost_metrics/);
|
||||
assert.match(docker, /rustProxyRateSampling\.difference/);
|
||||
assert.match(docker, /docker-service-editor:not\(\.docker-service-metrics\)\[open\]/);
|
||||
assert.match(docker, /expandedMetrics\.clear\(\); metricHistory\.clear\(\)/);
|
||||
assert.match(core, /window\.rustProxyDocker\?\.renderStatus\(s\)/);
|
||||
|
||||
// Keep the production module intact. Expose only local functions for this
|
||||
// self-contained DOM stub; no browser, network, or Rust server is involved.
|
||||
class Node {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.dataset = {}; this.className = ''; this.listeners = {}; this.attributes = {}; this.open = false; this.disabled = false; this._text = ''; }
|
||||
append(...children) { this.children.push(...children); }
|
||||
replaceChildren(...children) { this.children = [...children]; this._text = ''; }
|
||||
get textContent() { return this._text + this.children.map(node => node.textContent).join(''); }
|
||||
set textContent(value) { this.replaceChildren(); this._text = String(value); }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return this.attributes[name]; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
dispatch(name) { for (const callback of this.listeners[name] || []) callback({target:this}); }
|
||||
click() { this.dispatch('click'); }
|
||||
}
|
||||
const controls = new Map([['docker-metrics-toggle-all', new Node('button')], ['docker-discovery-list', new Node('div')]]);
|
||||
let sections = [], now = 1000;
|
||||
const document = {
|
||||
hidden: false,
|
||||
createElementNS(ns, tag) { return new Node(tag); },
|
||||
querySelectorAll(selector) { if (selector === '#docker-discovery-list .docker-agent-card') return []; assert.equal(selector, '#docker-discovery-list .docker-service-metrics'); return sections; },
|
||||
addEventListener() { },
|
||||
};
|
||||
const context = {
|
||||
window: {addEventListener(){}, rustProxyPreferences:{}, rustProxyAclPicker:{}},
|
||||
document, performance: {now:() => now},
|
||||
localStorage:{getItem(){return null;}},
|
||||
$: id => controls.get(id) || null,
|
||||
el(tag, value, classes) { const node = new Node(tag); if (value !== undefined) node.textContent = value; if (classes) node.className = classes; return node; },
|
||||
t: key => en[key] || key,
|
||||
fmt: value => Number(value).toLocaleString('en-US', {maximumFractionDigits:1}),
|
||||
rateBytes: value => `${(Number(value)/1024).toFixed(1)} KiB`,
|
||||
locale: () => 'en-US',
|
||||
rustProxyRefresh: {register(){}, current(){return {overview_seconds:3};}},
|
||||
latestStatus:null, authenticated:false, currentPanel:'overview',
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source('src/admin/web/app/rate-sampling.js'), context);
|
||||
const marker = '})();';
|
||||
assert.ok(docker.trimEnd().endsWith(marker));
|
||||
const injected = docker.trimEnd().slice(0, -marker.length) + `
|
||||
window._metricsTest = {
|
||||
statisticsSection, renderDockerMetrics, syncMetricsToggle,
|
||||
setRuntime(routes, pools) { poolRoutes = routes; poolIndex = new Map(pools.map(pool => [pool.id, pool])); },
|
||||
};
|
||||
})();`;
|
||||
vm.runInContext(injected, context);
|
||||
const metrics = context.window._metricsTest;
|
||||
const agent = {id:'docker-1'};
|
||||
const first = metrics.statisticsSection(agent, 'service/a');
|
||||
const second = metrics.statisticsSection(agent, 'service-b');
|
||||
sections = [first, second];
|
||||
const button = controls.get('docker-metrics-toggle-all');
|
||||
metrics.syncMetricsToggle();
|
||||
assert.ok(sections.every(section => !section.open), 'default collapsed');
|
||||
assert.equal(button.textContent, en.dockerExpandAllMetrics);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(first.className, 'docker-service-editor docker-service-metrics');
|
||||
assert.equal(first.children[0].tag, 'summary');
|
||||
assert.equal(first.children[0].children[0].textContent, en.dockerTrafficStatistics);
|
||||
assert.equal(first.children[1].className, 'vhost-live-grid');
|
||||
assert.equal(first.children[1].children.length, 6);
|
||||
button.click();
|
||||
assert.ok(sections.every(section => section.open), 'one click expands every host');
|
||||
assert.equal(button.textContent, en.dockerCollapseAllMetrics);
|
||||
const refreshed = metrics.statisticsSection(agent, 'service/a');
|
||||
assert.equal(refreshed.open, true, 'expanded state survives re-render');
|
||||
button.click();
|
||||
assert.ok(sections.every(section => !section.open), 'second click collapses all');
|
||||
first.open = true; first.dispatch('toggle');
|
||||
assert.equal(button.textContent, en.dockerExpandAllMetrics, 'partial expansion still offers expand all');
|
||||
|
||||
metrics.setRuntime([
|
||||
{agent_id:'docker-1',service_key:'service/a',pool_id:'docker-generated-a'},
|
||||
{agent_id:'docker-1',service_key:'service-b',pool_id:'docker-generated-b'},
|
||||
], [
|
||||
{id:'docker-generated-a',health_mode:'http',members:[{status:'up'}]},
|
||||
{id:'docker-generated-b',health_mode:'tcp',members:[{status:'down'}]},
|
||||
]);
|
||||
const base = {requests:26288, active:7,errors:129,ewma_latency_ms:20.4,bytes_in:15000,bytes_out:2000};
|
||||
metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':base,'docker-generated-b':{...base,requests:22}}});
|
||||
assert.equal(first.dockerMetricView.fields.status.textContent, '1/1 Up');
|
||||
assert.equal(first.dockerMetricView.fields.vhRequests.textContent, '26,288');
|
||||
assert.equal(first.dockerMetricView.fields.vhActive.textContent, '7');
|
||||
assert.equal(first.dockerMetricView.fields.vhErrors.textContent, '129');
|
||||
assert.equal(first.dockerMetricView.fields.vhLatency.textContent, '20.4 ms');
|
||||
assert.match(first.dockerMetricView.chartValue.textContent, /req\/s .* peak/);
|
||||
now += 3000;
|
||||
const increased = {...base, requests:26300, bytes_out:32720};
|
||||
metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':increased,'docker-generated-b':{...base,requests:22}}, backend_pools:[
|
||||
{id:'docker-generated-a',health_mode:'http',members:[{status:'down'}]}
|
||||
]});
|
||||
assert.equal(first.dockerMetricView.fields.vhTraffic.textContent, '10.0 KiB/s');
|
||||
assert.equal(first.dockerMetricView.fields.status.textContent, '0/1 Up', 'live status overrides the less frequent Docker inventory snapshot');
|
||||
assert.match(first.dockerMetricView.chartValue.textContent, /^4\.0 req\/s/);
|
||||
assert.ok(first.dockerMetricView.line.getAttribute('points').length > 5, 'chart receives sampled points');
|
||||
// Repaint of an unchanged status snapshot must not create a false 0 req/s sample.
|
||||
now += 500;
|
||||
metrics.renderDockerMetrics({vhost_metrics:{'docker-generated-a':increased}});
|
||||
assert.match(first.dockerMetricView.chartValue.textContent, /^4\.0 req\/s/);
|
||||
sections = [];
|
||||
context.window.rustProxyDocker.reset();
|
||||
assert.equal(button.disabled, true, 'no active hosts disables expand-all');
|
||||
assert.equal(metrics.statisticsSection(agent, 'service/a').open, false, 'sign-out resets expansion');
|
||||
console.log('Docker vhost statistics, shared classes, all/individual toggle and live counters: PASS');
|
||||
+4
-3
@@ -64,7 +64,8 @@ for (const locale of ["en_US", "pl_pl"]) {
|
||||
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");
|
||||
const currentVersion = source("Cargo.toml").match(/^version = "([^"]+)"/m)?.[1];
|
||||
assert.ok(currentVersion, "Cargo.toml must declare a release version");
|
||||
assert.equal(JSON.parse(source("src/admin/openapi.json")).info.version, currentVersion);
|
||||
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");
|
||||
console.log("0.34.5 UI layout, inactive probe controls, localization and synchronized API version: PASS");
|
||||
@@ -54,7 +54,7 @@ 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/);
|
||||
assert.match(css, /\.refresh-settings-grid\s*>\s*\.check\s*\{[^}]*grid-column:\s*1\s*\/\s*-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"'));
|
||||
@@ -74,7 +74,7 @@ for (const style of ['square', 'subtle', 'rounded', 'extra']) {
|
||||
}
|
||||
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, /\.metric-info\s*>\s*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']) {
|
||||
@@ -83,5 +83,7 @@ for (const locale of ['en_US', 'pl_pl']) {
|
||||
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"/);
|
||||
const currentVersion = source('Cargo.toml').match(/^version = "([^"]+)"/m)?.[1];
|
||||
assert.ok(currentVersion);
|
||||
assert.equal(JSON.parse(source('src/admin/openapi.json')).info.version, currentVersion);
|
||||
console.log('0.34.5 UX follow-up layout, source IDs, access scopes and corner preferences: PASS');
|
||||
@@ -0,0 +1,29 @@
|
||||
// UI contract for ordinary Vhost MIME request filtering + neutral Docker publish toggle.
|
||||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const editor = read('src/admin/web/vhosts/editor.js');
|
||||
const core = read('src/admin/web/vhosts/core.js');
|
||||
const docker = read('src/admin/web/docker.js');
|
||||
const css = read('src/admin/web/style.css');
|
||||
const openapi = JSON.parse(read('src/admin/openapi.json'));
|
||||
for (const language of ['en_US', 'pl_pl']) {
|
||||
const keys = JSON.parse(read(`langs/${language}.json`)).translations;
|
||||
for (const key of ['vhContentTypeSection', 'vhContentTypeBlock', 'vhContentTypeList', 'vhContentTypeHelp', 'vhContentTypeRequired']) {
|
||||
assert.ok(keys[key], `${language}: ${key}`);
|
||||
}
|
||||
}
|
||||
assert.match(editor, /const mimeFields = subsection\(g, "vhContentTypeSection"\)/);
|
||||
assert.match(editor, /mimeList\.disabled = !mimeEnabled\.checked/);
|
||||
assert.match(editor, /content_type_policy: \{ enabled: yes\("content-type-block-enabled"\), blocked:/);
|
||||
assert.match(core, /content_type_policy: \{ enabled: false, blocked: \[\] \}/);
|
||||
assert.doesNotMatch(docker, /content-type-block|vhContentType/);
|
||||
assert.match(css, /\.docker-publish-field\s*\{[^}]*padding: 0;/);
|
||||
assert.match(css, /\.docker-publish:checked::before\s*\{[^}]*var\(--accent\)/);
|
||||
assert.doesNotMatch(css.slice(css.indexOf('/* Compact neutral publish switch;'), css.indexOf('.docker-service-quick-actions {')), /var\(--green\)|var\(--pill-live-line\)|999px/);
|
||||
assert.equal(openapi.info.version, '0.34.6');
|
||||
assert.equal(openapi.components.schemas.Vhost.properties.content_type_policy.properties.blocked.maxItems, 64);
|
||||
console.log('Ordinary Vhost Content-Type editor and neutral Docker switch: PASS');
|
||||
Reference in new issue
Block a user