From 3883a21bc405be286015f854fe88c1df39ce6a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 3 Oct 2026 10:05:46 +0200 Subject: [PATCH] v0.24.7 --- AGENTS.md | 2 +- CHANGELOG.md | 20 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 25 +- ROADMAP.md | 24 ++ SHA256SUMS | 113 ++++----- debian/README.md | 2 +- debian/build-package.sh | 2 +- debian/changelog | 14 ++ dev.sh | 5 +- docker-agent/main.go | 2 +- docs/API.md | 6 + docs/API_ACCESS.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/CACHE.md | 6 +- docs/INSTALL.md | 2 +- docs/README.md | 2 + docs/TEST_REPORT.md | 47 ++-- docs/TRANSFORM.md | 6 +- docs/V0.24.6_AUDIT.md | 57 +++++ docs/V0.24.7_AUDIT.md | 76 ++++++ grafana/rustproxy-overview.json | 2 +- langs/en_US.json | 28 ++- langs/pl_pl.json | 28 ++- make_zip.py | 2 +- scripts/check_source.py | 45 ++-- scripts/deploy.py | 2 +- scripts/memory_contracts.py | 142 ++++++++++++ scripts/preflight.py | 4 +- scripts/release_smoke.py | 30 ++- scripts/routeros-oci-build/README.md | 4 +- scripts/swagger_assets.py | 2 +- scripts/upgrade.py | 6 +- src/admin/mod.rs | 50 +++- src/admin/openapi.json | 93 +++++++- src/admin/web/app.js | 120 +++++++++- src/admin/web/index.html | 88 ++++++- src/admin/web/transform-panel.js | 7 + src/admin/web/vhosts.js | 2 +- src/cache/cold.rs | 96 +++++--- src/cache/collapse.rs | 13 ++ src/cache/dashboard.rs | 4 + src/cache/lifecycle.rs | 4 + src/cache/model.rs | 45 +++- src/cache/tests.rs | 109 +++++++++ src/cache/tickets.rs | 3 + src/db.rs | 21 +- src/main.rs | 4 +- src/runtime.rs | 37 ++- src/system_stats/bsd.rs | 180 --------------- src/transform/css.rs | 14 +- src/transform/html.rs | 45 ++-- src/transform/json.rs | 52 ++++- src/transform/mod.rs | 334 +++++++++++++++++++++++++-- src/transform/svg.rs | 8 +- 56 files changed, 1640 insertions(+), 403 deletions(-) create mode 100644 docs/V0.24.6_AUDIT.md create mode 100644 docs/V0.24.7_AUDIT.md create mode 100755 scripts/memory_contracts.py delete mode 100644 src/system_stats/bsd.rs diff --git a/AGENTS.md b/AGENTS.md index 800176f..3045f7a 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.24.5. +compatibility. Current source version: 0.24.7. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index 8101554..7976025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.24.7 + +- Make the transform temporary-memory ceiling configurable from Settings and preserve a 128 MiB default for existing configurations. +- Add a configurable process-wide transform concurrency ceiling; excess work fails open unchanged rather than waiting behind transformation work. +- Add persisted RAM saver, Recommended, Performance and Uber performance transform resource presets without changing enabled transform formats or extension allow-lists. +- Show transform-resource warnings when the selected budget cannot fit the worst-case buffers for one maximum compressed body or exceeds half of detected host/cgroup RAM. +- Add live dashboard resource accounting: current transform RAM reservation/budget and active slots, HOT/transient cache RAM, non-cache process RSS, plus COLD disk occupancy and object limits. +- Include global transform representation/eligibility settings in cache compatibility identity while excluding resource-only preset/budget/concurrency controls. +- Extend SQLite/OpenAPI/offline memory/release regressions and update release identity to 0.24.7. + +## 0.24.6 + +- Persist the selected memory and COLD cache preset identities in the SQLite-backed cache configuration and restore their selected/custom state in Settings. +- Add a Recommended COLD policy preset with bounded queue sizing and conservative demotion/GC defaults while preserving operator-selected disk path, disk budget and object limit. +- Add a 128 MiB process-wide transform temporary-memory budget, bounded body aggregation independent of claimed Content-Length, and bounded decompression growth. +- Validate/compact JSON without a full DOM tree, remove routine HTML raw-tag string allocations and avoid repeated CSS output scans. +- Cap stale-while-revalidate background work at 8 keys and proactive sleeping refresh tasks at 256; keep COLD cleanup single-flight and candidate memory proportional to the requested deletion limit. +- Bound COLD delete fallback tasks and deletion metadata, and add Rust-independent regression checks for memory/task bounds across background workers. +- Update release identity to 0.24.6. + ## 0.24.5 - Normalize actually empty, length-unknown HTTP/3 request bodies before HTTP/1.1 upstream forwarding so Hyper emits a zero-length request instead of chunked framing for methods such as DELETE. diff --git a/Cargo.lock b/Cargo.lock index 2efb192..27c1de9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.24.5" +version = "0.24.7" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index aa47fa0..2a9eae8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.24.5" +version = "0.24.7" edition = "2021" rust-version = "1.88" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index 7247b0d..0524476 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# RustProxy 0.24.5 +# RustProxy 0.24.7 Independent Rust reverse proxy with a dashboard, SQLite configuration, streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools. -**0.24.5 release source.** +**0.24.7 release source.** ## Layer-4 streams @@ -11,6 +11,27 @@ 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.24.7 + +- Make the process-wide transformation memory budget configurable in **Settings -> Transform**, with a 1 MiB..2 GiB range. The budget remains a ceiling for estimated temporary buffers and is not pre-allocated. +- Add a configurable global maximum for simultaneous transformation attempts. When the limit is busy, transformation fails open with `concurrency_pressure` instead of queueing additional full-body work. +- Add persisted transform resource presets: **RAM saver** (32 MiB / 1 MiB body / 8 concurrent), **Recommended** (128 MiB / 2 MiB / 32), **Performance** (512 MiB / 8 MiB / 64) and **Uber performance** (1 GiB / 32 MiB / 128). Manual resource edits switch the preset indicator to Custom. +- Keep format switches and extension allow-lists independent from resource presets so selecting a performance profile does not silently change transformation semantics. +- Show live transform RAM reservations and active transform slots in the Transform dashboard, and add overview RAM accounting for HOT cache, transient cache, transform reservations and the remaining non-cache process RSS. +- Show current COLD storage occupancy against its disk/object limits in the overview cache block while keeping disk usage separate from process RAM. +- Make global transform representation/eligibility settings part of cache compatibility identity while excluding resource-only preset/budget/concurrency metadata. Changing JSON/HTML/etc. or the max transformable body now invalidates incompatible cached representations; changing only resource capacity does not. +- Update package/source/UI/OpenAPI/release identity to 0.24.7. + +## What changed in 0.24.6 + +- Persist the explicitly selected HOT memory preset and COLD policy preset inside the revisioned cache configuration stored in SQLite. The Settings page restores the selected state after reload, while the numeric cache limits remain authoritative. +- Add a **Recommended** COLD policy preset that enables idle/LRU/shutdown demotion, uses conservative GC timings and sizes the COLD writer queue from transient RAM without replacing the administrator's disk path, disk budget or object limit. +- Bound transformation memory globally to 128 MiB of estimated temporary buffers, bound response aggregation even when an upstream lies about `Content-Length`, and keep decompression allocation growth inside the configured transform body ceiling. +- Reduce transform allocation churn: JSON validation no longer materializes a full `serde_json::Value`, HTML raw-tag detection avoids per-tag lowercase strings, and CSS minification no longer scans the full output to recover the last character on normal paths. +- Bound cache background retention: stale-while-revalidate work is capped at 8 keys, proactive sleeping refresh tasks at 256, COLD cleanup is single-flight, COLD candidate selection retains only the requested best candidates, and saturated COLD delete fallback work cannot create unlimited detached tasks. +- Add Rust-independent memory-contract checks covering transform/cache bounds plus logging, diagnostics, DNS, backend health, streams, listener supervisors, ACME, Docker and update-check background workers. +- Update package/source/UI/OpenAPI/release identity to 0.24.6. + ## What changed in 0.24.5 - Fix HTTP/3 requests with an actually empty, length-unknown body when forwarding to an HTTP/1.1 upstream. RustProxy now observes the H3 body end before handing the request to Hyper so methods such as `DELETE`, `POST`, `PUT` and `PATCH` are represented as a known zero-length body instead of being emitted as `Transfer-Encoding: chunked`. diff --git a/ROADMAP.md b/ROADMAP.md index ed8c332..222e904 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,29 @@ # RustProxy roadmap +## 0.24.7 + +Current direction: 0.24.6 -> 0.24.7 + +- [x] Make the transform temporary-memory budget configurable and persisted with the normal SQLite-backed transform settings. +- [x] Add a process-wide maximum concurrent transformation limit with fail-open pressure behavior. +- [x] Add persisted RAM saver / Recommended / Performance / Uber performance resource presets while keeping transform types/extensions independent. +- [x] Add UI budget safety warnings and manual-edit -> Custom preset behavior. +- [x] Rotate cache compatibility identity for global transform representation/eligibility changes but not resource-only capacity metadata. +- [x] Extend OpenAPI, SQLite persistence, memory-contract and release/source regressions and update release identity to 0.24.7. +- [ ] Run compiler-enabled `./dev.sh fmt`, `./dev.sh check`, `./dev.sh test` and sustained RSS/heap acceptance for every resource preset on the release host. + +## 0.24.6 + +Current direction: 0.24.5 -> 0.24.6 + +- [x] Persist selected HOT/COLD cache preset intent in the SQLite-backed configuration and restore it in Settings. +- [x] Add a Recommended COLD policy preset without overriding disk capacity/path choices. +- [x] Bound transform aggregation/decompression/concurrency memory and remove avoidable JSON/HTML/CSS allocation churn. +- [x] Bound stale revalidation, proactive refresh scheduling, COLD cleanup selection/concurrency and delete fallback tasks. +- [x] Audit the remaining long-lived/background workers for queue/task cardinality and cancellation ownership. +- [x] Add Rust-independent memory contract regression checks and update release identity to 0.24.6. +- [ ] Run compiler-enabled `./dev.sh fmt`, `./dev.sh check`, `./dev.sh test` and sustained RSS/heap acceptance on the release host. + ## 0.24.5 Current direction: 0.24.4 -> 0.24.5 diff --git a/SHA256SUMS b/SHA256SUMS index d2063ac..c713288 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,19 +1,19 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example -9cd6d2c6e3d9acaec82000530d28b7e2a24c39313c6d96c1b15ffd13a7984bde .gitignore -6e27536e3111c9ffceb406bd4b0cf756909fff1e51c8d682ebe734539bd22bc8 AGENTS.md -53127d46ca032af306e9ded8dc2be1afc5a47f07cd4e6b1eff0a6409a5efe3bd CHANGELOG.md -1c54d0333fd6dbbff27ebe6b814c955cf9ab0e776f60496615fa70ebd3bda0a3 Cargo.lock -aed16132478f472abc172dae8c544eefdaab1f88f9d7b6bb00dc7e7934338eab Cargo.toml +9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore +6b594006d8214ed456fc514831a794ebcc1b7ac2b91b7f067da275d457881e96 AGENTS.md +017ef806440a57e7da5a32dbfa4743424e55fceb07f124282b3d69da9b66d452 CHANGELOG.md +4ffe7181d7c69bc8d7e6373d2e67302f72934256f090d9131a087ce2f8e899b0 Cargo.lock +6673250cb6640bfa7ec35e63c66882174ff80c6e62253b8cfd57b1ec7d00f3d2 Cargo.toml d6d22ba63f4bc3d16a13012ecaabff8a156be57c824d2f20f66976502fb27d1f Dockerfile -96916966502e9e64b4f774a6644dd045764babab2e44e8eec9d5dfad933035ee README.md -0eacc532549167c6c371b76769aeb8b359597c99a0aa1f4560fa76750b1516bb ROADMAP.md +cc5fd11f9d15334699bd75f979d624055e88ffacdad102dcb011f4c6b60e99af README.md +b304b4a6b2c4edff114d0adbad79ad49cd344b3079006fe380693c9e68c1bb76 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml a55fafd4c9fc17f8af591407e7f86d222ce06658c64dcffda92acbf420f969f1 build.rs -de73fa3783786d4e66ab1e5ed9c4e44e1201a298e223c692c604cd50e30f7ff2 debian/README.md -53f5d02d09d8a7616b0f9954af9a6dbb26bea60b1823da614dda411911f89c23 debian/build-package.sh +9447d94630a65e79000f1dc1658837a200e632141c33f221a706855b5438ff9f debian/README.md +867916d7e4256661e310042e202413df06f6ed27d58de23726eef9aadd8c1afe debian/build-package.sh 0763a4bd8b429f7922b23a7276ff694f3a751b83fb127f6ad7ec654e5e507ecd debian/build-repository.sh -7d169934839fa7a48b888da5cf801ae6f2bb533715de1da10e4bf3c2bd1b5f4d debian/changelog +767bb5bec17acc0c51f9c02b8a6bd637a9b0ba980f7a58c66e79744186359398 debian/changelog c90457b19b1b2f7998d3db59a656d215eb0fab23769197405960457b66d95cf7 debian/control dff2ecb9c14623416981f444bc6df1c4e2bf55d330260f36a312d8d42245400f debian/rules c537d54fd92a9b97c5f6e6ec63bc1276cda324513e2946b341b2193f63b62e6a debian/rustproxy.environment @@ -24,26 +24,26 @@ d1c483406fb53674ed8db5f30de599b5a91fa1c6b28242eb70a64d6a736430bb debian/rustpro e8e18df40bcd69d6aa404282679186d9b8256ac63a5b4e80d7f081e8a0095a2e debian/source/format 57d737d73b828bf763b210c8ddea39d7a63224f8d509a518d0b876ba2e6a805a deploy/README.md 50329bd9d6980fb1c90a5cb29966b19a257905791d1d07d185d6bd4e3b0bcb1a deploy/rustproxy.service -8c321c96974e79f725470d38b3aa3780d5b8c432a5c89a6f8d36bd575014657f dev.sh +261d682eac0d688274b5ee8d1e98e751fa2ea5613665d0ecb18524babcd71026 dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example 9e9f3f17f38f9b0730f598f65988ce8ef562d2182ef6778fc7292b990e9cf22f docker-agent/go.mod 9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh -8aeb74c89c914712c14b6e5958bd3242ad298c96d9be6d3163f9380c3847f9ba docker-agent/main.go +b829d52c5f834b1408ef5fac00846cdc61ae835103a5065f0de9c3e3bd161493 docker-agent/main.go 3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 0dbacca69143bb1e38d74270e24562a6749e387b26ef869643e876df6e0b1edb docs/ACME_PROVIDERS.md 888bbf7812e7283202f303680145c99b3f1a7a52fb3923fc3de1a6cbcfb72d00 docs/ACME_RFC2136.md -30822eed02d9a5fbc73ee6cfa1348c67c719665c026462c84e4106bd2a0e6b68 docs/API.md -da6b26d4bc6adec43c5c546eae42d92a5145095b9084bde652818892acd44f0a docs/API_ACCESS.md -98ad4df87b6620c0418c8c39fa6e8e1f8be88576edde2945f9be6e5dae7acb78 docs/ARCHITECTURE.md -9ad82a202abb308e9bf54fdf1a657104e0e1b5d93d05f0b1fb36248ba25a3993 docs/CACHE.md +3bfc98b55e7e905250c4ded38d18ca064c6006ae9232acfa5d48517d0da14f5b docs/API.md +abd3178126678ed94e4a904a9115c4b0c1246e1e8f77fe318393893a899762a4 docs/API_ACCESS.md +dde6a6a0b99ac34e65fe80e5eb8aebc2bc852bb97eb5c13a3c1ab538ef37f5a0 docs/ARCHITECTURE.md +2a1a6b8e5783ef4a25fc7933b4faedfde44557a827782a6a900d448163d81df7 docs/CACHE.md 0450813e3c875371d75cc41835f6362abb5300cd4bdf545f3011d9df8101170c docs/CACHE_DIAGNOSTICS.md db5534427ff0f004940fd6960906d3eaa75347a110e6322827c51c5d63f20adb docs/CONTAINER.md c822cd1d6a055d5ddec06d4f6077f0b13837dfb25ede1ec1f4e9a52e182db946 docs/CUSTOM_LOCATIONS.md 7db65a96f696aff1d7f74e2041cfc6ecdf437b1412ece90cee26da1ceff4cf7e docs/DOCKER_DISCOVERY.md 061e1ccee4f3e70126a27ba8bdbac10e895fdb4e6e4607c5c6a6ab1d164baf02 docs/HTTP3.md -7c3281b5e15101c434cbe62250f161adae050bf49826dbb93cf0ce5371affbb4 docs/INSTALL.md +3699457c13958ac7117b333ea69ce1f03cc21259b5f7acee408d9161d6043bd3 docs/INSTALL.md 0a124bd3eafaa9c6b3bc2b811fdc3e6295a6a2978766c37cc0735c27f4cf6d59 docs/INTEGRATIONS_040.md 2baaa1d34979c33caead9b271a7d235c673acb4e188c94917d975c25c179c242 docs/LIVE_LOGS.md 4e1504d1a343b94c2252545cbd5248af79f158c4566754dfe11f3c15a19ec0da docs/MANAGEMENT.md @@ -53,7 +53,7 @@ c822cd1d6a055d5ddec06d4f6077f0b13837dfb25ede1ec1f4e9a52e182db946 docs/CUSTOM_LO e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md 3da047768f32064c5ef0ad7a4c73c4b14930fe7317c54f5650f426af9f484593 docs/NPM_MIGRATION.md 8538816f6b64780ca4f652d189c5d2c00e40b0c8fcd1ad77ae2e11d39058dce6 docs/PROMETHEUS.md -8ecc6a0ac347c66e668255402ca4c46ad5dcb44bd463c86ab23843f9cc8d6e27 docs/README.md +184cae8470da4ef918a41e6201a784dbf61e87f43a4212afd046906eac025746 docs/README.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md 0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md ce5afb4f531bb5fb4c453deb49f643f2a563f32f7908c995e5bc8e0d7de995b7 docs/RENEWAL.md @@ -61,8 +61,8 @@ c6fa646500c0acac18bff6349cbb1d821680a849c8648b4eaa64f2a4305f9b9d docs/SECURITY. 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md fce52cc908fb86cab16072c0297b6cdaf4857e84185783bc96240d9a6bc321fd docs/SETTINGS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md -a6c7acde02c37b0f1f3f074bc1a1d9b899391a76ba719a16f0a2d57cfa6f1dda docs/TEST_REPORT.md -67de3ed624b92bc8d8ba7655cd55dde95d2a909f772e3e42535fd80459ab4c5a docs/TRANSFORM.md +0dc5602cd1149419521cf808a4c9920b15a6e5c20414bd70b74991691d0ccb09 docs/TEST_REPORT.md +8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md b02957b708174c9def830ced3c70c943a47af7a33467ef858255d04ab92c4af0 docs/TRANSPORT.md 96a6816915d526f00e261846131ee3f942ef332af996cf0f73c79ec1ba9f9c7a docs/V0.21.0_AUDIT.md 0c1c3ba6e8724d85ec54e254e70bbf05e063db025280ef4af866b774fb43f76b docs/V0.22.0_AUDIT.md @@ -78,6 +78,8 @@ e4d47b50fb0ce33d26817fc0afcc2f05ac53bc9b62c7e50e61b9fa9734e817b0 docs/V0.23.3_A 230005cbd56fed4bc832940c0a49cc14f50231650308724df105b0506bb34210 docs/V0.24.3_AUDIT.md b891db1453cd7cf6b10f79879f4edaf7c83f14e91e4e95826919d40cb5dca2d4 docs/V0.24.4_AUDIT.md 8d6519b00e01e5cd41ee6772941fdc7e9a70fe29626df71cefced6eefe6a94d0 docs/V0.24.5_AUDIT.md +4d2d8b84b38df57f5c6a03995019f8418806fe5867ee34ce192a90ef561986a8 docs/V0.24.6_AUDIT.md +8299b980ef547f741ddc568f6e2270deb3cffcf26e4a88a147b7f2f82372bf06 docs/V0.24.7_AUDIT.md bd7959e325685085507a20b22254c7024ceb9396b6314257694bd764ec096ba5 docs/VHOSTS.md e32472cea5ef3cd25c058e55df8adfcc1d7bf03e0d12dba69d2f2b9b94fe04b7 docs/VHOST_ACCESS.md c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md @@ -92,13 +94,13 @@ b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock- 17e7971fa9d9b141019fba3135d87cb1aa0cbc55d56b0718f879f880b615da26 examples/vhosts-balanced.json e06905b6363103b1bcee1a09b9e02292033f114d88f3edbea22ddab53e9e5071 examples/vhosts-private.json 529b47c6d4ea44d2a25d1cf9561cb5ee1e05f50a568c6dba5889741bcf936434 grafana/README.md -1f43923617da0a0b667a5c9bd98457c78024a786701f56a07be5f24f5eec8ad8 grafana/rustproxy-overview.json +dd2fe9a14176c61f1120415e59051e7584657a6057a77daeae10e33c11a97fea grafana/rustproxy-overview.json f45e5cc3d0c253534978d4b752b0667e2a7906ccf5a22106400e0f6b70b00d23 install-debian13.sh 858b39fa6886b71d20690bf369e76580922364b3cc7f0cf6ba9b040df9cd76e3 install.sh 29cada0936762f26495064d1524a668262ed729b21f20aebddc8b6e5cfea363b langs/README.md -2e3e5dc918d2838e91310b78922d8d84cbf6c9e8dcd3a0deedf6dd5045f07423 langs/en_US.json -9e1ed1d4f3daa0461ef3d3e137dca66c30141858943c0461a1c80b092a11d5f8 langs/pl_pl.json -0b9cd9ec07cb31ab0d0c75f08fe1ffee04dca099e90513017835f095d347012a make_zip.py +dd032822bc14e3017fd6ec21c4bc3e71daee2f0654855829dfcb0aa9a7400bcb langs/en_US.json +dcc9960ff63b1124a62163c8f404dec1dba95b321b168f1151e1122744e848ed langs/pl_pl.json +561ec0a7405f788a215a620df5760e79fbac50a79db88f2fb4c284627caa3205 make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql 5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql @@ -115,35 +117,36 @@ ee50f38c037765fab742e888d4b9fc4d874c31c785c6064d4eca3d2c4d24d944 nagios/check_r e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/acme_rfc2136.py ea2c670aa1fc2c145fb3666711642d526c2adb01537fc3b6398664e33136f0e4 scripts/admin_audit.py 761aadaffbfb08c32afca28af7e5379f933712563b00dddab7261a9505cb7d5b scripts/backup.py -7155159c6a76448bec8134900c8022c79c1047d0814cb7ab227d2eb331c2a53f scripts/check_source.py -15b3e90be1d89a9b25643bca3226ee4cf912bc2e9bba62bd281ea7a789552943 scripts/deploy.py +599d83a655dd1851d674cfa38bc969b653041c7fbe018a80e4797090ed3b64ed scripts/check_source.py +e10495bce1d229dc12a9a36a91453352fac77d9c69a26482f8748ad023eefb7f scripts/deploy.py 6facb2d65bfab234f09855097d12da8f72669a38ebceb4b1cbd03ab42b8d2469 scripts/langs_check.py +9f89ccdcd31da0f203113051a109ada8e51b44f91382ee2c5c75228d1e456540 scripts/memory_contracts.py 60504dd426195fca88d77e075d607c04540dbe8aae2cd806077b3818e0e0cf08 scripts/migrate_npm.py d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a scripts/mock_auth.py 2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 scripts/mock_server.py 2d4c859aae5ba357a6d30024c8487ae8f59f01b192711f13f9c2f8fee0f48b50 scripts/os-acme-client/README.md c73b814a42bb4ff703441dbd177ee625055eeb115f9b5bb0c2150aede573a159 scripts/os-acme-client/opnsense-rustproxy-cert-deploy.sh -635ec74ae1be91145e42d4e4580e2015df5b54177a17f04c42b1ee14075b9190 scripts/preflight.py -ae0834cfcc518f4bc5ce1203a703fd5ebcbe71cd02463fdcaf6d776a1241cb2e scripts/release_smoke.py -0f2deebabf637b890f38eb0317e3b26be75d3c169e154f86caf1df82f939325e scripts/routeros-oci-build/.env +65a620b85e9c0011f5f20474a10add19a1a7ab6074112d8a2f6aed4b63b8e943 scripts/preflight.py +348f0e4056945fdbea7a7bf371430b1adb0cf9e7242bfc7a7d4bd3de296c3e13 scripts/release_smoke.py +c37847ae9663197d306ea55eadbe4a1892d61d031ced923a8eeffc1ae876bb4b scripts/routeros-oci-build/.env 45be151cd85fc2ef2f1fdc3003ace63381af262e73af8a685ab48cb697cc0de1 scripts/routeros-oci-build/.env.routeros.example 3b48cff8506e5a6ae7d3876f47721f5de7d63e2e02264a78cf5b6ddd52d9fbc6 scripts/routeros-oci-build/Dockerfile.routeros 7c7a3e51f43b43e1c962a4b0bb591f99c1f06977bf77f23719f7f867dfe7eb8e scripts/routeros-oci-build/Dockerfile.routeros.dockerignore -e6fc807f43f290315c753d0d0d2b9eb081c4dd16690f8c35edef91863d08efe3 scripts/routeros-oci-build/README.md +d4edbd7c488a2187ec998c121e4298910fe74203f5f10bf3504e1bc5affa7ef5 scripts/routeros-oci-build/README.md d53b2ae7315ac143bd17d6327611a39e7c23666408bb95e39da2ca8517322ccb scripts/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a scripts/routeros-oci-build/docker-entrypoint.sh -02ba853fbfcd0d722b1ba121016ebfbe5354dcf7710c708e3110c2ea9fa7e1a5 scripts/swagger_assets.py +0546446681cf7cd597302a0297c60b920137ee6e9e6f31fe06c6e5c0b2270058 scripts/swagger_assets.py 102b23de19a88a8e60de392fb83aefd3dfb33e90cc97ed1690d9f3d4a33d8866 scripts/test_compression.py -61e6af8cbca6894e32c0b93f945087d40100e4ca4112249306ea7bbeea8ccf3a scripts/upgrade.py +bc1181d42e745dd904ff6f10bb698f408422b696873d589d020d1b5fe726c9d4 scripts/upgrade.py 081aad397b2b9485e20b684dc11682d3a16464c5b4f110cd461f8518eb74ad99 src/acl.rs 2d1ffc1c64e76c647abd3134719fb561b1f27d584c92d867011d8de7dd7f6662 src/acme.rs fdba60fa997aa18cb0a7ef50e286a8b6fd877d14fe829d4b0bcb5c7c269a5ae6 src/admin/documentation.rs -44d4d26ac04cd9fb15f73ff0dffb8d7fc76062fda2ab4c3eb4e6c7172085877f src/admin/mod.rs -6a78b88b4bb20457bb72b4d01157d596fa498432f916de37fc4ee9a38fa8267e src/admin/openapi.json +3299668f6792805b648536357f90ddf059ebbb7569440ee766d8d130aaad7552 src/admin/mod.rs +d1e2f452d4e08510aec4e276f058f5ab55597d6bc9cb8fdb7dbb67386f56c700 src/admin/openapi.json b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs 23c55f23deeec844a9abc2289440c9e0583aaeb72416fc5f3632a43a864c0efd src/admin/web/acme.js -cab34b79502b15c8c5b6e6b7ca0d0b7389179e75906053c8c1daa2ac29b343da src/admin/web/app.js -5412033412495e84e62415413dce8a65dc01db72dfbf56e50253c9d7cbfd37e2 src/admin/web/cache-panel.js +614da2851f76bccca8ff3c57de944bc68407a647af4d4112479bab7a33b0ed4c src/admin/web/app.js +d0be71555de13f732fd6ee44438a0110eb08c91e78bbc1aa36a1a84182780210 src/admin/web/cache-panel.js 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html 3b84a7a08f9f68e2c78fed4523b594c31a251addc5480a69c5f6949908230539 src/admin/web/docker.js 4b8a6c0e4c3870432354c88e6248655faa6072cc1f8d0c6906aa970174ec245d src/admin/web/docs.css @@ -157,7 +160,7 @@ f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg dee3b113907f77138cdf919efdfb097e45b889cff3893bfc6f02ed998643bbae src/admin/web/host404.js -4768f187f04b41e6423bd83e4f596e084eb1c1ac6592c422d5f2e034fb4fea96 src/admin/web/index.html +4d749fdb9a4f3ec8095253e1e288a08117d293a8945a9ab0e2395ec69b24c49a src/admin/web/index.html 24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js cdab0c11a28deebdf401c0453fbc76b675e9e643722106fb20e812bb9648ccde src/admin/web/login-keys.json a253d202dc9a147f2add34c64e0f35c38fdc7f667f9908933ce5f959b1a92a2e src/admin/web/login.html @@ -166,30 +169,30 @@ e41619e5cb0ba01a3d287748f28eb38bbe7641f9bedade252d286c18e619c400 src/admin/web/ 22318f39f7bb1ae98e65737d3c2ad9e63065b8e94ad59bed560c0e80d3a46949 src/admin/web/redirects.js dafc8b023f334091c7b5a04920038bce46ea3ef4b068e1f88cffe49d44eaeeee src/admin/web/settings.js 1a30affd6a717095a1763998a5850a79ae0f86b7b694806f2aba2b880735dd1a src/admin/web/streams.js -a330875bda5dab02b15a5d0587d24e93380f4d694841d31e89d2d1f294542f82 src/admin/web/style.css -a6f8edf5cd4d5b3485b5ea687f82e3a3b57b5be40c136026e7ad00b5e180891d src/admin/web/transform-panel.js -3b92b83803a309809b0321ba6575dbbb4fa8a133235a2de1fecdba0b3f553103 src/admin/web/vhosts.js +e5f300b6456a680d1cd80e4adec7127e01e06a4bed09a6ac4df73f614e765c20 src/admin/web/style.css +049be02e0f68c3f6702f3bf29be760c4e5bc1199393ed198107c0a264711300d src/admin/web/transform-panel.js +baeb8c6cdf9c365d1be5376d01e66231e1cdc77725741c72eb8c3bc07fe740e7 src/admin/web/vhosts.js 1e5c6c6157f8b66d90751a0480cb90c6fdfa229330fc4c3f3504bc0c689c3d60 src/auth/files.rs 606f5491f94da007881a679051677a4609881d3151ac753de02556e0e76e1ce8 src/auth/mod.rs 1ceb57d97cbff655f185e126d98b8dc7884c7aac865e1f70e3c6d199685c13a7 src/auth/store.rs 168f55f75ad6ebd80f98c3376f5885cdc8d313f8b03f4e93066a28cf7b8c8b81 src/balance.rs e2bf9d4f8a8c8c7f9a8df96872a657b9706d05f3f3a83721fe912ca0af37b29a src/basic_auth.rs -075b65ac7278fe11481855e844b8ff0654162e8b3e4194e8e3fbc1c0a1d05767 src/cache/cold.rs -1f7073115229bbfb1dd7ea487e1cd97e5815cb1767e068fda7bcb2c8c4b866c0 src/cache/collapse.rs -bdfbbebaeaac5301d775c9b2c9f1ea6505b667d6adeea5c4af542e4e0f61c502 src/cache/dashboard.rs +e3033cb43bd6bb0f3bae86a906fab5cf0901c88df334b10baa52b7d13402e375 src/cache/cold.rs +d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs +27a35689fef639f1aea00d9f5a86adc981c0c304dc3e28e6da40494dffbb6bfa src/cache/dashboard.rs bbd6de8568fea79cc4a11d33b905af75713e609c8f37ec943c82f0d180e66dcc src/cache/fill.rs 9110e0662022da6a0518bfbf93872143e8ecafb0f541c0d30a0288ad09fc5883 src/cache/invalidation.rs -5cf902ffa805f4c5296fe006dc4d0a3c7e03ff758a68480ba0a4b6e4af9e5fb3 src/cache/lifecycle.rs +1a422ab8dbc20306cd9aa0cb5442cd0febc6fc51a130dfd51c50fc84518a5254 src/cache/lifecycle.rs 8c451cf8008bdb9db19cbd6b45d1352f9c3790a1cd3a6b2341b21789fea9ffb2 src/cache/lookup.rs 58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs -61c95d48cc6a3b1d8619030ce34c2a3c340c09af50f3e05840d8f69564d348fc src/cache/model.rs +022bc07c5f63d5b8812056ecb239eb6293b5547752d705e3f268f284da64bb25 src/cache/model.rs cae9d35be0e31b5f942e3d3b20e7496581ec6491d7974509ece4cf540271b1f5 src/cache/policy.rs 7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs -09633b26d089b5a7944262b1d175c17c99de3d1704a03aac5344b4a29f3f279c src/cache/tests.rs -9874abc56a3f38bd06f663965588b709d2a8d29e2317dd3dd898c5aaf9460209 src/cache/tickets.rs +b20e2643084ac7f72f80c7f8fb8cf7ac4e16ee3d83d2f73767fcbb3e0ffe3cf8 src/cache/tests.rs +bc7f52d55c707fbb409306eadd9aa31115e823590f81b24f68a45264a6f6c86e src/cache/tickets.rs 5d1788e6da0669546729e7bea56c64f3b989d399793e67d34fdbf427a6e0c65b src/compression.rs 09279a1f5e6e97ca6f856081bc1e710ae0b57b708bc6a62157741a5ace2fdfbb src/config.rs -d465a0a7fd36bbd6a59cb6e551291530c16697865583cb038bc72ff0673d3240 src/db.rs +a05a72ce53bebf5a060c55d0259bbef21dcd1b96808b4934852edc79486ad2ad src/db.rs 19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs f93f0b1fdb894b29a271fc6f88ccc0afbd5c847ba3297cad3709858e17d05292 src/diagnostics.rs d7c1093b62dd37479dbefd1bc9f18d2fbc0ee5d4ebe155f7a7bbaccd058b1fd3 src/docker.rs @@ -199,7 +202,7 @@ eff4484fd9630009471f824c57c5c74f3d3c5378f9e46a3bdda03ede37daec82 src/geoip.rs 2f6dd8c74fe1d22a8fa4ec5a21d0d334a667ede1b11629541f4802252765b21b src/http3.rs a6630962453c7e68a409aac45da51093148b9b3c8d044ab13897a8c9e476ae4d src/lib.rs df14373ce3f9c5740d7501fdd4cb42d8b7b07b23ad86f2b69b5e534f0e0f01eb src/logging.rs -9125df29c30aac3812d8f6e8b8ee92c151ffc0ce344f334a5cbc81cd3ac3e1ef src/main.rs +f20fde5a4c5029cee8b87cfb5ab70a0d7fecceb2db4c98f8f349343aa293e344 src/main.rs 7022ec2e991e9906e30b06f5bca58f96b9d7e09cc92a27202b5196d54e341b75 src/metrics.rs 64494e5746912cfcb10612ea4e0b336004b1f738bd7999be7e58fc96abadb6e1 src/not_found.rs e4b1f61c12437cc840c36eb7ab7b69b03f310bca3f89288ade9992d9dd1b7e2c src/pages.rs @@ -210,7 +213,7 @@ f355d84c16cef89c43785fdc02181d6ad234fbc9c834367843822ff970088d00 src/prometheus 64665d778a90a22e93af7a17be037255d59fff3220d8924737da9be965acae69 src/proxy/websocket.rs 6b24aa692ed3cc7d20b561c90239e26e203f8e44f64d4bb45b8b63ba05906d4f src/rate_limit.rs acdca1bf861f95b85f08dbbcf69e8a2090335cab4d542f2509383bcc67a0e699 src/redirect.rs -d6be7744938707714f4e948fc62dd17119f93a068ffcde7537ac51aba945d32e src/runtime.rs +2f160ccabf87d345b00dbabc7863bf96e9839e346b2def70501a33cc2ccb1673 src/runtime.rs c403b63a4192e9549b8a82f29e6966fddd2130be1a9a86a895e7692168de98ea src/security.rs d35fbad33476b465d456dde8984daa4e52e34b9d546ab9e48dffcc40c74a4669 src/server.rs a80e8fc2990e62b8fd28426c8e1d213c9761577462179e96637d646ad3d87518 src/settings.rs @@ -219,12 +222,12 @@ d6f7d8c8e4155c931f037543fde258d0ef6354f7c829b6239e097fde69f07031 src/stream.rs 5dada498502763f3183c0c3758d1f6c5139366936fef4be585a018c4746a2c7b src/system_stats/mod.rs a026c13cc4757cb01bfd4595ac6b57a4d837482e40cd399e8d086a54700b2b97 src/tinyauth.rs 898553df5b2b416c4f0d9b7dc7706d9db5f79b9d12753354a1a1a7e45d48d159 src/tls.rs -f188257276292ae3ff683d1cc9162dd679728ef13247ba65cd498f00be489112 src/transform/css.rs -8b74590ef49d228c0c5959752215b5a8d376014436650f6b4ec647333930d713 src/transform/html.rs +3e47c2636251642dac1a777cfff18127abc7e208e19484fe7a8cb987ac293b95 src/transform/css.rs +1b82a3ebf89e4ba198ed202bc026862568a41579de44aa8444990594fe3b98c0 src/transform/html.rs 388f602176f152ef29a7c3ecd3ab328d6534b6d1eaae6e7c35a959b432232413 src/transform/js.rs -d51b1ada3cdfce44fccdff1e56ded76baff092c97572a27dc73174881ff37c25 src/transform/json.rs -d3120af52cf47ef07a18d10ffa4dcbe558f2ae38c1d738de8d471f7c6f4ec358 src/transform/mod.rs -6eb6e5b49b73ff8da615ca0fd1d871b87f2f1dc352b0587935ff0b602cecbcf7 src/transform/svg.rs +eca00dadae42aecfdcd918ec62b8603deb27caa20e1778befe211b57afa35001 src/transform/json.rs +e037ea5dd3639ab57258d1e70f1daaf9b80ba6f16d200da8deca134479074bbf src/transform/mod.rs +c0c150a8799942c53500e48b6c9d3068210d6e6ef5693514ee9602127f6e5b0c src/transform/svg.rs f255ed094a1cedf86f767bc062b1c589c5940a4f024274ca8238d2e83d0ede6c src/update_check.rs 77cabbcd9730e01d19e1147ede94272f6100d97281f7892d2b1bc0279c780f63 src/upstream.rs ea3ec51c0ffac30670cca8580374cbd1f67a792dfab2e6ece46aaf22c70636b1 src/vhost.rs diff --git a/debian/README.md b/debian/README.md index 56cd908..7ac5106 100644 --- a/debian/README.md +++ b/debian/README.md @@ -1,6 +1,6 @@ # Debian packaging -This directory builds the `rustproxy` binary package for RustProxy 0.24.5. +This directory builds the `rustproxy` binary package for RustProxy 0.24.7. The package installs: - `/usr/bin/rust-proxy` diff --git a/debian/build-package.sh b/debian/build-package.sh index 2afca00..fb14ffe 100755 --- a/debian/build-package.sh +++ b/debian/build-package.sh @@ -21,6 +21,6 @@ cargo fetch --locked dpkg-buildpackage -us -uc -b mkdir -p dist -find .. -maxdepth 1 -type f \( -name 'rustproxy_0.24.5_*.deb' -o -name 'rustproxy_0.24.5_*.changes' -o -name 'rustproxy_0.24.5_*.buildinfo' \) \ +find .. -maxdepth 1 -type f \( -name 'rustproxy_0.24.7_*.deb' -o -name 'rustproxy_0.24.7_*.changes' -o -name 'rustproxy_0.24.7_*.buildinfo' \) \ -exec cp -f {} dist/ \; echo "Packages copied to $ROOT/dist" diff --git a/debian/changelog b/debian/changelog index ab7393d..7690b20 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,17 @@ +rustproxy (0.24.7) stable; urgency=medium + + * Add configurable transform memory and concurrency budgets with persisted resource presets. + * Keep transform resource tuning fail-open and representation-aware cache compatibility safe. + + -- RustProxy Release Sat, 03 Oct 2026 08:49:00 +0200 + +rustproxy (0.24.6) stable; urgency=medium + + * Persist selected cache presets and add the Recommended COLD policy preset. + * Bound transform and cache background memory/task retention. + + -- RustProxy Release Sat, 03 Oct 2026 08:49:00 +0200 + rustproxy (0.24.5) stable; urgency=medium * Fix empty HTTP/3 request-body forwarding to HTTP/1.1 upstreams, including DELETE. diff --git a/dev.sh b/dev.sh index fc02fa1..36b8eeb 100755 --- a/dev.sh +++ b/dev.sh @@ -18,7 +18,7 @@ if [[ $# -gt 0 ]]; then shift; fi case "$mode" in help|-h|--help) cat <<'HELP' -RustProxy 0.24.5 +RustProxy 0.24.7 ./dev.sh Build and start proxy + local panel ./dev.sh run [serve flags] Same, with extra server options ./dev.sh admin setup-token Generate/print a one-time setup capability before first login @@ -90,6 +90,7 @@ if [[ "$mode" == "repair" ]]; then printf 'Source preflight: OK\n'; exit 0; fi if [[ "$mode" == "test" ]]; then python3 scripts/check_source.py python3 scripts/release_smoke.py + python3 scripts/memory_contracts.py fi if ! command -v cargo >/dev/null 2>&1; then cat >&2 <<'MISSING' @@ -122,7 +123,7 @@ case "$mode" in check) exec cargo check --locked --all-targets "$@" ;; release) exec cargo build --locked --release "$@" ;; esac -printf 'Building RustProxy 0.24.5...\n' >&2 +printf 'Building RustProxy 0.24.7...\n' >&2 cargo build --locked BIN="$CARGO_TARGET_DIR/debug/rust-proxy" case "$mode" in diff --git a/docker-agent/main.go b/docker-agent/main.go index 83bbdac..930635b 100644 --- a/docker-agent/main.go +++ b/docker-agent/main.go @@ -32,7 +32,7 @@ import ( "time" ) -const version = "0.24.5" +const version = "0.24.7" type config struct { Listen string diff --git a/docs/API.md b/docs/API.md index a3c598b..f2b92bc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -309,6 +309,12 @@ existing audit log. The `transform` configuration section is replaceable through the normal revision-CAS `PUT /api/config/transform` endpoint. Vhost objects carry a `transform` override and Docker assignments carry `transform_enabled`; both default to disabled per vhost. +Global transform settings also expose resource controls: `memory_budget_bytes` (1 MiB..2 GiB, +default 128 MiB), `max_body_bytes` (1 KiB..32 MiB, default 2 MiB), and `max_inflight` +(1..1024, default 32). `preset` is optional UI intent metadata with values +`conservative`, `recommended`, `performance`, or `uber`; numeric limits remain authoritative. +Resource pressure is fail-open: work that cannot reserve the configured memory/concurrency +capacity is left unchanged rather than queued behind full-body transformation work. `GET /api/transform` returns in-memory transformation observability for the administration Transform workspace: totals and per-vhost counters for examined/applied/skipped/error diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index f6da3d4..0304ecc 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -1,4 +1,4 @@ -# Administration API access - 0.24.5 +# Administration API access - 0.24.7 ## Sign-in boundary diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d8f4ac2..15066d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,7 +33,7 @@ committed document at startup; this is not one cross-memory/SQLite transaction. | `auth`, `admin` | Administrator lifecycle, local API, browser sessions, CSRF, SSE and static UI | | `db` | SQLite schema, migrations, desired-state persistence and revision checks | | `metrics`, `logging` | Aggregate instrumentation, bounded access ring and trace output | -| `transform` | Bounded decode/transform stage for eligible static identity representations before cache/compression | +| `transform` | Bounded decode/transform stage for eligible static identity representations before cache/compression, with live process-wide memory and concurrency ceilings | | `docker` | Desired Docker-agent settings, in-memory observations and ephemeral vhost reconciliation | | `runtime` | Shared process state, snapshot updates, admission limits and tunnel tracking | | `demo` | A separate opt-in demonstration backend with its own listener | diff --git a/docs/CACHE.md b/docs/CACHE.md index 904a271..bd06ce6 100644 --- a/docs/CACHE.md +++ b/docs/CACHE.md @@ -240,8 +240,10 @@ existing deduplication guard. ## Diagnostics and API `GET /api/cache` reports settings, HIT/MISS/BYPASS, hot/cold counts and bytes, -promotions/demotions, errors, fill pressure, object views and per-domain reason counters. -Hit ratio is `hits / (hits + misses)` and intentionally excludes BYPASS traffic. +configured COLD byte/object limits, promotions/demotions, errors, fill pressure, object +views and per-domain reason counters. The overview dashboard shows COLD disk occupancy +separately from RAM accounting. Hit ratio is `hits / (hits + misses)` and intentionally +excludes BYPASS traffic. `POST /api/cache/purge` requires the existing administrator session, same-origin marker and CSRF token. `?vhost=` purges only that vhost scope; omitting it purges all cache diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 8f5df75..07cb5c8 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,4 +1,4 @@ -# Installing and updating RustProxy 0.24.5 +# Installing and updating RustProxy 0.24.7 The scripts prepare a Linux/systemd installation. This source archive is **not a verified production build**: Rust/Cargo, real CA operations, live systemd startup diff --git a/docs/README.md b/docs/README.md index 535d731..1ef02f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ This index separates current operational documentation from historical/version-l - [PROMETHEUS.md](PROMETHEUS.md), [HTTP3.md](HTTP3.md), [STREAMS.md](STREAMS.md) - monitoring and transport features. - [SECURITY.md](SECURITY.md) - current security model and limitations. - [TEST_REPORT.md](TEST_REPORT.md) - release verification status and explicit environment limitations. +- [0.24.7 transform resource-control audit](V0.24.7_AUDIT.md) - configurable transformation memory/concurrency budgets and resource presets. +- [0.24.6 memory/preset audit](V0.24.6_AUDIT.md) - persisted cache presets, transformation memory bounds and background-worker memory/task review. - [0.24.5 specification audit](V0.24.5_AUDIT.md) - HTTP/3 empty-request-body normalization for HTTP/1.1 upstreams and DELETE regression matrix. - [0.24.4 specification audit](V0.24.4_AUDIT.md) - process-local Linux THP mitigation and memory-retention verification matrix. - [0.24.3 specification audit](V0.24.3_AUDIT.md) - memory-retention, H3 settings placement and dynamic diagnostics source-vs-spec matrix. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 3add57d..41eb069 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,23 +1,21 @@ -# Verification report - RustProxy 0.24.5 +# Verification report - RustProxy 0.24.7 ## Scope -RustProxy 0.24.5 is a protocol-framing and cache-memory maintenance release over 0.24.4. It keeps the 0.24.4 process-local THP mitigation, fixes the H3 -> H1 boundary for actually empty request bodies whose length is initially unknown, and hardens the existing HOT RAM -> COLD disk cache lifecycle without changing the COLD storage format. +RustProxy 0.24.7 makes transformation memory/concurrency limits configurable and adds persisted RAM saver, Recommended, Performance and Uber performance resource presets. The same version now exposes live transform resource usage, overview process/cache RAM accounting and COLD disk occupancy. It retains the 0.24.6 cache/background-worker memory hardening. ## Source contracts reviewed -- `ReceiveBody` in `src/http3.rs` reports end-of-stream only after the H3 DATA side reaches EOF and trailers are resolved; absence of `Content-Length` alone is not treated as proof of an empty body. -- `normalize_h3_h1_unknown_body()` runs only for HTTP/3 requests selected for an HTTP/1.1 upstream and only when no declared/exact body length is already available. -- EOF replaces the request body with `Body::empty()`. Non-empty first frames are replayed through `ReplayBody` and the remaining request body stays streaming. -- HTTP/2 upstreams, non-H3 downstreams and declared `Content-Length` requests are not pre-read by this normalization. -- The change is generic to body-capable methods; there is no Proxmox-specific or DELETE-to-H2 fallback. -- Published HOT objects are bounded by `max_bytes` using body backing capacity plus directly owned metadata and a conservative HOT-index charge; logical payload bytes are reported separately. -- Fill buffers reserve a bounded transient budget before growth, enforce `max_object_bytes` per chunk, and make HOT LRU room before further cache-buffer allocation. Failure leaves the response streaming uncached. -- HOT -> COLD demotion moves the `Entry` rather than cloning the payload. The `Bytes` allocation remains charged to transient RAM while queued/writing and disappears from the persisted `ColdEntry` after the write completes. -- The COLD writer queue is bounded by both object count and bytes; full COLD reads/promotions/revalidation reads reserve transient RAM before loading a body. -- Global `cache.enabled=false` is authoritative over vhost overrides. HOT/object values no longer have the old artificial 1 GiB configuration ceiling. -- Settings exposes conservative/recommended/performance presets derived from effective host/cgroup memory, plus non-blocking high-budget/headroom and inconsistent transient/object/queue warnings. A 2 GiB effective-memory VM gets approximately 400 MiB HOT / 96 MiB transient / 16 MiB max object / 48 MiB COLD queue under the recommended profile. -- Release identity is 0.24.5 across Cargo/package/UI/OpenAPI/release helpers. The existing dependency graph is unchanged; only the root `rust-proxy` package version changes. +- Transform `preset`, `memory_budget_bytes` and `max_inflight` are part of the normal revisioned Settings document and therefore persist in SQLite; the reopen test covers them. +- Existing configurations default to 128 MiB transform temporary memory and 32 concurrent attempts. +- New transform attempts use atomic runtime byte/concurrency reservations and fail open with fixed `memory_pressure` / `concurrency_pressure` reasons rather than queueing full-body work. +- Presets modify only resource limits; transform type switches and extension allow-lists remain independent. +- Global transform representation/eligibility settings participate in cache compatibility identity. Resource-only preset/budget/concurrency fields do not. +- OpenAPI and both language packs expose the new controls and preset states. +- `GET /api/status` exposes `transform_resources` and `memory_accounting`; the latter keeps process RSS separate from HOT/transient cache accounting and treats transform bytes as an overlapping conservative reservation. +- Cache runtime telemetry exposes COLD byte/object limits so the overview can show disk occupancy against configured capacity without mixing disk and RAM. +- `GET /api/transform` exposes current process-wide reserved transform bytes and active slots alongside the existing counters. +- 0.24.6 aggregate/decode bounds, allocation reductions and bounded cache/background workers remain in place. ## Verification status in this workspace @@ -25,28 +23,17 @@ RustProxy 0.24.5 is a protocol-framing and cache-memory maintenance release over |---|---| | `python3 scripts/preflight.py` | PASS | | `python3 scripts/langs_check.py` | PASS | -| Browser JavaScript `node --check` | PASS | +| `python3 scripts/memory_contracts.py` | PASS | +| Browser JavaScript `node --check` | PASS for all administration JS bundles | +| OpenAPI/language JSON parse | PASS | | Shell `bash -n` | PASS | | Python syntax / bytecode compile | PASS | | Docker agent `go test ./...` / `go vet ./...` / `go build ./...` | PASS | -| Existing `Cargo.lock` dependency graph | PRESERVED; root package version updated to 0.24.5 | | `python3 scripts/check_source.py` | PASS | | `python3 scripts/release_smoke.py` | PASS | | `./dev.sh fmt` / `cargo fmt --all` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace | | `./dev.sh check` / `cargo check --locked --all-targets` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace | | `./dev.sh test` Rust phase | BLOCKED: Rust/Cargo toolchain unavailable in this workspace | -| H1/H2/H3 live method matrix | test-host acceptance gate | -| Proxmox DELETE H3 access-log verification | test-host acceptance gate | -| HOT 8 MiB sustained-load/RSS acceptance | test-host acceptance gate | -| HOT 2 GiB bounded-admission acceptance | test-host acceptance gate | -| 5 s HOT -> COLD demotion/RSS/transient drain | test-host acceptance gate | -| Oversized-object streaming without full cache buffering | source regression added; runtime acceptance gate remains | -| Concurrent COLD read/write under small transient budget | test-host acceptance gate | +| Sustained per-preset transform RSS/heap profiling | release-host acceptance gate | -## Protocol acceptance target - -Run GET/POST/PUT/PATCH/DELETE over H1, H2 and H3. For DELETE, test both an empty body and a body-bearing request (use a mock upstream if the chosen Proxmox endpoint does not accept a DELETE body). The original H3 empty DELETE must no longer produce `501`; H1/H2 behavior must remain unchanged; Proxmox must receive `DELETE` and the same URI; and a raw HTTP/1.1 upstream must show non-chunked zero-length framing for the empty H3 case. - -For cache acceptance, test a small 8 MiB HOT tier under sustained traffic, a deliberately large 2 GiB HOT tier, `cold_after_seconds=5`, a response above `max_object_bytes`, and concurrent COLD promotion/demotion under a small transient limit. Published `hot_allocated_bytes` must remain within the HOT limit; transient/queue usage must drain after COLD persistence; large responses must keep streaming after their cache candidate is rejected; and RSS must plateau rather than track total traffic volume. - -See [V0.24.5_AUDIT.md](V0.24.5_AUDIT.md) for the source/spec matrix and remaining runtime gates. +See [V0.24.7_AUDIT.md](V0.24.7_AUDIT.md) for the resource-control design and remaining runtime acceptance gate. diff --git a/docs/TRANSFORM.md b/docs/TRANSFORM.md index c10bec5..b889732 100644 --- a/docs/TRANSFORM.md +++ b/docs/TRANSFORM.md @@ -16,10 +16,14 @@ statistics globally or per vhost: - average transform time; - applied transforms by JavaScript/CSS/HTML/SVG/JSON; - bounded skip/fail-open reason counters; +- current process-wide temporary-memory reservation versus the configured budget; +- current active transform slots versus the configured concurrency limit; - a per-vhost effectiveness table. Counters reset on process restart. They are diagnostic telemetry, not persisted cache -metadata. The Cache workspace separately marks stored transformed objects. +metadata. Resource usage is process-wide even when a vhost counter scope is selected. The +reservation is the transform limiter's conservative buffer estimate, not a second RSS +measurement. The Cache workspace separately marks stored transformed objects. ## Cache diagnostics diff --git a/docs/V0.24.6_AUDIT.md b/docs/V0.24.6_AUDIT.md new file mode 100644 index 0000000..1764a39 --- /dev/null +++ b/docs/V0.24.6_AUDIT.md @@ -0,0 +1,57 @@ +# RustProxy 0.24.6 memory and cache-preset audit + +## Scope + +0.24.6 persists cache-preset intent and hardens temporary/background memory use. The cache numeric limits remain the runtime source of truth; `memory_preset` and `cold_preset` are metadata stored in the same revisioned JSON configuration row in SQLite so the administration UI can restore the selected state after reload. + +This audit is source/static in the packaging workspace because Rust/Cargo and heap profilers are unavailable here. It adds compiler/runtime tests to the source tree and an offline memory-contract gate, but sustained RSS/heap acceptance must still run on a compiler-enabled release host. + +## Cache preset persistence + +| Area | Contract | +|---|---| +| Cache model | `Settings.memory_preset` accepts conservative/recommended/performance; `Settings.cold_preset` accepts recommended; old configurations deserialize both as `None`. | +| SQLite | `Store::replace` serializes the complete cache settings into `configuration.document`; the database reopen test now verifies both preset values survive close/reopen. | +| Cache identity | Preset metadata is excluded from `cache_revision()`, so changing only the selected label cannot purge HOT/COLD data. Effective numeric/policy changes still rotate cache identity normally. | +| UI | Settings restores the selected button/badge from configuration. Manual edits clear the affected preset metadata. | +| Recommended COLD | Enables COLD, idle/LRU/shutdown demotion, disables age demotion, sets 300 s idle, 3600 s max-hot, 60 s GC and 256 max-delete, and chooses a queue around half transient RAM. Disk path, disk bytes and entry/object limits are preserved. | + +## Transformation memory review + +| Path | 0.24.6 behavior | +|---|---| +| Concurrent transforms | A process-wide 128 MiB semaphore budget accounts for collected input plus expected decoded/output buffers. Transform is fail-open with `memory_pressure` when the budget is busy. | +| Response aggregation | `http_body_util::Limited` enforces `max_body_bytes` even when upstream `Content-Length` is false; over-limit aggregation becomes a body error instead of allocating without bound. | +| Decompression | Fixed 16 KiB reads and exact bounded reserve checks reject expansion beyond `max_body_bytes`. | +| JSON | `IgnoredAny` validates syntax without materializing `serde_json::Value`; whitespace compaction is one pass and preserves number lexemes/key order. | +| HTML | Raw element detection compares tag slices case-insensitively instead of allocating/lowercasing a tag name for every element. | +| CSS | Tracks the last output character instead of repeatedly scanning the full output string. | +| Large-buffer release | Existing glibc `malloc_trim(0)` throttling remains after successful large transforms. | + +## Background-worker memory/task review + +| Facility | Bound/ownership reviewed | +|---|---| +| Cache COLD writer | Existing object-count and byte budgets remain; delete fallback is limited to 2 detached tasks and otherwise applies bounded synchronous backpressure. | +| COLD cleanup | Manual/automatic cleanup share a single-flight semaphore. Candidate selection uses a bounded heap sized to the requested deletion limit; delete path metadata is flushed per 32-entry batch. | +| Stale-while-revalidate | Distinct background revalidations are capped at 8, limiting retained cached `Bytes` when objects are concurrently evicted/replaced. | +| Proactive refresh | Sleeping scheduled-key tasks are capped at 64..256 depending on configured refresh concurrency; actual refreshes keep the existing `proactive_refresh_max_inflight` bound. | +| Logging | History and broadcast buffers are fixed at 4096; sink delivery uses a bounded channel and `try_send`. | +| Diagnostics | One cancellation-aware sampler loop; each blocking sample is awaited before the next iteration. | +| DNS | Shared semaphore of 16 blocking resolver workers plus timeout; a timed-out OS resolver still owns its permit until the worker exits. | +| Backend health | One supervisor with a `JoinSet`, maximum 16 probes, at most one claimed probe per member, abort/drain on shutdown. | +| TCP/UDP streams | Per-stream connection/session semaphore, bounded UDP channel, `JoinSet` ownership and cancellation. | +| HTTP listeners | Accepted connections are gated by `max_connections`; connection tasks are owned by the listener `JoinSet`. App-owned tunnel/background tasks use `TaskTracker`. | +| Configuration writes | Validation/publication work is bounded by 8 update slots and serialized publication lock. | +| ACME | Issuance/renewal is single-flight through a semaphore; the scheduler is one cancellation-aware loop. | +| Docker discovery / update checker | One cancellation-aware periodic loop each; no per-tick detached task accumulation. | +| Admin live streams | Viewer permits and bounded broadcast/snapshot semantics prevent per-client unbounded queues. | +| Tunnel registry | Stores weak references and removes dead sessions on drop/list cleanup. | + +## Added regression coverage + +Rust tests cover preset serialization/database reopen, preset-only cache identity stability, transform memory-budget math, bounded decompression, false Content-Length aggregation, proactive pending-task limits, stale revalidation limits and single-flight COLD cleanup. `scripts/memory_contracts.py` independently verifies the source-level bounds above when Cargo is unavailable and is part of `./dev.sh test`. + +## Release-host acceptance gate + +Run `./dev.sh fmt`, `./dev.sh check` and `./dev.sh test`, then exercise representative transform/cache traffic while recording RSS/heap. Acceptance target: RSS reaches a plateau after warm-up; transform concurrency never exceeds the 128 MiB temporary budget estimate; COLD cleanup and revalidation counts remain bounded; no monotonically growing task/map/queue is observed after traffic stops. diff --git a/docs/V0.24.7_AUDIT.md b/docs/V0.24.7_AUDIT.md new file mode 100644 index 0000000..f5ad45c --- /dev/null +++ b/docs/V0.24.7_AUDIT.md @@ -0,0 +1,76 @@ +# RustProxy 0.24.7 transform resource-control audit + +## Scope + +0.24.7 makes the transformation resource envelope configurable without changing transformation semantics. The global transform configuration now persists a UI resource preset, a process-wide temporary-memory ceiling, and a process-wide concurrent-transform ceiling. Existing configurations inherit the former 128 MiB memory behavior and a 32-attempt concurrency ceiling through serde defaults. + +This workspace still has no Rust/Cargo toolchain, so compiler/type/runtime acceptance and sustained RSS profiling remain release-host gates. + +## Resource controls + +| Control | Default | Range | Behavior | +|---|---:|---:|---| +| `memory_budget_bytes` | 128 MiB | 1 MiB..2 GiB | Process-wide ceiling for estimated origin/decode/output buffers. It is not pre-allocated. If a new transform would exceed the current configured ceiling, the response fails open unchanged with `memory_pressure`. | +| `max_body_bytes` | 2 MiB | 1 KiB..32 MiB | Hard aggregation/decompression ceiling for one transformable response. | +| `max_inflight` | 32 | 1..1024 | Process-wide active transform-attempt ceiling. New work fails open unchanged with `concurrency_pressure` instead of waiting. | + +The memory limiter uses atomic byte reservations so a live configuration change takes effect for new attempts without resizing or replacing a semaphore. Existing reservations release their exact charge on drop. + +## Presets + +Presets persist only administrator intent; numeric values are authoritative. They do not change enabled transform kinds or the extension allow-list. + +| Preset | Memory | Max body | Max concurrent | +|---|---:|---:|---:| +| RAM saver | 32 MiB | 1 MiB | 8 | +| Recommended | 128 MiB | 2 MiB | 32 | +| Performance | 512 MiB | 8 MiB | 64 | +| Uber performance | 1 GiB | 32 MiB | 128 | + +Manual edits of any of the three resource fields clear the preset label to Custom. Settings stores the selected preset in the same revisioned SQLite configuration document and restores it after reopen. + +## Cache compatibility + +Global transform representation/eligibility settings are now included in cache compatibility identity. Changing enabled transform kinds, extensions or `max_body_bytes` rotates the identity so cached representations cannot outlive a semantic policy change. + +Resource-only fields (`preset`, `memory_budget_bytes`, `max_inflight`) are normalized out of the compatibility hash. Capacity tuning therefore does not purge HOT/COLD objects by itself. + +## UI safeguards + +The Transform settings page exposes all three resource controls and four presets. It warns when the configured temporary-memory budget is smaller than the worst-case estimate for one maximum-size compressed response, and when the budget exceeds 50% of detected effective host/cgroup RAM. Warnings do not block an intentional expert configuration. + +## Runtime dashboard accounting + +The same 0.24.7 release now exposes live resource accounting without changing any +resource limit. `GET /api/status` reports a `transform_resources` view and a +`memory_accounting` view. The Overview Process block shows process RSS, HOT cache RAM, +cache transient RAM, the transform reservation/budget, active transform slots and the +remaining non-cache RSS. The transform reservation is an intentionally conservative +limiter charge, so it is shown as an overlapping diagnostic rather than added to RSS. + +`non_cache_rss_bytes` is `process RSS - accounted HOT/transient cache RAM` with saturating +subtraction. It therefore still includes transform buffers, connections, TLS, logging, +Tokio/runtime state, allocator overhead and any other untracked process allocations. This +is preferable to inventing per-subsystem heap precision that the allocator does not +provide. + +The cache runtime view now also exposes `cold_limit_bytes` and `cold_limit_entries`; the +overview cache block shows current COLD disk bytes and objects against those limits. COLD +is explicitly disk occupancy and is never added to process RAM. + +The dedicated Transform dashboard also shows current process-wide reserved bytes versus +`memory_budget_bytes` and active transform attempts versus `max_inflight`. + +## Regression coverage + +- transform settings validation and serde preset round-trip; +- local atomic memory reservation obeys a runtime byte ceiling; +- local atomic inflight reservation obeys a runtime concurrency ceiling; +- SQLite close/reopen preserves transform preset, memory budget and concurrency limit; +- cache identity ignores resource-only changes but rotates for transform representation/eligibility changes; +- `scripts/memory_contracts.py` verifies the new dynamic memory/concurrency bounds without Rust; +- source/release smoke checks verify the Transform UI, OpenAPI fields and release identity. + +## Release-host acceptance gate + +Run `./dev.sh fmt`, `./dev.sh check` and `./dev.sh test`, then stress each preset with identity and compressed JS/CSS/HTML/SVG/JSON bodies. Acceptance target: RSS plateaus after warm-up, active transform work never exceeds the configured byte/concurrency ceilings, `memory_pressure`/`concurrency_pressure` appear only when expected, and cache contents remain valid after changing resource-only settings. diff --git a/grafana/rustproxy-overview.json b/grafana/rustproxy-overview.json index 2f7042d..56f9b08 100644 --- a/grafana/rustproxy-overview.json +++ b/grafana/rustproxy-overview.json @@ -3672,7 +3672,7 @@ }, "timepicker": {}, "timezone": "browser", - "title": "RustProxy 0.24.5 - Operations", + "title": "RustProxy 0.24.7 - Operations", "uid": "rustproxy-0170", "version": 2, "weekStart": "" diff --git a/langs/en_US.json b/langs/en_US.json index 336a6de..1d196f1 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -1218,15 +1218,25 @@ "processRssHistory": "Process RSS history", "cacheMemoryBreakdown": "Hot / transient cache RAM", "cacheTransientBreakdown": "Fill / cold queue / cold I/O", + "cacheColdStorageUsage": "COLD storage / budget", + "cacheColdStorageObjects": "objects", "cacheTransientMemory": "Transient cache RAM", "cacheFillShort": "fill", "cacheQueueShort": "cold queue", "cacheIoShort": "cold I/O", "processRssTotal": "RSS total", + "memoryCacheHot": "HOT cache RAM / limit", + "memoryCacheTransient": "Cache transient RAM / limit", + "memoryTransform": "Transform reserved RAM / budget", + "memoryTransformInflight": "Active transforms / limit", + "memoryNonCacheRss": "Non-cache process RSS", + "transformMemoryUsage": "Transform RAM reservation", + "transformConcurrencyUsage": "Active transform slots", + "transformBudgetShort": "budget", "processRssAnon": "Anonymous RSS", "processRssFile": "File-backed / shared RSS", "processDataSwap": "Data address space / swap", - "memoryAccountingHint": "OS RSS categories and RustProxy cache accounting are overlapping views; do not add them together.", + "memoryAccountingHint": "RSS is the real process footprint. HOT/transient cache values are RustProxy-accounted allocations; transform memory is a conservative active reservation. Non-cache RSS is RSS minus accounted cache and includes transforms, connections, TLS, logging, Tokio/runtime state, allocator overhead and other buffers, so these rows are not all additive.", "logTransport": "Transport", "logWebSocket": "WebSocket", "logUpstreamError": "Upstream error", @@ -1469,6 +1479,18 @@ "transformJson": "JSON compaction", "transformExtensions": "Allowed file extensions, one per line", "transformMaxBody": "Maximum transformable response (KiB)", + "transformResourcePresetTitle": "Resource preset", + "transformResourcePresetHelp": "Presets tune the temporary memory ceiling, maximum transformable response size and global concurrent transformation limit. Content types and extension allow-lists are left unchanged.", + "transformPresetConservative": "RAM saver", + "transformPresetRecommended": "Recommended", + "transformPresetPerformance": "Performance", + "transformPresetUber": "Uber performance", + "transformMemoryBudget": "Temporary transform memory budget (MiB)", + "transformMaxInflight": "Maximum concurrent transformations", + "transformMaxInflightShort": "concurrent", + "transformResourceCurrent": "Current limits", + "transformBudgetTooSmallWarning": "The memory budget is smaller than the worst-case temporary buffers for one maximum-size compressed response. Such responses can be skipped with memory_pressure.", + "transformBudgetHighWarning": "The transform memory ceiling is more than 50% of effective host/cgroup RAM:", "transformSafety": "Cache-Control: no-transform is always respected. Transformed representations receive a new ETag and are cached before gzip, Brotli or zstd encoding.", "saveTransform": "Apply transformation policy", "vhTransform": "Transform", @@ -1652,6 +1674,10 @@ "cachePresetConservative": "Conservative", "cachePresetRecommended": "Recommended", "cachePresetPerformance": "Performance", + "cachePresetSelected": "Selected", + "cachePresetCustom": "Custom", + "cacheColdPresetTitle": "COLD policy preset", + "cacheColdPresetHelp": "The recommended policy enables idle/LRU/shutdown demotion, sizes the COLD queue from transient RAM and uses conservative GC timings. Disk path, disk budget and object limit are left unchanged.", "cacheTransientLimit": "Transient RAM budget (MiB)", "cacheEvictionLimit": "LRU removals per admission", "cacheColdQueueLimit": "COLD queue RAM budget (MiB)", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index ab2a78e..d97342f 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -1218,15 +1218,25 @@ "processRssHistory": "Historia RSS procesu", "cacheMemoryBreakdown": "Cache hot / pamiฤ™ฤ‡ przejล›ciowa", "cacheTransientBreakdown": "Fill / kolejka cold / I/O cold", + "cacheColdStorageUsage": "Zajฤ™toล›ฤ‡ COLD / limit", + "cacheColdStorageObjects": "obiektรณw", "cacheTransientMemory": "Pamiฤ™ฤ‡ przejล›ciowa cache", "cacheFillShort": "fill", "cacheQueueShort": "kolejka cold", "cacheIoShort": "I/O cold", "processRssTotal": "RSS ล‚ฤ…cznie", + "memoryCacheHot": "RAM cache HOT / limit", + "memoryCacheTransient": "RAM przejล›ciowy cache / limit", + "memoryTransform": "RAM zarezerwowany przez transformacje / budลผet", + "memoryTransformInflight": "Aktywne transformacje / limit", + "memoryNonCacheRss": "RSS procesu poza cache", + "transformMemoryUsage": "Rezerwacja RAM transformacji", + "transformConcurrencyUsage": "Aktywne sloty transformacji", + "transformBudgetShort": "budลผet", "processRssAnon": "RSS anonimowy", "processRssFile": "RSS plikรณw / wspรณล‚dzielony", "processDataSwap": "Obszar danych / swap", - "memoryAccountingHint": "Kategorie RSS systemu i pomiary cache RustProxy czฤ™ล›ciowo siฤ™ pokrywajฤ…; nie naleลผy ich sumowaฤ‡.", + "memoryAccountingHint": "RSS pokazuje rzeczywisty ล›lad pamiฤ™ci procesu. HOT i pamiฤ™ฤ‡ przejล›ciowa cache sฤ… ksiฤ™gowane przez RustProxy, a pamiฤ™ฤ‡ transformacji jest konserwatywnฤ… rezerwacjฤ… aktywnej pracy. RSS poza cache to RSS pomniejszony o ksiฤ™gowany cache i obejmuje m.in. transformacje, poล‚ฤ…czenia, TLS, logowanie, Tokio/runtime, narzut alokatora i inne bufory, wiฤ™c nie wszystkie wiersze naleลผy sumowaฤ‡.", "logTransport": "Transport", "logWebSocket": "WebSocket", "logUpstreamError": "Bล‚ฤ…d upstreamu", @@ -1469,6 +1479,18 @@ "transformJson": "Kompaktowanie JSON", "transformExtensions": "Dozwolone rozszerzenia plikรณw, po jednym w linii", "transformMaxBody": "Maksymalny rozmiar transformowanej odpowiedzi (KiB)", + "transformResourcePresetTitle": "Preset zasobรณw", + "transformResourcePresetHelp": "Presety ustawiajฤ… limit pamiฤ™ci tymczasowej, maksymalny rozmiar transformowanej odpowiedzi i globalny limit rรณwnolegล‚ych transformacji. Typy treล›ci i lista rozszerzeล„ pozostajฤ… bez zmian.", + "transformPresetConservative": "Oszczฤ™dny RAM", + "transformPresetRecommended": "Rekomendowany", + "transformPresetPerformance": "Wydajnoล›ฤ‡", + "transformPresetUber": "Uber wydajnoล›ฤ‡", + "transformMemoryBudget": "Budลผet pamiฤ™ci tymczasowej transformacji (MiB)", + "transformMaxInflight": "Maksymalna liczba rรณwnolegล‚ych transformacji", + "transformMaxInflightShort": "rรณwnolegle", + "transformResourceCurrent": "Aktualne limity", + "transformBudgetTooSmallWarning": "Budลผet pamiฤ™ci jest mniejszy niลผ pesymistyczny koszt buforรณw dla jednej maksymalnej skompresowanej odpowiedzi. Takie odpowiedzi mogฤ… zostaฤ‡ pominiฤ™te z powodem memory_pressure.", + "transformBudgetHighWarning": "Limit pamiฤ™ci transformacji przekracza 50% efektywnej pamiฤ™ci RAM hosta/cgroup:", "transformSafety": "Cache-Control: no-transform jest zawsze respektowane. Przetransformowana reprezentacja otrzymuje nowy ETag i jest zapisywana do cache przed gzip, Brotli lub zstd.", "saveTransform": "Zastosuj ustawienia transformacji", "vhTransform": "Transformacja", @@ -1652,6 +1674,10 @@ "cachePresetConservative": "Oszczฤ™dny", "cachePresetRecommended": "Zalecany", "cachePresetPerformance": "Wydajnoล›ciowy", + "cachePresetSelected": "Wybrany", + "cachePresetCustom": "Wล‚asny", + "cacheColdPresetTitle": "Preset polityki COLD", + "cacheColdPresetHelp": "Preset zalecany wล‚ฤ…cza przenoszenie po bezczynnoล›ci/LRU/zamkniฤ™ciu, dobiera kolejkฤ™ COLD do pamiฤ™ci przejล›ciowej i ustawia zachowawcze czasy GC. ลšcieลผka, limit dysku i limit obiektรณw pozostajฤ… bez zmian.", "cacheTransientLimit": "Limit pamiฤ™ci przejล›ciowej (MiB)", "cacheEvictionLimit": "Maks. usuniฤ™ฤ‡ LRU na przyjฤ™cie obiektu", "cacheColdQueueLimit": "Limit RAM kolejki COLD (MiB)", diff --git a/make_zip.py b/make_zip.py index e020b2c..3647975 100755 --- a/make_zip.py +++ b/make_zip.py @@ -83,7 +83,7 @@ def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.24.5.zip') + parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.24.7.zip') output = parser.parse_args().output.resolve() paths = files() checksum = ROOT / 'SHA256SUMS' diff --git a/scripts/check_source.py b/scripts/check_source.py index 82e0e4e..d8adcec 100755 --- a/scripts/check_source.py +++ b/scripts/check_source.py @@ -16,7 +16,7 @@ def read_cache_source() -> str: preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.24.5" +assert manifest["package"]["version"] == "0.24.7" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" h3dep = manifest["dependencies"]["h3"] assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"] @@ -24,14 +24,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.24.5"' in lock + assert 'name = "rust-proxy"\nversion = "0.24.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.24.5", - "CHANGELOG.md": "## 0.24.5", - "ROADMAP.md": "## 0.24.5", - "AGENTS.md": "Current source version: 0.24.5.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.24.5", + "README.md": "# RustProxy 0.24.7", + "CHANGELOG.md": "## 0.24.7", + "ROADMAP.md": "## 0.24.7", + "AGENTS.md": "Current source version: 0.24.7.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.24.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" @@ -53,7 +53,7 @@ for marker in [ assert marker in update_check_source, marker assert 'update version document is too large or incomplete: {error}' in update_check_source assert '.context("update version document is too large or incomplete")' not in update_check_source -for name in ["README.md", "ROADMAP.md", "AGENTS.md", "CHANGELOG.md", "UPDATE_CHECK.toml", "Dockerfile", ".dockerignore", "docs/TEST_REPORT.md", "docs/V0.24.5_AUDIT.md", "docs/V0.24.1_AUDIT.md", "docs/V0.24.0_AUDIT.md", "docs/V0.23.2_AUDIT.md", "docs/V0.23.1_AUDIT.md", "docs/V0.22.3_AUDIT.md", "docs/LIVE_LOGS.md", "docs/README.md", "docs/API.md", "docs/ARCHITECTURE.md", "docs/VHOSTS.md", "docs/STREAMS.md", "docs/REDIRECTS.md", "docs/CONTAINER.md", "docs/NPM_MIGRATION.md", "docs/DOCKER_DISCOVERY.md", "docker-agent/main.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/docker-agent.env.example", "docker-agent/install.sh", "src/docker.rs", "src/admin/web/docker.js", "scripts/migrate_npm.py", "docs/CUSTOM_LOCATIONS.md", "nagios/README.md", "nagios/check_rustproxy.py", "nagios/check_rustproxy_api", "nagios/check_rustproxy_backends", "nagios/check_rustproxy_latency", "nagios/check_rustproxy_queues", "src/admin/web/error_401.html", "src/admin/web/error_404.html", "src/admin/web/streams.js", "src/admin/web/redirects.js", "src/admin/web/host404.js", "docs/NOT_FOUND_HOSTS.md", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "debian/build-package.sh", "debian/build-repository.sh", "grafana/rustproxy-overview.json", "grafana/README.md"]: +for name in ["README.md", "ROADMAP.md", "AGENTS.md", "CHANGELOG.md", "UPDATE_CHECK.toml", "Dockerfile", ".dockerignore", "docs/TEST_REPORT.md", "docs/V0.24.7_AUDIT.md", "docs/V0.24.6_AUDIT.md", "docs/V0.24.5_AUDIT.md", "docs/V0.24.1_AUDIT.md", "docs/V0.24.0_AUDIT.md", "docs/V0.23.2_AUDIT.md", "docs/V0.23.1_AUDIT.md", "docs/V0.22.3_AUDIT.md", "docs/LIVE_LOGS.md", "docs/README.md", "docs/API.md", "docs/ARCHITECTURE.md", "docs/VHOSTS.md", "docs/STREAMS.md", "docs/REDIRECTS.md", "docs/CONTAINER.md", "docs/NPM_MIGRATION.md", "docs/DOCKER_DISCOVERY.md", "docker-agent/main.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/docker-agent.env.example", "docker-agent/install.sh", "src/docker.rs", "src/admin/web/docker.js", "scripts/migrate_npm.py", "docs/CUSTOM_LOCATIONS.md", "nagios/README.md", "nagios/check_rustproxy.py", "nagios/check_rustproxy_api", "nagios/check_rustproxy_backends", "nagios/check_rustproxy_latency", "nagios/check_rustproxy_queues", "src/admin/web/error_401.html", "src/admin/web/error_404.html", "src/admin/web/streams.js", "src/admin/web/redirects.js", "src/admin/web/host404.js", "docs/NOT_FOUND_HOSTS.md", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "debian/build-package.sh", "debian/build-repository.sh", "grafana/rustproxy-overview.json", "grafana/README.md"]: assert (root / name).is_file(), name for path in (root / "examples").glob("*.json"): data = json.loads(path.read_text()) @@ -120,7 +120,7 @@ assert '/assets/docker.js' in html and 'cache-live-toggle' in html assert 'expose-revision' not in html and 'hideRevision' not in js and 'diagRevision' not in js for path in ['src/settings.rs', 'src/admin/mod.rs', 'src/cache/mod.rs', 'src/headers.rs', 'src/tinyauth.rs', 'src/admin/web/vhosts.js']: assert 'x-rust-proxy-revision' not in (root / path).read_text().lower(), f'legacy revision-header logic remains in {path}' -assert './dev.sh admin reset-password' not in html and 'rust-proxy admin reset-password locally' in html +assert './dev.sh admin reset-password' not in html and re.search(r'rust-proxy\s+admin reset-password locally', html) assert 'data-panel="redirects"' in html and 'id="panel-redirects"' in html assert 'data-panel="configuration"' not in html and 'id="panel-configuration"' not in html assert 'id="settings-tab-manual-config"' in html and 'id="settings-page-manual-config"' in html @@ -200,7 +200,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.24.5"', '"/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.24.7"', '"/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 @@ -225,6 +225,10 @@ assert 'impl Cache {\n include!(' not in cache_mod, "include! cannot be used for fragment in ["lifecycle.rs", "cold.rs", "dashboard.rs", "tickets.rs", "lookup.rs"]: assert (root / "src/cache" / fragment).read_text().lstrip().startswith("impl Cache {"), fragment assert 'disabled_cache_strips_diagnostics_without_exposing_bypass_headers' in cache_source +for marker in ['pub enum MemoryPreset', 'pub enum ColdPreset', 'BACKGROUND_REVALIDATION_LIMIT', 'cleanup_slots']: + assert marker in cache_source, marker +for marker in ['selectedCacheMemoryPreset', 'selectedCacheColdPreset', 'applyRecommendedColdCachePreset', 'data-cache-cold-preset="recommended"']: + assert marker in js or marker in html, marker assert 'if !self.enabled' in cache_source proxy_source = (root / "src/proxy/mod.rs").read_text() assert 'response.extensions().get::().is_none()' in proxy_source @@ -283,6 +287,11 @@ for marker in [ "fn decode<'a>", 'Cow::Borrowed(body)', 'trim_allocator_after_large_transform', + 'pub memory_budget_bytes: usize', + 'pub max_inflight: usize', + 'Preset::Uber', + 'try_reserve_transform_memory(required_memory, settings.memory_budget_bytes)', + 'try_reserve_transform_slot(settings.max_inflight)', 'drop(decoded);', 'drop(raw);', 'rustproxy-', @@ -295,12 +304,14 @@ for name in ['js.rs', 'css.rs']: assert 'Vec' not in minifier_source and '.chars().collect' not in minifier_source, name assert 'into_bytes()' in minifier_source, name assert 'pub mod transform;' in (root / 'src/lib.rs').read_text() -for marker in ['cache-cold-gc-interval', 'cache-cold-gc-max-delete', 'settings-tab-transform', 'transform-form', 'cache-cleanup-dialog']: +for marker in ['cache-cold-gc-interval', 'cache-cold-gc-max-delete', 'settings-tab-transform', 'transform-form', 'transform-memory-budget', 'transform-max-inflight', 'data-transform-preset="uber"', 'cache-cleanup-dialog']: assert marker in html, marker for marker in ['cache-transient-memory', 'cache-cold-queue-bytes', 'cache-eviction-limit', 'data-cache-memory-preset="recommended"', 'cache-memory-warning']: assert marker in html, marker for marker in ['effectiveCacheMemory', 'cacheMemoryHeadroom', 'cachePresetValues', 'applyCacheMemoryPreset', 'cacheMemoryPresetTooSmall', 'cacheObjectOverTransientWarning', 'cgroup_memory_max_bytes', 'transient_max_bytes', 'cold_queue_max_bytes', 'hot_eviction_max_objects']: assert marker in js, marker +for marker in ['selectedTransformPreset', 'transformPresetValues', 'applyTransformPreset', 'updateTransformResourceSizing', 'memory_budget_bytes', 'max_inflight']: + assert marker in js, marker assert 'envelope?.config.cache?.enabled === true && cacheToggle.value !== "off"' in vhosts_js for marker in ['transform: { enabled: false }', 'vhTransform', 'collectTransform']: assert marker in vhosts_js, marker @@ -569,7 +580,7 @@ assert 'if docker_targets.contains_key(id)' in prometheus_source assert 'rust_proxy_docker_vhost_exchange_duration_seconds' in prometheus_source settings_source = (root / 'src/admin/web/settings.js').read_text() assert 'prom-trusted-no-token' in settings_source and 'include_docker_vhosts' in settings_source and 'include_streams' in settings_source and 'include_logging' in settings_source -for marker in ['cache-memory-breakdown', 'cache-transient-breakdown', 'process-rss-total', 'process-rss-anon', 'process-rss-file', 'process-data-swap', 'memoryAccountingHint']: +for marker in ['cache-memory-breakdown', 'cache-transient-breakdown', 'cache-cold-storage', 'process-rss-total', 'memory-cache-hot', 'memory-cache-transient', 'memory-transform', 'memory-transform-inflight', 'memory-non-cache-rss', 'process-rss-anon', 'process-rss-file', 'process-data-swap', 'memoryAccountingHint']: assert marker in html, marker for marker in ['validate_h3', 'H3Tunnel', 'h3_client_to_backend', 'backend_to_h3', 'rfc9220_bootstrap_uses_extended_connect_without_h1_proof_headers']: assert marker in ws_source, marker @@ -682,7 +693,13 @@ assert '.docker-passive-health-editor .docker-health-grid {' in style_source assert '.docker-health-grid input:not([type="checkbox"])' in style_source and '.docker-passive-health-editor .docker-checkbox-field input[type="checkbox"]' in style_source assert 'id="sparkline-transform"' in html and 'id="rate-transform"' in html and 'liveTransformed' in html assert 'payload["transform_metrics"] = json!(state.transform_metrics.total())' in admin_source +for marker in ['payload["transform_resources"]', 'payload["memory_accounting"]', 'non_cache_rss_includes_transform']: + assert marker in admin_source, marker assert 'const transformed = s.transform_metrics || {}' in app_js and 'transformSamples.push(rates.transform)' in app_js +for marker in ['status.memory_accounting || {}', 'memory.cache_hot_bytes', 'memory.transform_reserved_bytes', 'cache-cold-storage']: + assert marker in app_js, marker +for marker in ['data?.resources || {}', 'tp-memory', 'tp-inflight']: + assert marker in transform_panel_js, marker assert 'function uptime(value)' in app_js and 'setOptionalMetric("process-uptime", status.uptime_seconds != null' in app_js assert 'connection-uptime-footer' not in html and 'id="process-uptime"' in html assert 'id="panel-transform"' in html and 'data-panel="transform"' in html and '/assets/transform-panel.js' in html @@ -1056,7 +1073,7 @@ assert 'id="process-platform"' in html and 'd.platform' in app_js assert 'id="panel-logging"' not in html assert 'id="settings-tab-logging"' in html and 'id="settings-page-logging"' in html and 'id="logging-form"' in html assert 'location.pathname === "/logging"' in settings_js_0241 -assert '"RuntimeInformation"' in openapi and '"version": "0.24.5"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.24.7"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. tls_0241 = (root / "src/tls.rs").read_text() @@ -1101,5 +1118,5 @@ for schema_name in ["RedirectHost", "NotFoundHost"]: node = openapi_0241["components"]["schemas"][schema_name]["properties"]["frontend"]["properties"]["hosts"] assert "maxItems" not in node, schema_name -print("PASS: 0.24.5 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, Debian/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-v6 migrations.") +print("PASS: 0.24.7 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, Debian/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-v6 migrations.") print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.") diff --git a/scripts/deploy.py b/scripts/deploy.py index 16e1cda..d58b14c 100755 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -97,7 +97,7 @@ def digest(path: Path) -> str: def fetch_https(url: str, max_bytes: int) -> bytes: if urllib.parse.urlparse(url).scheme != 'https': raise ValueError('GeoIP downloads require HTTPS.') - request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.24.5'}) + request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.24.7'}) with urllib.request.urlopen(request, timeout=30) as response: if urllib.parse.urlparse(response.geturl()).scheme != 'https': raise ValueError('GeoIP download redirected away from HTTPS.') diff --git a/scripts/memory_contracts.py b/scripts/memory_contracts.py new file mode 100755 index 0000000..b67d495 --- /dev/null +++ b/scripts/memory_contracts.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Static regression checks for bounded-memory/background-worker contracts. + +This is intentionally Rust-toolchain independent. It does not replace runtime RSS/ +heap profiling; it prevents the source-level bounds audited for this release from +silently disappearing before compiler-enabled acceptance runs. +""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(path: str, *markers: str) -> None: + text = read(path) + for marker in markers: + assert marker in text, f"{path}: missing memory contract: {marker}" + + +def forbid(path: str, *markers: str) -> None: + text = read(path) + for marker in markers: + assert marker not in text, f"{path}: forbidden memory pattern returned: {marker}" + + +# Transform path: hard aggregate/decode/concurrency budgets and no JSON DOM tree. +require( + "src/transform/mod.rs", + "DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024", + "pub memory_budget_bytes: usize", + "pub max_inflight: usize", + "try_reserve_transform_memory(required_memory, settings.memory_budget_bytes)", + "try_reserve_transform_slot(settings.max_inflight)", + "TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize", + "TRANSFORM_INFLIGHT: AtomicUsize", + "pub struct ResourceUsage", + "pub fn resource_usage(settings: &Settings)", + "memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load", + "Limited::new(body, settings.max_body_bytes)", + ".try_reserve_exact(read)", + '"memory_pressure"', + '"concurrency_pressure"', + "aggregation_is_bounded_even_when_content_length_is_false", + "transform_memory_reservation_obeys_runtime_budget", + "transform_inflight_reservation_obeys_runtime_limit", +) +require("src/transform/json.rs", "IgnoredAny::deserialize", "Vec::with_capacity(body.len())") +forbid("src/transform/json.rs", "use serde_json::Value", "from_slice::") +require("src/transform/css.rs", "let mut last_char: Option = None") +require("src/transform/html.rs", "enum RawElement", "fn raw_tag(tag: &str)") + +# Cache background work: all previously high-retention paths have hard bounds. +require( + "src/cache/model.rs", + "COLD_DELETE_FALLBACKS: usize = 2", + "COLD_CLEANUP_SLOTS: usize = 1", + "BACKGROUND_REVALIDATION_LIMIT: usize = 8", + "pub cold_limit_bytes: u64", + "pub cold_limit_entries: usize", +) +require( + "src/admin/mod.rs", + 'payload["transform_resources"]', + 'payload["memory_accounting"]', + '"non_cache_rss_includes_transform": true', +) +require( + "src/cache/cold.rs", + "BinaryHeap::with_capacity(requested_limit.saturating_add(1))", + "self.cold_delete_fallbacks.clone().try_acquire_owned()", + "self.cleanup_slots.clone().try_acquire_owned()", + "std::mem::take(&mut delete)", +) +require( + "src/cache/tickets.rs", + "active.len() >= BACKGROUND_REVALIDATION_LIMIT", +) +require( + "src/cache/collapse.rs", + ".saturating_mul(64)", + ".clamp(64, 256)", + "settings.proactive_refresh_max_inflight as u64", +) +require( + "src/cache/lifecycle.rs", + "mpsc::channel(COLD_QUEUE)", + "cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS))", + "cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS))", +) + +# Other long-lived/background facilities: queue/task/worker cardinality is bounded +# or owned by a cancellation-aware supervisor/TaskTracker. +require( + "src/logging.rs", + "HISTORY_CAPACITY: usize = 4096", + "LIVE_STREAM_CAPACITY: usize = 4096", + "mpsc::channel(SINK_QUEUE)", + "self.sink.try_send(entry)", +) +require( + "src/diagnostics.rs", + "tokio::task::spawn_blocking(move || system_stats::sample(previous)).await", + "stop.cancelled()", +) +require( + "src/upstream.rs", + "Semaphore::new(16)", + ".try_acquire_owned()", + "tokio::time::timeout(DNS_TIMEOUT, lookup)", +) +require( + "src/balance.rs", + "if checks.len() >= 16 { break; }", + "checks.abort_all()", +) +require( + "src/stream.rs", + "Semaphore::new(host.max_connections)", + "mpsc::channel(64)", + "JoinSet::new()", +) +require( + "src/server.rs", + "Semaphore::new(max_connections)", + "let mut tasks = JoinSet::new()", +) +require( + "src/runtime.rs", + "tokio_util::task::TaskTracker::new()", + "update_slots: Arc::new(Semaphore::new(8))", + "inflight: Arc::new(Semaphore::new(max_inflight))", +) +require("src/acme.rs", "slot: Arc::new(Semaphore::new(1))", ".try_acquire_owned()") +require("src/docker.rs", "shutdown.cancelled()", "Duration::from_secs(5)") +require("src/update_check.rs", "shutdown.cancelled()", "tokio::time::sleep(interval)") + +print("PASS: bounded-memory transform/cache contracts and background-worker limits are present.") diff --git a/scripts/preflight.py b/scripts/preflight.py index 8bc26b5..e8579b6 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -33,10 +33,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]: 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/upstream.rs", "src/tls.rs", "src/proxy/websocket.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/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/management.js", "src/pages.rs", "scripts/mock_server.py", "scripts/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "scripts/acme_provider.py", "src/basic_auth.rs", "scripts/deploy.py", "scripts/migrate_npm.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/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", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "grafana/rustproxy-overview.json"]: if not (root / path).is_file(): - raise ValueError(f"Incomplete RustProxy 0.24.5 source tree: missing {path}") + raise ValueError(f"Incomplete RustProxy 0.24.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.24.5 entry point; extract the complete package, not individual files.") + raise ValueError("src/lib.rs is not the 0.24.7 entry point; extract the complete package, not individual files.") return messages diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index 0699dab..55d00ba 100755 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -113,13 +113,13 @@ 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.24.5"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.24.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() assert (target / "docker-agent/main.go").is_file() manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) - assert manifest["to_version"] == "0.24.5" + assert manifest["to_version"] == "0.24.7" assert manifest["removed_obsolete_runtime_state"] is True assert not legacy.exists() assert not (backup / "legacy-data").exists() @@ -307,7 +307,7 @@ def ui_contract_smoke() -> None: assert 'data-panel="configuration"' not in html and 'id="panel-configuration"' not in html assert html.index('data-panel="vhosts"') < html.index('data-panel="redirects"') < html.index('data-panel="notfound"') < html.index('data-panel="streams"') < html.index('data-panel="acl-profiles"') assert "matching_resident_bytes" in cache and "matching_body_bytes" in cache - for marker in ["cache-memory-breakdown", "cache-transient-breakdown", "process-rss-total", "process-rss-anon", "process-rss-file", "process-data-swap", "memoryAccountingHint"]: + for marker in ["cache-memory-breakdown", "cache-transient-breakdown", "cache-cold-storage", "process-rss-total", "memory-cache-hot", "memory-cache-transient", "memory-transform", "memory-transform-inflight", "memory-non-cache-rss", "process-rss-anon", "process-rss-file", "process-data-swap", "memoryAccountingHint"]: assert marker in html, marker for marker in ["transient_bytes", "fill_bytes_active", "cold_queue_bytes", "cold_write_bytes_active", "cold_read_bytes_active", "process_rss_anon_bytes", "process_rss_file_bytes"]: assert marker in app or marker in cache, marker @@ -495,7 +495,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.24.5"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.24.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"']: assert marker in html, marker @@ -612,6 +612,10 @@ def source_contract_smoke() -> None: assert 'id="sparkline-transform"' in html and 'id="rate-transform"' in html assert 'id="panel-transform"' in html and 'data-panel="transform"' in html and '/assets/transform-panel.js' in html assert '/api/transform' in transform_panel and 'tp-cache-hits' in transform_panel + for marker in ['transform-memory-budget', 'transform-max-inflight', 'data-transform-preset="conservative"', 'data-transform-preset="recommended"', 'data-transform-preset="performance"', 'data-transform-preset="uber"']: + assert marker in html, marker + for marker in ['selectedTransformPreset', 'transformPresetValues', 'applyTransformPreset', 'updateTransformResourceSizing', 'memory_budget_bytes', 'max_inflight']: + assert marker in app_js, marker assert '
' in html assert '
' in html for marker in ['cache-live-hit-line', 'cache-live-miss-line', 'cache-live-bypass-line']: @@ -645,14 +649,20 @@ def source_contract_smoke() -> None: for fragment in ['lifecycle.rs', 'cold.rs', 'dashboard.rs', 'tickets.rs', 'lookup.rs']: assert (ROOT / 'src/cache' / fragment).read_text(encoding='utf-8').lstrip().startswith('impl Cache {') assert 'payload["transform_metrics"] = json!(state.transform_metrics.total())' in admin_rs + for marker in ['payload["transform_resources"]', 'payload["memory_accounting"]', 'non_cache_rss_includes_transform', 'cold_limit_bytes', 'cold_limit_entries']: + assert marker in admin_rs or marker in cache, marker assert 'const transformed = s.transform_metrics || {}' in app_js and 'transformSamples.push(rates.transform)' in app_js + for marker in ['status.memory_accounting || {}', 'memory.cache_hot_bytes', 'memory.transform_reserved_bytes', 'cache-cold-storage']: + assert marker in app_js, marker + for marker in ['data?.resources || {}', 'tp-memory', 'tp-inflight']: + assert marker in transform_panel, marker assert 'function uptime(value)' in app_js and 'setOptionalMetric("process-uptime", status.uptime_seconds != null' in app_js assert 'connection-uptime-footer' not in html and 'id="process-uptime"' in html assert 'function rateBytes(value)' in app_js and 'if (value === 0) return "0.0 KiB";' in app_js and 'return "<0.1 KiB";' in app_js assert 'function backendTrafficRate(' in vhosts_js and 'member.bytes_in' in vhosts_js and 'member.bytes_out' in vhosts_js and 'rateBytes(memberTraffic)' in vhosts_js and 'rateBytes(d.traffic)' in vhosts_js assert 'id="cache-domain-filter"' in html and 'cacheDomainFilterEnabled' in html and 'cacheDomainFilterDisabled' in html assert 'const visibleDomains = (data, groups) =>' in cache_panel_js and 'scoped?.settings?.enabled ?? data.settings?.enabled ?? false' in cache_panel_js - for marker in ['effectiveCacheMemory', 'cacheMemoryHeadroom', 'cachePresetValues', 'applyCacheMemoryPreset', 'cacheMemoryPresetTooSmall', 'cacheObjectOverTransientWarning', 'cgroup_memory_max_bytes', 'transient_max_bytes', 'cold_queue_max_bytes', 'hot_eviction_max_objects']: + for marker in ['effectiveCacheMemory', 'cacheMemoryHeadroom', 'cachePresetValues', 'applyCacheMemoryPreset', 'selectedCacheMemoryPreset', 'selectedCacheColdPreset', 'applyRecommendedColdCachePreset', 'cacheMemoryPresetTooSmall', 'cacheObjectOverTransientWarning', 'cgroup_memory_max_bytes', 'transient_max_bytes', 'cold_queue_max_bytes', 'hot_eviction_max_objects']: assert marker in app_js, marker assert 'envelope?.config.cache?.enabled === true && cacheToggle.value !== "off"' in vhosts_js assert 'groups = d.domains || [], shownGroups = visibleDomains(data, groups)' in cache_panel_js and 'replaceChildren(...shownGroups.map(row =>' in cache_panel_js @@ -750,7 +760,11 @@ def source_contract_smoke() -> None: "pub struct Settings", "pub struct Overrides", "resolved.enabled = global.enabled && self.enabled", "pub fn no_transform", "OriginValidators", "fn decode<'a>", - "Cow::Borrowed(body)", "trim_allocator_after_large_transform", "drop(decoded);", "drop(raw);", + "Cow::Borrowed(body)", "trim_allocator_after_large_transform", + "pub memory_budget_bytes: usize", "pub max_inflight: usize", "Preset::Uber", + "try_reserve_transform_memory(required_memory, settings.memory_budget_bytes)", + "try_reserve_transform_slot(settings.max_inflight)", + "drop(decoded);", "drop(raw);", "rustproxy-", "unchanged_compressed_body_is_canonicalized_to_identity", "origin_no_transform_blocks_body_changes_and_keeps_validator", @@ -912,7 +926,7 @@ def source_contract_smoke() -> None: assert "removed_obsolete_runtime_state" in upgrade_source and "legacy-data" not in upgrade_source and "certs_dir.is_symlink()" in upgrade_source 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") - for marker in ['"version": "0.24.5"', '"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.24.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"']: @@ -1060,7 +1074,7 @@ def main() -> None: source_contract_smoke() container_contract_smoke() debian_grafana_contract_smoke() - print("PASS: 0.24.5 SQLite/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, Debian packaging, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") + print("PASS: 0.24.7 SQLite/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, Debian packaging, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") if __name__ == "__main__": diff --git a/scripts/routeros-oci-build/README.md b/scripts/routeros-oci-build/README.md index d345066..e4bcec5 100644 --- a/scripts/routeros-oci-build/README.md +++ b/scripts/routeros-oci-build/README.md @@ -76,10 +76,10 @@ ENV_FILE=scripts/routeros-oci-build/.env \ bash scripts/routeros-oci-build/build-routeros-oci.sh push ``` -With RustProxy version `0.24.5` and the defaults this publishes: +With RustProxy version `0.24.7` and the defaults this publishes: ```text -zot.example.net/network/rustproxy:0.24.5 +zot.example.net/network/rustproxy:0.24.7 zot.example.net/network/rustproxy:latest zot.example.net/network/rustproxy:routeros ``` diff --git a/scripts/swagger_assets.py b/scripts/swagger_assets.py index 11f1449..7f9f75c 100755 --- a/scripts/swagger_assets.py +++ b/scripts/swagger_assets.py @@ -85,7 +85,7 @@ def download(destination: Path) -> None: """TLS verification uses Python's default trust store; never executes a shell.""" started = time.monotonic() opener = build_opener(HttpsRedirects()) - request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.24.5"}) + request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.24.7"}) with opener.open(request, timeout=30) as response, destination.open("wb") as output: length = response.headers.get("Content-Length") if length and int(length) > MAX_ARCHIVE: diff --git a/scripts/upgrade.py b/scripts/upgrade.py index a4ea8f4..599d5a5 100755 --- a/scripts/upgrade.py +++ b/scripts/upgrade.py @@ -46,7 +46,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None for name in MANAGED: if (target / name).is_symlink(): raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.") - backup = target / ".upgrade-backup" / ("before-0.24.5-" + uuid.uuid4().hex[:12]) + backup = target / ".upgrade-backup" / ("before-0.24.7-" + uuid.uuid4().hex[:12]) backup.mkdir(parents=True, mode=0o700) installed: list[str] = [] moved: list[str] = [] @@ -84,7 +84,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None remove_legacy = legacy.exists() if remove_legacy and (legacy.is_symlink() or not legacy.is_file()): raise ValueError("Obsolete renewal observation sidecar must be a regular file.") - (backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.24.5", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8") + (backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.24.7", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8") if remove_legacy: legacy.unlink() except BaseException: @@ -115,7 +115,7 @@ def main() -> None: backup = upgrade(Path(__file__).resolve().parents[1], args.target, stopped=args.stopped, data_dir=data_dir) except (OSError, ValueError) as error: parser.exit(2, f"Upgrade refused or rolled back: {error}\n") - print(f"Installed RustProxy 0.24.5. Previous managed source files: {backup}") + print(f"Installed RustProxy 0.24.7. Previous managed source files: {backup}") print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.") print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh") diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 3b1451e..eeee43c 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1246,9 +1246,32 @@ fn status_payload(state: &App) -> Value { "locked_listen": state.startup_locks.administration.map(|value| value.to_string()) }); payload["features"] = Value::Object(features); - payload["cache"] = json!(state.cache.view()); + let cache_view = state.cache.view(); + let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform); + payload["cache"] = json!(&cache_view); + payload["transform_resources"] = json!(&transform_resources); payload["geoip"] = json!(state.geoip.view()); let system = state.diagnostics.view(); + let cache_ram_bytes = (cache_view.hot_allocated_bytes as u64) + .saturating_add(cache_view.transient_bytes); + let non_cache_rss_bytes = system + .process_rss_bytes + .map(|rss| rss.saturating_sub(cache_ram_bytes)); + payload["memory_accounting"] = json!({ + "process_rss_bytes": system.process_rss_bytes, + "cache_hot_bytes": cache_view.hot_allocated_bytes, + "cache_hot_limit_bytes": cache_view.hot_limit_bytes, + "cache_transient_bytes": cache_view.transient_bytes, + "cache_transient_limit_bytes": cache_view.transient_limit_bytes, + "cache_total_bytes": cache_ram_bytes, + "transform_reserved_bytes": transform_resources.memory_reserved_bytes, + "transform_budget_bytes": transform_resources.memory_budget_bytes, + "transform_inflight": transform_resources.inflight, + "transform_max_inflight": transform_resources.max_inflight, + "non_cache_rss_bytes": non_cache_rss_bytes, + "non_cache_rss_includes_transform": true, + "method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate" + }); payload["runtime"] = json!({ "platform": system.platform, "statistics_source": system.source, @@ -2057,6 +2080,30 @@ mod tests { ); } + #[tokio::test] + async fn status_exposes_runtime_memory_accounting_and_cold_limits() { + let (router, store, _dir, secret, _) = fixture().await; + let response = router + .oneshot( + request("/api/status") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024); + assert_eq!(body["transform_resources"]["max_inflight"], 32); + assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024); + assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024); + assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024); + assert_eq!(body["cache"]["cold_limit_entries"], 50_000); + assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true); + store.close().await; + } + #[tokio::test] async fn rejects_legacy_token_rebinding_and_cross_origin() { let (router, store, _dir, secret, _) = fixture().await; @@ -3418,6 +3465,7 @@ async fn transform_status(State(state): State>) -> Json { .collect(); Json(json!({ "settings": global, + "resources": crate::transform::resource_usage(global), "metrics": state.transform_metrics.view(), "vhosts": vhosts })) diff --git a/src/admin/openapi.json b/src/admin/openapi.json index ba4223e..83b468d 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.24.5", + "version": "0.24.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": [ @@ -913,6 +913,35 @@ "properties": {}, "additionalProperties": true }, + "transform_resources": { + "type": "object", + "properties": { + "memory_reserved_bytes": { "type": "integer", "format": "int64" }, + "memory_budget_bytes": { "type": "integer", "format": "int64" }, + "inflight": { "type": "integer", "format": "int64" }, + "max_inflight": { "type": "integer", "format": "int64" } + }, + "additionalProperties": false + }, + "memory_accounting": { + "type": "object", + "properties": { + "process_rss_bytes": { "type": "integer", "format": "int64", "nullable": true }, + "cache_hot_bytes": { "type": "integer", "format": "int64" }, + "cache_hot_limit_bytes": { "type": "integer", "format": "int64" }, + "cache_transient_bytes": { "type": "integer", "format": "int64" }, + "cache_transient_limit_bytes": { "type": "integer", "format": "int64" }, + "cache_total_bytes": { "type": "integer", "format": "int64" }, + "transform_reserved_bytes": { "type": "integer", "format": "int64" }, + "transform_budget_bytes": { "type": "integer", "format": "int64" }, + "transform_inflight": { "type": "integer", "format": "int64" }, + "transform_max_inflight": { "type": "integer", "format": "int64" }, + "non_cache_rss_bytes": { "type": "integer", "format": "int64", "nullable": true }, + "non_cache_rss_includes_transform": { "type": "boolean" }, + "method": { "type": "string" } + }, + "additionalProperties": false + }, "cache": { "type": "object", "properties": {}, @@ -1265,6 +1294,16 @@ "type": "boolean", "default": true }, + "memory_preset": { + "type": "string", + "nullable": true, + "enum": [ + "conservative", + "recommended", + "performance" + ], + "description": "Last cache memory sizing preset explicitly selected in Settings. Numeric limits remain authoritative." + }, "max_bytes": { "type": "integer", "format": "int64", @@ -1309,6 +1348,14 @@ "type": "boolean", "default": false }, + "cold_preset": { + "type": "string", + "nullable": true, + "enum": [ + "recommended" + ], + "description": "Last COLD policy preset explicitly selected in Settings." + }, "cold_path": { "type": "string", "default": "cache/cold" @@ -2702,6 +2749,17 @@ "type": "boolean", "default": false }, + "preset": { + "type": "string", + "nullable": true, + "enum": [ + "conservative", + "recommended", + "performance", + "uber" + ], + "description": "Last resource preset selected in the administration UI. Numeric limits remain authoritative." + }, "javascript": { "type": "boolean", "default": true @@ -2745,6 +2803,20 @@ "minimum": 1024, "maximum": 33554432, "default": 2097152 + }, + "memory_budget_bytes": { + "type": "integer", + "minimum": 1048576, + "maximum": 2147483648, + "default": 134217728, + "description": "Process-wide ceiling for estimated temporary transform buffers. The budget is not pre-allocated." + }, + "max_inflight": { + "type": "integer", + "minimum": 1, + "maximum": 1024, + "default": 32, + "description": "Process-wide maximum number of active transformation attempts. Excess work fails open unchanged." } } }, @@ -3163,6 +3235,22 @@ "settings": { "$ref": "#/components/schemas/TransformSettings" }, + "resources": { + "type": "object", + "additionalProperties": false, + "properties": { + "memory_reserved_bytes": { "type": "integer", "format": "int64" }, + "memory_budget_bytes": { "type": "integer", "format": "int64" }, + "inflight": { "type": "integer", "format": "int64" }, + "max_inflight": { "type": "integer", "format": "int64" } + }, + "required": [ + "memory_reserved_bytes", + "memory_budget_bytes", + "inflight", + "max_inflight" + ] + }, "metrics": { "type": "object", "additionalProperties": false, @@ -3210,6 +3298,7 @@ }, "required": [ "settings", + "resources", "metrics", "vhosts" ] @@ -4243,7 +4332,7 @@ } } }, - "description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7โ€“30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.", + "description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.", "security": [], "parameters": [ { diff --git a/src/admin/web/app.js b/src/admin/web/app.js index 529a047..6fdf950 100644 --- a/src/admin/web/app.js +++ b/src/admin/web/app.js @@ -70,6 +70,7 @@ async function loadLanguages() { $("language").value = language; } let latestStatus = null, certificateInventory = new Map(); +let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null; function translate() { clearTimeout(toastTimer); hideToast(); document.documentElement.lang = locale(); document.documentElement.dir = activePack()?.meta.direction || "ltr"; @@ -633,6 +634,11 @@ function renderDiagnostics(status, appendSample = true) { $("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`; $("cache-memory-breakdown").textContent = `${fmtBytes(c.resident_bytes)} / ${fmtBytes(c.transient_bytes)}`; $("cache-transient-breakdown").textContent = `${fmtBytes(c.fill_bytes_active)} / ${fmtBytes(c.cold_queue_bytes)} / ${fmtBytes((Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0))}`; + const coldStorage = knownPair(c.cold_bytes, c.cold_limit_bytes, value => fmtBytes(value)); + const coldObjects = knownPair(c.cold_entries, c.cold_limit_entries, value => fmt(value)); + $("cache-cold-storage").textContent = envelope?.config.cache?.cold_enabled === true + ? `${coldStorage ?? "-"}${coldObjects == null ? "" : ` ยท ${coldObjects} ${t("cacheColdStorageObjects")}`}` + : t("notEnabled"); $("cache-evictions").textContent = `${fmt(c.evictions)} / ${fmt(c.expired)}`; if (appendSample) { if (c.resident_bytes != null) { cacheMemorySamples.push(Number(c.resident_bytes)); cacheMemorySamples = cacheMemorySamples.slice(-60); } @@ -664,6 +670,16 @@ function renderDiagnostics(status, appendSample = true) { setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : new Date(Number(d.process_start_time_seconds) * 1000).toLocaleString(locale())); setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds)); setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes)); + const memory = status.memory_accounting || {}; + const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value)); + const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value)); + const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value)); + const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value)); + setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory); + setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory); + setOptionalMetric("memory-transform", transformMemory != null, transformMemory); + setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight); + setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes)); setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes)); const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value)); setOptionalMetric("process-rss-file", rssFile != null, rssFile); @@ -736,7 +752,7 @@ function renderSettings() { if (!settingsDirty.has("response-form")) { const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {}; $("expose-request-id").checked = !!diagnostics.request_id; - $("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.5"; + $("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.7"; for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name]; $("compress-min").value = compression.min_bytes ?? 1024; syncServerMode(); } @@ -757,12 +773,15 @@ function renderSettings() { $("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n"); $("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n"); $("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age; + setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null); + setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null); syncColdCachePolicy(); syncAdvancedCachePolicy(); updateCacheMemorySizing(); } if (!settingsDirty.has("transform-form")) { const tr = config.transform || {}; + setTransformPresetSelection(["conservative", "recommended", "performance", "uber"].includes(tr.preset) ? tr.preset : null); $("transform-enabled").checked = !!tr.enabled; $("transform-javascript").checked = tr.javascript !== false; $("transform-css").checked = tr.css !== false; @@ -771,6 +790,9 @@ function renderSettings() { $("transform-json").checked = !!tr.json; $("transform-extensions").value = (tr.extensions || ["js", "mjs", "css", "html", "htm", "svg", "json"]).join("\n"); $("transform-max-body").value = (tr.max_body_bytes ?? 2097152) / 1024; + $("transform-memory-budget").value = (tr.memory_budget_bytes ?? 134217728) / 1048576; + $("transform-max-inflight").value = tr.max_inflight ?? 32; + updateTransformResourceSizing(); } if (!settingsDirty.has("logging-form")) { const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {}; @@ -814,6 +836,29 @@ function cachePresetValues(kind, bytes) { const entries = Math.max(1024, Math.min(16384, Math.round(hot * 16))); return { hot, transient, queue, objectKiB: objectMiB * 1024, entries, eviction: profile.eviction }; } +function cacheMemoryPresetName(kind) { + return kind ? t({ conservative: "cachePresetConservative", recommended: "cachePresetRecommended", performance: "cachePresetPerformance" }[kind]) : t("cachePresetCustom"); +} +function setCacheMemoryPresetSelection(kind) { + selectedCacheMemoryPreset = ["conservative", "recommended", "performance"].includes(kind) ? kind : null; + document.querySelectorAll("[data-cache-memory-preset]").forEach(button => { + const active = button.dataset.cacheMemoryPreset === selectedCacheMemoryPreset; + button.classList.toggle("primary", active); button.classList.toggle("outline", !active); + button.setAttribute("aria-pressed", String(active)); + }); + const badge = $("cache-memory-preset-selected"); + if (badge) badge.textContent = `${t("cachePresetSelected")}: ${cacheMemoryPresetName(selectedCacheMemoryPreset)}`; +} +function setCacheColdPresetSelection(kind) { + selectedCacheColdPreset = kind === "recommended" ? "recommended" : null; + document.querySelectorAll("[data-cache-cold-preset]").forEach(button => { + const active = button.dataset.cacheColdPreset === selectedCacheColdPreset; + button.classList.toggle("primary", active); button.classList.toggle("outline", !active); + button.setAttribute("aria-pressed", String(active)); + }); + const badge = $("cache-cold-preset-selected"); + if (badge) badge.textContent = `${t("cachePresetSelected")}: ${selectedCacheColdPreset ? t("cachePresetRecommended") : t("cachePresetCustom")}`; +} function updateCacheMemorySizing() { const detected = effectiveCacheMemory(), badge = $("cache-memory-detected"), warning = $("cache-memory-warning"); if (!badge || !warning) return; @@ -854,8 +899,65 @@ function applyCacheMemoryPreset(kind) { $("cache-memory").value = preset.hot; $("cache-transient-memory").value = preset.transient; $("cache-object").value = preset.objectKiB; $("cache-entries").value = preset.entries; $("cache-eviction-limit").value = preset.eviction; $("cache-cold-queue-bytes").value = preset.queue; + setCacheMemoryPresetSelection(kind); setSettingsDirty("cache-form"); updateCacheMemorySizing(); } +function applyRecommendedColdCachePreset() { + const transient = Number($("cache-transient-memory").value); + const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32; + $("cache-cold-enabled").checked = true; + if (!$("cache-cold-path").value.trim()) $("cache-cold-path").value = "cache/cold"; + $("cache-cold-on-idle").checked = true; $("cache-cold-on-age").checked = false; + $("cache-cold-on-lru").checked = true; $("cache-cold-on-shutdown").checked = true; + $("cache-cold-after").value = 300; $("cache-cold-max-hot").value = 3600; + $("cache-cold-gc-interval").value = 60; $("cache-cold-gc-max-delete").value = 256; + $("cache-cold-queue-bytes").value = Math.min(queue, transient || queue); + setCacheColdPresetSelection("recommended"); + syncColdCachePolicy(); setSettingsDirty("cache-form"); updateCacheMemorySizing(); +} +function transformPresetValues(kind) { + return { + conservative: { memoryMiB: 32, bodyKiB: 1024, inflight: 8 }, + recommended: { memoryMiB: 128, bodyKiB: 2048, inflight: 32 }, + performance: { memoryMiB: 512, bodyKiB: 8192, inflight: 64 }, + uber: { memoryMiB: 1024, bodyKiB: 32768, inflight: 128 } + }[kind] || { memoryMiB: 128, bodyKiB: 2048, inflight: 32 }; +} +function transformPresetName(kind) { + return kind ? t({ conservative: "transformPresetConservative", recommended: "transformPresetRecommended", performance: "transformPresetPerformance", uber: "transformPresetUber" }[kind]) : t("cachePresetCustom"); +} +function setTransformPresetSelection(kind) { + selectedTransformPreset = ["conservative", "recommended", "performance", "uber"].includes(kind) ? kind : null; + document.querySelectorAll("[data-transform-preset]").forEach(button => { + const active = button.dataset.transformPreset === selectedTransformPreset; + button.classList.toggle("primary", active); button.classList.toggle("outline", !active); + button.setAttribute("aria-pressed", String(active)); + }); + const badge = $("transform-preset-selected"); + if (badge) badge.textContent = `${t("cachePresetSelected")}: ${transformPresetName(selectedTransformPreset)}`; +} +function updateTransformResourceSizing() { + const warning = $("transform-memory-warning"); if (!warning) return; + const memoryMiB = Number($("transform-memory-budget")?.value), bodyKiB = Number($("transform-max-body")?.value), inflight = Number($("transform-max-inflight")?.value); + const detected = effectiveCacheMemory(); + const budgetBytes = memoryMiB * 1048576, bodyBytes = bodyKiB * 1024; + const worstEncodedEstimate = bodyBytes * 3; + const budgetCannotFitMax = Number.isFinite(budgetBytes) && Number.isFinite(worstEncodedEstimate) && budgetBytes < worstEncodedEstimate; + const highHostShare = detected && Number.isFinite(budgetBytes) && budgetBytes > detected.bytes * .5; + warning.hidden = !(budgetCannotFitMax || highHostShare); + if (budgetCannotFitMax) warning.textContent = t("transformBudgetTooSmallWarning"); + else if (highHostShare) warning.textContent = `${t("transformBudgetHighWarning")} ${fmtBytes(budgetBytes)} / ${fmtBytes(detected.bytes)}.`; + const summary = $("transform-preset-summary"); + if (summary) summary.textContent = `${t("transformResourceCurrent")}: ${Number.isFinite(memoryMiB) ? memoryMiB : "-"} MiB ยท ${Number.isFinite(bodyKiB) ? bodyKiB : "-"} KiB ยท ${t("transformMaxInflightShort")} ${Number.isFinite(inflight) ? inflight : "-"}`; +} +function applyTransformPreset(kind) { + const preset = transformPresetValues(kind); + $("transform-memory-budget").value = preset.memoryMiB; + $("transform-max-body").value = preset.bodyKiB; + $("transform-max-inflight").value = preset.inflight; + setTransformPresetSelection(kind); + setSettingsDirty("transform-form"); updateTransformResourceSizing(); +} function syncServerMode() { $("server-value").disabled = $("server-mode").value !== "custom"; $("server-value").required = !$("server-value").disabled; } function syncLoggingFileMode() { const enabled = $("log-file-enabled").checked, perVhost = $("log-file-per-vhost").checked; @@ -903,18 +1005,21 @@ bindSettingsForm("response-form", next => { next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked; }, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression })); bindSettingsForm("cache-form", next => { - next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked }; + next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked }; }, "cache"); bindSettingsForm("transform-form", next => { next.transform = { enabled: $("transform-enabled").checked, + preset: selectedTransformPreset, javascript: $("transform-javascript").checked, css: $("transform-css").checked, html: $("transform-html").checked, svg: $("transform-svg").checked, json: $("transform-json").checked, extensions: $("transform-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), - max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024) + max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024), + memory_budget_bytes: Math.round(Number($("transform-memory-budget").value) * 1048576), + max_inflight: Number($("transform-max-inflight").value) }; }, "transform"); const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"]; @@ -995,7 +1100,14 @@ $("log-file-enabled").addEventListener("change", syncLoggingFileMode); $("log-file-per-vhost").addEventListener("change", syncLoggingFileMode); for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age"]) $(id).addEventListener("change", syncColdCachePolicy); document.querySelectorAll("[data-cache-memory-preset]").forEach(button => button.addEventListener("click", () => applyCacheMemoryPreset(button.dataset.cacheMemoryPreset))); -for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-cold-queue-bytes"]) $(id).addEventListener("input", updateCacheMemorySizing); +document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset)); +document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset))); +for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-entries", "cache-eviction-limit"]) $(id).addEventListener("input", () => { setCacheMemoryPresetSelection(null); updateCacheMemorySizing(); }); +$("cache-cold-queue-bytes").addEventListener("input", () => { setCacheMemoryPresetSelection(null); setCacheColdPresetSelection(null); updateCacheMemorySizing(); }); +for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown", "cache-cold-after", "cache-cold-max-hot", "cache-cold-gc-interval", "cache-cold-gc-max-delete"]) { + for (const event of ["input", "change"]) $(id).addEventListener(event, () => setCacheColdPresetSelection(null)); +} +for (const id of ["transform-memory-budget", "transform-max-body", "transform-max-inflight"]) $(id).addEventListener("input", () => { setTransformPresetSelection(null); updateTransformResourceSizing(); }); for (const id of ["cache-collapse-enabled", "cache-proactive-enabled"]) $(id).addEventListener("change", syncAdvancedCachePolicy); syncColdCachePolicy(); syncAdvancedCachePolicy(); diff --git a/src/admin/web/index.html b/src/admin/web/index.html index c88301f..e877f5f 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -265,6 +265,10 @@
Fill / cold queue / cold I/O
-
+
+
COLD storage / budget
+
-
+
Evicted / expired
-
@@ -339,6 +343,26 @@
RSS total
-
+
+
HOT cache RAM / limit
+
-
+
+
+
Cache transient RAM / limit
+
-
+
+
+
Transform reserved RAM / budget
+
-
+
+
+
Active transforms / limit
+
-
+
+
+
Non-cache process RSS
+
-
+
Anonymous RSS
-
@@ -356,8 +380,7 @@
-
-

