From 79fd1989493110f90b5f2a744fe1dd8857ba101e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 11 Oct 2026 22:55:22 +0200 Subject: [PATCH] v0.31.7 --- AGENTS.md | 2 +- CHANGELOG.md | 12 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 22 ++-- ROADMAP.md | 10 ++ SHA256SUMS | 90 ++++++++------- dev.sh | 2 + docs/API.md | 4 + docs/IMPLEMENTATION_0.31.7.md | 29 +++++ docs/README.md | 3 +- docs/SETTINGS.md | 24 +++- docs/TEST_REPORT.md | 19 ++- langs/en_US.json | 9 ++ langs/pl_pl.json | 9 ++ scripts/resolve_dependencies_0312.sh | 4 +- src/admin/documentation/documents.rs | 1 + src/admin/openapi.json | 82 ++++++++++++- src/admin/router.rs | 1 + src/admin/web/app/config-history.js | 80 ++++++++++--- src/admin/web/index.html | 122 ++++++++++++-------- src/admin/web/integrations.js | 5 +- src/admin/web/management.js | 6 +- src/admin/web/settings.js | 25 +++- src/admin/web/style.css | 67 ++++++++++- src/admin/web/vhosts/core.js | 6 +- src/admin/web/vhosts/editor.js | 4 +- src/admin/web/vhosts/rates.js | 18 +++ src/cache/cold/demotion.rs | 5 +- src/cache/cold/worker.rs | 21 ++-- src/main/admin_runtime.rs | 7 +- src/proxy/forward_entry.rs | 3 +- src/rate_limit.rs | 71 ++++++++++-- src/rate_limit/tests.rs | 48 ++++++++ src/user_agent.rs | 13 ++- tools/checks/check_source.py | 24 ++-- tools/checks/dependency_upgrade_0312.py | 6 +- tools/checks/docs_check.py | 6 +- tools/checks/h3_navigation_0316.py | 6 +- tools/checks/preflight.py | 6 +- tools/checks/release_0317.py | 101 ++++++++++++++++ tools/checks/release_0317_ui_test.mjs | 74 ++++++++++++ tools/checks/release_smoke.py | 10 +- tools/checks/security_logs_0315_ui_test.mjs | 6 +- tools/checks/source_module_contracts.py | 2 +- tools/checks/system_info_0301_ui_test.mjs | 6 +- tools/checks/web_assets.py | 2 +- update.sh | 47 +++++++- 48 files changed, 922 insertions(+), 202 deletions(-) create mode 100644 docs/IMPLEMENTATION_0.31.7.md create mode 100644 src/admin/web/vhosts/rates.js create mode 100755 tools/checks/release_0317.py create mode 100644 tools/checks/release_0317_ui_test.mjs diff --git a/AGENTS.md b/AGENTS.md index 3c43a3d..839059d 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.31.6. +compatibility. Current source version: 0.31.7. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fe9b2..faab71b 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.31.7 + +- Refine revision history dialogs: place **Full JSON comparison** directly below **Restore revision**, allow long change lists to scroll without overflowing the modal, and use the standard dialog border for JSON comparison. No revision data, restore logic or API changes. + +- Group Global User-Agent blocklist, Global custom headers and Client response under a new **Settings → Rules & headers** tab with three sub-tabs, using the existing persistent Settings forms. Security continues to own session and hostname access controls. +- Introduce validated per-vhost User-Agent request limits: up to 64 case-insensitive substring/`*` patterns, each with its own request count, window and burst. First matching UA rule overrides country/default limits and is scoped per vhost and client IP; user agents without a match retain the existing limiter. Settings remain optional and older saved configs load unchanged. +- Show only changed configuration fields in the revision preview, with ID-stable array paths. Move full before/after JSON into a separate on-demand modal. Revision restore and retention remain unchanged. +- Add spacing between the HTTP/2 and HTTP/3 listener tuning links. Collapse ACL profile **Attached to** by default without hiding its contents or changing profile attachment rules. +- Correct `update.sh` retention cleanup (mtime-sort key was previously included in the removal path) and retain **two** installed releases, including the active release. Preserve **three** private stopped-service backups for rollback. Preserve Cargo fingerprints, compiled deps and executables; only expired incremental build state may be removed, after a successful update and only while Cargo's profile lock is available. +- Confirm managed-update marker is created before `systemctl stop` and read on shutdown, triggering best-effort HOT→COLD persistence even when `cold_on_shutdown=false` if cold storage is enabled. Add completion diagnostics counting successful persisted objects (including zero), not merely attempted writes; no claim of a successful runtime flush without testing. +- Retain SQLite schema 9, COLD format 3 and existing H3/mobile fixes. Compiler/live upgrade acceptance pending. + ## 0.31.6 - Preserve logo click as the original `href="/"` full-page navigation. For H3 to HTTP/1.1 backend forwarding, complete empty GET/HEAD receive streams through a bounded first-frame probe before forwarding. Hyper already special-cases GET/HEAD H1 encoding; this change targets H3 receive-half lifecycle, not assumed chunked GET framing. Also bound H3 GET/HEAD cache and 404 preflight so a missing FIN cannot indefinitely prevent navigation; rewind/replay any nonempty body unchanged. diff --git a/Cargo.lock b/Cargo.lock index 1117c3a..d217268 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.31.6" +version = "0.31.7" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 26f91b6..5a599e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.31.6" +version = "0.31.7" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index 634eebb..9440a6c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# RustProxy 0.31.6 +# RustProxy 0.31.7 **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). Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration, streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools. -**0.31.6 HTTP/3 mobile full-navigation stability and diagnostics (Cargo/live verification required).** +**0.31.7 per-vhost User-Agent limits, Settings grouping, revision diff, and safe update retention (Rust/Cargo and live testing required).** ## Layer-4 streams @@ -13,13 +13,19 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners. -## What changed in 0.31.6 +## What changed in 0.31.7 -- Make HTTP/3 `GET`/`HEAD` forwarding to HTTP/1.1 origins explicitly recognize a finished, empty H3 receive stream before upstream forwarding. Bounded probes at the cache/404 preflight and H1 forwarding stages preserve nonempty bodies and let slow peers continue streaming without blocking the document navigation. Hyper already special-cases H1 GET/HEAD framing; this is a receive-stream lifecycle fix, not a claim that GET was always chunked. It targets full reload via the original logo `` without changing its action or other panel navigation. -- Keep in-flight H3 response tasks draining after an H3 accept-loop failure rather than immediately resetting every active stream. -- Record peer cancellations with HTTP status **0** (not a synthetic HTTP 400) and emit diagnostic phases, H3 stream ID, method, sanitized path without query, selected response status and bytes already sent on interrupted H3 responses. These error events are in per-vhost file logs when logging is enabled; an H3 `GET /` also produces INFO/WARN navigation breadcrumbs in the service log to identify the *new* request. Do not confuse old-stream cancellations with failed new navigations. -- Retain the 0.31.5 mobile `/logs` layout, cache protocol toggle fix and trusted panel-Origin behavior. SQLite schema 9 and Cold Cache format 3 are unchanged. -- Rust/Cargo and actual iPhone+QUIC acceptance remain mandatory. See [0.31.6 implementation](docs/IMPLEMENTATION_0.31.6.md) and [test report](docs/TEST_REPORT.md). +- **Settings → Rules & headers:** the global User-Agent blocklist, global custom header editor and client response settings now share a Cache-style tabbed page (three persistent sub-tabs). Settings → Security keeps sessions/password and host access policies. +- **Vhosts → Traffic policy:** optional User-Agent-specific per-client-IP request limits. First matching case-insensitive substring or `*` wildcard rule overrides the default/country rate for that request, even when ordinary throttling is disabled. Each vhost, UA rule and IP has independent bounded fixed-window accounting. Up to 64 rules per vhost; patterns are validated and the feature is opt-in. +- **Revision history:** show changed leaf fields rather than object-sized JSON previews, including stable IDs for array members. Full before/after JSON is available through a separate comparison dialog. Restore behavior unchanged. +- **Listeners:** space apart HTTP/2 and HTTP/3 tuning shortcuts. **ACL profiles:** “Attached to” is initially collapsed but expandable. +- **`update.sh`:** retain exactly two installed binary release directories (including `current`) and three private pre-update backups. Correct the previous mtime-sort path deletion bug; old releases were only printed, not actually deleted. After a successful update, optionally purge only incremental Cargo state unused for 30+ days, never fingerprint, dependency, build-script or compiled executable artifacts. A source-tree upgrade retains its own backups and target directory. +- **Graceful managed update:** trace marker → systemd stop → forced best-effort HOT→COLD handoff when COLD is enabled; record completion, even when zero items were demoted. Existing update flush works regardless of `cold_on_shutdown`; no persisted schema or cold format change. +- **Compatibility:** retains all 0.31.6 H3/iPhone diagnostic fixes, 0.31.5 mobile Logs and protocol-independent cache behavior. SQLite schema 9 unchanged. Rust/Cargo and live reload/update verification remain mandatory. See [0.31.7 implementation](docs/IMPLEMENTATION_0.31.7.md) and [test report](docs/TEST_REPORT.md). + +## Previously in 0.31.6 + +- HTTP/3 full-navigation GET/HEAD receive-stream handling, client cancellation classification and diagnostic events. Keep logo behavior unchanged. See [0.31.6 implementation](docs/IMPLEMENTATION_0.31.6.md). ## Previously in 0.31.5 diff --git a/ROADMAP.md b/ROADMAP.md index 31d701f..6a9ccf8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,15 @@ # RustProxy roadmap +## 0.31.7 - UA-specific throttling and managed update housekeeping (runtime acceptance pending) + +- [x] Group global UA blocking, headers and response controls in a tabbed Settings page. +- [x] Add per-vhost UA rate-limit overrides with separate bounded counters. +- [x] Show leaf-level revision diffs, with optional full JSON comparison dialog. +- [x] Space apart listener protocol buttons and collapse ACL attachments by default. +- [x] Fix release retention path deletion; preserve two releases; limit Cargo cleanup to expired incremental state. +- [x] Verify update marker and forced HOT→COLD shutdown code path, and add completion logging. +- [ ] Compile and test with Rust/Cargo; run real update/rollback and browser/UI tests on a controlled host. + ## 0.31.6 - H3 iOS navigation hardening (runtime acceptance pending) - Finish live Safari/Chrome iPhone logo-navigation and repeated-refresh acceptance over H3 after Rust compilation. diff --git a/SHA256SUMS b/SHA256SUMS index 2aa514b..bbf1e33 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -715de2d377038368b11ba94b94930ef349b5c8b7e87454d9e80afae9b8fbe6e3 AGENTS.md -8550600e24446331fd4c2fbbd30a69d38b3ebddbe8f251c9e191841483cda9b2 CHANGELOG.md -b76929e915c20fb14013fb221a10b9a285ff19b9f95473a32c286f59a6d6ab9b Cargo.lock -d3407b114f32880318c1661de9d05f9f2761451fe270cfdc45c883e4f59fa7dd Cargo.toml +685f856d31bf33f724aaa4afc23a6b3d9d9ca9e8e99338792db4f3563621f449 AGENTS.md +43c9ccd8768c6b388c6cb29441f4e59a16422e5d3c82a9e52761b3f1c6bc46a0 CHANGELOG.md +b8e0b191c849a1c4eac46b0091c228a0c0cc52de4c708f9e9ca23a621b870e3a Cargo.lock +3f8c093e5a499d526df5f81ee246df70b3512a8fa1cbb1e071210b6eae0af706 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -cbb080c2fbbd543385351c44b0c53153df423334811aac8f3be93da5182c3ac8 README.md -1a85c502721492a0a2d653af9f18bb51bd0f149a0ce5813d176231cac972d90b ROADMAP.md +31b53a45263c72510c145b4b144bd32e7b4d5d720969f3c1dcd02ead20ebdcff README.md +db3235387db9e11f8193dfeeb853767f27487107a903cfbb0b5873b1b5124be8 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service -1a74775a1d98b21deb040ceeca8f7555a2839b35e5cce3a92a37749a22896adc dev.sh +8aa17b34eaedba76295f1b82201a4d38ca1dde8be8fdeda6b8c28371adf34d1a dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example @@ -27,7 +27,7 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md -a36880dc07e922a36a4c8be5fa889a03658bb0492a1e20ec00fbb130241c7719 docs/API.md +e25ce97c6d1d75f0cab162eee28c1cb77561b210ad36ba7189d81328c3bb4d6c docs/API.md b0c6fae1d538f33fb468a00307cbb745a601d9fc341c0a4f859d60c7e8d48865 docs/API_ACCESS.md 4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md @@ -45,6 +45,7 @@ eb52e72325e1307d05cb04878b6d22dfedea491908c5f36110b32421765096c8 docs/IMPLEMENT db2087608a1225c56e42c416d949b777fb15b1ed8b69a1e71fc2a6af6750a1e5 docs/IMPLEMENTATION_0.31.4.md ab2633deb3ad3c6bad056970363d3227213f919915a2298d9549e8029b5e0c78 docs/IMPLEMENTATION_0.31.5.md 684471c3d40bf832d460d42506adbba3e4a128540463738a575eff3ebcf22001 docs/IMPLEMENTATION_0.31.6.md +bfea31bfffcd6d64dd9322ceb0f99cd1818556fca0d3c2886efc3459b9e34b1c docs/IMPLEMENTATION_0.31.7.md a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md 3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md 4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md @@ -58,7 +59,7 @@ e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md 572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md -4a074b8ad77f1bf286f966b973e115d1f48f02084d93df09fafeab670e15038b docs/README.md +0b85364de02fea804434ff0c592a3968c9fc9e6d8840491243ea7ffa0fea2fcd docs/README.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md 0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md @@ -67,9 +68,9 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md -970025a6d1a37452b8bead6c9d2b3d4743ea602251dc446d575531d76c11ae83 docs/SETTINGS.md +7f27d782534455eebce538f10c902346f7d194acd75b6106c7f8dec3c44857e9 docs/SETTINGS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md -07ea27c13cda6af001deb53a59330041bfbe11b47ab1a4fb186e97cffb941cf6 docs/TEST_REPORT.md +031d7e1570fb9cfa036ef4958c4458046b7fbf33ec758b92f28d8d80886f5d2f docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md @@ -141,8 +142,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 -17796036957fe2afe593481d479460421a000d3c69038cfffe945149c53f1e21 langs/en_US.json -b6adc6c6e462f41652ea4974694fe5e9b5e972922e95e81968dcf552fcf1d029 langs/pl_pl.json +1e746962a3db371948b1f820bcca02ecf6852319868f6e77b421d4da277845f1 langs/en_US.json +a45134738a1cea89f0c18bf352e1ff7e18089769abf49b2a043f1d61d5f50f03 langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -168,7 +169,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py -7c9f1476c7f786c702c5d9c1693ad230b0900a67b6b3b3947f77b2c52f43eca1 scripts/resolve_dependencies_0312.sh +c904ae3acee8b4faf56fe64d8bd430542a44fbd923c65bb2bab310bf23def6ab scripts/resolve_dependencies_0312.sh 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py @@ -186,7 +187,7 @@ e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs 019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs 5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs -174a84114f2c8b14b7568a1467d5b7edcc0c82e8589fd3ae331113bfdaf41a41 src/admin/documentation/documents.rs +d60fc57931a51df3a1006fb7b694ea7682e0815a030256d8fa9b22f3c3013f47 src/admin/documentation/documents.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs 1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs b3aad0db47d930ee177253cb5d5eb772272452bab4385a34566b111c239a6302 src/admin/file_logs.rs @@ -194,7 +195,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file d5eb4019a4aa6a4fdcad9310dce9076dcb85ca4640540c64e924367ba2276234 src/admin/file_logs_query.rs 7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs 58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs -261978723c4ad0b0731cf71ca688add07d7b59af5a0c1e6adcccbc92c198fe7f src/admin/openapi.json +8d9db966f3c4f092c29bd17a4c16fbab2cfd3c6bb2c5f0063da3bb3035e91b67 src/admin/openapi.json 0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs @@ -203,7 +204,7 @@ d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/pref 055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs -cbcb7e73ec47b2d224d9072f5023b8ccec082e2b110ba090e84fb6fe05e92492 src/admin/router.rs +e41f1e4276f3f00db6caff2738fc01c949ace3aca468e4de82cbeecd2ae58276 src/admin/router.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs @@ -222,7 +223,7 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail bffa6611f3776e97d0065b510b63f083aabf7b3429fcc5cb26b1883fdb7c47dd src/admin/tests/part_10.rs 578f93cc5771b5858813945e5608b15c572bd3d7fd15bae291d292e2994ece25 src/admin/tests/part_11.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js -446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js +d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js a42640573b29bf0d13f87b3336d3c86e50cd7f8af2c453836edfda23628fba97 src/admin/web/app/core.js 23721bfbac850c4f77b116cc32bb9d2db6361ac4dd7e310a8c8070ab906997f6 src/admin/web/app/preferences.js 729ed152eb8471c67fb0e55e44fe0669c2708802ce83228df9cb4e53c14cee02 src/admin/web/app/refresh.js @@ -245,21 +246,22 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg bc9e11724536de89bced1f0a752974068a0229ee14c3488592a27fc58261aaed src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js -5d0f813af08bb125f201b7d000f5fcb7372c0567289d3a02940ba4d0d51820d0 src/admin/web/index.html -24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js +4740b0fe963604528db10f0beffca5c43f52a8fab923c71f31c4cdfdea446e9a src/admin/web/index.html +03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html 99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js -21ddae9743ca545c89f6df0db40810dd680812e58f2b2b6875ed64fd8fb97ac6 src/admin/web/management.js +136c093a907825e0d942c06186fffc834936b60dd6408bcafe81092cff7e3c64 src/admin/web/management.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js -8fcad0d1a161ac6f45d773d1dc3925db2d32b34bdbe57595924f84eacb4f9136 src/admin/web/settings.js +2936ef4ea7d56edb878758ddcaeed7f55fcfe57044c82eacab7d7126c0ba1b44 src/admin/web/settings.js bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js -09093fe70301df47fd3aaa0fc78ce1c44b256d86ae56a7732c2d8ddf9499393d src/admin/web/style.css +303d3b4d426c521392981c872b3fb685843b8e965ee510ab7a5bab014b21ebc8 src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js -57a2b975716bdab32c33ec1bf9427b25998550e81ce191440ee66ffb7e2a249e src/admin/web/vhosts/core.js -8f373c00cb545a03899ed30943ed82459bf6c5974b40ec89e7c44dfdc96bba43 src/admin/web/vhosts/editor.js +00f59a6f656e44c364ed3c8eed33ce1df878a488908b30a9523a87b64f50f9ef src/admin/web/vhosts/core.js +306a7e48425c54a0b3637e7789e68fefa0c9d7630cf8715175f1690993888b69 src/admin/web/vhosts/editor.js 30a6344e76de77b60cd7247a33c68f1cb5d972aebfde56469f2ebd44440c7bf9 src/admin/web/vhosts/health.js +137aeabed99cbdf9f122da6350e596e6a4e32244fa23101ccf6378305148d38d src/admin/web/vhosts/rates.js f2689283cfe43d6aae911b0df778a69335089be230fbfc6de5dcce1801c38427 src/admin/web/vhosts/render.js 91e3417fba7e8f59023cd64813befccc8341741cd4aa53dfa1ec68a03c7f4632 src/admin/web/web-server-profiles.js de3326d4fae254eb0b8c190f02e762f4094dcba86bfdcb24c5fb472676ebcc4b src/admin/web/web-server.js @@ -288,9 +290,9 @@ a1e5d43f5417d71f3974812cb539c0932f206e2290090917f067494f3587fc72 src/balance/le 9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs 7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs a9eeb93519aae2de7a1282296ced91d2eb8307cc966f8fbf6085af0fd21b0ad2 src/cache/cold/cleanup.rs -206809de4dfdf2f100762e91e6d964a6fa03879dd225d5801f5fd91fe47b3ff1 src/cache/cold/demotion.rs +e022003be144598100389cf38d76f509b5dbcc035d5bae556bb9b94cc8bd30ba src/cache/cold/demotion.rs 9fc18368e2ce278ef0ad2b73ff016aa24936c2186be371ca4010a1f6f63bb0ed src/cache/cold/startup.rs -4615ec97504fb0d41f2259595fdc96d272aeb30451c05b07d4f4fb1578472e6f src/cache/cold/worker.rs +ea319b6e200b0c6ca93da5e2af7a3f86e3667cef918716f54f82b8012111b9bd src/cache/cold/worker.rs d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs 27a35689fef639f1aea00d9f5a86adc981c0c304dc3e28e6da40494dffbb6bfa src/cache/dashboard.rs bbd6de8568fea79cc4a11d33b905af75713e609c8f37ec943c82f0d180e66dcc src/cache/fill.rs @@ -380,7 +382,7 @@ f5815d778cc1785723faed4abb15f9880f22b6524b5289fe1e7158905aac0986 src/logging/mo 42fef80af50735cd566c6801dad86ba81be98cf4a8ec7d45c2ea26f53f556a22 src/logging/runtime.rs 03c50bd14fecd421b0901a5f0af891dc277e35420e32068781e3a6d78c88c24b src/logging/tests.rs 3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs -2f6b5a9517453541bf43580a9d963e64e36338575ee9ad295ca3718803a59ed6 src/main/admin_runtime.rs +788cdeb96e887f85dc24798ec69b56619ee35fabeac5a40f8fa071bbf6cf24c6 src/main/admin_runtime.rs 42a9197475823f67c1ca762951ede5d7a2f10124bd6ed4d1a3365f4297be894c src/main/bootstrap.rs 5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs 4dd7c1084a6214c0f2513ebf86fe8ef144470695557772bfe25979edd64491ab src/main/shutdown.rs @@ -414,7 +416,7 @@ cea9e47ecb31b0e4c0bbe0eb83e0e53b5e7bdcb9930107b67a8c34eef01a3f49 src/proxy/forw 902cbf8dc3c1ec8d25411bc7fe0400d301f3a7cc885afada594e4eaee3bedd08 src/proxy/forward/success_prepare.rs 23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs 8961d8d649d8f4708f443f1b8b8a73c8b5f42811d2cad8cc6f596833511933f4 src/proxy/forward/web_source.rs -69cb26cc475cc6fdee235df78227543b7c5e591b7cdf6cb7a2ff1024beffc845 src/proxy/forward_entry.rs +a9c011061585c067f04f7885215d53e6c40474bdced15d2be5a5ba65190910a0 src/proxy/forward_entry.rs c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs 5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs 632ee37457d9bf1dbcd3211afdf489f4fc9faf018007d5c1036169436e8e5dea src/proxy/mod.rs @@ -429,8 +431,8 @@ d866e7e72dee6ff54c2418c34e9899cbcd7e4e027d9cbe2b625044dc9062e4ee src/proxy/webs 7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs a44421714aab37f7ceda19e3ec2512c645d2a3ce5dcb3da5bc7a0d87a442d6a8 src/proxy/websocket/tunnel.rs 15fabd38576b61f650c039acdbf7ac5a5bd24330062475b94315285b2dc258b6 src/proxy/websocket/validation.rs -463a871aef70a5531c33a9ea43b9dc4e628b272d654bb225e6161aa1497d96f3 src/rate_limit.rs -a767c23830f188f5cd6bb747ad6950732a987df3b36adec0d7a871cbab711bbf src/rate_limit/tests.rs +545247eb00b0116efbd4cc913cc251195a55feded1530e0d8d8945abbae78039 src/rate_limit.rs +e7e3a898e2c769b1ef11e58a13c1053104ada93dd694f8a03ea4b647c44c7d69 src/rate_limit/tests.rs d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.rs 811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs @@ -493,7 +495,7 @@ a67e58a597a5eeb7efe605ae6e438a8f84ff6a1cedf8cd228141cb95551872a8 src/upstream.r 225e3078e6775ffaa6dc6f12bc87503a123983cbb347edf38cae4c37ff02a5db src/upstream/client.rs 4aa2420a92d33c50f7027f57ac4eccdc050bdb7e9ec8fa6e0eaa809228b71cd8 src/upstream/client_tests.rs 6f932b8e59de8a5c89d3b29b838eb2a30f6e3ddd342fec29e39af1ce1b1c7d11 src/upstream/tests.rs -3b3e5dd27253cb5626a0e37f670692adeb132aa2e0e89be35abdd730272b86a0 src/user_agent.rs +a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs 6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs 3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs 6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs @@ -540,34 +542,36 @@ e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/a fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py 412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs 2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs -821736b16c13ce9a86b95fe91909c985c61fde5e382299a9b70183400b1d465e tools/checks/check_source.py +2865412f91aabd488934d264bc1c2bde7299894013bef776cb5fac4eade27668 tools/checks/check_source.py dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs 259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs -ce506b3c5e0a01552000fc2e0c841a359ff97956fcc0cbbc772f90dd0fce7999 tools/checks/dependency_upgrade_0312.py -1a4bba46b9ebc052b76fb356c3e1437c8d028d1f18b83994520a39d12de555e6 tools/checks/docs_check.py +03d399d7f75b3f6aa8b6f68fddd0e791ab0071d657b9ba7255988a1d8a812836 tools/checks/dependency_upgrade_0312.py +a95ad016d46197c6c7b183b10c7528dd883a16132c475c76e0ce1b2e07a9dd5d tools/checks/docs_check.py 6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs 83c74f0027a6d2f51f637d552593f9904e60ccd69665f6460136c48b159afb60 tools/checks/h3_cache_mobile_0315_test.mjs -33338a4778a32be16b8518ead7f642c6dc57206e3f671852ef52378c5814d9b0 tools/checks/h3_navigation_0316.py +157c3d630fe0846dda55c57d9e0640cbff2831988ccee8757854774b05e8b8e7 tools/checks/h3_navigation_0316.py e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs 1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs -b94a388135b8986730ca5c95c03445a1cd5dcdc9e787e3b53f9188e3e41aedd6 tools/checks/preflight.py +73554fe6ec7234aeda65e04c53084547d79c6268218516be2d220f9ad4348cad tools/checks/preflight.py 021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs -811d926d5a78c7fcc83325a7de9c89acc9437857faf9ba374d0f077c0fd55bc5 tools/checks/release_smoke.py +c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py +b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs +763d9b02213e61ca503ccef1612beb962c340ccffe92e9f5b8427530764aa89b tools/checks/release_smoke.py f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef 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 -598c390550357d89534b4fe882815bbe052280f0b870626c281d3500c6a6462c tools/checks/security_logs_0315_ui_test.mjs -f7fe205ee0cfe0347f73c10d626aa1dadda6a708bf8a46c25fc90075eeec968a tools/checks/source_module_contracts.py -19574fb1d7c786402eb864e723fdc2f86a9575841a2f41c3a06cc43460aaa6a3 tools/checks/system_info_0301_ui_test.mjs +ff69e1381a759e07effdc25f8401ba92730be6116fe15c7470539e50ddf4f3e5 tools/checks/security_logs_0315_ui_test.mjs +70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py +e4b6f095ade55881d1d7fe0b8b821e9c5c7312e13ee062db44804dd2b6f727f9 tools/checks/system_info_0301_ui_test.mjs 543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py 161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs 0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs -08f75c17084352df8d4dd0d1e712486d6d600739451735976de2234903538bc5 tools/checks/web_assets.py +c6777d83976f021d689857a0d53a9a30f4a4dafdf68cdcc672dd995279b09d1f tools/checks/web_assets.py 35464d3d2c4af0c972b99e3cf4c7b8423b68260a8c7bc6a929f8e7ab2a164a96 tools/checks/web_server_contracts.py 35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs @@ -576,5 +580,5 @@ d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock 2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py 538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py c711154a9df425a56607bd88f03815ffb408213a31a2b66ed1c1c9a6301a2fc6 tools/legacy/backup.py -fec1b9f7bf8d4b43c2b45e6a1f762c9412c51f3e4e3520aff185b0590b5d47be update.sh +89d2b4e60a2f4cb93e8943b74b1b5ca84374b01fa74ea07bec07bdff315ac0e9 update.sh 56c702111022e1ca2c8f6b72c635c749b05936ffe6387bdb0a202691614cbc96 upgrade.sh diff --git a/dev.sh b/dev.sh index d317582..579da64 100755 --- a/dev.sh +++ b/dev.sh @@ -91,6 +91,7 @@ if [[ "$mode" == "repair" ]]; then printf 'Source preflight: OK\n'; exit 0; fi if [[ "$mode" == "test" ]]; then python3 tools/checks/rust_module_contracts.py python3 tools/checks/h3_navigation_0316.py + python3 tools/checks/release_0317.py python3 tools/checks/system_info_no_subprocess_contracts.py python3 tools/checks/source_module_contracts.py python3 tools/checks/version_contracts.py @@ -116,6 +117,7 @@ if [[ "$mode" == "test" ]]; then node tools/checks/admin_public_origin_0314_config_sim.mjs node tools/checks/admin_panel_config_0314_ui_test.mjs node tools/checks/security_logs_0315_ui_test.mjs + node tools/checks/release_0317_ui_test.mjs node tools/checks/h3_cache_mobile_0315_test.mjs node tools/checks/panel_cache_http_0310_ui_test.mjs node tools/checks/vhost_origin_errors_0297_ui_test.mjs diff --git a/docs/API.md b/docs/API.md index 0475944..4c1837f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -599,3 +599,7 @@ As of 0.24.3 the preferred configuration is per listener: `listeners.endpoints[] The top-level `http2` object introduced in 0.24.0 and `PUT /api/config/http2` remain accepted for backward compatibility. They are a fallback only for listeners that do not yet have explicit `binding.http2_settings`; the structured 0.24.3 UI writes listener-scoped values. The runtime system diagnostics object includes `platform`, supplied by the shared system-statistics backend (`Linux`, `FreeBSD`, `OpenBSD`, `NetBSD`, or the generic/unsupported fallback). The browser only renders that value; it does not detect the operating system itself. + +## Per-vhost User-Agent rate limiting (0.31.7) + +The revisioned `/api/config` vhost rate-limit object accepts optional `user_agent_limits`, up to 64 entries with `pattern` (1..256 bytes, case-insensitive substring or `*` wildcard), `requests` (1..1000000), `window_seconds` (1..3600) and `burst` (0..1000000). The first matching rule replaces the standard and country rate for that request and uses a separate fixed-window counter for each (vhost, rule index, client IP). Requests with no matching User-Agent use the existing limiter. A User-Agent is trivially spoofable; these overrides are operational shaping, not access control. Global and vhost User-Agent blocklists continue to deny matching requests before rate limiting. diff --git a/docs/IMPLEMENTATION_0.31.7.md b/docs/IMPLEMENTATION_0.31.7.md new file mode 100644 index 0000000..5cdafdc --- /dev/null +++ b/docs/IMPLEMENTATION_0.31.7.md @@ -0,0 +1,29 @@ +# RustProxy 0.31.7 implementation and acceptance gate + +## Settings and vhost throttling + +- **Settings → Rules & headers** includes the Global User-Agent blocklist, Global custom headers, and Client response sections. Forms and request endpoints are unchanged, so there is no data migration. Settings → Security retains password, session and host-policy controls. +- `vhosts[].rate_limit.user_agent_limits` is an optional list (default `[]`) of `{pattern, requests, window_seconds, burst}`. At most 64 rules. Patterns use case-insensitive substring matching when no `*` is present, otherwise wildcard matching. Values are normalized and validated before persistence. The first matching rule has priority over the country override and base limiter for that request. No matching UA uses existing country/base behavior; missing UA does not select a UA override. Every vhost/UA rule/client IP has an independent fixed-window bucket within the shared 65,536-bucket cap. Cache, ACL, and global/vhost UA deny policies run with unchanged precedence. +- **Important:** UA is client-supplied. These rules provide traffic shaping, not trustworthy identity or bot protection. An absent/changed/oversized UA may avoid matching. Use ACL and base limits for security enforcement. + +## Revision history and layout + +- Revision-history UI polish: comparison control sits under Restore without extra dividers; long change lists scroll within the bounded desktop detail pane, while the stacked narrow-screen layout scrolls as one modal. Full JSON comparison uses the shared dialog border and borderless code blocks. +- Diff preview displays only changed leaf paths, sorted by object key. Array entries with stable `id` or `name` keys are compared by identity, avoiding spurious changes on reorder. Output is limited to 200 changes; large scalar previews truncate after 320 characters. The separate full JSON modal is opened explicitly and uses `textContent`, never HTML injection. Restore remains CAS-protected. +- Listener protocol buttons have a gap and may wrap; ACL profile attachments are in a closed-by-default native `
` element. + +## Installed release retention and Cargo safety + +- `update.sh` executes installer/update first, then prunes under the existing `.deploy.lock`. It retains `current` and one other latest release; three private pre-update backups remain (separate retention). Old updater incorrectly passed `find` sort keys into `rm` paths, so it printed removals but removed nothing; fixed by splitting the mtime key before path validation/removal. +- Cargo `target` is not cleared. Only directories under `target/{debug,release}/incremental` older than 30 days are eligible, and only if the corresponding Cargo profile lock is available and no symlinked target path is used. `deps`, `.fingerprint`, `build`, `.cargo-lock`, binaries and Cargo.lock are preserved. This cleanup does not promise meaningful disk recovery for release builds (incremental compilation is usually disabled for release mode). +- Managed updates create `.managed-update` before `systemctl stop`, and the service checks it during graceful shutdown. With an enabled cold tier, it forces a **best-effort** hot-to-cold direct persistence independent of the ordinary `cold_on_shutdown` checkbox. systemd grants 120 seconds, and runtime allows up to 90 seconds for the transfer after draining. The reported moved count reflects successful COLD insertions rather than merely attempted writes. Entries may still fail to persist (I/O/disk limits/timeouts); verify logs and COLD objects in an actual update. Disabling COLD does not allow automatic persistence to disk. + +## Upgrade and live acceptance (open) + +1. Compile and run `./dev.sh check`, `./dev.sh test` and `./dev.sh release` on Rust 1.99+ and verify the lockfile resolves. Do not call this release production-tested without those results. +2. Test Config revision CAS, UA matching and rate 429/Retry-After across two vhosts, clients, country policies and ordinary rate limits. Check `/settings?tab=policies` in EN/PL, including unsaved forms and the new full JSON modal. +3. On a staging installation with HOT+COLD enabled and `cold_on_shutdown=false`, cache a cacheable GET, run `bash update.sh`, confirm managed-update stop log and cold recovery after restart. Repeat rollback on a failed staged binary; backup retention must not remove the rollback candidate. +4. Verify `releases/` retains current and one predecessor, backups still retain three; verify `target/release/deps`, `.fingerprint`, `build` and the compiled binary remain intact and next `bash dev.sh release` is incremental. +5. Re-test H3 logo navigation on iPhone and log regressions; prior H3/iOS acceptance is still open. + +SQLite schema 9 and COLD format 3 unchanged. No persisted setting is removed; old JSON without the new `user_agent_limits` field still deserializes. diff --git a/docs/README.md b/docs/README.md index 4c5c2d9..3b649ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ This is the canonical index for the current project documentation. The authentic - [0.26.3 implementation note](V0.26.3_IMPLEMENTATION.md) - global interface scaling, chart themes, square draft dock and Settings navigation polish. - [0.26.2 implementation note](V0.26.2_IMPLEMENTATION.md) - unified Logs navigation, field-query search, dynamic JSON columns and RAW parsing. - [0.26.1 implementation note](V0.26.1_IMPLEMENTATION.md) - historical vhost-aware protocol logging and Stored Logs UX milestone. +- [0.31.7 UA throttling, Settings, revision history and safe update retention](IMPLEMENTATION_0.31.7.md) - rollout, storage and acceptance. - [0.31.6 H3 iOS navigation hardening and diagnostic events](IMPLEMENTATION_0.31.6.md) - bounded empty GET/HEAD handling, H3 error draining and mobile reproduction. - [0.31.5 User-Agent blocking and consolidated log analysis](IMPLEMENTATION_0.31.5.md) - global/per-vhost UA blocklists, Traffic copy actions, repeated-value aggregation and per-vhost gzip consolidation. - [0.31.4 administration public Origin and settings](IMPLEMENTATION_0.31.4.md) - opt-in HTTPS origin, one-field DNS allowlist, additional hosts in advanced settings, legacy deduplication, CSRF and remaining deployment checks. @@ -85,7 +86,7 @@ This is the canonical index for the current project documentation. The authentic ## Historical or version-labelled context -Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [IMPLEMENTATION_0.31.6.md](IMPLEMENTATION_0.31.6.md); [IMPLEMENTATION_0.31.5.md](IMPLEMENTATION_0.31.5.md) is retained as the previous log/cache milestone; [IMPLEMENTATION_0.31.4.md](IMPLEMENTATION_0.31.4.md) is retained as the previous administration-Origin milestone; [IMPLEMENTATION_0.31.3.md](IMPLEMENTATION_0.31.3.md) is retained as the previous mobile recovery milestone; [IMPLEMENTATION_0.31.1.md](IMPLEMENTATION_0.31.1.md) is retained as the prior mobile milestone; [IMPLEMENTATION_0.31.0.md](IMPLEMENTATION_0.31.0.md) is retained as the previous milestone; [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md) is retained as historical context, and [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md) is retained as the prior redirect/health/cache/log milestone; [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) is retained as the prior certificate-hardening milestone; [V0.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md) is retained as the prior external-path milestone; [V0.28.0_IMPLEMENTATION.md](V0.28.0_IMPLEMENTATION.md) is retained as the prior GeoIP/listener/cache milestone, and [V0.27.3_IMPLEMENTATION.md](V0.27.3_IMPLEMENTATION.md) is retained as the prior HTTP/3/cache-maintenance milestone, [V0.27.2_IMPLEMENTATION.md](V0.27.2_IMPLEMENTATION.md) is retained as the prior managed-ACME repair, [V0.27.1_IMPLEMENTATION.md](V0.27.1_IMPLEMENTATION.md) as the prior managed-runtime/list-sorting milestone, and older version notes remain historical evidence. The latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); older audit files remain available for traceability. +Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [IMPLEMENTATION_0.31.7.md](IMPLEMENTATION_0.31.7.md); [IMPLEMENTATION_0.31.6.md](IMPLEMENTATION_0.31.6.md) is retained as the prior H3 milestone; [IMPLEMENTATION_0.31.5.md](IMPLEMENTATION_0.31.5.md) is retained as the previous log/cache milestone; [IMPLEMENTATION_0.31.4.md](IMPLEMENTATION_0.31.4.md) is retained as the previous administration-Origin milestone; [IMPLEMENTATION_0.31.3.md](IMPLEMENTATION_0.31.3.md) is retained as the previous mobile recovery milestone; [IMPLEMENTATION_0.31.1.md](IMPLEMENTATION_0.31.1.md) is retained as the prior mobile milestone; [IMPLEMENTATION_0.31.0.md](IMPLEMENTATION_0.31.0.md) is retained as the previous milestone; [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md) is retained as historical context, and [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md) is retained as the prior redirect/health/cache/log milestone; [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) is retained as the prior certificate-hardening milestone; [V0.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md) is retained as the prior external-path milestone; [V0.28.0_IMPLEMENTATION.md](V0.28.0_IMPLEMENTATION.md) is retained as the prior GeoIP/listener/cache milestone, and [V0.27.3_IMPLEMENTATION.md](V0.27.3_IMPLEMENTATION.md) is retained as the prior HTTP/3/cache-maintenance milestone, [V0.27.2_IMPLEMENTATION.md](V0.27.2_IMPLEMENTATION.md) is retained as the prior managed-ACME repair, [V0.27.1_IMPLEMENTATION.md](V0.27.1_IMPLEMENTATION.md) as the prior managed-runtime/list-sorting milestone, and older version notes remain historical evidence. The latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); older audit files remain available for traceability. Older context documents such as [INTEGRATIONS_040](INTEGRATIONS_040.md), [MANAGEMENT](MANAGEMENT.md), [SECURITY_AUDIT](SECURITY_AUDIT.md), [ACME_RFC2136](ACME_RFC2136.md) and older version audits remain available for traceability but should not override the current functional/technical documents above. diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index b8d5ba5..3ddbae1 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -34,6 +34,28 @@ The main `/api/events?interval=N` connection (allowlisted 1/2/5/10, default 2 se ACME diagnostics formerly shown in About and under Administration are now on this tab: mode/state, certificate environment path, and the active Python/pip/Certbot versions. Versions are probed by the ACME runtime setup with bounded subprocess output/timeouts and published from stored runtime status. They are not queried at every page refresh or status API request. Failed/unavailable probes leave the version empty. No new persistent settings or permissions are introduced. +## Rules and headers (0.31.7) + +**Settings -> Rules & headers** (`/settings?tab=policies`) now groups the existing **Global User-Agent blocklist**, **Global custom headers** and **Client response** forms as persistent sub-tabs, following the Cache settings layout. There is no migration: each form continues to update its previous configuration section. **Settings -> Security** retains administrative hostname, Origin, session and password policies. + +**Vhosts -> Security -> Traffic policy** also supports optional `rate_limit.user_agent_limits` in each vhost, for example: + +```json +{ + "rate_limit": { + "enabled": true, "requests": 120, "window_seconds": 60, "burst": 30, + "user_agent_limits": [ + {"pattern": "*scanner*", "requests": 10, "window_seconds": 60, "burst": 0}, + {"pattern": "BadBot", "requests": 2, "window_seconds": 60, "burst": 0} + ] + } +} +``` + +The first case-insensitive match wins. A plain pattern searches for a substring and `*` matches any sequence. A matching User-Agent uses its **own bucket for the vhost, rule and client IP** and overrides that request's standard/country throttle; nonmatching requests continue using existing rate rules. Rules also work with `enabled=false` for the base limiter. The User-Agent header is untrusted, so avoid treating these limits as authentication or identity enforcement. + +The **Revision history** detail shows only changed leaf fields, scrolling locally when there are many. **Full JSON comparison** is directly below Restore in the action group and opens a separate standard dialog. ACL profile *Attached to* details are collapsed by default. Listener HTTP/2 and HTTP/3 controls have additional spacing. + ## Configuration and update semantics GET `/api/config` returns a revisioned envelope. PUT the entire document with @@ -69,7 +91,7 @@ This is a settings fragment, not a replacement for the existing routing/vhost do A complete opt-in example is in `examples/cache-dashboard.json`. The dashboard exposes the complete raw JSON editor under **Settings -> Manual config**. -The same card exposes **Revision history**, which reads the newest 512 retained configuration revisions from the configuration-history table introduced in SQLite schema 8 (current database schema 9) and groups them by month/day. Selecting a revision shows timestamp, actor and a bounded path-level before/after diff against the previous retained revision. An older retained revision can be restored from the same dialog; RustProxy validates it through the normal configuration update path and saves it as a new revision, preserving the prior history. +The same card exposes **Revision history**, which reads the newest 512 retained configuration revisions from the configuration-history table introduced in SQLite schema 8 (current database schema 9) and groups them by month/day. Selecting a revision shows timestamp, actor and only the changed fields against the previous retained revision; the separate Full JSON comparison dialog contains complete JSON documents. An older retained revision can be restored from the same dialog; RustProxy validates it through the normal configuration update path and saves it as a new revision, preserving the prior history. The historical `/configuration` browser route is retained only as a compatibility alias and opens that Settings block; it is no longer a primary-navigation item. Prefer the structured section editors for routine changes so unrelated sections are not republished. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 107d0f0..fe9016a 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,4 +1,21 @@ -# Verification report - RustProxy 0.31.6 +# Verification report - RustProxy 0.31.7 + +## 0.31.7 — revision history UI follow-up + +- Move **Full JSON comparison** directly below **Restore revision** inside the existing action group, without additional separators. Preserve existing revision selection, restore handlers, diff logic and JSON display behavior. +- Bound the revision history dialog to the viewport. Large change lists and day-revision buttons scroll inside their desktop panes; the calendar and the stacked mobile layout scroll without forcing page-level overflow. Make the full JSON dialog use the same base dialog border/background/padding as other windows, with a right-aligned close button and borderless JSON blocks. +- **PASS — browser layout fixture:** headless Chromium with the actual HTML/CSS dialog fragments, 200 changed fields, 50 day revisions and six month calendars at viewport sizes 1280×800, 1024×600, 800×600, 390×760 and 320×568. Desktop change list has independently scrollable content (including the final change); narrow viewports have a scrollable modal body and do not expand the document width. Full JSON dialog shares the generic border rule. This is synthetic browser rendering, not a running Rust server or real Safari. +- **OPEN:** end-to-end panel render with authenticated session, actual revision content and iPhone browser. Rust/Cargo compilation remains unverified in this environment. + +## 0.31.7 — source, updater and UI regression verification (2026-10-11) + +- **PASS:** `./dev.sh test` executed all bundled Python security/source/version/SQLite-9 migration/upgrade/memory checks, Node.js UI regressions, documentation validation and language-pack checks. It exits **127** only at the missing Rust/Cargo toolchain gate. Rust tests and release compilation did not run. +- **PASS:** new `tools/checks/release_0317.py` exercised the actual Bash `update.sh` retention code using a sandboxed fake installer: successful, failed and `--plan` updates; release directory names with spaces; active symlink protection; exactly two installed releases and three backups; age-based Cargo incremental cleanup while preserving recent state, `deps`, `.fingerprint`, `build`, binaries and locks; and skip on symlinked Cargo targets. Systemd was not called. +- **PASS:** new `tools/checks/release_0317_ui_test.mjs` executed the real configuration diff function against changed leaves, reordered ID-bearing arrays, created/deleted fields and the 200-entry bound. Validated Settings form relocation, listener spacing, collapsed ACL attachments, user-agent editor/OpenAPI and separate JSON dialog at the source level. This was not a live browser render. +- **PASS:** `GOPROXY=off GOSUMDB=off go test ./...` in `docker-agent`, `node --check` for all browser JavaScript modules, OpenAPI JSON parsing, `bash -n dev.sh update.sh`, `docs_check.py` and `langs_check.py`. +- **CHECKED IN SOURCE, NOT LIVE:** `scripts/deploy/deploy_runtime.py` creates `.managed-update` before `systemctl stop`; `src/main/admin_runtime.rs` checks the marker and calls forced HOT→COLD even with `cold_on_shutdown=false`; shutdown time budget is 90 seconds with a 120-second systemd stop budget. The cache persistence worker now returns a success boolean so `moved` counts only successfully inserted COLD objects. Existing Rust cold-tier unit tests cover enabled COLD with ordinary shutdown disabled, but could **not** run without Cargo. +- **OPEN:** Rust type checking/formatting, cargo --locked consistency, compiled vhost 429/Retry-After behavior, actual update/rollback and persistence after restart, browser UI on a live server, H2/H3 performance and iPhone reload acceptance. No production-ready claim. + ## 0.31.6 - iPhone H3 full navigation and observability (2026-10-11) diff --git a/langs/en_US.json b/langs/en_US.json index c3ced38..3d9fc84 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -6,6 +6,15 @@ "direction": "ltr" }, "translations": { + "policyRulesTitle": "Rules & headers", + "vhUaRateTitle": "User-Agent rate limit overrides", + "vhUaRatePattern": "User-Agent pattern", + "vhAddUaRate": "Add User-Agent rate limit", + "vhUaRateMax": "Maximum 64 User-Agent rate-limit rules", + "vhUaRateHelp": "First matching pattern wins. Case-insensitive substring or * wildcard. Per-client-IP and per-vhost, independent of the default and country limits; applies even if the default rate limit is off.", + "configHistoryFullCompare": "Full JSON comparison", + "configHistoryChangedFields": "Changed fields only", + "overview": "Overview", "mainMenu": "Menu", "routing": "Legacy routes", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 3e548a5..9b527f5 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -6,6 +6,15 @@ "direction": "ltr" }, "translations": { + "policyRulesTitle": "Reguły i nagłówki", + "vhUaRateTitle": "Limity dla User-Agent", + "vhUaRatePattern": "Wzorzec User-Agent", + "vhAddUaRate": "Dodaj limit User-Agent", + "vhUaRateMax": "Maksymalnie 64 reguły limitów User-Agent", + "vhUaRateHelp": "Pierwszy pasujący wzorzec wygrywa. Dopasowanie bez rozróżniania wielkości liter; fragment lub *. Limity są osobne dla IP i vhosta, niezależne od limitów ogólnych i krajowych. Działają także po wyłączeniu limitu ogólnego.", + "configHistoryFullCompare": "Pełne porównanie JSON", + "configHistoryChangedFields": "Tylko zmienione pola", + "overview": "Przegląd", "mainMenu": "Menu", "routing": "Starsze trasy", diff --git a/scripts/resolve_dependencies_0312.sh b/scripts/resolve_dependencies_0312.sh index 1c2fe65..2c4e92b 100755 --- a/scripts/resolve_dependencies_0312.sh +++ b/scripts/resolve_dependencies_0312.sh @@ -8,8 +8,8 @@ if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; the exit 127 fi python3 tools/checks/preflight.py -if [[ "$(python3 scripts/version.py)" != '0.31.6' ]]; then - printf 'Expected RustProxy 0.31.6 sources.\n' >&2 +if [[ "$(python3 scripts/version.py)" != '0.31.7' ]]; then + printf 'Expected RustProxy 0.31.7 sources.\n' >&2 exit 1 fi printf 'Toolchain: '; rustc --version diff --git a/src/admin/documentation/documents.rs b/src/admin/documentation/documents.rs index 61334d6..2b76213 100644 --- a/src/admin/documentation/documents.rs +++ b/src/admin/documentation/documents.rs @@ -244,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[ "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")), ), + reader_doc!("docs/IMPLEMENTATION_0.31.7.md", "technical"), reader_doc!("docs/IMPLEMENTATION_0.31.6.md", "technical"), reader_doc!("docs/IMPLEMENTATION_0.31.5.md", "technical"), reader_doc!("docs/IMPLEMENTATION_0.31.4.md", "technical"), diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 96a1b4b..8a742fb 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.31.6", + "version": "0.31.7", "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": [ @@ -662,6 +662,9 @@ }, "user_agent_blocking": { "$ref": "#/components/schemas/UserAgentBlocking" + }, + "rate_limit": { + "$ref": "#/components/schemas/VhostRateLimit" } }, "additionalProperties": true, @@ -5966,6 +5969,83 @@ "description": "Case-insensitive User-Agent block patterns. Plain text matches a substring; * is a wildcard." } } + }, + "UserAgentRateLimit": { + "type": "object", + "additionalProperties": false, + "required": [ + "pattern", + "requests", + "window_seconds", + "burst" + ], + "properties": { + "pattern": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Case-insensitive substring; * wildcard; first matching rule has precedence" + }, + "requests": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "window_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + }, + "burst": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + } + } + }, + "VhostRateLimit": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "requests": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "window_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + }, + "burst": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + }, + "country_access": { + "type": "object", + "description": "Country access policy" + }, + "country_limits": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object" + } + }, + "user_agent_limits": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/components/schemas/UserAgentRateLimit" + }, + "description": "First matching UA overrides the base and country rate; separate vhost/rule/IP fixed-window counters. Default empty." + } + } } } }, diff --git a/src/admin/router.rs b/src/admin/router.rs index c7ab565..a17ff27 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -84,6 +84,7 @@ fn admin_vhosts_javascript() -> &'static str { "(() => {", include_str!("web/vhosts/core.js"), include_str!("web/vhosts/health.js"), + include_str!("web/vhosts/rates.js"), include_str!("web/vhosts/editor.js"), include_str!("web/vhosts/render.js"), "})();", diff --git a/src/admin/web/app/config-history.js b/src/admin/web/app/config-history.js index 3706704..bb1bc96 100644 --- a/src/admin/web/app/config-history.js +++ b/src/admin/web/app/config-history.js @@ -1,4 +1,4 @@ -let configHistoryEntries = [], configHistorySelectedRevision = null; +let configHistoryEntries = [], configHistorySelectedRevision = null, configHistorySelectedJson = null; function historyDate(value, options = {}) { const effective = { ...options }; if (Object.hasOwn(effective, "timeStyle") || Object.hasOwn(effective, "hour")) effective.hour12 = (adminPreferences?.time_format || "24h") === "12h"; @@ -12,8 +12,8 @@ function historyDayKey(value) { function historyMonthKey(value) { return historyDayKey(value).slice(0, 7); } function historyValue(value) { if (value === undefined) return t("configHistoryMissing"); - const text = JSON.stringify(value, null, 2); - return text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text; + const result = JSON.stringify(value); + return result.length > 320 ? `${result.slice(0, 320)}…` : result; } function syncConfigHistoryRestoreButton() { const button = $("config-history-restore"), revision = Number(configHistorySelectedRevision); @@ -21,14 +21,57 @@ function syncConfigHistoryRestoreButton() { button.hidden = configHistorySelectedRevision === null; button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision); } +// Compare leaf fields, not serialized object/array blobs. Object IDs are used +// for stable array paths so reordering vhosts does not become a huge fake diff. function configDiff(before, after, path = "$", output = [], depth = 0) { - if (output.length >= 200) return output; - if (Object.is(before, after)) return output; - const beforeObject = before && typeof before === "object" && !Array.isArray(before); - const afterObject = after && typeof after === "object" && !Array.isArray(after); - if (beforeObject && afterObject && depth < 10) { + if (output.length >= 200 || Object.is(before, after)) return output; + if (depth >= 24) { + if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before: "[nested value]", after: "[nested value]" }); + return output; + } + const oldObj = before !== null && typeof before === "object"; + const newObj = after !== null && typeof after === "object"; + if (oldObj && newObj && !Array.isArray(before) && !Array.isArray(after)) { const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(); - for (const key of keys) configDiff(before[key], after[key], `${path}.${key}`, output, depth + 1); + for (const key of keys) { + if (output.length >= 200) break; + configDiff(before[key], after[key], `${path}[${JSON.stringify(key)}]`, output, depth + 1); + } + return output; + } + if (Array.isArray(before) && Array.isArray(after)) { + const named = items => items.every(item => item && typeof item === "object" && !Array.isArray(item) && (typeof item.id === "string" || typeof item.name === "string")); + const itemKey = item => String(item.id ?? item.name); + const stable = named(before) && named(after) + && new Set(before.map(itemKey)).size === before.length + && new Set(after.map(itemKey)).size === after.length; + if (stable) { + const oldItems = new Map(before.map(item => [itemKey(item), item])); + const newItems = new Map(after.map(item => [itemKey(item), item])); + for (const id of new Set([...oldItems.keys(), ...newItems.keys()])) { + if (output.length >= 200) break; + configDiff(oldItems.get(id), newItems.get(id), `${path}[${JSON.stringify(id)}]`, output, depth + 1); + } + } else { + for (let i = 0; i < Math.max(before.length, after.length) && output.length < 200; i++) { + configDiff(before[i], after[i], `${path}[${i}]`, output, depth + 1); + } + } + return output; + } + // New/deleted trees are traversed to show the affected fields only. + const tree = oldObj ? before : newObj ? after : null; + if (tree && !Array.isArray(tree)) { + for (const key of Object.keys(tree).sort()) { + if (output.length >= 200) break; + configDiff(before?.[key], after?.[key], `${path}[${JSON.stringify(key)}]`, output, depth + 1); + } + return output; + } + if (Array.isArray(tree)) { + for (let i = 0; i < tree.length && output.length < 200; i++) { + configDiff(before?.[i], after?.[i], `${path}[${i}]`, output, depth + 1); + } return output; } if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before, after }); @@ -38,21 +81,22 @@ function renderConfigRevisionDetail(detail) { const entry = detail?.revision; if (!entry) return; configHistorySelectedRevision = Number(entry.revision); + configHistorySelectedJson = entry.previous_config ? { before: entry.previous_config, after: entry.config } : null; + const compareButton = $("config-history-compare-open"); + compareButton.disabled = !configHistorySelectedJson; $("config-history-detail-title").textContent = `${t("configHistoryRevision")} ${entry.revision}`; $("config-history-detail-meta").textContent = `${historyDate(entry.time, { dateStyle: "medium", timeStyle: "short" })} · ${entry.actor}`; syncConfigHistoryRestoreButton(); document.querySelectorAll(".config-history-revision").forEach(button => button.classList.toggle("active", Number(button.dataset.revision) === Number(entry.revision))); - const diff = configDiff(entry.previous_config, entry.config); const box = $("config-history-diff"); box.replaceChildren(); if (!entry.previous_config) { box.append(el("p", t("configHistoryBaseline"), "empty")); return; } + const diff = configDiff(entry.previous_config, entry.config); if (!diff.length) { box.append(el("p", t("configHistoryNoChanges"), "empty")); return; } for (const change of diff) { const card = el("article", undefined, "config-history-change"); card.append(el("strong", change.path, "config-history-change-path")); const values = el("div", undefined, "config-history-change-values"); - for (const [label, value] of [[t("configHistoryBefore"), change.before], [t("configHistoryAfter"), change.after]]) { - const side = el("div"); side.append(el("span", label, "small-label"), el("pre", historyValue(value))); values.append(side); - } + values.append(el("code", historyValue(change.before), "diff-before"), el("span", "→", "fine"), el("code", historyValue(change.after), "diff-after")); card.append(values); box.append(card); } if (diff.length >= 200) box.append(el("p", t("configHistoryDiffLimit"), "fine")); @@ -127,7 +171,7 @@ async function restoreConfigRevision() { finally { busy = false; if (button?.isConnected) syncConfigHistoryRestoreButton(); } } async function loadConfigHistory() { - configHistorySelectedRevision = null; syncConfigHistoryRestoreButton(); + configHistorySelectedRevision = null; configHistorySelectedJson = null; $("config-history-compare-open").disabled = true; syncConfigHistoryRestoreButton(); $("config-history-summary").textContent = t("configHistoryLoading"); try { const result = await api("/api/config/history?limit=512"); configHistoryEntries = result.revisions || []; renderConfigHistory(); @@ -138,3 +182,11 @@ $("config-history-open").addEventListener("click", () => { const dialog = $("con $("config-history-close").addEventListener("click", () => $("config-history-dialog").close()); $("config-history-reload").addEventListener("click", () => void loadConfigHistory()); $("config-history-restore").addEventListener("click", () => void restoreConfigRevision()); + +$("config-history-compare-open").addEventListener("click", () => { + if (!configHistorySelectedJson) return; + $("config-history-json-before").textContent = JSON.stringify(configHistorySelectedJson.before, null, 2); + $("config-history-json-after").textContent = JSON.stringify(configHistorySelectedJson.after, null, 2); + const dialog = $("config-history-json-dialog"); if (!dialog.open) dialog.showModal(); +}); +$("config-history-json-close").addEventListener("click", () => $("config-history-json-dialog").close()); diff --git a/src/admin/web/index.html b/src/admin/web/index.html index a09d231..a0bdd21 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -1766,16 +1766,14 @@ id="settings-tab-connections" role="tab" tabindex="-1" type="button"> - -
-
-
-
USER-AGENT BLOCKING -

Global User-Agent blocklist

-

Blocks matching User-Agent values on public proxy vhosts before an upstream is opened. Per-vhost lists are configured in Vhosts → Security.

-
-
-
- - -

Matching is case-insensitive. Plain text matches a substring; * is a wildcard. Requests without User-Agent are not blocked by this rule.

-
-
-
ACCESS @@ -3174,10 +3183,13 @@
CHANGES

-

-
+
+ + + +
@@ -3186,6 +3198,18 @@
+ +
+

Full JSON comparison

+
+ +
+
+
+

Before

+

After

+
+
SQLITE / LAST 100 / RETAINED 10,000 diff --git a/src/admin/web/integrations.js b/src/admin/web/integrations.js index 24f4200..3ac160b 100644 --- a/src/admin/web/integrations.js +++ b/src/admin/web/integrations.js @@ -111,7 +111,8 @@ const enabled = field(grid, "listener-enabled", "vhEnabled", item.enabled !== false, "checkbox"); const tls = field(grid, "listener-tls", "vhTls", !!b.tls, "checkbox"); const h2 = field(grid, "listener-h2", "vhHttp2", b.tls && b.http2 !== false, "checkbox"); const h2c = field(grid, "listener-h2c", "listenerH2c", !b.tls && !!b.h2c, "checkbox"); const preservedHttp2 = effectiveHttp2Settings(b); - const h2Settings = button(row, "h2Configure", () => { + const protocolActions = el("div", undefined, "listener-protocol-settings-actions vh-wide"); row.append(protocolActions); + const h2Settings = button(protocolActions, "h2Configure", () => { window.rustProxySettings?.select("connections"); window.rustProxySettings?.selectHttpTab?.("http2"); document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" }); @@ -119,7 +120,7 @@ h2Settings.classList.add("listener-h2-settings-link"); const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null); const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox"); - const h3Settings = button(row, "h3Configure", () => { + const h3Settings = button(protocolActions, "h3Configure", () => { window.rustProxySettings?.select("connections"); window.rustProxySettings?.selectHttpTab?.("http3"); document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" }); diff --git a/src/admin/web/management.js b/src/admin/web/management.js index 95899ca..8302f58 100644 --- a/src/admin/web/management.js +++ b/src/admin/web/management.js @@ -394,8 +394,12 @@ if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`); for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`); } - if (users.length) pieces.push(`${t("aclProfileAttached")}: ${users.join(", ")}`); box.append(el("p", pieces.filter(Boolean).join("\n"), "entity-details")); + if (users.length) { + const attached = el("details", undefined, "acl-profile-attached"); + attached.append(el("summary", `${t("aclProfileAttached")} (${users.length})`), el("p", users.join(", "), "entity-details")); + box.append(attached); + } const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => { if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear(); diff --git a/src/admin/web/settings.js b/src/admin/web/settings.js index 8799fb8..62d5525 100644 --- a/src/admin/web/settings.js +++ b/src/admin/web/settings.js @@ -7,7 +7,7 @@ const requestedTab = typeof location !== "undefined" && location.pathname === "/logging" ? "logging" : (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null); - const tabAliases = { http2: "connections" }; + const tabAliases = { http2: "connections", headers: "policies", response: "policies" }; // Keep the public panel hostname implicit. Legacy configurations may have // written it into allowed_hosts in 0.31.4; do not show/save it twice. function pinnedAdminHostname(value) { @@ -57,6 +57,27 @@ }); } search?.addEventListener("input", applySettingsSearch); + const policyTabs = [...document.querySelectorAll("[data-policy-settings-tab]")].filter(button => typeof button.addEventListener === "function" && typeof button.setAttribute === "function"); + function selectPolicyTab(name, focus = false) { + if (!policyTabs.some(button => button.dataset.policySettingsTab === name)) return; + for (const button of policyTabs) { + const chosen = button.dataset.policySettingsTab === name; + button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; + if (chosen && focus) button.focus(); + } + document.querySelectorAll("[data-policy-settings-page]").forEach(page => page.hidden = page.dataset.policySettingsPage !== name); + } + for (const button of policyTabs) { + button.addEventListener("click", () => selectPolicyTab(button.dataset.policySettingsTab)); + button.addEventListener("keydown", event => { + const index = policyTabs.indexOf(button); let next; + if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % policyTabs.length; + else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + policyTabs.length - 1) % policyTabs.length; + else if (event.key === "Home") next = 0; else if (event.key === "End") next = policyTabs.length - 1; else return; + event.preventDefault(); selectPolicyTab(policyTabs[next].dataset.policySettingsTab, true); + }); + } + selectPolicyTab(["headers", "response"].includes(requestedTab) ? requestedTab : "user-agent"); const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")]; function selectCacheTab(name, focus = false) { if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return; @@ -352,6 +373,6 @@ curl --fail --show-error --request POST \ const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false; } catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; } }); - window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } }; + window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } }; select(active); render(); })(); diff --git a/src/admin/web/style.css b/src/admin/web/style.css index 2f91f3e..7e91b1e 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -4239,7 +4239,8 @@ dialog.vhost-dialog, dialog.logs-dialog, dialog.audit-dialog, dialog.certificate-dialog, -dialog.config-history-dialog { +dialog.config-history-dialog, +dialog.config-history-json-dialog { border-radius: 0; background: var(--bg); color: var(--text); @@ -8252,18 +8253,34 @@ body[data-density="compact"] .page-footer { overflow: hidden; } +.config-history-dialog[open] { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: min(820px, calc(100dvh - 28px)); +} + .config-history-layout { display: grid; grid-template-columns: minmax(380px, .95fr) minmax(420px, 1.05fr); + grid-template-rows: minmax(0, 1fr); gap: 10px; padding: 10px; - max-height: calc(100dvh - 90px); + min-height: 0; + overflow: hidden; } .config-history-browser, .config-history-detail { min-width: 0; - overflow: auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.config-history-detail { + display: flex; + flex-direction: column; + overflow: hidden; } .config-history-months { @@ -8353,9 +8370,12 @@ button.config-history-day.is-selected { .config-history-revisions { display: flex; + flex: 0 0 auto; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; + max-height: 110px; + overflow-y: auto; } .config-history-revision { @@ -8369,7 +8389,14 @@ button.config-history-day.is-selected { .config-history-diff { display: grid; + flex: 1 1 auto; + min-height: 0; + align-content: start; + grid-auto-rows: max-content; gap: 8px; + overflow-y: auto; + padding-right: 4px; + overscroll-behavior: contain; } .config-history-change { @@ -8402,14 +8429,22 @@ button.config-history-day.is-selected { @media (max-width: 860px) { .config-history-layout { - grid-template-columns: 1fr; - overflow: auto; + display: block; + overflow-y: auto; + } + + .config-history-browser { + max-height: min(300px, 38dvh); + overflow-y: auto; + margin-bottom: 10px; } - .config-history-browser, .config-history-detail { + display: block; overflow: visible; } + .config-history-revisions { max-height: none; } + .config-history-diff { overflow: visible; } .config-history-change-values { grid-template-columns: 1fr; @@ -10463,3 +10498,23 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { overflow-wrap: anywhere; } } + +/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */ +.listener-protocol-settings-actions { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; margin-block: 10px; } +.listener-protocol-settings-actions button { margin: 0; } +.listener-protocol-settings-actions button[hidden] { display: none; } + +/* 0.31.7 revision preview shows only changed leaf fields. */ +.config-history-change-values { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.config-history-change-values .diff-before { color: var(--muted); text-decoration: line-through; } +.config-history-change-values .diff-after { color: var(--text); } +.config-history-change-values code { white-space: pre-wrap; overflow-wrap: anywhere; } +.config-history-json-dialog { width: min(1200px, calc(100vw - 24px)); max-width: calc(100vw - 24px); max-height: calc(100dvh - 28px); overflow: auto; } +.config-history-json-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; } +.config-history-json-columns section { min-width: 0; } +.config-history-json-columns pre { padding: 12px; margin: 0; background: var(--panel); border: 0; overflow: auto; max-height: 68dvh; font: 11px/1.5 var(--mono); } +@media (max-width: 760px) { .config-history-json-columns { grid-template-columns: 1fr; } .config-history-json-columns pre { max-height: 38dvh; } } + +.acl-profile-attached { margin-block: 6px; } +.acl-profile-attached summary { cursor: pointer; color: var(--muted); font-size: 12px; } +.acl-profile-attached p { overflow-wrap: anywhere; } diff --git a/src/admin/web/vhosts/core.js b/src/admin/web/vhosts/core.js index 2bc6e4a..cb73e21 100644 --- a/src/admin/web/vhosts/core.js +++ b/src/admin/web/vhosts/core.js @@ -1,7 +1,7 @@ "use strict"; // Vhost UI: language content comes from langs/*.json, never a locale switch. // Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS. - let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null; + let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], uaRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null; const chartHistory = new Map(); const backendTrafficHistory = new Map(); const vhostTrafficHistory = new Map(); @@ -137,7 +137,7 @@ const state = { editing, revision, label: $("vhost-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")), - basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor, backendHealthEditorControl, + basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, uaRatesReader, pagesEditor, backendHealthEditorControl, stagedCertificates, uploading }; const title = read("id")?.value.trim() || editing || t("draftUntitled"); @@ -147,7 +147,7 @@ function restoreDraft(state) { draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs); editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl; - readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null; + readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; uaRatesReader = state.uaRatesReader || (() => []); pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null; stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label; if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus); } diff --git a/src/admin/web/vhosts/editor.js b/src/admin/web/vhosts/editor.js index 83d9c35..bcade15 100644 --- a/src/admin/web/vhosts/editor.js +++ b/src/admin/web/vhosts/editor.js @@ -248,7 +248,7 @@ field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 }); field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] }); field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 }); - note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp"); + note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); uaRatesReader = userAgentRateEditor(g, rate.user_agent_limits || []); note(g, "vhRateHelp"); const security = value.security_headers || {}, hsts = security.hsts || {}; g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 }); field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox"); @@ -446,7 +446,7 @@ return { id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"), proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") }, - rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() }, + rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader(), user_agent_limits: uaRatesReader() }, method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) }, server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") }, diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") }, diff --git a/src/admin/web/vhosts/rates.js b/src/admin/web/vhosts/rates.js new file mode 100644 index 0000000..e56ec6a --- /dev/null +++ b/src/admin/web/vhosts/rates.js @@ -0,0 +1,18 @@ +"use strict"; +// Per-vhost rate limit editors, shared in the bundled vhost closure. + function userAgentRateEditor(parent, values = []) { + parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide")); + const rows = el("div", undefined, "vh-wide vh-ua-rates"); parent.append(rows); + const add = (value = {}) => { + if (rows.children.length >= 64) { toast(t("vhUaRateMax"), true); return; } + const row = el("div", undefined, "vh-row vh-grid"); + field(row, "ua-rate-pattern", "vhUaRatePattern", value.pattern || "", "text", { required: true, maxLength: 256 }); + field(row, "ua-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 }); + field(row, "ua-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 }); + field(row, "ua-rate-burst", "vhRateBurst", value.burst ?? 0, "number", { required: true, min: 0, max: 1000000 }); + row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row); + }; + for (const value of values) add(value); + parent.append(button("vhAddUaRate", () => { add(); changed(); })); note(parent, "vhUaRateHelp"); + return () => [...rows.children].map(row => ({ pattern: str("ua-rate-pattern", row).trim(), requests: num("ua-rate-requests", row), window_seconds: num("ua-rate-window", row), burst: num("ua-rate-burst", row) })); + } diff --git a/src/cache/cold/demotion.rs b/src/cache/cold/demotion.rs index 4c5c008..05bdc1b 100644 --- a/src/cache/cold/demotion.rs +++ b/src/cache/cold/demotion.rs @@ -197,8 +197,9 @@ impl Cache { _memory_lease: memory_lease, } }; - self.persist_cold_inner(draft).await; - moved += 1; + if self.persist_cold_inner(draft).await { + moved += 1; + } } release_allocator_memory(); moved diff --git a/src/cache/cold/worker.rs b/src/cache/cold/worker.rs index 758891a..f266bf3 100644 --- a/src/cache/cold/worker.rs +++ b/src/cache/cold/worker.rs @@ -62,7 +62,7 @@ impl Cache { // reference and glibc is explicitly asked to return releasable pages. release_allocator_memory(); } - async fn persist_cold_inner(&self, draft: ColdDraft) { + async fn persist_cold_inner(&self, draft: ColdDraft) -> bool { let current = { let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); store.revision == draft.revision @@ -84,11 +84,11 @@ impl Cache { == draft.key_generation }; if !current { - return; + return false; } let Some(headers) = cold_header_pairs(&draft.entry.headers) else { self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); - return; + return false; }; let settings = self .tier_settings @@ -96,17 +96,17 @@ impl Cache { .unwrap_or_else(|e| e.into_inner()) .clone(); if !settings.cold_enabled || self.cold_root_for(&settings).as_ref() != Some(&draft.root) { - return; + return false; } let key = key_hex(&draft.key); let body_name = format!("{key}.body"); let (body_path, meta_path, body_tmp, meta_tmp) = cold_paths(&draft.root, &draft.key); let Some(shard) = body_path.parent() else { - return; + return false; }; if tokio::fs::create_dir_all(shard).await.is_err() { self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); - return; + return false; } let age_at_store = draft .entry @@ -167,7 +167,7 @@ impl Cache { }; let Ok(metadata) = serde_json::to_vec_pretty(&file) else { self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); - return; + return false; }; let write_ok = tokio::fs::write(&body_tmp, &draft.entry.body).await.is_ok() && tokio::fs::write(&meta_tmp, &metadata).await.is_ok() @@ -177,7 +177,7 @@ impl Cache { let _ = tokio::fs::remove_file(&body_tmp).await; let _ = tokio::fs::remove_file(&meta_tmp).await; self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); - return; + return false; } let still_current = { let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); @@ -202,10 +202,11 @@ impl Cache { if !still_current { let _ = tokio::fs::remove_file(&body_path).await; let _ = tokio::fs::remove_file(&meta_path).await; - return; + return false; } let disk_bytes = draft.entry.body.len() as u64 + metadata.len() as u64; let mut delete = Vec::new(); + let mut persisted = false; { let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); for key in incompatible_cold_variants(&cold, &draft.entry.base_key, &draft.entry.vary) { @@ -278,11 +279,13 @@ impl Cache { cold.lru.insert(sequence, draft.key); cold.index_variant(entry.base_key, draft.key); cold.entries.insert(draft.key, entry); + persisted = true; self.counters.hot_demotions.fetch_add(1, Ordering::Relaxed); } } for path in delete { let _ = tokio::fs::remove_file(path).await; } + persisted } } diff --git a/src/main/admin_runtime.rs b/src/main/admin_runtime.rs index 593c54a..25a39e6 100644 --- a/src/main/admin_runtime.rs +++ b/src/main/admin_runtime.rs @@ -431,6 +431,9 @@ async fn run( } else { grace }; + if managed_update { + 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( cache_flush_budget, @@ -441,9 +444,9 @@ async fn run( tokio::time::timeout(cache_flush_budget, state.cache.flush_hot_to_cold()).await }; match flush_result { - Ok(moved) if moved > 0 && managed_update => tracing::info!( + Ok(moved) if managed_update => tracing::info!( moved, - "managed update moved hot cache objects to cold storage" + "managed-update hot-to-cold cache handoff completed" ), Ok(moved) if moved > 0 => tracing::info!( moved, diff --git a/src/proxy/forward_entry.rs b/src/proxy/forward_entry.rs index 9155da5..fea27b5 100644 --- a/src/proxy/forward_entry.rs +++ b/src/proxy/forward_entry.rs @@ -250,11 +250,12 @@ async fn forward_request( ); } if internal_dispatch.is_none() { - if let Err(limited) = state.rate_limiter.check( + if let Err(limited) = state.rate_limiter.check_with_user_agent( snapshot.envelope.revision, &frontend.id, context.client_ip, country.as_deref(), + request.headers().get(http::header::USER_AGENT).and_then(|value| value.to_str().ok()), &frontend.rate_limit, ) { let mut response = error( diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 3d15940..7c155ca 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -12,6 +12,8 @@ use std::{ const MAX_BUCKETS: usize = 65_536; const MAX_COUNTRY_RULES: usize = 64; +const MAX_USER_AGENT_RULES: usize = 64; +const MAX_USER_AGENT_BYTES: usize = 4096; fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(|e| e.into_inner()) @@ -109,6 +111,21 @@ impl Default for CountryLimit { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct UserAgentLimit { + /// Case-insensitive substring (plain) or '*' wildcard pattern. + pub pattern: String, + pub requests: u32, + pub window_seconds: u64, + pub burst: u32, +} +impl Default for UserAgentLimit { + fn default() -> Self { + Self { pattern: String::new(), requests: default_requests(), window_seconds: default_window(), burst: default_burst() } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Settings { @@ -118,6 +135,9 @@ pub struct Settings { pub burst: u32, pub country_access: CountryAccess, pub country_limits: Vec, + /// First matching UA rule overrides the standard/country rate and uses + /// its own (vhost, rule, client IP) bucket, even if the base rate is off. + pub user_agent_limits: Vec, } impl Default for Settings { fn default() -> Self { @@ -128,6 +148,7 @@ impl Default for Settings { burst: default_burst(), country_access: CountryAccess::default(), country_limits: Vec::new(), + user_agent_limits: Vec::new(), } } } @@ -173,6 +194,14 @@ impl Settings { ); validate_limit(rule.requests, rule.window_seconds, rule.burst)?; } + ensure!(self.user_agent_limits.len() <= MAX_USER_AGENT_RULES, + "at most 64 User-Agent rate-limit overrides"); + let mut patterns = HashSet::new(); + for rule in &mut self.user_agent_limits { + rule.pattern = crate::user_agent::validate_pattern(&rule.pattern)?; + ensure!(patterns.insert(rule.pattern.clone()), "duplicate User-Agent rate-limit pattern"); + validate_limit(rule.requests, rule.window_seconds, rule.burst)?; + } Ok(()) } pub fn needs_geoip(&self) -> bool { @@ -181,25 +210,34 @@ impl Settings { pub fn country_allowed(&self, country: Option<&str>) -> bool { self.country_access.allows(country) } - fn effective(&self, country: Option<&str>) -> Option { + fn effective(&self, country: Option<&str>, user_agent: Option<&str>) -> Option<(Limit, Option)> { + // Matching only needs a bounded copy. Oversized headers cannot force + // unbounded work in the hot request path. + if let Some(agent) = user_agent.filter(|ua| ua.len() <= MAX_USER_AGENT_BYTES) { + let candidate = agent.to_ascii_lowercase(); + if let Some((index, rule)) = self.user_agent_limits.iter().enumerate() + .find(|(_, rule)| crate::user_agent::wildcard_match(&candidate, &rule.pattern)) { + return Some((Limit { requests: rule.requests, window_seconds: rule.window_seconds, burst: rule.burst }, Some(index))); + } + } if let Some(country) = country { if let Some(rule) = self .country_limits .iter() .find(|rule| rule.country.eq_ignore_ascii_case(country)) { - return Some(Limit { + return Some((Limit { requests: rule.requests, window_seconds: rule.window_seconds, burst: rule.burst, - }); + }, None)); } } - self.enabled.then_some(Limit { + self.enabled.then_some((Limit { requests: self.requests, window_seconds: self.window_seconds, burst: self.burst, - }) + }, None)) } } @@ -288,9 +326,26 @@ impl Limiter { country: Option<&str>, settings: &Settings, ) -> std::result::Result<(), Limited> { - let Some(limit) = settings.effective(country) else { + self.check_with_user_agent(revision, vhost, client, country, None, settings) + } + pub fn check_with_user_agent( + &self, + revision: u64, + vhost: &str, + client: IpAddr, + country: Option<&str>, + user_agent: Option<&str>, + settings: &Settings, + ) -> std::result::Result<(), Limited> { + let Some((limit, ua_index)) = settings.effective(country, user_agent) else { return Ok(()); }; + // The matching UA rule has an independent bucket. Normal and country + // requests share the historical bucket, preserving prior semantics. + let bucket_key = match ua_index { + Some(index) => format!("{vhost}\u{1f}ua:{index}"), + None => vhost.to_owned(), + }; let now = Instant::now(); let mut state = lock(&self.state); if state.revision != Some(revision) { @@ -300,7 +355,7 @@ impl Limiter { state.sweep_expired(now, false); let new_client = !state .buckets - .get(vhost) + .get(&bucket_key) .is_some_and(|clients| clients.contains_key(&client)); if new_client && state.bucket_count >= MAX_BUCKETS { state.sweep_expired(now, true); @@ -315,7 +370,7 @@ impl Limiter { } let bucket = state .buckets - .entry(vhost.to_owned()) + .entry(bucket_key) .or_default() .entry(client) .or_insert(Bucket { diff --git a/src/rate_limit/tests.rs b/src/rate_limit/tests.rs index a8086af..0cf8dfc 100644 --- a/src/rate_limit/tests.rs +++ b/src/rate_limit/tests.rs @@ -109,4 +109,52 @@ mod tests { assert_eq!(state.buckets.len(), 1); assert_eq!(state.bucket_count, 2); } + #[test] + fn user_agent_override_is_isolated_from_other_traffic() { + let mut settings = Settings { + enabled: true, + requests: 3, + burst: 0, + user_agent_limits: vec![UserAgentLimit { + pattern: "*BadBot*".into(), requests: 1, window_seconds: 60, burst: 0, + }], + ..Default::default() + }; + settings.validate().unwrap(); + let limiter = Limiter::new(); + let ip = "198.51.100.12".parse().unwrap(); + assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("badbot/1.0"), &settings).is_ok()); + assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("BADBOT/2"), &settings).is_err()); + for _ in 0..3 { + assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_ok()); + } + assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_err()); + // The same IP and UA on a different vhost gets a separate quota. + assert!(limiter.check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings).is_ok()); + } + #[test] + fn user_agent_overrides_work_without_base_rate_or_geoip() { + let mut settings = Settings { + enabled: false, + user_agent_limits: vec![UserAgentLimit { pattern: "scanner".into(), requests: 1, window_seconds: 60, burst: 0 }], + ..Default::default() + }; + settings.validate().unwrap(); + let limiter = Limiter::new(); + let ip = "192.0.2.100".parse().unwrap(); + assert!(limiter.check_with_user_agent(1,"site",ip,None,Some("ScanNER"),&settings).is_ok()); + assert!(limiter.check_with_user_agent(1,"site",ip,None,Some("scanner"),&settings).is_err()); + assert!(limiter.check_with_user_agent(1,"site",ip,None,None,&settings).is_ok()); + } + #[test] + fn user_agent_override_validation_is_bounded_and_unique() { + let mut settings = Settings { user_agent_limits: vec![ + UserAgentLimit { pattern: "TEST*".into(), ..Default::default() }, + UserAgentLimit { pattern: "test*".into(), ..Default::default() }, + ], ..Default::default() }; + assert!(settings.validate().is_err()); + settings.user_agent_limits[1].pattern = "other".into(); + assert!(settings.validate().is_ok()); + } + } diff --git a/src/user_agent.rs b/src/user_agent.rs index 804b6cc..0c305e1 100644 --- a/src/user_agent.rs +++ b/src/user_agent.rs @@ -56,7 +56,18 @@ impl Policy { } } -fn wildcard_match(candidate: &str, pattern: &str) -> bool { +// Shared matching semantics for both blocklists and per-vhost UA throttling. +pub(crate) fn validate_pattern(raw: &str) -> Result { + let value = raw.trim(); + ensure!( + !value.is_empty() && value.len() <= MAX_PATTERN_BYTES + && !value.chars().any(char::is_control), + "User-Agent patterns must be 1..{MAX_PATTERN_BYTES} bytes without controls" + ); + Ok(value.to_ascii_lowercase()) +} + +pub(crate) fn wildcard_match(candidate: &str, pattern: &str) -> bool { if !pattern.contains('*') { return candidate.contains(pattern); } diff --git a/tools/checks/check_source.py b/tools/checks/check_source.py index 6ae49c2..bb84a78 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.31.6" +assert manifest["package"]["version"] == "0.31.7" 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.31.6"' in lock + assert 'name = "rust-proxy"\nversion = "0.31.7"' 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.31.6", - "CHANGELOG.md": "## 0.31.6", - "ROADMAP.md": "## 0.31.6", - "AGENTS.md": "Current source version: 0.31.6.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.6", + "README.md": "# RustProxy 0.31.7", + "CHANGELOG.md": "## 0.31.7", + "ROADMAP.md": "## 0.31.7", + "AGENTS.md": "Current source version: 0.31.7.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.7", }.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" @@ -150,7 +150,7 @@ for marker in [ assert "data" in (root / ".dockerignore").read_text().splitlines() css = (root / "src/admin/web/style.css").read_text() assert ".logging-grid fieldset" not in css, "logging sinks must reuse shared card styling" -assert "dialog.config-history-dialog {" in css, "Revision History must use the shared dialog appearance rule" +assert re.search(r"dialog\.config-history-dialog,\s*dialog\.config-history-json-dialog\s*\{", css), "Revision History and JSON comparison must use shared dialog styling" assert ".config-history-dialog::backdrop" not in css, "Revision History must not duplicate the shared dialog backdrop" history_dialog_css = re.search(r"^\.config-history-dialog\s*\{([^}]*)\}", css, re.M).group(1) assert "border-radius:" not in history_dialog_css, "Revision History dialog must stay square-cornered" @@ -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.31.6"', '"/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.31.7"', '"/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 @@ -577,7 +577,7 @@ for marker in ['data-cache-settings-tab="general"', 'data-cache-settings-tab="re assert marker in html, marker for marker in ['data-settings-tab="connections"', 'data-http-settings-tab="http2"', 'data-http-settings-tab="http3"', 'id="http2-form"', 'id="http2-listener-rows"', 'id="http3-form"', 'id="http3-listener-rows"']: assert marker in html, marker -for marker in ['const tabAliases = { http2: "connections" }', 'data-http-settings-tab', 'selectHttpTab("http2")']: +for marker in ['const tabAliases = { http2: "connections", headers: "policies", response: "policies" }', 'data-http-settings-tab', 'selectHttpTab("http2")']: assert marker in settings_js or marker in html, marker for marker in ['renderHttp2Settings', 'collectHttp2Listeners', 'http2SettingsControls', 'preservedHttp2', 'listener-h2', 'h2Configure', 'selectHttpTab?.("http2")', 'renderHttp3Settings', 'collectHttp3Listeners', 'http3SettingsControls', 'preservedHttp3', 'listener-h3', 'h3Configure', 'selectHttpTab?.("http3")']: assert marker in integrations_js, marker @@ -1394,7 +1394,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.31.6"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.31.7"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. tls_0241 = rust_source(root, "src/tls.rs") @@ -1633,5 +1633,5 @@ for marker_0282 in [ assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text() assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text() -print("PASS: 0.31.6 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.") +print("PASS: 0.31.7 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.") print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.") diff --git a/tools/checks/dependency_upgrade_0312.py b/tools/checks/dependency_upgrade_0312.py index cf325b7..cebf8be 100755 --- a/tools/checks/dependency_upgrade_0312.py +++ b/tools/checks/dependency_upgrade_0312.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check the 0.31.6 dependency/source migration; optionally require a resolved lock. +"""Check the 0.31.7 dependency/source migration; optionally require a resolved lock. This is not a substitute for rustc, cargo metadata or runtime tests. """ @@ -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.31.6' +assert manifest['package']['version'] == '0.31.7' assert manifest['package']['rust-version'] == '1.99' deps = manifest['dependencies'] expected_requirements = { @@ -78,4 +78,4 @@ if missing: print(f'PENDING: lockfile still requires cargo update ({details})') else: print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required') -print('PASS: 0.31.6 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)') +print('PASS: 0.31.7 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)') diff --git a/tools/checks/docs_check.py b/tools/checks/docs_check.py index 015d4e3..6d426e4 100755 --- a/tools/checks/docs_check.py +++ b/tools/checks/docs_check.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Rust-independent documentation/Reader consistency checks for the 0.31.6 release.""" +"""Rust-independent documentation/Reader consistency checks for the 0.31.7 release.""" from pathlib import Path import re from rust_source import rust_source @@ -30,7 +30,7 @@ required = [ DOCS / "V0.25.1_AUDIT.md", DOCS / "V0.25.0_AUDIT.md", DOCS / "IMPLEMENTATION_0.31.5.md", - DOCS / "IMPLEMENTATION_0.31.6.md", + DOCS / "IMPLEMENTATION_0.31.7.md", DOCS / "IMPLEMENTATION_0.31.3.md", DOCS / "IMPLEMENTATION_0.31.1.md", DOCS / "TEST_REPORT.md", @@ -68,7 +68,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.31.6") +assert readme.startswith("# RustProxy 0.31.7") 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/h3_navigation_0316.py b/tools/checks/h3_navigation_0316.py index a718222..6512d8c 100755 --- a/tools/checks/h3_navigation_0316.py +++ b/tools/checks/h3_navigation_0316.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -"""Compiler-independent guard for the 0.31.6 H3 navigation patch.""" +"""Compiler-independent guard for the 0.31.7 H3 navigation patch.""" from pathlib import Path ROOT = Path(__file__).resolve().parents[2] html = (ROOT / 'src/admin/web/index.html').read_text() -assert '0.31.6' in (ROOT / "Cargo.toml").read_text() +assert '0.31.7' in (ROOT / "Cargo.toml").read_text() assert '',1)[0] body = (ROOT / 'src/proxy/cache_helpers.rs').read_text() @@ -28,4 +28,4 @@ for marker in ('h3_bodyless_get_and_head_are_normalized_for_http1_navigation', 'h3_stalled_cache_probe_bypasses_without_blocking_navigation', 'h3_cache_probe_replays_nonempty_get_without_dropping_bytes'): assert marker in rust_tests, marker -print('PASS: RustProxy 0.31.6 original logo, H3 empty-GET receive lifecycle, bounded probe, per-stream diagnostics and drain invariants') +print('PASS: RustProxy 0.31.7 original logo, H3 empty-GET receive lifecycle, bounded probe, per-stream diagnostics and drain invariants') diff --git a/tools/checks/preflight.py b/tools/checks/preflight.py index 484e40c..9450bdc 100755 --- a/tools/checks/preflight.py +++ b/tools/checks/preflight.py @@ -73,12 +73,12 @@ def check(root: Path, repair_known: bool = False) -> list[str]: flat = module.parent.with_suffix(".rs") if flat.is_file(): raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") - for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]: + for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]: if not (root / path).is_file(): - raise ValueError(f"Incomplete RustProxy 0.31.6 source tree: missing {path}") + raise ValueError(f"Incomplete RustProxy 0.31.7 source tree: missing {path}") text = (root / "src/lib.rs").read_text(encoding="utf-8") if '#[path = "auth/mod.rs"]' not in text: - raise ValueError("src/lib.rs is not the 0.31.6 entry point; extract the complete package, not individual files.") + raise ValueError("src/lib.rs is not the 0.31.7 entry point; extract the complete package, not individual files.") return messages diff --git a/tools/checks/release_0317.py b/tools/checks/release_0317.py new file mode 100755 index 0000000..d3fa8eb --- /dev/null +++ b/tools/checks/release_0317.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""0.31.7 offline update-retention regression without systemd or root privileges.""" +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[2] +UPDATER = ROOT / "update.sh" + + +def mkdir_age(path: Path, days: int) -> None: + path.mkdir(parents=True) + stamp = time.time() - days * 86400 + os.utime(path, (stamp, stamp)) + + +def call(update_prefix: Path, cargo_target: Path, mockbin: Path, *, fail: bool = False, plan: bool = False) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PATH"] = f"{mockbin}:{env.get('PATH', '')}" + env["CARGO_TARGET_DIR"] = str(cargo_target) + env["RP_TEST_UPDATE_FAIL"] = "1" if fail else "0" + args = ["bash", str(UPDATER), "--prefix", str(update_prefix)] + if plan: + args.append("--plan") + return subprocess.run(args, env=env, capture_output=True, text=True, timeout=15, check=False) + + +def main() -> None: + subprocess.run(["bash", "-n", str(UPDATER)], check=True) + with tempfile.TemporaryDirectory(prefix="rp-release-0317-") as tmp: + root = Path(tmp) + prefix = root / "installed rustproxy" + releases = prefix / "releases" + backups = prefix / "backups" + releases.mkdir(parents=True) + backups.mkdir() + for name, age in [("r-new", 0), ("r-second", 2), ("r-live", 8), ("r-ancient", 30), ("r-older extra", 40)]: + mkdir_age(releases / name, age) + (prefix / "current").symlink_to(releases / "r-live", target_is_directory=True) + for days in range(1, 6): + mkdir_age(backups / f"before-{days}", days) + target = root / "target" + for profile in ["release", "debug"]: + (target / profile / "deps").mkdir(parents=True) + (target / profile / ".fingerprint").mkdir() + (target / profile / "build").mkdir() + (target / profile / "rust-proxy").write_text("compiled binary") + mkdir_age(target / profile / "incremental" / "old", 50) + mkdir_age(target / profile / "incremental" / "new", 1) + mockbin = root / "bin" + mockbin.mkdir() + stub = mockbin / "python3" + stub.write_text("#!/bin/sh\n[ \"${RP_TEST_UPDATE_FAIL:-0}\" != 1 ] || exit 17\nexit 0\n") + stub.chmod(0o755) + + result = call(prefix, target, mockbin, fail=True) + assert result.returncode == 17, result.stderr + assert (releases / "r-older extra").exists(), "failed update cleaned releases" + assert (target / "release" / "incremental" / "old").exists(), "failed update cleaned target" + + result = call(prefix, target, mockbin, plan=True) + assert result.returncode == 0, result.stderr + assert (releases / "r-older extra").exists(), "planned update cleaned releases" + + result = call(prefix, target, mockbin) + assert result.returncode == 0, (result.stdout, result.stderr) + assert sorted(path.name for path in releases.iterdir()) == ["r-live", "r-new"], result.stdout + assert sorted(path.name for path in backups.iterdir()) == ["before-1", "before-2", "before-3"], result.stdout + assert (prefix / "current").resolve() == releases / "r-live", "current symlink changed" + for profile in ["debug", "release"]: + assert not (target / profile / "incremental" / "old").exists(), "old incremental retained" + assert (target / profile / "incremental" / "new").exists(), "recent incremental removed" + for artifact in ["deps", ".fingerprint", "build", "rust-proxy", ".cargo-lock"]: + assert (target / profile / artifact).exists(), f"removed Cargo artifact: {profile}/{artifact}" + + # Safety: do not touch target if a parent directory is a symlink. + target_symlink_root = root / "symlink" + target_symlink_root.symlink_to(target, target_is_directory=True) + mkdir_age(target / "release" / "incremental" / "symlink-test", 50) + result = call(prefix, target_symlink_root, mockbin) + assert result.returncode == 0, result.stderr + assert (target / "release" / "incremental" / "symlink-test").exists(), "followed symlinked target" + + # Static source contracts for the managed-update cache handoff. + deploy = (ROOT / "scripts/deploy/deploy_runtime.py").read_text() + runtime = (ROOT / "src/main/admin_runtime.rs").read_text() + demote = (ROOT / "src/cache/cold/demotion.rs").read_text() + assert "stop_for_managed_update(data)" in deploy + assert "run(['systemctl', 'stop', SERVICE])" in deploy + assert "data_dir.join(\".managed-update\").is_file()" in runtime + assert "state.cache.flush_hot_to_cold_for_update()" in runtime + assert "self.flush_hot_to_cold_inner(true).await" in demote + print("PASS: 0.31.7 failed/plan/successful update, two releases + three backups, Cargo safety/symlink guards, managed-update flush wiring.") + + +if __name__ == "__main__": + main() diff --git a/tools/checks/release_0317_ui_test.mjs b/tools/checks/release_0317_ui_test.mjs new file mode 100644 index 0000000..f557bd1 --- /dev/null +++ b/tools/checks/release_0317_ui_test.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// 0.31.7 DOM/source + executable revision-diff tests (no Rust runtime). +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +const read = path => fs.readFileSync(path, 'utf8'); +const html = read('src/admin/web/index.html'); +const css = read('src/admin/web/style.css'); +const settings = read('src/admin/web/settings.js'); +const hist = read('src/admin/web/app/config-history.js'); +const management = read('src/admin/web/management.js'); +const listener = read('src/admin/web/integrations.js'); +const router = read('src/admin/router.rs'); +const core = read('src/admin/web/vhosts/core.js'); +const editor = read('src/admin/web/vhosts/editor.js'); +const rates = read('src/admin/web/vhosts/rates.js'); +const model = read('src/rate_limit.rs'); +const api = JSON.parse(read('src/admin/openapi.json')); + +assert.match(html, /data-settings-tab="policies"/); +for (const name of ['user-agent', 'headers', 'response']) { + assert.match(html, new RegExp(`data-policy-settings-tab="${name}"`)); + assert.match(html, new RegExp(`data-policy-settings-page="${name}"`)); +} +for (const form of ['global-ua-block-form', 'headers-form', 'response-form']) { + assert.equal(html.split(`id="${form}"`).length, 2, `${form} must be unique`); +} +assert.match(settings, /selectPolicyTab\("user-agent"\)/); +assert.match(html, /id="config-history-json-dialog"/); +assert.match(html, /id="config-history-compare-open"/); +const actionStart = html.indexOf('
'); +const actionEnd = html.indexOf('
', actionStart); +const restorePos = html.indexOf('id="config-history-restore"', actionStart); +const comparePos = html.indexOf('id="config-history-compare-open"', actionStart); +assert.ok(actionStart > -1 && restorePos > actionStart && comparePos > restorePos && comparePos < actionEnd, + 'JSON compare button must appear directly under restore inside the same action group'); +assert.equal(html.split('id="config-history-compare-open"').length, 2); +assert.match(html, /id="config-json-compare-title"[\s\S]*?
[\s\S]*?id="config-history-json-close"/); +assert.match(css, /dialog\.config-history-dialog,\s*dialog\.config-history-json-dialog\s*\{/); +assert.match(css, /\.config-history-dialog\[open\]\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/); +assert.match(css, /\.config-history-diff\s*\{[^}]*overflow-y:\s*auto/); +assert.match(css, /\.config-history-layout\s*\{[^}]*overflow:\s*hidden/); +assert.match(css, /@media \(max-width:\s*860px\)\s*\{[\s\S]*?\.config-history-layout\s*\{[^}]*overflow-y:\s*auto/); +assert.match(css, /\.config-history-json-columns pre\s*\{[^}]*border:\s*0/); +assert.match(hist, /"config-history-json-before"\)\.textContent/); +assert.match(hist, /"config-history-json-after"\)\.textContent/); +assert.match(css, /\.listener-protocol-settings-actions\s*\{[^}]*gap:\s*14px/); +assert.match(listener, /listener-protocol-settings-actions/); +assert.match(management, /el\("details", undefined, "acl-profile-attached"\)/); +assert.doesNotMatch(management, /attached\.open\s*=\s*true/); +assert.match(router, /include_str!\("web\/vhosts\/rates\.js"\)/); +assert.match(editor, /user_agent_limits: uaRatesReader\(\)/); +assert.match(editor, /userAgentRateEditor\(g, rate\.user_agent_limits \|\| \[\]\)/); +assert.match(core, /uaRatesReader/); +assert.match(rates, /function userAgentRateEditor/); +assert.match(model, /pub user_agent_limits: Vec/); +assert.ok(api.components.schemas.VhostRateLimit.properties.user_agent_limits); + +const start = hist.indexOf('function configDiff('), end = hist.indexOf('function renderConfigRevisionDetail(', start); +assert.ok(start > 0 && end > start, 'executable diff function must exist'); +const context = { before: null, after: null }; +vm.createContext(context); +vm.runInContext(`${hist.slice(start, end)}\nglobalThis.diffResult = () => configDiff(before, after);`, context); +const diff = (before, after) => { context.before=before; context.after=after; return JSON.parse(JSON.stringify(context.diffResult())); }; +let result = diff({port:80, x:{a:true, b:2}, vhosts:[{id:'v1', host:'a'},{id:'v2', host:'b'}]}, + {port:443, x:{a:true, b:3}, vhosts:[{id:'v2', host:'b'},{id:'v1', host:'a'}]}); +assert.deepEqual(result.map(x => x.path), ['$["port"]','$["x"]["b"]']); +result = diff({a:1, headers:[]}, {a:1, headers:[{id:'one',value:'ok'}], policy:true}); +assert.deepEqual(result.map(x => x.path), ['$["headers"]["one"]["id"]','$["headers"]["one"]["value"]','$["policy"]']); +result = diff({items: [{id:'one', active: true}]}, {items: [{id:'one',active:false}]}); +assert.equal(result.length,1); +assert.equal(result[0].path,'$["items"]["one"]["active"]'); +assert.ok(diff({}, Object.fromEntries(Array.from({length:350},(_,i) => [i, i]))).length <= 200); +console.log('PASS: 0.31.7 Settings sections, vhost UA rate editor/API, ACL/listener layout, changed-field revision diff, bounded scrolling, conventional full JSON modal.'); diff --git a/tools/checks/release_smoke.py b/tools/checks/release_smoke.py index 0fb146c..a8bffa6 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.31.6"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.31.7"' 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.31.6" + assert manifest["to_version"] == "0.31.7" 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.31.6"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.7"' 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 @@ -1108,7 +1108,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.31.6"', '"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.31.7"', '"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"']: @@ -1313,7 +1313,7 @@ def main() -> None: source_contract_smoke() container_contract_smoke() grafana_contract_smoke() - print("PASS: 0.31.6 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") + print("PASS: 0.31.7 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") if __name__ == "__main__": diff --git a/tools/checks/security_logs_0315_ui_test.mjs b/tools/checks/security_logs_0315_ui_test.mjs index 2fb9929..691ced9 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\.31\.6"/); +assert.match(manifest, /version = "0\.31\.7"/); 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.31.6'); +assert.equal(openapi.info.version, '0.31.7'); 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); @@ -68,4 +68,4 @@ for (const pack of [en, pl]) { } } -console.log('PASS: 0.31.6 UA blocking, copy controls, repeated-value parser, vhost log consolidation, OpenAPI and locales.'); +console.log('PASS: 0.31.7 UA blocking, copy controls, repeated-value parser, vhost log consolidation, OpenAPI and locales.'); diff --git a/tools/checks/source_module_contracts.py b/tools/checks/source_module_contracts.py index bf722b7..90e88e8 100755 --- a/tools/checks/source_module_contracts.py +++ b/tools/checks/source_module_contracts.py @@ -36,7 +36,7 @@ for old in (ROOT / "src/admin/web/app.js", ROOT / "src/admin/web/vhosts.js"): assert not old.exists(), f"oversized browser monolith returned: {old.relative_to(ROOT)}" for required in ( "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", - "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", + "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", diff --git a/tools/checks/system_info_0301_ui_test.mjs b/tools/checks/system_info_0301_ui_test.mjs index 7d4fd0a..b5cc26c 100644 --- a/tools/checks/system_info_0301_ui_test.mjs +++ b/tools/checks/system_info_0301_ui_test.mjs @@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || { const elements = new Map(); const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); }; const status = { - version: '0.31.6', uptime_seconds: 4201, + version: '0.31.7', uptime_seconds: 4201, runtime: { platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64', statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3', @@ -63,7 +63,7 @@ const context = { vm.runInNewContext(code, context); assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13'); assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0'); -assert.equal(dollar('runtime-version').textContent, '0.31.6'); +assert.equal(dollar('runtime-version').textContent, '0.31.7'); assert.equal(dollar('runtime-logical-cpus').textContent, '8'); assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%'); assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5'); @@ -124,4 +124,4 @@ for (const key of ['python_version','pip_version','certbot_version']) { assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]); assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true); } -console.log('PASS: 0.31.6 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.'); +console.log('PASS: 0.31.7 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.'); diff --git a/tools/checks/web_assets.py b/tools/checks/web_assets.py index 02b7857..e583723 100755 --- a/tools/checks/web_assets.py +++ b/tools/checks/web_assets.py @@ -7,7 +7,7 @@ PANEL_PARTS = ( "redirects.js", "host404.js", "traffic-panel.js", "cache-panel.js", "transform-panel.js", "acme.js", "settings.js", "file-logs.js", ) -VHOST_PARTS = ("core.js", "health.js", "editor.js", "render.js") +VHOST_PARTS = ("core.js", "health.js", "rates.js", "editor.js", "render.js") def _read(root: Path, folder: str, parts: tuple[str, ...]) -> str: return "\n".join((root / "src/admin/web" / folder / part).read_text(encoding="utf-8") for part in parts) diff --git a/update.sh b/update.sh index 01e03c7..1199b93 100755 --- a/update.sh +++ b/update.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -KEEP_RELEASES=3 +KEEP_RELEASES=2 KEEP_BACKUPS=3 prune_directories() { @@ -12,6 +12,7 @@ prune_directories() { local protected="${3:-}" local kept=0 local entry + local sorted local name [[ -d "$base" ]] || return 0 @@ -25,7 +26,10 @@ prune_directories() { kept=1 fi - while IFS= read -r -d '' entry; do + while IFS= read -r -d '' sorted; do + # find prints a numeric mtime sort key before the actual path. Never + # pass that sort key to rm (the old updater did, so nothing was freed). + entry="${sorted#* }" name="${entry##*/}" if [[ -n "$protected" && "$name" == "$protected" ]]; then @@ -90,6 +94,41 @@ cleanup_installed_releases() { prune_directories "$releases" "$KEEP_RELEASES" "$current_release" } +# Cargo keeps critical fingerprints, build scripts and compiled deps in target/. +# Never remove these: doing so would trigger a full rebuild. Only stale +# incremental state can be reclaimed, after a successful managed update. +cleanup_safe_cargo_target() { + local target="${CARGO_TARGET_DIR:-$ROOT/target}" + local profile item + if [[ "$target" != /* ]]; then target="$ROOT/$target"; fi + [[ -d "$target" && ! -L "$target" ]] || return 0 + # Reject targets with symlinked ancestor paths; never follow links. + if [[ "$(realpath -ms -- "$target")" != "$(realpath -e -- "$target" 2>/dev/null || true)" ]]; then + printf 'WARNING: target cleanup skipped for symlinked target: %s\n' "$target" >&2 + return 0 + fi + command -v flock >/dev/null 2>&1 || return 0 + for profile in release debug; do + [[ -d "$target/$profile" && ! -L "$target/$profile" ]] || continue + # Cargo itself uses this file as its profile build lock. Skip if + # another build is using it, instead of competing with cargo. + exec {cargo_lock_fd}>>"$target/$profile/.cargo-lock" + if ! flock -n -x "$cargo_lock_fd"; then + exec {cargo_lock_fd}>&- + continue + fi + if [[ -d "$target/$profile/incremental" && ! -L "$target/$profile/incremental" ]]; then + while IFS= read -r -d '' item; do + [[ -d "$item" && ! -L "$item" ]] || continue + printf 'Removing stale Cargo incremental state: %s\n' "$item" + rm -rf -- "$item" + done < <(find "$target/$profile/incremental" -mindepth 1 -maxdepth 1 -type d -mtime +30 -print0) + fi + flock -u "$cargo_lock_fd" + exec {cargo_lock_fd}>&- + done +} + cleanup_source_backups() { local target="$1" local backup_dir @@ -152,4 +191,6 @@ if command -v flock >/dev/null 2>&1; then exec {cleanup_lock_fd}>&- else printf 'WARNING: flock unavailable; retention cleanup skipped\n' >&2 -fi \ No newline at end of file +fi +# This cleanup does not remove any Cargo dependency/compiler artifacts. +cleanup_safe_cargo_target \ No newline at end of file