This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 22:09:50 +02:00
1 parent 1fd81eaf5f
commit ee174bc283
36 files changed
+908 -183

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# RustProxy source and documentation maintenance # RustProxy source and documentation maintenance
Current source version: **0.34.3**. The Cargo package and binary are named `rust-proxy`. Current source version: **0.34.4**. The Cargo package and binary are named `rust-proxy`.
Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior. Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior.
+8 -1
View File
@@ -6,7 +6,14 @@
# Changelog # Changelog
Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.3. Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.4.
## 0.34.4 — Multi-profile ACLs, certificate override and mobile Docker details (source candidate)
- 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.
- The source release needs native Rust compilation and target-system acceptance tests before production use.
## 0.34.3 — QUIC socket buffering, live HTTP/3 settings and cache outcome mix (source candidate) ## 0.34.3 — QUIC socket buffering, live HTTP/3 settings and cache outcome mix (source candidate)
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]] [[package]]
name = "rust-proxy" name = "rust-proxy"
version = "0.34.3" version = "0.34.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rust-proxy" name = "rust-proxy"
version = "0.34.3" version = "0.34.4"
edition = "2021" edition = "2021"
rust-version = "1.99" rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration" description = "RustProxy - independent reverse proxy with local administration"
+8 -2
View File
@@ -1,4 +1,4 @@
# RustProxy 0.34.3 # RustProxy 0.34.4
RustProxy combines a reverse proxy, static/PHP web server, certificate management and traffic monitoring in one service with a browser administration panel. It supports several domains and backends without requiring a separate proxy configuration language. RustProxy combines a reverse proxy, static/PHP web server, certificate management and traffic monitoring in one service with a browser administration panel. It supports several domains and backends without requiring a separate proxy configuration language.
@@ -16,6 +16,12 @@ RustProxy combines a reverse proxy, static/PHP web server, certificate managemen
Features and default settings vary by component; configure only the capabilities you need. The [documentation index](docs/README.md) covers each workflow, including prerequisites, security boundaries, troubleshooting and API access. Features and default settings vary by component; configure only the capabilities you need. The [documentation index](docs/README.md) covers each workflow, including prerequisites, security boundaries, troubleshooting and API access.
## 0.34.4
- Docker vhosts: SHA-256 certificate fingerprints wrap correctly in mobile agent details.
- Ordinary and Docker vhosts support up to eight ACL profiles with deterministic explicit deny priority and merged explicit allows, while keeping standalone ACL layers restrictive. Existing singular ACL configuration remains supported.
- Certificate-to-domain mismatch can be explicitly accepted per ordinary or Docker vhost (with a warning/confirmation); normal validation stays enforced without this override, and TLS clients may still reject the certificate.
## 0.34.3 ## 0.34.3
- HTTP/3 now explicitly requests **4 MiB effective UDP receive and send buffers** per QUIC listener (configurable as `udp_receive_buffer_bytes` and `udp_send_buffer_bytes` in Settings > HTTP/3). On Linux the kernel can cap them using `net.core.rmem_max` / `net.core.wmem_max`; RustProxy logs the effective values and a warning if the target is not met. Setting either value to `0` leaves the OS default. Changing UDP buffers requires service restart. - HTTP/3 now explicitly requests **4 MiB effective UDP receive and send buffers** per QUIC listener (configurable as `udp_receive_buffer_bytes` and `udp_send_buffer_bytes` in Settings > HTTP/3). On Linux the kernel can cap them using `net.core.rmem_max` / `net.core.wmem_max`; RustProxy logs the effective values and a warning if the target is not met. Setting either value to `0` leaves the OS default. Changing UDP buffers requires service restart.
@@ -68,4 +74,4 @@ The authenticated **`/docs`** page includes the guides and a separate interactiv
## Status of this source archive ## Status of this source archive
Version **0.34.3** is provided as a **source release candidate**. The archive includes the 0.34.2 tests plus HTTP/3 socket-buffer and live-settings regressions, and a browser chart calculation test. Native compilation, full Rust tests and target-VM throughput testing must pass before a production release can be declared. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md). Version **0.34.4** is provided as a **source release candidate**. Configuration, ACL and TLS changes require native compilation and integration testing on the target systems before a production deployment is declared. See [Installation](docs/INSTALL.md) and [Roadmap](ROADMAP.md).
+34 -34
View File
@@ -1,12 +1,12 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
00e914cd6a8f8962d857b92bdfa172aa7ce0bdbcafcebe8c9c67f452ac8479f2 AGENTS.md ad2dc4d62a754442b775ee92f725ee39c68795ff75052cb75d797a95420b7fa2 AGENTS.md
a5ff03509541769856ed56c98370a1dce3a0f059b15128a6a4495f8d4e00fe57 CHANGELOG.md d3af0ee0017c80338d24169a1c5b2962c18f827ab364e859551b2ab2d36c28dc CHANGELOG.md
789f77a2ecb443f171197d002c0911779bf1a8d8f0c151a18a6a4ee54d8c97eb Cargo.lock 9076d0c433202e776b22c667d3ba694f5110a182bbea65931026fc8fe7bc7a13 Cargo.lock
b6cf2ee2017e6a0ad99f66a809713f031b7b5480a4c97d74e2ed2c07c5c4f210 Cargo.toml 9709ce361cf2855ff2aa9ad099764b276e81e87f04feb045160b10f575b04f0c Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
06a8e6bdf44aaa434fe4b9767b9f336188317954a01c99174fcacb770c1c4682 README.md f9f9466a7f3dbe89e6d538bfd6a23a3b9e53fae41db34947672d5b3854ede03d README.md
ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
@@ -27,7 +27,7 @@ b7d0466f70b541a705d234fc8a88b99b6197fdd5e682d3a288a78c76764358cf docker-agent/m
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
b0eaa2cfe4f6e86582f67891e457591ae650d15145e025d85cab43e37c883ac8 docs/ACCESS.md b96e15c9968dfb2cb175037828e44a58287924fa4c6917ace9c54d0e40b633d3 docs/ACCESS.md
64dacde6ce787302b6586f780eae7ef3e30bf81f8072c0b38020ec7a10dc3db0 docs/ACME_PROVIDERS.md 64dacde6ce787302b6586f780eae7ef3e30bf81f8072c0b38020ec7a10dc3db0 docs/ACME_PROVIDERS.md
9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md 9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md
2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md
@@ -37,20 +37,20 @@ e6d9feabc7a4fcb9a56a2028accbab19ca44479ee67deeefd0ab37f29c45cf4a docs/CONFIGURA
80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md 80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md
dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md
9fb2d0bcac70d1883dd16557622851a1f0527b3d3569a4b5177f5e6dedd3c74d docs/DASHBOARD.md 9fb2d0bcac70d1883dd16557622851a1f0527b3d3569a4b5177f5e6dedd3c74d docs/DASHBOARD.md
5a8d311c7ab36af9fb84964585bd974b92d8ea4979829b5735fb834ef10156ce docs/DOCKER_DISCOVERY.md 1b6ab6a0b20aaafa7750c24c910400cd8f841b566ff0863f86f5228e054df22c docs/DOCKER_DISCOVERY.md
dbae1a739874ac7b61e276c1ad6fd7c3465acc95afa4db3751224c8426ad6569 docs/INSTALL.md dbae1a739874ac7b61e276c1ad6fd7c3465acc95afa4db3751224c8426ad6569 docs/INSTALL.md
37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md 37001cbf8ed248ca05ec40f12dd9876d33f6e0c97dcb21eb2e6f53c3d97a60bb docs/INTEGRATIONS.md
8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md 8c5007550ebae8408cbb72a75b2af9894578a1b9acda6bd34bc5f533916ddf72 docs/LOGS.md
888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md 888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md
e4ffd659be1e6c445b582a76643a46408699ef1e929d3a4c6e741d8146ded031 docs/MONITORING.md e4ffd659be1e6c445b582a76643a46408699ef1e929d3a4c6e741d8146ded031 docs/MONITORING.md
d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md
6cd363d5679d5da50069b545f9b3084fcf9838dc7be5d6cd108c356f2f94b905 docs/README.md 14039f58802eddd023bd3ff91aae63cd45aa37685e040d77c271d0bc8dc43aa0 docs/README.md
7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md 7c47aed9c178e8c707b98d1b20ec985c1170f193de0c7b10b682e76da576e764 docs/RESPONSE_DELIVERY.md
701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md 701023a4cdcac145dcb2bc5bb709afe79599a5d70cbda6fdfe19ab39ad703bfc docs/SECURITY.md
519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md 519f9c45fe31ede5afdfb8c4e4ef05f17540843ac93fe8b05ff6c4fbea7bcf68 docs/STREAMS.md
890b3f014fff3aa88167c2e8d4159bab7753a521b8ab536c5f6473fa834e569f docs/TRANSPORT.md 890b3f014fff3aa88167c2e8d4159bab7753a521b8ab536c5f6473fa834e569f docs/TRANSPORT.md
8542c4a5c9d5dfe474459099546ae590b4c4d3f363f0bba9721c6107643c2d3a docs/TROUBLESHOOTING.md 8542c4a5c9d5dfe474459099546ae590b4c4d3f363f0bba9721c6107643c2d3a docs/TROUBLESHOOTING.md
23765b0d3e32766966e922aa79951b8bc94fc93c0ffa38824e9dfc7e6cd4a1aa docs/VHOSTS.md 7c8d40210a2f70258c261e05eeb41955d4f65fcafb3159be8cc127d4cf0b5a81 docs/VHOSTS.md
f80fe921f0e93f1833ade5740b102c548210519b3b792d386762a57b92ea54cc docs/WEB_SERVER.md f80fe921f0e93f1833ade5740b102c548210519b3b792d386762a57b92ea54cc docs/WEB_SERVER.md
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json 5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json 2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
@@ -76,8 +76,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md
3b176f3983616312589c4cb0e47365658bca7af13a4f8f3be264b4d3b6c82c16 langs/en_US.json 1718141c7ac2a00c256149b0993414af3c63a07205f80090dc945a12e8fa4576 langs/en_US.json
ecfc9babcae633bfd16a1b686d14bb35641ae6fc1b9bff8f247d54db6b788c49 langs/pl_pl.json 2c302a0957d6c40300dd6ccef5740524b2e2e5ee314b134aaa06c17164a13cc3 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -113,10 +113,10 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
9bf4e4a61824f82b773b4e8ffa0909b9cf0a42bfc14d695bd64da240cc634a61 src/acl.rs 7f953ac05756e6e5d08e1b73835ed6a1226ec8e9439bc519e5ed04bcf3f4b492 src/acl.rs
63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs 63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs
4803e44de39b872a5b3b55f9ca9e81c066ba52cb8f6adb879fd57c5f94b93e89 src/acl/dynamic/tests.rs 4803e44de39b872a5b3b55f9ca9e81c066ba52cb8f6adb879fd57c5f94b93e89 src/acl/dynamic/tests.rs
5e77c7e8d9781f6a8ff3d3f09da4dbcd5277b241901ff17c6c8c2703f2abbcef src/acl/tests.rs 5caa062edfa2eb9e53ba0193089a7cd3204b4c9851564dbd4881c26b7a29347a src/acl/tests.rs
6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs 6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs
587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs 587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs
3f158f46e80b403b658b4e9b1e3710e38c70e47b2d476b8223d9c394ffeeba95 src/acme/model.rs 3f158f46e80b403b658b4e9b1e3710e38c70e47b2d476b8223d9c394ffeeba95 src/acme/model.rs
@@ -142,7 +142,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs 4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs
8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs 8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs
fe938fa4c606daf4dd0e501e97f7f143f0cc0fd83b33a87b43d20fe1c62ef431 src/admin/openapi.json a2d30b49e2a81cef5c524a092bad98c0d2e76c78c90207327eb94b5ada246873 src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -185,7 +185,7 @@ f1a764691f3e2cdb7c6c43e37dae78249561c017a1b74e973a378cb780b72835 src/admin/web/
560a8e047182fbcee08aa4f8d999400a8a5ddca0b6a4640efd46f6b5c34fde91 src/admin/web/dashboard-defaults.json 560a8e047182fbcee08aa4f8d999400a8a5ddca0b6a4640efd46f6b5c34fde91 src/admin/web/dashboard-defaults.json
14213011cfb6ac334c8711b76bbdde97588a7b7ba4eb2592a1e387f877d6cbe0 src/admin/web/dashboard-widgets.json 14213011cfb6ac334c8711b76bbdde97588a7b7ba4eb2592a1e387f877d6cbe0 src/admin/web/dashboard-widgets.json
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
d01a2e48f344fe074c1e63ee0a9bbf7151dfe26140690418535197e13a2a16d7 src/admin/web/docker.js ff63a9a6dbc55369df9e03760018bec67be60b24b94495e727a7c4fd1216b67c src/admin/web/docker.js
d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css
aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html
6adb291fcc98d516fd9745d9673db16e408854a32cbf41335ae956bda11d46de src/admin/web/docs.js 6adb291fcc98d516fd9745d9673db16e408854a32cbf41335ae956bda11d46de src/admin/web/docs.js
@@ -210,7 +210,7 @@ a6d073e94d7fff31b5d590759a5b390558da95e7e37353bf308dbbf302228cc9 src/admin/web/
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html
c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js
097fdb41aa1cd526c4d9bc65e502b05a62e664ef9fff8e69f706b93b0b124dc2 src/admin/web/management.js 48de12a10260927c6598977ef921fe3f2e4234072960ab84aa61bc6329f762b6 src/admin/web/management.js
4481a592fcce9a2aa1db5e4a25619e19fd459c39039b86c0549dfc427eec1b45 src/admin/web/partials/overview-charts.html 4481a592fcce9a2aa1db5e4a25619e19fd459c39039b86c0549dfc427eec1b45 src/admin/web/partials/overview-charts.html
a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/partials/overview-metrics.html a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/partials/overview-metrics.html
3928afce2ed6d5f2d3e29475dd29e4c3bd3c6df180ad5119beaff0c7a58e3b3b src/admin/web/prometheus-token.js 3928afce2ed6d5f2d3e29475dd29e4c3bd3c6df180ad5119beaff0c7a58e3b3b src/admin/web/prometheus-token.js
@@ -218,15 +218,15 @@ a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/
17784875e475d6df42964d7ecfe50ca82b74bad0e942342bc17537e727224796 src/admin/web/settings.js 17784875e475d6df42964d7ecfe50ca82b74bad0e942342bc17537e727224796 src/admin/web/settings.js
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js c13cffe18a616fe5faa5ee945ab0d94d8d392ae8f32474c62fe41010e063ddb1 src/admin/web/streams.js
005b814a4deb2d98fb4d55f37579b153f47a2b91b143d0bd80137059b44b7e3c src/admin/web/style.css d4f5886577a7e16b732678a3cb4dbedb87b072179cade91287bad24391b74c8f src/admin/web/style.css
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js
d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js fde265c3cdc902f81b6400193d9679e738539f64dee29bb857801b13cbeed8fd src/admin/web/vhosts/core.js
1f0c6cad61554851b8f10e0a888348c3a2467af188f2369d9236aa2272698033 src/admin/web/vhosts/editor.js 78694b2ac4bd0b3bbd500bb472d6741dfded19aef1cf7c1d2ab6d2afc81330fd src/admin/web/vhosts/editor.js
8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js 8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js
b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/vhosts/protocol.js
2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js 2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js
bb93e2017f40e3a45ff7f4ca92e4188bb464f432704b6d2c39da9d9621b7984c src/admin/web/vhosts/render.js 69c8848159dcc7c4d93f49de3be75553cbecd0731d64302e2b644ae61196e704 src/admin/web/vhosts/render.js
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js 4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
fd476445a88eb40270e706814eee50cbfbba4069a26dd843c095367e55a147a1 src/admin/web/web-server.js fd476445a88eb40270e706814eee50cbfbba4069a26dd843c095367e55a147a1 src/admin/web/web-server.js
53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs 53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs
@@ -293,12 +293,12 @@ e0146bf5d2e2e33aedcccd9205e5bd457559c9d77dc7ce266f8c165d1b867f0f src/compressio
2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs 2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs
7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs 7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs
56b3d37e7447995bec2fdda1c17dcc74b60958a63ca88b88ff57e56347d8b0b9 src/config/backend.rs 56b3d37e7447995bec2fdda1c17dcc74b60958a63ca88b88ff57e56347d8b0b9 src/config/backend.rs
beb831a208c4e5f7ebe8327686b1e0edc66608c7032f895cb4fc3366b189aae0 src/config/compile.rs cb882e390bc429df11a1ca7c3a1d4175ffbb70c4e821b168c5d7a11f55163d14 src/config/compile.rs
a4bb70f4da2ae1fa890e65ec15f87e919e2288637dc0eb2c383234e8babbcb05 src/config/internal_routing.rs a4bb70f4da2ae1fa890e65ec15f87e919e2288637dc0eb2c383234e8babbcb05 src/config/internal_routing.rs
40e09627e655d9319b576592f8d635e8c2da12905e5cee5952f7b6596010eb8c src/config/model.rs 7f162241a424a570841832253f2f1a3726d69c7963eaa1728c4ae1800386c2df src/config/model.rs
fc993c356386a9ff17379d48a70ef388df5b2b10ca9ee04f06342f68f9dbbaae src/config/routing.rs d2501ce56f7582ae462ac903db312054aa021827941104d0d698736032a6cb8d src/config/routing.rs
48ecd9de5a54b0a4aa484f38a7e171f60a033b725e14b158d1ef07b67997f9eb src/config/snapshot.rs 48ecd9de5a54b0a4aa484f38a7e171f60a033b725e14b158d1ef07b67997f9eb src/config/snapshot.rs
975f168b0630f71ed6eb1b27fef8a3d5c356238a0ef61b85fd9401416dbecd4b src/config/tests.rs 76af9d85af2bf16e98a02af25f818fc449e2aacba8c17689c2b7c3e59fe77b29 src/config/tests.rs
f3c39dc4ac09b9aa7fa8ff01da933f67f25f5030c05f54eef1c7d1e064d1f3c1 src/db.rs f3c39dc4ac09b9aa7fa8ff01da933f67f25f5030c05f54eef1c7d1e064d1f3c1 src/db.rs
ba82a4dede3dadd4b6c62434b0a0086420278067a46e93286d09b13e9ebc9d36 src/db/api_tokens.rs ba82a4dede3dadd4b6c62434b0a0086420278067a46e93286d09b13e9ebc9d36 src/db/api_tokens.rs
30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs 30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs
@@ -309,9 +309,9 @@ c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.r
e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs
b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
93a8a3f6ba12761ddcbd19f22f32fb2c0e6948b660fb774ddd7ebdba9b6562a5 src/docker/client.rs 93a8a3f6ba12761ddcbd19f22f32fb2c0e6948b660fb774ddd7ebdba9b6562a5 src/docker/client.rs
4c2c988191a97f81568a8df64af772463d010844f1abf327871901cdb32d4336 src/docker/model.rs 7806b2ac71b41895df1ef3eb998bd8b0d2bd4ce3ca4f6001782481239b16213e src/docker/model.rs
5f4610c59b2d4ff6a205a2d9ae11bc84be689b2a68302459f546b3f43e53593c src/docker/reconcile.rs a4c0a2e325e43a88db3c53c26ab73f15142d8edceb0f4bdf2eaf95fd03c8f7f5 src/docker/reconcile.rs
a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs c6e1abe79e44457599c564a4fa7a31752b009b622bf5271f78e3be2863137dce src/docker/tests.rs
61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs 61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs
f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs
01db5b1387755fb156f89f6dcec8031023483dd9b7eb17ba6c387e8179af4bd1 src/forwarding/tests.rs 01db5b1387755fb156f89f6dcec8031023483dd9b7eb17ba6c387e8179af4bd1 src/forwarding/tests.rs
@@ -376,7 +376,7 @@ cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forw
0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs 0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs
3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs 3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs
bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs
3b9f27445c637dbf093b07a5b4e77cbff4df2bab2ad622bb87b6772dab245ec6 src/proxy/forward_entry.rs 19ab40cab0a8cce0c750277a86ddc9e9a1af58a408f359b4f68768faf68429df src/proxy/forward_entry.rs
9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs 9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs
cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/headers.rs cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/headers.rs
0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs 0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs
@@ -438,13 +438,13 @@ d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_sta
53d40d0e169dffe18c802f07764cc0b4bfdf61d758eadb8f0d12e55d7438a7cd src/tinyauth.rs 53d40d0e169dffe18c802f07764cc0b4bfdf61d758eadb8f0d12e55d7438a7cd src/tinyauth.rs
0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs 0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs
1084cccd9de28acd6cb0f0813de7c0953910f51726f73f4acdb2e3ada4593976 src/tls.rs 1084cccd9de28acd6cb0f0813de7c0953910f51726f73f4acdb2e3ada4593976 src/tls.rs
4972a436f8878dec5f9eb8ff17eb82670b0bfd9e94e821c516575479f5fc1919 src/tls/certificates.rs 1d7a6e0df1ca4d4acfbc1d059e60d80c9ccf31fa7d130091df44bff9f8ad4d31 src/tls/certificates.rs
436575144aa352498a4696f12c2258bef3a898f3f50e6fbc6d67c3b93afa099f src/tls/external_paths.rs 436575144aa352498a4696f12c2258bef3a898f3f50e6fbc6d67c3b93afa099f src/tls/external_paths.rs
3e6947dde167f82a5efc407ccf9bd4c9f5b26cc8633d1418a10284bc1a1503d4 src/tls/server_config.rs 627c973870f6da0b18a90f84d3e92ed3f9fbdd5587490e4424dd77baaab3674c src/tls/server_config.rs
31c0f51a3a0bab433f438c3ba1b1cdaf3c63fae6086e7d99b3464a537f75a5e1 src/tls/tests.rs 7de4781a04ba6ccfbdbd3b8a30939bf6cd6eda4e7d6a07f05302c61c79f5703c src/tls/tests.rs
2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/tests/acme.rs 2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/tests/acme.rs
93b359e9defd62506729bc90e56c96783e2f14dd477dd67f337ca83149da7168 src/tls/tests/external_paths.rs 93b359e9defd62506729bc90e56c96783e2f14dd477dd67f337ca83149da7168 src/tls/tests/external_paths.rs
10c4a187e3aeb0c72a8c8968ea0cc8bfae4e3cb2f3d834ebd3da1056c4171ec5 src/tls/types.rs 3292498e7122bb9fbee49d29e51e37cbb337c51af62ce367a06f6b01f6c0491b src/tls/types.rs
6ae584944343b83ee3a2772cd93c985ddeaece7a9e6467b7cdbf5da22ee88028 src/traffic.rs 6ae584944343b83ee3a2772cd93c985ddeaece7a9e6467b7cdbf5da22ee88028 src/traffic.rs
e796efe9d94e2e79220287196fe2d2d9f4a1c7640b9f71652d409478e25d3c09 src/traffic/tests.rs e796efe9d94e2e79220287196fe2d2d9f4a1c7640b9f71652d409478e25d3c09 src/traffic/tests.rs
d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs
@@ -472,10 +472,10 @@ a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent
6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs 6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs
3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs 3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs
6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs 6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs
63e9fc7f7be78dc3e16a89f015a56a2da6ba3aff0197b70ff48321dfb17529bb src/vhost/model.rs 597ab87e69de8cc8ac3836903d18c699d788c165b46ff918d4a233a46b72d96e src/vhost/model.rs
35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs 35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs
9b16b5307e140db97363f56c9fac8319e56df846bb1c919d3bc06091ab6ce69c src/vhost/tests.rs 9b16b5307e140db97363f56c9fac8319e56df846bb1c919d3bc06091ab6ce69c src/vhost/tests.rs
b7d0f7f5d0bbd74add3fda9b1308350c4f70f8b6debf979fb977a4f2f659c32f src/vhost/validation.rs 7ce20fda4d36ac3415fc1599a098cd1b79be777f1db3043ca56f74723c97ed9e src/vhost/validation.rs
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs 8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs 9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs
bcaa1ae15134d85668b05a88d8abea01e5d188f535ad83cd4459732fd56f2f44 src/web_server/config/model.rs bcaa1ae15134d85668b05a88d8abea01e5d188f535ad83cd4459732fd56f2f44 src/web_server/config/model.rs
+3 -1
View File
@@ -4,11 +4,13 @@ Access control can be configured globally, on a Vhost and on selected Custom loc
## IP, country and DDNS rules ## IP, country and DDNS rules
- **IP/CIDR ACL:** allow or deny IPv4/IPv6 networks. Use reusable ACL profiles for settings shared by several domains. - **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.
- **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. - **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**. - **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. - **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.
**Multiple profiles on one Vhost:** For different explicit allowlists, allows are combined (logical OR). Explicit IP denies and blocked countries take precedence over allows from every selected profile. If no explicit allow or deny matches, fallback allows only when all selected profile defaults permit it (country allow-only requires a listed country). One selected profile uses the pre-existing ACL behavior exactly. The separate inline Vhost ACL, country policy, location ACL and global host ACL remain restrictive scopes (logical AND): a deny there cannot be overridden by a profile allow. Do not mix broadly permissive profiles with blocking profiles without checking the effective result from both an allowed and a blocked IP/country.
Default deny plus explicit allow exceptions is usually safer for private admin applications. Apply rules to a staging client before enforcing them on a live business-critical service. Default deny plus explicit allow exceptions is usually safer for private admin applications. Apply rules to a staging client before enforcing them on a live business-critical service.
## HTTP methods and rate limits ## HTTP methods and rate limits
+3 -1
View File
@@ -76,7 +76,9 @@ In **Docker vhosts**, select the service and set:
| **Frontend listeners / certificate** | Selected through the agent defaults and RustProxy configuration; ensure certificate covers the generated name. | | **Frontend listeners / certificate** | Selected through the agent defaults and RustProxy configuration; ensure certificate covers the generated name. |
| **Force HTTPS / WebSockets** | Enable only when needed and when the right frontend listeners exist. | | **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. | | **Cache / compression / transforms** | Optional; obey the global limits and privacy policy of the application. |
| **Health / ACL / timeouts** | Set checks and protections appropriate for the application, not just the Docker host. | | **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. |
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.
Use **Publish** and test the resulting hostname. Edits to an already published service use its own **Apply changes** action; unpublishing does not stop its Docker container. Hidden rows in the page are only a browser view preference, not a security policy. Use **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.
+1 -1
View File
@@ -1,6 +1,6 @@
# RustProxy documentation # RustProxy documentation
Practical guides for **RustProxy 0.34.3**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive. Practical guides for **RustProxy 0.34.4**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive.
## Getting started ## Getting started
+6
View File
@@ -55,3 +55,9 @@ Backend pools support scheduling/weighting, standby members, draining and option
## Safe operating practices ## Safe operating practices
Keep Vhost IDs stable for monitoring/history unless renaming is intentional. Renaming may reset the runtime identity and invalidate incompatible cache objects. Confirm the deployed scheme and Host, HTTPS trust, method/access restrictions, health status and logs after saving. For per-path behavior see [Custom locations](CUSTOM_LOCATIONS.md); for policies see [Access](ACCESS.md); for caching see [Cache](CACHE.md). Keep Vhost IDs stable for monitoring/history unless renaming is intentional. Renaming may reset the runtime identity and invalidate incompatible cache objects. Confirm the deployed scheme and Host, HTTPS trust, method/access restrictions, health status and logs after saving. For per-path behavior see [Custom locations](CUSTOM_LOCATIONS.md); for policies see [Access](ACCESS.md); for caching see [Cache](CACHE.md).
## Several ACL profiles and intentional TLS mismatch
Assign up to eight ACL profiles using the Vhost security tab. Explicit deny/blocked country wins, matching explicit allows combine across those profiles, and unmatched defaults must all allow. The legacy single `acl_profile_id` field still works. Inline, location and global policies are independently enforced. See [Access](ACCESS.md) for precedence and safe examples.
Normally RustProxy rejects a named certificate assigned to domains outside its DNS SubjectAltName coverage. To intentionally use such a certificate, select a **named** certificate and enable **Force an incompatible certificate** for that Vhost, then accept the confirmation. This records `allow_certificate_mismatch: true` on this Vhost only. Certificate/key matching remains mandatory, and client browsers will still warn/reject the certificate. Prefer a matching certificate; an inherited default certificate does not support this force option.
+7
View File
@@ -1071,6 +1071,13 @@
"vhAclProfile": "ACL profile", "vhAclProfile": "ACL profile",
"vhAclProfileNone": "No reusable profile", "vhAclProfileNone": "No reusable profile",
"vhAclProfileHelp": "Optional reusable profile. Inline vhost ACL and country rules below are still enforced in addition to the profile.", "vhAclProfileHelp": "Optional reusable profile. Inline vhost ACL and country rules below are still enforced in addition to the profile.",
"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",
"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", "vhHostAdd": "Add hostname",
"vhHostsHelp": "Add multiple hostnames (e.g. domain.local i subdomain.local) with Enter or comma. They share this vhost and its backend/settings. Wildcards are supported only as leading *. Exact hosts take priority.", "vhHostsHelp": "Add multiple hostnames (e.g. domain.local i subdomain.local) with Enter or comma. They share this vhost and its backend/settings. Wildcards are supported only as leading *. Exact hosts take priority.",
"vhHostInvalid": "Invalid hostname or wildcard pattern.", "vhHostInvalid": "Invalid hostname or wildcard pattern.",
+7
View File
@@ -1071,6 +1071,13 @@
"vhAclProfile": "Profil ACL", "vhAclProfile": "Profil ACL",
"vhAclProfileNone": "Bez wspólnego profilu", "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": "Opcjonalny profil wielokrotnego użytku. Indywidualne ACL i reguły krajów vhosta poniżej nadal są dodatkowo egzekwowane.",
"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",
"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", "vhHostAdd": "Dodaj hostname",
"vhHostsHelp": "Dodaj wiele domen (np. domena1.local i domena2.local), zatwierdzając Enterem lub przecinkiem. Wszystkie korzystają z tego samego vhosta i backendu. Wildcard tylko w postaci *.domena.pl.", "vhHostsHelp": "Dodaj wiele domen (np. domena1.local i domena2.local), zatwierdzając Enterem lub przecinkiem. Wszystkie korzystają z tego samego vhosta i backendu. Wildcard tylko w postaci *.domena.pl.",
"vhHostInvalid": "Nieprawidłowy hostname lub wzorzec wildcard.", "vhHostInvalid": "Nieprawidłowy hostname lub wzorzec wildcard.",
+89 -8
View File
@@ -59,13 +59,17 @@ impl Acl {
ensure!(rule.network.len() <= 254, "ACL network is too long"); ensure!(rule.network.len() <= 254, "ACL network is too long");
let target = if let Ok(network) = parse_network(&rule.network) { let target = if let Ok(network) = parse_network(&rule.network) {
if let IpNet::V6(net) = network { if let IpNet::V6(net) = network {
ensure!(net.addr().to_ipv4_mapped().is_none(), ensure!(
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"); net.addr().to_ipv4_mapped().is_none(),
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"
);
} }
RuleMatch::Ip(network) RuleMatch::Ip(network)
} else { } else {
ensure!(rule.action == Action::Allow, ensure!(
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"); rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
);
RuleMatch::Dns(dynamic::hostname(&rule.network)?) RuleMatch::Dns(dynamic::hostname(&rule.network)?)
}; };
rules.push((target, rule.action)); rules.push((target, rule.action));
@@ -76,6 +80,84 @@ impl Acl {
}) })
} }
} }
/// One reusable profile. Only the set of profiles attached to the same Vhost
/// is combined; local Vhost ACLs, locations and global host rules remain
/// independent restrictions.
#[derive(Debug, Clone)]
pub struct ProfileAccess {
pub acl: Option<CompiledAcl>,
pub countries: crate::rate_limit::CountryAccess,
}
/// Keep the single-profile rules unchanged. With multiple profiles, explicit
/// IP or country denies prevail across all profiles, explicit allows are a
/// union, and unmatched defaults allow only when every profile allows by
/// default. This avoids interpreting an unmatched default-deny as a veto of
/// another profile's explicit exception.
pub fn allows_profiles_with_country(
profiles: &[ProfileAccess],
peer: IpAddr,
country: Option<&str>,
) -> bool {
if profiles.len() <= 1 {
return profiles.first().is_none_or(|profile| {
allows_with_country(profile.acl.as_ref(), &profile.countries, peer, 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 country_listed = country.is_some_and(|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)
{
return false;
}
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);
}
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> {
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"
);
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"
);
}
Ok(())
}
impl CompiledAcl { impl CompiledAcl {
fn explicit_action(&self, peer: IpAddr) -> Option<Action> { fn explicit_action(&self, peer: IpAddr) -> Option<Action> {
let peer = match peer { let peer = match peer {
@@ -139,9 +221,7 @@ pub fn allows_with_country(
&& (ip_action == Some(Action::Allow) && (ip_action == Some(Action::Allow)
|| acl.is_none_or(|value| value.default == Action::Allow)) || acl.is_none_or(|value| value.default == Action::Allow))
} }
crate::rate_limit::CountryMode::Allow => { crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed,
ip_action == Some(Action::Allow) || country_listed
}
} }
} }
/// Validate the already-decoded routing representation used internally after /// Validate the already-decoded routing representation used internally after
@@ -186,7 +266,8 @@ pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
); );
} }
ensure!( ensure!(
!raw.split('/').any(|segment| segment == "." || segment == ".."), !raw.split('/')
.any(|segment| segment == "." || segment == ".."),
"ambiguous path segments" "ambiguous path segments"
); );
Ok(std::borrow::Cow::Borrowed(raw)) Ok(std::borrow::Cow::Borrowed(raw))
+88
View File
@@ -272,4 +272,92 @@ mod tests {
assert!(matches!(routing_path_view("/%61").unwrap(), Cow::Owned(_))); assert!(matches!(routing_path_view("/%61").unwrap(), Cow::Owned(_)));
assert!(routing_path_view(&format!("/{}", "x".repeat(8192))).is_err()); assert!(routing_path_view(&format!("/{}", "x".repeat(8192))).is_err());
} }
fn profile(default: Action, rules: &[(&str, Action)], mode: crate::rate_limit::CountryMode, countries: &[&str]) -> ProfileAccess {
ProfileAccess {
acl: Some(Acl {
default,
rules: rules.iter().map(|(network, action)| Rule {
network: (*network).into(), action: *action,
}).collect(),
}.compile().unwrap()),
countries: crate::rate_limit::CountryAccess {
mode,
countries: countries.iter().map(|s| (*s).into()).collect(),
},
}
}
#[test]
fn two_acl_profiles_union_explicit_allow_but_retain_default_deny() {
use crate::rate_limit::CountryMode::Off;
let profiles = vec![
profile(Action::Deny, &[("10.0.0.0/8", Action::Allow)], Off, &[]),
profile(Action::Deny, &[("192.0.2.0/24", Action::Allow)], Off, &[]),
];
assert!(allows_profiles_with_country(&profiles, "10.1.2.3".parse().unwrap(), None));
assert!(allows_profiles_with_country(&profiles, "192.0.2.1".parse().unwrap(), None));
assert!(!allows_profiles_with_country(&profiles, "203.0.113.9".parse().unwrap(), None));
}
#[test]
fn explicit_deny_has_priority_over_any_matching_allow() {
use crate::rate_limit::CountryMode::Off;
let profiles = vec![
profile(Action::Allow, &[("0.0.0.0/0", Action::Allow)], Off, &[]),
profile(Action::Deny, &[("198.51.100.0/24", Action::Deny)], Off, &[]),
];
assert!(!allows_profiles_with_country(&profiles, "198.51.100.5".parse().unwrap(), Some("PL")));
assert!(allows_profiles_with_country(&profiles, "203.0.113.5".parse().unwrap(), Some("PL")));
let conflicting = vec![
profiles[0].clone(),
profile(Action::Deny, &[("0.0.0.0/0", Action::Deny)], Off, &[]),
];
assert!(!allows_profiles_with_country(&conflicting, "203.0.113.5".parse().unwrap(), None));
}
#[test]
fn country_block_wins_across_profiles_and_geo_allow_merges_with_ip_allow() {
use crate::rate_limit::CountryMode::{Allow, Block};
let profiles = vec![
profile(Action::Deny, &[("10.0.0.0/8", Action::Allow)], Allow, &["PL"]),
profile(Action::Deny, &[], Block, &["CN"]),
];
assert!(allows_profiles_with_country(&profiles, "203.0.113.7".parse().unwrap(), Some("pl")));
assert!(allows_profiles_with_country(&profiles, "10.2.3.4".parse().unwrap(), Some("US")));
assert!(!allows_profiles_with_country(&profiles, "10.2.3.4".parse().unwrap(), Some("CN")));
assert!(!allows_profiles_with_country(&profiles, "203.0.113.7".parse().unwrap(), Some("US")));
}
#[test]
fn defaults_require_consensus_and_inline_restrictions_still_deny() {
use crate::rate_limit::CountryMode::Off;
let profiles = vec![profile(Action::Allow, &[], Off, &[]), profile(Action::Deny, &[], Off, &[])];
let peer = "203.0.113.5".parse().unwrap();
assert!(!allows_profiles_with_country(&profiles, peer, None));
let all_allow = vec![profile(Action::Allow, &[], Off, &[]), profile(Action::Allow, &[], Off, &[])];
assert!(allows_profiles_with_country(&all_allow, peer, None));
let inline = Acl { default: Action::Deny, rules: vec![] }.compile().unwrap();
assert!(!(allows_profiles_with_country(&all_allow, peer, None) && inline.allows(peer)));
assert!(allows_profiles_with_country(&[], peer, None));
}
#[test]
fn singular_profile_keeps_legacy_behavior_and_ids_validate() {
use crate::rate_limit::CountryMode::Allow;
let single = profile(Action::Allow, &[], Allow, &["DE"]);
for country in [None, Some("DE"), Some("PL")] {
for peer in ["10.1.2.3", "203.0.113.5"] {
let peer = peer.parse().unwrap();
assert_eq!(
allows_profiles_with_country(&[single.clone()], peer, country),
allows_with_country(single.acl.as_ref(), &single.countries, peer, country),
);
}
}
assert!(validate_profile_ids(Some("old"), &["new".into()]).is_ok());
assert!(validate_profile_ids(Some("same"), &["same".into()]).is_err());
assert!(validate_profile_ids(None, &vec!["id".into(); 2]).is_err());
assert!(validate_profile_ids(Some("old"), &(0..8).map(|v| format!("profile{v}")).collect::<Vec<_>>()).is_err());
}
} }
+39 -2
View File
@@ -2,8 +2,8 @@
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
"title": "RustProxy Administration API", "title": "RustProxy Administration API",
"version": "0.34.3", "version": "0.34.4",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. Version 0.34.3 also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only." "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
}, },
"servers": [ "servers": [
{ {
@@ -672,6 +672,20 @@
}, },
"rate_limit": { "rate_limit": {
"$ref": "#/components/schemas/VhostRateLimit" "$ref": "#/components/schemas/VhostRateLimit"
},
"acl_profile_ids": {
"type": "array",
"maxItems": 8,
"items": {
"type": "string",
"maxLength": 64
},
"description": "Up to 8 additional reusable ACL profiles. Explicit IP denies and country blocks beat all allows; otherwise explicit allows combine as OR; unmatched defaults require unanimous allow. Legacy acl_profile_id is still honored. Inline and global policies remain cumulative."
},
"allow_certificate_mismatch": {
"type": "boolean",
"default": false,
"description": "Explicit, persistent administrator opt-in: allow assigning a named public certificate to hostnames not covered by its DNS SAN. This does not suppress TLS client warnings, validate the certificate less during agent polling, or disable backend TLS verification. Requires a named certificate."
} }
}, },
"additionalProperties": true, "additionalProperties": true,
@@ -2884,6 +2898,20 @@
"default": null, "default": null,
"description": "Legacy compatibility field accepted when loading older 0.27.0 pre-release configuration. Ignored for Docker vhosts; deflate availability is controlled by global Response settings.", "description": "Legacy compatibility field accepted when loading older 0.27.0 pre-release configuration. Ignored for Docker vhosts; deflate availability is controlled by global Response settings.",
"deprecated": true "deprecated": true
},
"acl_profile_ids": {
"type": "array",
"maxItems": 8,
"items": {
"type": "string",
"maxLength": 64
},
"description": "Up to 8 additional reusable ACL profiles. Explicit IP denies and country blocks beat all allows; otherwise explicit allows combine as OR; unmatched defaults require unanimous allow. Legacy acl_profile_id is still honored. Inline and global policies remain cumulative."
},
"allow_certificate_mismatch": {
"type": "boolean",
"default": false,
"description": "Explicit, persistent administrator opt-in: allow assigning a named public certificate to hostnames not covered by its DNS SAN. This does not suppress TLS client warnings, validate the certificate less during agent polling, or disable backend TLS verification. Requires a named certificate."
} }
}, },
"required": [ "required": [
@@ -2966,6 +2994,15 @@
"nullable": true, "nullable": true,
"maxLength": 64, "maxLength": 64,
"description": "Optional default ACL profile inherited by generated Docker vhosts whose assignment does not select its own ACL profile." "description": "Optional default ACL profile inherited by generated Docker vhosts whose assignment does not select its own ACL profile."
},
"acl_profile_ids": {
"type": "array",
"maxItems": 8,
"items": {
"type": "string",
"maxLength": 64
},
"description": "Up to 8 additional reusable ACL profiles. Explicit IP denies and country blocks beat all allows; otherwise explicit allows combine as OR; unmatched defaults require unanimous allow. Legacy acl_profile_id is still honored. Inline and global policies remain cumulative."
} }
}, },
"required": [ "required": [
+60 -15
View File
@@ -50,6 +50,25 @@
function input(value = "", type = "text", cls = "") { const node = el("input"); node.type = type; node.value = value ?? ""; if (cls) node.className = cls; return node; } function input(value = "", type = "text", cls = "") { const node = el("input"); node.type = type; node.value = value ?? ""; if (cls) node.className = cls; return node; }
function labeled(key, control, help = "") { const label = el("label", undefined, "docker-field"); label.append(el("span", t(key)), control); if (help) label.append(el("small", help, "fine")); return label; } function labeled(key, control, help = "") { const label = el("label", undefined, "docker-field"); label.append(el("span", t(key)), control); if (help) label.append(el("small", help, "fine")); return label; }
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 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;
}
function readProfileChoices(scope, selector) {
return [...scope.querySelectorAll(`${selector} input[type=checkbox]:checked`)].map(input => input.value);
}
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; };
choices.addEventListener("change", sync); sync(); return warning;
}
function aliasEditor(initial = [], onChange = () => { }) { 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"); 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")); field.append(el("span", t("dockerAliases"))); inputNode.maxLength = 253; inputNode.placeholder = "alias1 / app.example.com"; inputNode.setAttribute("aria-label", t("dockerAliasAdd"));
@@ -145,10 +164,11 @@
const target = input(value.default_target_ip || value.target_ip_override || "", "text", "docker-agent-target"); target.placeholder = t("dockerTargetAuto"); const target = input(value.default_target_ip || value.target_ip_override || "", "text", "docker-agent-target"); target.placeholder = t("dockerTargetAuto");
const certificate = el("select", undefined, "docker-agent-certificate"), defaultCertificate = el("option", t("vhCertificateDefault")); defaultCertificate.value = ""; certificate.append(defaultCertificate); 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 || ""; 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 = el("select", undefined, "docker-agent-acl-profile"), noAcl = el("option", t("dockerDefaultAclNone")); noAcl.value = ""; defaultAcl.append(noAcl); const defaultAcl = profileChoices(selectedProfileIds(value), "docker-agent-acl-profile");
for (const profile of envelope?.config?.acl_profiles || []) { const item = el("option", profile.name || profile.id); item.value = profile.id; defaultAcl.append(item); } defaultAcl.value = value.acl_profile_id || ""; const defaultAclField = labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"));
defaultAclField.append(profileWarning(defaultAcl));
const grid = el("div", undefined, "field-grid docker-agent-grid"); 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")), labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"))); 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);
const listenerField = el("div", undefined, "docker-agent-listener-field"); listenerField.append(el("span", t("dockerListeners"), "docker-agent-field-label")); const listenerField = el("div", undefined, "docker-agent-listener-field"); listenerField.append(el("span", t("dockerListeners"), "docker-agent-field-label"));
const listenerControl = window.rustProxyIntegrations.bindings(listenerField, value.listener_ids || [], !agent); listenerField.querySelector(".listener-choices")?.classList.add("docker-agent-listener-choices"); row._readListeners = listenerControl.read; grid.append(listenerField); const listenerControl = window.rustProxyIntegrations.bindings(listenerField, value.listener_ids || [], !agent); listenerField.querySelector(".listener-choices")?.classList.add("docker-agent-listener-choices"); row._readListeners = listenerControl.read; grid.append(listenerField);
const security = el("div", undefined, "docker-agent-security"), secBadge = el("span", "", "pill docker-agent-security-badge"), secHint = el("span", "", "fine docker-agent-security-hint"); security.append(secBadge, secHint); const security = el("div", undefined, "docker-agent-security"), secBadge = el("span", "", "pill docker-agent-security-badge"), secHint = el("span", "", "fine docker-agent-security-hint"); security.append(secBadge, secHint);
@@ -167,7 +187,7 @@
id: row.querySelector(".docker-agent-id").value.trim(), name: row.querySelector(".docker-agent-name").value.trim(), enabled: row.querySelector(".docker-agent-enabled").checked, id: row.querySelector(".docker-agent-id").value.trim(), name: row.querySelector(".docker-agent-name").value.trim(), enabled: row.querySelector(".docker-agent-enabled").checked,
endpoint: row.querySelector(".docker-agent-endpoint").value.trim().replace(/\/$/, ""), token: row.querySelector(".docker-agent-token").value, endpoint: row.querySelector(".docker-agent-endpoint").value.trim().replace(/\/$/, ""), token: row.querySelector(".docker-agent-token").value,
tls_fingerprint_sha256: row.querySelector(".docker-agent-fingerprint").value.trim() || null, base_domain: row.querySelector(".docker-agent-domain").value.trim().toLowerCase(), tls_fingerprint_sha256: row.querySelector(".docker-agent-fingerprint").value.trim() || null, base_domain: row.querySelector(".docker-agent-domain").value.trim().toLowerCase(),
poll_interval_seconds: Number(row.querySelector(".docker-agent-poll").value), certificate_id: row.querySelector(".docker-agent-certificate").value || null, acl_profile_id: row.querySelector(".docker-agent-acl-profile").value || null, default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null, poll_interval_seconds: Number(row.querySelector(".docker-agent-poll").value), certificate_id: row.querySelector(".docker-agent-certificate").value || null, acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-agent-acl-profile"), default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null,
listener_ids: listenerIds, assignments: structuredClone(row._assignments || []) listener_ids: listenerIds, assignments: structuredClone(row._assignments || [])
}; };
}) })
@@ -236,15 +256,15 @@
} }
function aclEditor(assignment, agent) { function aclEditor(assignment, agent) {
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 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 = el("select", undefined, "docker-acl-profile"); const grid = el("div", undefined, "field-grid docker-acl-grid"), profile = profileChoices(selectedProfileIds(assignment), "docker-acl-profile");
const inheritedProfile = agent?.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(item => item.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : ""; const inheritedProfiles = selectedProfileIds(agent);
const none = el("option", inheritedProfile ? `${t("dockerAclProfileInherit")}: ${inheritedProfile}` : t("dockerAclProfileNone")); none.value = ""; profile.append(none); 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"));
for (const item of envelope?.config?.acl_profiles || []) { const option = el("option", item.name || item.id); option.value = item.id; profile.append(option); } profile.value = assignment?.acl_profile_id || ""; profileField.append(profileWarning(profile, inheritedProfiles.length));
const inline = input("", "checkbox", "docker-acl-inline"); inline.checked = !!assignment?.acl; const inlineLabel = checkboxField("dockerAclInline", inline); 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 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 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(); const sync = () => { defaultAction.disabled = !inline.checked; rules.disabled = !inline.checked; }; inline.addEventListener("change", sync); sync();
grid.append(labeled("dockerAclProfile", profile), inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details; grid.append(profileField, inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
} }
function timeoutEditor(assignment) { 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); 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);
@@ -260,7 +280,9 @@
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, 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. // Docker publications intentionally expose one compression switch only. Codec selection stays global.
compression_zstd: null, compression_br: null, compression_gzip: null, compression_deflate: null, compression_zstd: null, compression_br: null, compression_gzip: null, compression_deflate: null,
acl_profile_id: row.querySelector(".docker-acl-profile")?.value || null, acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null, acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-acl-profile"),
allow_certificate_mismatch: row.querySelector(".docker-cert-mismatch")?.checked === true,
acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null,
proxy_timeouts: { connect_timeout_seconds: Number(row.querySelector(".docker-proxy-connect").value), response_header_timeout_seconds: Number(row.querySelector(".docker-proxy-header").value), read_timeout_seconds: Number(row.querySelector(".docker-proxy-read").value), send_timeout_seconds: Number(row.querySelector(".docker-proxy-send").value) }, proxy_timeouts: { connect_timeout_seconds: Number(row.querySelector(".docker-proxy-connect").value), response_header_timeout_seconds: Number(row.querySelector(".docker-proxy-header").value), read_timeout_seconds: Number(row.querySelector(".docker-proxy-read").value), send_timeout_seconds: Number(row.querySelector(".docker-proxy-send").value) },
health_check: collectHealth(row), passive_health: collectPassiveHealth(row) health_check: collectHealth(row), passive_health: collectPassiveHealth(row)
}; };
@@ -268,7 +290,13 @@
async function saveAssignment(agentId, row, publish) { async function saveAssignment(agentId, row, publish) {
const current = structuredClone(cfg()), agent = current.agents.find(item => item.id === agentId); if (!agent) return false; const current = structuredClone(cfg()), agent = current.agents.find(item => item.id === agentId); if (!agent) return false;
const index = (agent.assignments || []).findIndex(item => item.service_key === row.dataset.key); const index = (agent.assignments || []).findIndex(item => item.service_key === row.dataset.key);
if (publish) { const assignment = collectAssignment(row); if (!assignment.hostname) throw new Error(t("dockerHostnameRequired")); if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment); } if (publish) {
const assignment = collectAssignment(row); if (!assignment.hostname) throw new Error(t("dockerHostnameRequired"));
const previous = index >= 0 ? agent.assignments[index] : null;
const consentNames = item => JSON.stringify([item?.hostname || "", ...(item?.aliases || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort());
if (assignment.allow_certificate_mismatch && (!previous?.allow_certificate_mismatch || consentNames(assignment) !== consentNames(previous)) && !(await appConfirm(t("dockerCertMismatchConfirm"), { danger: true }))) return false;
if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment);
}
else if (index >= 0) agent.assignments.splice(index, 1); else if (index >= 0) agent.assignments.splice(index, 1);
const saved = await saveSection("docker-agents", current); if (saved) row.classList.remove("is-dirty"); return saved; const saved = await saveSection("docker-agents", current); if (saved) row.classList.remove("is-dirty"); return saved;
} }
@@ -304,7 +332,12 @@
const transformEnabled = input("", "checkbox", "docker-transform-enabled"); transformEnabled.checked = assignment?.transform_enabled === true; const transformField = checkboxField("dockerTransform", transformEnabled); const transformEnabled = input("", "checkbox", "docker-transform-enabled"); transformEnabled.checked = assignment?.transform_enabled === true; const transformField = checkboxField("dockerTransform", transformEnabled);
const compressionEnabled = input("", "checkbox", "docker-compression-enabled"); compressionEnabled.checked = assignment?.compression_enabled !== false; const compressionField = checkboxField("dockerCompression", compressionEnabled); compressionField.title = t("dockerCompressionHelp"); const compressionEnabled = input("", "checkbox", "docker-compression-enabled"); compressionEnabled.checked = assignment?.compression_enabled !== false; const compressionField = checkboxField("dockerCompression", compressionEnabled); compressionField.title = t("dockerCompressionHelp");
const switches = el("div", undefined, "docker-switch-row"); switches.append(preserveLabel, forceSslField, websocketField, cacheField, compressionField, transformField); const switches = el("div", undefined, "docker-switch-row"); switches.append(preserveLabel, forceSslField, websocketField, cacheField, compressionField, transformField);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches); 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)); editor.append(route, 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); 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}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
@@ -335,8 +368,8 @@
const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase(); const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase();
overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview); overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview);
const agentDetails = el("details", undefined, "docker-agent-details"), detailsSummary = el("summary", t("dockerAgentDetails")), summary = el("div", undefined, "docker-agent-summary"); const agentDetails = el("details", undefined, "docker-agent-details"), detailsSummary = el("summary", t("dockerAgentDetails")), summary = el("div", undefined, "docker-agent-summary");
const defaultAclName = agent.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(profile => profile.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : t("dockerDefaultAclNone"); const defaultAclName = selectedProfileIds(agent).map(id => ((envelope?.config?.acl_profiles || []).find(profile => profile.id === id)?.name || id)).join(", ") || t("dockerDefaultAclNone");
summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono")); agentDetails.append(detailsSummary, summary); box.append(agentDetails); summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono docker-fingerprint")); agentDetails.append(detailsSummary, summary); box.append(agentDetails);
const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box); const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box);
} }
applyViewFilters(); applyViewFilters();
@@ -407,7 +440,19 @@
$("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); }); $("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); });
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); }); $("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } }); $("docker-agents-form")?.addEventListener("submit", async event => {
event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try {
await withFormSubmitState(form, event.submitter, async () => {
const next = collectSettings();
const changingAcceptedCertificate = next.agents.some(agent => {
const previous = (cfg().agents || []).find(old => old.id === agent.id);
return previous && previous.certificate_id !== agent.certificate_id && agent.assignments.some(item => item.allow_certificate_mismatch);
});
if (changingAcceptedCertificate && !(await appConfirm(t("dockerCertMismatchConfirm"), { danger: true }))) return;
if (await saveSection("docker-agents", next)) { setSettingsDirty("docker-agents-form", false); await refresh(true); }
});
} catch (error) { toast(error.message, true); }
});
rustProxyRefresh.register("docker", { rustProxyRefresh.register("docker", {
interval: () => 5, interval: () => 5,
// Even with local Live view off, observe the first startup result. // Even with local Live view off, observe the first startup result.
+4 -3
View File
@@ -406,10 +406,11 @@
const box = el("div", undefined, "entity acl-profile-card"); const box = el("div", undefined, "entity acl-profile-card");
const top = el("div", undefined, "entity-top"); top.append(el("span", profile.name || profile.id, "entity-title"), el("small", profile.id)); box.append(top); const top = el("div", undefined, "entity-top"); top.append(el("span", profile.name || profile.id, "entity-title"), el("small", profile.id)); box.append(top);
const pieces = friendlySummary(profile); const pieces = friendlySummary(profile);
const users = (envelope.config.vhosts || []).filter(v => v.acl_profile_id === profile.id).map(v => v.id); const usesProfile = value => value.acl_profile_id === profile.id || (value.acl_profile_ids || []).includes(profile.id);
const users = (envelope.config.vhosts || []).filter(usesProfile).map(v => v.id);
for (const agent of envelope.config.docker_agents?.agents || []) { for (const agent of envelope.config.docker_agents?.agents || []) {
if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`); if (usesProfile(agent)) users.push(`docker-agent:${agent.id}`);
for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`); for (const assignment of agent.assignments || []) if (usesProfile(assignment)) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`);
} }
box.append(el("p", pieces.filter(Boolean).join("\n"), "entity-details")); box.append(el("p", pieces.filter(Boolean).join("\n"), "entity-details"));
if (users.length) { if (users.length) {
+83
View File
@@ -12314,3 +12314,86 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
color: var(--muted); color: var(--muted);
font: 400 10px var(--mono); font: 400 10px var(--mono);
} }
.docker-agent-summary>span,
.docker-agent-security-hint,
.docker-fingerprint {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.vh-acl-profile-picker,
.docker-acl-choices {
min-width: 0;
}
.vh-acl-profile-picker>strong {
display: block;
margin-bottom: 8px;
}
.vh-acl-profile-choices,
.docker-acl-choices {
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;
font-size: 12px;
}
.vh-acl-profile-choice input,
.docker-acl-choice input {
flex: 0 0 auto;
width: auto !important;
min-height: 0 !important;
height: auto !important;
}
.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;
width: 100%;
box-sizing: border-box;
}
+1 -1
View File
@@ -45,7 +45,7 @@ const defaults = () => ({
frontend: { hosts: [], listener_ids: [] }, frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" }, source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }], backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts() error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl_profile_ids: [], acl: null, allow_certificate_mismatch: false, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
}); });
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); } function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
+22 -3
View File
@@ -238,8 +238,22 @@ function load(value = defaults(), migrationIds = [], show = true) {
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote"); field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {}; const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
g = group("vhTrafficPolicy"); g = group("vhTrafficPolicy");
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select"); const selectedProfiles = new Set([value.acl_profile_id, ...(value.acl_profile_ids || [])].filter(Boolean));
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp"); 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"));
profileBox.append(choices);
const multiWarning = text("p", "multiAclWarning", "fine acl-stack-warning");
const syncMultiWarning = () => { multiWarning.hidden = choices.querySelectorAll('input:checked').length < 2; };
choices.addEventListener("change", syncMultiWarning); syncMultiWarning();
profileBox.append(multiWarning); g.append(profileBox); note(g, "vhAclProfileHelp");
field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] }); field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 }); field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhMethodHelp"); note(g, "vhMethodHelp");
@@ -273,6 +287,10 @@ function load(value = defaults(), migrationIds = [], show = true) {
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select"); const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates(); const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint"); g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
const mismatch = field(g, "allow-certificate-mismatch", "vhCertificateMismatchForce", value.allow_certificate_mismatch === true, "checkbox");
const mismatchHint = note(g, "vhCertificateMismatchWarning");
const syncMismatch = () => { mismatch.disabled = !selector.value; if (!selector.value) mismatch.checked = false; mismatchHint.hidden = !mismatch.checked; };
selector.addEventListener("change", syncMismatch); mismatch.addEventListener("change", syncMismatch); syncMismatch();
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] }; const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const tlsPolicy = value.tls_policy || inheritedTlsPolicy; const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox"); const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
@@ -445,7 +463,8 @@ function collect() {
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict")); if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
} }
return { 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"), 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: nullable("acl-profile-id"), 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),
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") }, 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() }, 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()) }, method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
+9
View File
@@ -561,6 +561,15 @@ form.addEventListener("submit", async event => {
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale")); if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= []; const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return; const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
const oldVhost = (envelope.config.vhosts || []).find(v => v.id === editing);
// A previous acceptance must not silently authorize new hostnames or a different certificate.
const certificateScope = item => JSON.stringify({
certificate: item?.certificate_id || null,
hosts: [...(item?.frontend?.hosts || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort(),
wwwRedirect: item?.www_redirect === true,
wwwTarget: item?.www_redirect_target || null
});
if (value.allow_certificate_mismatch && (!oldVhost?.allow_certificate_mismatch || certificateScope(value) !== certificateScope(oldVhost)) && !(await appConfirm(t("vhCertificateMismatchConfirm"), { danger: true }))) return;
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict")); if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return; if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id); const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
+104 -30
View File
@@ -48,29 +48,58 @@ pub fn compile_with_active(
config.compression.validate()?; config.compression.validate()?;
config.server_header.validate()?; config.server_header.validate()?;
let forwarding = config.forwarding.compile()?; 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(); let mut php_profiles = HashMap::new();
for profile in &mut config.php_fpm_profiles { for profile in &mut config.php_fpm_profiles {
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?; profile
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(), .validate()
"duplicate PHP-FPM profile id"); .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(); let mut web_services = HashMap::new();
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused // 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. // workers. Different directive sets on one socket can leak to the next vhost.
// Report unsafe legacy configurations but keep them loadable for migration. // 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 { 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 // A profile changes effective runtime settings, not the saved standalone
// PHP configuration. Detaching a profile restores local settings exactly. // PHP configuration. Detaching a profile restores local settings exactly.
let mut effective = service.clone(); let mut effective = service.clone();
if let Some(id) = &service.php_profile_id { if let Some(id) = &service.php_profile_id {
effective.php = php_profiles.get(id).with_context(|| { effective.php = php_profiles
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id) .get(id)
})?.clone(); .with_context(|| {
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?; 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 { if effective.php.enabled {
let php = &effective.php; let php = &effective.php;
@@ -82,13 +111,25 @@ pub fn compile_with_active(
); );
} }
} else { } else {
php_endpoint_policies.insert(php.endpoint.clone(), ( php_endpoint_policies.insert(
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(), 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))?); let compiled = Arc::new(
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id"); 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)"); ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new(); let mut vhost_ids = HashSet::new();
@@ -267,7 +308,9 @@ pub fn compile_with_active(
// Prevent unbounded DNS ACL work across the entire published configuration. // Prevent unbounded DNS ACL work across the entire published configuration.
crate::acl::dynamic::configured_hostnames(&config)?; crate::acl::dynamic::configured_hostnames(&config)?;
for agent in &config.docker_agents.agents { for agent in &config.docker_agents.agents {
if let Some(profile_id) = agent.acl_profile_id.as_deref() { for profile_id in
crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
{
ensure!( ensure!(
acl_profiles.contains_key(profile_id), acl_profiles.contains_key(profile_id),
"Docker agent {} references unknown default ACL profile {}", "Docker agent {} references unknown default ACL profile {}",
@@ -276,7 +319,10 @@ pub fn compile_with_active(
); );
} }
for assignment in &agent.assignments { for assignment in &agent.assignments {
if let Some(profile_id) = assignment.acl_profile_id.as_deref() { for profile_id in crate::acl::profile_ids(
assignment.acl_profile_id.as_deref(),
&assignment.acl_profile_ids,
) {
ensure!( ensure!(
acl_profiles.contains_key(profile_id), acl_profiles.contains_key(profile_id),
"Docker assignment {} on agent {} references unknown ACL profile {}", "Docker assignment {} on agent {} references unknown ACL profile {}",
@@ -395,7 +441,9 @@ fn validate_route_rewrite(
Ok(()) 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 { let Some(redirect) = redirect else {
return Ok(None); return Ok(None);
}; };
@@ -411,24 +459,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>)
); );
// Replace capture references with a harmless segment so URL structure can // Replace capture references with a harmless segment so URL structure can
// be validated before requests reach the data plane. // be validated before requests reach the data plane.
let authority_end = redirect.target.find("://").and_then(|start| { let authority_end = redirect
let rest = &redirect.target[start + 3..]; .target
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len())) .find("://")
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?; .and_then(|start| {
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority"); 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"
);
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?") let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
.expect("static capture regex") .expect("static capture regex")
.replace_all(&redirect.target, "capture"); .replace_all(&redirect.target, "capture");
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?; let parsed =
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host"); url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials"); 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() { let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
None | Some("") => None, None | Some("") => None,
Some(pattern) => { 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); let mut builder = regex::RegexBuilder::new(pattern);
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024); builder
Some(builder.build().context("invalid location redirect rewrite regex")?) .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 { Ok(Some(crate::config::CompiledLocationRedirect {
+2
View File
@@ -263,6 +263,8 @@ pub struct CompiledFrontend {
pub acl_profile_id: Option<String>, pub acl_profile_id: Option<String>,
pub profile_acl: Option<CompiledAcl>, pub profile_acl: Option<CompiledAcl>,
pub profile_country_access: crate::rate_limit::CountryAccess, pub profile_country_access: crate::rate_limit::CountryAccess,
/// All Vhost profile scopes, checked before the inline ACL.
pub profile_policies: Vec<crate::acl::ProfileAccess>,
pub acl: Option<CompiledAcl>, pub acl: Option<CompiledAcl>,
pub headers: CompiledHeaders, pub headers: CompiledHeaders,
pub security: crate::security::CompiledSecurity, pub security: crate::security::CompiledSecurity,
+11 -5
View File
@@ -173,12 +173,17 @@ fn compile_routing(
.as_ref() .as_ref()
.map(|base| crate::tls::variant(base, &tls_policy, false)) .map(|base| crate::tls::variant(base, &tls_policy, false))
.transpose()?; .transpose()?;
let (profile_acl, profile_country_access) = match host.acl_profile_id.as_deref() { let profile_policies: Vec<crate::acl::ProfileAccess> = crate::acl::profile_ids(
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| { host.acl_profile_id.as_deref(), &host.acl_profile_ids,
).map(|id| {
let (acl, countries) = acl_profiles.get(id).cloned().ok_or_else(|| {
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id) anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
})?, })?;
None => (None, crate::rate_limit::CountryAccess::default()), Ok(crate::acl::ProfileAccess { acl, countries })
}; }).collect::<Result<_>>()?;
let (profile_acl, profile_country_access) = profile_policies.first()
.map(|entry| (entry.acl.clone(), entry.countries.clone()))
.unwrap_or_else(|| (None, crate::rate_limit::CountryAccess::default()));
let frontend = Arc::new(CompiledFrontend { let frontend = Arc::new(CompiledFrontend {
metrics, metrics,
websockets: host.websockets, websockets: host.websockets,
@@ -215,6 +220,7 @@ fn compile_routing(
acl_profile_id: host.acl_profile_id.clone(), acl_profile_id: host.acl_profile_id.clone(),
profile_acl, profile_acl,
profile_country_access, profile_country_access,
profile_policies,
acl: host.acl.as_ref().map(Acl::compile).transpose()?, acl: host.acl.as_ref().map(Acl::compile).transpose()?,
headers: host.headers.compile()?, headers: host.headers.compile()?,
security: host.security_headers.compile()?, security: host.security_headers.compile()?,
+21 -14
View File
@@ -1,9 +1,6 @@
impl Snapshot { impl Snapshot {
// Exact-first lookups with stack-buffered wildcard keys for normal hosts. // Exact-first lookups with stack-buffered wildcard keys for normal hosts.
fn find_host_pattern<T>( fn find_host_pattern<T>(host: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> {
host: &str,
mut lookup: impl FnMut(&str) -> Option<T>,
) -> Option<T> {
if let Some(value) = lookup(host) { if let Some(value) = lookup(host) {
return Some(value); return Some(value);
} }
@@ -136,11 +133,7 @@ impl Snapshot {
}); });
} }
} }
for (candidate, matched_by) in [ for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
(authority, "authority"),
(host, "host"),
(sni, "sni"),
] {
let Some(candidate) = candidate else { let Some(candidate) = candidate else {
continue; continue;
}; };
@@ -212,7 +205,10 @@ impl Snapshot {
/// Legacy inspection helper only; network handlers must call select_on. /// Legacy inspection helper only; network handlers must call select_on.
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> { pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
Self::find_host_pattern(host, |pattern| { 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 self.routes
.get(pattern) .get(pattern)
.and_then(|routes| select_route(routes, path, |_| true)) .and_then(|routes| select_route(routes, path, |_| true))
@@ -269,17 +265,25 @@ where
prefix prefix
} }
impl CompiledRoute { 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 { let Some(spec) = &self.redirect else {
return Ok(None); return Ok(None);
}; };
let target = if let Some(regex) = &spec.rewrite_regex { 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() regex.replace(path, spec.target.as_str()).into_owned()
} else { } else {
spec.target.clone() 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 spec.preserve_query {
if let Some(query) = uri.query() { if let Some(query) = uri.query() {
let merged = match target.query() { let merged = match target.query() {
@@ -289,7 +293,10 @@ impl CompiledRoute {
target.set_query(Some(&merged)); 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 /// Return a normalized replacement path for the upstream hop. `None` means
/// preserve the client's exact origin-form path bytes. /// preserve the client's exact origin-form path bytes.
+38
View File
@@ -1054,4 +1054,42 @@ fn stack_host_lookup_preserves_long_internal_host_fallback() {
assert_eq!(patterns[1], format!("*.{}.sub.example.test", "x".repeat(260))); assert_eq!(patterns[1], format!("*.{}.sub.example.test", "x".repeat(260)));
assert_eq!(patterns[2], "*.sub.example.test"); assert_eq!(patterns[2], "*.sub.example.test");
} }
#[test]
fn multiple_profile_bindings_compile_and_do_not_weaken_inline_acl() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.acl_profiles = serde_json::from_value(serde_json::json!([
{"id":"office","name":"Office","acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}},
{"id":"home","name":"Home","acl":{"default":"deny","rules":[{"network":"192.0.2.0/24","action":"allow"}]}}
])).unwrap();
c.vhosts[0].acl_profile_ids = vec!["office".into(), "home".into()];
c.vhosts[0].acl = Some(crate::acl::Acl {
default: crate::acl::Action::Deny,
rules: vec![crate::acl::Rule { network: "10.0.0.0/8".into(), action: crate::acl::Action::Allow }],
});
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.profile_policies.len(), 2);
assert!(crate::acl::allows_profiles_with_country(&frontend.profile_policies, "192.0.2.5".parse().unwrap(), None));
assert!(!frontend.acl.as_ref().unwrap().allows("192.0.2.5".parse().unwrap()));
c.vhosts[0].acl_profile_ids = vec!["office".into(), "unknown".into()];
assert!(compile(c.clone(), 2, &policy()).is_err());
c.vhosts[0].acl_profile_ids = vec!["office".into(), "office".into()];
assert!(compile(c, 3, &policy()).is_err());
}
#[test]
fn old_vhost_profile_field_survives_json_roundtrip() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.acl_profiles = serde_json::from_value(serde_json::json!([{"id":"office","name":"Office"}])).unwrap();
c.vhosts[0].acl_profile_id = Some("office".into());
let json = serde_json::to_string(&c).unwrap();
let decoded: Config = serde_json::from_str(&json).unwrap();
assert!(decoded.vhosts[0].acl_profile_ids.is_empty());
let snapshot = compile(decoded, 1, &policy()).unwrap();
let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.profile_policies.len(), 1);
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
}
} }
+23 -12
View File
@@ -54,6 +54,8 @@ pub struct Agent {
pub poll_interval_seconds: u64, pub poll_interval_seconds: u64,
pub certificate_id: Option<String>, pub certificate_id: Option<String>,
pub acl_profile_id: Option<String>, pub acl_profile_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acl_profile_ids: Vec<String>,
#[serde(alias = "target_ip_override")] #[serde(alias = "target_ip_override")]
pub default_target_ip: Option<IpAddr>, pub default_target_ip: Option<IpAddr>,
pub listener_ids: Vec<String>, pub listener_ids: Vec<String>,
@@ -72,6 +74,7 @@ impl Default for Agent {
poll_interval_seconds: 30, poll_interval_seconds: 30,
certificate_id: None, certificate_id: None,
acl_profile_id: None, acl_profile_id: None,
acl_profile_ids: Vec::new(),
default_target_ip: None, default_target_ip: None,
listener_ids: Vec::new(), listener_ids: Vec::new(),
assignments: Vec::new(), assignments: Vec::new(),
@@ -121,6 +124,10 @@ pub struct Assignment {
pub cache_enabled: bool, pub cache_enabled: bool,
pub transform_enabled: bool, pub transform_enabled: bool,
pub acl_profile_id: Option<String>, pub acl_profile_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acl_profile_ids: Vec<String>,
/// Opt in to a deliberately mismatched public certificate for this hostname.
pub allow_certificate_mismatch: bool,
pub acl: Option<crate::acl::Acl>, pub acl: Option<crate::acl::Acl>,
pub proxy_timeouts: crate::vhost::ProxyTimeouts, pub proxy_timeouts: crate::vhost::ProxyTimeouts,
pub health_check: crate::balance::HealthCheck, pub health_check: crate::balance::HealthCheck,
@@ -146,6 +153,8 @@ impl Default for Assignment {
cache_enabled: false, cache_enabled: false,
transform_enabled: false, transform_enabled: false,
acl_profile_id: None, acl_profile_id: None,
acl_profile_ids: Vec::new(),
allow_certificate_mismatch: false,
acl: None, acl: None,
proxy_timeouts: crate::vhost::ProxyTimeouts::default(), proxy_timeouts: crate::vhost::ProxyTimeouts::default(),
health_check: crate::balance::HealthCheck::default(), health_check: crate::balance::HealthCheck::default(),
@@ -283,12 +292,11 @@ impl Settings {
if let Some(ip) = agent.default_target_ip { if let Some(ip) = agent.default_target_ip {
usable_target_ip(ip).context("invalid Docker agent default target IP")?; usable_target_ip(ip).context("invalid Docker agent default target IP")?;
} }
if let Some(profile_id) = agent.acl_profile_id.as_deref() { crate::acl::validate_profile_ids(
ensure!( agent.acl_profile_id.as_deref(),
crate::config::valid_id(profile_id), &agent.acl_profile_ids,
"invalid Docker agent ACL profile id" )
); .context("invalid Docker agent ACL profiles")?;
}
ensure!( ensure!(
agent.listener_ids.len() <= 16, agent.listener_ids.len() <= 16,
"Docker agent allows at most 16 listener references" "Docker agent allows at most 16 listener references"
@@ -324,12 +332,15 @@ impl Settings {
if let Some(ip) = assignment.target_ip { if let Some(ip) = assignment.target_ip {
usable_target_ip(ip).context("invalid Docker assignment target IP")?; usable_target_ip(ip).context("invalid Docker assignment target IP")?;
} }
if let Some(profile_id) = assignment.acl_profile_id.as_deref() { crate::acl::validate_profile_ids(
ensure!( assignment.acl_profile_id.as_deref(),
crate::config::valid_id(profile_id), &assignment.acl_profile_ids,
"invalid Docker assignment ACL profile id" )
); .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"
);
if assignment.force_ssl == Some(true) { if assignment.force_ssl == Some(true) {
let mut has_http = false; let mut has_http = false;
let mut tls_ports = HashSet::new(); let mut tls_ports = HashSet::new();
+18 -5
View File
@@ -67,7 +67,11 @@ pub fn effective_config(
// last successful inventory. // last successful inventory.
let inventory = agent let inventory = agent
.enabled .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(); .flatten();
for assignment in agent for assignment in agent
.assignments .assignments
@@ -81,9 +85,7 @@ pub fn effective_config(
.services .services
.iter() .iter()
.find(|service| service.key == assignment.service_key) .find(|service| service.key == assignment.service_key)
.and_then(|service| { .and_then(|service| route_target(agent, assignment, inventory, service))
route_target(agent, assignment, inventory, service)
})
}) })
.map(|(ip, port)| -> Result<_> { .map(|(ip, port)| -> Result<_> {
usable_target_ip(ip)?; usable_target_ip(ip)?;
@@ -111,6 +113,15 @@ pub fn effective_config(
})] })]
}) })
.unwrap_or_default(); .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 (acl_profile_id, acl_profile_ids) = if override_profiles {
(&assignment.acl_profile_id, &assignment.acl_profile_ids)
} else {
(&agent.acl_profile_id, &agent.acl_profile_ids)
};
let value = serde_json::json!({ let value = serde_json::json!({
"id": generated_id(&agent.id, &assignment.service_key), "id": generated_id(&agent.id, &assignment.service_key),
"frontend": {"hosts": hosts, "listener_ids": agent.listener_ids}, "frontend": {"hosts": hosts, "listener_ids": agent.listener_ids},
@@ -123,7 +134,9 @@ pub fn effective_config(
"compression": {"enabled": assignment.compression_enabled}, "compression": {"enabled": assignment.compression_enabled},
"cache": {"enabled": assignment.cache_enabled}, "cache": {"enabled": assignment.cache_enabled},
"transform": {"enabled": assignment.transform_enabled}, "transform": {"enabled": assignment.transform_enabled},
"acl_profile_id": assignment.acl_profile_id.clone().or_else(|| agent.acl_profile_id.clone()), "acl_profile_id": acl_profile_id,
"acl_profile_ids": acl_profile_ids,
"allow_certificate_mismatch": assignment.allow_certificate_mismatch,
"acl": assignment.acl.clone(), "acl": assignment.acl.clone(),
"proxy_timeouts": assignment.proxy_timeouts.clone(), "proxy_timeouts": assignment.proxy_timeouts.clone(),
"load_balancing": { "load_balancing": {
+59 -1
View File
@@ -58,6 +58,7 @@ mod tests {
poll_interval_seconds: 30, poll_interval_seconds: 30,
certificate_id: Some("docker-cert".into()), certificate_id: Some("docker-cert".into()),
acl_profile_id: None, acl_profile_id: None,
acl_profile_ids: Vec::new(),
default_target_ip: None, default_target_ip: None,
listener_ids: vec!["http".into()], listener_ids: vec!["http".into()],
assignments: vec![Assignment { assignments: vec![Assignment {
@@ -227,7 +228,14 @@ mod tests {
..Default::default() ..Default::default()
}); });
let mut observed = observation(service(), vec![]); 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(); let effective = effective_config(desired, &observed).unwrap();
assert_eq!(effective.vhosts.len(), 1); assert_eq!(effective.vhosts.len(), 1);
assert!(effective.vhosts[0].backends.is_empty()); assert!(effective.vhosts[0].backends.is_empty());
@@ -491,4 +499,54 @@ mod tests {
"generated Docker vhost id is invalid: {id}" "generated Docker vhost id is invalid: {id}"
); );
} }
#[test]
fn docker_assignments_inherit_or_override_acl_sets_and_force_tls_only_on_consent() {
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(),
base_domain: "docker1.lab.local".into(),
listener_ids: vec!["http".into()],
certificate_id: Some("selected-cert".into()),
acl_profile_id: Some("legacy".into()),
acl_profile_ids: vec!["work".into(), "vpn".into()],
assignments: vec![Assignment {
service_key: "container:app:8080/tcp".into(),
hostname: "app".into(),
allow_certificate_mismatch: true,
..Default::default()
}],
..Default::default()
});
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_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].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!(!overridden.vhosts[0].allow_certificate_mismatch);
}
#[test]
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();
assert_eq!(assignment.acl_profile_id.as_deref(), Some("legacy"));
assert!(assignment.acl_profile_ids.is_empty());
assert!(!assignment.allow_certificate_mismatch);
}
} }
+33 -18
View File
@@ -88,10 +88,7 @@ async fn forward_request(
.get::<Arc<crate::config::Snapshot>>() .get::<Arc<crate::config::Snapshot>>()
.cloned() .cloned()
.unwrap_or_else(|| state.config.load_full()); .unwrap_or_else(|| state.config.load_full());
let internal_dispatch = request let internal_dispatch = request.extensions().get::<InternalFallbackRoute>().cloned();
.extensions()
.get::<InternalFallbackRoute>()
.cloned();
// Retain the original URI's shared backing bytes independently of `request`. // Retain the original URI's shared backing bytes independently of `request`.
// A normalized, already-safe path can then be borrowed for the entire // A normalized, already-safe path can then be borrowed for the entire
// forwarding operation, even after the request URI/headers are rewritten. // forwarding operation, even after the request URI/headers are rewritten.
@@ -181,9 +178,9 @@ async fn forward_request(
return response; return response;
} }
if !tls { if !tls {
if let Some(response) = frontend.and_then(|frontend| { if let Some(response) =
force_ssl_redirect(frontend, &host, request.uri()) frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
}) { {
return response; return response;
} }
} }
@@ -196,7 +193,9 @@ async fn forward_request(
&original_host, &original_host,
request.uri(), request.uri(),
frontend.is_some_and(|frontend| frontend.tinyauth.is_some()), 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), .map(Arc::as_ref),
host_already_validated, host_already_validated,
) { ) {
@@ -237,7 +236,10 @@ async fn forward_request(
let mut country: Option<String> = None; let mut country: Option<String> = None;
if let Some(frontend) = frontend { if let Some(frontend) = frontend {
if capture_country if capture_country
|| frontend.profile_country_access.needs_geoip() || frontend
.profile_policies
.iter()
.any(|profile| profile.countries.needs_geoip())
|| frontend.rate_limit.needs_geoip() || frontend.rate_limit.needs_geoip()
{ {
country = cloudflare_country country = cloudflare_country
@@ -250,9 +252,8 @@ async fn forward_request(
*exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone(); *exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone();
} }
} }
if !crate::acl::allows_with_country( if !crate::acl::allows_profiles_with_country(
frontend.profile_acl.as_ref(), &frontend.profile_policies,
&frontend.profile_country_access,
context.client_ip, context.client_ip,
country.as_deref(), country.as_deref(),
) || !crate::acl::allows_with_country( ) || !crate::acl::allows_with_country(
@@ -272,7 +273,10 @@ async fn forward_request(
&frontend.id, &frontend.id,
context.client_ip, context.client_ip,
country.as_deref(), 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, &frontend.rate_limit,
) { ) {
let mut response = error( let mut response = error(
@@ -296,7 +300,17 @@ async fn forward_request(
); );
} }
if cache_http_invalidation_method(request.method()) { 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 { if let Some((_redirect, rule)) = redirect {
let authority = original_host.to_str().unwrap_or(&host); let authority = original_host.to_str().unwrap_or(&host);
@@ -435,10 +449,11 @@ async fn forward_request(
) )
}); });
}; };
let fallback_body = match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await { let fallback_body =
Ok(body) => body, match prepare_error_fallback_body(&mut request, route.error_fallback.is_some()).await {
Err(response) => return response, Ok(body) => body,
}; Err(response) => return response,
};
let ( let (
backend, backend,
backend_lease, backend_lease,
+5
View File
@@ -285,6 +285,11 @@ pub fn for_all_frontends(
if let Some(id) = &vhost.certificate_id { if let Some(id) = &vhost.certificate_id {
let hosts = vhost.routing_hosts()?; let hosts = vhost.routing_hosts()?;
assign(&hosts, id, "vhost")?; assign(&hosts, id, "vhost")?;
if vhost.allow_certificate_mismatch {
for host in hosts {
result.forced_mismatches.insert((id.clone(), host));
}
}
} }
} }
for redirect in redirects { for redirect in redirects {
+9 -4
View File
@@ -372,10 +372,15 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<A
.strip_prefix("*.") .strip_prefix("*.")
.map(|base| format!("rustproxy-wildcard-check.{base}")) .map(|base| format!("rustproxy-wildcard-check.{base}"))
.unwrap_or_else(|| host.clone()); .unwrap_or_else(|| host.clone());
let mut checker = ResolvesServerCertUsingSni::new(); if !settings.forced_mismatches.contains(&(cert.id.clone(), host.clone())) {
checker let mut checker = ResolvesServerCertUsingSni::new();
.add(&validation_name, key.clone()) checker
.with_context(|| format!("certificate does not cover {host}"))?; .add(&validation_name, key.clone())
.with_context(|| format!("certificate does not cover {host}; explicit Vhost consent is required to override"))?;
} else {
tracing::warn!(certificate = %cert.id, hostname = %host,
"Explicitly serving a TLS certificate without hostname verification; clients may reject it");
}
names.insert(host, Arc::new(key.clone())); names.insert(host, Arc::new(key.clone()));
} }
if settings.default_certificate.as_deref() == Some(cert.id.as_str()) { if settings.default_certificate.as_deref() == Some(cert.id.as_str()) {
+42
View File
@@ -431,4 +431,46 @@ mod tests {
); );
assert!(certificate_covered_hosts(dir.path(), "../legacy.pem").is_err()); assert!(certificate_covered_hosts(dir.path(), "../legacy.pem").is_err());
} }
#[test]
fn mismatched_named_certificate_requires_explicit_per_vhost_consent() {
let dir = tempfile::tempdir().unwrap();
initialize_default(dir.path()).unwrap();
generate(dir.path(), "named", vec!["right.example.test".into()]).unwrap();
let settings = Settings {
certificates: vec![Certificate {
id: "named".into(),
issuer: None,
covered_hosts: vec!["right.example.test".into()],
hosts: vec![],
cert_file: "named.pem".into(),
key_file: "named.key".into(),
}],
..Default::default()
};
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
let vhost = &mut config.vhosts[0];
vhost.certificate_id = Some("named".into());
vhost.frontend.hosts = vec!["other.example.test".into()];
let blocked = for_vhosts(&settings, &config.vhosts).unwrap();
assert!(compile(&blocked, Some(dir.path())).is_err());
assert!(blocked.forced_mismatches.is_empty());
vhost.allow_certificate_mismatch = true;
let forced = for_vhosts(&settings, &config.vhosts).unwrap();
assert!(forced.forced_mismatches.contains(&("named".into(), "other.example.test".into())));
assert!(compile(&forced, Some(dir.path())).is_ok());
let saved = serde_json::to_value(&forced).unwrap();
assert!(saved.get("forced_mismatches").is_none());
let restored: Settings = serde_json::from_value(saved).unwrap();
assert!(restored.forced_mismatches.is_empty());
assert!(compile(&restored, Some(dir.path())).is_err());
let mut other = config.vhosts[0].clone();
other.id = "another".into();
other.frontend.hosts = vec!["third.example.test".into()];
other.allow_certificate_mismatch = false;
config.vhosts.push(other);
let partial = for_vhosts(&settings, &config.vhosts).unwrap();
assert!(compile(&partial, Some(dir.path())).is_err());
}
} }
+4
View File
@@ -25,6 +25,9 @@ pub struct Settings {
#[serde(default)] #[serde(default)]
pub certificates: Vec<Certificate>, pub certificates: Vec<Certificate>,
pub policy: ProtocolPolicy, pub policy: ProtocolPolicy,
/// Derived from explicitly confirmed Vhosts; never accepted from TLS JSON.
#[serde(skip)]
pub forced_mismatches: HashSet<(String, String)>,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
@@ -32,6 +35,7 @@ impl Default for Settings {
default_certificate: None, default_certificate: None,
certificates: Vec::new(), certificates: Vec::new(),
policy: ProtocolPolicy::default(), policy: ProtocolPolicy::default(),
forced_mismatches: HashSet::new(),
} }
} }
} }
+18 -3
View File
@@ -58,7 +58,10 @@ pub struct MethodPolicy {
} }
impl MethodPolicy { impl MethodPolicy {
pub fn validate(&mut self) -> Result<()> { 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(); let mut seen = std::collections::HashSet::new();
for method in &mut self.methods { for method in &mut self.methods {
*method = method.trim().to_ascii_uppercase(); *method = method.trim().to_ascii_uppercase();
@@ -66,7 +69,10 @@ impl MethodPolicy {
!method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(), !method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(),
"invalid HTTP method in method policy" "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!( ensure!(
self.mode == MethodPolicyMode::Off || !self.methods.is_empty(), self.mode == MethodPolicyMode::Off || !self.methods.is_empty(),
@@ -276,6 +282,10 @@ pub struct Vhost {
pub tinyauth: crate::tinyauth::Settings, pub tinyauth: crate::tinyauth::Settings,
#[serde(default)] #[serde(default)]
pub certificate_id: Option<String>, pub certificate_id: Option<String>,
/// Explicitly accept serving an SNI name with a certificate that does not
/// contain it in DNS SAN. Clients will still report a TLS name mismatch.
#[serde(default)]
pub allow_certificate_mismatch: bool,
/// Optional protocol/cipher override. None inherits the global TLS policy. /// Optional protocol/cipher override. None inherits the global TLS policy.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_policy: Option<crate::tls::ProtocolPolicy>, pub tls_policy: Option<crate::tls::ProtocolPolicy>,
@@ -302,13 +312,18 @@ pub struct Vhost {
/// Optional reusable ACL profile. The profile and the inline ACL are both enforced. /// Optional reusable ACL profile. The profile and the inline ACL are both enforced.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub acl_profile_id: Option<String>, pub acl_profile_id: Option<String>,
/// Additional reusable profiles. Explicit denies outrank all allows across
/// selected profiles. Inline ACL remains an independent restriction.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acl_profile_ids: Vec<String>,
#[serde(default)] #[serde(default)]
pub acl: Option<Acl>, pub acl: Option<Acl>,
#[serde(default)] #[serde(default)]
pub headers: HeaderRules, pub headers: HeaderRules,
#[serde(default)] #[serde(default)]
pub security_headers: SecurityHeaders, 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. /// Nginx-style upstream timeout controls. Empty values inherit process defaults.
#[serde(default)] #[serde(default)]
pub proxy_timeouts: ProxyTimeouts, pub proxy_timeouts: ProxyTimeouts,
+46 -16
View File
@@ -71,7 +71,10 @@ impl Vhost {
!self.websocket_rfc9220 || self.http3_enabled, !self.websocket_rfc9220 || self.http3_enabled,
"RFC 9220 requires HTTP/3 to be enabled for the vhost" "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(); let mut hosts = std::collections::HashSet::new();
for host in &mut self.frontend.hosts { for host in &mut self.frontend.hosts {
*host = crate::config::normalize_host_pattern(host)?; *host = crate::config::normalize_host_pattern(host)?;
@@ -93,9 +96,11 @@ impl Vhost {
let _ = self.www_redirect_pair()?; let _ = self.www_redirect_pair()?;
let _ = self.routing_hosts()?; let _ = self.routing_hosts()?;
} }
if let Some(id) = &self.acl_profile_id { crate::acl::validate_profile_ids(self.acl_profile_id.as_deref(), &self.acl_profile_ids)?;
ensure!(crate::config::valid_id(id), "invalid ACL profile id"); ensure!(
} !self.allow_certificate_mismatch || self.certificate_id.is_some(),
"certificate mismatch override requires an explicitly selected certificate"
);
self.method_policy.validate()?; self.method_policy.validate()?;
ensure!( ensure!(
!self.frontend.listener_ids.is_empty(), !self.frontend.listener_ids.is_empty(),
@@ -159,7 +164,10 @@ impl Vhost {
"location cannot define both a local response and a redirect" "location cannot define both a local response and a redirect"
); );
if let Some(id) = &route.acl_profile_id { 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.country_access.validate()?;
route.method_policy.validate()?; route.method_policy.validate()?;
@@ -197,13 +205,21 @@ impl Vhost {
// Reject cycles at configuration time; a runtime depth guard remains as a // Reject cycles at configuration time; a runtime depth guard remains as a
// second line of defense for hot-reload/state inconsistencies. // second line of defense for hot-reload/state inconsistencies.
let mut starts = vec![None]; 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 { for start in starts {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let mut current = start; let mut current = start;
loop { loop {
let key = current.unwrap_or("<main>"); 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 { let fallback = match current {
None => self.error_fallback.as_ref(), None => self.error_fallback.as_ref(),
Some(id) => self Some(id) => self
@@ -221,7 +237,9 @@ impl Vhost {
if let Some(custom) = &member.health_check { if let Some(custom) = &member.health_check {
let mut settings = self.load_balancing.clone(); let mut settings = self.load_balancing.clone();
settings.health_check = custom.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()?; self.proxy_timeouts.validate()?;
@@ -242,7 +260,8 @@ impl Vhost {
"choose either Tinyauth or Basic authentication" "choose either Tinyauth or Basic authentication"
); );
self.pages.validate()?; self.pages.validate()?;
self.transform.resolve(&crate::transform::Settings::default())?; self.transform
.resolve(&crate::transform::Settings::default())?;
self.security_headers.compile()?; self.security_headers.compile()?;
self.user_agent_blocking.compile()?; self.user_agent_blocking.compile()?;
self.headers.compile()?; self.headers.compile()?;
@@ -317,8 +336,10 @@ pub fn validate_listener(listener: Listener) -> Result<()> {
/// counterpart must both be routable, even if only one was configured. /// counterpart must both be routable, even if only one was configured.
fn www_partner(host: &str) -> Result<String> { fn www_partner(host: &str) -> Result<String> {
if let Some(bare) = host.strip_prefix("www.") { if let Some(bare) = host.strip_prefix("www.") {
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(), ensure!(
"www redirect requires a DNS hostname"); !bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname"
);
crate::config::normalize_host(bare) crate::config::normalize_host(bare)
} else { } else {
crate::config::normalize_host(&format!("www.{host}")) crate::config::normalize_host(&format!("www.{host}"))
@@ -326,14 +347,23 @@ fn www_partner(host: &str) -> Result<String> {
} }
impl Vhost { impl Vhost {
fn www_canonical_host(&self) -> Result<&str> { 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)) .or_else(|| self.frontend.hosts.first().map(String::as_str))
.ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?; .ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?;
ensure!(!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(), ensure!(
"www redirect requires an exact DNS hostname"); !canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname"
);
let partner = www_partner(canonical)?; let partner = www_partner(canonical)?;
ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner), ensure!(
"www redirect target or its www/non-www partner must belong to this vhost"); 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) Ok(canonical)
} }