This commit is contained in:
Mateusz Gruszczyński committed 2026-10-13 23:24:14 +02:00
1 parent 8b9ca4db44
commit 00218a72df
35 files changed
+784 -864

No files matched your search

+1 -1
View File
@@ -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
+12
View File
@@ -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
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.32.4"
version = "0.32.5"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -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"
+27 -2
View File
@@ -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
+9
View File
@@ -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.
+34 -31
View File
@@ -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
+1
View File
@@ -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
+27
View File
@@ -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.
-63
View File
@@ -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.
+10 -5
View File
@@ -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."
}
}
+10 -5
View File
@@ -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ę."
}
}
+25 -8
View File
@@ -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<CompiledAcl> {
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)?;
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"
);
}
rules.push((network, rule.action));
RuleMatch::Ip(network)
} else {
ensure!(
rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
);
RuleMatch::Dns(dynamic::hostname(&rule.network)?)
};
rules.push((target, rule.action));
}
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
+151
View File
@@ -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<IpAddr>,
valid_until: Instant,
}
static CACHE: LazyLock<ArcSwap<HashMap<String, Addresses>>> =
LazyLock::new(|| ArcSwap::from_pointee(HashMap::new()));
static CHANGED: LazyLock<Notify> = 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<String> {
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<Vec<String>> {
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::<ipnet::IpNet>().is_ok() || rule.network.parse::<IpAddr>().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<Addresses>) {
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::<Vec<_>>(),
_ => {
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<crate::runtime::App>,
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(&current.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");
+41
View File
@@ -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));
}
}
+45
View File
@@ -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 {
+3 -2
View File
@@ -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",
+22 -2
View File
@@ -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);
+183 -679
View File
File diff suppressed because it is too large. Load diff
+98 -27
View File
@@ -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<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new();
let mut php_endpoint_policies: HashMap<
String,
(
String,
std::collections::BTreeMap<String, String>,
std::collections::BTreeMap<String, String>,
),
> = HashMap::new();
for service in &mut config.web_services {
service.validate().with_context(|| format!("Web Service {}", service.id))?;
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
// A profile changes effective runtime settings, not the saved standalone
// PHP configuration. Detaching a profile restores local settings exactly.
let mut effective = service.clone();
if let Some(id) = &service.php_profile_id {
effective.php = php_profiles.get(id).with_context(|| {
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
})?.clone();
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
effective.php = php_profiles
.get(id)
.with_context(|| {
format!(
"Web Service {} references unknown PHP-FPM profile {}",
service.id, id
)
})?
.clone();
effective
.validate()
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
}
if effective.php.enabled {
let php = &effective.php;
@@ -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<Option<crate::config::CompiledLocationRedirect>> {
fn validate_location_redirect(
redirect: Option<&crate::vhost::LocationRedirect>,
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
let Some(redirect) = redirect else {
return Ok(None);
};
@@ -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 authority_end = redirect
.target
.find("://")
.and_then(|start| {
let rest = &redirect.target[start + 3..];
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
rest.char_indices()
.find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
.map(|(offset, _)| start + 3 + offset)
.or(Some(redirect.target.len()))
})
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(
!redirect.target[..authority_end].contains('$'),
"location redirect captures are not allowed in the URL authority"
);
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
.expect("static capture regex")
.replace_all(&redirect.target, "capture");
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
let parsed =
url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
"location redirect target must use http/https and include a host"
);
ensure!(
parsed.username().is_empty() && parsed.password().is_none(),
"location redirect target cannot contain credentials"
);
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
None | Some("") => None,
Some(pattern) => {
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
ensure!(
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
"invalid location redirect rewrite regex"
);
let mut builder = regex::RegexBuilder::new(pattern);
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
Some(builder.build().context("invalid location redirect rewrite regex")?)
builder
.size_limit(2 * 1024 * 1024)
.dfa_size_limit(2 * 1024 * 1024);
Some(
builder
.build()
.context("invalid location redirect rewrite regex")?,
)
}
};
Ok(Some(crate::config::CompiledLocationRedirect {
+12 -6
View File
@@ -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"
+1
View File
@@ -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,
+1
View File
@@ -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(())
}
+7
View File
@@ -1,4 +1,10 @@
impl App {
pub fn start_dns_acl_updates(self: &Arc<Self>) {
self.tunnels.spawn(crate::acl::dynamic::poll_loop(
Arc::downgrade(self), self.shutdown.clone(),
));
}
pub fn start_geoip_updates(self: &Arc<Self>) {
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)
})
+2 -2
View File
@@ -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).')
+9 -9
View File
@@ -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")
+1 -1
View File
@@ -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 = {
+1 -1
View File
@@ -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
+31
View File
@@ -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)')
+1 -1
View File
@@ -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 '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
+3 -3
View File
@@ -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)')
+3 -3
View File
@@ -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):
+4 -4
View File
@@ -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"']:
+2 -2
View File
@@ -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);
+5 -5
View File
@@ -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')