This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 22:47:13 +02:00
1 parent ee174bc283
commit 52e7ce7b46
24 files changed
+666 -412

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ Release history focused on **features, settings, default behavior, compatibility
## 0.34.4 — Multi-profile ACLs, certificate override and mobile Docker details (source candidate)
- ACL profile picker corners (dropdown, counter, rule cards, policy badges, selected chips and remove buttons) follow the dashboard corner-style setting (Square, Subtle, Rounded, Extra rounded); Original keeps its prior shape. ACL selection and rule evaluation are unchanged.
- Docker agent details now wrap certificate SHA-256 fingerprints on narrow screens.
- Ordinary Vhosts and Docker publications may attach up to eight reusable ACL profiles. Legacy `acl_profile_id` remains loadable; new assignments use `acl_profile_ids`. Among selected profiles, an explicit IP deny or blocked country always denies, explicit IP/country allows are unioned, and unmatched defaults must all permit access. Single-profile behavior is unchanged. Inline Vhost/location/global controls are still additional (AND) restrictions, not bypassed. Docker assignments override inherited agent profiles when they select their own profiles.
- `allow_certificate_mismatch` permits a deliberate, confirmed exception per named certificate assignment on an ordinary Vhost or Docker publication. TLS identity/key checks remain intact. Only explicitly selected hostname/certificate pairs skip RustProxy DNS SAN binding verification. Client hostname validation still fails/warns for a mismatched certificate; do not use as a permanent replacement for a matching certificate.
+18 -15
View File
@@ -2,11 +2,11 @@
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
ad2dc4d62a754442b775ee92f725ee39c68795ff75052cb75d797a95420b7fa2 AGENTS.md
d3af0ee0017c80338d24169a1c5b2962c18f827ab364e859551b2ab2d36c28dc CHANGELOG.md
4e8ca84fb8efe1294e92f3c02ab23dcf9bd2b3924eb2959d771e35ef16411d16 CHANGELOG.md
9076d0c433202e776b22c667d3ba694f5110a182bbea65931026fc8fe7bc7a13 Cargo.lock
9709ce361cf2855ff2aa9ad099764b276e81e87f04feb045160b10f575b04f0c Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
f9f9466a7f3dbe89e6d538bfd6a23a3b9e53fae41db34947672d5b3854ede03d README.md
cdea894a8559ea02cc6d22b6defa43c1c46c1d0f591b00090ffc938d80527d8c 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
b96e15c9968dfb2cb175037828e44a58287924fa4c6917ace9c54d0e40b633d3 docs/ACCESS.md
d363e19e27e3d044939a87a58b08a83e9e7999291fdd0f9da59c028386b442e4 docs/ACCESS.md
64dacde6ce787302b6586f780eae7ef3e30bf81f8072c0b38020ec7a10dc3db0 docs/ACME_PROVIDERS.md
9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
@@ -37,7 +37,7 @@ e6d9feabc7a4fcb9a56a2028accbab19ca44479ee67deeefd0ab37f29c45cf4a docs/CONFIGURA
80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
9fb2d0bcac70d1883dd16557622851a1f0527b3d3569a4b5177f5e6dedd3c74d docs/DASHBOARD.md
1b6ab6a0b20aaafa7750c24c910400cd8f841b566ff0863f86f5228e054df22c docs/DOCKER_DISCOVERY.md
c0b7f122de42c2db9fc1791d1aa2dd5322a9ad2efcf6c9cbda93aabcc7caf31d docs/DOCKER_DISCOVERY.md
dbae1a739874ac7b61e276c1ad6fd7c3465acc95afa4db3751224c8426ad6569 docs/INSTALL.md
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md
@@ -48,7 +48,7 @@ d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.m
7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md
701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md
519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md
890b3f014fff3aa88167c2e8d4159bab7753a521b8ab536c5f6473fa834e569f docs/TRANSPORT.md
5bedc7d8b9fcaa80cacea225b1f6750101a7ac1b34eb9ce58310ec60329e380d docs/TRANSPORT.md
8542c4a5c9d5dfe474459099546ae590b4c4d3f363f0bba9721c6107643c2d3a docs/TROUBLESHOOTING.md
7c8d40210a2f70258c261e05eeb41955d4f65fcafb3159be8cc127d4cf0b5a81 docs/VHOSTS.md
f80fe921f0e93f1833ade5740b102c548210519b3b792d386762a57b92ea54cc docs/WEB_SERVER.md
@@ -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
1718141c7ac2a00c256149b0993414af3c63a07205f80090dc945a12e8fa4576 langs/en_US.json
2c302a0957d6c40300dd6ccef5740524b2e2e5ee314b134aaa06c17164a13cc3 langs/pl_pl.json
30eafa6d04fe09c9193e495599bb90184ed7ca4b23d960f711aed4ecf477edb6 langs/en_US.json
58415a9b4e6380c4a34d505f274d0ab26325b4ff52fd2825e081e4dd00ba55c4 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -142,7 +142,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs
8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs
a2d30b49e2a81cef5c524a092bad98c0d2e76c78c90207327eb94b5ada246873 src/admin/openapi.json
ef072f5af05e712750128f791a4ae45c0d741e3144751ec4c0848b4992e839de src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -152,13 +152,14 @@ d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/oper
4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
48842cddf9a0e5b1da57988af824da96c81258f4c870b31641e0a45d1ed61ece src/admin/router.rs
ae6cc6020b896a54b9ba2dcbc0fbed9f13d289ebd7ef8804008386fca8533e85 src/admin/router.rs
0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs
698dbe70871e599c98e8c54c226bfeba00f2ba6e2f71a0da13b4b502d7721685 src/admin/status.rs
6d3224349d08b1052ca66eb153aba513de53bcb666c6f57f00075509c6b6cc47 src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
5e52252a8e886d8e28eed1d456cdfb7387ea6a193007043662889ff366f6d6f5 src/admin/tests.rs
37cd17ebe7bdc01188ad9fb4941a3e0821eea19b08b6a45cee9650d56bf8301c src/admin/web/acl-picker.js
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
c0d08c5b8fc13dec9bf53fd842d224b9df3874fc3bdf31413e3ab182a8cec2aa src/admin/web/app/backend-dashboard.js
@@ -185,7 +186,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
ff63a9a6dbc55369df9e03760018bec67be60b24b94495e727a7c4fd1216b67c src/admin/web/docker.js
5addc619f310623ff62be1dd4169b2e83328770f542e76b8cef01399f1c43b8f src/admin/web/docker.js
d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css
aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html
6adb291fcc98d516fd9745d9673db16e408854a32cbf41335ae956bda11d46de src/admin/web/docs.js
@@ -218,11 +219,11 @@ a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/
17784875e475d6df42964d7ecfe50ca82b74bad0e942342bc17537e727224796 src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
d4f5886577a7e16b732678a3cb4dbedb87b072179cade91287bad24391b74c8f src/admin/web/style.css
1629afe7e3bbf9f4a511de240f9056c51a8b8962c3ea63cd71d29e2fb5b5f350 src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
fde265c3cdc902f81b6400193d9679e738539f64dee29bb857801b13cbeed8fd src/admin/web/vhosts/core.js
78694b2ac4bd0b3bbd500bb472d6741dfded19aef1cf7c1d2ab6d2afc81330fd src/admin/web/vhosts/editor.js
408f2890f4de6c51b0a9c2da55ad0818716f3f21f799fe1de3db7139ca94e656 src/admin/web/vhosts/editor.js
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js
@@ -309,9 +310,9 @@ c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.r
e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs
b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
93a8a3f6ba12761ddcbd19f22f32fb2c0e6948b660fb774ddd7ebdba9b6562a5 src/docker/client.rs
7806b2ac71b41895df1ef3eb998bd8b0d2bd4ce3ca4f6001782481239b16213e src/docker/model.rs
a4c0a2e325e43a88db3c53c26ab73f15142d8edceb0f4bdf2eaf95fd03c8f7f5 src/docker/reconcile.rs
c6e1abe79e44457599c564a4fa7a31752b009b622bf5271f78e3be2863137dce src/docker/tests.rs
61ee4067da4824513791155326ea3f78d4dd711a486f21cddc44671a3bd0f1d9 src/docker/model.rs
eef783112c8c45c64133edb1205f088179cd394f79f39cd452eb1f53dbffb00d src/docker/reconcile.rs
f0bd0e64bdce72ac77d69805bee17d29776392187a09fd0087f019cc8c3e232f src/docker/tests.rs
61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs
f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs
01db5b1387755fb156f89f6dcec8031023483dd9b7eb17ba6c387e8179af4bd1 src/forwarding/tests.rs
@@ -501,6 +502,8 @@ b35e00e4084b4de2125e9b6fff9699358b881277427bed8706fb2e8286e3cf28 src/web_server
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
c645740e5175e32a9ee743700e99a2f16b6fd894de5f734b84021b774dd747eb src/web_server/serve.rs
dc3a18c14c3cc28519103e860b0f42a59d163db15df3f68b76778cdfa6786e46 src/web_server/tests.rs
cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_picker_corner_styles.cjs
2467a6c2295d3fb2ad84cdb4a85176372953034fef2f4d95dc3bf58f019efe21 tests/acl_picker_dom.cjs
8bfee0027ce317f3427ecdf9193549d4a52db58a7b2b99db7cdeebc458d7d4c0 tests/cache_outcomes.cjs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
+1 -1
View File
@@ -4,7 +4,7 @@ Access control can be configured globally, on a Vhost and on selected Custom loc
## IP, country and DDNS rules
- **IP/CIDR ACL:** allow or deny IPv4/IPv6 networks. Use reusable ACL profiles for settings shared by several domains. A Vhost can select up to eight profiles (`acl_profile_ids`, or one legacy `acl_profile_id`); Docker assignments can override or inherit the agent's profile selection.
- **IP/CIDR ACL:** allow or deny IPv4/IPv6 networks. Use reusable ACL profiles for settings shared by several domains. Both ordinary and Docker Vhosts use a searchable multi-select with selected badges and a preview of IP/CIDR rules, country restrictions and the default policy; the selection changes presentation only, not ACL evaluation. A Vhost can select up to eight profiles (`acl_profile_ids`, or one legacy `acl_profile_id`); Docker assignments can override or inherit the agent's profile selection.
- **DNS/DDNS:** an ACL `allow` rule may use a hostname, such as `allow office.example.ddns.net`; `deny` with DNS is not supported. Records are refreshed periodically; unknown, failed or expired resolutions authorize nobody under the allow rule. This is appropriate for a trusted, controlled DDNS record, not a replacement for login.
- **Country access:** allow-only or blocking based on the configured GeoIP database. Allow-only mode fails closed when a country cannot be determined. Keep the GeoIP database updated in **Settings → GeoIP updates**.
- **Cloudflare client IP:** enable the integration only when traffic really arrives through a controlled Cloudflare/trusted-proxy boundary. Never trust arbitrary client-supplied forwarding headers as an identity proof.
+3 -2
View File
@@ -74,11 +74,12 @@ In **Docker vhosts**, select the service and set:
| **Backend IP/port** | Pick a detected, reachable Docker-host address or the configured automatic option. Docker host/firewall binding must permit RustProxy to connect. |
| **HTTP / HTTPS upstream** | Match the container's actual service protocol; review origin TLS verification and Host behavior. |
| **Frontend listeners / certificate** | Selected through the agent defaults and RustProxy configuration; ensure certificate covers the generated name. |
| **Frontend protocols** | HTTP/1.1 is always available. Enable/disable HTTP/2 and HTTP/3 independently per Docker vhost (both enabled by default). H2/H3 require corresponding frontend listener support; switching them does not change the selected HTTP/HTTPS backend scheme. H3 disabled also removes its advertisement. |
| **Force HTTPS / WebSockets** | Enable only when needed and when the right frontend listeners exist. |
| **Cache / compression / transforms** | Optional; obey the global limits and privacy policy of the application. |
| **Health / ACL / timeouts** | Select up to eight reusable ACL profiles per app or inherit the agent's defaults; optional inline ACL remains independently enforced. Explicit denies and country blocks beat merged allows. Test allowed and blocked clients. |
| **Health / ACL / timeouts** | Select up to eight reusable ACL profiles per app or inherit the agent's defaults. A searchable multi-select previews the profile ID, default action, IP rules and country filters; selected profiles appear as removable badges. Optional inline ACL remains independently enforced. Explicit denies and country blocks beat merged allows. Test allowed and blocked clients. |
To deliberately assign a domain not covered by the agent's **named frontend certificate**, enable **Force certificate mismatch** 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.
To deliberately assign a domain not covered by the agent's **named frontend certificate**, enable **Accept certificate/domain mismatch** in the top-right TLS section of the per-service editor and confirm the risk. This records `allow_certificate_mismatch: true` on that publication only. Without consent, the server rejects the incompatible mapping. The client browser may still reject the TLS handshake; a matching SAN certificate is strongly preferred. The agent's own outbound TLS fingerprint validation is unaffected.
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.
+4
View File
@@ -69,3 +69,7 @@ Idle/read/connect/response-header timeouts and backend connection pools are conf
- **Unexpected HTTP→HTTPS redirect:** inspect Vhost `force_ssl`, selected listeners and actual public scheme.
For listener configuration see [Configuration](CONFIGURATION.md); for safer deployment see [Security](SECURITY.md).
## Docker vhost frontend protocols
A published Docker vhost stores `http2_enabled` and `http3_enabled` (both `true` by default, including older assignments without these fields). Both can be disabled individually in **Docker vhosts → Configure vhost → Frontend protocols**. HTTP/1.1 remains available. These switches control only the frontend transport on listeners selected for the agent; the separate Docker backend HTTP/HTTPS scheme is unchanged. Disabled H2 is not offered by SNI/ALPN; disabled H3 rejects HTTP/3 requests and withdraws `Alt-Svc` where HTTP/3 was previously advertised. A listener must already support a protocol to use it, and adding a new QUIC listener may require a restart.
+28 -6
View File
@@ -1068,14 +1068,36 @@
"aclProfileCountryInvalid": "Country codes must use two-letter ISO codes.",
"aclProfileCountryRequired": "Add at least one country or disable country exceptions.",
"aclProfileDuplicate": "An ACL profile with this ID already exists.",
"vhAclProfile": "ACL profile",
"vhAclProfile": "ACL profiles",
"vhAclProfileNone": "No reusable profile",
"vhAclProfileHelp": "Optional reusable profile. Inline vhost ACL and country rules below are still enforced in addition to the profile.",
"vhAclProfileHelp": "Select up to eight reusable ACL profiles. Hover or open the selector to preview their default action, IP rules and country restrictions. Inline Vhost ACLs and other restrictions still apply separately.",
"multiAclWarning": "Multiple ACL profiles: explicit IP deny and blocked country take priority over allows in every selected profile. Explicit allows are combined (OR); if no rule matches, all profile defaults must allow. Vhost inline ACL, location ACL and global host ACL still apply separately (AND). Avoid mixing allow-all policies with denies: it is easy to expose or accidentally block access.",
"vhCertificateMismatchForce": "Force an incompatible certificate (explicit risk acceptance)",
"vhCertificateMismatchWarning": "This allows RustProxy to publish this name with a certificate that does not cover it. Browsers and TLS clients will still reject or warn about the hostname mismatch. Use only when necessary; obtain a matching certificate whenever possible.",
"vhCertificateMismatchConfirm": "Publish with a certificate that may not cover these domain names? This override disables the certificate/name binding check ONLY for this Vhost. TLS clients may reject the connection. Confirm the risk.",
"dockerCertMismatchForce": "Force certificate mismatch for this Docker Vhost",
"aclPickerChoose": "Select ACL profiles",
"aclPickerSelected": "Selected profiles: {count}",
"aclPickerSearch": "Search profiles, IDs or rules…",
"aclPickerNone": "No profiles selected",
"aclPickerNoProfiles": "Create an ACL profile first in Access control.",
"aclPickerNoMatches": "No matching ACL profiles",
"aclPickerLimit": "Maximum of 8 profiles. Remove one before adding another.",
"aclPickerMoreRules": "more rules",
"aclPickerNoRules": "No IP exceptions",
"aclPickerNoIpRules": "No IP ACL",
"aclPickerDefaultAllow": "Default ALLOW",
"aclPickerDefaultDeny": "Default DENY",
"aclPickerBlockedCountries": "Block country",
"aclPickerAllowedCountries": "Allow country",
"dockerCertSection": "Frontend TLS certificate",
"dockerCertMismatchRisk": "Risk accepted",
"dockerCertMismatchUnavailable": "To accept a mismatch, select a named frontend certificate in Docker agent settings. The inherited default certificate needs no override.",
"dockerFrontendProtocols": "Frontend protocols",
"dockerFrontendProtocolsHelp": "Per Docker vhost; requires support on the agent-selected listeners. Does not change the backend HTTP/HTTPS scheme.",
"dockerHttp1Always": "Always available",
"dockerHttp2Enabled": "HTTP/2 (H2)",
"dockerHttp3Enabled": "HTTP/3 (H3/QUIC)",
"dockerCertMismatchForce": "Accept certificate/domain mismatch for this Docker vhost",
"dockerCertMismatchWarning": "The agent-selected public certificate may not cover this hostname or its aliases. Saving this override allows the binding but client browsers will still show a certificate error. A matching certificate is recommended.",
"dockerCertMismatchConfirm": "Confirm publishing this Docker Vhost with an incompatible TLS certificate? Browsers may reject the connection. This explicitly waives RustProxy hostname coverage validation for this published hostname and its aliases.",
"vhHostAdd": "Add hostname",
@@ -1552,13 +1574,13 @@
"dockerUnhideVhost": "Show in view",
"dockerCertificate": "Frontend certificate",
"dockerCertificateHelp": "Certificate assigned to every generated vhost for this agent. Default uses normal TLS/SNI certificate selection.",
"dockerDefaultAcl": "Default ACL profile",
"dockerDefaultAcl": "Default ACL profiles",
"dockerDefaultAclNone": "No default ACL profile",
"dockerDefaultAclHelp": "Inherited by generated Docker vhosts that do not select their own per-app ACL profile.",
"dockerDefaultAclHelp": "Select up to eight ACL profiles inherited by Docker vhosts without an explicit per-app selection. An empty app selection inherits these defaults.",
"dockerApplyService": "Apply changes",
"dockerAccessControl": "Access control",
"dockerAclSummary": "Optional ACL profile and per-app IP rules",
"dockerAclProfile": "ACL profile",
"dockerAclProfile": "ACL profiles",
"dockerAclProfileNone": "No ACL profile",
"dockerAclProfileInherit": "Agent default",
"dockerAclInline": "Use additional per-app ACL",
+28 -6
View File
@@ -1068,14 +1068,36 @@
"aclProfileCountryInvalid": "Kody krajów muszą mieć dwuliterowy format ISO.",
"aclProfileCountryRequired": "Dodaj co najmniej jeden kraj albo wyłącz wyjątki krajów.",
"aclProfileDuplicate": "Profil ACL o tym ID już istnieje.",
"vhAclProfile": "Profil ACL",
"vhAclProfile": "Profile ACL",
"vhAclProfileNone": "Bez wspólnego profilu",
"vhAclProfileHelp": "Opcjonalny profil wielokrotnego użytku. Indywidualne ACL i reguły krajów vhosta poniżej nadal są dodatkowo egzekwowane.",
"vhAclProfileHelp": "Wybierz maksymalnie 8 profili ACL. Rozwiń listę, by zobaczyć domyślną akcję, reguły IP i ograniczenia krajów. Lokalne ACL Vhosta i pozostałe ograniczenia nadal działają niezależnie.",
"multiAclWarning": "Wiele profili ACL: jawne blokady IP i zablokowane kraje mają pierwszeństwo przed zezwoleniami we wszystkich profilach. Jawne zezwolenia są łączone (LUB); gdy żadna reguła nie pasuje, wszystkie wartości domyślne muszą zezwalać. Lokalne ACL vhosta, lokalizacje i globalne ACL pozostają dodatkowymi ograniczeniami (ORAZ). Nie zaleca się łączenia reguł „zezwól wszystkim” i „blokuj” — łatwo przypadkowo otworzyć albo zablokować dostęp.",
"vhCertificateMismatchForce": "Wymuś niedopasowany certyfikat (świadoma akceptacja ryzyka)",
"vhCertificateMismatchWarning": "RustProxy pozwoli przypisać domenę do certyfikatu, który jej nie obejmuje. Przeglądarki i klienci TLS nadal mogą odrzucić połączenie z powodu niezgodności nazwy. Zalecany jest prawidłowy certyfikat.",
"vhCertificateMismatchConfirm": "Przypisać do tych domen certyfikat, który może ich nie obejmować? Wyjątek dotyczy tylko tego vhosta i nie usuwa ostrzeżeń po stronie przeglądarek. Potwierdź akceptację ryzyka.",
"dockerCertMismatchForce": "Wymuś niezgodny certyfikat dla tego Docker vhosta",
"aclPickerChoose": "Wybierz profile ACL",
"aclPickerSelected": "Wybrane profile: {count}",
"aclPickerSearch": "Szukaj nazwy, ID lub reguły…",
"aclPickerNone": "Nie wybrano profili",
"aclPickerNoProfiles": "Najpierw utwórz profil ACL w kontroli dostępu.",
"aclPickerNoMatches": "Brak pasujących profili ACL",
"aclPickerLimit": "Maksymalnie 8 profili. Usuń jeden przed dodaniem następnego.",
"aclPickerMoreRules": "kolejnych reguł",
"aclPickerNoRules": "Brak wyjątków IP",
"aclPickerNoIpRules": "Brak ACL IP",
"aclPickerDefaultAllow": "Domyślnie ZEZWÓL",
"aclPickerDefaultDeny": "Domyślnie BLOKUJ",
"aclPickerBlockedCountries": "Blokuj kraj",
"aclPickerAllowedCountries": "Zezwól kraj",
"dockerCertSection": "Certyfikat TLS frontendu",
"dockerCertMismatchRisk": "Ryzyko zaakceptowane",
"dockerCertMismatchUnavailable": "Aby wymusić niezgodność, wybierz konkretny certyfikat w ustawieniach agenta Docker. Dziedziczony certyfikat domyślny nie wymaga wyjątku.",
"dockerFrontendProtocols": "Protokoły frontendu",
"dockerFrontendProtocolsHelp": "Ustawienie dla tego Docker Vhosta, zależne od wybranych listenerów agenta. Nie zmienia HTTP/HTTPS do kontenera.",
"dockerHttp1Always": "Zawsze dostępny",
"dockerHttp2Enabled": "HTTP/2 (H2)",
"dockerHttp3Enabled": "HTTP/3 (H3/QUIC)",
"dockerCertMismatchForce": "Zaakceptuj niezgodność domeny z certyfikatem tego Docker Vhosta",
"dockerCertMismatchWarning": "Certyfikat frontendu wybrany dla agenta może nie obejmować tej domeny ani jej aliasów. Wymuszenie pozwala zapisać przypisanie, ale przeglądarka nadal może zgłosić błąd TLS. Zaleca się zgodny certyfikat.",
"dockerCertMismatchConfirm": "Potwierdzić przypisanie niezgodnego certyfikatu do Docker vhosta? Przeglądarki mogą odrzucić połączenie. Wyjątek wyłącza kontrolę zgodności nazw tylko dla tej publikacji i jej aliasów.",
"vhHostAdd": "Dodaj hostname",
@@ -1552,13 +1574,13 @@
"dockerUnhideVhost": "Pokaż w widoku",
"dockerCertificate": "Certyfikat frontendu",
"dockerCertificateHelp": "Certyfikat przypisany do wszystkich generowanych vhostów tego agenta. Domyślna opcja używa standardowego wyboru TLS/SNI.",
"dockerDefaultAcl": "Domyślny profil ACL",
"dockerDefaultAcl": "Domyślne profile ACL",
"dockerDefaultAclNone": "Bez domyślnego profilu ACL",
"dockerDefaultAclHelp": "Dziedziczony przez generowane Docker vhosty, które nie mają wybranego własnego profilu ACL per aplikacja.",
"dockerDefaultAclHelp": "Wybierz maksymalnie 8 profili dziedziczonych przez Docker Vhosty bez własnego wyboru. Brak zaznaczenia przy aplikacji oznacza dziedziczenie.",
"dockerApplyService": "Zastosuj zmiany",
"dockerAccessControl": "Kontrola dostępu",
"dockerAclSummary": "Opcjonalny profil ACL i reguły IP per aplikacja",
"dockerAclProfile": "Profil ACL",
"dockerAclProfile": "Profile ACL",
"dockerAclProfileNone": "Bez profilu ACL",
"dockerAclProfileInherit": "Domyślny agenta",
"dockerAclInline": "Użyj dodatkowego ACL per aplikacja",
+14 -36
View File
@@ -59,17 +59,13 @@ impl Acl {
ensure!(rule.network.len() <= 254, "ACL network is too long");
let target = if let Ok(network) = parse_network(&rule.network) {
if let IpNet::V6(net) = network {
ensure!(
net.addr().to_ipv4_mapped().is_none(),
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"
);
ensure!(net.addr().to_ipv4_mapped().is_none(),
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules");
}
RuleMatch::Ip(network)
} else {
ensure!(
rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
);
ensure!(rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny");
RuleMatch::Dns(dynamic::hostname(&rule.network)?)
};
rules.push((target, rule.action));
@@ -107,16 +103,9 @@ pub fn allows_profiles_with_country(
let mut explicit_allow = false;
let mut defaults_allow = true;
for profile in profiles {
let ip_action = profile
.acl
.as_ref()
.and_then(|acl| acl.explicit_action(peer));
let ip_action = profile.acl.as_ref().and_then(|acl| acl.explicit_action(peer));
let country_listed = country.is_some_and(|code| {
profile
.countries
.countries
.iter()
.any(|value| value.eq_ignore_ascii_case(code))
profile.countries.countries.iter().any(|value| value.eq_ignore_ascii_case(code))
});
if ip_action == Some(Action::Deny)
|| (profile.countries.mode == crate::rate_limit::CountryMode::Block && country_listed)
@@ -126,34 +115,22 @@ pub fn allows_profiles_with_country(
explicit_allow |= ip_action == Some(Action::Allow)
|| (profile.countries.mode == crate::rate_limit::CountryMode::Allow && country_listed);
defaults_allow &= profile.countries.mode != crate::rate_limit::CountryMode::Allow
&& profile
.acl
.as_ref()
.is_none_or(|acl| acl.default == Action::Allow);
&& profile.acl.as_ref().is_none_or(|acl| acl.default == Action::Allow);
}
explicit_allow || defaults_allow
}
/// The legacy singular reference remains supported. Modern clients write the
/// array only; if both exist we require both policies to avoid silent bypass.
pub fn profile_ids<'a>(
legacy: Option<&'a str>,
ids: &'a [String],
) -> impl Iterator<Item = &'a str> {
pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator<Item = &'a str> {
legacy.into_iter().chain(ids.iter().map(String::as_str))
}
pub fn validate_profile_ids(legacy: Option<&str>, ids: &[String]) -> Result<()> {
ensure!(
ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8,
"at most 8 ACL profiles may be assigned"
);
ensure!(ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8, "at most 8 ACL profiles may be assigned");
let mut seen = std::collections::HashSet::new();
for id in profile_ids(legacy, ids) {
ensure!(
crate::config::valid_id(id) && seen.insert(id),
"invalid or duplicate ACL profile id"
);
ensure!(crate::config::valid_id(id) && seen.insert(id), "invalid or duplicate ACL profile id");
}
Ok(())
}
@@ -221,7 +198,9 @@ pub fn allows_with_country(
&& (ip_action == Some(Action::Allow)
|| acl.is_none_or(|value| value.default == Action::Allow))
}
crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed,
crate::rate_limit::CountryMode::Allow => {
ip_action == Some(Action::Allow) || country_listed
}
}
}
/// Validate the already-decoded routing representation used internally after
@@ -266,8 +245,7 @@ pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
);
}
ensure!(
!raw.split('/')
.any(|segment| segment == "." || segment == ".."),
!raw.split('/').any(|segment| segment == "." || segment == ".."),
"ambiguous path segments"
);
Ok(std::borrow::Cow::Borrowed(raw))
+10
View File
@@ -2832,6 +2832,16 @@
"default": true,
"description": "Enable classic HTTP/1.1 WebSocket Upgrade forwarding for this generated Docker vhost."
},
"http2_enabled": {
"type": "boolean",
"default": true,
"description": "Allow frontend HTTP/2 (h2) for this Docker publication when the selected TLS listener supports it. false disables H2 for this generated vhost only; it does not change the backend protocol. Missing fields in existing assignments default to true."
},
"http3_enabled": {
"type": "boolean",
"default": true,
"description": "Allow frontend HTTP/3 (QUIC) and Alt-Svc advertisement when configured on the selected listener. false disables H3 for this generated vhost only. Missing fields in existing assignments default to true. HTTP/1.1 remains available."
},
"cache_enabled": {
"type": "boolean",
"default": false,
+1
View File
@@ -83,6 +83,7 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/management.js"),
include_str!("web/drafts.js"),
include_str!("web/integrations.js"),
include_str!("web/acl-picker.js"),
admin_vhosts_javascript(),
include_str!("web/web-server-profiles.js"),
include_str!("web/web-server.js"),
+84
View File
@@ -0,0 +1,84 @@
"use strict";
window.rustProxyAclPicker = (() => {
const LIMIT = 8;
const profiles = () => envelope?.config?.acl_profiles || [];
const chosenIds = root => [...(root?.querySelectorAll('.acl-picker-option input[type="checkbox"]:checked') || [])].map(input => input.value);
const preview = profile => {
const rules = profile.acl?.rules || [], country = profile.country_access || {};
const lines = rules.slice(0, 3).map(rule => `${rule.action === "deny" ? t("deny") : t("allow")} ${rule.network}`);
if (rules.length > 3) lines.push(`+${rules.length - 3} ${t("aclPickerMoreRules")}`);
if (country.mode === "allow" || country.mode === "block") {
const codes = (country.countries || []).slice(0, 5).join(", ");
const extra = (country.countries || []).length - 5;
lines.push(`${t(country.mode === "block" ? "aclPickerBlockedCountries" : "aclPickerAllowedCountries")}: ${codes}${extra > 0 ? ` +${extra}` : ""}`);
}
if (!lines.length) lines.push(t(profile.acl ? "aclPickerNoRules" : "aclPickerNoIpRules"));
return lines.join(" · ");
};
function create(selected = [], className = "") {
const root = el("div", undefined, `acl-picker ${className}`.trim());
const details = el("details", undefined, "acl-picker-details"), summary = el("summary", undefined, "acl-picker-summary");
const heading = el("span", t("aclPickerChoose"), "acl-picker-heading");
const counter = el("span", "", "acl-picker-counter");
summary.append(heading, counter); details.append(summary);
const dropdown = el("div", undefined, "acl-picker-dropdown");
const search = el("input", "", "acl-picker-search"); search.type = "search"; search.placeholder = t("aclPickerSearch"); search.setAttribute("aria-label", t("aclPickerSearch"));
const options = el("div", undefined, "acl-picker-options");
const empty = el("p", t("aclPickerNoMatches"), "fine acl-picker-empty");
const maxHint = el("small", t("aclPickerLimit"), "fine acl-picker-limit");
dropdown.append(search, options, empty, maxHint); details.append(dropdown);
const selectedTags = el("div", undefined, "acl-picker-selected");
root.append(details, selectedTags);
const items = profiles();
const checks = new Map(), selectedSet = new Set(selected);
if (!items.length) {
const noProfiles = el("p", t("aclPickerNoProfiles"), "fine");
options.append(noProfiles); search.disabled = true;
}
for (const profile of items) {
const label = el("label", undefined, "acl-picker-option");
const check = el("input"); check.type = "checkbox"; check.value = profile.id; check.checked = selectedSet.has(profile.id);
const content = el("span", undefined, "acl-picker-option-content");
const headingRow = el("span", undefined, "acl-picker-option-heading");
const name = el("strong", profile.name || profile.id), identity = el("code", profile.id, "acl-picker-id");
const badge = el("span", t(!profile.acl ? "aclPickerNoIpRules" : profile.acl.default === "allow" ? "aclPickerDefaultAllow" : "aclPickerDefaultDeny"), `acl-picker-policy ${profile.acl?.default === "deny" ? "is-deny" : profile.acl?.default === "allow" ? "is-allow" : "is-none"}`);
headingRow.append(name, identity, badge);
content.append(headingRow, el("small", preview(profile), "acl-picker-rule-preview"));
label.append(check, content); options.append(label); checks.set(profile.id, check);
const allRules = (profile.acl?.rules || []).map(rule => `${rule.action} ${rule.network}`).join(" ");
const countries = (profile.country_access?.countries || []).join(" ");
const searchText = `${profile.name || ""} ${profile.id} ${allRules} ${countries}`.toLocaleLowerCase();
label.dataset.search = searchText;
}
const syncSearch = () => {
const query = search.value.trim().toLocaleLowerCase(); let visible = 0;
for (const label of options.querySelectorAll(".acl-picker-option")) {
label.hidden = !!query && !label.dataset.search.includes(query);
if (!label.hidden) visible++;
}
empty.hidden = visible > 0 || items.length === 0;
};
const sync = () => {
const ids = chosenIds(root), full = ids.length >= LIMIT;
counter.textContent = `${ids.length}/${LIMIT}`;
heading.textContent = ids.length ? t("aclPickerSelected").replace("{count}", ids.length) : t("aclPickerChoose");
selectedTags.replaceChildren();
for (const id of ids) {
const profile = items.find(item => item.id === id);
const tag = el("span", undefined, "acl-picker-tag");
const label = el("span", profile?.name || id); label.title = preview(profile || {});
const remove = el("button", "×", "acl-picker-remove"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${profile?.name || id}`);
remove.addEventListener("click", () => { const box = checks.get(id); box.checked = false; box.dispatchEvent(new Event("change", { bubbles: true })); });
tag.append(label, remove); selectedTags.append(tag);
}
if (!ids.length) selectedTags.append(el("span", t("aclPickerNone"), "fine acl-picker-no-selection"));
for (const check of checks.values()) check.disabled = full && !check.checked;
maxHint.hidden = !full;
};
options.addEventListener("change", event => { if (event.target.matches('input[type="checkbox"]')) sync(); });
search.addEventListener("input", syncSearch);
search.addEventListener("keydown", event => { if (event.key === "Escape") { search.value = ""; syncSearch(); details.open = false; summary.focus(); } });
syncSearch(); sync(); return root;
}
return { create, read: chosenIds, count: root => chosenIds(root).length };
})();
+49 -24
View File
@@ -52,23 +52,25 @@
function checkboxField(key, control) { const field = el("div", undefined, "docker-field docker-checkbox-field"), label = el("label", undefined, "check vh-check"); label.append(control, el("span", t("enabled"))); field.append(el("span", t(key)), label); return field; }
function selectedProfileIds(value) { return [...new Set([value?.acl_profile_id, ...(value?.acl_profile_ids || [])].filter(Boolean))]; }
function profileChoices(initial, className) {
const box = el("div", undefined, `docker-acl-choices ${className}`), chosen = new Set(initial);
for (const profile of envelope?.config?.acl_profiles || []) {
const label = el("label", undefined, "check docker-acl-choice"), control = input("", "checkbox");
control.value = profile.id; control.checked = chosen.has(profile.id);
label.append(control, el("span", profile.name || profile.id)); box.append(label);
}
if (!box.children.length) box.append(el("span", t("dockerDefaultAclNone"), "fine"));
return box;
return window.rustProxyAclPicker.create(initial, className);
}
function readProfileChoices(scope, selector) {
return [...scope.querySelectorAll(`${selector} input[type=checkbox]:checked`)].map(input => input.value);
return window.rustProxyAclPicker.read(scope.querySelector(selector));
}
function profileWarning(choices, inheritedCount = 0) {
const warning = el("p", t("multiAclWarning"), "fine acl-stack-warning");
const sync = () => { warning.hidden = (readProfileChoices(choices, ":scope").length || inheritedCount) < 2; };
const sync = () => {
const selected = window.rustProxyAclPicker.count(choices);
warning.hidden = (selected || inheritedCount) < 2;
};
choices.addEventListener("change", sync); sync(); return warning;
}
function profileField(key, picker, help) {
const wrapper = el("div", undefined, "docker-field docker-profile-field");
wrapper.append(el("span", t(key)), picker);
if (help) wrapper.append(el("small", help, "fine"));
return wrapper;
}
function aliasEditor(initial = [], onChange = () => { }) {
const field = el("div", undefined, "docker-field docker-alias-field host-chip-field"), box = el("div", undefined, "host-chip-editor docker-alias-editor"), inputNode = input("", "text", "docker-alias-input");
field.append(el("span", t("dockerAliases"))); inputNode.maxLength = 253; inputNode.placeholder = "alias1 / app.example.com"; inputNode.setAttribute("aria-label", t("dockerAliasAdd"));
@@ -165,7 +167,7 @@
const certificate = el("select", undefined, "docker-agent-certificate"), defaultCertificate = el("option", t("vhCertificateDefault")); defaultCertificate.value = ""; certificate.append(defaultCertificate);
for (const cert of envelope?.config?.tls?.certificates || []) { const item = el("option", cert.id); item.value = cert.id; certificate.append(item); } certificate.value = value.certificate_id || "";
const defaultAcl = profileChoices(selectedProfileIds(value), "docker-agent-acl-profile");
const defaultAclField = labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"));
const defaultAclField = profileField("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"));
defaultAclField.append(profileWarning(defaultAcl));
const grid = el("div", undefined, "field-grid docker-agent-grid");
grid.append(labeled("dockerAgentId", id), labeled("dockerAgentName", name), labeled("dockerAgentEndpoint", endpoint, t("dockerAgentEndpointHelp")), labeled("dockerAgentToken", token), labeled("dockerTlsFingerprint", fingerprint, t("dockerTlsFingerprintHelp")), labeled("dockerBaseDomain", domain), labeled("dockerPollInterval", poll, t("dockerPollIntervalHelp")), labeled("dockerDefaultTarget", target), labeled("dockerCertificate", certificate, t("dockerCertificateHelp")), defaultAclField);
@@ -258,13 +260,13 @@
const details = el("details", undefined, "docker-acl-editor"), summary = el("summary"); summary.append(el("span", t("dockerAccessControl")), el("small", t("dockerAclSummary"), "fine")); details.append(summary);
const grid = el("div", undefined, "field-grid docker-acl-grid"), profile = profileChoices(selectedProfileIds(assignment), "docker-acl-profile");
const inheritedProfiles = selectedProfileIds(agent);
const profileField = labeled("dockerAclProfile", profile, inheritedProfiles.length ? `${t("dockerAclProfileInherit")}: ${inheritedProfiles.map(id => (envelope?.config?.acl_profiles || []).find(item => item.id === id)?.name || id).join(", ")}` : t("dockerAclProfileNone"));
profileField.append(profileWarning(profile, inheritedProfiles.length));
const profileControl = profileField("dockerAclProfile", profile, inheritedProfiles.length ? `${t("dockerAclProfileInherit")}: ${inheritedProfiles.map(id => (envelope?.config?.acl_profiles || []).find(item => item.id === id)?.name || id).join(", ")}` : t("dockerAclProfileNone"));
profileControl.append(profileWarning(profile, inheritedProfiles.length));
const inline = input("", "checkbox", "docker-acl-inline"); inline.checked = !!assignment?.acl; const inlineLabel = checkboxField("dockerAclInline", inline);
const defaultAction = el("select", undefined, "docker-acl-default"); for (const value of ["deny", "allow"]) { const option = el("option", t(value)); option.value = value; defaultAction.append(option); } defaultAction.value = assignment?.acl?.default || "deny";
const rules = document.createElement("textarea"); rules.className = "docker-acl-rules"; rules.rows = 4; rules.maxLength = 32768; rules.spellcheck = false; rules.value = rulesText(assignment?.acl); rules.placeholder = "allow 10.0.0.0/8\ndeny 0.0.0.0/0";
const sync = () => { defaultAction.disabled = !inline.checked; rules.disabled = !inline.checked; }; inline.addEventListener("change", sync); sync();
grid.append(profileField, inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
grid.append(profileControl, inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
}
function timeoutEditor(assignment) {
const value = { ...defaultProxyTimeouts(), ...(assignment?.proxy_timeouts || {}) }, details = el("details", undefined, "docker-timeout-editor"), summary = el("summary"); summary.append(el("span", t("dockerProxyTimeouts")), el("small", t("dockerProxyTimeoutsHelp"), "fine")); details.append(summary);
@@ -277,7 +279,7 @@
const inline = row.querySelector(".docker-acl-inline")?.checked;
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, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, compression_enabled: row.querySelector(".docker-compression-enabled").checked,
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"),
@@ -335,13 +337,40 @@
const certMismatch = input("", "checkbox", "docker-cert-mismatch"); certMismatch.checked = assignment?.allow_certificate_mismatch === true;
const mismatchField = checkboxField("dockerCertMismatchForce", certMismatch);
const mismatchHelp = el("p", t("dockerCertMismatchWarning"), "fine acl-stack-warning");
const syncMismatch = () => { certMismatch.disabled = !agent.certificate_id; if (!agent.certificate_id) certMismatch.checked = false; mismatchHelp.hidden = !certMismatch.checked; };
certMismatch.addEventListener("change", syncMismatch); syncMismatch(); mismatchField.append(mismatchHelp);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches, mismatchField);
editor.append(route, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
const mismatchUnavailable = el("p", t("dockerCertMismatchUnavailable"), "fine");
const riskPill = el("span", t("dockerCertMismatchRisk"), "acl-picker-policy is-deny docker-cert-risk-pill");
const syncMismatch = () => {
certMismatch.disabled = !agent.certificate_id;
if (!agent.certificate_id) certMismatch.checked = false;
mismatchHelp.hidden = !certMismatch.checked;
mismatchUnavailable.hidden = !!agent.certificate_id;
riskPill.hidden = !certMismatch.checked;
};
certMismatch.addEventListener("change", syncMismatch);
mismatchField.append(mismatchHelp, mismatchUnavailable);
const certificateBar = el("div", undefined, "docker-service-certificate-bar");
const tlsControl = el("div", undefined, "docker-service-certificate-control");
const tlsHeading = el("div", undefined, "docker-service-certificate-title");
tlsHeading.append(el("strong", t("dockerCertSection")), riskPill);
tlsControl.append(tlsHeading, mismatchField); certificateBar.append(tlsControl);
syncMismatch();
const h2Enabled = input("", "checkbox", "docker-http2-enabled"); h2Enabled.checked = assignment?.http2_enabled !== false;
const h3Enabled = input("", "checkbox", "docker-http3-enabled"); h3Enabled.checked = assignment?.http3_enabled !== false;
const protocolBox = el("section", undefined, "docker-protocol-panel");
const protocolHeading = el("div", undefined, "docker-protocol-heading");
protocolHeading.append(el("strong", t("dockerFrontendProtocols")), el("small", t("dockerFrontendProtocolsHelp"), "fine"));
const protocolOptions = el("div", undefined, "docker-protocol-options");
const h1 = el("span", undefined, "docker-protocol-fixed");
h1.append(el("strong", "HTTP/1.1"), el("small", t("dockerHttp1Always"), "fine"));
protocolOptions.append(h1, checkboxField("dockerHttp2Enabled", h2Enabled), checkboxField("dockerHttp3Enabled", h3Enabled));
protocolBox.append(protocolHeading, protocolOptions);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
editor.append(certificateBar, route, protocolBox, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
const actions = el("div", undefined, "docker-service-actions"), apply = el("button", t("dockerApplyService"), "outline docker-apply"); apply.type = "button"; apply.disabled = !publish.checked; apply.addEventListener("click", async () => { apply.disabled = true; try { await saveAssignment(agent.id, row, true); } catch (error) { toast(error.message, true); } finally { if (row.isConnected) apply.disabled = !publish.checked; } }); actions.append(apply); editor.append(actions); row.append(editor);
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
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));
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 () => {
@@ -360,8 +389,6 @@
const state = obs(agent.id), box = el("article", undefined, "card docker-agent-card"); box.dataset.agentId = agent.id; box.dataset.searchText = `${agent.id} ${agent.name || ""} ${agent.base_domain || ""}`.toLowerCase(); const head = el("div", undefined, "card-heading"), left = el("div"); left.append(el("span", agent.id, "small-label"), el("h2", agent.name));
const badges = el("div", undefined, "entity-actions"), connection = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill ${state?.ok ? "live" : ""}`), sec = securityInfo(agent, state); badges.append(el("span", t(sec.key), `pill ${sec.cls}`.trim()), connection); head.append(left, badges); box.append(head);
if (!agent.enabled) { box.append(el("p", t("dockerAgentDisabled"), "fine")); target.append(box); continue; }
// No observation is the normal state while the first background poll is in flight.
// A configured TLS fingerprint alone is not proof that an agent was contacted.
if (!state) box.append(el("p", t("dockerNoObservation"), "fine"));
else if (!state.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state.error || t("dockerUnknownPollError"), "fine")); box.append(error); }
if (!state?.inventory) { const rows = el("div", undefined, "docker-service-list"); for (const item of agent.assignments || []) rows.append(assignmentRow(agent, null, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box); continue; }
@@ -455,8 +482,6 @@
});
rustProxyRefresh.register("docker", {
interval: () => 5,
// Even with local Live view off, observe the first startup result.
// Once every enabled agent has a first result, stop unless Live is enabled.
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
run: async context => refresh(false, context)
});
+170 -64
View File
@@ -12315,7 +12315,8 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
font: 400 10px var(--mono);
}
.docker-agent-summary>span,
/* Docker TLS SHA-256 values must wrap even in narrow mobile details cards. */
.docker-agent-summary > span,
.docker-agent-security-hint,
.docker-fingerprint {
min-width: 0;
@@ -12324,76 +12325,181 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
word-break: break-word;
}
.vh-acl-profile-picker,
.docker-acl-choices {
/* Reusable searchable ACL multi-select: identical in the ordinary vhost editor,
Docker agent defaults and Docker per-service overrides. All internal corners
follow the user-selected control radius; fallbacks preserve Original style. */
.vh-acl-profile-picker, .acl-picker { min-width: 0; width: 100%; }
.vh-acl-profile-picker > strong { display: block; margin-bottom: 8px; }
.acl-picker-details {
border: 1px solid var(--field-line);
background: var(--field-bg);
border-radius: var(--rp-radius-control, 8px);
min-width: 0;
}
.vh-acl-profile-picker>strong {
display: block;
margin-bottom: 8px;
}
.vh-acl-profile-choices,
.docker-acl-choices {
.acl-picker-details[open] { border-color: var(--border-hover); }
.acl-picker-summary {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 5px 14px;
}
.vh-acl-profile-choice,
.docker-acl-choice {
display: inline-flex;
align-items: center;
min-height: 32px;
gap: 7px;
max-width: 100%;
overflow-wrap: anywhere;
gap: 12px;
padding: 10px 12px;
font-size: 12px;
cursor: pointer;
user-select: none;
min-height: 21px;
}
.vh-acl-profile-choice input,
.docker-acl-choice input {
flex: 0 0 auto;
width: auto !important;
min-height: 0 !important;
height: auto !important;
.acl-picker-heading { flex: 1 1 auto; font-weight: 600; min-width: 0; }
.acl-picker-counter {
color: var(--accent);
border: 1px solid var(--pill-line);
border-radius: var(--rp-radius-control, 12px);
padding: 2px 7px;
font: 11px var(--mono);
margin-left: auto;
}
.acl-stack-warning {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.5;
color: var(--muted);
}
.docker-acl-grid .docker-acl-choices,
.docker-agent-grid .docker-acl-choices {
padding: 5px 0;
}
.docker-acl-grid .docker-field:has(.docker-acl-choices) {
grid-column: span 2;
}
.docker-cert-mismatch {
flex: 0 0 auto;
}
@media (max-width: 760px) {
.docker-acl-grid .docker-field:has(.docker-acl-choices) {
grid-column: auto;
}
.docker-agent-summary>span,
.docker-fingerprint {
width: 100%;
}
}
.docker-agent-grid .docker-agent-fingerprint {
min-width: 0;
.acl-picker-dropdown { padding: 10px; border-top: 1px solid var(--line); }
.acl-picker-search, .docker-agent-grid .acl-picker-search {
width: 100%;
box-sizing: border-box;
min-height: 34px;
height: 34px;
margin: 0 0 9px;
}
.acl-picker-options { display: grid; max-height: 270px; overflow-y: auto; gap: 5px; }
.acl-picker-option {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid var(--line);
border-radius: var(--rp-radius-control, 6px);
background: var(--panel);
padding: 9px 10px;
cursor: pointer;
min-width: 0;
}
.acl-picker-option:hover, .acl-picker-option:has(input:focus-visible) { border-color: var(--border-hover); }
.acl-picker-option:has(input:checked) { border-color: var(--pill-live-line); background: var(--nav-selected-bg); }
.acl-picker-option[hidden], .acl-picker-empty[hidden], .acl-picker-limit[hidden] { display: none !important; }
.acl-picker-option input[type=checkbox] {
flex: 0 0 auto;
width: 16px !important;
height: 16px !important;
min-height: 16px !important;
margin: 2px 0 0;
}
.acl-picker-option input:disabled { opacity: .35; }
.acl-picker-option:has(input:disabled) { cursor: not-allowed; opacity: .6; }
.acl-picker-option-content { display: grid; flex: 1; min-width: 0; gap: 5px; }
.acl-picker-option-heading { display: flex; flex-wrap: wrap; gap: 5px 9px; align-items: center; }
.acl-picker-option-heading strong { font-size: 12px; color: var(--text); overflow-wrap: anywhere; }
.acl-picker-id { font: 10px var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.acl-picker-policy {
font: 10px var(--mono);
line-height: 1.4;
border-radius: var(--rp-radius-control, 12px);
padding: 2px 7px;
border: 1px solid var(--line);
color: var(--muted);
white-space: nowrap;
}
.acl-picker-policy.is-deny { border-color: var(--danger-line); color: var(--red); }
.acl-picker-policy.is-allow { border-color: var(--pill-live-line); color: var(--green); }
.acl-picker-rule-preview {
display: block;
line-height: 1.45;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 11px;
}
.acl-picker-selected { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 0; min-width: 0; }
.acl-picker-tag {
display: inline-flex;
align-items: center;
max-width: 100%;
gap: 7px;
padding: 2px 4px 2px 9px;
border-radius: var(--rp-radius-control, 15px);
border: 1px solid var(--pill-live-line);
background: var(--nav-selected-bg);
font-size: 11px;
}
.acl-picker-tag > span { overflow-wrap: anywhere; min-width: 0; }
.acl-picker-tag .acl-picker-remove {
border: none;
border-radius: var(--rp-radius-control, 50%);
background: transparent;
color: var(--muted);
min-width: 20px;
height: 20px;
font-size: 16px;
padding: 0;
cursor: pointer;
}
.acl-picker-tag .acl-picker-remove:hover { background: var(--panel2); color: var(--text); }
.acl-picker-no-selection { font-size: 11px; }
.acl-picker-limit { display: block; padding-top: 7px; }
.acl-stack-warning { margin: 8px 0 0; font-size: 11px; line-height: 1.5; color: var(--notice-text); }
.docker-profile-field { min-width: 0; }
.docker-acl-grid .docker-profile-field { grid-column: span 2; }
.docker-agent-grid .docker-profile-field { grid-column: span 2; }
/* Certificate acceptance is an independent, upper-right security control. */
.docker-service-certificate-bar { display: flex; justify-content: flex-end; margin: 0 0 12px; }
.docker-service-certificate-control {
display: grid;
gap: 5px;
width: min(100%, 510px);
padding: 10px 12px;
border: 1px solid var(--notice-line);
border-radius: 8px;
background: var(--notice-bg);
min-width: 0;
}
.docker-service-certificate-title { display: flex; align-items: center; gap: 9px; }
.docker-service-certificate-title strong { font-size: 11px; }
.docker-cert-risk-pill[hidden] { display: none; }
.docker-service-certificate-control .docker-field > span:first-child { color: var(--text); }
.docker-service-certificate-control .docker-checkbox-field { gap: 3px; }
.docker-service-certificate-control .docker-checkbox-field .check { font-size: 11px; }
.docker-service-certificate-control .acl-stack-warning { color: var(--notice-text); }
.docker-service-certificate-control .fine[hidden] { display: none; }
.docker-cert-mismatch { flex: 0 0 auto; }
.docker-protocol-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 11px 12px;
margin: 10px 0 12px;
min-width: 0;
}
.docker-protocol-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 5px 12px; margin-bottom: 9px; }
.docker-protocol-heading strong { font-size: 12px; }
.docker-protocol-heading small { font-size: 11px; }
.docker-protocol-options { display: flex; flex-wrap: wrap; gap: 8px; }
.docker-protocol-options > * {
display: grid;
align-content: center;
min-width: 130px;
flex: 1 1 145px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--panel2);
padding: 8px 12px;
min-height: 49px;
box-sizing: border-box;
}
.docker-protocol-fixed { gap: 4px; }
.docker-protocol-fixed strong { font-size: 11px; }
.docker-protocol-fixed small { font-size: 10px; }
.docker-protocol-options .docker-checkbox-field > span:first-child { color: var(--text); font-size: 11px; }
.docker-protocol-options .docker-checkbox-field .vh-check { font-size: 11px; }
@media (max-width: 760px) {
.docker-acl-grid .docker-profile-field, .docker-agent-grid .docker-profile-field { grid-column: auto; }
.docker-service-certificate-control { width: 100%; }
.docker-agent-summary > span, .docker-fingerprint { width: 100%; }
.acl-picker-option-heading { align-items: flex-start; }
}
/* Keep the editable SHA-256 input from expanding narrow settings grids. */
.docker-agent-grid .docker-agent-fingerprint { min-width: 0; width: 100%; box-sizing: border-box; }
+3 -14
View File
@@ -83,8 +83,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp);
wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget);
g.append(wwwPanel);
// Persist only the selected canonical host; choosing the www variant does
// not require adding an extra hostname to the vhost manually.
let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
const syncCanonical = () => {
const allHosts = lines(read("hosts").value);
@@ -129,8 +127,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
wwwNoHosts.hidden = bases.length > 0;
wwwRedirect.disabled = !bases.length;
if (!bases.length) wwwRedirect.checked = false;
// Never hide the destination choices: the unchecked state is a preview
// that the operator can configure before enabling the rule.
wwwDetails.hidden = false;
wwwInactive.hidden = wwwRedirect.checked;
wwwRedirect.setAttribute("aria-expanded", "true");
@@ -241,14 +237,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
const selectedProfiles = new Set([value.acl_profile_id, ...(value.acl_profile_ids || [])].filter(Boolean));
const profileBox = el("div", undefined, "vh-wide vh-acl-profile-picker");
profileBox.append(text("strong", "vhAclProfile"));
const choices = el("div", undefined, "vh-acl-profile-choices");
for (const profile of envelope?.config.acl_profiles || []) {
const label = el("label", undefined, "check vh-acl-profile-choice");
const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.name = "acl-profile-choices";
checkbox.value = profile.id; checkbox.checked = selectedProfiles.has(profile.id);
label.append(checkbox, el("span", profile.name || profile.id)); choices.append(label);
}
if (!choices.children.length) choices.append(text("span", "vhAclProfileNone", "fine"));
const choices = window.rustProxyAclPicker.create([...selectedProfiles], "vh-acl-profile-choices");
profileBox.append(choices);
const multiWarning = text("p", "multiAclWarning", "fine acl-stack-warning");
const syncMultiWarning = () => { multiWarning.hidden = choices.querySelectorAll('input:checked').length < 2; };
@@ -406,7 +395,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
}
function collect() {
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
locationsReader();
let paths, acl, headers;
try {
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
@@ -464,7 +453,7 @@ function collect() {
}
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: [...form.querySelectorAll('input[name="acl-profile-choices"]:checked')].map(input => input.value),
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()) },
+31 -108
View File
@@ -48,58 +48,29 @@ pub fn compile_with_active(
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(
config.php_fpm_profiles.len() <= 128,
"too many PHP-FPM profiles (max 128)"
);
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)");
let mut php_profiles = HashMap::new();
for profile in &mut config.php_fpm_profiles {
profile
.validate()
.with_context(|| format!("PHP-FPM profile {}", profile.id))?;
ensure!(
php_profiles
.insert(profile.id.clone(), profile.php.clone())
.is_none(),
"duplicate PHP-FPM profile id"
);
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?;
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(),
"duplicate PHP-FPM profile id");
}
ensure!(
config.web_services.len() <= 128,
"too many Web Services (max 128)"
);
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
let mut web_services = HashMap::new();
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused
// workers. Different directive sets on one socket can leak to the next vhost.
// Report unsafe legacy configurations but keep them loadable for migration.
let mut php_endpoint_policies: HashMap<
String,
(
String,
std::collections::BTreeMap<String, String>,
std::collections::BTreeMap<String, String>,
),
> = HashMap::new();
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new();
for service in &mut config.web_services {
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
service.validate().with_context(|| format!("Web Service {}", service.id))?;
// A profile changes effective runtime settings, not the saved standalone
// PHP configuration. Detaching a profile restores local settings exactly.
let mut effective = service.clone();
if let Some(id) = &service.php_profile_id {
effective.php = php_profiles
.get(id)
.with_context(|| {
format!(
"Web Service {} references unknown PHP-FPM profile {}",
service.id, id
)
})?
.clone();
effective
.validate()
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
effective.php = php_profiles.get(id).with_context(|| {
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
})?.clone();
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
}
if effective.php.enabled {
let php = &effective.php;
@@ -111,25 +82,13 @@ pub fn compile_with_active(
);
}
} else {
php_endpoint_policies.insert(
php.endpoint.clone(),
(
effective.id.clone(),
php.php_values.clone(),
php.php_admin_values.clone(),
),
);
php_endpoint_policies.insert(php.endpoint.clone(), (
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(),
));
}
}
let compiled = Arc::new(
effective
.compile()
.with_context(|| format!("Web Service {}", service.id))?,
);
ensure!(
web_services.insert(service.id.clone(), compiled).is_none(),
"duplicate Web Service id"
);
let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?);
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id");
}
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new();
@@ -308,27 +267,19 @@ pub fn compile_with_active(
// Prevent unbounded DNS ACL work across the entire published configuration.
crate::acl::dynamic::configured_hostnames(&config)?;
for agent in &config.docker_agents.agents {
for profile_id in
crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
{
for profile_id in crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) {
ensure!(
acl_profiles.contains_key(profile_id),
"Docker agent {} references unknown default ACL profile {}",
agent.id,
profile_id
agent.id, profile_id
);
}
for assignment in &agent.assignments {
for profile_id in crate::acl::profile_ids(
assignment.acl_profile_id.as_deref(),
&assignment.acl_profile_ids,
) {
for profile_id in crate::acl::profile_ids(assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids) {
ensure!(
acl_profiles.contains_key(profile_id),
"Docker assignment {} on agent {} references unknown ACL profile {}",
assignment.service_key,
agent.id,
profile_id
assignment.service_key, agent.id, profile_id
);
}
}
@@ -441,9 +392,7 @@ fn validate_route_rewrite(
Ok(())
}
fn validate_location_redirect(
redirect: Option<&crate::vhost::LocationRedirect>,
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
let Some(redirect) = redirect else {
return Ok(None);
};
@@ -459,50 +408,24 @@ fn validate_location_redirect(
);
// Replace capture references with a harmless segment so URL structure can
// be validated before requests reach the data plane.
let authority_end = redirect
.target
.find("://")
.and_then(|start| {
let authority_end = redirect.target.find("://").and_then(|start| {
let rest = &redirect.target[start + 3..];
rest.char_indices()
.find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
.map(|(offset, _)| start + 3 + offset)
.or(Some(redirect.target.len()))
})
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(
!redirect.target[..authority_end].contains('$'),
"location redirect captures are not allowed in the URL authority"
);
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
.expect("static capture regex")
.replace_all(&redirect.target, "capture");
let parsed =
url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
"location redirect target must use http/https and include a host"
);
ensure!(
parsed.username().is_empty() && parsed.password().is_none(),
"location redirect target cannot contain credentials"
);
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
None | Some("") => None,
Some(pattern) => {
ensure!(
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
"invalid location redirect rewrite regex"
);
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
let mut builder = regex::RegexBuilder::new(pattern);
builder
.size_limit(2 * 1024 * 1024)
.dfa_size_limit(2 * 1024 * 1024);
Some(
builder
.build()
.context("invalid location redirect rewrite regex")?,
)
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
Some(builder.build().context("invalid location redirect rewrite regex")?)
}
};
Ok(Some(crate::config::CompiledLocationRedirect {
+14 -21
View File
@@ -1,6 +1,9 @@
impl Snapshot {
// Exact-first lookups with stack-buffered wildcard keys for normal hosts.
fn find_host_pattern<T>(host: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> {
fn find_host_pattern<T>(
host: &str,
mut lookup: impl FnMut(&str) -> Option<T>,
) -> Option<T> {
if let Some(value) = lookup(host) {
return Some(value);
}
@@ -133,7 +136,11 @@ impl Snapshot {
});
}
}
for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
for (candidate, matched_by) in [
(authority, "authority"),
(host, "host"),
(sni, "sni"),
] {
let Some(candidate) = candidate else {
continue;
};
@@ -205,10 +212,7 @@ impl Snapshot {
/// Legacy inspection helper only; network handlers must call select_on.
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
Self::find_host_pattern(host, |pattern| {
let fallback = self
.fallbacks
.get(pattern)
.and_then(|routes| routes.first())?;
let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?;
self.routes
.get(pattern)
.and_then(|routes| select_route(routes, path, |_| true))
@@ -265,25 +269,17 @@ where
prefix
}
impl CompiledRoute {
pub fn redirect_location(
&self,
path: &str,
uri: &http::Uri,
) -> Result<Option<http::HeaderValue>> {
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> {
let Some(spec) = &self.redirect else {
return Ok(None);
};
let target = if let Some(regex) = &spec.rewrite_regex {
ensure!(
regex.is_match(path),
"location redirect rewrite regex did not match"
);
ensure!(regex.is_match(path), "location redirect rewrite regex did not match");
regex.replace(path, spec.target.as_str()).into_owned()
} else {
spec.target.clone()
};
let mut target =
url::Url::parse(&target).context("invalid compiled location redirect target")?;
let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?;
if spec.preserve_query {
if let Some(query) = uri.query() {
let merged = match target.query() {
@@ -293,10 +289,7 @@ impl CompiledRoute {
target.set_query(Some(&merged));
}
}
Ok(Some(
http::HeaderValue::from_str(target.as_str())
.context("invalid redirect Location header")?,
))
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?))
}
/// Return a normalized replacement path for the upstream hop. `None` means
/// preserve the client's exact origin-form path bytes.
+13 -8
View File
@@ -35,6 +35,8 @@ const MAX_SERVICES: usize = 512;
const MAX_ADDRESSES: usize = 64;
const MAX_BINDINGS: usize = 32;
fn yes() -> bool { true }
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
@@ -113,6 +115,12 @@ pub struct Assignment {
/// the per-app redirect switch existed. New UI assignments persist true.
pub force_ssl: Option<bool>,
pub websockets: bool,
/// Frontend HTTP/2 opt-out. Old assignments inherit the enabled default.
#[serde(default = "yes")]
pub http2_enabled: bool,
/// Frontend HTTP/3 opt-out. Requires a configured QUIC listener to be offered.
#[serde(default = "yes")]
pub http3_enabled: bool,
/// Single Docker-vhost dynamic-compression switch. None inherits the global policy.
pub compression_enabled: Option<bool>,
/// Legacy pre-release 0.27.0 fields kept for saved-config compatibility. Docker
@@ -145,6 +153,8 @@ impl Default for Assignment {
preserve_host: true,
force_ssl: None,
websockets: true,
http2_enabled: true,
http3_enabled: true,
compression_enabled: None,
compression_gzip: None,
compression_br: None,
@@ -292,10 +302,7 @@ impl Settings {
if let Some(ip) = agent.default_target_ip {
usable_target_ip(ip).context("invalid Docker agent default target IP")?;
}
crate::acl::validate_profile_ids(
agent.acl_profile_id.as_deref(),
&agent.acl_profile_ids,
)
crate::acl::validate_profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
.context("invalid Docker agent ACL profiles")?;
ensure!(
agent.listener_ids.len() <= 16,
@@ -333,10 +340,8 @@ impl Settings {
usable_target_ip(ip).context("invalid Docker assignment target IP")?;
}
crate::acl::validate_profile_ids(
assignment.acl_profile_id.as_deref(),
&assignment.acl_profile_ids,
)
.context("invalid Docker assignment ACL profiles")?;
assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids,
).context("invalid Docker assignment ACL profiles")?;
ensure!(
!assignment.allow_certificate_mismatch || agent.certificate_id.is_some(),
"Docker certificate mismatch override requires a selected agent certificate"
+7 -8
View File
@@ -67,11 +67,7 @@ pub fn effective_config(
// last successful inventory.
let inventory = agent
.enabled
.then(|| {
observed
.get(&agent.id)
.and_then(|item| item.inventory.as_ref())
})
.then(|| observed.get(&agent.id).and_then(|item| item.inventory.as_ref()))
.flatten();
for assignment in agent
.assignments
@@ -85,7 +81,9 @@ pub fn effective_config(
.services
.iter()
.find(|service| service.key == assignment.service_key)
.and_then(|service| route_target(agent, assignment, inventory, service))
.and_then(|service| {
route_target(agent, assignment, inventory, service)
})
})
.map(|(ip, port)| -> Result<_> {
usable_target_ip(ip)?;
@@ -115,8 +113,7 @@ pub fn effective_config(
.unwrap_or_default();
// A per-publication selection overrides the agent default; an empty
// selection inherits it, preserving legacy configuration behavior.
let override_profiles =
assignment.acl_profile_id.is_some() || !assignment.acl_profile_ids.is_empty();
let override_profiles = assignment.acl_profile_id.is_some() || !assignment.acl_profile_ids.is_empty();
let (acl_profile_id, acl_profile_ids) = if override_profiles {
(&assignment.acl_profile_id, &assignment.acl_profile_ids)
} else {
@@ -128,6 +125,8 @@ pub fn effective_config(
"certificate_id": agent.certificate_id.clone(),
"force_ssl": assignment.force_ssl.unwrap_or(false),
"websockets": assignment.websockets,
"http2_enabled": assignment.http2_enabled,
"http3_enabled": assignment.http3_enabled,
"backends": backends,
// Docker publications intentionally use one compression switch. The enabled
// codec set is inherited from global Response settings.
+63 -20
View File
@@ -44,6 +44,61 @@ mod tests {
}
}
#[test]
fn docker_protocol_flags_default_on_but_can_be_disabled_and_round_trip() {
let legacy: Assignment = serde_json::from_value(serde_json::json!({
"service_key": "container:app:8080/tcp", "hostname": "app"
})).unwrap();
assert!(legacy.http2_enabled && legacy.http3_enabled);
let mut configured = legacy;
configured.http2_enabled = false;
configured.http3_enabled = false;
let serialized = serde_json::to_value(&configured).unwrap();
assert_eq!(serialized["http2_enabled"], false);
assert_eq!(serialized["http3_enabled"], false);
let restored: Assignment = serde_json::from_value(serialized).unwrap();
assert!(!restored.http2_enabled && !restored.http3_enabled);
}
#[test]
fn docker_frontend_protocols_are_per_publication_and_do_not_change_acl() {
let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
desired.vhosts.clear();
desired.docker_agents.agents.push(Agent {
id: "node1".into(),
name: "Node 1".into(),
endpoint: "http://10.0.0.2:9876".into(),
token: "012345678901234567890123".into(),
base_domain: "docker1.lab.local".into(),
listener_ids: vec!["http".into()],
acl_profile_id: Some("office".into()),
assignments: vec![
Assignment {
service_key: "container:app:8080/tcp".into(),
hostname: "app".into(),
http2_enabled: false,
http3_enabled: false,
..Default::default()
},
Assignment {
service_key: "container:second:8080/tcp".into(),
hostname: "second".into(),
..Default::default()
},
],
..Default::default()
});
let result = effective_config(desired, &observation(service(), vec![])).unwrap();
assert_eq!(result.vhosts.len(), 2);
let first = &result.vhosts[0];
assert!(!first.http2_enabled && !first.http3_enabled);
assert!(first.websockets);
assert_eq!(first.acl_profile_id.as_deref(), Some("office"));
let other = &result.vhosts[1];
assert!(other.http2_enabled && other.http3_enabled);
assert_eq!(other.acl_profile_id.as_deref(), Some("office"));
}
#[test]
fn generated_vhost_supports_base_domain_custom_domain_aliases_and_selected_ip() {
let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
@@ -198,6 +253,8 @@ mod tests {
.unwrap();
assert_eq!(assignment.force_ssl, None);
assert!(assignment.websockets);
assert!(assignment.http2_enabled);
assert!(assignment.http3_enabled);
assert!(!assignment.cache_enabled);
assert_eq!(assignment.compression_enabled, None);
assert_eq!(assignment.compression_zstd, None);
@@ -228,14 +285,7 @@ mod tests {
..Default::default()
});
let mut observed = observation(service(), vec![]);
observed
.get_mut("node1")
.unwrap()
.inventory
.as_mut()
.unwrap()
.services
.clear();
observed.get_mut("node1").unwrap().inventory.as_mut().unwrap().services.clear();
let effective = effective_config(desired, &observed).unwrap();
assert_eq!(effective.vhosts.len(), 1);
assert!(effective.vhosts[0].backends.is_empty());
@@ -521,21 +571,14 @@ mod tests {
});
let observed = observation(service(), vec![]);
let inherited = effective_config(desired.clone(), &observed).unwrap();
assert_eq!(
inherited.vhosts[0].acl_profile_id.as_deref(),
Some("legacy")
);
assert_eq!(inherited.vhosts[0].acl_profile_id.as_deref(), Some("legacy"));
assert_eq!(inherited.vhosts[0].acl_profile_ids, vec!["work", "vpn"]);
assert!(inherited.vhosts[0].allow_certificate_mismatch);
desired.docker_agents.agents[0].assignments[0].acl_profile_ids =
vec!["guest".into(), "branch".into()];
desired.docker_agents.agents[0].assignments[0].acl_profile_ids = vec!["guest".into(), "branch".into()];
desired.docker_agents.agents[0].assignments[0].allow_certificate_mismatch = false;
let overridden = effective_config(desired, &observed).unwrap();
assert_eq!(overridden.vhosts[0].acl_profile_id, None);
assert_eq!(
overridden.vhosts[0].acl_profile_ids,
vec!["guest", "branch"]
);
assert_eq!(overridden.vhosts[0].acl_profile_ids, vec!["guest", "branch"]);
assert!(!overridden.vhosts[0].allow_certificate_mismatch);
}
@@ -543,10 +586,10 @@ mod tests {
fn older_docker_assignment_json_has_safe_defaults() {
let assignment: Assignment = serde_json::from_value(serde_json::json!({
"service_key": "container:app:8080/tcp", "hostname": "app", "acl_profile_id": "legacy"
}))
.unwrap();
})).unwrap();
assert_eq!(assignment.acl_profile_id.as_deref(), Some("legacy"));
assert!(assignment.acl_profile_ids.is_empty());
assert!(!assignment.allow_certificate_mismatch);
}
}
+12 -28
View File
@@ -88,7 +88,10 @@ 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.
@@ -178,9 +181,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;
}
}
@@ -193,9 +196,7 @@ 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,
) {
@@ -236,10 +237,7 @@ 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
@@ -273,10 +271,7 @@ 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(
@@ -300,17 +295,7 @@ 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);
@@ -449,8 +434,7 @@ 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,
};
+3 -10
View File
@@ -58,10 +58,7 @@ pub struct MethodPolicy {
}
impl MethodPolicy {
pub fn validate(&mut self) -> Result<()> {
ensure!(
self.methods.len() <= 32,
"method policy allows at most 32 methods"
);
ensure!(self.methods.len() <= 32, "method policy allows at most 32 methods");
let mut seen = std::collections::HashSet::new();
for method in &mut self.methods {
*method = method.trim().to_ascii_uppercase();
@@ -69,10 +66,7 @@ impl MethodPolicy {
!method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(),
"invalid HTTP method in method policy"
);
ensure!(
seen.insert(method.clone()),
"duplicate HTTP method in method policy"
);
ensure!(seen.insert(method.clone()), "duplicate HTTP method in method policy");
}
ensure!(
self.mode == MethodPolicyMode::Off || !self.methods.is_empty(),
@@ -322,8 +316,7 @@ pub struct Vhost {
pub headers: HeaderRules,
#[serde(default)]
pub security_headers: SecurityHeaders,
#[serde(default)]
pub user_agent_blocking: crate::user_agent::Policy,
#[serde(default)] pub user_agent_blocking: crate::user_agent::Policy,
/// Nginx-style upstream timeout controls. Empty values inherit process defaults.
#[serde(default)]
pub proxy_timeouts: ProxyTimeouts,
+13 -41
View File
@@ -71,10 +71,7 @@ impl Vhost {
!self.websocket_rfc9220 || self.http3_enabled,
"RFC 9220 requires HTTP/3 to be enabled for the vhost"
);
ensure!(
!self.frontend.hosts.is_empty(),
"vhost requires at least one hostname"
);
ensure!(!self.frontend.hosts.is_empty(), "vhost requires at least one hostname");
let mut hosts = std::collections::HashSet::new();
for host in &mut self.frontend.hosts {
*host = crate::config::normalize_host_pattern(host)?;
@@ -164,10 +161,7 @@ impl Vhost {
"location cannot define both a local response and a redirect"
);
if let Some(id) = &route.acl_profile_id {
ensure!(
crate::config::valid_id(id),
"invalid location ACL profile id"
);
ensure!(crate::config::valid_id(id), "invalid location ACL profile id");
}
route.country_access.validate()?;
route.method_policy.validate()?;
@@ -205,21 +199,13 @@ impl Vhost {
// Reject cycles at configuration time; a runtime depth guard remains as a
// second line of defense for hot-reload/state inconsistencies.
let mut starts = vec![None];
starts.extend(
self.routes
.iter()
.filter(|route| route.enabled)
.map(|route| Some(route.id.as_str())),
);
starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str())));
for start in starts {
let mut seen = std::collections::HashSet::new();
let mut current = start;
loop {
let key = current.unwrap_or("<main>");
ensure!(
seen.insert(key.to_owned()),
"recursive error fallback loop detected"
);
ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected");
let fallback = match current {
None => self.error_fallback.as_ref(),
Some(id) => self
@@ -237,9 +223,7 @@ impl Vhost {
if let Some(custom) = &member.health_check {
let mut settings = self.load_balancing.clone();
settings.health_check = custom.clone();
settings
.validate()
.with_context(|| format!("backend {} health check", member.id))?;
settings.validate().with_context(|| format!("backend {} health check", member.id))?;
}
}
self.proxy_timeouts.validate()?;
@@ -260,8 +244,7 @@ impl Vhost {
"choose either Tinyauth or Basic authentication"
);
self.pages.validate()?;
self.transform
.resolve(&crate::transform::Settings::default())?;
self.transform.resolve(&crate::transform::Settings::default())?;
self.security_headers.compile()?;
self.user_agent_blocking.compile()?;
self.headers.compile()?;
@@ -336,10 +319,8 @@ pub fn validate_listener(listener: Listener) -> Result<()> {
/// counterpart must both be routable, even if only one was configured.
fn www_partner(host: &str) -> Result<String> {
if let Some(bare) = host.strip_prefix("www.") {
ensure!(
!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname"
);
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname");
crate::config::normalize_host(bare)
} else {
crate::config::normalize_host(&format!("www.{host}"))
@@ -347,23 +328,14 @@ fn www_partner(host: &str) -> Result<String> {
}
impl Vhost {
fn www_canonical_host(&self) -> Result<&str> {
let canonical = self
.www_redirect_target
.as_deref()
let canonical = self.www_redirect_target.as_deref()
.or_else(|| self.frontend.hosts.first().map(String::as_str))
.ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?;
ensure!(
!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname"
);
ensure!(!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname");
let partner = www_partner(canonical)?;
ensure!(
self.frontend
.hosts
.iter()
.any(|host| host == canonical || host == &partner),
"www redirect target or its www/non-www partner must belong to this vhost"
);
ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner),
"www redirect target or its www/non-www partner must belong to this vhost");
Ok(canonical)
}
+24
View File
@@ -0,0 +1,24 @@
// Run with: node tests/acl_picker_corner_styles.cjs
// The shared ACL picker must respect all dashboard corner-style presets without
// relying on a separate radius setting or changing its selection behaviour.
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const css = fs.readFileSync(path.resolve(__dirname, "../src/admin/web/style.css"), "utf8");
const parts = [
".acl-picker-details", ".acl-picker-counter", ".acl-picker-option",
".acl-picker-policy", ".acl-picker-tag", ".acl-picker-tag .acl-picker-remove"
];
for (const selector of parts) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const block = css.match(new RegExp(`${escaped}\\s*\\{([^}]+)\\}`));
assert.ok(block, `Missing shared picker style: ${selector}`);
assert.match(block[1], /border-radius:\s*var\(--rp-radius-control,\s*[^)]+\);/, `${selector} must use the dashboard control radius`);
}
for (const [preset, radius] of Object.entries({ square: 0, subtle: 4, rounded: 8, extra: 12 })) {
const rule = css.match(new RegExp(`html\\[data-corner-style=${preset}\\]\\s*\\{([^}]+)\\}`));
assert.ok(rule, `Missing corner-style preset: ${preset}`);
assert.match(rule[1], new RegExp(`--rp-radius-control:\\s*${radius}px;`), `Unexpected radius for ${preset}`);
}
console.log("ACL picker corner styles: PASS (six surfaces, four presets, Original fallbacks)");
+72
View File
@@ -0,0 +1,72 @@
// Run with: node tests/acl_picker_dom.cjs. Tiny DOM stub for picker state/regression.
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
const path = require("node:path");
class DomEvent {
constructor(type, options = {}) { this.type = type; this.bubbles = !!options.bubbles; this.target = null; }
}
class DomNode {
constructor(tag) { this.tag = tag; this.className = ""; this.children = []; this.parent = null; this.dataset = {}; this.handlers = new Map(); this.hidden = false; this.disabled = false; this.checked = false; this.value = ""; this.type = ""; this._text = ""; }
get textContent() { return this._text + this.children.map(c => c.textContent).join(""); }
set textContent(text) { this.replaceChildren(); this._text = String(text); }
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
replaceChildren(...children) { for (const child of this.children) child.parent = null; this.children = []; this._text = ""; this.append(...children); }
setAttribute() { }
focus() { }
addEventListener(type, handler) { if (!this.handlers.has(type)) this.handlers.set(type, []); this.handlers.get(type).push(handler); }
dispatchEvent(event) { if (!event.target) event.target = this; for (const handler of this.handlers.get(event.type) || []) handler(event); if (event.bubbles && this.parent) this.parent.dispatchEvent(event); }
click() { this.dispatchEvent(new DomEvent("click", {bubbles:true})); }
hasClass(name) { return this.className.split(/\s+/).includes(name); }
matches(selector) { return selector === 'input[type="checkbox"]' && this.tag === "input" && this.type === "checkbox"; }
all() { return this.children.flatMap(child => [child, ...child.all()]); }
querySelectorAll(selector) {
const descendants = this.all();
if (selector === '.acl-picker-option input[type="checkbox"]:checked') return descendants.filter(n => n.tag === "input" && n.type === "checkbox" && n.checked && n.parent?.hasClass("acl-picker-option"));
if (selector === '.acl-picker-option input[type="checkbox"]') return descendants.filter(n => n.tag === "input" && n.type === "checkbox" && n.parent?.hasClass("acl-picker-option"));
const value = selector.match(/^\.acl-picker-option input\[value="([^"]+)"\]$/);
if (value) return descendants.filter(n => n.tag === "input" && n.value === value[1] && n.parent?.hasClass("acl-picker-option"));
const cls = selector.match(/^\.([\w-]+)$/);
if (cls) return descendants.filter(n => n.hasClass(cls[1]));
throw Error("Unsupported test selector " + selector);
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
}
const profiles = Array.from({length:10}, (_,i) => ({
id: `profile_${i}`, name: i === 0 ? "Office networks" : `Profile ${i}`,
acl: {default: "deny", rules:[{network:`10.${i}.0.0/16`,action:"allow"},{network:"192.0.2.0/24",action:"deny"}]},
country_access: {mode: i === 0 ? "block" : "off", countries: i === 0 ? ["RU"] : []}
}));
const context = {
window: {},
envelope: {config:{acl_profiles:profiles}},
el(tag, text, cls) { const node = new DomNode(tag); if (text !== undefined) node.textContent = text; if (cls) node.className = cls; return node; },
t: key => key,
Event: DomEvent,
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.resolve(__dirname, "../src/admin/web/acl-picker.js"), "utf8"), context);
const picker = context.window.rustProxyAclPicker;
const root = picker.create(["profile_0", "profile_1"], "vh-acl-profile-choices");
assert.deepEqual(Array.from(picker.read(root)), ["profile_0", "profile_1"]);
assert.equal(root.querySelectorAll(".acl-picker-tag").length, 2);
assert.match(root.textContent, /10\.0\.0\.0\/16/);
assert.match(root.textContent, /RU/);
const search = root.querySelector(".acl-picker-search");
search.value = "Office"; search.dispatchEvent(new DomEvent("input", {bubbles:true}));
assert.equal(root.querySelectorAll(".acl-picker-option").filter(node => !node.hidden).length, 1);
search.value = ""; search.dispatchEvent(new DomEvent("input", {bubbles:true}));
root.querySelector(".acl-picker-remove").click();
assert.deepEqual(Array.from(picker.read(root)), ["profile_1"]);
for (const check of root.querySelectorAll('.acl-picker-option input[type="checkbox"]').slice(2,9)) {
check.checked = true;
check.dispatchEvent(new DomEvent("change", {bubbles:true}));
}
assert.equal(picker.count(root), 8);
assert.equal(root.querySelector('.acl-picker-option input[value="profile_9"]').disabled, true);
root.querySelector(".acl-picker-remove").click();
assert.equal(root.querySelector('.acl-picker-option input[value="profile_9"]').disabled, false);
const docker = picker.create([], "docker-acl-profile");
assert.deepEqual(Array.from(picker.read(docker)), []); // empty override inherits agent profiles
console.log("ACL picker DOM regression: PASS (choices, preview, search, badges, limit and inheritance)");