OS RSS categories and RustProxy cache - accounting are overlapping views; do not add them together.

+

RSS is the real process footprint. HOT/transient cache values are RustProxy-accounted allocations; transform memory is a conservative active reservation. Non-cache RSS is RSS minus accounted cache and includes transforms, connections, TLS, logging, Tokio/runtime state, allocator overhead and other buffers, so these rows are not all additive.

Process CPU @@ -1099,6 +1122,10 @@ id="tp-skipped">--
Body/transport errors--
+
Transform RAM reservation--
+
Active transform slots--
@@ -1326,7 +1353,7 @@

Diagnostic headers

@@ -1377,8 +1404,9 @@ multipart/unsupported ranges pass through.

-

Memory sizing

- +

Memory sizing

+ - + -

Presets use the effective memory available to RustProxy (host RAM capped by cgroup when present). They @@ -1387,7 +1415,7 @@

- @@ -1480,6 +1508,17 @@ deterministic sampling and a global concurrency cap to avoid refresh storms.

diff --git a/src/admin/web/transform-panel.js b/src/admin/web/transform-panel.js index 6e17455..4c19a96 100644 --- a/src/admin/web/transform-panel.js +++ b/src/admin/web/transform-panel.js @@ -134,6 +134,13 @@ $("tp-skipped").textContent = number(counters.skipped); $("tp-errors").textContent = number(counters.errors); $("tp-duration").textContent = `${t("transformAvgTime")}: ${averageMs(counters)}`; + const resources = data?.resources || {}; + const reserved = Number(resources.memory_reserved_bytes), budget = Number(resources.memory_budget_bytes); + const inflight = Number(resources.inflight), maxInflight = Number(resources.max_inflight); + $("tp-memory").textContent = Number.isFinite(reserved) ? fmtBytes(reserved) : "-"; + $("tp-memory-detail").textContent = Number.isFinite(budget) ? `${t("transformBudgetShort")}: ${fmtBytes(budget)}${budget > 0 && Number.isFinite(reserved) ? ` ยท ${fmt(reserved / budget * 100, 1)}%` : ""}` : "-"; + $("tp-inflight").textContent = Number.isFinite(inflight) ? number(inflight) : "-"; + $("tp-inflight-detail").textContent = Number.isFinite(maxInflight) ? `${t("cacheLimitShort")}: ${number(maxInflight)}` : "-"; renderTypes(counters); renderReasons(counters); renderVhosts(data); $("transform-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`; } diff --git a/src/admin/web/vhosts.js b/src/admin/web/vhosts.js index e78c33e..b38375d 100644 --- a/src/admin/web/vhosts.js +++ b/src/admin/web/vhosts.js @@ -600,7 +600,7 @@ authMode.addEventListener("change", syncAuth); syncAuth(); g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers; const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] }); - const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.5", "text", { maxLength: 256 }); + const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.7", "text", { maxLength: 256 }); const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer(); const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = []; for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox")); diff --git a/src/cache/cold.rs b/src/cache/cold.rs index 143f9a8..cbd1a09 100644 --- a/src/cache/cold.rs +++ b/src/cache/cold.rs @@ -720,22 +720,37 @@ impl Cache { } } - fn enqueue_cold_delete(&self, paths: Vec) { + fn enqueue_cold_delete(&self, mut paths: Vec) { if paths.is_empty() { return; } if let Some(tx) = &self.cold_tx { - if tx.try_send(ColdCommand::Delete(paths.clone())).is_ok() { - return; + match tx.try_send(ColdCommand::Delete(paths)) { + Ok(()) => return, + Err(error) => { + paths = match error.into_inner() { + ColdCommand::Delete(paths) => paths, + _ => unreachable!("enqueue_cold_delete only sends delete commands"), + }; + } } } - // Queue saturation must not block request processing. The index is already - // updated; a best-effort detached delete keeps physical cleanup asynchronous. - tokio::spawn(async move { + // Do not create an unbounded number of detached delete tasks when the + // writer queue is saturated. A tiny fallback pool absorbs short bursts; + // sustained saturation falls back to synchronous removal so stale files + // cannot be resurrected on restart at the cost of bounded backpressure. + if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() { + tokio::spawn(async move { + let _permit = permit; + for path in paths { + let _ = tokio::fs::remove_file(path).await; + } + }); + } else { for path in paths { - let _ = tokio::fs::remove_file(path).await; + let _ = std::fs::remove_file(path); } - }); + } } async fn remove_cold_candidates( @@ -748,8 +763,13 @@ impl Cache { const SNAPSHOT_BATCH: usize = 256; const DELETE_BATCH: usize = 32; + let requested_limit = request + .and_then(|value| value.oldest) + .unwrap_or(max_delete) + .min(max_delete); + let manual = request.is_some(); let now = SystemTime::now(); - let mut candidates = Vec::new(); + let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1)); let mut after_sequence = 0u64; // Copy candidate metadata in bounded chunks. Request-path lookups only @@ -788,27 +808,32 @@ impl Cache { }) }); if matches { - candidates.push((sequence, key, hits, last_access, stored_at)); + let priority = if manual { + CleanupPriority::Manual(stored_at, last_access, hits) + } else { + CleanupPriority::Automatic(hits, last_access, stored_at) + }; + let candidate = CleanupCandidate { + priority, + sequence, + key, + }; + if candidates.len() < requested_limit { + candidates.push(candidate); + } else if candidates + .peek() + .is_some_and(|worst| candidate.cmp(worst).is_lt()) + { + candidates.pop(); + candidates.push(candidate); + } } } tokio::task::yield_now().await; } - if request.is_some() { - candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| { - (*stored_at, *last_access, *hits) - }); - } else { - // Automatic GC prefers rarely used, least recently used, oldest data. - candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| { - (*hits, *last_access, *stored_at) - }); - } - - let requested_limit = request - .and_then(|value| value.oldest) - .unwrap_or(max_delete) - .min(max_delete); + let mut candidates = candidates.into_vec(); + candidates.sort_unstable(); let mut result = ColdCleanupResult::default(); let mut delete = Vec::new(); @@ -818,7 +843,7 @@ impl Cache { } { let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); - for (sequence, key, _, _, _) in chunk { + for candidate in chunk { if result.removed_entries >= requested_limit { break; } @@ -835,18 +860,21 @@ impl Cache { } if !cold .entries - .get(key) - .is_some_and(|entry| entry.sequence == *sequence && entry.pinned_reads == 0) + .get(&candidate.key) + .is_some_and(|entry| entry.sequence == candidate.sequence && entry.pinned_reads == 0) { continue; } - if let Some(entry) = cold.remove(key) { + if let Some(entry) = cold.remove(&candidate.key) { result.removed_entries += 1; result.freed_bytes = result.freed_bytes.saturating_add(entry.disk_bytes); delete.extend([entry.meta_path, entry.body_path]); } } } + // Keep pending deletion metadata bounded to one selection batch; a + // 10k-entry manual cleanup must not build one large Vec. + self.enqueue_cold_delete(std::mem::take(&mut delete)); tokio::task::yield_now().await; } @@ -855,11 +883,13 @@ impl Cache { .cold_evictions .fetch_add(result.removed_entries as u64, Ordering::Relaxed); } - self.enqueue_cold_delete(delete); result } async fn cold_gc_pass(&self) -> ColdCleanupResult { + let Ok(_cleanup_slot) = self.cleanup_slots.clone().try_acquire_owned() else { + return ColdCleanupResult::default(); + }; let settings = self .tier_settings .lock() @@ -903,6 +933,11 @@ impl Cache { .unwrap_or_else(|e| e.into_inner()) .clone(); ensure!(settings.cold_enabled, "cold cache is disabled"); + let cleanup_slot = self + .cleanup_slots + .clone() + .try_acquire_owned() + .map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?; let id = self.cleanup_sequence.fetch_add(1, Ordering::Relaxed).saturating_add(1); let job = ColdCleanupJob { id, @@ -924,6 +959,7 @@ impl Cache { } let cache = self.clone(); tokio::spawn(async move { + let _cleanup_slot = cleanup_slot; { let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner()); if let Some(job) = jobs.get_mut(&id) { diff --git a/src/cache/collapse.rs b/src/cache/collapse.rs index 14691bc..5c73b92 100644 --- a/src/cache/collapse.rs +++ b/src/cache/collapse.rs @@ -210,6 +210,19 @@ impl Cache { .proactive_scheduled_keys .lock() .unwrap_or_else(|e| e.into_inner()); + // Each scheduled key owns a sleeping Tokio task plus refresh context. + // Bound the pending set independently from the cache entry count so a + // large cache with long TTLs cannot turn into thousands of resident tasks. + let pending_limit = settings + .proactive_refresh_max_inflight + .saturating_mul(64) + .clamp(64, 256); + if scheduled.len() >= pending_limit && !scheduled.contains(&ticket.key) { + self.counters + .proactive_skipped + .fetch_add(1, Ordering::Relaxed); + return None; + } if !scheduled.insert(ticket.key) { return None; } diff --git a/src/cache/dashboard.rs b/src/cache/dashboard.rs index bdf107a..4ab83b9 100644 --- a/src/cache/dashboard.rs +++ b/src/cache/dashboard.rs @@ -38,6 +38,8 @@ impl Cache { cold_queue_limit_bytes: settings.cold_queue_max_bytes, cold_entries: cold.entries.len(), cold_bytes: cold.bytes, + cold_limit_bytes: settings.cold_max_bytes, + cold_limit_entries: settings.cold_max_entries, cold_hits: n(&self.counters.cold_hits), cold_promotions: n(&self.counters.cold_promotions), hot_demotions: n(&self.counters.hot_demotions), @@ -121,6 +123,8 @@ impl Cache { ), ("cold_entries", "gauge", view.cold_entries as u64), ("cold_bytes", "gauge", view.cold_bytes), + ("cold_limit_bytes", "gauge", view.cold_limit_bytes), + ("cold_limit_entries", "gauge", view.cold_limit_entries as u64), ("cold_hits_total", "counter", view.cold_hits), ("cold_promotions_total", "counter", view.cold_promotions), ("hot_demotions_total", "counter", view.hot_demotions), diff --git a/src/cache/lifecycle.rs b/src/cache/lifecycle.rs index d510f74..e8d2d9a 100644 --- a/src/cache/lifecycle.rs +++ b/src/cache/lifecycle.rs @@ -10,6 +10,8 @@ impl Cache { tier_settings: Mutex::new(Settings::default()), data_root: None, cold_tx: None, + cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)), + cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS)), fills: Arc::new(Semaphore::new(FILL_SLOTS)), fill_bytes_active: AtomicU64::new(0), transient_bytes_active: Arc::new(AtomicU64::new(0)), @@ -47,6 +49,8 @@ impl Cache { tier_settings: Mutex::new(settings), data_root, cold_tx: Some(tx), + cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)), + cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS)), fills: Arc::new(Semaphore::new(FILL_SLOTS)), fill_bytes_active: AtomicU64::new(0), transient_bytes_active: Arc::new(AtomicU64::new(0)), diff --git a/src/cache/model.rs b/src/cache/model.rs index 2f90e87..91cfc3c 100644 --- a/src/cache/model.rs +++ b/src/cache/model.rs @@ -6,7 +6,7 @@ use http_body::{Body as HttpBody, Frame, SizeHint}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, BinaryHeap, HashMap, HashSet}, path::{Component, Path, PathBuf}, pin::Pin, sync::{ @@ -21,12 +21,28 @@ use tokio_util::sync::CancellationToken; const MAX_HEADER_BYTES: usize = 32 * 1024; const FILL_SLOTS: usize = 8; +const COLD_DELETE_FALLBACKS: usize = 2; +const COLD_CLEANUP_SLOTS: usize = 1; +const BACKGROUND_REVALIDATION_LIMIT: usize = 8; // Cold writes can contain complete response bodies. Keep the queue deliberately // small so demotion cannot become a second, hidden RAM cache while disk is slow. const COLD_QUEUE: usize = 16; const COLD_FORMAT_VERSION: u8 = 3; type Key = [u8; 32]; +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +enum CleanupPriority { + Automatic(u64, SystemTime, SystemTime), + Manual(SystemTime, SystemTime, u64), +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +struct CleanupCandidate { + priority: CleanupPriority, + sequence: u64, + key: Key, +} + /// `Bytes` releases its allocation when the last reference is dropped, but /// glibc can keep large freed arenas mapped. After bulk cache demotions/purges, /// return releasable pages to the OS instead of letting RSS stay artificially @@ -59,10 +75,28 @@ impl Default for CollapseTimeoutFallback { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MemoryPreset { + Conservative, + Recommended, + Performance, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ColdPreset { + Recommended, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Settings { pub enabled: bool, + /// UI sizing profile last explicitly selected by the administrator. The + /// actual numeric limits remain authoritative; this only preserves intent + /// across reloads so the dashboard can show which preset was applied. + pub memory_preset: Option, /// Hard RAM budget for published HOT entries, charged by backing allocation /// plus directly owned metadata rather than only logical body length. pub max_bytes: usize, @@ -80,6 +114,9 @@ pub struct Settings { pub hot_eviction_max_objects: usize, /// Persistent cold tier. Disabled by default to avoid implicit disk writes. pub cold_enabled: bool, + /// Last explicitly selected COLD policy profile, persisted with the cache + /// configuration for the same reason as `memory_preset`. + pub cold_preset: Option, /// Relative to the RustProxy data directory. pub cold_path: String, pub cold_max_bytes: u64, @@ -135,6 +172,7 @@ impl Default for Settings { fn default() -> Self { Self { enabled: true, + memory_preset: None, max_bytes: 256 * 1024 * 1024, max_object_bytes: 16 * 1024 * 1024, max_entries: 4096, @@ -142,6 +180,7 @@ impl Default for Settings { cold_queue_max_bytes: 32 * 1024 * 1024, hot_eviction_max_objects: 64, cold_enabled: false, + cold_preset: None, cold_path: "cache/cold".into(), cold_max_bytes: 4 * 1024 * 1024 * 1024, cold_max_entries: 50_000, @@ -572,6 +611,8 @@ pub struct View { pub cold_queue_limit_bytes: usize, pub cold_entries: usize, pub cold_bytes: u64, + pub cold_limit_bytes: u64, + pub cold_limit_entries: usize, pub cold_hits: u64, pub cold_promotions: u64, pub hot_demotions: u64, @@ -1067,6 +1108,8 @@ pub struct Cache { tier_settings: Mutex, data_root: Option, cold_tx: Option>, + cold_delete_fallbacks: Arc, + cleanup_slots: Arc, fills: Arc, fill_bytes_active: AtomicU64, transient_bytes_active: Arc, diff --git a/src/cache/tests.rs b/src/cache/tests.rs index 517df0e..808ea90 100644 --- a/src/cache/tests.rs +++ b/src/cache/tests.rs @@ -662,6 +662,8 @@ mod tests { #[test] fn cache_memory_defaults_are_bounded_and_sane() { let s = Settings::default(); + assert_eq!(s.memory_preset, None); + assert_eq!(s.cold_preset, None); assert_eq!(s.max_bytes, 256 * 1024 * 1024); assert_eq!(s.max_object_bytes, 16 * 1024 * 1024); assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024); @@ -672,6 +674,19 @@ mod tests { assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024); } + #[test] + fn cache_preset_intent_round_trips_with_configuration() { + let mut settings = Settings::default(); + settings.memory_preset = Some(MemoryPreset::Recommended); + settings.cold_preset = Some(ColdPreset::Recommended); + let encoded = serde_json::to_value(&settings).unwrap(); + assert_eq!(encoded["memory_preset"], "recommended"); + assert_eq!(encoded["cold_preset"], "recommended"); + let decoded: Settings = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended)); + assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended)); + } + #[test] fn transient_budget_is_hard_and_released_with_lease() { let cache = Cache::new(1); @@ -2142,6 +2157,84 @@ mod v022_cache_tests { assert_eq!(cache.view().proactive_inflight, 0); } + #[test] + fn proactive_refresh_pending_tasks_are_bounded() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.proactive_refresh_enabled = true; + settings.proactive_refresh_percent = 100; + settings.proactive_refresh_max_inflight = 1; + let mut guards = Vec::new(); + for index in 0..64 { + let ticket = cache + .ticket( + 22, + "http", + "refresh.example", + "origin", + &format!("/asset-{index}"), + &HeaderMap::new(), + ) + .unwrap(); + guards.push( + cache + .begin_proactive_schedule(&ticket, &settings) + .expect("schedule below pending limit"), + ); + } + let overflow = cache + .ticket( + 22, + "http", + "refresh.example", + "origin", + "/overflow", + &HeaderMap::new(), + ) + .unwrap(); + assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none()); + assert!(cache.view().proactive_skipped > 0); + drop(guards); + } + + #[test] + fn background_revalidation_set_has_a_hard_limit() { + let cache = Cache::new(22); + let base = Revalidation { + headers: HeaderMap::new(), + body: Bytes::new(), + body_allocated: 0, + _transient_leases: Vec::new(), + hits: 0, + transformed: false, + storage_key: [0; 32], + resident_age_at_lookup: Duration::ZERO, + skip_compression: false, + reusable_without_validation: false, + age: 0, + ttl: 0, + stale_if_error: 0, + must_revalidate: false, + proxy_revalidate: false, + origin_etag: None, + origin_last_modified: None, + }; + let mut guards = Vec::new(); + for index in 0..BACKGROUND_REVALIDATION_LIMIT { + let mut cached = base.clone(); + cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes()); + guards.push( + cache + .begin_background_revalidation(&cached) + .expect("revalidation below hard limit"), + ); + } + let mut overflow = base; + overflow.storage_key = [0xff; 32]; + assert!(cache.begin_background_revalidation(&overflow).is_none()); + drop(guards); + } + #[tokio::test] async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() { let cache = Cache::new(22); @@ -3036,6 +3129,22 @@ mod cold_tier_tests { panic!("cold cleanup did not finish"); } + #[tokio::test] + async fn manual_cold_cleanup_is_single_flight() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone()); + let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap(); + let result = cache.start_cold_cleanup(ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }); + assert!(result.is_err()); + shutdown.cancel(); + } + #[tokio::test] async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/cache/tickets.rs b/src/cache/tickets.rs index 5ade84c..8f818ec 100644 --- a/src/cache/tickets.rs +++ b/src/cache/tickets.rs @@ -310,6 +310,9 @@ impl Cache { // representation so an SWR refresh for one variant cannot suppress another. let key = cached.storage_key; let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner()); + if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) { + return None; + } if !active.insert(key) { return None; } diff --git a/src/db.rs b/src/db.rs index f583f93..62e4281 100644 --- a/src/db.rs +++ b/src/db.rs @@ -385,7 +385,12 @@ mod tests { let file = dir.path().join("test.sqlite3"); let store = Store::open(&file, &Config::default()).await.unwrap(); assert_eq!(store.load().await.unwrap().revision, 0); - let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + config.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended); + config.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended); + config.transform.preset = Some(crate::transform::Preset::Performance); + config.transform.memory_budget_bytes = 512 * 1024 * 1024; + config.transform.max_inflight = 64; assert!(store.replace(0, &config).await.unwrap()); assert!(!store.replace(0, &Config::default()).await.unwrap()); assert_eq!(store.recent_audit().await.unwrap().len(), 1); @@ -394,6 +399,20 @@ mod tests { let data = reopened.load().await.unwrap(); assert_eq!(data.revision, 1); assert_eq!(data.config.vhosts[0].backends[0].id, "demo"); + assert_eq!( + data.config.cache.memory_preset, + Some(crate::cache::MemoryPreset::Recommended) + ); + assert_eq!( + data.config.cache.cold_preset, + Some(crate::cache::ColdPreset::Recommended) + ); + assert_eq!( + data.config.transform.preset, + Some(crate::transform::Preset::Performance) + ); + assert_eq!(data.config.transform.memory_budget_bytes, 512 * 1024 * 1024); + assert_eq!(data.config.transform.max_inflight, 64); reopened.close().await; } #[tokio::test] diff --git a/src/main.rs b/src/main.rs index 4437744..4769c16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,7 @@ use zeroize::Zeroizing; #[command( name = "rust-proxy", version, - about = "RustProxy - independent Rust reverse proxy 0.24.5" + about = "RustProxy - independent Rust reverse proxy 0.24.7" )] struct Cli { #[arg( @@ -1426,7 +1426,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> { } }); let interactive = io::stdin().is_terminal() && io::stdout().is_terminal(); - println!("RustProxy live logs 0.24.5 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits."); + println!("RustProxy live logs 0.24.7 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits."); if interactive { print!("> "); io::stdout().flush()?; } let mut filters = LiveLogFilters::default(); let mut history = std::collections::BTreeMap::::new(); diff --git a/src/runtime.rs b/src/runtime.rs index db09536..bb2dbeb 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -83,8 +83,23 @@ enum CacheInvalidation { /// Keeping it stable across logging/TLS/admin saves lets the persistent cold /// tier survive a restart after unrelated control-plane edits. fn cache_revision(config: &config::Config) -> u64 { + // Preset fields are UI intent metadata. Persist them, but do not rotate the + // cache compatibility identity (and purge HOT/COLD) when only the label + // changes while the effective cache limits stay identical. + let mut cache = config.cache.clone(); + cache.memory_preset = None; + cache.cold_preset = None; + // Resource-only transform controls decide how much optimization work may + // happen concurrently, but do not change the transformed representation. + // Keep those out of the cache identity while including representation and + // eligibility controls such as enabled kinds, extensions and max body. + let mut transform = config.transform.clone(); + transform.preset = None; + transform.memory_budget_bytes = 0; + transform.max_inflight = 0; let encoded = serde_json::to_vec(&( - &config.cache, + &cache, + &transform, &config.headers, &config.forwarding, &config.vhosts, @@ -605,6 +620,26 @@ mod cache_revision_tests { }); assert_eq!(cache_revision(&docker), identity); + let mut preset = base.clone(); + preset.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended); + preset.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended); + preset.transform.preset = Some(crate::transform::Preset::Uber); + preset.transform.memory_budget_bytes = 1024 * 1024 * 1024; + preset.transform.max_inflight = 128; + assert_eq!(cache_revision(&preset), identity); + assert!(matches!( + cache_invalidation(&base, &preset), + CacheInvalidation::None + )); + + let mut transform_policy = base.clone(); + transform_policy.transform.json = !transform_policy.transform.json; + assert_ne!(cache_revision(&transform_policy), identity); + + let mut transform_body_limit = base.clone(); + transform_body_limit.transform.max_body_bytes += 1024; + assert_ne!(cache_revision(&transform_body_limit), identity); + let mut cache = base.clone(); cache.cache.max_entries += 1; let cache_identity = cache_revision(&cache); diff --git a/src/system_stats/bsd.rs b/src/system_stats/bsd.rs deleted file mode 100644 index 1b91899..0000000 --- a/src/system_stats/bsd.rs +++ /dev/null @@ -1,180 +0,0 @@ -use super::{now_ms, platform_name, NetworkInterfaceView, View}; -use std::{collections::BTreeMap, process::Command}; - -#[derive(Default)] -pub(crate) struct Counters { - cpu_total: Option, - cpu_idle: Option, - process_cpu_micros: Option, -} - -fn command_output(program: &str, args: &[&str]) -> Option { - let output = Command::new(program).args(args).output().ok()?; - output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()) -} - -fn sysctl(name: &str) -> Option { - command_output("sysctl", &["-n", name]) -} - -fn sysctl_u64(names: &[&str]) -> Option { - names.iter().find_map(|name| sysctl(name)?.split_whitespace().next()?.parse().ok()) -} - -fn cpu_times() -> Option<(u64, u64)> { - let values: Vec = sysctl("kern.cp_time")? - .split_whitespace() - .filter_map(|value| value.parse().ok()) - .collect(); - if values.len() < 5 { - return None; - } - Some((values.iter().sum(), values[4])) -} - -fn process_cpu_micros() -> Option { - let mut usage = std::mem::MaybeUninit::::zeroed(); - // SAFETY: getrusage initializes the supplied rusage structure on success. - let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; - if rc != 0 { - return None; - } - // SAFETY: rc == 0 guarantees the structure was initialized by libc. - let usage = unsafe { usage.assume_init() }; - let user = u64::try_from(usage.ru_utime.tv_sec).ok()?.saturating_mul(1_000_000) - + u64::try_from(usage.ru_utime.tv_usec).ok()?; - let system = u64::try_from(usage.ru_stime.tv_sec).ok()?.saturating_mul(1_000_000) - + u64::try_from(usage.ru_stime.tv_usec).ok()?; - Some(user.saturating_add(system)) -} - -fn load_average() -> Option<[f64; 3]> { - let raw = sysctl("vm.loadavg")?; - let values: Vec = raw - .trim_matches(|c: char| c == '{' || c == '}' || c.is_whitespace()) - .split_whitespace() - .filter_map(|value| value.parse().ok()) - .collect(); - (values.len() >= 3).then(|| [values[0], values[1], values[2]]) -} - -fn process_rss_bytes() -> Option { - let pid = std::process::id().to_string(); - command_output("ps", &["-o", "rss=", "-p", &pid])? - .split_whitespace() - .next()? - .parse::() - .ok() - .map(|kib| kib.saturating_mul(1024)) -} - -fn network_interfaces() -> Vec { - let Some(text) = command_output("netstat", &["-ibn"]) else { - return Vec::new(); - }; - let mut lines = text.lines(); - let Some(header) = lines.next() else { - return Vec::new(); - }; - let columns: Vec<_> = header.split_whitespace().collect(); - let name_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Name")); - let ibytes_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Ibytes")); - let obytes_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Obytes")); - let (Some(name_index), Some(ibytes_index), Some(obytes_index)) = - (name_index, ibytes_index, obytes_index) - else { - return Vec::new(); - }; - let mut interfaces: BTreeMap = BTreeMap::new(); - for line in lines { - let fields: Vec<_> = line.split_whitespace().collect(); - if fields.len() <= name_index || fields.len() <= ibytes_index || fields.len() <= obytes_index { - continue; - } - let name = fields[name_index].trim_end_matches('*'); - if name.is_empty() || name == "lo0" { - continue; - } - let (Ok(rx), Ok(tx)) = (fields[ibytes_index].parse::(), fields[obytes_index].parse::()) else { - continue; - }; - let entry = interfaces.entry(name.to_owned()).or_default(); - entry.0 = entry.0.max(rx); - entry.1 = entry.1.max(tx); - } - interfaces - .into_iter() - .take(64) - .map(|(name, (rx_bytes, tx_bytes))| NetworkInterfaceView { - name, - rx_bytes, - tx_bytes, - rx_bytes_per_second: None, - tx_bytes_per_second: None, - }) - .collect() -} - -pub(crate) fn sample(previous: Option<(View, Counters)>) -> (View, Counters) { - let (cpu_total, cpu_idle) = cpu_times().map_or((None, None), |(total, idle)| (Some(total), Some(idle))); - let counters = Counters { - cpu_total, - cpu_idle, - process_cpu_micros: process_cpu_micros(), - }; - let mut view = View { - source: "bsd_native", - platform: platform_name(), - sampled_at_ms: now_ms(), - logical_cpus: sysctl_u64(&["hw.ncpu", "hw.ncpuonline"]) - .and_then(|value| usize::try_from(value).ok()) - .or_else(|| std::thread::available_parallelism().ok().map(usize::from)), - memory_total_bytes: sysctl_u64(&["hw.physmem64", "hw.physmem"]), - process_rss_bytes: process_rss_bytes(), - load_average: load_average(), - network_interfaces: network_interfaces(), - ..Default::default() - }; - if !view.network_interfaces.is_empty() { - view.network_rx_bytes = Some(view.network_interfaces.iter().map(|item| item.rx_bytes).sum()); - view.network_tx_bytes = Some(view.network_interfaces.iter().map(|item| item.tx_bytes).sum()); - } - if let Some((old, old_counters)) = previous { - let system_total = counters - .cpu_total - .zip(old_counters.cpu_total) - .and_then(|(new, old)| new.checked_sub(old)) - .filter(|value| *value > 0); - if let Some(total) = system_total { - view.cpu_percent = counters - .cpu_idle - .zip(old_counters.cpu_idle) - .and_then(|(new, old)| new.checked_sub(old)) - .map(|idle| (100.0 * (1.0 - idle as f64 / total as f64)).clamp(0.0, 100.0)); - } - let seconds = view.sampled_at_ms.saturating_sub(old.sampled_at_ms) as f64 / 1000.0; - if seconds > 0.0 { - view.process_cpu_percent = counters - .process_cpu_micros - .zip(old_counters.process_cpu_micros) - .and_then(|(new, old)| new.checked_sub(old)) - .map(|micros| micros as f64 / (seconds * 10_000.0)); - view.network_rx_bytes_per_second = view - .network_rx_bytes - .zip(old.network_rx_bytes) - .map(|(new, old)| new.saturating_sub(old) as f64 / seconds); - view.network_tx_bytes_per_second = view - .network_tx_bytes - .zip(old.network_tx_bytes) - .map(|(new, old)| new.saturating_sub(old) as f64 / seconds); - for interface in &mut view.network_interfaces { - if let Some(previous) = old.network_interfaces.iter().find(|old| old.name == interface.name) { - interface.rx_bytes_per_second = Some(interface.rx_bytes.saturating_sub(previous.rx_bytes) as f64 / seconds); - interface.tx_bytes_per_second = Some(interface.tx_bytes.saturating_sub(previous.tx_bytes) as f64 / seconds); - } - } - } - } - // Keep unknown/unsupported BSD metrics as None instead of substituting a different semantic. - (view, counters) -} diff --git a/src/transform/css.rs b/src/transform/css.rs index 11bf6ff..cb3c881 100644 --- a/src/transform/css.rs +++ b/src/transform/css.rs @@ -6,12 +6,15 @@ pub fn minify(body: &[u8]) -> Result> { let mut chars = input.chars().peekable(); let mut quote: Option = None; let mut pending_space = false; + let mut last_char: Option = None; while let Some(ch) = chars.next() { if let Some(q) = quote { out.push(ch); + last_char = Some(ch); if ch == '\\' { if let Some(next) = chars.next() { out.push(next); + last_char = Some(next); } } else if ch == q { quote = None; @@ -19,12 +22,14 @@ pub fn minify(body: &[u8]) -> Result> { continue; } if matches!(ch, '\'' | '"') { - if pending_space && needs_space(out.chars().last(), Some(ch)) { + if pending_space && needs_space(last_char, Some(ch)) { out.push(' '); + last_char = Some(' '); } pending_space = false; quote = Some(ch); out.push(ch); + last_char = Some(ch); continue; } if ch == '/' && chars.peek() == Some(&'*') { @@ -43,14 +48,17 @@ pub fn minify(body: &[u8]) -> Result> { pending_space = true; continue; } - if pending_space && needs_space(out.chars().last(), Some(ch)) { + if pending_space && needs_space(last_char, Some(ch)) { out.push(' '); + last_char = Some(' '); } pending_space = false; - if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && out.ends_with(' ') { + if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && last_char == Some(' ') { out.pop(); + last_char = out.chars().last(); } out.push(ch); + last_char = Some(ch); } let trimmed_len = out.trim_end().len(); out.truncate(trimmed_len); diff --git a/src/transform/html.rs b/src/transform/html.rs index f48ceb7..8fe3f57 100644 --- a/src/transform/html.rs +++ b/src/transform/html.rs @@ -1,5 +1,13 @@ use anyhow::{Context, Result}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RawElement { + Script, + Style, + Textarea, + Pre, +} + /// Conservative HTML minification. /// /// HTML whitespace can be semantically significant, especially between inline @@ -11,13 +19,15 @@ pub fn minify(body: &[u8]) -> Result> { let bytes = input.as_bytes(); let mut out = String::with_capacity(input.len()); let mut cursor = 0usize; - let mut raw_element: Option = None; + let mut raw_element: Option = None; while cursor < bytes.len() { if bytes[cursor] == b'<' { if raw_element.is_none() && starts_with_ascii_ci(bytes, cursor, b"") else { - return Ok(body.to_vec()); + out.clear(); + out.push_str(input); + return Ok(out.into_bytes()); }; let end = end + 3; out.push_str(&input[cursor..end]); @@ -26,7 +36,9 @@ pub fn minify(body: &[u8]) -> Result> { } if raw_element.is_none() && bytes[cursor..].starts_with(b"") else { - return Ok(body.to_vec()); + out.clear(); + out.push_str(input); + return Ok(out.into_bytes()); }; cursor = end + 3; continue; @@ -35,15 +47,12 @@ pub fn minify(body: &[u8]) -> Result> { if let Some(end) = find_tag_end(bytes, cursor + 1) { let tag = &input[cursor..=end]; out.push_str(tag); - if let Some((name, closing, self_closing)) = tag_name(tag) { + if let Some((name, closing, self_closing)) = raw_tag(tag) { if closing { - if raw_element.as_deref() == Some(name.as_str()) { + if raw_element == Some(name) { raw_element = None; } - } else if !self_closing - && raw_element.is_none() - && matches!(name.as_str(), "script" | "style" | "textarea" | "pre") - { + } else if !self_closing && raw_element.is_none() { raw_element = Some(name); } } @@ -91,7 +100,7 @@ fn find_tag_end(bytes: &[u8], mut cursor: usize) -> Option { None } -fn tag_name(tag: &str) -> Option<(String, bool, bool)> { +fn raw_tag(tag: &str) -> Option<(RawElement, bool, bool)> { let inner = tag.strip_prefix('<')?.strip_suffix('>')?.trim(); if inner.starts_with('!') || inner.starts_with('?') { return None; @@ -101,9 +110,19 @@ fn tag_name(tag: &str) -> Option<(String, bool, bool)> { let self_closing = inner.ends_with('/'); let name = inner .split(|ch: char| ch.is_ascii_whitespace() || ch == '/' || ch == '>') - .next()? - .to_ascii_lowercase(); - (!name.is_empty()).then_some((name, closing, self_closing)) + .next()?; + let name = if name.eq_ignore_ascii_case("script") { + RawElement::Script + } else if name.eq_ignore_ascii_case("style") { + RawElement::Style + } else if name.eq_ignore_ascii_case("textarea") { + RawElement::Textarea + } else if name.eq_ignore_ascii_case("pre") { + RawElement::Pre + } else { + return None; + }; + Some((name, closing, self_closing)) } #[cfg(test)] diff --git a/src/transform/json.rs b/src/transform/json.rs index 6b722f2..9d5ff95 100644 --- a/src/transform/json.rs +++ b/src/transform/json.rs @@ -1,6 +1,54 @@ use anyhow::{Context, Result}; +use serde::{de::IgnoredAny, Deserialize}; +/// Validate JSON without materializing a full `serde_json::Value`, then remove +/// insignificant ASCII whitespace in one pass. This keeps peak memory close to +/// input + output instead of input + parsed tree + serialized output. pub fn compact(body: &[u8]) -> Result> { - let value: serde_json::Value = serde_json::from_slice(body).context("parse JSON")?; - serde_json::to_vec(&value).context("serialize compact JSON") + let mut deserializer = serde_json::Deserializer::from_slice(body); + IgnoredAny::deserialize(&mut deserializer).context("parse JSON")?; + deserializer.end().context("parse JSON trailing data")?; + + let mut output = Vec::with_capacity(body.len()); + let mut in_string = false; + let mut escaped = false; + for &byte in body { + if in_string { + output.push(byte); + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + continue; + } + if byte == b'"' { + in_string = true; + output.push(byte); + } else if !matches!(byte, b' ' | b'\n' | b'\r' | b'\t') { + output.push(byte); + } + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compacts_without_rewriting_values_or_duplicate_keys() { + let input = br#"{ "n": 1.2300e+02, "s": "a b\\n c", "k": 1, "k": 2 }"#; + assert_eq!( + compact(input).unwrap(), + br#"{"n":1.2300e+02,"s":"a b\\n c","k":1,"k":2}"# + ); + } + + #[test] + fn rejects_invalid_json_instead_of_stripping_whitespace_blindly() { + assert!(compact(br#"{"a": 1,}"#).is_err()); + } } diff --git a/src/transform/mod.rs b/src/transform/mod.rs index 6bc353e..21bc9eb 100644 --- a/src/transform/mod.rs +++ b/src/transform/mod.rs @@ -3,13 +3,16 @@ use anyhow::{ensure, Context as _, Result}; use axum::{body::Body, response::Response}; use http::{HeaderMap, HeaderValue}; -use http_body_util::BodyExt; +use http_body_util::{BodyExt, Limited}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::borrow::Cow; use std::collections::BTreeMap; use std::io::Read; -use std::sync::Mutex; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, +}; use std::time::Duration; mod css; @@ -19,9 +22,23 @@ mod json; mod svg; const DEFAULT_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; +const DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024; +const DEFAULT_MAX_INFLIGHT: usize = 32; +const TRANSFORM_MEMORY_UNIT_BYTES: usize = 64 * 1024; +static TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize = AtomicUsize::new(0); +static TRANSFORM_INFLIGHT: AtomicUsize = AtomicUsize::new(0); #[cfg(all(target_os = "linux", target_env = "gnu"))] const ALLOCATOR_TRIM_MIN_BODY_BYTES: usize = 256 * 1024; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Preset { + Conservative, + Recommended, + Performance, + Uber, +} + /// Large transformable responses can temporarily own the collected origin body, /// a decoded buffer and the transformed output at the same time. On glibc, freed /// pages may remain in malloc arenas for a long time. The systemd unit also caps @@ -65,6 +82,10 @@ fn trim_allocator_after_large_transform(released_bytes: usize) { pub struct Settings { /// Global kill switch. Per-vhost opt-in is still required. pub enabled: bool, + /// Last resource profile explicitly selected in the administrator UI. The + /// numeric limits below are authoritative; this field only preserves UI + /// intent across reloads. + pub preset: Option, pub javascript: bool, pub css: bool, pub html: bool, @@ -75,11 +96,18 @@ pub struct Settings { pub extensions: Vec, /// Hard aggregation/decode ceiling for transformable static responses. pub max_body_bytes: usize, + /// Process-wide ceiling for estimated temporary response buffers owned by + /// active transformations. This is a limit, not a pre-allocation. + pub memory_budget_bytes: usize, + /// Process-wide number of transformation attempts allowed to own an active + /// transform slot at the same time. Excess work fails open unchanged. + pub max_inflight: usize, } impl Default for Settings { fn default() -> Self { Self { enabled: false, + preset: None, javascript: true, css: true, html: false, @@ -92,6 +120,8 @@ impl Default for Settings { .map(str::to_owned) .collect(), max_body_bytes: DEFAULT_MAX_BODY_BYTES, + memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES, + max_inflight: DEFAULT_MAX_INFLIGHT, } } } @@ -101,6 +131,14 @@ impl Settings { (1024..=32 * 1024 * 1024).contains(&self.max_body_bytes), "transform.max_body_bytes must be 1 KiB..32 MiB" ); + ensure!( + (1024 * 1024..=2 * 1024 * 1024 * 1024).contains(&self.memory_budget_bytes), + "transform.memory_budget_bytes must be 1 MiB..2 GiB" + ); + ensure!( + (1..=1024).contains(&self.max_inflight), + "transform.max_inflight must be 1..1024" + ); ensure!(self.extensions.len() <= 64, "at most 64 transform extensions"); let mut seen = std::collections::HashSet::new(); for extension in &self.extensions { @@ -296,6 +334,24 @@ pub struct MetricsView { pub scopes: Vec, } +#[derive(Debug, Clone, Copy, Serialize)] +pub struct ResourceUsage { + /// Estimated temporary transform bytes currently reserved by active work. + pub memory_reserved_bytes: usize, + pub memory_budget_bytes: usize, + pub inflight: usize, + pub max_inflight: usize, +} + +pub fn resource_usage(settings: &Settings) -> ResourceUsage { + ResourceUsage { + memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load(Ordering::Acquire), + memory_budget_bytes: settings.memory_budget_bytes, + inflight: TRANSFORM_INFLIGHT.load(Ordering::Acquire), + max_inflight: settings.max_inflight, + } +} + #[derive(Default)] struct MetricsStore { total: Counters, @@ -505,14 +561,27 @@ fn supported_encoding(headers: &HeaderMap) -> Option> { } fn decode<'a>(body: &'a [u8], encoding: Option<&str>, limit: usize) -> Result> { - fn read_bounded(reader: impl Read, limit: usize, label: &'static str) -> Result> { + fn read_bounded(mut reader: impl Read, limit: usize, label: &'static str) -> Result> { let mut output = Vec::with_capacity(limit.min(64 * 1024)); - let mut bounded = reader.take(limit.saturating_add(1) as u64); - bounded.read_to_end(&mut output).with_context(|| format!("decode {label}"))?; - ensure!( - output.len() <= limit, - "decoded transform body exceeds configured limit" - ); + let mut chunk = [0u8; 16 * 1024]; + loop { + let read = reader + .read(&mut chunk) + .with_context(|| format!("decode {label}"))?; + if read == 0 { + break; + } + ensure!( + output.len().saturating_add(read) <= limit, + "decoded transform body exceeds configured limit" + ); + if output.capacity().saturating_sub(output.len()) < read { + output + .try_reserve_exact(read) + .map_err(|_| anyhow::anyhow!("decoded transform allocation failed"))?; + } + output.extend_from_slice(&chunk[..read]); + } Ok(output) } @@ -588,15 +657,123 @@ fn drop_vary_accept_encoding(headers: &mut HeaderMap) { fn transformed_etag(body: &[u8]) -> HeaderValue { let digest = Sha256::digest(body); - let mut hex = String::with_capacity(64); + let mut value = String::with_capacity(76); + value.push_str("\"rustproxy-"); for byte in digest { use std::fmt::Write as _; - let _ = write!(&mut hex, "{byte:02x}"); + let _ = write!(&mut value, "{byte:02x}"); } - HeaderValue::from_str(&format!("\"rustproxy-{hex}\"")) + value.push('"'); + HeaderValue::from_str(&value) .expect("sha256 validator is always a valid header") } +fn transform_memory_required_bytes( + content_length: usize, + encoding: Option<&str>, + max_body_bytes: usize, +) -> Option { + let estimated = if encoding.is_none() { + content_length.checked_mul(2)? + } else { + content_length.checked_add(max_body_bytes.checked_mul(2)?)? + }; + let units = estimated.checked_add(TRANSFORM_MEMORY_UNIT_BYTES - 1)? + / TRANSFORM_MEMORY_UNIT_BYTES; + units.max(1).checked_mul(TRANSFORM_MEMORY_UNIT_BYTES) +} + +struct TransformMemoryReservation<'a> { + counter: &'a AtomicUsize, + bytes: usize, +} + +impl Drop for TransformMemoryReservation<'_> { + fn drop(&mut self) { + self.counter.fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +fn try_reserve_memory_from( + counter: &AtomicUsize, + required_bytes: usize, + budget_bytes: usize, +) -> Option> { + if required_bytes > budget_bytes { + return None; + } + let mut current = counter.load(Ordering::Acquire); + loop { + let next = current.checked_add(required_bytes)?; + if next > budget_bytes { + return None; + } + match counter.compare_exchange_weak( + current, + next, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + return Some(TransformMemoryReservation { + counter, + bytes: required_bytes, + }) + } + Err(actual) => current = actual, + } + } +} + +fn try_reserve_transform_memory( + required_bytes: usize, + budget_bytes: usize, +) -> Option> { + try_reserve_memory_from( + &TRANSFORM_MEMORY_IN_USE_BYTES, + required_bytes, + budget_bytes, + ) +} + +struct TransformInflightReservation<'a> { + counter: &'a AtomicUsize, +} + +impl Drop for TransformInflightReservation<'_> { + fn drop(&mut self) { + self.counter.fetch_sub(1, Ordering::AcqRel); + } +} + +fn try_reserve_slot_from( + counter: &AtomicUsize, + max_inflight: usize, +) -> Option> { + let mut current = counter.load(Ordering::Acquire); + loop { + if current >= max_inflight { + return None; + } + let next = current.checked_add(1)?; + match counter.compare_exchange_weak( + current, + next, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Some(TransformInflightReservation { counter }), + Err(actual) => current = actual, + } + } +} + +fn try_reserve_transform_slot( + max_inflight: usize, +) -> Option> { + try_reserve_slot_from(&TRANSFORM_INFLIGHT, max_inflight) +} + /// Transform an eligible response. The call is fail-open for unsupported media, /// encodings and malformed minifiable content; transport/body collection errors /// still propagate because the original stream can no longer be replayed safely. @@ -651,12 +828,33 @@ pub async fn apply( if content_length > settings.max_body_bytes { return Ok(skip(response, Some(kind), "too_large")); } + let Some(required_memory) = + transform_memory_required_bytes(content_length, encoding, settings.max_body_bytes) + else { + return Ok(skip(response, Some(kind), "memory_budget")); + }; + let Some(_inflight_slot) = try_reserve_transform_slot(settings.max_inflight) else { + return Ok(skip(response, Some(kind), "concurrency_pressure")); + }; + let Some(_memory_reservation) = + try_reserve_transform_memory(required_memory, settings.memory_budget_bytes) + else { + // Transformations are an optimization. Under concurrent allocation + // pressure, preserve streaming/origin bytes instead of queueing more + // full-response buffers behind the memory budget. + return Ok(skip(response, Some(kind), "memory_pressure")); + }; let (mut parts, body) = response.into_parts(); - let raw = body + // Do not trust Content-Length as a memory bound. A broken or malicious + // upstream can send more bytes than declared; Limited stops aggregation at + // the configured ceiling instead of letting BodyExt::collect grow without + // bound. Once aggregation starts an over-limit stream cannot be replayed + // safely, so it is treated like the other body/transport collection errors. + let raw = Limited::new(body, settings.max_body_bytes) .collect() .await - .context("collect static response for transformation")? + .context("collect bounded static response for transformation")? .to_bytes(); if raw.len() > settings.max_body_bytes { let mut response = Response::from_parts(parts, Body::from(raw)); @@ -751,6 +949,114 @@ mod tests { assert!(matches!(decoded, Cow::Borrowed(_))); } + #[test] + fn transform_memory_budget_accounts_for_identity_and_decoded_buffers() { + assert_eq!( + transform_memory_required_bytes(2 * 1024 * 1024, None, 2 * 1024 * 1024), + Some(4 * 1024 * 1024) + ); + assert_eq!( + transform_memory_required_bytes( + 2 * 1024 * 1024, + Some("gzip"), + 2 * 1024 * 1024 + ), + Some(6 * 1024 * 1024) + ); + assert_eq!( + transform_memory_required_bytes( + DEFAULT_MEMORY_BUDGET_BYTES, + Some("gzip"), + DEFAULT_MEMORY_BUDGET_BYTES + ), + Some(3 * DEFAULT_MEMORY_BUDGET_BYTES) + ); + } + + #[test] + fn transform_memory_reservation_obeys_runtime_budget() { + let counter = AtomicUsize::new(0); + let required = 4 * 1024 * 1024; + let reservation = try_reserve_memory_from(&counter, required, required).unwrap(); + assert!(try_reserve_memory_from(&counter, required, required).is_none()); + drop(reservation); + assert!(try_reserve_memory_from(&counter, required, required).is_some()); + } + + #[test] + fn transform_inflight_reservation_obeys_runtime_limit() { + let counter = AtomicUsize::new(0); + let slot = try_reserve_slot_from(&counter, 1).unwrap(); + assert!(try_reserve_slot_from(&counter, 1).is_none()); + drop(slot); + assert!(try_reserve_slot_from(&counter, 1).is_some()); + } + + #[test] + fn resource_usage_exposes_configured_limits() { + let settings = Settings { + memory_budget_bytes: 512 * 1024 * 1024, + max_inflight: 64, + ..Default::default() + }; + let usage = resource_usage(&settings); + assert_eq!(usage.memory_budget_bytes, 512 * 1024 * 1024); + assert_eq!(usage.max_inflight, 64); + } + + #[test] + fn resource_preset_metadata_round_trips() { + let mut settings = Settings::default(); + settings.preset = Some(Preset::Uber); + settings.memory_budget_bytes = 1024 * 1024 * 1024; + settings.max_body_bytes = 32 * 1024 * 1024; + settings.max_inflight = 128; + let encoded = serde_json::to_value(&settings).unwrap(); + assert_eq!(encoded["preset"], "uber"); + let decoded: Settings = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.preset, Some(Preset::Uber)); + assert_eq!(decoded.memory_budget_bytes, 1024 * 1024 * 1024); + assert_eq!(decoded.max_inflight, 128); + } + + #[test] + fn legacy_transform_settings_receive_resource_defaults() { + let decoded: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap(); + assert!(decoded.enabled); + assert_eq!(decoded.preset, None); + assert_eq!(decoded.memory_budget_bytes, DEFAULT_MEMORY_BUDGET_BYTES); + assert_eq!(decoded.max_body_bytes, DEFAULT_MAX_BODY_BYTES); + assert_eq!(decoded.max_inflight, DEFAULT_MAX_INFLIGHT); + } + + #[test] + fn bounded_decoder_rejects_expansion_past_limit() { + use std::io::Write as _; + let identity = vec![b'x'; 128 * 1024]; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&identity).unwrap(); + let encoded = encoder.finish().unwrap(); + assert!(decode(&encoded, Some("gzip"), 64 * 1024).is_err()); + } + + #[tokio::test] + async fn aggregation_is_bounded_even_when_content_length_is_false() { + let settings = Settings { + enabled: true, + json: true, + max_body_bytes: 1024, + ..Default::default() + }; + let mut response = Response::new(Body::from(vec![b' '; 2048])); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("128")); + assert!(apply(response, "/data.json", &settings, false).await.is_err()); + } + #[test] fn metrics_track_applied_skipped_and_cached_transformed_responses() { let metrics = Metrics::default(); diff --git a/src/transform/svg.rs b/src/transform/svg.rs index b8777f1..840dceb 100644 --- a/src/transform/svg.rs +++ b/src/transform/svg.rs @@ -12,7 +12,9 @@ pub fn optimize(body: &[u8]) -> Result> { while cursor < bytes.len() { if bytes[cursor..].starts_with(b"") else { - return Ok(body.to_vec()); + out.clear(); + out.push_str(input); + return Ok(out.into_bytes()); }; let end = end + 3; out.push_str(&input[cursor..end]); @@ -21,7 +23,9 @@ pub fn optimize(body: &[u8]) -> Result> { } if bytes[cursor..].starts_with(b"") else { - return Ok(body.to_vec()); + out.clear(); + out.push_str(input); + return Ok(out.into_bytes()); }; cursor = end + 3; continue;