diff --git a/AGENTS.md b/AGENTS.md index 66ce151..fdde1fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for -compatibility. Current source version: 0.32.4. +compatibility. Current source version: 0.32.5. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c09499..b5c0ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ > Operator-facing release history. Historical engineering notes are kept in > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.32.5 + +- Add bounded DNS/DDNS hostnames as **allow-only** client ACL rules in reusable + profiles, host policies, vhosts, locations and Docker ACL assignments. +- Refresh A/AAAA answers asynchronously every 60 seconds and after config + publication; cache immutable, expiring address sets for lock-free request + checks. Fail closed on lookup failure and initial unresolved state. +- Add IP/CIDR vs DNS/DDNS selection in the ACL profile editor and localized + operator guidance; preserve the JSON ACL rule format and previous policies. +- Add DNS ACL validation/regression tests. Cargo and real rotation testing + remain required on a Rust-enabled staging host. + ## 0.32.4 - Fix Rust `E0382` in retry construction: clone the upstream URI before diff --git a/Cargo.lock b/Cargo.lock index e8bf462..566f14b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.32.4" +version = "0.32.5" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 919e892..56f9bb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.32.4" +version = "0.32.5" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index f43b70a..a770cca 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustProxy 0.32.4 +# RustProxy 0.32.5 **0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md). @@ -7,7 +7,32 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos **0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.** -## What changed in 0.32.4 +## What changed in 0.32.5 + +- **Dynamic DNS/DDNS ACL allow rules:** reusable ACL profiles, host policies, + inline vhost/location ACLs, and Docker ACL assignments now accept `allow + office.example.ddns.net` in place of a static client IP/CIDR. Existing IP, + CIDR and country-based controls retain their previous precedence. +- DNS answers (A and AAAA) are refreshed every **60 seconds**, with up to + **128 distinct hostnames**, **32 unique addresses per hostname**, **8 parallel + lookups** and a **4-second timeout**. A failed/empty/invalid resolution is + removed from the allowlist (fail closed); successful entries expire within + 180 seconds if refresh tasks stall. Initial access is denied until the first + successful lookup. No DNS lookup or locks on the proxy request path. +- The ACL profile editor now offers an IP/CIDR vs DNS/DDNS selector and + explains the allow-only semantics. Existing JSON documents keep the `network` + string format; no database migration, extra public route or backend resolver + behavior change. Name resolution follows the machine's DNS configuration. +- DNS names are **allow-only**: dynamic deny rules are rejected because a DNS + outage could otherwise silently remove a security deny. Hostnames must be + ASCII FQDNs (punycode for IDNs, no wildcards, URL or ports). +- **Security note:** allowing a DDNS hostname delegates address authorization + to whoever controls its DNS records. Use trusted domains and keep a + default-deny ACL profile; old IPs may remain allowed until the next DNS refresh. +- Rust/Cargo compilation and real DNS/address-rotation testing are **pending** + before production deployment. + +## Previously in 0.32.4 - **Follow-up build correction:** a retry request now passes a cloned URI to the HTTP request builder, retaining the original for backend-specific diff --git a/ROADMAP.md b/ROADMAP.md index 0e8cbdf..eee9222 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # RustProxy roadmap +## 0.32.5 - Dynamic DNS client ACLs (runtime acceptance pending) + +- [x] Preserve CIDR/IP rules and introduce allow-only FQDNs for client ACLs. +- [x] Add bounded A/AAAA periodic resolver, immutable cache, expiration and + immediate refresh on published configuration changes. +- [x] Add DNS/DDNS editor selector, PL/EN guidance and source/regression tests. +- [ ] Compile and test Rust with Cargo; stage live DDNS rotation, DNS outage, + mixed IPv4/IPv6 answers, vhosts and trusted-forwarded-client-IP paths. + ## 0.32.4 - Upstream Host/authority parity and Overview gutters (runtime acceptance pending) - [x] Remove `preserve_host` H1-only validation and the Auto/H2 UI forced-uncheck. diff --git a/SHA256SUMS b/SHA256SUMS index 233b5b6..631c3f6 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -4c6c3590caca877484ca256335bb1c9fcfa9736999f9c74d0a46ba8255894f49 AGENTS.md -e055e50b5a46ba97e4bcddbac7f5f0635efb10240ac8039ac5d01c70672c5372 CHANGELOG.md -d66bffcc09f1e3c351e639ed46c0b8c4ff4d814b81c410a96a5ace9f78cdd34a Cargo.lock -d9f6c09426dbfbd0bd876efb57722e252dfce1090b489a06c2086852307a6583 Cargo.toml +ad0382153576411fb19861072635c02bdf33de740d6f122d8f27bd9c52ce112a AGENTS.md +91c6942b3a0d24d61bcfd43f9ee9fc8f3f5d889aeaae63dde118849d64a283c5 CHANGELOG.md +2f1de87bae58761fc8690ec7225520e3a16213c77cbbb5981e34f12a95810668 Cargo.lock +8c76ffaf4adba93df26e30f3dd163e0e5e201d069d1ae69c071ca42d7fc7264e Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -f99952e718c956a34bc2c4e81a2ccccaef767c7567fe76a29e05e05beaa9e2fa README.md -3aa4c5b581dd179d3926676c65870815cbfd47bda7b8e60741e5ac9712f8fbbb ROADMAP.md +31e13d43d5432731ca7f1bfd29d73eb2b62b8d23fe8678205f712f0daf9908c6 README.md +fc03a8d19055642313f0b34bcd22e1cce17efc4d91be7fe7e4819e33b91c531d ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service -7b14a253256617d7a0618f84964ff0ced0a5d5a20e99c7e2c2581b4147dc829d dev.sh +3e709b09ddf4e2c17497c66b416d07e1423eeeb5f29a9100b06c8d3d5cacc36a dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example @@ -61,9 +61,9 @@ d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.m ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md -c90409f4dc979e72998983f4c470c10a07e9ae1f9a551d4c06d6b77400ca9831 docs/SETTINGS.md +25a06e7ddbb95d286e849b2fc5ba4adeedbddba0bad948e55bd91d962fd606de docs/SETTINGS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md -464862ebc9f53615361b910dac0e936f61bf0588f0c3ca03b1626886b5c3befb docs/TEST_REPORT.md +e79adbe06155b024adfe8d072abba36394b3c6ef2007920627c217e290c08a88 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md fe3ec6ca16ff933239c12dacf39dd7b45717c0551a8b9f6ce601bf78136c9e37 docs/TRANSPORT.md @@ -95,8 +95,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md -fa53054d2108a8b0935a5c595c609a224f2a03694fcdee27137e197d15b8921c langs/en_US.json -776eb519bea38465945a13fd2ebe6e4b24682d39d7fd6a4d6f58be01abd41a70 langs/pl_pl.json +1c3bdb963bc4fa4eef48439721d7fee54375458f3a1d44f9e31c0c4ed7aca4f0 langs/en_US.json +70d8ad7d503118356721ff7953b829049da793934dd73f4abbbbd7c7f0b4fc63 langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -131,8 +131,10 @@ f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_s 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/tests/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/tests/test_stream_udp.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py -ad62f73bb42906d617cc1b479f02d4648c7324cb89fc11d2c6aa6bf13774eec9 src/acl.rs -ea272433e7f92e983509a8ab1aa54242440abef21df526c2f99944ef279e09b9 src/acl/tests.rs +55485a513e8c4da2a58daa7abcb15b32ed408a5fc062cf23599fb83d16b90cfe src/acl.rs +a0690d0d48bb92e53f4b94ce430ac434736ec2860c03ac4d50bd94df49706458 src/acl/dynamic.rs +2b452b2819f9af4f60c984f98b5fb1e4e79353dee4fad1add6ecb540b7be1c74 src/acl/dynamic/tests.rs +a22e5ea6381a1eacafe650be0f5cd6e653773e31f6491a562fe0459a309fb7cc src/acl/tests.rs 6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs 587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs 3f158f46e80b403b658b4e9b1e3710e38c70e47b2d476b8223d9c394ffeeba95 src/acme/model.rs @@ -154,7 +156,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs 7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs 33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs -eeb447366ceb666ce6e9ddef5f47eeb4052c58872f5a2543b1c9ed536a1989b5 src/admin/openapi.json +c1b8ab15455a8199fbe011c6a7dd203572c4d4772c107d30531a6fb38a92ace2 src/admin/openapi.json d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs @@ -215,12 +217,12 @@ c24411dd62afaecfc631682554d4a037c7dd27100d817e9901ccdfd505c2ed5b src/admin/web/ 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html c2e5f554810b8e34fb47361fb90a01073a2e332ee26d82e030956fad3ec1f83e src/admin/web/login.js -36af1f8f83af7e6f9a60b4d9524714e02c077e539a90aac0ce990d0587c0c3ed src/admin/web/management.js +0d0ec350d713aaf32a33607b2ddf0a8bb1858f2fd31c8396042cec4c84aee6ef src/admin/web/management.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js c28f4254ddc1f720d62646ee0f0b590b60a89c0bf447e8ec74ff3dde0c7635ba src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html e3075af59644e42fa5735574d8e9caa1bcebfe8de184b78aff9f4026b91915f1 src/admin/web/streams.js -478898f011c49862675b65e974727c7e900e6562554f3ae7fb376d598943ea5d src/admin/web/style.css +065a377512c146254a19ce03c299f9d07f54e4f2cb0bccae965ac63e3a96063f src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js d816f2f70eef398c18091479320c36d07f9afe07278a250b840ebcdd7bc715f2 src/admin/web/vhosts/core.js @@ -293,7 +295,7 @@ a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tick e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs 7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs 34be288c858a037c7174f6c4dee23178b01b565aa9d65f0951f351e1685847df src/config/backend.rs -a3d6a9d01dd9b3b602ea2f3dd5559503ea82129544c8944c26ad39445aaa3ad3 src/config/compile.rs +beb831a208c4e5f7ebe8327686b1e0edc66608c7032f895cb4fc3366b189aae0 src/config/compile.rs 490d05d84b2fe349ddde54eae44880993fab70811652484dd9ba38b5069d603b src/config/internal_routing.rs a19fc675720c7c2dbf4bba706e1b1c30c83c6df1da3f29e3cbb3b82442e7246a src/config/model.rs f66c2388f31b19142c12469df23d43451e0f7c48b158d4996920282c06d32076 src/config/routing.rs @@ -335,7 +337,7 @@ f5815d778cc1785723faed4abb15f9880f22b6524b5289fe1e7158905aac0986 src/logging/mo 42fef80af50735cd566c6801dad86ba81be98cf4a8ec7d45c2ea26f53f556a22 src/logging/runtime.rs 03c50bd14fecd421b0901a5f0af891dc277e35420e32068781e3a6d78c88c24b src/logging/tests.rs 3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs -788cdeb96e887f85dc24798ec69b56619ee35fabeac5a40f8fa071bbf6cf24c6 src/main/admin_runtime.rs +fc8e99eac83c083d65101f6402aacbcc8392d66508c31566a50de3ebcb12e7da src/main/admin_runtime.rs 42a9197475823f67c1ca762951ede5d7a2f10124bd6ed4d1a3365f4297be894c src/main/bootstrap.rs 5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs 4dd7c1084a6214c0f2513ebf86fe8ef144470695557772bfe25979edd64491ab src/main/shutdown.rs @@ -391,12 +393,12 @@ e7e3a898e2c769b1ef11e58a13c1053104ada93dd694f8a03ea4b647c44c7d69 src/rate_limit d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.rs 811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs -4e0b25c2b86eefddd76c64b39dc1c1cd6d243addc32cdea99e6c3ac84ed879a6 src/runtime/certificates.rs +8d0d7b01871c8c588c40a54330a1053c85024655fbe38516ca6c94c3c19a8956 src/runtime/certificates.rs 8d2443df8dab1ce6f11878e991b4928046b7362c417c5703f6d1f3a977646cbe src/runtime/constructors.rs -31dc5cdea00efd63594dc6becd5a9103594696145cbc8e7b7594fb407214ad9d src/runtime/docker.rs +92b4b284f1b69dca58e4e3f76199f0aecc397eed26d10123b427af1bb8a4a57e src/runtime/docker.rs 6b2b57888863a396461fff202bfdaa6d8e869b2628cc171c0823f31f388e5178 src/runtime/tests.rs 4036b48925f5b25de70e21dfbb278488c20b39248dcda4cad4d19d5d7aed2b8d src/runtime/types.rs -88bc546fdda20d3f1f8f5e7d7872ede1fa334b8bb5102c729bd72f56e3fb94b9 src/runtime/updates.rs +e1d172acd2b230b8f4dd671cae8862a76f28416cbf0e43f86214a796edd90fab src/runtime/updates.rs 208a378b14ca80e1412ffa19ef1b140594182971e2e797ee0bb95bcdb8404da7 src/secret_store.rs 6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs 6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs @@ -500,19 +502,20 @@ c9d0bef4b8ed7a2f49b0fad3e116cace62d3e6e1ca31e02a3073d1fdde5157d8 tools/checks/a f3dba206c60cd2e6fcc5f4a2c8d652ccf47362694448312cc1f8dc4112d93e7e tools/checks/backend_cache_0297_ui_test.mjs f34f0f78edc4b6c6226006337090c72d1f8e57ddd77939b37cf00538a05abfa8 tools/checks/backend_dashboard_0323_ui_test.mjs 45cd21192248ffe6f66c20bab03da1a5ce299f040883764d09a640859e19924d tools/checks/backend_health_0300_ui_test.mjs -03492edbaa3aa67d81a0b0ce71f849a870c695c8ea436f02ce270701f5476738 tools/checks/certificate_api_0319.py +48359559ca415890acbef0f2717bb4b7acacdc26b6cf5f78135a9d9637f83547 tools/checks/certificate_api_0319.py 526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs -334771ffcca95c18e91d10d1ef053d2085d6addcff4ebd0da779c30800cce9fc tools/checks/check_source.py +d48c5080b34d2df18d0f3bf16cf7b58a36efbe4f6f2b96724581dea2b1a91544 tools/checks/check_source.py dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs 7508f232a9e48049002dd4c9f34f9737bc92d304c51219f50430578dbc721855 tools/checks/dashboard_inline_layout_0323_ui_test.mjs dba768a42247cdd01863a032cf7ba223a52240285e70b0f7a0abd4a2e68c5fa1 tools/checks/dashboard_layout_editor_0323_ui_test.mjs 259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs -29d2c6e7473cffd9d7e486e1dc377a47c25e786fd173e697a882606273601cdf tools/checks/dependency_upgrade_0312.py +f6a6d12275bc8ba627b4616a546d075511094bb064cd9058231cc86ca489743e tools/checks/dependency_upgrade_0312.py 38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs -e233c4644b7c85459aafb576c59fe7d6cea184db4778204edd9ab269724d2c62 tools/checks/docs_check.py +3396e5a510787e70e2ad8d1fad90356af461a88fd84429525d56fa82113da0f3 tools/checks/docs_check.py +2d44f85ec083dedfee60dc15e65e040c6c208614411458b36a2fca8b5762f4f7 tools/checks/dynamic_acl_0325.py 5bf26d9c883704e5ac85670bbcb7fb0bbad5d01779ab50407ff4fd5f3022a9cb tools/checks/file_logs_tabs_0315_ui_test.mjs 72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs -0ff6b762ad2b727c4354e0f0710651a51282f14466072960f958a116551246e9 tools/checks/h3_navigation_0316.py +1b5ead59bb2ea0e1ff32bf085f791018149cb9cc29b071fae4fb98ea80010824 tools/checks/h3_navigation_0316.py e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py @@ -526,26 +529,26 @@ f450e97dccf75c24e228e696f21ed4ae826a823ea4edde2429894a6b6b36eaab tools/checks/o a1e68daf3a72b65545b0f5346094cbc0357c80d88504603090b776ef770f08de tools/checks/overview_wide_masonry_0323_ui_test.mjs 1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/preflight.py -c5163e47b07393f4101c0bacb78d6041b3738bb2ab01c547d2b7511abbbe54b1 tools/checks/preserve_host_0324.py +200e86cb2598bd3b422187c6421d10017f0b39afb137aaf7614858e1b3924592 tools/checks/preserve_host_0324.py 3352941648add6d8a8044c0b5cad6bf264546d38c884a5beed64cd95addd850c tools/checks/preserve_host_0324_ui_test.mjs 021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs -af42355fc7aeca631e3e24dcaadc75a721f9031d1c339d248d649b89c572ec54 tools/checks/release_0318.py +56f8f6839df0e65a1c9a4b64bdd5d8ce7bd3dcd76280f85181d2a6c333308e13 tools/checks/release_0318.py e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs -689ec03d97e89af1704a3e4fe04a4cec495676da589d2ff5565ae051a2de54c6 tools/checks/release_smoke.py +62350e7fb5d4c4cfbe1b68ada1176c655922706dc2ebabc70913f5189ffa4155 tools/checks/release_smoke.py 31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py 23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py -0087a7d7b766ab9183cc45a8a93396d05c004f5899a193fc36b5e1151d7fc93f tools/checks/security_logs_0315_ui_test.mjs +c8bdf822bfc972614b47caf3d6bfb392f1c4c96b43fc0113b963a8def73b7e7d tools/checks/security_logs_0315_ui_test.mjs 011bbe65dae072999a3951aba3d74a520926abf836b784cdb2395023d2942867 tools/checks/source_module_contracts.py 37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py bb862620104f925488e36904e258b7c934a097362ef879ecb275c64008cebb83 tools/checks/stream_0320_ui_test.mjs 19cb347c87b0fa462fc2f3f2f5bef62e93628334ab4b8a1776df417e47c91ef0 tools/checks/stream_0320_visibility_regression.mjs 8542f5c668155aa9f70de11e6fdaf64c5a80629a15ecc5f1d8c40f3d655f9b55 tools/checks/system_info_0301_ui_test.mjs 543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py -3b703261c71c0f128a385ea9996da89abbe2cfbbf1a4b381184653cc28aaf84d tools/checks/upstream_protocols_0323.py +760188db7aaae178c5f4570c1bcfac8b6cffe2e2e0522bb317331dc44515cbe5 tools/checks/upstream_protocols_0323.py a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py 161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs ea98e8ce2bdc841814e486ea4c13f94e284b2c50a5887598b7a288f75a14deaf tools/checks/vhost_www_ui_test.mjs diff --git a/dev.sh b/dev.sh index a9f517c..ca3cd41 100755 --- a/dev.sh +++ b/dev.sh @@ -100,6 +100,7 @@ if [[ "$mode" == "test" ]]; then python3 tools/checks/version_contracts.py python3 tools/checks/upstream_protocols_0323.py python3 tools/checks/preserve_host_0324.py + python3 tools/checks/dynamic_acl_0325.py python3 tools/checks/check_source.py python3 tools/checks/release_smoke.py python3 tools/checks/memory_contracts.py diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 9d7fbb8..844587d 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -607,3 +607,30 @@ The sample charts are browser-local, bounded and reset on a changed config revision, logout or source unavailability. They are not computed while hidden. No additional polling, SQLite schema migration or new administrator endpoint is introduced. Existing Stream widgets remain independent. + + +## Dynamic DNS / DDNS client ACL (0.32.5) + +In **ACL profiles**, add an **Allow** rule and select **DNS / DDNS** instead of +IP/CIDR. Enter the hostname without a URL, port or wildcard, for example: + +```text +allow myoffice.example.ddns.net +``` + +Apply the profile to the desired vhost. Use the **Allow exceptions** preset +(default deny) to admit only resolved addresses (and any other explicitly +allowed IP/country exceptions). Host policies and inline vhost/location ACLs +accept the same rule format. A/AAAA records are refreshed every 60 seconds, +with immediate refresh on published configuration changes. No request-path DNS +lookup is performed. The initial state and failed, empty, oversized, or expired +DNS results **do not authorize any client IP** for that DNS rule. Only +**allow** rules may contain DNS names; `deny example.com` is rejected. + +Limits: 128 distinct names across active ACLs, 32 returned IPs/name, four- +second lookup timeout and eight parallel DNS lookups. Successful answers expire +after 180 seconds if the refresh task stalls. DNS changes may take up to the +next poll interval to take effect. The chosen DDNS provider controls which IPs +receive access; always trust the provider and protect its credentials. Existing +IP/CIDR ACLs and country rules are unchanged. No new admin access path or +backend origin DNS exceptions are created by this feature. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 72dba1f..e69de29 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,63 +0,0 @@ -# Verification report - RustProxy 0.32.4 - -## Scope - -Review of the supplied RustProxy 0.32.3 source and patch to 0.32.4: -HTTP Host / HTTP/2 authority parity, explicit TCP/DNS destination and TLS/SNI -isolation, relaxed HTTPS-by-IP troubleshooting hint, and uniform Overview gutters. -Follow-up patch to the first 0.32.4 archive: -- Fix reported `E0382` in `src/proxy/types.rs`: `Request::builder().uri(uri)` - moved `uri` before `upstream_request_target(&uri, ...)` could borrow it. - `.uri(uri.clone())` preserves the original URI for authority selection. -- Make normal Overview gutters uniformly 4px (dense 3px, comfortable 12px). - Resolve the higher-specificity `html[data-dashboard-density] .stats-grid` - rule which otherwise overrode the aligned grid and kept the larger gaps. -Existing database/configuration schemas and retry/transport policies are -unchanged. Only compiling Rust and live protocol tests prove runtime behavior. - -## Available checks - -- `./dev.sh test`: **all pre-Cargo checks passed**, including source/security, - SQLite migration/upgrade/backup simulations, memory-model scripts and **35 - Node.js UI/behavior test scripts**. The command then exited **127** because - Rust/Cargo is not installed; this is **not** a passing Rust test run. -- New `preserve_host_0324.py` and `preserve_host_0324_ui_test.mjs` passed: - authority/destination split source contracts, four protocol selectors, - verified/unencrypted origin warnings, explicit diagnostics preset. -- Expanded Chromium CSS fixture `tools/checks/overview_gutters_0324_browser.py`: - **27 checks passed** (3 container widths x 3 UI density modes x 3 Dashboard - densities), checking horizontal/vertical gutters, equally high rows and - numeric-to-large-card transition. Normal/dense/comfortable gaps measured - **4/3/12px** and the outer grid bottom margin remained `0px`. - The older `overview_grid_spacing_0323_browser.py` also passed with all - 62 blocks, responsive breakpoints and browser zoom 90%-130%. - These are simulated layout tests, not a live administrator session. -- `preserve_host_0324.py` now guards against moving `uri` before its final - authority adjustment, in addition to the backend/transport source contracts. - The existing Rust unit test for retry authority remains in the source. -- Version consistency: Cargo manifest, package root in existing lockfile, - OpenAPI, README, release notes and static assertion fixtures updated to 0.32.4. - External Cargo dependency resolutions have **not** been regenerated. - -## Rust validation not performed (blocking for production) - -The user's Rust compiler reported `error[E0382]: borrow of moved value: uri` -from the first 0.32.4 archive. This has been corrected in source, but **the -corrected code has not been compiled here**: no Cargo or Rust compiler is -available in the source-editing environment. -Do not treat static tests or the simulated TLS-peer tests added to Rust source -as evidence that RustProxy builds or that an actual Angie backend accepts the -corrected requests. Run, on a Rust 1.99+ host: - -```sh -cargo fmt --all -- --check -cargo check --locked --all-targets -cargo test --locked -``` - -Then validate on staging with HTTPS-by-IP and `preserve_host: true` for -HTTP/1.1, Auto (H2 and H1 ALPN), compatible HTTP/2 and strict H2; check -`:authority` vs `Host`, SNI, certificate verification, WebSockets, H2C and -HTTP health probe `host` overrides. Inspect Angie response/error logs for -400 responses and verify backend connectivity through pool rotation/retry. -No claim of universal backend compatibility or measured throughput is made. diff --git a/langs/en_US.json b/langs/en_US.json index 691ef01..15f3039 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -783,7 +783,7 @@ "locationHelp": "The longest matching prefix wins. The original path and query are retained. Select a backend from this host or add a dedicated backend. Host authentication and ACL always apply.", "aclUse": "Enable IP access list", "aclAllow": "Allow", - "aclNetwork": "IP network (CIDR)", + "aclNetwork": "IP/CIDR or DNS name", "aclAction": "Action", "aclAdd": "Add IP rule", "authMode": "Authentication", @@ -1000,7 +1000,7 @@ "aclProfileId": "Profile ID", "aclProfileName": "Display name", "aclProfileIpEnabled": "Enable IP ACL", - "aclProfileRules": "IP rules — one “allow|deny CIDR” per line", + "aclProfileRules": "ACL rules — allow IP/CIDR or DNS hostname; deny IP/CIDR only (one per line)", "aclProfileCombineHelp": "Country and IP exceptions are combined with OR inside this profile. Explicit deny rules and blocked countries win; attached profile, vhost and location policies must still all pass.", "aclProfilePolicyMode": "Choose the base policy", "aclProfilePolicyModeHelp": "Use a guided allow-list or block-list for normal cases. Custom mode keeps full control for mixed policies.", @@ -1028,7 +1028,7 @@ "aclProfilePreviewDefaultAllow": "DEFAULT: ALLOW", "aclProfilePreviewAllowedCountries": "ALLOW COUNTRY", "aclProfilePreviewBlockedCountries": "BLOCK COUNTRY", - "aclProfilePreviewAllowedIps": "ALLOW IP", + "aclProfilePreviewAllowedIps": "ALLOW IP / DNS", "aclProfilePreviewBlockedIps": "BLOCK IP", "aclProfilePreviewIpRules": "IP RULES", "aclProfilePreviewNoExceptions": "No exceptions configured", @@ -1042,7 +1042,7 @@ "aclProfileCountriesShortBlock": "Blocked countries", "aclProfileIpsShortAllow": "Allowed IPs", "aclProfileIpsShortBlock": "Blocked IPs", - "aclProfileIpRulesShort": "IP rules", + "aclProfileIpRulesShort": "IP/DNS rules", "aclProfileIpOff": "IP ACL disabled", "aclProfileCountriesOff": "Country filtering disabled", "aclProfileAttached": "Attached to", @@ -2624,6 +2624,11 @@ "cornerStyleSubtle": "Subtle", "cornerStyleRounded": "Rounded", "cornerStyleExtra": "Extra rounded", - "cornerStyleHelp": "Change corners across the interface independently of your color theme. Original preserves the existing shape." + "cornerStyleHelp": "Change corners across the interface independently of your color theme. Original preserves the existing shape.", + "aclRuleType": "Address type", + "aclRuleIp": "IP / CIDR", + "aclRuleDns": "DNS / DDNS", + "aclDnsAllowOnly": "DNS/DDNS addresses only support allow rules. Use IP/CIDR for deny.", + "aclDnsHelp": "DNS/DDNS allow rules resolve A/AAAA every 60 seconds. DNS errors remove access (fail closed). Enter a hostname such as office.example.ddns.net; no URL or wildcard. Select Allow exceptions with default Deny. Up to 128 unique hostnames, 32 addresses per hostname." } } diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 93e5114..b363a4c 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -783,7 +783,7 @@ "locationHelp": "Wygrywa najdłuższy pasujący prefiks. Ścieżka i query pozostają bez zmian. Wybierz backend hosta lub dodaj osobny. Uwierzytelnianie i ACL hosta obowiązują zawsze.", "aclUse": "Włącz listę dostępu IP", "aclAllow": "Zezwól", - "aclNetwork": "Sieć IP (CIDR)", + "aclNetwork": "Adres IP/CIDR lub nazwa DNS", "aclAction": "Akcja", "aclAdd": "Dodaj regułę IP", "authMode": "Uwierzytelnianie", @@ -1000,7 +1000,7 @@ "aclProfileId": "ID profilu", "aclProfileName": "Nazwa wyświetlana", "aclProfileIpEnabled": "Włącz ACL IP", - "aclProfileRules": "Reguły IP — po jednej „allow|deny CIDR” w wierszu", + "aclProfileRules": "Reguły ACL — allow IP/CIDR lub nazwa DNS; deny tylko IP/CIDR (po jednej na wiersz)", "aclProfileCombineHelp": "Wyjątki krajów i IP są łączone operatorem LUB w obrębie tego profilu. Jawne blokady IP i zablokowane kraje wygrywają; profil, vhost i lokalizacja nadal muszą przejść wszystkie swoje polityki.", "aclProfilePolicyMode": "Wybierz bazową politykę", "aclProfilePolicyModeHelp": "Do typowych przypadków użyj prostego trybu zezwoleń albo blokad. Tryb niestandardowy daje pełną kontrolę nad mieszanymi regułami.", @@ -1028,7 +1028,7 @@ "aclProfilePreviewDefaultAllow": "DOMYŚLNIE: ZEZWALAJ", "aclProfilePreviewAllowedCountries": "ZEZWÓL KRAJ", "aclProfilePreviewBlockedCountries": "BLOKUJ KRAJ", - "aclProfilePreviewAllowedIps": "ZEZWÓL IP", + "aclProfilePreviewAllowedIps": "ZEZWÓL IP / DNS", "aclProfilePreviewBlockedIps": "BLOKUJ IP", "aclProfilePreviewIpRules": "REGUŁY IP", "aclProfilePreviewNoExceptions": "Brak skonfigurowanych wyjątków", @@ -1042,7 +1042,7 @@ "aclProfileCountriesShortBlock": "Blokowane kraje", "aclProfileIpsShortAllow": "Dozwolone IP", "aclProfileIpsShortBlock": "Blokowane IP", - "aclProfileIpRulesShort": "reguł IP", + "aclProfileIpRulesShort": "reguł IP/DNS", "aclProfileIpOff": "ACL IP wyłączony", "aclProfileCountriesOff": "Filtrowanie krajów wyłączone", "aclProfileAttached": "Przypięty do", @@ -2624,6 +2624,11 @@ "cornerStyleSubtle": "Delikatne", "cornerStyleRounded": "Zaokrąglone", "cornerStyleExtra": "Mocno zaokrąglone", - "cornerStyleHelp": "Ustawia zaokrąglenia w całym panelu niezależnie od motywu. Oryginalne zachowują dotychczasowy wygląd." + "cornerStyleHelp": "Ustawia zaokrąglenia w całym panelu niezależnie od motywu. Oryginalne zachowują dotychczasowy wygląd.", + "aclRuleType": "Typ adresu", + "aclRuleIp": "IP / CIDR", + "aclRuleDns": "DNS / DDNS", + "aclDnsAllowOnly": "Reguły DNS/DDNS obsługują tylko allow. Do deny użyj IP/CIDR.", + "aclDnsHelp": "Reguły DNS/DDNS typu allow odświeżają rekordy A/AAAA co 60 sekund. Błąd DNS usuwa zezwolenie (fail closed). Podaj nazwę, np. biuro.example.ddns.net, bez URL i gwiazdek. Wybierz wyjątki zezwalające z domyślną blokadą. Maksymalnie 128 nazw i 32 adresy na nazwę." } } diff --git a/src/acl.rs b/src/acl.rs index d01ea41..ed7ffbd 100644 --- a/src/acl.rs +++ b/src/acl.rs @@ -4,6 +4,8 @@ use ipnet::IpNet; use serde::{Deserialize, Serialize}; use std::net::IpAddr; +pub(crate) mod dynamic; + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Action { @@ -42,22 +44,35 @@ pub struct Acl { #[derive(Debug, Clone)] pub struct CompiledAcl { default: Action, - rules: Vec<(IpNet, Action)>, + rules: Vec<(RuleMatch, Action)>, +} +#[derive(Debug, Clone)] +enum RuleMatch { + Ip(IpNet), + Dns(String), } impl Acl { pub fn compile(&self) -> Result { ensure!(self.rules.len() <= 128, "ACL supports at most 128 rules"); let mut rules = Vec::with_capacity(self.rules.len()); for rule in &self.rules { - ensure!(rule.network.len() <= 64, "ACL network is too long"); - let network = parse_network(&rule.network)?; - if let IpNet::V6(net) = network { + ensure!(rule.network.len() <= 254, "ACL network is too long"); + let target = if let Ok(network) = parse_network(&rule.network) { + if let IpNet::V6(net) = network { + ensure!( + net.addr().to_ipv4_mapped().is_none(), + "use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules" + ); + } + RuleMatch::Ip(network) + } else { ensure!( - net.addr().to_ipv4_mapped().is_none(), - "use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules" + rule.action == Action::Allow, + "dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny" ); - } - rules.push((network, rule.action)); + RuleMatch::Dns(dynamic::hostname(&rule.network)?) + }; + rules.push((target, rule.action)); } Ok(CompiledAcl { default: self.default, @@ -73,7 +88,11 @@ impl CompiledAcl { }; let mut allowed = false; for (network, action) in &self.rules { - if network.contains(&peer) { + let matched = match network { + RuleMatch::Ip(net) => net.contains(&peer), + RuleMatch::Dns(host) => dynamic::matches(host, peer), + }; + if matched { if *action == Action::Deny { return Some(Action::Deny); } @@ -124,9 +143,7 @@ pub fn allows_with_country( && (ip_action == Some(Action::Allow) || acl.is_none_or(|value| value.default == Action::Allow)) } - crate::rate_limit::CountryMode::Allow => { - ip_action == Some(Action::Allow) || country_listed - } + crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed, } } /// Validate the already-decoded routing representation used internally after diff --git a/src/acl/dynamic.rs b/src/acl/dynamic.rs new file mode 100644 index 0000000..f08ab65 --- /dev/null +++ b/src/acl/dynamic.rs @@ -0,0 +1,151 @@ +//! Bounded, fail-closed DNS/DDNS ACL address cache. No resolver runs in the request path. +use crate::config::Config; +use anyhow::{ensure, Result}; +use arc_swap::ArcSwap; +use futures_util::{stream, StreamExt}; +use std::{ + collections::{BTreeSet, HashMap}, + net::IpAddr, + sync::{Arc, LazyLock, Weak}, + time::{Duration, Instant}, +}; +use tokio::{sync::Notify, time::MissedTickBehavior}; +use tokio_util::sync::CancellationToken; + +pub const REFRESH_SECONDS: u64 = 60; +const MAX_AGE_SECONDS: u64 = 180; +const MAX_NAMES: usize = 128; +const MAX_ADDRESSES: usize = 32; +const PARALLEL_LOOKUPS: usize = 8; +const LOOKUP_SECONDS: u64 = 4; + +#[derive(Clone)] +struct Addresses { + ips: Vec, + valid_until: Instant, +} + +static CACHE: LazyLock>> = + LazyLock::new(|| ArcSwap::from_pointee(HashMap::new())); +static CHANGED: LazyLock = LazyLock::new(Notify::new); + +pub(crate) fn matches(name: &str, ip: IpAddr) -> bool { + let cache = CACHE.load(); + cache.get(name).is_some_and(|entry| { + entry.valid_until > Instant::now() && entry.ips.binary_search(&ip).is_ok() + }) +} + +/// Strict ASCII DNS hostnames only (IDNs can be supplied in punycode). +/// A and AAAA results are eligible. IPs and CIDRs remain separate static rules. +pub(crate) fn hostname(value: &str) -> Result { + let candidate = value.strip_suffix('.').unwrap_or(value); + ensure!(candidate.len() <= 253 && candidate.contains('.'), "ACL DNS name requires a fully qualified hostname"); + ensure!(candidate.is_ascii() && !candidate.contains('/') && !candidate.contains(':'), "invalid ACL DNS name"); + let labels: Vec<_> = candidate.split('.').collect(); + ensure!(labels.last().is_some_and(|part| part.chars().any(|ch| ch.is_ascii_alphabetic())), "invalid ACL DNS name suffix"); + for label in labels { + ensure!(!label.is_empty() && label.len() <= 63, "invalid ACL DNS label length"); + ensure!(label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'), "invalid ACL DNS label"); + } + Ok(candidate.to_ascii_lowercase()) +} + +/// Walk every ACL scope, including standalone host policies, ACL profiles, +/// inline vhost/location ACLs and Docker overrides. Limit process-wide DNS work. +pub(crate) fn configured_hostnames(config: &Config) -> Result> { + let mut names = BTreeSet::new(); + let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> { + if let Some(acl) = acl { + for rule in &acl.rules { + if rule.network.parse::().is_ok() || rule.network.parse::().is_ok() { + continue; + } + // A slash is always a CIDR error, never a DNS name. + let name = hostname(&rule.network)?; + ensure!(rule.action == crate::acl::Action::Allow, + "dynamic DNS ACL rules only support allow; use static IP/CIDR for deny"); + names.insert(name); + ensure!(names.len() <= MAX_NAMES, "too many distinct dynamic DNS ACL names (max 128)"); + } + } + Ok(()) + }; + for policy in &config.host_policies { inspect(Some(&policy.acl))?; } + for profile in &config.acl_profiles { inspect(profile.acl.as_ref())?; } + for vhost in &config.vhosts { + inspect(vhost.acl.as_ref())?; + for route in &vhost.routes { inspect(route.acl.as_ref())?; } + } + for agent in &config.docker_agents.agents { + for assignment in &agent.assignments { inspect(assignment.acl.as_ref())?; } + } + Ok(names.into_iter().collect()) +} + +async fn resolve(name: String) -> (String, Option) { + let answer = tokio::time::timeout( + Duration::from_secs(LOOKUP_SECONDS), + tokio::net::lookup_host((name.as_str(), 0u16)), + ).await; + let mut ips = match answer { + Ok(Ok(sockets)) => sockets.map(|socket| socket.ip()).collect::>(), + _ => { + tracing::warn!(hostname = %name, "dynamic ACL DNS lookup failed; no addresses permitted"); + return (name, None); + } + }; + ips.sort_unstable(); + ips.dedup(); + if ips.is_empty() || ips.len() > MAX_ADDRESSES || ips.iter().any(|ip| { + matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some()) + }) { + tracing::warn!(hostname = %name, "dynamic ACL DNS returned an invalid or excessive address set"); + return (name, None); + } + (name, Some(Addresses { ips, valid_until: Instant::now() + Duration::from_secs(MAX_AGE_SECONDS) })) +} + +pub(crate) fn config_changed() { CHANGED.notify_one(); } + +pub(crate) async fn poll_loop( + app: Weak, + shutdown: CancellationToken, +) { + let mut interval = tokio::time::interval(Duration::from_secs(REFRESH_SECONDS)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + _ = interval.tick() => {}, + _ = CHANGED.notified() => {}, + } + let Some(state) = app.upgrade() else { break; }; + let snapshot = state.config.load(); + let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; }; + drop(snapshot); + // Resolve concurrently with hard bounds; write one immutable snapshot. + let results: HashMap<_, _> = tokio::select! { + _ = shutdown.cancelled() => break, + result = stream::iter(names.iter().cloned()) + .map(resolve) + .buffer_unordered(PARALLEL_LOOKUPS) + .filter_map(|(name, answer)| async move { answer.map(|value| (name, value)) }) + .collect() => result, + }; + // A concurrently changed configuration must not publish obsolete DNS data. + let current = state.config.load(); + if configured_hostnames(¤t.envelope.config).ok().as_ref() != Some(&names) { + CHANGED.notify_one(); + continue; + } + CACHE.store(Arc::new(results)); + } + // Do not retain permit decisions when the resolver lifecycle stops. + CACHE.store(Arc::new(HashMap::new())); +} + +#[cfg(test)] +include!("dynamic/tests.rs"); diff --git a/src/acl/dynamic/tests.rs b/src/acl/dynamic/tests.rs new file mode 100644 index 0000000..bd7ade6 --- /dev/null +++ b/src/acl/dynamic/tests.rs @@ -0,0 +1,41 @@ +mod tests { + use super::*; + #[test] + fn validates_names_without_accepting_cidr_lookalikes_or_urls() { + assert_eq!(hostname("My-Office.DDNS.Example.").unwrap(), "my-office.ddns.example"); + for bad in ["localhost", "", "*.example.com", "http://ddns.example", "1.2.3.999", "foo..example", "-x.example", "a_.example", "a.example/24"] { + assert!(hostname(bad).is_err(), "{bad}"); + } + } + #[test] + fn resolver_expiry_fails_closed_and_address_sets_are_exact() { + let name = "test-dynamic-unique.example"; + let ip: IpAddr = "198.51.100.4".parse().unwrap(); + let entry = Addresses { ips: vec![ip], valid_until: Instant::now() + Duration::from_secs(1) }; + assert!(entry.valid_until > Instant::now()); + // The request path accepts only cached exact addresses, never a subnet. + let mut cache = HashMap::new(); cache.insert(name.to_owned(), entry); + CACHE.store(Arc::new(cache)); + assert!(matches(name, ip)); + assert!(!matches(name, "198.51.100.5".parse().unwrap())); + // A formerly resolved IP cannot remain permitted after expiration. + let mut expired = HashMap::new(); + expired.insert(name.to_owned(), Addresses { + ips: vec![ip], valid_until: Instant::now() - Duration::from_secs(1), + }); + CACHE.store(Arc::new(expired)); + assert!(!matches(name, ip)); + // IPv6 addresses have the same exact-match and rotation semantics. + let new_ip: IpAddr = "2001:db8::8".parse().unwrap(); + let mut rotated = HashMap::new(); + rotated.insert(name.to_owned(), Addresses { + ips: vec![new_ip], valid_until: Instant::now() + Duration::from_secs(10), + }); + CACHE.store(Arc::new(rotated)); + assert!(matches(name, new_ip)); + assert!(!matches(name, ip)); + CACHE.store(Arc::new(HashMap::new())); + assert!(!matches(name, new_ip)); + } +} + diff --git a/src/acl/tests.rs b/src/acl/tests.rs index d305320..cbe7f31 100644 --- a/src/acl/tests.rs +++ b/src/acl/tests.rs @@ -61,6 +61,51 @@ mod tests { assert!(!acl.allows("2001:db8::8".parse().unwrap())); } + #[test] + fn dynamic_dns_allow_is_fail_closed_until_resolved_and_old_cidrs_still_work() { + let acl = Acl { + default: Action::Deny, + rules: vec![ + Rule { network: "Office.DDNS.Example".into(), action: Action::Allow }, + Rule { network: "192.0.2.0/24".into(), action: Action::Allow }, + Rule { network: "192.0.2.99".into(), action: Action::Deny }, + ], + }.compile().unwrap(); + assert!(!acl.allows("198.51.100.25".parse().unwrap())); + assert!(acl.allows("192.0.2.42".parse().unwrap())); + assert!(!acl.allows("192.0.2.99".parse().unwrap())); + for rejected in ["example.com/24", "*.example.com", "https://example.com", "foo..example.org"] { + assert!(Acl { + default: Action::Deny, + rules: vec![Rule { network: rejected.into(), action: Action::Allow }], + }.compile().is_err(), "{rejected}"); + } + assert!(Acl { + default: Action::Allow, + rules: vec![Rule { network: "example.ddns.net".into(), action: Action::Deny }], + }.compile().is_err()); + } + #[test] + fn configured_dns_names_are_deduplicated_across_acl_scopes() { + let mut config = crate::config::Config::default(); + config.acl_profiles.push(crate::config::AclProfile { + id: "home".into(), + name: "Home".into(), + acl: Some(Acl { default: Action::Deny, rules: vec![ + Rule { network: "Home.DDNS.Example".into(), action: Action::Allow }, + ] }), + ..Default::default() + }); + config.host_policies.push(crate::config::HostPolicy { + hosts: vec!["example.org".into()], + acl: Acl { default: Action::Deny, rules: vec![ + Rule { network: "home.ddns.example".into(), action: Action::Allow }, + Rule { network: "office.ddns.example".into(), action: Action::Allow }, + ] }, + }); + let hosts = crate::acl::dynamic::configured_hostnames(&config).unwrap(); + assert_eq!(hosts, vec!["home.ddns.example", "office.ddns.example"]); + } #[test] fn country_allow_and_ip_allow_are_alternatives_inside_one_scope() { let acl = Acl { diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 81291b7..cd94912 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.32.4", + "version": "0.32.5", "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." }, "servers": [ @@ -251,7 +251,8 @@ "type": "object", "properties": { "network": { - "type": "string" + "type": "string", + "description": "Client IPv4/IPv6 address, CIDR, or allow-only DNS/DDNS hostname (A/AAAA refreshed every 60s; failed lookups deny the hostname)." }, "action": { "type": "string", @@ -11871,4 +11872,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/admin/web/management.js b/src/admin/web/management.js index fd68612..8e4813f 100644 --- a/src/admin/web/management.js +++ b/src/admin/web/management.js @@ -222,6 +222,7 @@ const addButton = $("acl-profile-rule-add"), importButton = $("acl-profile-import-rules"); const previewDefault = $("acl-profile-preview-default"), previewFlow = $("acl-profile-preview-flow"), previewNote = $("acl-profile-preview-note"); const countryHelp = $("acl-profile-country-help"), countryLabel = $("acl-profile-country-label"), ipHelp = $("acl-profile-ip-help"); + const dnsHint = i18n("p", "aclDnsHelp", "fine"); ipHelp?.after(dnsHint); const modeInputs = [...form.querySelectorAll('input[name="acl-profile-mode"]')]; const i18n = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; }; const selectedMode = () => modeInputs.find(node => node.checked)?.value || "allow_exceptions"; @@ -240,6 +241,13 @@ if (network.includes(":")) return `${network}/128`; return network; } + const ruleKind = value => /[a-z]/i.test(value) && value.includes(".") && !value.includes(":") && !value.includes("/") ? "dns" : "ip"; + function syncRuleKind(row) { + const kind = row.querySelector(".acl-profile-rule-type").value; + const network = row.querySelector(".acl-profile-rule-network"); + network.maxLength = kind === "dns" ? 253 : 64; + network.placeholder = kind === "dns" ? "office.example.ddns.net" : "10.0.0.0/8 or 203.0.113.5"; + } function addRule(rule = null, mark = true) { if (ruleRows.children.length >= 128) { toast(t("vhRowLimit"), true); return; } const mode = selectedMode(), fallbackAction = mode === "custom" ? "allow" : guidedAction(mode); @@ -250,11 +258,18 @@ for (const actionValue of ["allow", "deny"]) { const option = el("option", t(actionValue === "allow" ? "aclAllow" : "vhDeny")); option.value = actionValue; actionSelect.append(option); } actionSelect.value = value.action || fallbackAction; actionLabel.append(actionSelect); const actionBadge = el("span", "", "acl-profile-rule-action-badge"); actionLabel.append(actionBadge); + const typeLabel = el("label"); typeLabel.append(i18n("span", "aclRuleType")); + const kind = el("select"); kind.className = "acl-profile-rule-type"; + for (const [id, key] of [["ip", "aclRuleIp"], ["dns", "aclRuleDns"]]) { + const item = i18n("option", key); item.value = id; kind.append(item); + } + kind.value = ruleKind(value.network || ""); typeLabel.append(kind); const networkLabel = el("label"); networkLabel.append(i18n("span", "aclNetwork")); - const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.maxLength = 64; network.autocomplete = "off"; network.spellcheck = false; network.placeholder = "10.0.0.0/8 or 203.0.113.5"; network.value = value.network || ""; networkLabel.append(network); + const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.autocomplete = "off"; network.spellcheck = false; network.value = value.network || ""; networkLabel.append(network); network.addEventListener("blur", () => { const normalized = normalizeNetwork(network.value); if (normalized !== network.value) { network.value = normalized; syncRulesText(); renderPreview(); } }); + kind.addEventListener("change", () => { network.value = ""; syncRuleKind(row); syncRulesText(); dirty = true; syncPolicy(); network.focus(); }); const remove = action(t("remove"), () => { row.remove(); syncRulesText(); dirty = true; syncPolicy(); }); remove.classList.add("acl-profile-rule-remove"); - row.append(actionLabel, networkLabel, remove); ruleRows.append(row); + row.append(actionLabel, typeLabel, networkLabel, remove); ruleRows.append(row); syncRuleKind(row); if (mark) { syncRulesText(); dirty = true; } syncRuleRows(); } @@ -370,6 +385,9 @@ function refreshTranslations() { $("acl-profile-editing").textContent = t(editing ? "aclProfileEdit" : "aclProfileNew"); for (const row of ruleRows.children) { + const network = row.querySelector(".acl-profile-rule-network"); + const kind = row.querySelector(".acl-profile-rule-type"); + if (kind) { kind.value = ruleKind(network?.value || ""); syncRuleKind(row); } const select = row.querySelector(".acl-profile-rule-action"); if (select?.options?.length >= 2) { select.options[0].textContent = t("aclAllow"); @@ -433,6 +451,8 @@ } const finalMode = selectedMode(), expectedAction = finalMode === "custom" ? null : guidedAction(finalMode); const aclRules = collectRuleRows().map(rule => ({ ...rule, action: expectedAction || rule.action })); + if (aclRules.some(rule => rule.action === "deny" && ruleKind(rule.network) === "dns")) + throw fieldError(ruleRows, t("aclDnsAllowOnly")); if (aclRules.some(rule => !rule.network)) { const input = [...ruleRows.querySelectorAll(".acl-profile-rule-network")].find(node => !node.value.trim()); throw fieldError(input || rules, t("ruleSyntax")); } for (const [row, rule] of [...ruleRows.children].map((row, index) => [row, aclRules[index]])) row.querySelector(".acl-profile-rule-network").value = rule.network; const finalDefault = finalMode === "custom" ? aclDefault.value : guidedDefault(finalMode); diff --git a/src/admin/web/style.css b/src/admin/web/style.css index fd8ddd5..ac2e468 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -253,10 +253,10 @@ html[data-theme="cream"] { --scrollbar-thumb-hover: #978a76; } +/* Theme palettes change color only; corners are controlled independently. */ html[data-theme="ultra_dark"] :is(.card, .stat, dialog) { box-shadow: 0 6px 24px rgb(0 0 0 / .18); } - html[data-theme="cream"] :is(.card, .stat, dialog) { box-shadow: 0 6px 20px rgb(78 58 38 / .055); } @@ -9804,6 +9804,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } } +/* 0.31.5 traffic/log analysis actions */ .traffic-copy-value, .file-log-aggregate-copy { appearance: none; @@ -9855,6 +9856,10 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } } + +/* Stored Logs on narrow screens: bounded container and a compact record list. + The complete parsed JSON is available by opening any row, while the desktop + table and the persisted column picker keep their original behavior. */ @media (max-width: 700px) { #panel-filelogs, @@ -9984,6 +9989,8 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { overflow-wrap: anywhere; } + /* Every user-selected column is available even when the administrator + replaces all default columns with custom parsed fields. */ #panel-filelogs .file-logs-table td[data-mobile-column="record"] { grid-column: 1 / -1; font-weight: 600; @@ -10075,6 +10082,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } } +/* Tablet/narrow-desktop log tables scroll locally instead of expanding
. */ #panel-filelogs, #panel-filelogs .file-logs-workspace, #file-log-view-entries, @@ -10116,6 +10124,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { } } +/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */ .listener-protocol-settings-actions { display: flex; gap: 14px; @@ -10132,6 +10141,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { display: none; } +/* 0.31.7 revision preview shows only changed leaf fields. */ .config-history-change-values { display: flex; flex-wrap: wrap; @@ -10205,6 +10215,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { overflow-wrap: anywhere; } +/* v0.32 Stream extensions reuse the responsive Vhost editor and stat cards. */ .stream-backend-pool { display: grid; gap: .8rem; @@ -10294,7 +10305,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { grid-template-columns: repeat(6, minmax(0, 1fr)); } } - +/* Optional HTTP backend mode distribution (configured modes, not negotiated ALPN). */ #panel-overview .backend-mode-bar { display: flex; height: 7px; @@ -10305,317 +10316,94 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--line); margin: 7px 0 5px; } +#panel-overview .backend-mode-bar span { min-width: 0; } +#panel-overview .backend-mode-h1 { background: var(--traffic-h1); } +#panel-overview .backend-mode-auto { background: var(--traffic-h2); } +#panel-overview .backend-mode-compat { background: var(--traffic-h2); opacity: .5; } +#panel-overview .backend-mode-h2 { background: var(--traffic-h3); } -#panel-overview .backend-mode-bar span { - min-width: 0; -} - -#panel-overview .backend-mode-h1 { - background: var(--traffic-h1); -} - -#panel-overview .backend-mode-auto { - background: var(--traffic-h2); -} - -#panel-overview .backend-mode-compat { - background: var(--traffic-h2); - opacity: .5; -} - -#panel-overview .backend-mode-h2 { - background: var(--traffic-h3); -} - -#panel-overview .backend-mode-bar[aria-label] { - background-clip: padding-box; -} - -.vh-origin-protocol-info { - color: var(--muted); -} - -.vh-origin-protocol-warning { - color: var(--muted); -} +#panel-overview .backend-mode-bar[aria-label] { background-clip: padding-box; } +.vh-origin-protocol-info { color: var(--muted); } +.vh-origin-protocol-warning { color: var(--muted); } +/* Overview arrangement dialog: visual clones never participate in metric updates. */ .overview-layout-dialog { width: min(1180px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); overflow: auto; } - -.overview-layout-dialog>.window-heading { +.overview-layout-dialog > .window-heading { position: sticky; top: 0; z-index: 2; background: var(--bg); border-bottom: 1px solid var(--line); } - -.overview-layout-body { - display: grid; - gap: 16px; - padding: 14px; -} - -.overview-layout-body h3 { - font-size: 14px; - margin: 0 0 10px; -} - -.overview-layout-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--ui-gap, 10px); -} - -.overview-layout-tile { - min-width: 0; - border: 1px solid var(--line); - background: var(--panel); - cursor: grab; -} - -.overview-layout-tile:active { - cursor: grabbing; -} - -.overview-layout-tile.is-dragging { - opacity: .4; -} - -.overview-layout-tile.is-drop-target { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -.overview-layout-tile-toolbar { - display: flex; - align-items: center; - gap: 3px; - padding: 4px; - border-bottom: 1px solid var(--line); -} - -.overview-layout-tile-toolbar button { - min-height: 28px; - min-width: 28px; - padding: 3px 7px; -} - -.overview-layout-grip { - padding: 0 5px; - color: var(--muted); - user-select: none; -} - -.overview-layout-hide { - margin-inline-start: auto; - font-size: 11px; -} - -.overview-layout-grid .stat { - border: 0; - padding: 12px; - min-height: 98px; - pointer-events: none; -} - -.overview-layout-grid .stat>span { - font-size: 11px; -} - -.overview-layout-grid .stat strong { - font-size: 24px; - margin-top: 7px; - overflow-wrap: anywhere; -} - -.overview-layout-grid .stat small { - display: block; - font-size: 10px; - line-height: 1.4; - overflow-wrap: anywhere; -} - -.overview-layout-grid .stat svg { - width: 100%; - height: 53px; -} - -.overview-layout-grid .stat-chart-legend { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.overview-layout-section-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.overview-layout-section-heading input { - max-width: 230px; -} - -.overview-layout-hidden-list { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; -} - -.overview-layout-hidden-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - border: 1px solid var(--line); - padding: 8px; - background: var(--panel); - min-width: 0; -} - -.overview-layout-hidden-item span { - font-size: 12px; - min-width: 0; - overflow-wrap: anywhere; -} - -.overview-layout-hidden-item button { - flex-shrink: 0; -} - -.overview-layout-footer { - position: sticky; - bottom: 0; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - padding: 12px; - border-top: 1px solid var(--line); - background: var(--bg); - z-index: 2; -} - -.overview-layout-footer p { - margin: 0 auto 0 0; - font-size: 11px; - overflow-wrap: anywhere; -} - -.dashboard-layout-open-settings { - margin: 5px 0; - justify-self: start; -} - +.overview-layout-body { display: grid; gap: 16px; padding: 14px; } +.overview-layout-body h3 { font-size: 14px; margin: 0 0 10px; } +.overview-layout-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--ui-gap, 10px); } +.overview-layout-tile { min-width: 0; border: 1px solid var(--line); background: var(--panel); cursor: grab; } +.overview-layout-tile:active { cursor: grabbing; } +.overview-layout-tile.is-dragging { opacity: .4; } +.overview-layout-tile.is-drop-target { outline: 2px solid var(--accent); outline-offset: -2px; } +.overview-layout-tile-toolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border-bottom: 1px solid var(--line); } +.overview-layout-tile-toolbar button { min-height: 28px; min-width: 28px; padding: 3px 7px; } +.overview-layout-grip { padding: 0 5px; color: var(--muted); user-select: none; } +.overview-layout-hide { margin-inline-start: auto; font-size: 11px; } +.overview-layout-grid .stat { border: 0; padding: 12px; min-height: 98px; pointer-events: none; } +.overview-layout-grid .stat > span { font-size: 11px; } +.overview-layout-grid .stat strong { font-size: 24px; margin-top: 7px; overflow-wrap: anywhere; } +.overview-layout-grid .stat small { display: block; font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; } +.overview-layout-grid .stat svg { width: 100%; height: 53px; } +.overview-layout-grid .stat-chart-legend { display: flex; flex-wrap: wrap; gap: 8px; } +.overview-layout-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.overview-layout-section-heading input { max-width: 230px; } +.overview-layout-hidden-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } +.overview-layout-hidden-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--line); padding: 8px; background: var(--panel); min-width: 0; } +.overview-layout-hidden-item span { font-size: 12px; min-width: 0; overflow-wrap: anywhere; } +.overview-layout-hidden-item button { flex-shrink: 0; } +.overview-layout-footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px; border-top: 1px solid var(--line); background: var(--bg); z-index: 2; } +.overview-layout-footer p { margin: 0 auto 0 0; font-size: 11px; overflow-wrap: anywhere; } +.dashboard-layout-open-settings { margin: 5px 0; justify-self: start; } @media (max-width: 850px) { - - .overview-layout-grid, - .overview-layout-hidden-list { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + .overview-layout-grid, .overview-layout-hidden-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } } - @media (max-width: 550px) { - - .overview-layout-grid, - .overview-layout-hidden-list { - grid-template-columns: minmax(0, 1fr); - } - - .overview-layout-section-heading { - align-items: stretch; - flex-direction: column; - } - - .overview-layout-section-heading input { - max-width: none; - width: 100%; - } - - .overview-layout-footer { - flex-wrap: wrap; - } - - .overview-layout-footer p { - width: 100%; - } + .overview-layout-grid, .overview-layout-hidden-list { grid-template-columns: minmax(0, 1fr); } + .overview-layout-section-heading { align-items: stretch; flex-direction: column; } + .overview-layout-section-heading input { max-width: none; width: 100%; } + .overview-layout-footer { flex-wrap: wrap; } + .overview-layout-footer p { width: 100%; } } +/* One rearrangeable Overview canvas. Existing large charts, diagnostics and the + backend table keep their original nodes/IDs and their data update handlers. */ #panel-overview .dashboard-overview-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--ui-gap); align-items: stretch; } - -#panel-overview .dashboard-overview-grid>[data-dashboard-widget] { - min-width: 0; -} - -#panel-overview .dashboard-overview-grid>[hidden] { - display: none !important; -} - -#panel-overview .dashboard-overview-grid>.card:not(.pool-card) { - grid-column: span 2; -} - -#panel-overview .dashboard-overview-grid>.pool-card { - grid-column: 1 / -1; - margin-bottom: 0; -} - -#panel-overview .dashboard-overview-grid>.activity-card svg { - min-width: 0; -} - +#panel-overview .dashboard-overview-grid > [data-dashboard-widget] { min-width: 0; } +#panel-overview .dashboard-overview-grid > [hidden] { display: none !important; } +#panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: span 2; } +#panel-overview .dashboard-overview-grid > .pool-card { grid-column: 1 / -1; margin-bottom: 0; } +#panel-overview .dashboard-overview-grid > .activity-card svg { min-width: 0; } @media (max-width: 1200px) { - #panel-overview .dashboard-overview-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - #panel-overview .dashboard-overview-grid>.stat { - grid-column: span 2; - } - - #panel-overview .dashboard-overview-grid>.card:not(.pool-card) { - grid-column: span 2; - } + #panel-overview .dashboard-overview-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } + #panel-overview .dashboard-overview-grid > .stat { grid-column: span 2; } + #panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: span 2; } } - @media (max-width: 850px) { - #panel-overview .dashboard-overview-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - #panel-overview .dashboard-overview-grid>.stat { - grid-column: span 1; - } - - #panel-overview .dashboard-overview-grid>.card:not(.pool-card) { - grid-column: 1 / -1; - } + #panel-overview .dashboard-overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + #panel-overview .dashboard-overview-grid > .stat { grid-column: span 1; } + #panel-overview .dashboard-overview-grid > .card:not(.pool-card) { grid-column: 1 / -1; } } - @media (max-width: 550px) { - #panel-overview .dashboard-overview-grid { - grid-template-columns: minmax(0, 1fr); - } - - #panel-overview .dashboard-overview-grid>.stat { - grid-column: 1; - } + #panel-overview .dashboard-overview-grid { grid-template-columns: minmax(0, 1fr); } + #panel-overview .dashboard-overview-grid > .stat { grid-column: 1; } } - -.overview-layout-tile.is-panel { - grid-column: span 2; -} - +.overview-layout-tile.is-panel { grid-column: span 2; } .overview-layout-grid .overview-layout-panel-preview { display: grid; gap: 7px; @@ -10624,45 +10412,17 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--panel); pointer-events: none; } +.overview-layout-panel-preview > strong { font-size: 13px; } +.overview-layout-panel-preview svg { width: 100%; height: 80px; } +.overview-layout-panel-preview small { color: var(--muted); } +.overview-layout-footer #overview-layout-default { margin-right: auto; } +.overview-reset-confirm { width: min(460px, calc(100vw - 24px)); padding: 16px; } +.overview-reset-confirm > p { line-height: 1.5; } +@media (max-width: 850px) { .overview-layout-tile.is-panel { grid-column: span 2; } } +@media (max-width: 550px) { .overview-layout-tile.is-panel { grid-column: span 1; } } -.overview-layout-panel-preview>strong { - font-size: 13px; -} - -.overview-layout-panel-preview svg { - width: 100%; - height: 80px; -} - -.overview-layout-panel-preview small { - color: var(--muted); -} - -.overview-layout-footer #overview-layout-default { - margin-right: auto; -} - -.overview-reset-confirm { - width: min(460px, calc(100vw - 24px)); - padding: 16px; -} - -.overview-reset-confirm>p { - line-height: 1.5; -} - -@media (max-width: 850px) { - .overview-layout-tile.is-panel { - grid-column: span 2; - } -} - -@media (max-width: 550px) { - .overview-layout-tile.is-panel { - grid-column: span 1; - } -} - +/* Overview block kinds come from the same server-validated registry as the + saved order. Labels distinguish numbers, plots, tables and diagnostic panels. */ .overview-widget-type { display: inline-block; width: fit-content; @@ -10676,70 +10436,31 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { line-height: 1.25; overflow-wrap: anywhere; } +.dashboard-widget-setting .dashboard-widget-toggle { flex-wrap: wrap; } +.dashboard-widget-setting .overview-widget-type { margin-left: 15px; } +.overview-layout-tile-toolbar .overview-widget-type { margin-left: 3px; } +.overview-layout-hidden-item .overview-widget-type { flex: 0 0 auto; } +.overview-layout-filters { display: flex; flex-wrap: wrap; gap: 8px; } +.overview-layout-filters select, .overview-layout-filters input { width: min(220px, 100%); max-width: 100%; } -.dashboard-widget-setting .dashboard-widget-toggle { - flex-wrap: wrap; -} - -.dashboard-widget-setting .overview-widget-type { - margin-left: 15px; -} - -.overview-layout-tile-toolbar .overview-widget-type { - margin-left: 3px; -} - -.overview-layout-hidden-item .overview-widget-type { - flex: 0 0 auto; -} - -.overview-layout-filters { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.overview-layout-filters select, -.overview-layout-filters input { - width: min(220px, 100%); - max-width: 100%; -} - -.overview-inline-actions { - display: inline-flex; - gap: 7px; - align-items: center; - flex-wrap: wrap; -} - -.overview-inline-actions[hidden], -.overview-inline-hint[hidden] { - display: none !important; -} - -.overview-inline-hint { - margin: 0 0 10px; -} - -#panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] { +/* Inline editing of the actual Overview blocks; live charts and original DOM + IDs remain in place. Dragging is enabled only on the explicit grip button. */ +.overview-inline-actions { display: inline-flex; gap: 7px; align-items: center; flex-wrap: wrap; } +.overview-inline-actions[hidden], .overview-inline-hint[hidden] { display: none !important; } +.overview-inline-hint { margin: 0 0 10px; } +#panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { position: relative; outline: 1px dashed var(--accent); outline-offset: -2px; padding-top: 66px; } - -#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-dragging { - opacity: .45; -} - -#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-before { +#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-dragging { opacity: .45; } +#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-drop-before { box-shadow: inset 4px 0 var(--accent); } - -#panel-overview .dashboard-overview-grid.is-unlocked>.is-overview-drop-after { +#panel-overview .dashboard-overview-grid.is-unlocked > .is-overview-drop-after { box-shadow: inset -4px 0 var(--accent); } - .overview-inline-toolbar { position: absolute; top: 3px; @@ -10752,56 +10473,37 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { gap: 3px; pointer-events: auto; } - #panel-overview .overview-inline-toolbar button { min-width: 28px; min-height: 27px; padding: 2px 7px; font-size: 12px; } - -#panel-overview .overview-inline-toolbar .overview-inline-grip { - cursor: grab; - user-select: none; -} - -#panel-overview .overview-inline-toolbar .overview-inline-grip:active { - cursor: grabbing; -} - -#panel-overview .overview-inline-toolbar .overview-widget-type { - margin-left: auto; -} - +#panel-overview .overview-inline-toolbar .overview-inline-grip { cursor: grab; user-select: none; } +#panel-overview .overview-inline-toolbar .overview-inline-grip:active { cursor: grabbing; } +#panel-overview .overview-inline-toolbar .overview-widget-type { margin-left: auto; } @media (max-width: 600px) { - .overview-layout-hidden-item { - flex-wrap: wrap; - } - - .overview-layout-filters select, - .overview-layout-filters input { - width: 100%; - } - - #panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] { - padding-top: 68px; - } + .overview-layout-hidden-item { flex-wrap: wrap; } + .overview-layout-filters select, .overview-layout-filters input { width: 100%; } + #panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { padding-top: 68px; } } + +/* Direct Overview editing: hide is reversible until Save and remains + recoverable later from the block selector or the compact hidden shelf. */ #panel-overview .overview-inline-toolbar .overview-inline-hide { color: var(--red); border: 1px solid var(--danger-line); background: var(--panel); + /* Use the same .quiet radius/corner preset as the arrow buttons. */ font-size: 18px; line-height: 1; } - #panel-overview .overview-inline-toolbar .overview-inline-hide:hover, #panel-overview .overview-inline-toolbar .overview-inline-hide:focus-visible { border-color: var(--danger-line-strong); background: var(--notice-bg); } - .overview-inline-hidden { margin: 0 0 10px; border: 1px solid var(--line); @@ -10809,19 +10511,13 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--panel); color: var(--text); } - -.overview-inline-hidden>summary { +.overview-inline-hidden > summary { padding: 9px 12px; cursor: pointer; color: var(--muted); font-size: 12px; } - -.overview-inline-hidden>summary:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - +.overview-inline-hidden > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .overview-inline-hidden-list { display: flex; flex-wrap: wrap; @@ -10831,7 +10527,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { padding: 10px 12px; border-top: 1px solid var(--line); } - .overview-inline-hidden-item { display: inline-flex; align-items: center; @@ -10844,12 +10539,8 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { background: var(--panel2); font-size: 12px; } - -.overview-inline-hidden-item>span:not(.overview-widget-type) { - overflow-wrap: anywhere; -} - -.overview-inline-hidden-item>button { +.overview-inline-hidden-item > span:not(.overview-widget-type) { overflow-wrap: anywhere; } +.overview-inline-hidden-item > button { flex: 0 0 auto; min-width: 27px; min-height: 27px; @@ -10859,19 +10550,10 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { color: var(--green); background: var(--panel); } - -.overview-inline-hidden-item>button:hover { - border-color: var(--accent); -} - +.overview-inline-hidden-item > button:hover { border-color: var(--accent); } @media (max-width: 600px) { - #panel-overview .dashboard-overview-grid.is-unlocked>[data-dashboard-widget] { - padding-top: 82px; - } - - .overview-inline-hidden-item { - max-width: 100%; - } + #panel-overview .dashboard-overview-grid.is-unlocked > [data-dashboard-widget] { padding-top: 82px; } + .overview-inline-hidden-item { max-width: 100%; } } /* The original fixed Overview grid, also used for personalized layouts. @@ -10879,10 +10561,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { every tile in a row stretches to the SAME height. This intentionally favors symmetry over masonry or floating short cards beside tall panels. Existing density controls define the identical horizontal/vertical gap. */ -#panel-overview { - container: overview-canvas / inline-size; -} - +#panel-overview { container: overview-canvas / inline-size; } #panel-overview .dashboard-overview-grid.is-aligned { display: grid; /* A compact gutter throughout: tiles, large panels, rows and columns. @@ -10898,15 +10577,13 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { align-content: start; gap: var(--overview-gap); } - /* The earlier html[data-dashboard-density] .stats-grid rules have greater specificity than .is-aligned and otherwise restore the old, larger gutter. Keep the compact value in the combined Overview grid at every density. */ #panel-overview .dashboard-overview-grid.is-aligned.stats-grid { gap: var(--overview-gap); } - -#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget] { +#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget] { grid-column: auto / span 2; grid-row: auto; align-self: stretch; @@ -10917,290 +10594,117 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { margin-block: 0; position: relative; } - -#panel-overview .dashboard-overview-grid.is-aligned>.card:not(.pool-card) { - grid-column: auto / span 4; -} - -#panel-overview .dashboard-overview-grid.is-aligned>.card.overview-wide-chart { - grid-column: auto / span 6; -} - -#panel-overview .dashboard-overview-grid.is-aligned>.pool-card { - grid-column: 1 / -1; -} - +#panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: auto / span 4; } +#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-wide-chart { grid-column: auto / span 6; } +#panel-overview .dashboard-overview-grid.is-aligned > .pool-card { grid-column: 1 / -1; } /* Each size category starts at the first track, even after drag/drop. */ -#panel-overview .dashboard-overview-grid.is-aligned>.overview-row-start, -#panel-overview .dashboard-overview-grid.is-aligned>.card.overview-row-start { - grid-column-start: 1; -} - +#panel-overview .dashboard-overview-grid.is-aligned > .overview-row-start, +#panel-overview .dashboard-overview-grid.is-aligned > .card.overview-row-start { grid-column-start: 1; } @container overview-canvas (max-width: 1080px) { - #panel-overview .dashboard-overview-grid.is-aligned { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - #panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget].stat { - grid-column: auto / span 2; - } - - #panel-overview .dashboard-overview-grid.is-aligned>.card:not(.pool-card) { - grid-column: auto / span 3; - } - - #panel-overview .dashboard-overview-grid.is-aligned>.card.overview-wide-chart, - #panel-overview .dashboard-overview-grid.is-aligned>.pool-card { - grid-column: 1 / -1; - } + #panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(6, minmax(0, 1fr)); } + #panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 2; } + #panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: auto / span 3; } + #panel-overview .dashboard-overview-grid.is-aligned > .card.overview-wide-chart, + #panel-overview .dashboard-overview-grid.is-aligned > .pool-card { grid-column: 1 / -1; } } - @container overview-canvas (max-width: 700px) { - #panel-overview .dashboard-overview-grid.is-aligned { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - #panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget].stat { - grid-column: auto / span 1; - } - - #panel-overview .dashboard-overview-grid.is-aligned>.card:not(.pool-card) { - grid-column: 1 / -1; - } + #panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: repeat(2, minmax(0, 1fr)); } + #panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: auto / span 1; } + #panel-overview .dashboard-overview-grid.is-aligned > .card:not(.pool-card) { grid-column: 1 / -1; } } - @container overview-canvas (max-width: 440px) { - #panel-overview .dashboard-overview-grid.is-aligned { - grid-template-columns: minmax(0, 1fr); - } - - #panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget].stat { - grid-column: 1; - } + #panel-overview .dashboard-overview-grid.is-aligned { grid-template-columns: minmax(0, 1fr); } + #panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].stat { grid-column: 1; } } - /* Keep category boundaries intact at every responsive breakpoint. */ -#panel-overview .dashboard-overview-grid.is-aligned>[data-dashboard-widget].overview-row-start { - grid-column-start: 1; -} +#panel-overview .dashboard-overview-grid.is-aligned > [data-dashboard-widget].overview-row-start { grid-column-start: 1; } /* Full-size optional charts: static SVG geometry and bounded historical data. */ -#panel-overview .overview-wide-chart { - padding: 15px; - overflow: hidden; -} - -#panel-overview .overview-wide-chart .card-heading { - min-width: 0; - align-items: baseline; - gap: 10px; -} - -#panel-overview .overview-wide-chart h2 { - min-width: 0; - overflow-wrap: anywhere; -} - -#panel-overview .overview-wide-summary { - color: var(--accent); - font: 500 12px var(--mono); - white-space: nowrap; -} - -#panel-overview .overview-wide-svg { - display: block; - width: 100%; - height: clamp(125px, 13vw, 190px); - margin-block: 13px 9px; -} - -#panel-overview .overview-wide-svg .chart-grid { - stroke: var(--line); - fill: none; - stroke-width: 1; - opacity: .6; -} - -#panel-overview .overview-wide-series { - fill: none; - stroke-width: 2; - stroke-linejoin: round; - stroke-linecap: round; -} - -#panel-overview .overview-wide-series-0 { - stroke: var(--chart-accent); -} - -#panel-overview .overview-wide-series-1 { - stroke: var(--chart-secondary); -} - -#panel-overview .overview-wide-series-2 { - stroke: var(--chart-tertiary); -} - -#panel-overview .overview-wide-legend { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 16px; - color: var(--muted); - font-size: 11px; -} - -#panel-overview .overview-wide-legend>span { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; -} - -#panel-overview .overview-wide-legend i { - width: 8px; - height: 8px; - border-radius: 100%; - background: var(--chart-accent); -} - -#panel-overview .overview-wide-legend .overview-wide-key-1 { - background: var(--chart-secondary); -} - -#panel-overview .overview-wide-legend .overview-wide-key-2 { - background: var(--chart-tertiary); -} - -#panel-overview .overview-wide-legend b { - font: 500 11px var(--mono); - color: var(--text); -} - +#panel-overview .overview-wide-chart { padding: 15px; overflow: hidden; } +#panel-overview .overview-wide-chart .card-heading { min-width: 0; align-items: baseline; gap: 10px; } +#panel-overview .overview-wide-chart h2 { min-width: 0; overflow-wrap: anywhere; } +#panel-overview .overview-wide-summary { color: var(--accent); font: 500 12px var(--mono); white-space: nowrap; } +#panel-overview .overview-wide-svg { display: block; width: 100%; height: clamp(125px, 13vw, 190px); margin-block: 13px 9px; } +#panel-overview .overview-wide-svg .chart-grid { stroke: var(--line); fill: none; stroke-width: 1; opacity: .6; } +#panel-overview .overview-wide-series { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } +#panel-overview .overview-wide-series-0 { stroke: var(--chart-accent); } +#panel-overview .overview-wide-series-1 { stroke: var(--chart-secondary); } +#panel-overview .overview-wide-series-2 { stroke: var(--chart-tertiary); } +#panel-overview .overview-wide-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; color: var(--muted); font-size: 11px; } +#panel-overview .overview-wide-legend > span { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } +#panel-overview .overview-wide-legend i { width: 8px; height: 8px; border-radius: 100%; background: var(--chart-accent); } +#panel-overview .overview-wide-legend .overview-wide-key-1 { background: var(--chart-secondary); } +#panel-overview .overview-wide-legend .overview-wide-key-2 { background: var(--chart-tertiary); } +#panel-overview .overview-wide-legend b { font: 500 11px var(--mono); color: var(--text); } /* Block metadata stays to the left of every action group. */ -.dashboard-widget-setting .dashboard-widget-toggle { - flex-wrap: nowrap; -} - -.dashboard-widget-setting .dashboard-widget-toggle .overview-widget-type { - margin: 0 0 0 auto; - flex: 0 0 auto; -} - -.dashboard-widget-setting .dashboard-widget-setting-actions { - justify-self: end; - flex-shrink: 0; -} - -.overview-layout-tile-toolbar { - flex-wrap: wrap; -} - -.overview-layout-tile-toolbar .overview-widget-type { - margin: 0; -} - -.overview-layout-tile-toolbar .overview-layout-controls { - display: inline-flex; - align-items: center; - gap: 3px; - margin-left: auto; -} - -.overview-layout-hidden-item>button { - margin-left: auto; -} - -.overview-layout-hidden-item>.overview-widget-type { - margin: 0; -} - -#panel-overview .overview-inline-toolbar .overview-widget-type { - margin: 0 auto 0 0; -} - -#panel-overview .overview-inline-toolbar .overview-inline-hide { - margin-left: 0; -} - -.overview-inline-hidden-item>.overview-widget-type { - margin-right: 0; -} - -.overview-inline-hidden-item>.overview-inline-restore { - order: 3; - margin-left: auto; -} +.dashboard-widget-setting .dashboard-widget-toggle { flex-wrap: nowrap; } +.dashboard-widget-setting .dashboard-widget-toggle .overview-widget-type { margin: 0 0 0 auto; flex: 0 0 auto; } +.dashboard-widget-setting .dashboard-widget-setting-actions { justify-self: end; flex-shrink: 0; } +.overview-layout-tile-toolbar { flex-wrap: wrap; } +.overview-layout-tile-toolbar .overview-widget-type { margin: 0; } +.overview-layout-tile-toolbar .overview-layout-controls { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; } +.overview-layout-hidden-item > button { margin-left: auto; } +.overview-layout-hidden-item > .overview-widget-type { margin: 0; } +#panel-overview .overview-inline-toolbar .overview-widget-type { margin: 0 auto 0 0; } +#panel-overview .overview-inline-toolbar .overview-inline-hide { margin-left: 0; } +.overview-inline-hidden-item > .overview-widget-type { margin-right: 0; } +.overview-inline-hidden-item > .overview-inline-restore { order: 3; margin-left: auto; } /* Appearance selector: always show the block-kind label, independently from the visibility button, with actions pinned to the right edge. */ .dashboard-widget-list { grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); } - .dashboard-widget-setting { grid-template-columns: minmax(0, 1fr) auto; gap: 6px; } - .dashboard-widget-setting .dashboard-widget-toggle { width: 100%; overflow: hidden; min-width: 0; flex-wrap: nowrap; } - .dashboard-widget-setting .dashboard-widget-setting-label { flex: 1 1 auto; min-width: 0; } - -.dashboard-widget-setting>.overview-widget-type { +.dashboard-widget-setting > .overview-widget-type { flex: none; margin: 0; white-space: nowrap; overflow-wrap: normal; } - -.dashboard-widget-setting>.dashboard-widget-setting-actions { +.dashboard-widget-setting > .dashboard-widget-setting-actions { justify-self: end; white-space: nowrap; } -html[data-corner-style="square"] { - --rp-radius-surface: 0px; - --rp-radius-control: 0px; -} - -html[data-corner-style="subtle"] { - --rp-radius-surface: 5px; - --rp-radius-control: 4px; -} - -html[data-corner-style="rounded"] { - --rp-radius-surface: 12px; - --rp-radius-control: 8px; -} - -html[data-corner-style="extra"] { - --rp-radius-surface: 18px; - --rp-radius-control: 12px; -} - -html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is(.card, .stat, dialog, .login-card, .appearance-section, .settings-tabs, +/* Global shape preference. "default" applies NO overrides so existing dark, + light, cream and Ultra Dark keep the same established corner geometry. + Circular status dots, spinners, switches and chart markers retain their + semantic shape; underline tabs and navigation accent strips stay intact. */ +html[data-corner-style="square"] { --rp-radius-surface: 0px; --rp-radius-control: 0px; } +html[data-corner-style="subtle"] { --rp-radius-surface: 5px; --rp-radius-control: 4px; } +html[data-corner-style="rounded"] { --rp-radius-surface: 12px; --rp-radius-control: 8px; } +html[data-corner-style="extra"] { --rp-radius-surface: 18px; --rp-radius-control: 12px; } +html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is( + .card, .stat, dialog, .login-card, .appearance-section, .settings-tabs, .dashboard-widget-setting, .overview-layout-tile, .overview-layout-hidden-item, .overview-inline-hidden-item, .notice, .toast, .chart-style-choice, .stream-dashboard-card, .pool-card, - .file-log-advanced-parser, .table-container, .metric-info-popover) { - border-radius: var(--rp-radius-surface) !important; -} - -html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is(button:not(.nav):not(.file-log-tab), input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), + .file-log-advanced-parser, .table-container, .metric-info-popover +) { border-radius: var(--rp-radius-surface) !important; } +html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner-style="rounded"], [data-corner-style="extra"]) :is( + button:not(.nav):not(.file-log-tab), input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea, .pill, .overview-widget-type, .badge, .quiet, .outline, .primary, .code, .command, .dashboard-widget-toggle, .settings-tabs button, - .overview-inline-hidden-toggle, .overview-inline-restore) { - border-radius: var(--rp-radius-control) !important; -} + .overview-inline-hidden-toggle, .overview-inline-restore +) { border-radius: var(--rp-radius-control) !important; } -#settings-page-appearance .dashboard-widget-setting .overview-widget-type { - display: none; -} \ No newline at end of file +/* Appearance stays badge-free; badges remain useful in the visual editor. */ +#settings-page-appearance .dashboard-widget-setting .overview-widget-type { display: none; } + +/* ACL DDNS input: keep the rule type separate from its hostname. */ +.acl-profile-rule { grid-template-columns: minmax(100px, .6fr) minmax(105px, .6fr) minmax(175px, 1.5fr) auto; } +@media (max-width: 640px) { .acl-profile-rule { grid-template-columns: 1fr; } } diff --git a/src/config/compile.rs b/src/config/compile.rs index 527a085..82b7eb6 100644 --- a/src/config/compile.rs +++ b/src/config/compile.rs @@ -48,29 +48,58 @@ pub fn compile_with_active( config.compression.validate()?; config.server_header.validate()?; let forwarding = config.forwarding.compile()?; - ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)"); + ensure!( + config.php_fpm_profiles.len() <= 128, + "too many PHP-FPM profiles (max 128)" + ); let mut php_profiles = HashMap::new(); for profile in &mut config.php_fpm_profiles { - profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?; - ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(), - "duplicate PHP-FPM profile id"); + profile + .validate() + .with_context(|| format!("PHP-FPM profile {}", profile.id))?; + ensure!( + php_profiles + .insert(profile.id.clone(), profile.php.clone()) + .is_none(), + "duplicate PHP-FPM profile id" + ); } - ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)"); + ensure!( + config.web_services.len() <= 128, + "too many Web Services (max 128)" + ); let mut web_services = HashMap::new(); // PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused // workers. Different directive sets on one socket can leak to the next vhost. // Report unsafe legacy configurations but keep them loadable for migration. - let mut php_endpoint_policies: HashMap, std::collections::BTreeMap)> = HashMap::new(); + let mut php_endpoint_policies: HashMap< + String, + ( + String, + std::collections::BTreeMap, + std::collections::BTreeMap, + ), + > = HashMap::new(); for service in &mut config.web_services { - service.validate().with_context(|| format!("Web Service {}", service.id))?; + service + .validate() + .with_context(|| format!("Web Service {}", service.id))?; // A profile changes effective runtime settings, not the saved standalone // PHP configuration. Detaching a profile restores local settings exactly. let mut effective = service.clone(); if let Some(id) = &service.php_profile_id { - effective.php = php_profiles.get(id).with_context(|| { - format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id) - })?.clone(); - effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?; + effective.php = php_profiles + .get(id) + .with_context(|| { + format!( + "Web Service {} references unknown PHP-FPM profile {}", + service.id, id + ) + })? + .clone(); + effective + .validate() + .with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?; } if effective.php.enabled { let php = &effective.php; @@ -82,13 +111,25 @@ pub fn compile_with_active( ); } } else { - php_endpoint_policies.insert(php.endpoint.clone(), ( - effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(), - )); + php_endpoint_policies.insert( + php.endpoint.clone(), + ( + effective.id.clone(), + php.php_values.clone(), + php.php_admin_values.clone(), + ), + ); } } - let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?); - ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id"); + let compiled = Arc::new( + effective + .compile() + .with_context(|| format!("Web Service {}", service.id))?, + ); + ensure!( + web_services.insert(service.id.clone(), compiled).is_none(), + "duplicate Web Service id" + ); } ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)"); let mut vhost_ids = HashSet::new(); @@ -264,6 +305,8 @@ pub fn compile_with_active( "duplicate ACL profile id" ); } + // Prevent unbounded DNS ACL work across the entire published configuration. + crate::acl::dynamic::configured_hostnames(&config)?; for agent in &config.docker_agents.agents { if let Some(profile_id) = agent.acl_profile_id.as_deref() { ensure!( @@ -393,7 +436,9 @@ fn validate_route_rewrite( Ok(()) } -fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result> { +fn validate_location_redirect( + redirect: Option<&crate::vhost::LocationRedirect>, +) -> Result> { let Some(redirect) = redirect else { return Ok(None); }; @@ -409,24 +454,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) ); // Replace capture references with a harmless segment so URL structure can // be validated before requests reach the data plane. - let authority_end = redirect.target.find("://").and_then(|start| { - let 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 authority_end = redirect + .target + .find("://") + .and_then(|start| { + let rest = &redirect.target[start + 3..]; + rest.char_indices() + .find(|(_, ch)| matches!(ch, '/' | '?' | '#')) + .map(|(offset, _)| start + 3 + offset) + .or(Some(redirect.target.len())) + }) + .ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?; + ensure!( + !redirect.target[..authority_end].contains('$'), + "location redirect captures are not allowed in the URL authority" + ); let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?") .expect("static capture regex") .replace_all(&redirect.target, "capture"); - let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?; - ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host"); - ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials"); + let parsed = + url::Url::parse(&sample).context("location redirect target must be an absolute URL")?; + ensure!( + matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), + "location redirect target must use http/https and include a host" + ); + ensure!( + parsed.username().is_empty() && parsed.password().is_none(), + "location redirect target cannot contain credentials" + ); let rewrite_regex = match redirect.rewrite_pattern.as_deref() { None | Some("") => None, Some(pattern) => { - ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex"); + ensure!( + pattern.len() <= 1024 && !pattern.chars().any(char::is_control), + "invalid location redirect rewrite regex" + ); let mut builder = regex::RegexBuilder::new(pattern); - builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024); - Some(builder.build().context("invalid location redirect rewrite regex")?) + builder + .size_limit(2 * 1024 * 1024) + .dfa_size_limit(2 * 1024 * 1024); + Some( + builder + .build() + .context("invalid location redirect rewrite regex")?, + ) } }; Ok(Some(crate::config::CompiledLocationRedirect { diff --git a/src/main/admin_runtime.rs b/src/main/admin_runtime.rs index 25a39e6..ea3d21a 100644 --- a/src/main/admin_runtime.rs +++ b/src/main/admin_runtime.rs @@ -146,7 +146,10 @@ async fn run( } let renewal_observations = store.load_certificate_renewals().await?; let geoip_format = snapshot.envelope.config.geoip_updates.format; - let geoip = match rust_proxy::geoip::Database::load_format(&data_dir.join("geoip"), geoip_format) { + let geoip = match rust_proxy::geoip::Database::load_format( + &data_dir.join("geoip"), + geoip_format, + ) { Ok(database) => std::sync::Arc::new(database), Err(error) => { tracing::warn!(%error, "GeoIP country database unavailable; country allow lists fail closed"); @@ -258,6 +261,7 @@ async fn run( // socket has bound successfully so DNS/network failures can never obscure or // interfere with listener startup failures. state.start_geoip_updates(); + state.start_dns_acl_updates(); state.start_update_check(update_check); let shutdown = state.shutdown.clone(); let grace = Duration::from_secs(options.shutdown_timeout_secs); @@ -432,7 +436,10 @@ async fn run( grace }; if managed_update { - tracing::info!(budget_seconds = cache_flush_budget.as_secs(), "managed-update hot-to-cold cache handoff started"); + tracing::info!( + budget_seconds = cache_flush_budget.as_secs(), + "managed-update hot-to-cold cache handoff started" + ); } let flush_result = if managed_update { tokio::time::timeout( @@ -444,10 +451,9 @@ async fn run( tokio::time::timeout(cache_flush_budget, state.cache.flush_hot_to_cold()).await }; match flush_result { - Ok(moved) if managed_update => tracing::info!( - moved, - "managed-update hot-to-cold cache handoff completed" - ), + Ok(moved) if managed_update => { + tracing::info!(moved, "managed-update hot-to-cold cache handoff completed") + } Ok(moved) if moved > 0 => tracing::info!( moved, "flushed hot cache objects to cold storage before shutdown" diff --git a/src/runtime/certificates.rs b/src/runtime/certificates.rs index ba00ff3..7e51070 100644 --- a/src/runtime/certificates.rs +++ b/src/runtime/certificates.rs @@ -77,6 +77,7 @@ impl App { })? .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; self.config.store(Arc::new(prepared)); + crate::acl::dynamic::config_changed(); if let Some(certificate_id) = certificate_id { tracing::info!( revision, diff --git a/src/runtime/docker.rs b/src/runtime/docker.rs index c5240a3..46a1b7c 100644 --- a/src/runtime/docker.rs +++ b/src/runtime/docker.rs @@ -37,6 +37,7 @@ impl App { })? .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; self.config.store(Arc::new(prepared)); + crate::acl::dynamic::config_changed(); Ok(()) } diff --git a/src/runtime/updates.rs b/src/runtime/updates.rs index d620335..3dac698 100644 --- a/src/runtime/updates.rs +++ b/src/runtime/updates.rs @@ -1,4 +1,10 @@ impl App { + pub fn start_dns_acl_updates(self: &Arc) { + self.tunnels.spawn(crate::acl::dynamic::poll_loop( + Arc::downgrade(self), self.shutdown.clone(), + )); + } + pub fn start_geoip_updates(self: &Arc) { self.tunnels.spawn(crate::geoip::update_poll_loop( Arc::downgrade(self), @@ -152,6 +158,7 @@ impl App { this.logs.configure(envelope.config.logging.clone()); crate::logging::configure_error_log(envelope.config.error_log.clone()); this.config.store(Arc::new(prepared)); + crate::acl::dynamic::config_changed(); tracing::info!(revision, "configuration applied"); Ok(envelope) }) diff --git a/tools/checks/certificate_api_0319.py b/tools/checks/certificate_api_0319.py index d57b3c0..3a42337 100755 --- a/tools/checks/certificate_api_0319.py +++ b/tools/checks/certificate_api_0319.py @@ -7,7 +7,7 @@ import tomllib root = Path(__file__).resolve().parents[2] read = lambda path: (root / path).read_text(encoding='utf-8') version = tomllib.loads(read('Cargo.toml'))['package']['version'] -assert version == '0.32.4', version +assert version == '0.32.5', version spec = json.loads(read('src/admin/openapi.json')) assert spec['info']['version'] == version paths = spec['paths'] @@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'): for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate', 'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'): assert strings.get(key), (language, key) -assert '0.32.4' in read('docs/TEST_REPORT.md') +assert '0.32.5' in read('docs/TEST_REPORT.md') print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).') diff --git a/tools/checks/check_source.py b/tools/checks/check_source.py index a65f0a8..69b957b 100755 --- a/tools/checks/check_source.py +++ b/tools/checks/check_source.py @@ -24,7 +24,7 @@ def read_docker_agent_source() -> str: preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.32.4" +assert manifest["package"]["version"] == "0.32.5" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" h3dep = manifest["dependencies"]["h3"] assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"] @@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"] lock_path = root / "Cargo.lock" if lock_path.exists(): lock = lock_path.read_text() - assert 'name = "rust-proxy"\nversion = "0.32.4"' in lock + assert 'name = "rust-proxy"\nversion = "0.32.5"' in lock assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries" for release_doc, marker in { - "README.md": "# RustProxy 0.32.4", - "CHANGELOG.md": "## 0.32.4", - "ROADMAP.md": "## 0.32.4", - "AGENTS.md": "Current source version: 0.32.4.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.4", + "README.md": "# RustProxy 0.32.5", + "CHANGELOG.md": "## 0.32.5", + "ROADMAP.md": "## 0.32.5", + "AGENTS.md": "Current source version: 0.32.5.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.5", }.items(): assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable" @@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi assert '"StreamMetrics"' in openapi and '"streams"' in openapi -for marker in ['"version": "0.32.4"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: +for marker in ['"version": "0.32.5"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: assert marker in openapi, marker for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: assert marker in openapi, marker @@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log" assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241 assert 'location.pathname === "/logging"' in settings_js_0241 -assert '"RuntimeInformation"' in openapi and '"version": "0.32.4"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.32.5"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. tls_0241 = rust_source(root, "src/tls.rs") diff --git a/tools/checks/dependency_upgrade_0312.py b/tools/checks/dependency_upgrade_0312.py index 4c7097b..db59991 100755 --- a/tools/checks/dependency_upgrade_0312.py +++ b/tools/checks/dependency_upgrade_0312.py @@ -14,7 +14,7 @@ parser = argparse.ArgumentParser() parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update') args = parser.parse_args() manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text()) -assert manifest['package']['version'] == '0.32.4' +assert manifest['package']['version'] == '0.32.5' assert manifest['package']['rust-version'] == '1.99' deps = manifest['dependencies'] expected_requirements = { diff --git a/tools/checks/docs_check.py b/tools/checks/docs_check.py index c929ecd..d3041dd 100755 --- a/tools/checks/docs_check.py +++ b/tools/checks/docs_check.py @@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin readme = (ROOT / "README.md").read_text(encoding="utf-8") roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8") changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") -assert readme.startswith("# RustProxy 0.32.4") +assert readme.startswith("# RustProxy 0.32.5") assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive" assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme diff --git a/tools/checks/dynamic_acl_0325.py b/tools/checks/dynamic_acl_0325.py new file mode 100644 index 0000000..d998c7b --- /dev/null +++ b/tools/checks/dynamic_acl_0325.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Static source contract for DDNS ACL 0.32.5; NOT a Rust test.""" +from pathlib import Path +import json +import tomllib +ROOT = Path(__file__).resolve().parents[2] +read = lambda p: (ROOT / p).read_text(encoding="utf-8") +acl = read('src/acl.rs') +dns = read('src/acl/dynamic.rs') +js = read('src/admin/web/management.js') +checks = { + 'keep static IP/CIDR checks': 'RuleMatch::Ip(net) => net.contains(&peer)' in acl, + 'dynamic DNS only allows explicit allow rules': 'dynamic DNS ACL rules only support allow' in acl, + 'deny precedence retained': 'return Some(Action::Deny)' in acl, + 'strict hostname validation': 'pub(crate) fn hostname(' in dns, + 'scope scan covers vhosts, locations, profiles, host policies and docker': all(f in dns for f in [ + 'config.host_policies', 'config.acl_profiles', 'config.vhosts', 'vhost.routes', 'config.docker_agents.agents', 'agent.assignments' + ]), + 'bounded asynchronous DNS work': all(x in dns for x in ['REFRESH_SECONDS: u64 = 60', 'MAX_AGE_SECONDS: u64 = 180', 'MAX_NAMES: usize = 128', 'MAX_ADDRESSES: usize = 32', 'PARALLEL_LOOKUPS: usize = 8', 'LOOKUP_SECONDS: u64 = 4', 'buffer_unordered(PARALLEL_LOOKUPS)']), + 'lookup on refresh, not on request': 'lookup_host' in dns and 'lookup_host' not in acl and 'dynamic::matches' in acl, + 'fail closed on empty or failed resolution': 'CACHE.store(Arc::new(results))' in dns and 'return (name, None);' in dns and 'entry.valid_until > Instant::now()' in dns, + 'update after configuration commit': all('crate::acl::dynamic::config_changed()' in read(p) for p in ['src/runtime/updates.rs', 'src/runtime/docker.rs', 'src/runtime/certificates.rs']), + 'startup background refresh': 'state.start_dns_acl_updates();' in read('src/main/admin_runtime.rs'), + 'visual selector and disabled DNS deny': '.acl-profile-rule-type' in js and 'aclDnsAllowOnly' in js, + 'old network field persisted': 'network: normalizeNetwork(' in js, + 'PL/EN translations': all(k in json.loads(read(f'langs/{loc}.json'))['translations'] for loc in ('en_US','pl_pl') for k in ('aclRuleType','aclRuleDns','aclDnsHelp','aclDnsAllowOnly')), + 'package version': tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.5', +} +for name, ok in checks.items(): + if not ok: raise AssertionError(name) +print(f'PASS: {len(checks)} DNS ACL 0.32.5 static source checks (not compiled or network-tested)') diff --git a/tools/checks/h3_navigation_0316.py b/tools/checks/h3_navigation_0316.py index 4a64565..a2f43db 100755 --- a/tools/checks/h3_navigation_0316.py +++ b/tools/checks/h3_navigation_0316.py @@ -4,7 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] html = (ROOT / 'src/admin/web/index.html').read_text() -assert '0.32.4' in (ROOT / "Cargo.toml").read_text() +assert '0.32.5' in (ROOT / "Cargo.toml").read_text() assert '',1)[0] body = (ROOT / 'src/proxy/cache_helpers.rs').read_text() diff --git a/tools/checks/preserve_host_0324.py b/tools/checks/preserve_host_0324.py index e6d3d81..6d5f16d 100644 --- a/tools/checks/preserve_host_0324.py +++ b/tools/checks/preserve_host_0324.py @@ -51,6 +51,6 @@ for marker in ['margin: 0;', '--overview-gap: max(3px, calc(var(--ui-gap) - 1px) retry = re.search(r'fn retry_request\([\s\S]*?\n}\n', proxy) assert retry and '.uri(uri.clone())' in retry[0] assert 'upstream_request_target(&uri, request.headers_mut()' in retry[0] -assert json.loads(read('src/admin/openapi.json'))['info']['version']=='0.32.4' -assert 'version = "0.32.4"' in read('Cargo.toml') -print('PASS: 0.32.4 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)') +assert json.loads(read('src/admin/openapi.json'))['info']['version']=='0.32.5' +assert 'version = "0.32.5"' in read('Cargo.toml') +print('PASS: 0.32.5 origin pinning, authority, retry, CSS gutter source contracts (not Rust runtime)') diff --git a/tools/checks/release_0318.py b/tools/checks/release_0318.py index 57cbee0..ae6daee 100755 --- a/tools/checks/release_0318.py +++ b/tools/checks/release_0318.py @@ -8,9 +8,9 @@ from html.parser import HTMLParser ROOT = Path(__file__).resolve().parents[2] def read(rel): return (ROOT / rel).read_text(encoding='utf-8') -assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.4' -assert 'name = "rust-proxy"\nversion = "0.32.4"' in read('Cargo.lock') -assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.4' +assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.5' +assert 'name = "rust-proxy"\nversion = "0.32.5"' in read('Cargo.lock') +assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.5' pages = read('src/pages.rs') for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504): diff --git a/tools/checks/release_smoke.py b/tools/checks/release_smoke.py index 6c3e9e2..37a9493 100755 --- a/tools/checks/release_smoke.py +++ b/tools/checks/release_smoke.py @@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None: assert backup.is_dir() assert (backup / "src/obsolete.rs").is_file() assert not (target / "src/obsolete.rs").exists() - assert 'version = "0.32.4"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.32.5"' in (target / "Cargo.toml").read_text(encoding="utf-8") assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n" assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n" assert (target / "langs/site.json").is_file() @@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None: assert (target / "docker-agent/certificate.go").is_file() assert (target / "docker-agent/server.go").is_file() manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) - assert manifest["to_version"] == "0.32.4" + assert manifest["to_version"] == "0.32.5" assert manifest["removed_obsolete_runtime_state"] is True assert not legacy.exists() assert not (backup / "legacy-data").exists() @@ -617,7 +617,7 @@ def source_contract_smoke() -> None: assert marker in admin, marker anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0] assert '"/api/status"' not in anonymous_block - assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.4"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.5"' in openapi_0241 assert 'id="panel-logging"' not in html for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']: assert marker in html, marker @@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None: assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8") assert '"/api/config/history/restore"' in openapi - for marker in ['"version": "0.32.4"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: + for marker in ['"version": "0.32.5"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: assert marker in openapi, marker assert '"software_update"' in openapi and '"update_available"' in openapi for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']: diff --git a/tools/checks/security_logs_0315_ui_test.mjs b/tools/checks/security_logs_0315_ui_test.mjs index 2ca2841..0ba1b6b 100644 --- a/tools/checks/security_logs_0315_ui_test.mjs +++ b/tools/checks/security_logs_0315_ui_test.mjs @@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json'); const en = json('langs/en_US.json').translations; const pl = json('langs/pl_pl.json').translations; -assert.match(manifest, /version = "0\.32\.4"/); +assert.match(manifest, /version = "0\.32\.5"/); assert.match(lib, /pub mod user_agent;/); assert.match(model, /user_agent_blocking: crate::user_agent::Policy/); assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/); @@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/); assert.match(fileLogs, /aggregate_min_count/); assert.match(fileLogs, /file-log-aggregate-copy/); -assert.equal(openapi.info.version, '0.32.4'); +assert.equal(openapi.info.version, '0.32.5'); assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking); assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking); assert.ok(openapi.components.schemas.FileLogSource.properties.group_id); diff --git a/tools/checks/upstream_protocols_0323.py b/tools/checks/upstream_protocols_0323.py index 08acb50..f3da1e4 100755 --- a/tools/checks/upstream_protocols_0323.py +++ b/tools/checks/upstream_protocols_0323.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Offline source contracts for RustProxy 0.32.4 upstream protocols and card registry. +"""Offline source contracts for RustProxy 0.32.5 upstream protocols and card registry. These assertions supplement, rather than replace, Rust ALPN, H2C and websocket runtime tests. No code execution against a RustProxy server is implied. @@ -141,7 +141,7 @@ for field in ('dashboard_widgets', 'dashboard_hidden_widgets'): -assert '"version": "0.32.4"' in read('src/admin/openapi.json') -assert 'version = "0.32.4"' in read('Cargo.toml') -assert 'version = "0.32.4"' in read('Cargo.lock') -print(f'PASS: 0.32.4 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards') +assert '"version": "0.32.5"' in read('src/admin/openapi.json') +assert 'version = "0.32.5"' in read('Cargo.toml') +assert 'version = "0.32.5"' in read('Cargo.lock') +print(f'PASS: 0.32.5 upstream H1/Auto/compatible-HTTP2/strict-H2 source contracts and {len(ids)} registered Overview Cards')