0.34.6_repo_cleanup

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 09:31:15 +02:00
1 parent ce1a90397c
commit 133d2f0f91
273 files changed
+4856 -5813

No files matched your search

+253 -253
View File
@@ -9,7 +9,7 @@ fbba365b98bfc31b891d8ef240270b76987d67778b4d795552069e6b94f3f9ea Cargo.toml
fcd1ace66c47e43e13b8cfadd76a9ae02ad545af944fa684c489034227776432 README.md fcd1ace66c47e43e13b8cfadd76a9ae02ad545af944fa684c489034227776432 README.md
ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs 7309cfaa188c7a1fcf30be1b8313ee99c00b7fe5d6d626ddefcc7345fa066112 build.rs
2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md 2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md
13d514de5042a230cacf8c10abe1ef183c94ac799a89b8e2c111152ac7c69b48 deploy/rustproxy.service 13d514de5042a230cacf8c10abe1ef183c94ac799a89b8e2c111152ac7c69b48 deploy/rustproxy.service
c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh
@@ -106,7 +106,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
26abaf315e087d2c14d477c80baef82453068dd2d258d091aaa2d7b238fa9428 scripts/preflight.py 91c5ab0f82af5a4eb5589f5ebb19975f053eacaa46fa3d59b74f3ddde6370562 scripts/preflight.py
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh 3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
@@ -114,52 +114,52 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
7f953ac05756e6e5d08e1b73835ed6a1226ec8e9439bc519e5ed04bcf3f4b492 src/acl.rs 5130529d8f55ba9d89c29235a7eac133a80c9e9b2591267b1d082207568abbeb src/acl.rs
63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs adab6c8cd7c3f826e300effbd7d10c1bf12ee49253b3e5ad445e4ac04900f5dc src/acl/dynamic/mod.rs
4803e44de39b872a5b3b55f9ca9e81c066ba52cb8f6adb879fd57c5f94b93e89 src/acl/dynamic/tests.rs 1eee2ea2c8dd6068dd0bbb058a06d26436f14767d8efbc2010bbdb4e3e523672 src/acl/dynamic/tests.rs
5caa062edfa2eb9e53ba0193089a7cd3204b4c9851564dbd4881c26b7a29347a src/acl/tests.rs 75120cd3115aea6fc40665086617eab9816f08f556cef8db370806195c7641bf src/acl/tests.rs
6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs 6189029ba493982acc38c9dc1f47fa1f0c933dec5ecfb95b60f8be83d33b5aa3 src/acme.rs
587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs b9eff08fddf9b8787aa04e72288ea2ca0033965e4563d060ca51f267e4963918 src/acme/jobs.rs
3f158f46e80b403b658b4e9b1e3710e38c70e47b2d476b8223d9c394ffeeba95 src/acme/model.rs f7f737aa28d2d209f4f20a221885e3bb3596dfc8e3c8c9a22ba53bb975c52df8 src/acme/model.rs
c5334de6c732d76b2bea7bfc17b3ac305a250bbaad31a5420d6a7d7abb200449 src/acme/providers.rs 40cd44f2b0d43ba45471ef5a012b3737ca3e16e33f6404b4f13b9cd9bd57f0ac src/acme/providers/mod.rs
d1dcb7bdfb7cfdc9c3b88cfb6908201c58532bc888744a13f45853079f214659 src/acme/providers/tests.rs d1dcb7bdfb7cfdc9c3b88cfb6908201c58532bc888744a13f45853079f214659 src/acme/providers/tests.rs
29ff0647417b0f1d50a4cc075ab51afecff9ddf47db3eb7948d51db5a38f33f3 src/acme/runtime_bootstrap.rs 3ec53c7f6e1feb1dcd23bf4ab54cabd8b3601d48977cc01a0ec47c3b2bd88182 src/acme/runtime_bootstrap.rs
87f64198770ae026c03a242fa542db2590ca734b02e756f1e5c28d66236f753a src/acme/runtime_env.rs 069b79d455d1126fd3967837493af896a2da9c584ceb582f2ffcceff321795eb src/acme/runtime_env.rs
f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs 579ddf69314d7f1e11270b00135de64762d77d5ebc25bf98a52945086a4cea72 src/acme/runtime_versions.rs
e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs 9c1fa211bfd307ce301ea7ef5cdac15269d69d5e01f47bc08a4cfc26502c56ac src/acme/tests.rs
b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs 1171a1854708210e2f2107445a7c39620b8048478ff0f252f20193be5385cdf9 src/admin/access.rs
12883df8e5a88d9da6026959b4736838ba9dd276f900ca14e33622dceeedbb25 src/admin/api_token_access.rs bcf131abdae425a85b5a54ade51346f623de1e10fa08e18f7c10f1fd2dd0aec1 src/admin/api_token_access.rs
0a8a6fb22b90b11ab8ed329779aa3813f6a63a39edac7fe8d821402821073635 src/admin/api_tokens.rs 5f5b150bb8f51f57462e519650093354ae7b40fcb80c4380fd3391a33f4b30f2 src/admin/api_tokens.rs
b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs 852820625bd330f6eb87aa734d02ef9f42d1eb5ce31b0f10a8cc06b1287b5bf1 src/admin/certificate_api.rs
51f46a2cb3167c4e3a784dfbb59b8fe0e9d5d3d51365b26699498e31d0afb6a9 src/admin/certificates.rs a402209141667fdce69698a60dc0b867248effa191494d0a0d93d468d7df9476 src/admin/certificates.rs
521ac794501570cfa2e8e50c3a5cec2ca08ee4dbce8ac9c0e0310c09612f1062 src/admin/config_reload.rs 521ac794501570cfa2e8e50c3a5cec2ca08ee4dbce8ac9c0e0310c09612f1062 src/admin/config_reload.rs
b5f6d49f1add583cfc8cd4aa36ffe4db56dac4de3b151fe8239fe42f507019cc src/admin/dashboard_presets.rs 159c3970d25fa29acf2483fc9422b003e5d63884713f62baa8375e4a4020730e src/admin/dashboard_presets.rs
5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs e9b03088840689636cba66eae0e4e53c495900aae3c2021add6ea3a2ef91977b src/admin/documentation.rs
ea7aafb4466caa66c6147f0e08d189c42222c43212bd7acd8e8842fd935a48ef src/admin/documentation/documents.rs d4a249e8c2bf06a12fbd561fcdf5a5211aa5ed0e0dcd5111b84bbb13b3640c49 src/admin/documentation/documents.rs
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs 1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs
ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs
c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs b4f225850d3d137ef23569d69774a46ccd5b02b21f7a035133cd14f45179d8ea src/admin/file_logs_io.rs
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs 75a51d477283f98d1b0d9b0aba1d593681fe4d120e243d9807c7ab3aa3ec21b5 src/admin/file_logs_query.rs
4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs f66ff718f82a326f80808b0a5088bd05a48625be802e5ba305fff3c344b59948 src/admin/logging_api.rs
8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs 1ba0520c6348246cbb35b60e596e99dc600137c0696de86508cf06587bd86563 src/admin/mod.rs
fff31f340853b3f1a26b443e483a0a3937acb1a2cdaff67643933a32a06887ac src/admin/openapi.json fff31f340853b3f1a26b443e483a0a3937acb1a2cdaff67643933a32a06887ac src/admin/openapi.json
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs 74ad6cebcdfdd5ded422c6a720b5ad7d5f9566f1d995efeaec95319e2fe5a452 src/admin/operations.rs
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs d75c1d31c8b57d64b0bf8d925b0b7630411b6a4b3e4be239fd9b8c82b5b97a82 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs 1a54d9c0594070d24e0faaf9497208085a5c7e0345dd30125e700727be34e010 src/admin/panel_content.rs
92b3e05f04711d8001b56816405859631d9e917868e15be965c7e76ef4ddef99 src/admin/preferences.rs 730c559afaa8a50affc3eb6dc6fbde7005c0356237352fcf8c5423b65725a8fe src/admin/preferences.rs
4a5cb4c3a3aa8b9a519020854506ce4be5e688a6c4298260322ae641dded7fe9 src/admin/preferences_columns.rs 35a7f99c7448c90b8637f4b3a7becca138f1f378fe5dc3f87122151525bb20f9 src/admin/preferences_columns.rs
668cab06d8c8ddbeda2595bcb7aec236e0c56b22ca6adfe9f19c3d160de0fb39 src/admin/preferences_tests.rs 51dbfdf8b228cd4a381aa720b19146418d23763670fca74ff729bd88b13d6782 src/admin/preferences_tests.rs
4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs 62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs e81a0357d88ee3e3f9031755f4b0e2f5d381936abbbe569c5cde4cbc421b64e3 src/admin/refresh_preferences.rs
ae6cc6020b896a54b9ba2dcbc0fbed9f13d289ebd7ef8804008386fca8533e85 src/admin/router.rs 00b5dd3090792986217b28c69bf826748145129e5517b260d95019b1d7b2ebb2 src/admin/router.rs
0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs 2e7b0871e4af18ed3410f5b79c5fca6c75e6593d71b70b90aa3d80e3770bbb89 src/admin/session.rs
978f50b9ec93dc9bd6736f3dfd9a4c60ae732c9764a0cb232abcb1d42d9315d4 src/admin/status.rs 0c86d94b714cfff26e184541ed3bfcb317c88e45afe084d5f3b28558946dd9b3 src/admin/status.rs
6d3224349d08b1052ca66eb153aba513de53bcb666c6f57f00075509c6b6cc47 src/admin/status_live.rs 2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs 060e0b378cc48ce17c7e007675467d369ebaf113b64074e595cc471bbfe41800 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
5e52252a8e886d8e28eed1d456cdfb7387ea6a193007043662889ff366f6d6f5 src/admin/tests.rs fd38c6843e09508e90937362719da67a3678c657642ad5147cbce53ef891b011 src/admin/tests.rs
c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
@@ -231,278 +231,282 @@ b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/
69c8848159dcc7c4d93f49de3be75553cbecd0731d64302e2b644ae61196e704 src/admin/web/vhosts/render.js 69c8848159dcc7c4d93f49de3be75553cbecd0731d64302e2b644ae61196e704 src/admin/web/vhosts/render.js
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js 4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js
fd476445a88eb40270e706814eee50cbfbba4069a26dd843c095367e55a147a1 src/admin/web/web-server.js fd476445a88eb40270e706814eee50cbfbba4069a26dd843c095367e55a147a1 src/admin/web/web-server.js
53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs 2d40c2cb32d7365df0c9cd6c8cb6f0483c7a799786c648f4836a0d9740c6c5ef src/auth/files/mod.rs
b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs
775178ee5781e64574bdd7c1fb7d64e7e283977fbd9c98c99896532a7f1df612 src/auth/mod.rs 50ef648351271d78310e00caa6d246e0009171ed036a3e4e1144737df8c064c2 src/auth/mod.rs
c96a87e82ff0c1c9ce3d0cd5135903bb53af78d1c4fb6ebfe5fea503bf5644ea src/auth/store.rs 25b9f78a7eee277902d375266bd0f023b79794bcdf646214fc161c5e6dab72d1 src/auth/store.rs
745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs 745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs
09c3a622a7d2169e49e3369ea5f0f816eab2f717eaf1675c625d518c8f34d367 src/backup.rs 32a25e53ac9542424debeaf1b637155bd4e60abd8692df082729d81232eb90b3 src/backup.rs
76aa2afe9e7302be39b3512f30d0448d9134300f8ae930d64ada40cf3ad4a8a3 src/backup/archive.rs 76aa2afe9e7302be39b3512f30d0448d9134300f8ae930d64ada40cf3ad4a8a3 src/backup/archive.rs
732cd4f9ed38ae69f8bbae61b1db8baaa6f86b07ca21c41f24a206a30f0a96fc src/backup/ownership.rs bb9de9d12dd57289b1941fd05b25f7de1249c3f114d663071b702a05b135879b src/backup/ownership.rs
076350506215faec3cfe7d9de4e60baf29672cc22a41eabe601dcc510c257799 src/backup/snapshot.rs 37f44cf3ecd71c7e817c76462c4c7de7ca6ecd68c0544f6429a861cdcde1fbdd src/backup/snapshot.rs
5c19490d834d9971166b6b784a75b081bb95fd50c8c634ef3c87924dfffd4a19 src/backup/tests.rs ece7a5eda4b57bae8f5d819a309cb411b95b1845f5746ac162dee51456365857 src/backup/tests.rs
f5afae218f3a6f91bc2f1f5a90094630ce5b7ca574de61807b5a317b13937ec2 src/backup/validation.rs f5afae218f3a6f91bc2f1f5a90094630ce5b7ca574de61807b5a317b13937ec2 src/backup/validation.rs
426a946b2c9e244f09f6cbc5f9664b8ad0737529a9bb5a3d549d27196a1f8043 src/balance.rs eec564a7ef886d18ab75289025a47d62bb702f07d63f92d2f83b7afeecebbb15 src/balance.rs
ca1e03b56a872dca37ed9d05ead41c1538a808e80b09fb9494823c443cd51c61 src/balance/health.rs 8f7975b488fad31f8d83ffedf8a5b6774b86b59290ce0711e779bf2496d5503f src/balance/health.rs
70c44291960b835c7a456cf066469b0002e1e35ba497663361d659d452a52e5e src/balance/lease_runtime.rs 344631248c71164187497a10d177d8eaa3e2f4a08111520d2536ec89ae55e1e0 src/balance/lease_runtime.rs
aba0f4ae46f05b07bfc434b7174553ce20e5187e1b40a6cf946eb2aefbe1840b src/balance/model.rs ebe2b95f8fc5904850a88207f0690449ced49b28dc14ce5608b706659e338fca src/balance/model.rs
8550e5d64aa6fee442cea3e8e243fc4cfc1e8aa39726bc5cc6b2afa9307e3d05 src/balance/pool_core.rs aebcdcc1aaf8e38c4f286e6744c09525eef99625d1050479780c0f3c5b429c21 src/balance/pool_core.rs
477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs 477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs
8abb6048303ee8c022546a528d5693167766e7450efd8e20f37ec6fd0fa9ef5e src/balance/pool_view.rs a801a99646441cb748ab05f01101cc9c7a680788eb803c5b786ec6c5a7533e24 src/balance/pool_view.rs
814be4263c35b84de2b3c3bb3c60d7ead9f593154ac85aa6c8ae35700ee591c7 src/balance/tests.rs 05227b05c37af2aebbde1ae37d81c2789088590ca45dcce3ae926c4fcf027bc9 src/balance/tests.rs
2b1d2da46b132c8f5e75784bc1d458e231a08342b76dc2aebfbe8124a5ddc959 src/basic_auth.rs 16a22aa72f311d1fea6d8da03c3ea1590bc1f248b21876354eecc0da0fd93308 src/basic_auth/mod.rs
9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs 9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs
fb4bcb9d8852d2aeff41569291fe96d31c719d60789e860629a18cd15b8f24bf src/cache/cache_regression_tests.rs
7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs 7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs
a9eeb93519aae2de7a1282296ced91d2eb8307cc966f8fbf6085af0fd21b0ad2 src/cache/cold/cleanup.rs b75ff512a1c6344483e00d2c67578ceea6b5472c279da0246f3622daa68d7e16 src/cache/cold/cleanup.rs
8354ae90c1a0857773a0c6d79427925e0115d94ccfafb809ccc1ebf4ef10f9b9 src/cache/cold/demotion.rs cd5cace53675f995f526e7296822ee5c7fb4b05d01c0ebb641d3efceb04175f4 src/cache/cold/cold_tier_tests.rs
2eaba3261da2a547e9af91330319a3abd3a8ad8b5b44a337a84040720209017d src/cache/cold/startup.rs 79547f697246651c89aac685b26f0701dd3a1db6b028695fa5ecc3d2ae14f643 src/cache/cold/demotion.rs
e7c7fd71727d542c2df3e03a4a1cba9d839c516963f66bbfde1c29ec81053500 src/cache/cold/worker.rs e80f84449412c4ae7bc753672a554160519bf1f186b5707252c3286cbe13f02e src/cache/cold/startup.rs
d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs 83095bc93132f306c537b31ba929669fedac99af3c4f133d0533b5ed7ac4e6ed src/cache/cold/worker.rs
563919c53d38e9b83505a5715469833569efb2a09e91a2b9a89c335187368dee src/cache/dashboard.rs c1ce42ca57844c9f80234c1995f1b151dcc9373a0d55387ee887a8098d434f9c src/cache/collapse.rs
7eaf3947cfc89cc6ef55a64fef0b3fedf1d9a1c33322aba2b5170c0ea34a5f2c src/cache/fill.rs 81d61fad3b5d3d6cc131cb3cbed8438528e7473b23026440ff1b993ee69b5876 src/cache/dashboard.rs
5820a33f7be9473b2a67613462e95db029c2e44385e460323936d314963d6b10 src/cache/dashboard_tests.rs
7c2549ce80a50f6e998b8f74a5fb86300f7ebfbf8a24b4c35f855bf6a40a7880 src/cache/fill.rs
675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs 675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs
c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs
3b8f77bde71253a3ca600be5438b7cc72f7da258f3afa5de2cf64553e49922d1 src/cache/invalidation/invalidate.rs d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/invalidation/http_invalidation_tests.rs
f3c5ec56f8d81a285877f6a07239081c2b1b064fc9143ba3bf36d616b0239d86 src/cache/lifecycle.rs ba90f7ae78b15c9cd13f9b75bccfb290d99e9cbba2023de73c7198ae24ca5637 src/cache/invalidation/invalidate.rs
bfe59157d1656cdaf8e7cd82e659b25c7fcfd21c9b7ad17f7390466d7393957d src/cache/lifecycle.rs
bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs
bbd5659a3197e443b3b5b726d39d7b7934bb9aeaedcdc04285bbf4321d1359d4 src/cache/lookup/hot.rs d5d01ffcc68e5ec6a913f20df108964a5b325f1aa972c078ba5e6defde81591d src/cache/lookup/hot.rs
5c3f749b3028e436a99614c22dbd05907b63c12a5c7c6baeda24c8f506db4f65 src/cache/lookup/insert.rs a0a23e7ec1baa403524811f8096bbc026b1027b7b1b79357ad920b0a4be770b7 src/cache/lookup/hot_hit_034_tests.rs
b38f93fa2eac694aac4dd2f1d69b55688928b53b78bf095d479843956c78e605 src/cache/lookup/tiered.rs e9c6ab0c2be9f96573f826e6a1b9e4c2b8b526f779e72e95a6463ae07e45ab55 src/cache/lookup/insert.rs
5d4338ecccdbe9d78476e28491d92d4a1f96ab3c1d40590a7cfd1ba131b3ee12 src/cache/mod.rs 0338e0066e134b9e8032f117d9557b9bc5edc574f11710c2d36a6ca1c7467a99 src/cache/lookup/tiered.rs
639d5be6258699ec7e9f260ec33bd2c366203b6655bf0e98f30517c2e12ceeeb src/cache/mod.rs
0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs 0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs
9704265423d53e87c04cbac11effdcc3792439699494138dc86466ab50656993 src/cache/model/cache.rs 952ce10a89c714571a5b8a79d5fb3ca60f5216901f95b3f82e7e257b4682fa9d src/cache/model/cache.rs
0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs 0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs
29bfce96b2ffd102d9c848c7b717a45fedb9aa44511f62f7a1f28a53dafd063a src/cache/model/entry.rs 4a44e2161c2e8b2d6decca6caf1f9315a723c888e0894236c88c656b01b8442d src/cache/model/entry.rs
88d74fc1b7b9ca15a54a8f4893ad3993105952148bcfee099e9bc9727b622aa3 src/cache/model/http_invalidation.rs eee9e6614cc5a185e07686f9ad24525728cc49dfe8b9ce6a6bad0c14f3f0bcd3 src/cache/model/http_invalidation.rs
5ea7d8e72c714e5c5330dd81b5a6e2c8ed79e4367af85f133e63a756ad9788a3 src/cache/model/maintenance.rs a7b4b861127fe2b1527f12e5ce01ebacf60fe266f913467a2cc66d7f80e98c60 src/cache/model/maintenance.rs
5441fa3a384add28b6fdd15300cf2648f8237dbfc211cad9117754203be76911 src/cache/model/settings.rs 298fa122c7583bf8447d08255c4d0ab335e23c7e48cac3f621f235c201046fb7 src/cache/model/settings.rs
55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs c2efe75f740ba31e83c49bf76e7fc179e897f41c140eea700e2675ae648e4a1c src/cache/model/views.rs
e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs
989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs d01b8443c6d02a9b5d7f1477719865ea105126b8620e8fe13d7b4711e47fffb1 src/cache/policy/request.rs
d6051d55168fe1db97980a54c22c708f4f325ba72bfc36f35a1851eb5dcc051d src/cache/policy/response.rs 7cf0318d2b6dfc5f00806d608d78eeae9535b4fbb22b28826aad11b72579f9f0 src/cache/policy/response.rs
7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs e59efd322f518ebd7b5b154115188f382c2cf7882ca0a401736ba5d685981ea5 src/cache/policy/scope_tests.rs
cf2513043813421e10260cef63645fdb8c5249114f16d91eae9f919b0214242a src/cache/stats.rs 982d4b32f5ef2f80e6e18dd7334152a903631d73edd2db532915e272b6bd2f59 src/cache/range.rs
93bd3e9e5c1a045c784cee0e1fdb9e1c5d862ed70d5d91c54c5a769bca8fd949 src/cache/tests.rs 48c260e8dd75c89fb4518f6a20ece21017f69b0f5c9f9d80222ae34fab62b3b3 src/cache/stats.rs
9183fa8e6086e92fb148fc278fd866d44e3dd988c848a071e32066e44a1cbf0a src/cache/tests/cold_tier_tests.rs 42a6bd984ee8ab69ba03030a113818252b26d5b922262b7759367d67899353e0 src/cache/tests.rs
25ed7635bfd5e34de55b319bf163db9a0af26642664ce7685ebe1241bab52cac src/cache/tests/dashboard_tests.rs 276a557d0ad85c4d5a7a3dae1e49e748591c79879f4c0d5784ec277ce8d08023 src/cache/tickets.rs
a0a23e7ec1baa403524811f8096bbc026b1027b7b1b79357ad920b0a4be770b7 src/cache/tests/hot_hit_034_tests.rs 9547a2fc3cf5220aa3edf3283e09b091f8b0fb6ccbe36e53f84c625c7482e08e src/cache/v022_cache_tests.rs
d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/tests/http_invalidation_tests.rs c2d8548d12aeb2aaf02d47f6162904170526c9592aad9839a6bd5490feb269bc src/compression/mod.rs
1659ffd8d227d2ec8b98e6a5e06b6143e6ac4ac06ab423c184980abcb47085da src/cache/tests/scope_tests.rs
d4429cf4f35fd8d0082108fb62602fde72c9c2fc6edfadfe3268c6a8214a8f89 src/cache/tests/tests.rs
af28534625859ff66011e10c54cff8f82c2cfa1c173c4d99b89c27c1437f0659 src/cache/tests/v022_cache_tests.rs
f76b41a2b0ffa5699a456cc2cfa0b063098c90552fe01e0d05e6ee91a01cbe57 src/cache/tickets.rs
e0146bf5d2e2e33aedcccd9205e5bd457559c9d77dc7ce266f8c165d1b867f0f src/compression.rs
2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs 2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs
7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs
56b3d37e7447995bec2fdda1c17dcc74b60958a63ca88b88ff57e56347d8b0b9 src/config/backend.rs 9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs
cb882e390bc429df11a1ca7c3a1d4175ffbb70c4e821b168c5d7a11f55163d14 src/config/compile.rs 7261770b504b5d2bb4637a2a755c8ddd8301636aaf703ce9c8a01b0623bf339b src/config/compile.rs
a4bb70f4da2ae1fa890e65ec15f87e919e2288637dc0eb2c383234e8babbcb05 src/config/internal_routing.rs 4febd62f15f33079b843f59fed94ffb1e9cc8693f58e54f4ab15ee03fd653b9f src/config/internal_routing.rs
8ab99c684da56a8efd7a44ab6e7de2877097150fa163b17e5cfedbf30bca5eeb src/config/model.rs 0d9f62fe4600b230e200f41652effc22ac7c1b649fbe63505ed6ea34c644feea src/config/model.rs
25efab17c6955e33a3b3807b147ef25f1e8de352939fa60f5c9f24a5ef7602ef src/config/routing.rs 5a2b888c2b2b3e506a8f1ee846ebdef60c1b3a3befe312d05431d4591a78c709 src/config/route_policy_tests.rs
48ecd9de5a54b0a4aa484f38a7e171f60a033b725e14b158d1ef07b67997f9eb src/config/snapshot.rs 40e9483d7b2450f55d29b46c408727f4e331b85ecda44fbd7b96bd9a09f02655 src/config/routing.rs
76af9d85af2bf16e98a02af25f818fc449e2aacba8c17689c2b7c3e59fe77b29 src/config/tests.rs 422c43ef9fbbf6b9b8a44c162a95cda890ead6dfa1ccd24da4782844cbcb8834 src/config/snapshot.rs
f3c39dc4ac09b9aa7fa8ff01da933f67f25f5030c05f54eef1c7d1e064d1f3c1 src/db.rs e09a3a8ca70ce7e42007553d48798bcb8b4fbdf791e338b36f238d7ab99fd05e src/config/tests.rs
ba82a4dede3dadd4b6c62434b0a0086420278067a46e93286d09b13e9ebc9d36 src/db/api_tokens.rs 596a6697e8eea1d78fc86baaf8e9eb906ac38db6c1fe9a7ac62ecac85875940d src/config/transport_tests.rs
30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs eb04a8922da26e45c9b6005c0cdf1b1ddf2da3c3b680650b22a1d1f16d17a4b2 src/db.rs
20ba52d3755c359d670968efa53b0604ff7cbed63a416f9f765b50a1f45074ac src/db/dashboard_presets.rs e37168285f1c7e17a17a028a151784743e2ed6b9b5daa5560eacd6c0403eefb6 src/db/api_tokens.rs
111baffc0fc7d2001d6b7d2542f95593ba5723bf37af7b209a273bc56545436c src/db/certificate_api.rs
5491dd7bb9b3fceac97c4dea63aba6d5c7abcb0fbd06e36ae986e00c79bc07d0 src/db/dashboard_presets.rs
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs 2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs
19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs dada314e1709e960eb2e225b4d10c3cd12606c62c4bc2c0220aec1ba133e4ade src/demo.rs
e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs 5808205f03401e3f2c7f5c826d5de76e83e4b6f94c64ad3e50f505f985f6d26e src/diagnostics.rs
b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs 045593a42a6e4f632e5052c655788f0843faa313fc929265b89509f6dfff91ed src/docker.rs
93a8a3f6ba12761ddcbd19f22f32fb2c0e6948b660fb774ddd7ebdba9b6562a5 src/docker/client.rs 93a8a3f6ba12761ddcbd19f22f32fb2c0e6948b660fb774ddd7ebdba9b6562a5 src/docker/client.rs
61ee4067da4824513791155326ea3f78d4dd711a486f21cddc44671a3bd0f1d9 src/docker/model.rs da83e954040b281e99a9cacdf750d1745333b6c8ea1a5c3b6793ff01168e49e4 src/docker/model.rs
eef783112c8c45c64133edb1205f088179cd394f79f39cd452eb1f53dbffb00d src/docker/reconcile.rs a5239889203ac3fd6c82824ec3a8dfb1f250648ab05e3dea02a09183f82c1db2 src/docker/reconcile.rs
f0bd0e64bdce72ac77d69805bee17d29776392187a09fd0087f019cc8c3e232f src/docker/tests.rs 3c0d97c9f6b8a5ccdf779ac2a965c39b9bd899dbb28a84e128d81fa75ccf696c src/docker/tests.rs
61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs e36db1ed3188ffe54df7fc4ef76c4b453f23683bb7b8cdf601046e2bd50e0403 src/forwarding.rs
f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs 08d2e08a798abb944a53570f0bdaa08f1266b1107677ceda070091af4c2fc121 src/forwarding/peer_formatting.rs
01db5b1387755fb156f89f6dcec8031023483dd9b7eb17ba6c387e8179af4bd1 src/forwarding/tests.rs b821e2efd2939cb13d01e402532f33feba6682acb6a7a3223be086c1cd171820 src/forwarding/tests.rs
af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs 5cef023035309caa5ca2035a11a77cd485f72092ab1c7670398d415460f84bc2 src/geoip.rs
80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs 80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs
06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs 06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs
d3dc58628683472ef4d205fa5c42f0b7ef83cf90846146d96941cd3b36a40766 src/geoip/tests.rs d3dc58628683472ef4d205fa5c42f0b7ef83cf90846146d96941cd3b36a40766 src/geoip/tests.rs
70091bdf6f38f03b6df8c8c4407c300677e57f293130122503f936500963d37c src/geoip/update.rs 70091bdf6f38f03b6df8c8c4407c300677e57f293130122503f936500963d37c src/geoip/update.rs
c1b49bef28d87712e4c46138e70b52c4bcfb1a00196579031fac043d0b4aaa7b src/geoip/update_http.rs c1b49bef28d87712e4c46138e70b52c4bcfb1a00196579031fac043d0b4aaa7b src/geoip/update_http.rs
9a912dad0b8d1e10410fd95d0da3684656092ef85cf8b23eda4ffe7fb92921d8 src/geoip/update_tests.rs 9a912dad0b8d1e10410fd95d0da3684656092ef85cf8b23eda4ffe7fb92921d8 src/geoip/update_tests.rs
f6ebafafa82391f8cb3b5a09331f3762b6ca5a9fb64bafb5772cde3ab814dfcf src/headers.rs 61a2b01db9010f9debfb38b43a26bf588438a5e470dff938e4e54f9d7ba46732 src/headers/mod.rs
e4f07bfb7eb75b6897ef812870caff158679a3046d64582428abe8c6df490dbe src/headers/tests.rs e4f07bfb7eb75b6897ef812870caff158679a3046d64582428abe8c6df490dbe src/headers/tests.rs
502b170c1a708abc0e0b6b5579c66f4c0da816dd53f5ff82b2b97a1506644c01 src/http3.rs d7c764af29c132e3d8d1fae1bdbe6bf20a27046dc294f83761fea335726f2306 src/http3.rs
eb57117938a447d9a6c2ec1806ebd9fecc640a68c88950a8b35fa9bad14d0dc6 src/http3/body.rs 4b56bddbfaa1db9151807c7bc016e573e78afce2ecf593df628e2e311df61658 src/http3/body.rs
ed25da264a2d25b40c4fcddc97c626a3a43d4f3a9be6c6d31af58b9e4925c198 src/http3/diagnostics.rs dd4e4200743b43895c0ee2071dc9ce36659b10251ce23d1082be169594a20d76 src/http3/diagnostics.rs
37f54961d8f52b79a75fb2be31e81f4fdb1e66b06350345c4483d6197b8a0bbc src/http3/identity.rs 49ce4452ef7973cafa2e4071e2e14be0e16000e931a6501e4dc613652e49e230 src/http3/identity.rs
9d31c89ed7139d13f12f2031183f9542ee25e3f9d56303617c086747fe1487f6 src/http3/settings.rs 0b41072f6bcf28725c80f471584cd55d4900424ce5d55ac7f6909813788c63fd src/http3/settings.rs
8b7e840c93f08f49a5b15b2f594366930536070d76391db9fd2dd1e884ce88a8 src/http3/tests.rs 01ed8610a8d169bafecf5ef91d325042d9b59a228caa8a89f76005cee20e6811 src/http3/tests.rs
68fb5ab6a889995a66ad12cb6acf88c5d04f0aca572e2ae15f1a9cd171a5f24b src/lib.rs 68fb5ab6a889995a66ad12cb6acf88c5d04f0aca572e2ae15f1a9cd171a5f24b src/lib.rs
2551189a3e5701371026c9abd2c5d44d37495f28fb67e220180421bfd315e3dd src/logging.rs e8399753a6ff49b7de19edb0a268bf871c9979a518354160cf07b4669051fcbb src/logging.rs
21ddb01b8f9c7fab60840d18103ef8de4716ff0c890cc1525b472b69af7916ec src/logging/error_runtime.rs 4310aa77bec91dc473808787cebef664d2c9bda8d78742ad0fd410670f0f4f56 src/logging/error_runtime.rs
add1293410e87ddae85ba4b8bec57b56ff54415b8112576c450f85920ab6c38c src/logging/format.rs 9f9705e07cba9f3c2888babb2eef63b706549042e0f4ce29fb6a1646ae58f9b8 src/logging/format.rs
f5815d778cc1785723faed4abb15f9880f22b6524b5289fe1e7158905aac0986 src/logging/model.rs e51524d45ddf54574bd33d71dd4ab18abbb642390fb6b6dda2a2c6e5439fe9a2 src/logging/model.rs
b9bef61d332df0dec47f15d864a7aaea240935c432bf935e40144916e37553bc src/logging/runtime.rs 3afbf5083695f21ae4a8bff10b81ec3dec55124007c31b8a0d36c3014e4873a0 src/logging/runtime.rs
106f769f45a832079b170e1cf11d7e6a78adba354bb0a88bfb8e3aff14f8cce6 src/logging/tests.rs 48187ebfebeaf6863207ce338df2d6d0f418fc3aa0689481c23c2a8ad9970b52 src/logging/tests.rs
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs 0e9993aa6679381d91f3a22068b30e1611439d7fe50900f49a4a73ffdfbdb3b1 src/main.rs
7b81540a0f035f99bf2765a7d3ed75f9fcb8b72895a1ee51bc1aae3ae0c09869 src/main/admin_runtime.rs 04ad738f90c67754f9f133863a0e2ae94665357f527c2fc3db05863087ae4d83 src/main/admin_runtime.rs
22e8ec9b36c66ec53a78b582f54aa320b2eb301c96ae4f23f92bcb84d4f1ebd7 src/main/bootstrap.rs 2c32c9b2014f9fcb111b64dd85f2106a8731b6d57fc3721522b2ec5f4349f4fd src/main/bootstrap.rs
5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs 5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs
33935951388699d3a78bcd4314fbde8226bc535e9c05932e7edef65e894bd7b9 src/main/shutdown.rs b240033715323e7cb7b9bcaf92edd481f7089b4da6bab25005ca6733da8756dc src/main/shutdown/mod.rs
0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs 0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs
e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs ca01c97cc52a50797306193a546e4379fe3ce9401b0c936c756b881180315ab0 src/main/tasks.rs
1def53b16ae1a498e6d31e4ecd7337af240121abe36ca505fed087bd4e426ab8 src/metrics.rs a719c51aebcba0f16f8810c37618201954e79d6806da085ee6c5e4697a05b521 src/metrics.rs
6eb36a1a8a2bd04833aba17cca9645a8c55fcf93d512c9ac44c5368b83fe4059 src/metrics/global.rs 9c7ed78345abc33d260f8d5cd52339bf8de748c08b53eaa2bdea8a32a0ea2a20 src/metrics/global.rs
2715e2aa3068d9a4dd8f9cbf44e31917c4166111e6fd770219fc2e26cbbec3e0 src/metrics/rate_sampling.rs def9a23ea4edd87fe142ac597387f1c0235088c95c693809f5793279d4cbe02f src/metrics/rate_sampling.rs
78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs f27f1f8bed769883d1a7ae6a48f89f7712394dfab65d05702b0f944f0a45bd8e src/metrics/vhost/mod.rs
e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs 40cc25ed1993127dece24857221b6da2debac37faf403abcb451ec442a240273 src/metrics/vhost/tests.rs
a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs e7ddf1f453a3e207ece18a4358cb44cc02ee68333ecc0b6c68028ad17661cc8b src/not_found/mod.rs
c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs
3567c2b65b09553e8aa46fb223dcab885b358fee9fc69cd0faf148059433b9e4 src/pages.rs d32d757ace8753eccb842c24b7af77061141478d10fe0acdfb05263d19e9a64c src/pages/mod.rs
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs 8416712d3e8ce81a1794152ef98220313066527d613142cc0958c4915add8c39 src/pages/tests.rs
63a84af77b6c3dd390aa9dfde71c527eec6e1a3c0a38c1f02c08055e525f250c src/prometheus.rs 5650a3f70162abd97021e0a51a92cac2c4dd39004426ef99dff36cbb0741aeac src/prometheus.rs
192a780d5874afc158d62a7dbe1687d734635afe9342fff526b8a2f6db4e5633 src/prometheus/core.rs 192a780d5874afc158d62a7dbe1687d734635afe9342fff526b8a2f6db4e5633 src/prometheus/core.rs
74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs 2321ee667569803dfe6c92c0e98adba0ac8fcdc6a3eacd5857233f1acae93daf src/prometheus/docker_misc.rs
97926653a7c00e884784c2739a998d666abb3a891f1642bcf1ecc6c49d0efa62 src/prometheus/json.rs cc909edf740a091f9057912f555dd888b644fa1cb49647fe0e2e1f5dba8ff5af src/prometheus/json.rs
c34c3204bfb07a0de95250dfc1208979a5e701ccf5b4093cfbfd9b996ff3ba15 src/prometheus/model.rs f9aab3297fcf647295849f77cbf99197f4dd27efd6817e54c03451e04972430c src/prometheus/model.rs
67ea91c74c9e7860ee16da8f3543f642d5b6549a148a50a35e4d8f660510517f src/prometheus/tests.rs 2ea975c96fb45bfeeda95583e909abb26328a16e411fa46a1e31b2a359f7e711 src/prometheus/tests.rs
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs 028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs f1b0254f53b1a4336bd8170ad441ba072b49a114e497b985cb20699b4e375044 src/prometheus/web_php.rs
2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs 0e8db3facf4f4df7b05f058e2dccc8521d88bea9da67ac0889b77d349d524d9c src/proxy/body/mod.rs
66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs 57a71c9c6e4e0deba5d05963b9870af8f4c4d8dbd321ed4e52cee88746c997f0 src/proxy/body/tests.rs
2c65303210bc6783e4c75d0ecedc9be5a1bbeb063ceea8e1ac9983edbe9f5d31 src/proxy/cache_helpers.rs dff5d29ad6d126fde26f2b5e770c3b35275a82b6d59e41dd85edf28ac5eb2740 src/proxy/cache_helpers.rs
aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs 7e3837d2f6ba7d07ca2cbabda57b315f5a7f4d75a13e5eddf6861a150d0d2ed6 src/proxy/cache_http_invalidation.rs
676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/cache_http_invalidation_tests.rs
a679baaa062beadff9ddb61a3bd6cde1ce27cc62147d2e27b468c0a8cd32ceee src/proxy/cache_plan/mod.rs
3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs
a8848cbdf85368548b003af2b43823c453866c472af4c7d2ff0ec7da14c44c8d src/proxy/error_fallback.rs a0b9e2e9ab0bc89c7b64bd19365198234b1c5c13f455ea47be122a5660639b17 src/proxy/error_fallback.rs
f2f9cae1c74eaf66fc365c0709a6eb64e0b27457d4a00728e02cff8a641a1295 src/proxy/forward/cache_lookup.rs c825f436ce0730ce542f51a1363a0fdfb0b1a4113a2257384d052de1afd56359 src/proxy/forward/cache_fast_path_tests.rs
cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forward/response.rs a20cd11a504e423e5b1af5cebb5b4186530d4eaacaa02c3212a1174cdbfb6498 src/proxy/forward/cache_lookup.rs
44e1bae0a35ddae6a084c1d50b936fd10dd51059f331525b758447b526a2bddf src/proxy/forward/route_cache.rs 0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/forward/h3_navigation_tests.rs
691b54a0cc33089e67d61ffc715b5c015976bae3fbd453b26cb06764be2482e5 src/proxy/forward/success_finish.rs ce97cfcb07d90415937f0520ee7175502b22e60694c1ea8e3d2b47678d190e5f src/proxy/forward/response.rs
0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs 02e1fbe16e59c746d57dfbabf655a729a2cde8131886689532a414c47467a53f src/proxy/forward/route_cache.rs
3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs 3f91a40a01378d1fbac56ec376df6626e0c4fb6c3b8c8d42583b9c398d368ad9 src/proxy/forward/success_finish.rs
bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs e631c63fc10a23e4533bbd158659710b184656b323d0e6169a8627294c6972ba src/proxy/forward/success_prepare.rs
d21cefaeb3b4e4bd187705124e281f44bb0d2b597fbe076f3b2ca00a1c024302 src/proxy/forward_entry.rs e9ac9be64f6f83c4ca2cd5d299649f24dd16197712083c6dc10e55c662c65b80 src/proxy/forward/upstream.rs
53ae013330edab4d97a9cdab2c513f8548f6816aac60f7035bf3433a6a2db818 src/proxy/forward/web_source.rs
3340583d395fcc859cf3421f4e33cf8347915ee4c468f7d0e4abebce8156fdca src/proxy/forward_entry.rs
9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs 9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs
cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/headers.rs 5b07b98d855453d5a4272f5fc98a75973c3e6032416e0fe4f7f151c1bf239fd8 src/proxy/headers/authority_tests.rs
3e642599bb0aaa3ab36d83a69d9e04541cddde90f38661934ee0255011ceefb5 src/proxy/headers/mod.rs
0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs 0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs
9ed6577b381285d2b7b874f60fd38d8192a0a5f3dbabdb5b737091158c43ac31 src/proxy/identity.rs c04451ac8abebf4662ccc3ee4610d5342ad5382da2098f7af5db274d079770b6 src/proxy/identity.rs
050c66311f71cac75c1d4c8547351a1d6f1111ed0db128691366fdd73647e90a src/proxy/mod.rs a08ee061e146cb7a08311106f73a04468c89f77b6ad05a2c623621a2ae9faf2a src/proxy/mod.rs
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs 5559efb5984e8da34235e2e5cb34cd6865a34ede20e159bc9f04fc9190a5c09f src/proxy/redirect_helpers.rs
2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs de1763b89d98607e20f0f6e3c9b546483927cbe5450efc2e5634fe691fd52ad6 src/proxy/request_id/mod.rs
b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs
6a46cc0245175db56384ad793065141dece2213657ca73620fcc66fbe53a4b1b src/proxy/tests/authority_tests.rs 580fefdc7dfb27da73f86f4d2067d46a1f856d2f398b0598d37a743e880d3dac src/proxy/types.rs
91776c89a0ac3560902ec751846325ae5168289d2cd8c568808c89fa82aa5f69 src/proxy/tests/cache_fast_path_tests.rs 2ed96bc94003a881ab60f6871e3215598a3a519dc9f2480137f57292b70dc888 src/proxy/upstream_helpers.rs
0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs 991bcc5caebc882849b6abc928230c0a6ec6e25f6aff6a8856e8a9807b9de72d src/proxy/web_cache_revalidation/mod.rs
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs c827ad43de9546dbc5d9744b1e8209da0f70d2be0304a60a776f9a71d75f57d8 src/proxy/web_cache_revalidation/tests.rs
fecc1c7ffff740caa7168370e59234a17b9836a6dce6d08b770ee9648c22709b src/proxy/types.rs 3d591201595a3b1e928ded20a8736477a47e5acc3bcbde158f7c17547ce155ba src/proxy/websocket.rs
1455d33b16daf9f88bba76255795fcab6e050fb9c2bbea0bd601ac90e8ec30d4 src/proxy/upstream_helpers.rs f8972bedb1a487fac86177774cd98cf2619fb25af97e57cfac2853db5cbf91a9 src/proxy/websocket/handshake.rs
2d9142e3ae95895b5271b4009ab48236a3a2074ce51179c021193b35777560e4 src/proxy/web_cache_revalidation.rs c02d42b5c7eeedbc54c58e98691f7d8e39f8e9be63a48f6d6b4ad6b8ea431b7e src/proxy/websocket/http2_tests.rs
d7b4b809b12ee89293df45e9a64b1c9981fb3f89deaab3762f4a3ad3992dd7b1 src/proxy/web_cache_revalidation/tests.rs faedf6cc2bd4c42f342a0aa188b634d6b8d280ca0e121032977eadaecb88e9d7 src/proxy/websocket/http3_tests.rs
69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs
d866e7e72dee6ff54c2418c34e9899cbcd7e4e027d9cbe2b625044dc9062e4ee src/proxy/websocket/handshake.rs
7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs 7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs
a44421714aab37f7ceda19e3ec2512c645d2a3ce5dcb3da5bc7a0d87a442d6a8 src/proxy/websocket/tunnel.rs a44421714aab37f7ceda19e3ec2512c645d2a3ce5dcb3da5bc7a0d87a442d6a8 src/proxy/websocket/tunnel.rs
15fabd38576b61f650c039acdbf7ac5a5bd24330062475b94315285b2dc258b6 src/proxy/websocket/validation.rs 999cbf85cb6e917db529e30ba4a5eed1cc9e95a054da7b3039c730bb80061449 src/proxy/websocket/validation.rs
545247eb00b0116efbd4cc913cc251195a55feded1530e0d8d8945abbae78039 src/rate_limit.rs 00aff5b5b0719930230eff4d7e3adbe998cf0258bf7361d0913679c863afd3fa src/rate_limit/mod.rs
e7e3a898e2c769b1ef11e58a13c1053104ada93dd694f8a03ea4b647c44c7d69 src/rate_limit/tests.rs 2fcfa9706eb0708519d5181debaf3c85a86e401a8898df72f9eaf0ab72c9df85 src/rate_limit/tests.rs
d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.rs e53c40b85792d4804a432e3c97dc13af8e29304bb0d250ee9ab9018bde522eeb src/redirect/mod.rs
811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs 811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs
244d72b3dab36ec505c96d26a967420714fd3807268820f680fb3dcea0e88c0f src/runtime.rs 244d72b3dab36ec505c96d26a967420714fd3807268820f680fb3dcea0e88c0f src/runtime.rs
8d0d7b01871c8c588c40a54330a1053c85024655fbe38516ca6c94c3c19a8956 src/runtime/certificates.rs 3c71d8eef9ccb49ad93cf6014c5c27756ce0eaeddfca116048f24c483fda68e2 src/runtime/certificates.rs
02c598ffcf6af5aea95cd6bdd1a173bc63db8afdeb9153ec87bf0964b7244714 src/runtime/constructors.rs 2d610dc36f746215ad3bc1d860ee624a9233886d73a019fd243e51901503fc53 src/runtime/constructors.rs
92b4b284f1b69dca58e4e3f76199f0aecc397eed26d10123b427af1bb8a4a57e src/runtime/docker.rs eb2b0eeeb617cad704732d87acb667530144836e26edb0189929b1f25f35c6b5 src/runtime/docker.rs
9015732f5592cb8d5503aee9199a0ead6c42ae20687d506e8752bd1065eafd82 src/runtime/external_config.rs 18f7460e3738386e070f66932cd77240001255447a769d6e19a40573254a1473 src/runtime/external_config.rs
ee2050b9f8efb45a209c91d76043a1ba29c4289978896aaf7e7d344d48fdc369 src/runtime/tests.rs a3b65ac6133979b2905b878e5634537994816abb79480af219a17896c6dc4e33 src/runtime/tests.rs
0a585647b205290d0f7e52f9fe83b7ece1403cd87ca0e726669e27a3fb9faaa9 src/runtime/types.rs d7a65497d1faa2f09a09c0a0ef77b444990cfb5ab379c85f6c27c1f9c0c143cc src/runtime/types.rs
78b734fc804bb3d673f6cc0ab0bc8862612072ca7f4423d7c9bdacbe78223fc2 src/runtime/updates.rs 11e99eb425887ad12b73d949f4b497fabc6897fee8fc7647fcb3766269c2fdc0 src/runtime/updates.rs
208a378b14ca80e1412ffa19ef1b140594182971e2e797ee0bb95bcdb8404da7 src/secret_store.rs e3dbd60953db5a976985dd796e4268b41a0f3df59764c4664015bd41e2578058 src/secret_store/mod.rs
6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs 6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs
6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs 98854bfeb35cc5266210b1c33dffb36b8441e27e61d90ded8b681430696d156f src/security/mod.rs
0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs 0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs
98c52aa3b50d2e211ab752ccaf353c9a8c1500a87f9c1684b5dd0ba9d10e4619 src/server.rs 0e8589f56e3229dec556b427b87c2cc5a5a9ffaa9ea84fc2522dc72d3239027d src/server.rs
f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/http.rs ad4cfd4a7d6f331a8ce3bf17a6c461dba788bb41994f667033c6963df3b96332 src/server/http.rs
164384468c933320abf1c656f1e07a687aa869f5794d3e5a9b429885b5272266 src/server/http1_connection.rs 00dbce1ff1ea08774bcfd79d900d71704256e06ab0cc46c379c3d107c99ba50e src/server/http1_connection.rs
cd33fa634a5f1ee1d425e9e78269f9575b2e324967115383c80c6883c878ccfe src/server/listeners.rs 1cb8260c74de89c3e13bc225da32cd86fe883acd3c720d0cb2fc44c0c55e940c src/server/listeners.rs
4424a99b0b688d1119ee25626778b4804aedfaa0799a4a9bc0ee99bc5c7ba96b src/server/sendfile.rs a64bc0d4352ec8d97895978a98b6b66e0459842d1ec410a1d123d5d29d9ef058 src/server/sendfile.rs
71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs 5f6a59c6085c1250a93a70020661d1e8174d3b600b494d16f8eaf7941ee3bb99 src/server/serve/mod.rs
e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs 048a05a46108f50bef7da50b0c03b752d76e586b411de8ca3f55313bbdb08aee src/server/serve/tests.rs
960a82e02f09d75141fe3c1eecca915b66f17fdc8167196cd2fe21d5e6859376 src/server/tls_runtime.rs 669ea1041c103cb952f7a516363c8fe7baf51622f721aabdd57015e0e0279530 src/server/tls_runtime.rs
73ec453ac2850f6963f5bc33401cb8c92aa26d5123f15d68e6633860d91f265e src/settings.rs d0af5ee91ff7f0315d347b3fd41d60b41e9dd095e0d015ecd99f17c4f3a923f7 src/settings.rs
240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs 298673b6407b0ceda6ebc2ad4f4d787688cb0a5f144164f74895c745bcc53aa0 src/settings/public_origin.rs
1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs a4fdf54f87bcc96b31e8a0ea400398a70011a044bf2ff3de78a0489b767f9de3 src/settings/tests.rs
9dbf814870b8636770f579c3d36d16f5b6afe8bc66adfad356c26d7ae7dbce45 src/stream.rs 2ad7888b263e1dd9f0bd588dc35590c9eb232f8fbacd10ea7554cb55b4dbd25b src/stream.rs
254840126be2310426f6332f5efbe98147d9d8de1999731f40a3d13325c1dc6c src/stream/health.rs c7771539fbb94544947e958e410c46b73f9647011e56b1b229ce7035fe21851f src/stream/health.rs
bdc56be7dae85a97a4d5b8d46ef31ec4177292b1cadd507fe423b6d4ee1403bd src/stream/model.rs c02e85af45590866f20933561f85ea9b4f2ead62c2ce41db75113632670a7b5f src/stream/model.rs
de138a4ac68bcddba3a4f17ceba008bab643359d76a68726a755069f19334bed src/stream/pool.rs 5b74acf26aad3b73a10501866b716de5dace753453d5f09c4f22c414e5ad880c src/stream/pool.rs
6ec42b8647c95a0c2025462e0be29c71481ce1c3b1f4d4193926471f1cf06be0 src/stream/runtime.rs 6ec42b8647c95a0c2025462e0be29c71481ce1c3b1f4d4193926471f1cf06be0 src/stream/runtime.rs
9346cbd71e006e41a7188f28eb86f9b00bac822e4cb31c76c8d6d2ec5d4c4711 src/stream/tests.rs 9346cbd71e006e41a7188f28eb86f9b00bac822e4cb31c76c8d6d2ec5d4c4711 src/stream/tests.rs
3070d78a7b88c2e47ed59960fa35e7d220a4cd7d78e48f6c6d2bcc5ff77f9620 src/system_stats/linux.rs 63924d279f5fdfde24d2e0663518a123dd673cc6ed9ce52233c4b6851bfb1efd src/system_stats/linux/mod.rs
d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_stats/linux/tests.rs d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_stats/linux/tests.rs
751d59582fb92c84e0c0cc7c0855c9d0a545805c9ba3be61826e50fb96c297ed src/system_stats/mod.rs 2c7da96822d605193f0208df70de670612259fd8c762c998ae828848cb210e11 src/system_stats/mod.rs
050aa831b1012c4aaade63863f433f01bfacf5d3f95787e57975572116a30ab2 src/system_stats/tests.rs 050aa831b1012c4aaade63863f433f01bfacf5d3f95787e57975572116a30ab2 src/system_stats/tests.rs
53d40d0e169dffe18c802f07764cc0b4bfdf61d758eadb8f0d12e55d7438a7cd src/tinyauth.rs e7ed064a1706de9a7345e41fb8b6288b4467c12d1653e8afba6dc392c7a3a36a src/tinyauth/mod.rs
0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs 0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs
1084cccd9de28acd6cb0f0813de7c0953910f51726f73f4acdb2e3ada4593976 src/tls.rs df98becda799b6bf4ff5e2fec7d0e0a486e6b7eca6f9f5ad49ffdafd49a4f498 src/tls.rs
1d7a6e0df1ca4d4acfbc1d059e60d80c9ccf31fa7d130091df44bff9f8ad4d31 src/tls/certificates.rs 2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/acme_tests.rs
436575144aa352498a4696f12c2258bef3a898f3f50e6fbc6d67c3b93afa099f src/tls/external_paths.rs 7425ee6c839f85d792e9251c6b9039e72fb49a5820a2266f751e6f4967a5871c src/tls/certificates.rs
627c973870f6da0b18a90f84d3e92ed3f9fbdd5587490e4424dd77baaab3674c src/tls/server_config.rs 2ff3c219f87bd82677045a4809d3d94921c5b230bbc2f3e7131b07548f73ea7e src/tls/external_paths.rs
5816e5e5b870a29d3f96d9e409fb2b667c1b2f4f599fd2b0ee25b522166df49e src/tls/tests.rs 60c16793b3d6115596e79e2cfe5436ad774709d20ca1cd55883e0f44e40ec871 src/tls/external_paths_tests.rs
2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/tests/acme.rs 7b4c0f58b3a1e1791bb3c682a001b78bfdbbdafdbc143db8063b3f22dc168616 src/tls/server_config.rs
93b359e9defd62506729bc90e56c96783e2f14dd477dd67f337ca83149da7168 src/tls/tests/external_paths.rs 9d6f048e791a3b7152904d1728258d298ad9d34aa3f83d13baf1b09a6fb7601d src/tls/tests.rs
3292498e7122bb9fbee49d29e51e37cbb337c51af62ce367a06f6b01f6c0491b src/tls/types.rs d9d79edf5a8ef6e3b39a21d35dd5ec6e5a336047117918949e7ec6f5b20ed3f1 src/tls/types.rs
6ae584944343b83ee3a2772cd93c985ddeaece7a9e6467b7cdbf5da22ee88028 src/traffic.rs d637a77a9271736993f2e49022f752750364d4c6ba3cfd5cf7c9c3c8f37154b3 src/traffic/mod.rs
e796efe9d94e2e79220287196fe2d2d9f4a1c7640b9f71652d409478e25d3c09 src/traffic/tests.rs dc8a83fae47da0d86e6ce0a329cb5274233264df56eebdb56b9bf37cb7be2bb4 src/traffic/tests.rs
d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs 65fcac840f8b5f1508213e3f374aa318b450b0f9f90ba827c5ea935b9c60bee7 src/transform/apply.rs
87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs 34a06c2dc635e80690961ef61fe84110f9578f55adf89f92a04668783ccb0a98 src/transform/compile.rs
b2c5236589093454ec60984b785ad6a4af6149d47f6db37d8e08c17fec7a2c1b src/transform/css.rs 5a16b628af31fced425903a9d4b6ee83e1fe06ba023d574db16467baefe00ad3 src/transform/css.rs
5478670cef87777b14a2dab308f5a9182df56d7ea2f5625bdc94aa7e3d661503 src/transform/html.rs 2b9d5c11d4c02b4a3ad3b9bb1a18c63693f245b854084f5eba16595df47b47f8 src/transform/html/mod.rs
aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs
a1d2fec07617c8a8dc425bfaeaf77c844152a0a174568a88a5baf4c317cc6c5c src/transform/js.rs 047acd7209c1db19709801abbe3ee1076c1fc33f0fe80cc5d9b60669d5f1bae4 src/transform/js/mod.rs
cf5195ab6bacccae0082da7b3f1bd0382b73fdeb3607f4ae569ca6daab9d10d0 src/transform/js/tests.rs cf5195ab6bacccae0082da7b3f1bd0382b73fdeb3607f4ae569ca6daab9d10d0 src/transform/js/tests.rs
226ae561dc7b0243c04e55e28cdfd2b76acd48cf614d1919b81c196660b03cfb src/transform/json.rs b0d20cf6784bf31457ea5a853e2fa5d85f6452395a78a538392703d97e687a11 src/transform/json/mod.rs
216e24e29ea87be4a124f95f898e155f127fd959d7d5d9b30f8cd0b2b5018f30 src/transform/json/tests.rs 216e24e29ea87be4a124f95f898e155f127fd959d7d5d9b30f8cd0b2b5018f30 src/transform/json/tests.rs
6a8c28be93fb590942493c075b29ace1527981542bd183f232aa82b62e951eca src/transform/mod.rs d13fa462d6df516a4cfaee9f4e184044c5b6345e311aeb3d9058efafe54e4c5e src/transform/mod.rs
fde2fa3aa94b30d8536afec1c11348cfbd922d0b5ff89e91d34266e8e3863e26 src/transform/model.rs 954b86300c3ea47281acb2f43b0771e838f83ac09eb55e882e42de26aee3128f src/transform/model.rs
a4e1d2aa2913587c250f251356431097b76995aee6e8bf9e35c4978a95491de1 src/transform/svg.rs 4b52257e1a3643910b5e7043852d7218230c0021c31215d90d0f8e384f2ae233 src/transform/svg/mod.rs
0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs 0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs
d1f3f53fedae0d8ef69eeee457f7d30ed5a670a1ed4ced60683400ee46680260 src/transform/tests.rs d1f3f53fedae0d8ef69eeee457f7d30ed5a670a1ed4ced60683400ee46680260 src/transform/tests.rs
6146c59f36cf419623747d497b3de685da6c84964df43317bebdcb21deb9ee09 src/update_check.rs 64f711d2a0063036e3a2cba470b44eb165f3574bc28cf273296c2c3adb9ed4a5 src/update_check/mod.rs
c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs
92efcdaafb9f5a8514daf63d7fcde29be977d7bdcd90162eda0ce00d6967ffbd src/upstream.rs b7ee3629a8fde0972bc1822b764f5fa2c813a760dd5dfb7f1ce643a7db8f9f75 src/upstream.rs
ca695e246bee5389b3757fe8588e48bff5d0ad473bc8588ad4ba7b52c99d7dfa src/upstream/client.rs fe9d4deb34cea2408ac5a1030315989f29a6a7d078a46ac1b59d12aa07f67878 src/upstream/client.rs
56c8a9d1c801be0f41b35c75cb42716c46976951948009c8707122c0a6a1eb58 src/upstream/client_tests.rs eb513923f8951c8bf30bad3cbac77d34f5ebbc7af3ba69a08a25bae834f8a016 src/upstream/client_tests.rs
75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs 8c88d928b823e4b6ba6d852f74664c770d8b3fce32bc76c583dc1f672fe33f14 src/upstream/protocol_tests.rs
8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs ae880b40197454ea3fbf4d2113984dc372098b2e54b97e87c83cc319de253377 src/upstream/tests.rs
a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs b88cb47a009be4924bcf069b05c3e788d1eaf2b15cbdcf3099036ece89cd0d66 src/user_agent/mod.rs
6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs 6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs
3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs d61c427c1e9cf457f1232b822d73e846e9f329cfbe61215e7ca061d53762155d src/vhost.rs
6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs 54b0c1d2f04e5c654123c3422645eb5a0a9609141cc4a1c6addca2d028392772 src/vhost/error_fallback.rs
3c24678359cc6e2ec94c2a0e1cc399b591ef683adab9b965633c898c6241130f src/vhost/model.rs 9d358be766f767d9e1a42f585edcf7fc1cba8f22e5eaac294a83920425ec03c7 src/vhost/model.rs
35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs e003b935d91791f20d80c59bb46f9bb0820694dcea5d95e65364b53b016284ef src/vhost/redirect.rs
94bb735627f2c4a0af5c4df45bb2b41299caf37b7e6e4d0d7a860d3890eb40e0 src/vhost/tests.rs dd6e121445402a2ac8744f0e4f23d879b4356f47c1bb00737bd15ba459661978 src/vhost/tests.rs
e912d5dd09fa172032ca04d89572b845dd8f38ab5bb8598433b07629181f791d src/vhost/validation.rs c95cc8f4549460917633a12ccb352a00a83c0f004b23c0b9ddecba42eee3ea17 src/vhost/validation.rs
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs 8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs 9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs
bcaa1ae15134d85668b05a88d8abea01e5d188f535ad83cd4459732fd56f2f44 src/web_server/config/model.rs daeb5e05e4129764102b3f04e62b2dde38a8c7739bb475a13bc8c8f6d5844da1 src/web_server/config/model.rs
a863ad1337fbc353c0f42caea4ac8b3644792e2aa08c37f992b7882b4b8dc6d5 src/web_server/config/validation.rs a863ad1337fbc353c0f42caea4ac8b3644792e2aa08c37f992b7882b4b8dc6d5 src/web_server/config/validation.rs
86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs 86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs
200cba8cb3482e49db73d2c60cb60786175177c7d4be2a566cd281fec7ba7fca src/web_server/fastcgi/protocol.rs cd032866a9d74d6802c2d99f2799ce971c6a8400e39d7ceaf542cac8c4ee97fc src/web_server/fastcgi/protocol.rs
cef44ccdf882d84892a64bdbfdc1ba01380fce5e5f86d2829b0f9613f738b964 src/web_server/fastcgi/response.rs e954308d94848471f6a9d70a50cd2181d17b9c41b2705a3e6c6c0e882a03733b src/web_server/fastcgi/response.rs
e15361622683268c5606a39a9e5db3938e630999767898391226e94ad2e421e8 src/web_server/filesystem.rs b98666353e4af560ab8423d4772d9ad26451dabb1c6d60f5faf547116a2bcd24 src/web_server/filesystem.rs
d4fec3682532b0f2d11a0aefcd024f69fb623a39922ddb6c528a2d2de265347d src/web_server/htaccess.rs d4fec3682532b0f2d11a0aefcd024f69fb623a39922ddb6c528a2d2de265347d src/web_server/htaccess.rs
ca3cd376b313af1ce0ee65081b3327eb213f000d5a215aa4ee6b764de9702126 src/web_server/htaccess/model.rs 66b846cce61345ea6d182db03e4f8eddc5d4a3cbe87f2046523310f1ea13a5a3 src/web_server/htaccess/model.rs
76de1d11df75f4a64556b2821fd0a1774d26c9f0ca0e5f2b5fcfad0722d43d47 src/web_server/htaccess/parse.rs 9c57b7c556984f79404d4afeb9841d0280a198b94c42d887bc6d96b7748a0e67 src/web_server/htaccess/parse.rs
66260d159986d41eab0dfde2cb9f0713fc1cc209c2fb47e9023bd24fd37919e8 src/web_server/htaccess/rewrite.rs 66260d159986d41eab0dfde2cb9f0713fc1cc209c2fb47e9023bd24fd37919e8 src/web_server/htaccess/rewrite.rs
8ca74d9cc4470045301f70eb4a9fbabd0dbfe74434429148d2230df9f996c06c src/web_server/htaccess/tests.rs 8ca74d9cc4470045301f70eb4a9fbabd0dbfe74434429148d2230df9f996c06c src/web_server/htaccess/tests.rs
3341264f6e8041ac5a5d3c4b1601a69787386116019b5febc1a5e22bdb1186f9 src/web_server/metrics.rs b4a75519d9c7bb4f176126a284ea5da68fe512674200765da0984b245e4278ba src/web_server/metrics.rs
0dbef9b71869fc52cf01e9cb400ffd48fb433ae625383edee6c6c2d735608a76 src/web_server/mod.rs 029bc2b277b2fd7528d5c9f5637453ce6d1d3ec2df7e441f6844cf55edef6b5b src/web_server/mod.rs
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs 67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
d98c80663847d6d83073df074ebff65952755415574708abcc153bd8d3adfaa9 src/web_server/response.rs d98c80663847d6d83073df074ebff65952755415574708abcc153bd8d3adfaa9 src/web_server/response.rs
833c9fea744b9649046e1a64d6fc6972fbf6cea640e1943b0db52b7ccfa9faad src/web_server/response/file.rs c69502a28eec224eeded7cad93159aae3e7c136721a19f4542d5f682e055f61b src/web_server/response/file.rs
ba793a140699956a9aef6bc79b0c731c481af8ab7ac3d816a28695743febcb30 src/web_server/response/file_body.rs cbeb022847e64d30e9d63aac92bee00d29281b3602e1a0589f3c978cbc7c2238 src/web_server/response/file_body/mod.rs
c2e54fd803a10f74490f183e8c030fd0e3e72f5de4e79c55bc393ab57d90ab9a src/web_server/response/file_body/tests.rs 4e91d09fa086d388e384d14b347eff8c74c010915ca7c84171e074872323e341 src/web_server/response/file_body/tests.rs
b35e00e4084b4de2125e9b6fff9699358b881277427bed8706fb2e8286e3cf28 src/web_server/response/headers.rs c63a5935bf8e101f1813e029dd62da7d073eb598453ee98aafe9ffa6f85157c2 src/web_server/response/headers.rs
305c0109b4f869a99381a142853f9e2205f5c7c4f904b2dfa4643274bfc542af src/web_server/routing.rs f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server/routing.rs
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs 400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
c645740e5175e32a9ee743700e99a2f16b6fd894de5f734b84021b774dd747eb src/web_server/serve.rs b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs
dc3a18c14c3cc28519103e860b0f42a59d163db15df3f68b76778cdfa6786e46 src/web_server/tests.rs df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs
cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_picker_corner_styles.cjs cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_picker_corner_styles.cjs
2467a6c2295d3fb2ad84cdb4a85176372953034fef2f4d95dc3bf58f019efe21 tests/acl_picker_dom.cjs 2467a6c2295d3fb2ad84cdb4a85176372953034fef2f4d95dc3bf58f019efe21 tests/acl_picker_dom.cjs
8bfee0027ce317f3427ecdf9193549d4a52db58a7b2b99db7cdeebc458d7d4c0 tests/cache_outcomes.cjs 8bfee0027ce317f3427ecdf9193549d4a52db58a7b2b99db7cdeebc458d7d4c0 tests/cache_outcomes.cjs
@@ -510,11 +514,7 @@ cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_pick
2e957134ae1b041ab1f1cac6149892a2704bd61a37fbe4afc0810e2911b5cedd tests/docker_vhost_actions.cjs 2e957134ae1b041ab1f1cac6149892a2704bd61a37fbe4afc0810e2911b5cedd tests/docker_vhost_actions.cjs
c1b806e756a84913d0a1cd2ace0b0f94fb5f5817e0480870a65279b4d7fc5bbb tests/docker_vhost_metrics.cjs c1b806e756a84913d0a1cd2ace0b0f94fb5f5817e0480870a65279b4d7fc5bbb tests/docker_vhost_metrics.cjs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
930cc63183479400d01195574ec0246a482618915bc3c24b4d48a11faa286018 tests/modal_frontend_0346.cjs 930cc63183479400d01195574ec0246a482618915bc3c24b4d48a11faa286018 tests/modal_frontend_0346.cjs
eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs
7a28cfdb43a3cecf90136cf8898c060c382af557ce4ff168a6672391a0e5c26b tests/transport_config.rs
071d590439bba50b526e1b2344b5ee414210af8f4f6a318d1611640386de576f tests/ux_0345.cjs 071d590439bba50b526e1b2344b5ee414210af8f4f6a318d1611640386de576f tests/ux_0345.cjs
b312f9f5d5f4b3d30f935ab3f3c893fd8b8f2d571acc8198a1a89ed9ecd1f0ac tests/ux_0345_followup.cjs b312f9f5d5f4b3d30f935ab3f3c893fd8b8f2d571acc8198a1a89ed9ecd1f0ac tests/ux_0345_followup.cjs
67353441435c6378780e4b6c5dd8732d7160f7fbce8df9cf4791cbd6037f5840 tests/vhost_content_type_ui.cjs 67353441435c6378780e4b6c5dd8732d7160f7fbce8df9cf4791cbd6037f5840 tests/vhost_content_type_ui.cjs
-3
View File
@@ -1,9 +1,6 @@
//! Build extensible language packs and pinned, authenticated Swagger UI assets.
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::{collections::HashSet, env, fs, path::PathBuf, process::Command}; use std::{collections::HashSet, env, fs, path::PathBuf, process::Command};
// Prepare the actual embedded bytes here, not in a dependency's earlier build.
// Cargo config discovery is deliberately irrelevant to the pinned default.
fn swagger_assets( fn swagger_assets(
root: &std::path::Path, root: &std::path::Path,
out: &std::path::Path, out: &std::path::Path,
+2 -5
View File
@@ -108,8 +108,7 @@ def read_assets(path: Path, checksum: str | None) -> tuple[dict[str, bytes], str
snapshot = stream.read(MAX_ARCHIVE + 1) snapshot = stream.read(MAX_ARCHIVE + 1)
if not 0 < len(snapshot) <= MAX_ARCHIVE: if not 0 < len(snapshot) <= MAX_ARCHIVE:
raise AssetError("Swagger archive exceeds the 64 MiB limit.") raise AssetError("Swagger archive exceeds the 64 MiB limit.")
# Parse exactly the same bounded bytes that were hashed, not a reopened file
# that could have changed between verification and ZIP member reads.
digest = hashlib.sha256(snapshot).hexdigest() digest = hashlib.sha256(snapshot).hexdigest()
if checksum and digest != checksum: if checksum and digest != checksum:
raise AssetError("Swagger archive SHA-256 mismatch; no assets were published.") raise AssetError("Swagger archive SHA-256 mismatch; no assets were published.")
@@ -217,12 +216,10 @@ def main() -> int:
checksum = expected_digest(os.environ.get("SWAGGER_UI_ARCHIVE_SHA256")) checksum = expected_digest(os.environ.get("SWAGGER_UI_ARCHIVE_SHA256"))
offline = os.environ.get("CARGO_NET_OFFLINE", "").lower() in ("1", "true") offline = os.environ.get("CARGO_NET_OFFLINE", "").lower() in ("1", "true")
paths = prepare(args.out_dir.resolve(), source, checksum, offline=offline) paths = prepare(args.out_dir.resolve(), source, checksum, offline=offline)
# A fresh cryptographically random build identifier, shared by all JS/CSS assets.
# Regenerated only when Cargo actually runs the build script.
import secrets import secrets
atomic_write(args.out_dir.resolve() / "asset-build-id.txt", secrets.token_hex(8).encode("ascii")) atomic_write(args.out_dir.resolve() / "asset-build-id.txt", secrets.token_hex(8).encode("ascii"))
for path in paths: for path in paths:
# These values are Cargo directives, so never allow extra output lines.
if any(c in str(path) for c in "\r\n\0"): if any(c in str(path) for c in "\r\n\0"):
raise AssetError("Build input/output path contains an invalid control character.") raise AssetError("Build input/output path contains an invalid control character.")
print(f"cargo:rerun-if-changed={path}") print(f"cargo:rerun-if-changed={path}")
+1 -12
View File
@@ -12,10 +12,6 @@ LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85"
def check(root: Path, repair_known: bool = False) -> list[str]: def check(root: Path, repair_known: bool = False) -> list[str]:
messages: list[str] = [] messages: list[str] = []
# 0.25.1 split-source migration: these monoliths are obsolete when the new
# bounded module tree is present. Preserve any local edits instead of silently
# deleting them when a source archive is extracted over an older checkout.
obsolete_split_files = ( obsolete_split_files = (
"src/admin/web/app.js", "src/admin/web/app.js",
"src/admin/web/vhosts.js", "src/admin/web/vhosts.js",
@@ -40,8 +36,6 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
target = backup_root / relative target = backup_root / relative
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
if target.exists(): if target.exists():
# Never overwrite a prior backup; if the bytes match, the current stale
# copy can simply be removed. Otherwise force the operator to reconcile it.
if target.read_bytes() != obsolete.read_bytes(): if target.read_bytes() != obsolete.read_bytes():
raise ValueError( raise ValueError(
f"Backup target {target.relative_to(root)} already contains different data; " f"Backup target {target.relative_to(root)} already contains different data; "
@@ -74,12 +68,9 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
flat = module.parent.with_suffix(".rs") flat = module.parent.with_suffix(".rs")
if flat.is_file(): if flat.is_file():
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]: for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression/mod.rs", "src/diagnostics.rs", "src/traffic/mod.rs", "src/system_stats/mod.rs", "src/system_stats/linux/mod.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security/mod.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages/mod.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth/mod.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "CHANGELOG.md", "ROADMAP.md", "scripts/runtime/acme_provider.py", "src/basic_auth/mod.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/MONITORING.md", "docs/CUSTOM_LOCATIONS.md", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file(): if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}") raise ValueError(f"Incomplete RustProxy 0.34.2 source tree: missing {path}")
# The Reader uses compile-time include_str! for its bundled Markdown. A
# dangling registration is a real compiler error, unlike an omitted old
# release audit. Match both reader_doc! and the expanded include_str! form.
registry = root / "src/admin/documentation/documents.rs" registry = root / "src/admin/documentation/documents.rs"
if not registry.is_file(): if not registry.is_file():
raise ValueError("Incomplete RustProxy 0.34.2 source tree: missing src/admin/documentation/documents.rs") raise ValueError("Incomplete RustProxy 0.34.2 source tree: missing src/admin/documentation/documents.rs")
@@ -89,8 +80,6 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
for doc_path in sorted(embedded_docs): for doc_path in sorted(embedded_docs):
if not (root / doc_path).is_file(): if not (root / doc_path).is_file():
raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration") raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration")
# Fragments inserted by include! cannot start with inner doc comments.
# Scan all Rust includes, not just an individual admin fragment (E0753).
for parent in (root / "src").rglob("*.rs"): for parent in (root / "src").rglob("*.rs"):
source = parent.read_text(encoding="utf-8") source = parent.read_text(encoding="utf-8")
for relative in re.findall(r'\binclude!\s*\(\s*"([^"\n]+\.rs)"\s*\)', source): for relative in re.findall(r'\binclude!\s*\(\s*"([^"\n]+\.rs)"\s*\)', source):
-31
View File
@@ -1,4 +1,3 @@
//! Explicit network ACLs. Peer IP is default; only the central explicit trusted-proxy policy may resolve a forwarded client IP.
use anyhow::{ensure, Context, Result}; use anyhow::{ensure, Context, Result};
use ipnet::IpNet; use ipnet::IpNet;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -76,20 +75,12 @@ impl Acl {
}) })
} }
} }
/// One reusable profile. Only the set of profiles attached to the same Vhost
/// is combined; local Vhost ACLs, locations and global host rules remain
/// independent restrictions.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProfileAccess { pub struct ProfileAccess {
pub acl: Option<CompiledAcl>, pub acl: Option<CompiledAcl>,
pub countries: crate::rate_limit::CountryAccess, pub countries: crate::rate_limit::CountryAccess,
} }
/// Keep the single-profile rules unchanged. With multiple profiles, explicit
/// IP or country denies prevail across all profiles, explicit allows are a
/// union, and unmatched defaults allow only when every profile allows by
/// default. This avoids interpreting an unmatched default-deny as a veto of
/// another profile's explicit exception.
pub fn allows_profiles_with_country( pub fn allows_profiles_with_country(
profiles: &[ProfileAccess], profiles: &[ProfileAccess],
peer: IpAddr, peer: IpAddr,
@@ -120,8 +111,6 @@ pub fn allows_profiles_with_country(
explicit_allow || defaults_allow explicit_allow || defaults_allow
} }
/// The legacy singular reference remains supported. Modern clients write the
/// array only; if both exist we require both policies to avoid silent bypass.
pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator<Item = &'a str> { pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator<Item = &'a str> {
legacy.into_iter().chain(ids.iter().map(String::as_str)) legacy.into_iter().chain(ids.iter().map(String::as_str))
} }
@@ -157,8 +146,6 @@ impl CompiledAcl {
allowed.then_some(Action::Allow) allowed.then_some(Action::Allow)
} }
/// Explicit deny beats all matching allow rules. Otherwise, allow or the default applies.
/// Host policy and route policy are both evaluated, so route allow cannot override host deny.
pub fn allows(&self, peer: IpAddr) -> bool { pub fn allows(&self, peer: IpAddr) -> bool {
match self.explicit_action(peer) { match self.explicit_action(peer) {
Some(Action::Deny) => false, Some(Action::Deny) => false,
@@ -168,10 +155,6 @@ impl CompiledAcl {
} }
} }
/// Evaluate the IP and country controls that belong to one access-policy scope.
/// Explicit IP denies and blocked countries always deny. In allow-only country mode,
/// a listed country or an explicit IP allow is sufficient; this lets a default-deny
/// policy express e.g. "private networks OR PL/DE" without weakening other scopes.
pub fn allows_with_country( pub fn allows_with_country(
acl: Option<&CompiledAcl>, acl: Option<&CompiledAcl>,
countries: &crate::rate_limit::CountryAccess, countries: &crate::rate_limit::CountryAccess,
@@ -203,10 +186,6 @@ pub fn allows_with_country(
} }
} }
} }
/// Validate the already-decoded routing representation used internally after
/// matching/rewriting. Encoded spaces from a valid request are literal spaces
/// at this stage; reserved separators, percent and dot-segment ambiguity stay
/// forbidden.
pub fn validate_decoded_routing_path(path: &str) -> Result<()> { pub fn validate_decoded_routing_path(path: &str) -> Result<()> {
ensure!( ensure!(
path.starts_with('/') && path.len() <= 8192, path.starts_with('/') && path.len() <= 8192,
@@ -225,10 +204,6 @@ pub fn validate_decoded_routing_path(path: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Validate a routing path without allocating for the common already-normalized
/// form. The slow path delegates to `routing_path` to preserve percent decode,
/// UTF-8 validation, slash folding and ambiguity rejection exactly as before.
/// Callers must not use this value as the upstream URL: the raw URI is forwarded.
pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> { pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
ensure!( ensure!(
raw.starts_with('/') && raw.len() <= 8192, raw.starts_with('/') && raw.len() <= 8192,
@@ -251,12 +226,6 @@ pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
Ok(std::borrow::Cow::Borrowed(raw)) Ok(std::borrow::Cow::Borrowed(raw))
} }
/// Routing/ACL use one decoded representation; upstream still receives the original path.
/// Reject encoded separators, percent (double decoding), controls, ambiguous dot segments,
/// matrix parameters and malformed UTF-8. Repeated literal separators are
/// collapsed for routing/ACL decisions (matching nginx's default merge_slashes
/// behavior), while the upstream request still retains its original target.
/// No silent fallback to a less-restrictive route.
pub fn routing_path(raw: &str) -> Result<String> { pub fn routing_path(raw: &str) -> Result<String> {
ensure!( ensure!(
raw.starts_with('/') && raw.len() <= 8192, raw.starts_with('/') && raw.len() <= 8192,
+1 -12
View File
@@ -1,4 +1,3 @@
//! Bounded, fail-closed DNS/DDNS ACL address cache. No resolver runs in the request path.
use crate::config::Config; use crate::config::Config;
use anyhow::{ensure, Result}; use anyhow::{ensure, Result};
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
@@ -36,8 +35,6 @@ pub(crate) fn matches(name: &str, ip: IpAddr) -> bool {
}) })
} }
/// Strict ASCII DNS hostnames only (IDNs can be supplied in punycode).
/// A and AAAA results are eligible. IPs and CIDRs remain separate static rules.
pub(crate) fn hostname(value: &str) -> Result<String> { pub(crate) fn hostname(value: &str) -> Result<String> {
let candidate = value.strip_suffix('.').unwrap_or(value); let candidate = value.strip_suffix('.').unwrap_or(value);
ensure!(candidate.len() <= 253 && candidate.contains('.'), "ACL DNS name requires a fully qualified hostname"); ensure!(candidate.len() <= 253 && candidate.contains('.'), "ACL DNS name requires a fully qualified hostname");
@@ -53,8 +50,6 @@ pub(crate) fn hostname(value: &str) -> Result<String> {
Ok(candidate.to_ascii_lowercase()) Ok(candidate.to_ascii_lowercase())
} }
/// Walk every ACL scope, including standalone host policies, ACL profiles,
/// inline vhost/location ACLs and Docker overrides. Limit process-wide DNS work.
pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> { pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
let mut names = BTreeSet::new(); let mut names = BTreeSet::new();
let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> { let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> {
@@ -63,7 +58,6 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
if rule.network.parse::<ipnet::IpNet>().is_ok() || rule.network.parse::<IpAddr>().is_ok() { if rule.network.parse::<ipnet::IpNet>().is_ok() || rule.network.parse::<IpAddr>().is_ok() {
continue; continue;
} }
// A slash is always a CIDR error, never a DNS name.
let name = hostname(&rule.network)?; let name = hostname(&rule.network)?;
ensure!(rule.action == crate::acl::Action::Allow, ensure!(rule.action == crate::acl::Action::Allow,
"dynamic DNS ACL rules only support allow; use static IP/CIDR for deny"); "dynamic DNS ACL rules only support allow; use static IP/CIDR for deny");
@@ -86,8 +80,6 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
} }
async fn resolve(name: String) -> (String, Option<Addresses>) { async fn resolve(name: String) -> (String, Option<Addresses>) {
// Keep the returned cache key owned independently of the DNS lookup future.
// The lookup result can retain the borrowed hostname until it is dropped.
let lookup_name = name.clone(); let lookup_name = name.clone();
let answer = tokio::time::timeout( let answer = tokio::time::timeout(
Duration::from_secs(LOOKUP_SECONDS), Duration::from_secs(LOOKUP_SECONDS),
@@ -129,7 +121,6 @@ pub(crate) async fn poll_loop(
let snapshot = state.config.load(); let snapshot = state.config.load();
let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; }; let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; };
drop(snapshot); drop(snapshot);
// Resolve concurrently with hard bounds; write one immutable snapshot.
let results: HashMap<_, _> = tokio::select! { let results: HashMap<_, _> = tokio::select! {
_ = shutdown.cancelled() => break, _ = shutdown.cancelled() => break,
result = stream::iter(names.iter().cloned()) result = stream::iter(names.iter().cloned())
@@ -138,7 +129,6 @@ pub(crate) async fn poll_loop(
.filter_map(|(name, answer)| async move { answer.map(|value| (name, value)) }) .filter_map(|(name, answer)| async move { answer.map(|value| (name, value)) })
.collect() => result, .collect() => result,
}; };
// A concurrently changed configuration must not publish obsolete DNS data.
let current = state.config.load(); let current = state.config.load();
if configured_hostnames(&current.envelope.config).ok().as_ref() != Some(&names) { if configured_hostnames(&current.envelope.config).ok().as_ref() != Some(&names) {
CHANGED.notify_one(); CHANGED.notify_one();
@@ -146,9 +136,8 @@ pub(crate) async fn poll_loop(
} }
CACHE.store(Arc::new(results)); CACHE.store(Arc::new(results));
} }
// Do not retain permit decisions when the resolver lifecycle stops.
CACHE.store(Arc::new(HashMap::new())); CACHE.store(Arc::new(HashMap::new()));
} }
#[cfg(test)] #[cfg(test)]
include!("dynamic/tests.rs"); include!("tests.rs");
-3
View File
@@ -13,19 +13,16 @@ mod tests {
let ip: IpAddr = "198.51.100.4".parse().unwrap(); let ip: IpAddr = "198.51.100.4".parse().unwrap();
let entry = Addresses { ips: vec![ip], valid_until: Instant::now() + Duration::from_secs(1) }; let entry = Addresses { ips: vec![ip], valid_until: Instant::now() + Duration::from_secs(1) };
assert!(entry.valid_until > Instant::now()); assert!(entry.valid_until > Instant::now());
// The request path accepts only cached exact addresses, never a subnet.
let mut cache = HashMap::new(); cache.insert(name.to_owned(), entry); let mut cache = HashMap::new(); cache.insert(name.to_owned(), entry);
CACHE.store(Arc::new(cache)); CACHE.store(Arc::new(cache));
assert!(matches(name, ip)); assert!(matches(name, ip));
assert!(!matches(name, "198.51.100.5".parse().unwrap())); assert!(!matches(name, "198.51.100.5".parse().unwrap()));
// A formerly resolved IP cannot remain permitted after expiration.
let mut expired = HashMap::new(); let mut expired = HashMap::new();
expired.insert(name.to_owned(), Addresses { expired.insert(name.to_owned(), Addresses {
ips: vec![ip], valid_until: Instant::now() - Duration::from_secs(1), ips: vec![ip], valid_until: Instant::now() - Duration::from_secs(1),
}); });
CACHE.store(Arc::new(expired)); CACHE.store(Arc::new(expired));
assert!(!matches(name, ip)); assert!(!matches(name, ip));
// IPv6 addresses have the same exact-match and rotation semantics.
let new_ip: IpAddr = "2001:db8::8".parse().unwrap(); let new_ip: IpAddr = "2001:db8::8".parse().unwrap();
let mut rotated = HashMap::new(); let mut rotated = HashMap::new();
rotated.insert(name.to_owned(), Addresses { rotated.insert(name.to_owned(), Addresses {
+8
View File
@@ -360,4 +360,12 @@ mod tests {
assert!(validate_profile_ids(Some("old"), &(0..8).map(|v| format!("profile{v}")).collect::<Vec<_>>()).is_err()); assert!(validate_profile_ids(Some("old"), &(0..8).map(|v| format!("profile{v}")).collect::<Vec<_>>()).is_err());
} }
#[test]
fn percent_encoded_spaces_are_valid_request_paths() {
assert_eq!(
routing_path("/Ubuntu%2014.04%20LTS/").unwrap(),
"/Ubuntu 14.04 LTS/"
);
}
} }
+1 -5
View File
@@ -1,10 +1,6 @@
//! Bounded control-plane adapter for Certbot HTTP-01/DNS-01 providers. Never on traffic paths.
//! Provider credentials enter via stdin, are encrypted in Secret Store, and never
//! travel through argv, logs or API responses.
include!("acme/model.rs"); include!("acme/model.rs");
include!("acme/runtime_env.rs"); include!("acme/runtime_env.rs");
include!("acme/providers.rs"); include!("acme/providers/mod.rs");
include!("acme/jobs.rs"); include!("acme/jobs.rs");
#[cfg(test)] #[cfg(test)]
include!("acme/tests.rs"); include!("acme/tests.rs");
-9
View File
@@ -159,7 +159,6 @@ impl Manager {
"acme.not_due" "acme.not_due"
} }
.into(); .into();
// A dry run must not postpone a due production renewal.
if !dry_run { if !dry_run {
entry.failures = 0; entry.failures = 0;
entry.next_check = now + 43200 + (rand::random::<u16>() as i64 % 1800); entry.next_check = now + 43200 + (rand::random::<u16>() as i64 % 1800);
@@ -231,8 +230,6 @@ async fn deploy_renewal(
.await .await
.map_err(|_| "acme.validation_worker_failed".to_owned())? .map_err(|_| "acme.validation_worker_failed".to_owned())?
.map_err(|_| "acme.certificate_validation_failed".to_owned())?; .map_err(|_| "acme.certificate_validation_failed".to_owned())?;
// CAS retries preserve edits to other parts of the document. A simultaneous
// change of this certificate wins over the worker; never silently overwrite it.
for _ in 0..3 { for _ in 0..3 {
let snapshot = state.config.load_full(); let snapshot = state.config.load_full();
let Some(current) = snapshot let Some(current) = snapshot
@@ -280,16 +277,10 @@ async fn deploy_renewal(
} }
Err("acme.configuration_changed".into()) Err("acme.configuration_changed".into())
} }
/// One scheduler per process, sharing the issuance semaphore. It does not run
/// its own shell hooks or stop listeners; validated snapshots rotate TCP and QUIC TLS.
pub async fn renewal_loop( pub async fn renewal_loop(
weak: std::sync::Weak<App>, weak: std::sync::Weak<App>,
shutdown: tokio_util::sync::CancellationToken, shutdown: tokio_util::sync::CancellationToken,
) { ) {
// Observations are restored from SQLite before listeners are exposed. If an
// older installation has no SQLite observations, the normal scheduler will
// safely reconstruct them on its first check; certificate/key state is not
// derived from historical observation sidecars.
let delay = 300 + (rand::random::<u16>() as u64 % 300); let delay = 300 + (rand::random::<u16>() as u64 % 300);
tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(delay)) => {} } tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(delay)) => {} }
loop { loop {
-1
View File
@@ -394,7 +394,6 @@ impl Manager {
finished.message = message; finished.message = message;
} }
} }
// Never write provider stderr, request JSON or TSIG material to audit.
let _ = state let _ = state
.store .store
.audit( .audit(
@@ -35,8 +35,6 @@ async fn execute(
.map_err(|_| "acme.validation_worker_failed".to_owned())? .map_err(|_| "acme.validation_worker_failed".to_owned())?
} }
/// A reserved validation path: only an active HTTP-01 job and its exact DNS
/// names can read bounded tokens. No origin, cache, auth or custom pages here.
pub async fn challenge( pub async fn challenge(
axum::extract::State(state): axum::extract::State<Arc<App>>, axum::extract::State(state): axum::extract::State<Arc<App>>,
request: axum::extract::Request, request: axum::extract::Request,
@@ -140,10 +138,8 @@ pub async fn challenge(
} }
#[cfg(test)] #[cfg(test)]
include!("providers/tests.rs"); include!("tests.rs");
/// Public diagnostics contain no account/credential material. Desired renewal
/// permission lives with the certificate; observations never overwrite it.
#[derive(Clone, Default, Serialize, Deserialize)] #[derive(Clone, Default, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct RenewalState { pub struct RenewalState {
+7 -18
View File
@@ -13,7 +13,6 @@ async fn create_virtual_environment(
return false; return false;
} }
let _ = tokio::fs::remove_dir_all(staging).await; let _ = tokio::fs::remove_dir_all(staging).await;
// Fall back to an application-local virtualenv bootstrap.
let bootstrap = staging.with_extension("virtualenv-bootstrap"); let bootstrap = staging.with_extension("virtualenv-bootstrap");
let _ = tokio::fs::remove_dir_all(&bootstrap).await; let _ = tokio::fs::remove_dir_all(&bootstrap).await;
let install_bootstrap = |break_system_packages: bool| { let install_bootstrap = |break_system_packages: bool| {
@@ -30,30 +29,20 @@ async fn create_virtual_environment(
"--timeout", "--timeout",
"30", "30",
]); ]);
// Some distro pip builds enforce PEP 668 even for --target. Retrying with
// --break-system-packages is safe here because the destination is the
// private bootstrap directory; distribution site-packages are untouched.
if break_system_packages { if break_system_packages {
install.arg("--break-system-packages"); install.arg("--break-system-packages");
} }
install.arg("--target").arg(&bootstrap).arg("virtualenv>=20,<21"); install
.arg("--target")
.arg(&bootstrap)
.arg("virtualenv>=20,<21");
install install
}; };
let installed = command_success( let installed =
install_bootstrap(false), command_success(install_bootstrap(false), Duration::from_secs(300), shutdown).await;
Duration::from_secs(300),
shutdown,
)
.await;
if !installed { if !installed {
let _ = tokio::fs::remove_dir_all(&bootstrap).await; let _ = tokio::fs::remove_dir_all(&bootstrap).await;
if !command_success( if !command_success(install_bootstrap(true), Duration::from_secs(300), shutdown).await {
install_bootstrap(true),
Duration::from_secs(300),
shutdown,
)
.await
{
let _ = tokio::fs::remove_dir_all(&bootstrap).await; let _ = tokio::fs::remove_dir_all(&bootstrap).await;
return false; return false;
} }
+11 -8
View File
@@ -24,7 +24,6 @@ pub struct RuntimeStatus {
pub python: bool, pub python: bool,
pub pip: bool, pub pip: bool,
pub certbot: bool, pub certbot: bool,
/// Versions of the executables actually selected for ACME, never a guessed system default.
pub python_version: Option<String>, pub python_version: Option<String>,
pub pip_version: Option<String>, pub pip_version: Option<String>,
pub certbot_version: Option<String>, pub certbot_version: Option<String>,
@@ -75,13 +74,14 @@ fn find_on_path(name: &str) -> Option<PathBuf> {
if candidate.components().count() > 1 { if candidate.components().count() > 1 {
return candidate.is_file().then(|| candidate.to_owned()); return candidate.is_file().then(|| candidate.to_owned());
} }
for directory in env::var_os("PATH").into_iter().flat_map(|paths| env::split_paths(&paths).collect::<Vec<_>>()) { for directory in env::var_os("PATH")
.into_iter()
.flat_map(|paths| env::split_paths(&paths).collect::<Vec<_>>())
{
let path = directory.join(name); let path = directory.join(name);
if path.is_file() { if path.is_file() {
return Some(path); return Some(path);
} }
// Windows PATH lookup uses PATHEXT; Path::is_file alone does not.
// Python and Certbot are installed as .exe launchers on Windows.
#[cfg(windows)] #[cfg(windows)]
if path.extension().is_none() { if path.extension().is_none() {
let executable = path.with_extension("exe"); let executable = path.with_extension("exe");
@@ -230,7 +230,8 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
let managed = data_dir.join("acme-python"); let managed = data_dir.join("acme-python");
let managed_directory = Some(managed.to_string_lossy().into_owned()); let managed_directory = Some(managed.to_string_lossy().into_owned());
if managed_environment_valid(&managed, &state.shutdown).await { if managed_environment_valid(&managed, &state.shutdown).await {
let versions = runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed))); let versions =
runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)));
state.acme.set_runtime(RuntimeStatus { state.acme.set_runtime(RuntimeStatus {
status: "ready", status: "ready",
mode: "managed", mode: "managed",
@@ -274,7 +275,9 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
let system_versions = runtime_versions( let system_versions = runtime_versions(
&python, &python,
system_pip, system_pip,
distro_certbot.as_deref().filter(|_| distro_certbot_available), distro_certbot
.as_deref()
.filter(|_| distro_certbot_available),
); );
state.acme.set_runtime(RuntimeStatus { state.acme.set_runtime(RuntimeStatus {
status: "initializing", status: "initializing",
@@ -291,7 +294,8 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
let build = build_managed_environment(&python, &managed, system_pip, &state.shutdown).await; let build = build_managed_environment(&python, &managed, system_pip, &state.shutdown).await;
if build.is_ok() { if build.is_ok() {
tracing::info!(path = %managed.display(), "managed ACME runtime ready"); tracing::info!(path = %managed.display(), "managed ACME runtime ready");
let versions = runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed))); let versions =
runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)));
state.acme.set_runtime(RuntimeStatus { state.acme.set_runtime(RuntimeStatus {
status: "ready", status: "ready",
mode: "managed", mode: "managed",
@@ -383,7 +387,6 @@ impl Drop for ProcessGroup {
fn drop(&mut self) { fn drop(&mut self) {
#[cfg(unix)] #[cfg(unix)]
if let Some(id) = self.0 { if let Some(id) = self.0 {
// SAFETY: this process group belongs only to the managed child.
unsafe { unsafe {
libc::kill(-(id as i32), libc::SIGKILL); libc::kill(-(id as i32), libc::SIGKILL);
} }
+43 -24
View File
@@ -1,7 +1,3 @@
// Version information is diagnostic, not a health check. Read package metadata
// for the *selected* runtime without launching Python, pip, Certbot or a shell.
// A missing/ambiguous version stays None; do not guess an interpreter patch
// version from an executable name or report a hard-coded Certbot pin.
use std::io::Read; use std::io::Read;
const MAX_VERSION_METADATA_BYTES: u64 = 16 * 1024; const MAX_VERSION_METADATA_BYTES: u64 = 16 * 1024;
@@ -21,7 +17,10 @@ fn metadata_text(path: &Path) -> Option<String> {
fn safe_version(raw: &str) -> Option<String> { fn safe_version(raw: &str) -> Option<String> {
let version = raw.trim(); let version = raw.trim();
(version.len() <= 48 (version.len() <= 48
&& version.bytes().next().is_some_and(|byte| byte.is_ascii_digit()) && version
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_digit())
&& version && version
.bytes() .bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".+-_".contains(&byte))) .all(|byte| byte.is_ascii_alphanumeric() || b".+-_".contains(&byte)))
@@ -41,8 +40,6 @@ fn python_environment_root(executable: &Path) -> Option<std::path::PathBuf> {
fn interpreter_version(root: &Path) -> Option<String> { fn interpreter_version(root: &Path) -> Option<String> {
let content = metadata_text(&root.join("pyvenv.cfg"))?; let content = metadata_text(&root.join("pyvenv.cfg"))?;
// CPython venv writes `version = X.Y.Z`. `version_info` from virtualenv
// is a fallback when its standard `version` field is not present.
for key in ["version", "version_info"] { for key in ["version", "version_info"] {
for line in content.lines() { for line in content.lines() {
if let Some((name, value)) = line.split_once('=') { if let Some((name, value)) = line.split_once('=') {
@@ -61,20 +58,20 @@ fn python_minor(version: &str) -> Option<String> {
let mut parts = version.split('.'); let mut parts = version.split('.');
let major = parts.next()?; let major = parts.next()?;
let minor = parts.next()?; let minor = parts.next()?;
(major.bytes().all(|b| b.is_ascii_digit()) (major.bytes().all(|b| b.is_ascii_digit()) && minor.bytes().all(|b| b.is_ascii_digit()))
&& minor.bytes().all(|b| b.is_ascii_digit()))
.then(|| format!("python{major}.{minor}")) .then(|| format!("python{major}.{minor}"))
} }
fn package_sites(root: &Path, python_version: Option<&str>) -> Vec<std::path::PathBuf> { fn package_sites(root: &Path, python_version: Option<&str>) -> Vec<std::path::PathBuf> {
let mut sites = vec![root.join("Lib/site-packages"), root.join("lib/python3/dist-packages")]; let mut sites = vec![
root.join("Lib/site-packages"),
root.join("lib/python3/dist-packages"),
];
for base in [root.join("lib"), root.join("lib64")] { for base in [root.join("lib"), root.join("lib64")] {
if let Some(minor) = python_version.and_then(python_minor) { if let Some(minor) = python_version.and_then(python_minor) {
sites.push(base.join(&minor).join("site-packages")); sites.push(base.join(&minor).join("site-packages"));
sites.push(base.join(&minor).join("dist-packages")); sites.push(base.join(&minor).join("dist-packages"));
} else if let Ok(entries) = std::fs::read_dir(&base) { } else if let Ok(entries) = std::fs::read_dir(&base) {
// Python installations with no pyvenv.cfg may contain multiple
// interpreter versions. Never choose between conflicting results.
for entry in entries.take(MAX_SITE_DIRECTORIES).flatten() { for entry in entries.take(MAX_SITE_DIRECTORIES).flatten() {
let path = entry.path(); let path = entry.path();
if entry.file_name().to_string_lossy().starts_with("python3.") && path.is_dir() { if entry.file_name().to_string_lossy().starts_with("python3.") && path.is_dir() {
@@ -91,14 +88,26 @@ fn package_version(root: &Path, package: &str, python_version: Option<&str>) ->
let prefix = format!("{package}-"); let prefix = format!("{package}-");
let mut result: Option<String> = None; let mut result: Option<String> = None;
for site in package_sites(root, python_version) { for site in package_sites(root, python_version) {
let Ok(entries) = std::fs::read_dir(site) else { continue }; let Ok(entries) = std::fs::read_dir(site) else {
continue;
};
for entry in entries.take(MAX_PACKAGES_PER_SITE).flatten() { for entry in entries.take(MAX_PACKAGES_PER_SITE).flatten() {
let name = entry.file_name(); let name = entry.file_name();
let Some(dir_name) = name.to_str() else { continue }; let Some(dir_name) = name.to_str() else {
let Some(suffix) = dir_name.strip_prefix(prefix.as_str()) continue;
.and_then(|name| name.strip_suffix(".dist-info")) else { continue }; };
let Some(directory_version) = safe_version(suffix) else { continue }; let Some(suffix) = dir_name
let Some(content) = metadata_text(&entry.path().join("METADATA")) else { continue }; .strip_prefix(prefix.as_str())
.and_then(|name| name.strip_suffix(".dist-info"))
else {
continue;
};
let Some(directory_version) = safe_version(suffix) else {
continue;
};
let Some(content) = metadata_text(&entry.path().join("METADATA")) else {
continue;
};
let mut reported_name = None; let mut reported_name = None;
let mut reported_version = None; let mut reported_version = None;
for line in content.lines() { for line in content.lines() {
@@ -107,13 +116,19 @@ fn package_version(root: &Path, package: &str, python_version: Option<&str>) ->
} else if let Some(value) = line.strip_prefix("Version: ") { } else if let Some(value) = line.strip_prefix("Version: ") {
reported_version = safe_version(value); reported_version = safe_version(value);
} }
if reported_name.is_some() && reported_version.is_some() { break; } if reported_name.is_some() && reported_version.is_some() {
break;
}
} }
if !reported_name.is_some_and(|name| name.eq_ignore_ascii_case(package)) if !reported_name.is_some_and(|name| name.eq_ignore_ascii_case(package))
|| reported_version.as_deref() != Some(directory_version.as_str()) { || reported_version.as_deref() != Some(directory_version.as_str())
{
continue; continue;
} }
if result.as_ref().is_some_and(|previous| previous != &directory_version) { if result
.as_ref()
.is_some_and(|previous| previous != &directory_version)
{
return None; return None;
} }
result = Some(directory_version); result = Some(directory_version);
@@ -133,7 +148,8 @@ fn runtime_versions(python: &Path, has_pip: bool, certbot: Option<&Path>) -> Run
let root = python_environment_root(python); let root = python_environment_root(python);
let python_version = root.as_deref().and_then(interpreter_version); let python_version = root.as_deref().and_then(interpreter_version);
let pip_version = if has_pip { let pip_version = if has_pip {
root.as_deref().and_then(|root| package_version(root, "pip", python_version.as_deref())) root.as_deref()
.and_then(|root| package_version(root, "pip", python_version.as_deref()))
} else { } else {
None None
}; };
@@ -146,6 +162,9 @@ fn runtime_versions(python: &Path, has_pip: bool, certbot: Option<&Path>) -> Run
}; };
package_version(&certbot_root, "certbot", certbot_python_version) package_version(&certbot_root, "certbot", certbot_python_version)
}); });
RuntimeVersions { python: python_version, pip: pip_version, certbot: certbot_version } RuntimeVersions {
python: python_version,
pip: pip_version,
certbot: certbot_version,
}
} }
+23 -7
View File
@@ -111,7 +111,6 @@ mod renewal_regressions {
"acme.configuration_changed" "acme.configuration_changed"
); );
assert_eq!(app.config.load().envelope.revision, 0); assert_eq!(app.config.load().envelope.revision, 0);
// Explicit Check now remains allowed when scheduled checks are disabled.
deploy_renewal(&app, &original, &output, false) deploy_renewal(&app, &original, &output, false)
.await .await
.unwrap(); .unwrap();
@@ -147,7 +146,6 @@ mod renewal_regressions {
} }
} }
mod runtime_metadata_tests { mod runtime_metadata_tests {
use super::*; use super::*;
@@ -166,13 +164,22 @@ mod runtime_metadata_tests {
std::fs::create_dir_all(venv_bin(root)).unwrap(); std::fs::create_dir_all(venv_bin(root)).unwrap();
std::fs::write(&python, "").unwrap(); std::fs::write(&python, "").unwrap();
std::fs::write(&certbot, "").unwrap(); std::fs::write(&certbot, "").unwrap();
std::fs::write(root.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.13.5\n").unwrap(); std::fs::write(
root.join("pyvenv.cfg"),
"home = /usr/bin\nversion = 3.13.5\n",
)
.unwrap();
let site = if cfg!(windows) { let site = if cfg!(windows) {
root.join("Lib/site-packages") root.join("Lib/site-packages")
} else { } else {
root.join("lib/python3.13/site-packages") root.join("lib/python3.13/site-packages")
}; };
write_package(&site, "pip", "25.2", "Metadata-Version: 2.3\nName: pip\nVersion: 25.2\n"); write_package(
&site,
"pip",
"25.2",
"Metadata-Version: 2.3\nName: pip\nVersion: 25.2\n",
);
write_package(&site, "certbot", "5.8.0", "Name: certbot\nVersion: 5.8.0\n"); write_package(&site, "certbot", "5.8.0", "Name: certbot\nVersion: 5.8.0\n");
let versions = runtime_versions(&python, true, Some(&certbot)); let versions = runtime_versions(&python, true, Some(&certbot));
assert_eq!(versions.python.as_deref(), Some("3.13.5")); assert_eq!(versions.python.as_deref(), Some("3.13.5"));
@@ -202,9 +209,18 @@ mod runtime_metadata_tests {
let executable = root.join("bin/python3"); let executable = root.join("bin/python3");
std::fs::create_dir_all(root.join("bin")).unwrap(); std::fs::create_dir_all(root.join("bin")).unwrap();
std::fs::write(&executable, "").unwrap(); std::fs::write(&executable, "").unwrap();
write_package(&root.join("lib/python3/dist-packages"), "pip", "23.0", "Name: pip\nVersion: 23.0\n"); write_package(
write_package(&root.join("lib/python3.13/site-packages"), "pip", "25.0", "Name: pip\nVersion: 25.0\n"); &root.join("lib/python3/dist-packages"),
"pip",
"23.0",
"Name: pip\nVersion: 23.0\n",
);
write_package(
&root.join("lib/python3.13/site-packages"),
"pip",
"25.0",
"Name: pip\nVersion: 25.0\n",
);
assert!(runtime_versions(&executable, true, None).pip.is_none()); assert!(runtime_versions(&executable, true, None).pip.is_none());
} }
} }
+7 -34
View File
@@ -46,8 +46,6 @@ fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
} }
} }
fn unauthorized(request: &Request) -> Response { fn unauthorized(request: &Request) -> Response {
// Only top-level documentation navigation gets a fixed, same-origin login
// redirect. API/spec/assets ALWAYS return a small 401, never a login HTML body.
if (request.method() == Method::GET || request.method() == Method::HEAD) if (request.method() == Method::GET || request.method() == Method::HEAD)
&& matches!(request.uri().path(), "/docs" | "/docs/") && matches!(request.uri().path(), "/docs" | "/docs/")
{ {
@@ -65,9 +63,6 @@ fn admin_request_authority(request: &Request, tls: bool) -> Result<String, ()> {
}; };
if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) { if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) {
// Hyper exposes :authority through the request URI for H2/H3. Browsers do
// not have to send a legacy Host field, so requiring one here rejects
// perfectly valid administration requests negotiated over ALPN h2.
let authority = request.uri().authority().ok_or(())?.as_str(); let authority = request.uri().authority().ok_or(())?.as_str();
if let Some(scheme) = request.uri().scheme_str() { if let Some(scheme) = request.uri().scheme_str() {
if scheme != if tls { "https" } else { "http" } { if scheme != if tls { "https" } else { "http" } {
@@ -86,9 +81,6 @@ fn admin_request_authority(request: &Request, tls: bool) -> Result<String, ()> {
return Ok(authority.to_owned()); return Ok(authority.to_owned());
} }
// The administration listener is an origin server, never a forward proxy.
// HTTP/1.x therefore keeps the existing exact single-Host requirement and
// rejects absolute-form targets.
if request.uri().scheme().is_some() || request.uri().authority().is_some() { if request.uri().scheme().is_some() || request.uri().authority().is_some() {
return Err(()); return Err(());
} }
@@ -131,14 +123,7 @@ fn admin_host_allowed(
!a.as_str().contains('@') !a.as_str().contains('@')
&& ((direct_host && port == configured_addr.port()) && ((direct_host && port == configured_addr.port())
// An explicit DNS allow-list describes the browser-facing
// authority. A reverse proxy is therefore free to terminate
// on 80/443 (or another external port) while forwarding to
// the private administration socket on its configured port.
|| explicit_allowed || explicit_allowed
// Preserve the historical direct-TLS convenience when no
// strict DNS allow-list exists; unlike the explicit list it
// remains bound to the actual administration listener port.
|| (dns_without_allowlist && port == configured_addr.port())) || (dns_without_allowlist && port == configured_addr.port()))
}) })
} }
@@ -229,21 +214,19 @@ fn external_admin_authority(
} }
} }
// In pinned-origin mode accept only bounded, consistent proxy-hop metadata:
// the first hop must report browser HTTPS, later internal hops may use HTTP.
// We still do NOT pick a trusted scheme from these values; the explicit pin
// defines the panel origin. A clear public HTTP or unknown value fails closed.
fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool { fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
let mut hops = 0; let mut hops = 0;
for value in headers.get_all("x-forwarded-proto").iter() { for value in headers.get_all("x-forwarded-proto").iter() {
let Ok(value) = value.to_str() else { return false; }; let Ok(value) = value.to_str() else {
return false;
};
for part in value.split(',') { for part in value.split(',') {
hops += 1; hops += 1;
if hops > 8 { return false; } if hops > 8 {
return false;
}
let scheme = part.trim(); let scheme = part.trim();
if (hops == 1 && scheme != "https") if (hops == 1 && scheme != "https") || !matches!(scheme, "http" | "https") {
|| !matches!(scheme, "http" | "https")
{
return false; return false;
} }
} }
@@ -251,11 +234,6 @@ fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
true true
} }
/// A configured panel URL is authoritative only for an explicitly trusted
/// direct reverse-proxy peer. In this mode X-Forwarded-Proto is deliberately
/// ignored: conflicting hop values such as "https, http" are not evidence of
/// the browser scheme. The public URL comes from administrator configuration,
/// not from a user-controlled header.
fn configured_admin_public_authority( fn configured_admin_public_authority(
state: &App, state: &App,
headers: &HeaderMap, headers: &HeaderMap,
@@ -267,7 +245,6 @@ fn configured_admin_public_authority(
return Ok(None); return Ok(None);
}; };
if !trusted_admin_proxy(state, peer) { if !trusted_admin_proxy(state, peer) {
// Never apply the pin to arbitrary direct/untrusted connections.
return Ok(None); return Ok(None);
} }
if !admin_host_allowed( if !admin_host_allowed(
@@ -314,10 +291,6 @@ fn external_admin_scheme(
} }
} }
/// Plain HTTP requests accepted on an HTTPS administration socket are redirected
/// to the same authority/path. The TLS server only selects this router after it
/// has positively identified a non-TLS first byte, so admin content is never
/// served over this path.
pub fn https_redirect_router() -> Router { pub fn https_redirect_router() -> Router {
Router::new().fallback(|request: Request| async move { Router::new().fallback(|request: Request| async move {
let raw_host = request let raw_host = request
-2
View File
@@ -1,5 +1,3 @@
// Narrow method/path allowlist: tokens never inherit browser-only administration
// routes (password/session, credentials, backups, docs, preferences or SSE).
fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> bool { fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> bool {
let read = method == Method::GET let read = method == Method::GET
&& matches!( && matches!(
-2
View File
@@ -1,5 +1,3 @@
// General-purpose API credentials are independent of browser sessions,
// certificate deployment credentials, and the Prometheus scrape credential.
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct CreateApiToken { struct CreateApiToken {
+24 -10
View File
@@ -119,8 +119,6 @@ async fn certificate_api_rotate(
Ok(Json(value)) Ok(Json(value))
} }
// Per-credential settings are intentionally separate from token rotation.
// The input cannot include token/token_hash, and every read returns only metadata.
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct CertificateApiEdit { struct CertificateApiEdit {
@@ -134,7 +132,10 @@ async fn certificate_api_detail(
Path(key_id): Path<String>, Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) { if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into())); return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
} }
let credential = state let credential = state
.store .store
@@ -152,11 +153,17 @@ async fn certificate_api_edit(
Json(input): Json<CertificateApiEdit>, Json(input): Json<CertificateApiEdit>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) { if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into())); return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
} }
let comment = input.comment.trim(); let comment = input.comment.trim();
if !valid_certificate_api_comment(comment) { if !valid_certificate_api_comment(comment) {
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "certificate_api.invalid_comment".into())); return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_comment".into(),
));
} }
let mut certificate_ids: Vec<String> = input let mut certificate_ids: Vec<String> = input
.certificate_ids .certificate_ids
@@ -168,7 +175,9 @@ async fn certificate_api_edit(
certificate_ids.dedup(); certificate_ids.dedup();
if certificate_ids.is_empty() if certificate_ids.is_empty()
|| certificate_ids.len() > 32 || certificate_ids.len() > 32
|| certificate_ids.iter().any(|value| value == "*" || !crate::config::valid_id(value)) || certificate_ids
.iter()
.any(|value| value == "*" || !crate::config::valid_id(value))
{ {
return Err(ApiError( return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
@@ -178,7 +187,11 @@ async fn certificate_api_edit(
let credential = state let credential = state
.store .store
.edit_certificate_api_credential( .edit_certificate_api_credential(
&key_id, comment, input.allow_deploy, &certificate_ids, &session.username, &key_id,
comment,
input.allow_deploy,
&certificate_ids,
&session.username,
) )
.await .await
.map_err(auth::internal)? .map_err(auth::internal)?
@@ -192,7 +205,10 @@ async fn certificate_api_regenerate(
Path(key_id): Path<String>, Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) { if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into())); return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
} }
let token = auth::random_secret(); let token = auth::random_secret();
let hash = auth::secret_hash(&token); let hash = auth::secret_hash(&token);
@@ -202,7 +218,6 @@ async fn certificate_api_regenerate(
.await .await
.map_err(auth::internal)? .map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?; .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
// The only endpoint in this flow that returns the new bearer secret.
Ok(Json(json!({ "credential": credential, "token": token }))) Ok(Json(json!({ "credential": credential, "token": token })))
} }
@@ -242,4 +257,3 @@ async fn certificate_api_revoke_all(
.map_err(auth::internal)?; .map_err(auth::internal)?;
Ok(Json(json!({"configured": false, "revoked": revoked}))) Ok(Json(json!({"configured": false, "revoked": revoked})))
} }
-4
View File
@@ -24,7 +24,6 @@ async fn certificate_deploy(
)); ));
} }
} }
// Check the credential scope before disclosing certificate storage state.
let directory = state.policy.cert_dir.clone().ok_or_else(|| { let directory = state.policy.cert_dir.clone().ok_or_else(|| {
ApiError( ApiError(
StatusCode::CONFLICT, StatusCode::CONFLICT,
@@ -106,8 +105,6 @@ async fn certificate_deploy(
let key_file = config.tls.certificates[index].key_file.clone(); let key_file = config.tls.certificates[index].key_file.clone();
if config.tls.certificates[index].covered_hosts == covered_hosts { if config.tls.certificates[index].covered_hosts == covered_hosts {
// A no-op does not pass through update_as(), so explicitly retain
// the same optimistic-concurrency guarantee as a real publish.
if state.config.load().envelope.revision != expected_revision { if state.config.load().envelope.revision != expected_revision {
return Err(ApiError( return Err(ApiError(
StatusCode::CONFLICT, StatusCode::CONFLICT,
@@ -423,7 +420,6 @@ async fn basic_hash(
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
crate::basic_auth::validate_username(&input.username) crate::basic_auth::validate_username(&input.username)
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "basic.invalid_username".into()))?; .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "basic.invalid_username".into()))?;
// The semaphore is held by the blocking worker even if the HTTP client disconnects.
let permit = state let permit = state
.basic_workers .basic_workers
.clone() .clone()
-3
View File
@@ -1,4 +1,3 @@
/// Account-scoped snapshots of Overview order, visibility, widths and display.
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -63,8 +62,6 @@ async fn dashboard_presets(
tracing::warn!("skipped invalid saved Overview preset"); tracing::warn!("skipped invalid saved Overview preset");
continue; continue;
}; };
// Compatibility with future/retired widgets: use the same registry
// reconciliation logic as ordinary saved administrator preferences.
let mut prefs = AdminPreferences::default(); let mut prefs = AdminPreferences::default();
prefs.dashboard_widgets = layout.dashboard_widgets; prefs.dashboard_widgets = layout.dashboard_widgets;
prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets; prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets;
+3 -8
View File
@@ -1,5 +1,3 @@
//! Authenticated documentation Reader and OpenAPI/Swagger UI on the admin listener.
//! Documentation and vendor assets are embedded at compile time; no runtime filesystem or CDN access.
use axum::{ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
Json, Json,
@@ -8,9 +6,6 @@ use http::StatusCode;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::sync::OnceLock; use std::sync::OnceLock;
// Keep Reader input independent from build-script output. `include_str!` makes every
// document a direct compiler input, so a clean or partially removed target directory
// cannot leave the crate pointing at a missing OUT_DIR artifact.
mod documents; mod documents;
use documents::READER_DOCUMENTS; use documents::READER_DOCUMENTS;
@@ -18,7 +13,9 @@ pub(super) async fn index() -> impl IntoResponse {
Html(include_str!("web/docs.html").replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())) Html(include_str!("web/docs.html").replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()))
} }
pub(super) async fn fingerprinted(axum::extract::Path(name): axum::extract::Path<String>) -> Response { pub(super) async fn fingerprinted(
axum::extract::Path(name): axum::extract::Path<String>,
) -> Response {
let full = format!("/docs/{name}"); let full = format!("/docs/{name}");
match super::fingerprinted_path(&full, "/docs/").as_deref() { match super::fingerprinted_path(&full, "/docs/").as_deref() {
Some("/docs/init.js") => script().await.into_response(), Some("/docs/init.js") => script().await.into_response(),
@@ -93,8 +90,6 @@ pub(super) async fn schema() -> Json<Value> {
} }
pub(super) fn asset(name: &'static str) -> Response { pub(super) fn asset(name: &'static str) -> Response {
// Bytes are embedded for debug AND release, and always served through the
// authenticated router. Never read a file or fetch a CDN on an HTTP request.
let (content_type, bytes): (&'static str, &'static [u8]) = match name { let (content_type, bytes): (&'static str, &'static [u8]) = match name {
"swagger-ui-bundle.js" => ( "swagger-ui-bundle.js" => (
"text/javascript; charset=utf-8", "text/javascript; charset=utf-8",
-3
View File
@@ -1,6 +1,3 @@
//! Embedded current-version Markdown guides for the authenticated documentation Reader.
//! Keep this list in sync with docs/README.md. The embedded files must exist at build time.
macro_rules! reader_doc { macro_rules! reader_doc {
($path:literal, $category:literal) => { ($path:literal, $category:literal) => {
( (
+53 -21
View File
@@ -1,12 +1,11 @@
// Bound candidate retention while scanning potentially enormous rotation folders.
// The heap's top is the least desirable result: oldest timestamp, then
// lexicographically last name. Final output preserves newest/name order.
fn newest_log_files( fn newest_log_files(
candidates: impl Iterator<Item = (String, std::path::PathBuf, u64)>, candidates: impl Iterator<Item = (String, std::path::PathBuf, u64)>,
limit: usize, limit: usize,
) -> Vec<(String, std::path::PathBuf, u64)> { ) -> Vec<(String, std::path::PathBuf, u64)> {
use std::{cmp::Reverse, collections::BinaryHeap}; use std::{cmp::Reverse, collections::BinaryHeap};
if limit == 0 { return Vec::new(); } if limit == 0 {
return Vec::new();
}
let mut bounded = BinaryHeap::with_capacity(limit); let mut bounded = BinaryHeap::with_capacity(limit);
for (name, path, modified_ms) in candidates { for (name, path, modified_ms) in candidates {
let item = (Reverse(modified_ms), name, path); let item = (Reverse(modified_ms), name, path);
@@ -17,14 +16,14 @@ fn newest_log_files(
bounded.push(item); bounded.push(item);
} }
} }
let mut result = bounded.into_iter() let mut result = bounded
.into_iter()
.map(|(Reverse(modified), name, path)| (name, path, modified)) .map(|(Reverse(modified), name, path)| (name, path, modified))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
result result
} }
// Bounded file-log inventory helpers and gzip tail reader.
fn rotated_gzip_sources( fn rotated_gzip_sources(
active: &std::path::Path, active: &std::path::Path,
id_prefix: &str, id_prefix: &str,
@@ -33,25 +32,52 @@ fn rotated_gzip_sources(
enabled: bool, enabled: bool,
format: &'static str, format: &'static str,
) -> Vec<FileLogSource> { ) -> Vec<FileLogSource> {
let Some(parent) = active.parent() else { return Vec::new(); }; let Some(parent) = active.parent() else {
let Some(base) = active.file_name().and_then(|name| name.to_str()) else { return Vec::new(); }; return Vec::new();
let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink()); };
if !safe { return Vec::new(); } let Some(base) = active.file_name().and_then(|name| name.to_str()) else {
return Vec::new();
};
let safe = std::fs::symlink_metadata(parent)
.ok()
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if !safe {
return Vec::new();
}
let prefix = format!("{base}."); let prefix = format!("{base}.");
let entries = newest_log_files(std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| { let entries = newest_log_files(
std::fs::read_dir(parent)
.ok()
.into_iter()
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned(); let name = entry.file_name().to_string_lossy().into_owned();
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; } if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") {
return None;
}
let file_type = entry.file_type().ok()?; let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() { return None; } if !file_type.is_file() || file_type.is_symlink() {
return None;
}
let meta = entry.metadata().ok()?; let meta = entry.metadata().ok()?;
Some((name, entry.path(), file_modified_ms(&meta))) Some((name, entry.path(), file_modified_ms(&meta)))
}), 64); }),
entries.into_iter().map(|(name,path,_)| source_from_path( 64,
);
entries
.into_iter()
.map(|(name, path, _)| {
source_from_path(
format!("{id_prefix}:gz:{name}"), format!("{id_prefix}:gz:{name}"),
format!("{label_prefix} · {name}"), format!("{label_prefix} · {name}"),
id_prefix.to_owned(), id_prefix.to_owned(),
kind, path, enabled, format kind,
)).collect() path,
enabled,
format,
)
})
.collect()
} }
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> { fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
@@ -81,8 +107,6 @@ fn read_tail(
let mut file = open_file_log(&source.path)?; let mut file = open_file_log(&source.path)?;
let length = file.metadata()?.len(); let length = file.metadata()?.len();
if source.compressed { if source.compressed {
// gzip streams are not seekable. Read a complete bounded rotation, then
// keep only the same 2 MiB decompressed tail used for plain files.
if length > budget || length > FILE_LOG_GZIP_INPUT_MAX { if length > budget || length > FILE_LOG_GZIP_INPUT_MAX {
return Ok((Vec::new(), length.min(budget), true)); return Ok((Vec::new(), length.min(budget), true));
} }
@@ -108,7 +132,11 @@ fn read_tail(
} }
let text = String::from_utf8_lossy(&content); let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev(); let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>(); let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
return Ok((lines, length, truncated || iter.next().is_some())); return Ok((lines, length, truncated || iter.next().is_some()));
} }
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE); let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
@@ -126,6 +154,10 @@ fn read_tail(
} }
let text = String::from_utf8_lossy(&content); let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev(); let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>(); let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some())) Ok((lines, bytes, truncated || iter.next().is_some()))
} }
+88 -34
View File
@@ -50,8 +50,6 @@ fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchCl
true true
} }
// Order the entire bounded matching scan before applying offset/limit. Sorting
// only the current browser page would give misleading navigation results.
#[derive(Debug)] #[derive(Debug)]
enum FileLogSortKey { enum FileLogSortKey {
Number(serde_json::Number), Number(serde_json::Number),
@@ -59,29 +57,43 @@ enum FileLogSortKey {
} }
fn stored_log_summary(value: &Value) -> String { fn stored_log_summary(value: &Value) -> String {
let field = |name| value.get(name).and_then(Value::as_str).filter(|text| !text.is_empty()); let field = |name| {
value
.get(name)
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
};
if let Some(reason) = field("reason").or_else(|| field("protocol_error")) { if let Some(reason) = field("reason").or_else(|| field("protocol_error")) {
return reason.to_owned(); return reason.to_owned();
} }
if field("method").is_some() || field("path").is_some() { if field("method").is_some() || field("path").is_some() {
return format!("{} {}{}", field("method").unwrap_or(""), return format!(
"{} {}{}",
field("method").unwrap_or(""),
field("host").or_else(|| field("authority")).unwrap_or(""), field("host").or_else(|| field("authority")).unwrap_or(""),
field("path").unwrap_or("")).trim().to_owned(); field("path").unwrap_or("")
)
.trim()
.to_owned();
} }
for key in ["error", "error_detail", "message", "raw", "outcome"] { for key in ["error", "error_detail", "message", "raw", "outcome"] {
if let Some(text) = field(key) { return text.to_owned(); } if let Some(text) = field(key) {
return text.to_owned();
}
} }
String::new() String::new()
} }
fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> { fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> {
if field == "record" { if field == "record" {
return Some(FileLogSortKey::Text(stored_log_summary(record).to_lowercase())); return Some(FileLogSortKey::Text(
stored_log_summary(record).to_lowercase(),
));
} }
// Prefer millisecond timestamps for numeric chronological sorting. RAW
// file timestamps use the existing ISO/text fallback.
let value = if field == "time" { let value = if field == "time" {
record.get("time_ms").filter(|value| value.is_number()) record
.get("time_ms")
.filter(|value| value.is_number())
.or_else(|| record.get("timestamp")) .or_else(|| record.get("timestamp"))
} else { } else {
file_log_field(record, field) file_log_field(record, field)
@@ -91,39 +103,51 @@ fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> {
Value::Number(number) => Some(FileLogSortKey::Number(number.clone())), Value::Number(number) => Some(FileLogSortKey::Number(number.clone())),
Value::String(text) if !text.is_empty() => Some(FileLogSortKey::Text(text.to_lowercase())), Value::String(text) if !text.is_empty() => Some(FileLogSortKey::Text(text.to_lowercase())),
Value::Bool(boolean) => Some(FileLogSortKey::Text(boolean.to_string())), Value::Bool(boolean) => Some(FileLogSortKey::Text(boolean.to_string())),
Value::Array(_) | Value::Object(_) => Some(FileLogSortKey::Text(value.to_string().to_lowercase())), Value::Array(_) | Value::Object(_) => {
Some(FileLogSortKey::Text(value.to_string().to_lowercase()))
}
_ => None, _ => None,
} }
} }
fn file_log_timestamp_cmp_desc(left: &Value, right: &Value) -> std::cmp::Ordering { fn file_log_timestamp_cmp_desc(left: &Value, right: &Value) -> std::cmp::Ordering {
match (left.get("time_ms").and_then(Value::as_u64), right.get("time_ms").and_then(Value::as_u64)) { match (
left.get("time_ms").and_then(Value::as_u64),
right.get("time_ms").and_then(Value::as_u64),
) {
(Some(a), Some(b)) => b.cmp(&a), (Some(a), Some(b)) => b.cmp(&a),
_ => right.get("timestamp").and_then(Value::as_str).unwrap_or("") _ => right
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.cmp(left.get("timestamp").and_then(Value::as_str).unwrap_or("")), .cmp(left.get("timestamp").and_then(Value::as_str).unwrap_or("")),
} }
} }
fn sort_file_log_records(records: &mut Vec<Value>, field: &str, descending: bool) { fn sort_file_log_records(records: &mut Vec<Value>, field: &str, descending: bool) {
use std::cmp::Ordering; use std::cmp::Ordering;
// Build one key per record; do not repeatedly allocate/parse strings in let mut keyed = std::mem::take(records)
// the O(n log n) comparison loop. The scan itself is limited to 20k lines. .into_iter()
let mut keyed = std::mem::take(records).into_iter().enumerate() .enumerate()
.map(|(ordinal, record)| (stored_log_sort_key(&record, field), ordinal, record)) .map(|(ordinal, record)| (stored_log_sort_key(&record, field), ordinal, record))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
keyed.sort_by(|left, right| { keyed.sort_by(|left, right| {
let order = match (&left.0, &right.0) { let order = match (&left.0, &right.0) {
(None, None) => Ordering::Equal, (None, None) => Ordering::Equal,
(None, Some(_)) => return Ordering::Greater, // missing values always last (None, Some(_)) => return Ordering::Greater,
(Some(_), None) => return Ordering::Less, (Some(_), None) => return Ordering::Less,
(Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) => (Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) => {
match (a.as_i64(), b.as_i64()) { match (a.as_i64(), b.as_i64()) {
(Some(left), Some(right)) => left.cmp(&right), (Some(left), Some(right)) => left.cmp(&right),
_ => match (a.as_u64(), b.as_u64()) { _ => match (a.as_u64(), b.as_u64()) {
(Some(left), Some(right)) => left.cmp(&right), (Some(left), Some(right)) => left.cmp(&right),
_ => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(Ordering::Equal), _ => a
}, .as_f64()
.partial_cmp(&b.as_f64())
.unwrap_or(Ordering::Equal),
}, },
}
}
(Some(FileLogSortKey::Text(a)), Some(FileLogSortKey::Text(b))) => a.cmp(b), (Some(FileLogSortKey::Text(a)), Some(FileLogSortKey::Text(b))) => a.cmp(b),
(Some(FileLogSortKey::Number(_)), Some(FileLogSortKey::Text(_))) => Ordering::Less, (Some(FileLogSortKey::Number(_)), Some(FileLogSortKey::Text(_))) => Ordering::Less,
(Some(FileLogSortKey::Text(_)), Some(FileLogSortKey::Number(_))) => Ordering::Greater, (Some(FileLogSortKey::Text(_)), Some(FileLogSortKey::Number(_))) => Ordering::Greater,
@@ -145,10 +169,20 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|| !text_ok(&query.q, 512) || !text_ok(&query.q, 512)
|| !text_ok(&query.client_ip, 128) || !text_ok(&query.client_ip, 128)
|| !text_ok(&query.vhost, 256) || !text_ok(&query.vhost, 256)
|| query.aggregate_field.as_ref().is_some_and(|field| !valid_raw_field_key(field)) || query
|| query.sort_by.as_ref().is_some_and(|field| !valid_raw_field_key(field)) .aggregate_field
|| query.aggregate_limit.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS) .as_ref()
|| query.aggregate_min_count.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES) .is_some_and(|field| !valid_raw_field_key(field))
|| query
.sort_by
.as_ref()
.is_some_and(|field| !valid_raw_field_key(field))
|| query
.aggregate_limit
.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS)
|| query
.aggregate_min_count
.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES)
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET || query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|| query || query
.limit .limit
@@ -187,8 +221,11 @@ async fn file_logs_query(
let requested = query.source.as_deref().unwrap_or("all"); let requested = query.source.as_deref().unwrap_or("all");
let mut truncated = false; let mut truncated = false;
if requested != "all" { if requested != "all" {
let consolidate_vhosts = query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:"); let consolidate_vhosts =
sources.retain(|source| source.id == requested || (consolidate_vhosts && source.group_id == requested)); query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:");
sources.retain(|source| {
source.id == requested || (consolidate_vhosts && source.group_id == requested)
});
if sources.is_empty() { if sources.is_empty() {
return Err(ApiError( return Err(ApiError(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@@ -210,9 +247,15 @@ async fn file_logs_query(
let mut scanned_lines = 0usize; let mut scanned_lines = 0usize;
let mut records = Vec::new(); let mut records = Vec::new();
let mut fields = std::collections::BTreeSet::new(); let mut fields = std::collections::BTreeSet::new();
let aggregate_field = query.aggregate_field.as_deref().map(str::to_ascii_lowercase); let aggregate_field = query
.aggregate_field
.as_deref()
.map(str::to_ascii_lowercase);
let aggregate_min_count = query.aggregate_min_count.unwrap_or(2); let aggregate_min_count = query.aggregate_min_count.unwrap_or(2);
let aggregate_limit = query.aggregate_limit.unwrap_or(50).clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS); let aggregate_limit = query
.aggregate_limit
.unwrap_or(50)
.clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS);
let mut aggregate_counts = std::collections::HashMap::<String, usize>::new(); let mut aggregate_counts = std::collections::HashMap::<String, usize>::new();
let mut aggregate_truncated = false; let mut aggregate_truncated = false;
for (index, source) in sources.iter().enumerate() { for (index, source) in sources.iter().enumerate() {
@@ -241,8 +284,13 @@ async fn file_logs_query(
let mut values = Vec::new(); let mut values = Vec::new();
file_log_aggregate_values(field_value, &mut values); file_log_aggregate_values(field_value, &mut values);
for aggregate_value in values { for aggregate_value in values {
if aggregate_counts.contains_key(&aggregate_value) || aggregate_counts.len() < FILE_LOG_MAX_AGGREGATE_VALUES { if aggregate_counts.contains_key(&aggregate_value)
*aggregate_counts.entry(aggregate_value).or_insert(0) += 1; || aggregate_counts.len()
< FILE_LOG_MAX_AGGREGATE_VALUES
{
*aggregate_counts
.entry(aggregate_value)
.or_insert(0) += 1;
} else { } else {
aggregate_truncated = true; aggregate_truncated = true;
} }
@@ -257,9 +305,13 @@ async fn file_logs_query(
Err(_) => truncated = true, Err(_) => truncated = true,
} }
} }
sort_file_log_records(&mut records, query.sort_by.as_deref().unwrap_or("time"), sort_file_log_records(
query.sort_descending.unwrap_or(true)); &mut records,
let mut aggregation = aggregate_counts.into_iter() query.sort_by.as_deref().unwrap_or("time"),
query.sort_descending.unwrap_or(true),
);
let mut aggregation = aggregate_counts
.into_iter()
.filter(|(_, count)| *count >= aggregate_min_count) .filter(|(_, count)| *count >= aggregate_min_count)
.map(|(value, count)| json!({"value": value, "count": count})) .map(|(value, count)| json!({"value": value, "count": count}))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -267,7 +319,9 @@ async fn file_logs_query(
let left_count = left.get("count").and_then(Value::as_u64).unwrap_or(0); let left_count = left.get("count").and_then(Value::as_u64).unwrap_or(0);
let right_count = right.get("count").and_then(Value::as_u64).unwrap_or(0); let right_count = right.get("count").and_then(Value::as_u64).unwrap_or(0);
right_count.cmp(&left_count).then_with(|| { right_count.cmp(&left_count).then_with(|| {
left.get("value").and_then(Value::as_str).unwrap_or("") left.get("value")
.and_then(Value::as_str)
.unwrap_or("")
.cmp(right.get("value").and_then(Value::as_str).unwrap_or("")) .cmp(right.get("value").and_then(Value::as_str).unwrap_or(""))
}) })
}); });
-4
View File
@@ -52,8 +52,6 @@ async fn update_section(
config.logging = part.logging; config.logging = part.logging;
config.error_log = part.error_log; config.error_log = part.error_log;
} else { } else {
// Backward compatibility for clients that still send only the
// historical access-logging object to /api/config/logging.
config.logging = config.logging =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?; serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?;
} }
@@ -334,8 +332,6 @@ async fn events(
"events.too_many_viewers".into(), "events.too_many_viewers".into(),
) )
})?; })?;
// One bounded snapshot, produced only when the consumer polls. No per-client queue.
// Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions.
let stream = futures_util::stream::unfold( let stream = futures_util::stream::unfold(
(state, secret, permit, false, interval), (state, secret, permit, false, interval),
|(state, secret, permit, delayed, interval)| async move { |(state, secret, permit, delayed, interval)| async move {
-2
View File
@@ -1,5 +1,3 @@
//! Local control plane. Never mounted on the public proxy listener.
include!("router.rs"); include!("router.rs");
include!("access.rs"); include!("access.rs");
include!("api_token_access.rs"); include!("api_token_access.rs");
+30 -10
View File
@@ -79,8 +79,16 @@ async fn cache_purge(
)); ));
} }
} }
let permit = state.cache_invalidation_slots.clone().try_acquire_owned() let permit = state
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?; .cache_invalidation_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"cache maintenance busy".into(),
)
})?;
let detail = format!( let detail = format!(
"scope={}; in-flight fills invalidated", "scope={}; in-flight fills invalidated",
scope.vhost.as_deref().unwrap_or("all") scope.vhost.as_deref().unwrap_or("all")
@@ -98,7 +106,9 @@ async fn cache_purge(
Some(id) => cache.purge_scope(id), Some(id) => cache.purge_scope(id),
None => cache.purge(None), None => cache.purge(None),
} }
}).await.map_err(auth::internal)?; })
.await
.map_err(auth::internal)?;
Ok(Json( Ok(Json(
json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}), json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}),
)) ))
@@ -126,8 +136,16 @@ async fn cache_invalidate(
)); ));
} }
} }
let permit = state.cache_invalidation_slots.clone().try_acquire_owned() let permit = state
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?; .cache_invalidation_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"cache maintenance busy".into(),
)
})?;
let detail = format!( let detail = format!(
"operation={:?}; selector={:?}; vhost={}; host={}; value={:?}", "operation={:?}; selector={:?}; vhost={}; host={}; value={:?}",
request.operation, request.operation,
@@ -145,7 +163,9 @@ async fn cache_invalidate(
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
let _permit = permit; let _permit = permit;
cache.invalidate(request) cache.invalidate(request)
}).await.map_err(auth::internal)? })
.await
.map_err(auth::internal)?
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?; .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
Ok(Json(json!({ Ok(Json(json!({
"result": result, "result": result,
@@ -163,8 +183,6 @@ async fn cache_ban_delete(
Extension(session): Extension<Session>, Extension(session): Extension<Session>,
Path(id): Path<u64>, Path(id): Path<u64>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
// This is a destructive operation: fail closed if the audit store is
// unavailable, rather than removing a ban and only then trying to log it.
if !state.cache.bans().iter().any(|rule| rule.id == id) { if !state.cache.bans().iter().any(|rule| rule.id == id) {
return Err(ApiError( return Err(ApiError(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -181,7 +199,10 @@ async fn cache_ban_delete(
.await .await
.map_err(auth::internal)?; .map_err(auth::internal)?;
if !state.cache.remove_ban(id) { if !state.cache.remove_ban(id) {
return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into())); return Err(ApiError(
StatusCode::NOT_FOUND,
"unknown cache BAN rule".into(),
));
} }
Ok(Json(json!({"removed": id, "bans": state.cache.bans()}))) Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
} }
@@ -322,7 +343,6 @@ async fn certificate_upload(
.audit(&session.username, "certificate.upload", &certificate.id) .audit(&session.username, "certificate.upload", &certificate.id)
.await .await
.map_err(auth::internal)?; .map_err(auth::internal)?;
// Never serialize the PEM request or any key material, including on success.
Ok(Json(json!({"certificate":certificate, "staged":true}))) Ok(Json(json!({"certificate":certificate, "staged":true})))
} }
+75 -16
View File
@@ -1,10 +1,14 @@
// Fixed private admin assets, shared by all admins. The existing route/access
// allowlist decides which paths are reachable. Do not expand routes dynamically.
use super::panel_content::PanelContent; use super::panel_content::PanelContent;
use crate::runtime::App; use crate::runtime::App;
use axum::{extract::{Request, State}, response::{IntoResponse, Response}}; use axum::{
extract::{Request, State},
response::{IntoResponse, Response},
};
use http::StatusCode; use http::StatusCode;
use std::{collections::HashMap, sync::{Arc, LazyLock, OnceLock}}; use std::{
collections::HashMap,
sync::{Arc, LazyLock, OnceLock},
};
struct Asset { struct Asset {
mime: &'static str, mime: &'static str,
@@ -20,21 +24,57 @@ const SVG: &str = "image/svg+xml";
static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| { static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
let entries: [(&'static str, &'static str, &'static str); 22] = [ let entries: [(&'static str, &'static str, &'static str); 22] = [
("/assets/login.js", JS, include_str!("web/login.js")), ("/assets/login.js", JS, include_str!("web/login.js")),
("/assets/login-langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/login-langs.json"))), (
("/assets/langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/langs.json"))), "/assets/login-langs.json",
JSON,
include_str!(concat!(env!("OUT_DIR"), "/login-langs.json")),
),
(
"/assets/langs.json",
JSON,
include_str!(concat!(env!("OUT_DIR"), "/langs.json")),
),
("/assets/app.js", JS, super::admin_app_javascript()), ("/assets/app.js", JS, super::admin_app_javascript()),
("/assets/management.js", JS, include_str!("web/management.js")), (
"/assets/management.js",
JS,
include_str!("web/management.js"),
),
("/assets/drafts.js", JS, include_str!("web/drafts.js")), ("/assets/drafts.js", JS, include_str!("web/drafts.js")),
("/assets/integrations.js", JS, include_str!("web/integrations.js")), (
("/assets/traffic-panel.js", JS, include_str!("web/traffic-panel.js")), "/assets/integrations.js",
("/assets/cache-panel.js", JS, include_str!("web/cache-panel.js")), JS,
("/assets/transform-panel.js", JS, include_str!("web/transform-panel.js")), include_str!("web/integrations.js"),
),
(
"/assets/traffic-panel.js",
JS,
include_str!("web/traffic-panel.js"),
),
(
"/assets/cache-panel.js",
JS,
include_str!("web/cache-panel.js"),
),
(
"/assets/transform-panel.js",
JS,
include_str!("web/transform-panel.js"),
),
("/assets/file-logs.js", JS, include_str!("web/file-logs.js")), ("/assets/file-logs.js", JS, include_str!("web/file-logs.js")),
("/assets/settings.js", JS, include_str!("web/settings.js")), ("/assets/settings.js", JS, include_str!("web/settings.js")),
("/assets/acme.js", JS, include_str!("web/acme.js")), ("/assets/acme.js", JS, include_str!("web/acme.js")),
("/assets/vhosts.js", JS, super::admin_vhosts_javascript()), ("/assets/vhosts.js", JS, super::admin_vhosts_javascript()),
("/assets/web-server-profiles.js", JS, include_str!("web/web-server-profiles.js")), (
("/assets/web-server.js", JS, include_str!("web/web-server.js")), "/assets/web-server-profiles.js",
JS,
include_str!("web/web-server-profiles.js"),
),
(
"/assets/web-server.js",
JS,
include_str!("web/web-server.js"),
),
("/assets/docker.js", JS, include_str!("web/docker.js")), ("/assets/docker.js", JS, include_str!("web/docker.js")),
("/assets/streams.js", JS, include_str!("web/streams.js")), ("/assets/streams.js", JS, include_str!("web/streams.js")),
("/assets/redirects.js", JS, include_str!("web/redirects.js")), ("/assets/redirects.js", JS, include_str!("web/redirects.js")),
@@ -42,7 +82,18 @@ static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
("/assets/style.css", CSS, include_str!("web/style.css")), ("/assets/style.css", CSS, include_str!("web/style.css")),
("/favicon.svg", SVG, include_str!("web/favicon.svg")), ("/favicon.svg", SVG, include_str!("web/favicon.svg")),
]; ];
entries.into_iter().map(|(path, mime, raw)| (path, Asset { mime, raw, content: OnceLock::new() })) entries
.into_iter()
.map(|(path, mime, raw)| {
(
path,
Asset {
mime,
raw,
content: OnceLock::new(),
},
)
})
.collect() .collect()
}); });
@@ -50,7 +101,13 @@ pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Re
let resolved = super::fingerprinted_path(request.uri().path(), "/assets/"); let resolved = super::fingerprinted_path(request.uri().path(), "/assets/");
let path = match resolved.as_deref() { let path = match resolved.as_deref() {
Some(path) => path, Some(path) => path,
None if matches!(request.uri().path(), "/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg") => request.uri().path(), None if matches!(
request.uri().path(),
"/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg"
) =>
{
request.uri().path()
}
None => return StatusCode::NOT_FOUND.into_response(), None => return StatusCode::NOT_FOUND.into_response(),
}; };
let Some(asset) = ASSETS.get(path) else { let Some(asset) = ASSETS.get(path) else {
@@ -58,6 +115,8 @@ pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Re
}; };
let snapshot = state.config.load(); let snapshot = state.config.load();
let codecs = &snapshot.envelope.config.administration.panel_compression; let codecs = &snapshot.envelope.config.administration.panel_compression;
asset.content.get_or_init(|| PanelContent::new(asset.raw)) asset
.content
.get_or_init(|| PanelContent::new(asset.raw))
.response(request.headers(), codecs, asset.mime) .response(request.headers(), codecs, asset.mime)
} }
+33 -11
View File
@@ -1,5 +1,3 @@
// Shared immutable precompressed responses for private panel HTML and assets.
// Each embedded document is encoded once on first request, never per visitor.
use crate::settings::PanelCompression; use crate::settings::PanelCompression;
use axum::{body::Body, response::Response}; use axum::{body::Body, response::Response};
use bytes::Bytes; use bytes::Bytes;
@@ -22,16 +20,31 @@ impl PanelContent {
let mut br = Vec::new(); let mut br = Vec::new();
{ {
let mut writer = brotli::CompressorWriter::new(&mut br, 4096, 5, 22); let mut writer = brotli::CompressorWriter::new(&mut br, 4096, 5, 22);
writer.write_all(raw.as_bytes()).expect("compress panel Brotli"); writer
.write_all(raw.as_bytes())
.expect("compress panel Brotli");
} }
let zstd = zstd::stream::encode_all(raw.as_bytes(), 3).expect("compress panel zstd"); let zstd = zstd::stream::encode_all(raw.as_bytes(), 3).expect("compress panel zstd");
Self { plain: Bytes::from_static(raw.as_bytes()), gzip, brotli: Bytes::from(br), zstd: Bytes::from(zstd) } Self {
plain: Bytes::from_static(raw.as_bytes()),
gzip,
brotli: Bytes::from(br),
zstd: Bytes::from(zstd),
}
} }
pub(super) fn response(&self, headers: &HeaderMap, codecs: &PanelCompression, mime: &'static str) -> Response { pub(super) fn response(
&self,
headers: &HeaderMap,
codecs: &PanelCompression,
mime: &'static str,
) -> Response {
let settings = crate::compression::Settings { let settings = crate::compression::Settings {
gzip: codecs.gzip, br: codecs.br, zstd: codecs.zstd, gzip: codecs.gzip,
deflate: false, min_bytes: 1024, br: codecs.br,
zstd: codecs.zstd,
deflate: false,
min_bytes: 1024,
}; };
let encoding = if crate::compression::no_transform(headers) { let encoding = if crate::compression::no_transform(headers) {
None None
@@ -45,12 +58,21 @@ impl PanelContent {
_ => (self.plain.clone(), None), _ => (self.plain.clone(), None),
}; };
let mut response = Response::new(Body::from(body.clone())); let mut response = Response::new(Body::from(body.clone()));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(mime)); response
response.headers_mut().insert(header::CONTENT_LENGTH, HeaderValue::from_str(&body.len().to_string()).expect("valid length")); .headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&body.len().to_string()).expect("valid length"),
);
if let Some(name) = name { if let Some(name) = name {
response.headers_mut().insert(header::CONTENT_ENCODING, HeaderValue::from_static(name)); response
.headers_mut()
.insert(header::CONTENT_ENCODING, HeaderValue::from_static(name));
} }
response.headers_mut().insert(header::VARY, HeaderValue::from_static("Accept-Encoding")); response
.headers_mut()
.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
response response
} }
} }
-7
View File
@@ -27,9 +27,6 @@ fn default_admin_table_density() -> String {
fn default_admin_dashboard_density() -> String { fn default_admin_dashboard_density() -> String {
DASHBOARD_DEFAULT_PRESET.dashboard_density.clone() DASHBOARD_DEFAULT_PRESET.dashboard_density.clone()
} }
// Preserve the user's saved order/visibility, drop identifiers unknown to this
// binary, and append new registry widgets according to their opt-in policy.
// Stored preferences from older/newer releases are normalized on read.
fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) { fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
prefs.dashboard_widgets.retain(|id| { prefs.dashboard_widgets.retain(|id| {
@@ -131,8 +128,6 @@ struct AdminPreferences {
dashboard_widgets: Vec<String>, dashboard_widgets: Vec<String>,
#[serde(default = "default_hidden_dashboard_widgets")] #[serde(default = "default_hidden_dashboard_widgets")]
dashboard_hidden_widgets: Vec<String>, dashboard_hidden_widgets: Vec<String>,
// Null/absent: retain legacy visual grouping for existing installations.
// A map (including {}) means that order is fully user-controlled.
#[serde(default)] #[serde(default)]
dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>, dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>,
#[serde(default = "default_file_log_columns")] #[serde(default = "default_file_log_columns")]
@@ -473,8 +468,6 @@ async fn update_preferences(
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>, input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, ApiError> { ) -> Result<Json<AdminPreferences>, ApiError> {
let Json(mut preferences) = input.map_err(public::invalid_json)?; let Json(mut preferences) = input.map_err(public::invalid_json)?;
// Do not rewrite the caller's saved order on PUT; drop only stale IDs.
// Legacy partial lists are expanded on the next GET, as before.
preferences preferences
.dashboard_widgets .dashboard_widgets
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id)); .retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
-7
View File
@@ -75,8 +75,6 @@ const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
"maxConnections", "maxConnections",
"idleTimeout", "idleTimeout",
]; ];
// The same JSON registry is embedded into the authenticated admin JS bundle.
// No independent allowlist is maintained by the browser or backend.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct DashboardWidgetDefinition { struct DashboardWidgetDefinition {
id: String, id: String,
@@ -89,9 +87,6 @@ static DASHBOARD_WIDGETS: std::sync::LazyLock<Vec<DashboardWidgetDefinition>> =
.expect("invalid bundled Overview Cards registry") .expect("invalid bundled Overview Cards registry")
}); });
// A single operator-curated factory preset feeds both the Rust API defaults
// and the authenticated dashboard. New registry widgets remain opt-in unless
// explicitly added to the preset; saved user preferences are never rewritten.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct DashboardDefaultPreset { struct DashboardDefaultPreset {
@@ -147,8 +142,6 @@ static DASHBOARD_DEFAULT_PRESET: std::sync::LazyLock<DashboardDefaultPreset> =
preset preset
}); });
// None denotes the legacy grouped layout. Some({}) switches to freely
// ordered 12-column CSS Grid while retaining every historical card width.
fn valid_dashboard_span(display_type: &str, span: u8) -> bool { fn valid_dashboard_span(display_type: &str, span: u8) -> bool {
match display_type { match display_type {
"table" => matches!(span, 6 | 12), "table" => matches!(span, 6 | 12),
-2
View File
@@ -123,7 +123,6 @@ fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() {
.dashboard_hidden_widgets .dashboard_hidden_widgets
.contains(&"extra_process_cpu".into())); .contains(&"extra_process_cpu".into()));
assert!(prefs.dashboard_widgets.contains(&"backend_table".into())); assert!(prefs.dashboard_widgets.contains(&"backend_table".into()));
// 0.33.0 WWW cards stay opt-in when upgrading an old saved custom layout.
for id in [ for id in [
"www_status", "www_status",
"www_kind", "www_kind",
@@ -186,7 +185,6 @@ fn saved_dashboard_order_and_visibility_survive_new_factory_preset() {
assert!(prefs assert!(prefs
.dashboard_hidden_widgets .dashboard_hidden_widgets
.contains(&"backend_traffic".to_owned())); .contains(&"backend_traffic".to_owned()));
// WWW was absent from the saved document and is introduced only as opt-in.
assert!(prefs assert!(prefs
.dashboard_hidden_widgets .dashboard_hidden_widgets
.contains(&"www_status".to_owned())); .contains(&"www_status".to_owned()));
@@ -1,4 +1,3 @@
//! Deliberately small anonymous surface. Operational templates/assets are private.
use crate::{ use crate::{
auth::Session, auth::Session,
runtime::{ApiError, App}, runtime::{ApiError, App},
@@ -18,22 +17,19 @@ static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response { fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
let content = ADMIN_INDEX.get_or_init(|| { let content = ADMIN_INDEX.get_or_init(|| {
// Compile-time embedded HTML fragments are assembled once at startup. let (metrics, web_metrics) = include_str!("../web/partials/overview-metrics.html")
// No additional browser requests, changed DOM nodes or script ordering.
let (metrics, web_metrics) = include_str!("web/partials/overview-metrics.html")
.split_once("<!-- RP_METRICS_SPLIT -->") .split_once("<!-- RP_METRICS_SPLIT -->")
.expect("invalid Overview metrics partial"); .expect("invalid Overview metrics partial");
let (charts, web_charts) = include_str!("web/partials/overview-charts.html") let (charts, web_charts) = include_str!("../web/partials/overview-charts.html")
.split_once("<!-- RP_CHARTS_SPLIT -->") .split_once("<!-- RP_CHARTS_SPLIT -->")
.expect("invalid Overview charts partial"); .expect("invalid Overview charts partial");
let html = include_str!("web/index.html") let html = include_str!("../web/index.html")
.replace("<!-- RP_METRICS_MAIN -->", metrics) .replace("<!-- RP_METRICS_MAIN -->", metrics)
.replace("<!-- RP_CHARTS_MAIN -->", charts) .replace("<!-- RP_CHARTS_MAIN -->", charts)
.replace("<!-- RP_METRICS_WEB -->", web_metrics) .replace("<!-- RP_METRICS_WEB -->", web_metrics)
.replace("<!-- RP_CHARTS_WEB -->", web_charts) .replace("<!-- RP_CHARTS_WEB -->", web_charts)
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()) .replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION")); .replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"));
// Exactly one static panel document shared between all administrator accounts.
PanelContent::new(Box::leak(html.into_boxed_str())) PanelContent::new(Box::leak(html.into_boxed_str()))
}); });
content.response(headers, compression, "text/html; charset=utf-8") content.response(headers, compression, "text/html; charset=utf-8")
@@ -45,7 +41,7 @@ pub(super) async fn index(
headers: HeaderMap, headers: HeaderMap,
) -> Response { ) -> Response {
if session.is_none() { if session.is_none() {
let html = include_str!("web/login.html") let html = include_str!("../web/login.html")
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()); .replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id());
return Html(html).into_response(); return Html(html).into_response();
} }
@@ -57,7 +53,6 @@ pub(super) async fn index(
} }
pub(super) fn invalid_json(error: JsonRejection) -> ApiError { pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
// Keep useful protocol statuses without returning serde's input excerpts.
let status = match error.status() { let status = match error.status() {
StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE, StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE,
StatusCode::UNSUPPORTED_MEDIA_TYPE => StatusCode::UNSUPPORTED_MEDIA_TYPE, StatusCode::UNSUPPORTED_MEDIA_TYPE => StatusCode::UNSUPPORTED_MEDIA_TYPE,
+4 -3
View File
@@ -1,4 +1,3 @@
// Stored inside the existing per-account administration preferences document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
struct RefreshPreferences { struct RefreshPreferences {
@@ -36,6 +35,8 @@ fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiE
} }
fn default_live_log_columns() -> Vec<String> { fn default_live_log_columns() -> Vec<String> {
LIVE_LOG_COLUMNS.iter().map(|column| (*column).to_owned()).collect() LIVE_LOG_COLUMNS
.iter()
.map(|column| (*column).to_owned())
.collect()
} }
-10
View File
@@ -3,8 +3,6 @@ mod panel_assets;
mod panel_content; mod panel_content;
mod public; mod public;
// Embedded asset URLs change with every build, independent of Cargo package version.
// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404.
fn asset_build_id() -> &'static str { fn asset_build_id() -> &'static str {
include_str!(concat!(env!("OUT_DIR"), "/asset-build-id.txt")) include_str!(concat!(env!("OUT_DIR"), "/asset-build-id.txt"))
} }
@@ -57,8 +55,6 @@ fn admin_app_javascript() -> &'static str {
"const ADMIN_DASHBOARD_DEFAULTS = Object.freeze(", "const ADMIN_DASHBOARD_DEFAULTS = Object.freeze(",
include_str!("web/dashboard-defaults.json"), include_str!("web/dashboard-defaults.json"),
");", ");",
// The samples controller is needed by top-level listeners in core.js.
// Define it first; DOM initialization and translations happen later.
include_str!("web/app/overview-samples.js"), include_str!("web/app/overview-samples.js"),
include_str!("web/app/core.js"), include_str!("web/app/core.js"),
include_str!("web/app/session-resume.js"), include_str!("web/app/session-resume.js"),
@@ -286,8 +282,6 @@ pub fn router(state: Arc<App>) -> Router {
.layer(middleware::from_fn_with_state(state.clone(), observe_admin)) .layer(middleware::from_fn_with_state(state.clone(), observe_admin))
.with_state(state) .with_state(state)
} }
// Separate from proxy exchanges: measure only time to admin response headers,
// including access denials. SSE stream lifetime is intentionally not included.
async fn observe_admin(State(state): State<Arc<App>>, request: Request, next: Next) -> Response { async fn observe_admin(State(state): State<Arc<App>>, request: Request, next: Next) -> Response {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed); state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed);
@@ -325,14 +319,10 @@ fn secure(mut response: Response) -> Response {
"permissions-policy", "permissions-policy",
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
); );
// Do not let downstream middleware add implementation/version fingerprints.
h.remove("server"); h.remove("server");
h.remove("x-powered-by"); h.remove("x-powered-by");
response response
} }
// This is a method-and-path allowlist, NOT a path prefix. Everything else,
// including unknown routes, schema, metrics, assets and method mismatches,
// requires an active administrator session before extractors read a body.
fn panel_document_path(path: &str) -> bool { fn panel_document_path(path: &str) -> bool {
matches!( matches!(
path, path,
+87 -42
View File
@@ -1,9 +1,5 @@
// Only log browser origin metadata: never cookies, CSRF tokens, Authorization,
// query strings, or untrusted forwarding data. Malformed origins are redacted.
fn diagnostic_admin_authority(value: &str) -> &str { fn diagnostic_admin_authority(value: &str) -> &str {
if value.len() <= 255 && !value.contains('@') if value.len() <= 255 && !value.contains('@') && value.parse::<http::uri::Authority>().is_ok() {
&& value.parse::<http::uri::Authority>().is_ok()
{
value value
} else { } else {
"(invalid)" "(invalid)"
@@ -47,7 +43,11 @@ fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted:
return "(invalid)"; return "(invalid)";
}; };
if name == "x-forwarded-proto" { if name == "x-forwarded-proto" {
if matches!(value, "http" | "https") { value } else { "(invalid)" } if matches!(value, "http" | "https") {
value
} else {
"(invalid)"
}
} else { } else {
diagnostic_admin_authority(value) diagnostic_admin_authority(value)
} }
@@ -63,14 +63,19 @@ fn invalid_admin_origin(
) -> Response { ) -> Response {
let headers = request.headers(); let headers = request.headers();
let path = request.uri().path(); let path = request.uri().path();
let path = if path.len() <= 160 { path } else { "(long_path)" }; let path = if path.len() <= 160 {
path
} else {
"(long_path)"
};
let browser_host = single_header(headers, HOST.as_str()) let browser_host = single_header(headers, HOST.as_str())
.map(diagnostic_admin_authority) .map(diagnostic_admin_authority)
.unwrap_or("(absent_or_duplicate)"); .unwrap_or("(absent_or_duplicate)");
let browser_fetch_site = match single_header(headers, "sec-fetch-site") { let browser_fetch_site = match single_header(headers, "sec-fetch-site") {
None => "(absent)", None => "(absent)",
Some("same-origin" | "same-site" | "cross-site" | "none") => Some("same-origin" | "same-site" | "cross-site" | "none") => {
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)"), single_header(headers, "sec-fetch-site").unwrap_or("(invalid)")
}
_ => "(invalid)", _ => "(invalid)",
}; };
tracing::warn!( tracing::warn!(
@@ -88,12 +93,15 @@ fn invalid_admin_origin(
forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto), forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto),
"administration origin rejected" "administration origin rejected"
); );
// When a browser navigates to the dashboard document, JSON here would let wants_html = request
// replace the entire page with a raw API error. Keep the 403 (no shell or .headers()
// protected data) but present a standalone recovery message instead. .get(http::header::ACCEPT)
let wants_html = request.headers().get(http::header::ACCEPT)
.and_then(|header| header.to_str().ok()) .and_then(|header| header.to_str().ok())
.is_some_and(|value| value.split(',').any(|entry| entry.trim_start().starts_with("text/html"))); .is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim_start().starts_with("text/html"))
});
if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html { if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html {
return secure(( return secure((
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
@@ -117,10 +125,8 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let headers = request.headers(); let headers = request.headers();
let trusted_host = trusted_admin_proxy(&state, peer); let trusted_host = trusted_admin_proxy(&state, peer);
let trusted_proto = trusted_admin_forwarded_proto(&state, peer); let trusted_proto = trusted_admin_forwarded_proto(&state, peer);
// Pinned browser origin is panel-only. Certificate automation using API let automation_credentials =
// credentials keeps the original forwarding/TLS authorization semantics. headers.contains_key(CERTIFICATE_API_KEY_HEADER) || headers.contains_key(AUTHORIZATION);
let automation_credentials = headers.contains_key(CERTIFICATE_API_KEY_HEADER)
|| headers.contains_key(AUTHORIZATION);
let pinned_authority_result = if automation_credentials { let pinned_authority_result = if automation_credentials {
Ok(None) Ok(None)
} else { } else {
@@ -128,22 +134,39 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
}; };
let pinned_authority = match pinned_authority_result { let pinned_authority = match pinned_authority_result {
Ok(value) => value, Ok(value) => value,
Err(()) => return invalid_admin_origin(&request, "pinned_origin_mismatch", Some("https"), Err(()) => {
state.active_administration.public_origin.as_deref().and_then(|value| value.strip_prefix("https://")), return invalid_admin_origin(
trusted_host, trusted_proto), &request,
"pinned_origin_mismatch",
Some("https"),
state
.active_administration
.public_origin
.as_deref()
.and_then(|value| value.strip_prefix("https://")),
trusted_host,
trusted_proto,
)
}
}; };
let (scheme, external_authority) = if let Some(authority) = pinned_authority { let (scheme, external_authority) = if let Some(authority) = pinned_authority {
// HTTPS is a trusted, administrator-pinned identity here. Do not select
// any list element of an ambiguous forwarded-proto chain.
("https", authority) ("https", authority)
} else { } else {
let scheme = match external_admin_scheme(&state, headers, peer, transport) { let scheme = match external_admin_scheme(&state, headers, peer, transport) {
Ok(value) => value, Ok(value) => value,
Err(()) => { Err(()) => {
return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto); return invalid_admin_origin(
&request,
"external_scheme",
None,
None,
trusted_host,
trusted_proto,
);
} }
}; };
let authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") { let authority =
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
Ok(value) => value, Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
}; };
@@ -156,13 +179,27 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
}) })
{ {
return invalid_admin_origin(&request, "origin_mismatch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto); return invalid_admin_origin(
&request,
"origin_mismatch",
Some(scheme),
Some(&external_authority),
trusted_host,
trusted_proto,
);
} }
if headers if headers
.get("sec-fetch-site") .get("sec-fetch-site")
.is_some_and(|h| h == "cross-site") .is_some_and(|h| h == "cross-site")
{ {
return invalid_admin_origin(&request, "cross_site_fetch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto); return invalid_admin_origin(
&request,
"cross_site_fetch",
Some(scheme),
Some(&external_authority),
trusted_host,
trusted_proto,
);
} }
let path = request.uri().path().to_owned(); let path = request.uri().path().to_owned();
let public = anonymous_allowed(request.method(), &path); let public = anonymous_allowed(request.method(), &path);
@@ -174,7 +211,10 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let mut certificate_api_access = None; let mut certificate_api_access = None;
let certificate_api_session = if headers.contains_key(CERTIFICATE_API_KEY_HEADER) { let certificate_api_session = if headers.contains_key(CERTIFICATE_API_KEY_HEADER) {
let Some(operation) = certificate_api_operation.as_ref() else { let Some(operation) = certificate_api_operation.as_ref() else {
return secure(reject(StatusCode::FORBIDDEN, "certificate_api.invalid_route")); return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.invalid_route",
));
}; };
match certificate_api_credentials(headers) { match certificate_api_credentials(headers) {
Ok(None) => None, Ok(None) => None,
@@ -243,8 +283,8 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
} else { } else {
None None
}; };
let general_api_session = if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) { let general_api_session =
// No cookies are accepted as a fallback when a malformed Bearer header is supplied. if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) {
let bearer = single_header(headers, AUTHORIZATION.as_str()) let bearer = single_header(headers, AUTHORIZATION.as_str())
.and_then(|header| header.strip_prefix("Bearer ")); .and_then(|header| header.strip_prefix("Bearer "));
let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else { let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else {
@@ -260,18 +300,30 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let peer_ip = crate::vhost::canonical_ip(peer.0.ip()); let peer_ip = crate::vhost::canonical_ip(peer.0.ip());
let grant = match state.store.verify_api_token(&hash, peer_ip).await { let grant = match state.store.verify_api_token(&hash, peer_ip).await {
Ok(Some(grant)) => grant, Ok(Some(grant)) => grant,
Ok(None) => return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token_or_ip")), Ok(None) => {
return secure(reject(
StatusCode::UNAUTHORIZED,
"api_token.invalid_token_or_ip",
))
}
Err(error) => return secure(auth::internal(error).into_response()), Err(error) => return secure(auth::internal(error).into_response()),
}; };
if !general_api_token_allowed(request.method(), &path, &grant.permission) { if !general_api_token_allowed(request.method(), &path, &grant.permission) {
return secure(reject(StatusCode::FORBIDDEN, "api_token.insufficient_permission")); return secure(reject(
StatusCode::FORBIDDEN,
"api_token.insufficient_permission",
));
} }
Some(Session { Some(Session {
token_hash: hash, csrf: String::new(), token_hash: hash,
csrf: String::new(),
username: format!("api-token:{}", grant.id), username: format!("api-token:{}", grant.id),
expires_at: auth::now().saturating_add(60), generation: 0, expires_at: auth::now().saturating_add(60),
generation: 0,
}) })
} else { None }; } else {
None
};
let external_credential = certificate_api_session.is_some() || general_api_session.is_some(); let external_credential = certificate_api_session.is_some() || general_api_session.is_some();
let session_seconds = state let session_seconds = state
.config .config
@@ -280,9 +332,6 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
.config .config
.administration .administration
.session_seconds(); .session_seconds();
// Panel document routes optionally authenticate so a direct refresh can choose
// the application shell when the browser still has a valid cookie. Public static
// login resources and capability discovery never load operational state.
let session = if certificate_api_session.is_some() { let session = if certificate_api_session.is_some() {
certificate_api_session certificate_api_session
} else if general_api_session.is_some() { } else if general_api_session.is_some() {
@@ -352,16 +401,12 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
} }
} }
if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() { if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() {
// Swagger UI sets inline layout styles. This exception is limited to the
// authenticated documentation document; scripts remain self-only/no-eval.
response.headers_mut().insert("content-security-policy", HeaderValue::from_static( response.headers_mut().insert("content-security-policy", HeaderValue::from_static(
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")); "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"));
} }
response response
} }
async fn auth_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> { async fn auth_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
// The one bit needed to render first-account setup; no version, paths, names,
// listeners, provider state, metrics or configuration before authentication.
Ok(Json( Ok(Json(
json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}), json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}),
)) ))
-7
View File
@@ -228,8 +228,6 @@ fn status_payload(state: &App) -> Value {
}); });
payload payload
} }
// SSE and emergency polling use only volatile telemetry. Never serialize the
// whole configuration, OS diagnostics or certificate metadata on each tick.
include!("status_live.rs"); include!("status_live.rs");
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> { async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
let snapshot = state.config.load_full(); let snapshot = state.config.load_full();
@@ -275,9 +273,6 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
let config = &snapshot.envelope.config; let config = &snapshot.envelope.config;
let configured_certificates = config.tls.certificates.clone(); let configured_certificates = config.tls.certificates.clone();
let certificate_directory = state.policy.cert_dir.clone(); let certificate_directory = state.policy.cert_dir.clone();
// Inspect only public certificate files on a blocking worker; never read keys.
// Live PEM coverage wins so in-place renewals are reflected immediately; stored
// metadata is only a fallback when the public certificate file cannot be read.
let inspected_metadata = tokio::task::spawn_blocking(move || { let inspected_metadata = tokio::task::spawn_blocking(move || {
configured_certificates configured_certificates
.into_iter() .into_iter()
@@ -313,8 +308,6 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}), json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}),
) )
} }
// Storage metadata belongs to GET /api/config, not to the editable `config`
// document or the revisioned envelopes returned by update/restore endpoints.
#[derive(Serialize)] #[derive(Serialize)]
struct ConfigurationRead { struct ConfigurationRead {
#[serde(flatten)] #[serde(flatten)]
-5
View File
@@ -1,6 +1,3 @@
// The OS collector already samples in the background. Expose just the
// chart inputs in compact SSE/fallback snapshots; the full, slower System
// information document remains behind /api/system-info.
fn live_chart_system_payload(state: &App) -> Value { fn live_chart_system_payload(state: &App) -> Value {
let view = state.diagnostics.view(); let view = state.diagnostics.view();
json!({ json!({
@@ -14,8 +11,6 @@ fn live_chart_system_payload(state: &App) -> Value {
}) })
} }
// Snapshot already holds bounded, configured Web Service IDs. Do not expose
// paths, FastCGI addresses or PHP INI parameters in a status poll.
fn web_server_metrics_payload(snapshot: &crate::config::Snapshot) -> Value { fn web_server_metrics_payload(snapshot: &crate::config::Snapshot) -> Value {
let mut services = serde_json::Map::new(); let mut services = serde_json::Map::new();
for service in &snapshot.envelope.config.web_services { for service in &snapshot.envelope.config.web_services {
+8 -9
View File
@@ -1,6 +1,3 @@
// Reuse sysinfo's native OS APIs on Linux, BSD, macOS and Windows. The
// diagnostics endpoint must never spawn system commands or parse platform files.
// Host identification is cached because it changes only after a reboot.
fn valid_host_text(value: String) -> Option<String> { fn valid_host_text(value: String) -> Option<String> {
let value = value.trim(); let value = value.trim();
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control)) (!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
@@ -9,7 +6,8 @@ fn valid_host_text(value: String) -> Option<String> {
fn os_release() -> Option<&'static str> { fn os_release() -> Option<&'static str> {
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new(); static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
VALUE.get_or_init(|| { VALUE
.get_or_init(|| {
sysinfo::System::long_os_version() sysinfo::System::long_os_version()
.or_else(|| { .or_else(|| {
let name = sysinfo::System::name()?; let name = sysinfo::System::name()?;
@@ -19,20 +17,21 @@ fn os_release() -> Option<&'static str> {
}) })
}) })
.and_then(valid_host_text) .and_then(valid_host_text)
}).as_deref() })
.as_deref()
} }
fn kernel_release() -> Option<&'static str> { fn kernel_release() -> Option<&'static str> {
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new(); static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
VALUE.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text)) VALUE
.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
.as_deref() .as_deref()
} }
// Cached diagnostics and ACME metadata are requested separately from high-rate SSE.
// Preserve the legacy full /api/status shape for existing API clients.
fn system_info_payload(state: &App) -> Value { fn system_info_payload(state: &App) -> Value {
let cache_view = state.cache.view(); let cache_view = state.cache.view();
let transform_resources = crate::transform::resource_usage(&state.config.load().envelope.config.transform); let transform_resources =
crate::transform::resource_usage(&state.config.load().envelope.config.transform);
let mut payload = json!({}); let mut payload = json!({});
let system = state.diagnostics.view(); let system = state.diagnostics.view();
let cache_ram_bytes = let cache_ram_bytes =
+110 -63
View File
@@ -315,7 +315,6 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
} }
// Origin on an ordinary same-origin GET may be absent, or may match.
for origin in [None, Some("http://127.0.0.1:9090")] { for origin in [None, Some("http://127.0.0.1:9090")] {
let mut request = Request::builder() let mut request = Request::builder()
.uri("/api/auth/session") .uri("/api/auth/session")
@@ -902,7 +901,6 @@ mod tests {
.await .await
.unwrap(); .unwrap();
if path == "/logs" { if path == "/logs" {
// The operational log route is private even on browser refresh.
assert_eq!(public.status(), StatusCode::UNAUTHORIZED); assert_eq!(public.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(public).await, json!({"error":"auth.required"})); assert_eq!(json_body(public).await, json!({"error":"auth.required"}));
} else { } else {
@@ -1243,7 +1241,6 @@ mod tests {
.body(Body::from("{}")) .body(Body::from("{}"))
.unwrap() .unwrap()
}; };
// Certificate API credentials cannot trigger an all-certificate reload.
let response = router let response = router
.clone() .clone()
.oneshot(external( .oneshot(external(
@@ -1254,8 +1251,6 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
// The allowlisted ID passes the auth gate and reaches the handler; this
// fixture intentionally has no such configured certificate, so it is 404.
let response = router let response = router
.clone() .clone()
.oneshot(external( .oneshot(external(
@@ -1266,7 +1261,6 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
// A reload-only token cannot use the deploy endpoint at all.
let response = router let response = router
.clone() .clone()
.oneshot( .oneshot(
@@ -1281,7 +1275,6 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
// A different ID is rejected by the credential grant before the handler.
let response = router let response = router
.clone() .clone()
.oneshot(external( .oneshot(external(
@@ -1320,8 +1313,6 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
// This fixture has no certificate directory. An out-of-scope credential
// must see only the grant error, never the storage availability error.
assert_eq!( assert_eq!(
json_body(response).await, json_body(response).await,
json!({"error":"certificate_api.certificate_forbidden"}) json!({"error":"certificate_api.certificate_forbidden"})
@@ -1482,7 +1473,6 @@ mod tests {
assert!(detail["credential"].get("token_hash").is_none()); assert!(detail["credential"].get("token_hash").is_none());
let created_at = detail["credential"]["created_at"].clone(); let created_at = detail["credential"]["created_at"].clone();
// A bearer-only automation client cannot read or modify management records.
let denied = router let denied = router
.clone() .clone()
.oneshot( .oneshot(
@@ -1526,7 +1516,6 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY);
// An edit changes only metadata and grants, not the current bearer secret.
let edited = router.clone().oneshot(admin( let edited = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod", Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}), json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}),
@@ -1895,8 +1884,6 @@ mod tests {
"backend_table_sort":"requests", "backend_table_sort":"requests",
"backend_table_descending":true "backend_table_descending":true
}); });
// Older clients may omit fields added since their preferences were
// saved. The API returns and stores the typed, defaulted document.
let normalized = serde_json::to_value( let normalized = serde_json::to_value(
serde_json::from_value::<AdminPreferences>(selected.clone()).unwrap(), serde_json::from_value::<AdminPreferences>(selected.clone()).unwrap(),
) )
@@ -1943,7 +1930,6 @@ mod tests {
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap()) serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
.unwrap(); .unwrap();
assert_eq!(saved, normalized); assert_eq!(saved, normalized);
// Preferences must also survive a subsequent authenticated read.
let persisted = router let persisted = router
.clone() .clone()
.oneshot( .oneshot(
@@ -1976,7 +1962,6 @@ mod tests {
} }
} }
assert_eq!(json_body(persisted).await, upgraded); assert_eq!(json_body(persisted).await, upgraded);
// Wrong allowlist value is rejected even if the rest is valid.
let mut invalid = selected.clone(); let mut invalid = selected.clone();
invalid["refresh"]["traffic_seconds"] = json!(4); invalid["refresh"]["traffic_seconds"] = json!(4);
let rejected = router let rejected = router
@@ -2120,8 +2105,6 @@ mod tests {
if valid_csrf { if valid_csrf {
req = req.header("x-csrf-token", &csrf); req = req.header("x-csrf-token", &csrf);
} }
// This fixture has no certificate directory: valid auth reaches the
// 409 precondition, never a child process, DNS update or CA request.
let response = router let response = router
.clone() .clone()
.oneshot(req.body(Body::from("{}")).unwrap()) .oneshot(req.body(Body::from("{}")).unwrap())
@@ -2898,7 +2881,6 @@ mod tests {
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec![Some(200), Some(404), Some(500), None] vec![Some(200), Some(404), Some(500), None]
); );
// Page two must reflect the sorted complete scan, not a local page sort.
assert_eq!(records[1]["status"], 404); assert_eq!(records[1]["status"], 404);
sort_file_log_records(&mut records, "status", true); sort_file_log_records(&mut records, "status", true);
assert_eq!( assert_eq!(
@@ -3099,8 +3081,6 @@ mod tests {
assert_eq!(live.status(), StatusCode::OK); assert_eq!(live.status(), StatusCode::OK);
let live = json_body(live).await; let live = json_body(live).await;
assert!(live.get("metrics").is_some()); assert!(live.get("metrics").is_some());
// Compact snapshots contain only already-collected data required for
// fast Overview charts, not the expensive System details document.
assert!(live["live_system"].get("sampled_at_ms").is_some()); assert!(live["live_system"].get("sampled_at_ms").is_some());
assert!(live["live_system"].get("process_cpu_percent").is_some()); assert!(live["live_system"].get("process_cpu_percent").is_some());
assert!(live["live_system"].get("process_rss_bytes").is_some()); assert!(live["live_system"].get("process_rss_bytes").is_some());
@@ -3170,7 +3150,7 @@ mod tests {
} }
value.overview_seconds = 3; value.overview_seconds = 3;
assert!(validate_refresh_preferences(&value).is_ok()); assert!(validate_refresh_preferences(&value).is_ok());
value.overview_seconds = 2; // previous installations stay valid value.overview_seconds = 2;
assert!(validate_refresh_preferences(&value).is_ok()); assert!(validate_refresh_preferences(&value).is_ok());
value.overview_seconds = 1; value.overview_seconds = 1;
for seconds in [1, 2, 4, 31] { for seconds in [1, 2, 4, 31] {
@@ -3256,8 +3236,6 @@ mod tests {
"CSRF {method} {path}" "CSRF {method} {path}"
); );
} }
// The valid session and CSRF continue to work for a benign mutation,
// proving the failed requests above did not revoke the cookie.
let response = router let response = router
.oneshot( .oneshot(
request("/api/auth/logout") request("/api/auth/logout")
@@ -3341,8 +3319,6 @@ mod tests {
config.administration.public_origin = Some("https://panel.example.test".into()); config.administration.public_origin = Some("https://panel.example.test".into());
let (router, store, _dir, secret, csrf) = fixture_with_config(config).await; let (router, store, _dir, secret, csrf) = fixture_with_config(config).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME); let cookie = format!("{}={secret}", auth::COOKIE_NAME);
// Without an explicit origin pin, 0.31.3 rejected this session GET as
// external_scheme although the browser address remained correct.
let result = router let result = router
.clone() .clone()
.oneshot( .oneshot(
@@ -3367,8 +3343,6 @@ mod tests {
.unwrap(); .unwrap();
assert!(session_cookie.contains("; Secure")); assert!(session_cookie.contains("; Secure"));
// The incoming admin Host may itself be the pinned public DNS name.
// There is no separate allowed_hosts entry in the saved configuration.
let pinned_host = Request::builder() let pinned_host = Request::builder()
.uri("/api/auth/session") .uri("/api/auth/session")
.header("host", "panel.example.test") .header("host", "panel.example.test")
@@ -3383,8 +3357,6 @@ mod tests {
StatusCode::OK StatusCode::OK
); );
// Explicit Origin, CSRF token and app request marker remain mandatory
// for a state-changing request even when XFP contains multiple hops.
let unauthorized = router let unauthorized = router
.clone() .clone()
.oneshot( .oneshot(
@@ -3484,8 +3456,6 @@ mod tests {
assert_eq!(result.status(), StatusCode::FORBIDDEN, assert_eq!(result.status(), StatusCode::FORBIDDEN,
"must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}"); "must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}");
} }
// Credential-bearing certificate automation does not inherit the
// browser-origin override, even through the same trusted admin socket.
let automation = router let automation = router
.clone() .clone()
.oneshot( .oneshot(
@@ -3582,10 +3552,8 @@ mod tests {
assert!(payload["config"].get("storage").is_none()); assert!(payload["config"].get("storage").is_none());
assert_eq!(payload["storage"]["mode"], "sqlite"); assert_eq!(payload["storage"]["mode"], "sqlite");
assert!(payload["storage"]["config_path"].is_null()); assert!(payload["storage"]["config_path"].is_null());
assert!(payload["storage"]["database_path"].is_null()); // fixture omits data_dir assert!(payload["storage"]["database_path"].is_null());
// The old GET endpoint is gone. A dynamic PUT-only section route may
// match the path, but it must not respond to GET.
let old_route = router let old_route = router
.oneshot( .oneshot(
request("/api/config/storage") request("/api/config/storage")
@@ -3669,15 +3637,38 @@ mod tests {
async fn json_metrics_api_is_authenticated_and_matches_prometheus_families() { async fn json_metrics_api_is_authenticated_and_matches_prometheus_families() {
let (router, store, _dir, secret, _) = fixture().await; let (router, store, _dir, secret, _) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME); let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let snapshot = router.clone().oneshot( let snapshot = router
request("/api/metrics/json").header("cookie", &cookie) .clone()
.body(Body::empty()).unwrap() .oneshot(
).await.unwrap(); request("/api/metrics/json")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(snapshot.status(), StatusCode::OK); assert_eq!(snapshot.status(), StatusCode::OK);
assert!(snapshot.headers()["content-type"].to_str().unwrap().starts_with("application/json")); assert!(snapshot.headers()["content-type"]
.to_str()
.unwrap()
.starts_with("application/json"));
let snapshot = json_body(snapshot).await; let snapshot = json_body(snapshot).await;
for field in ["summary", "vhosts", "requests", "system", "cache", "backends", "web_server", for field in [
"streams", "docker", "traffic", "transform", "logging", "capacity", "prometheus"] { "summary",
"vhosts",
"requests",
"system",
"cache",
"backends",
"web_server",
"streams",
"docker",
"traffic",
"transform",
"logging",
"capacity",
"prometheus",
] {
assert!(snapshot.get(field).is_some(), "missing: {field}"); assert!(snapshot.get(field).is_some(), "missing: {field}");
} }
assert_eq!(snapshot["vhosts"]["total"], 0); assert_eq!(snapshot["vhosts"]["total"], 0);
@@ -3687,21 +3678,52 @@ mod tests {
assert_eq!(snapshot["summary"]["backends"]["total"], 0); assert_eq!(snapshot["summary"]["backends"]["total"], 0);
assert!(snapshot["summary"]["traffic"]["bytes_total"].is_number()); assert!(snapshot["summary"]["traffic"]["bytes_total"].is_number());
assert!(snapshot["prometheus"]["samples"]["rust_proxy_requests_total"].is_array()); assert!(snapshot["prometheus"]["samples"]["rust_proxy_requests_total"].is_array());
assert_eq!(snapshot["prometheus"]["types"]["rust_proxy_requests_total"], "counter"); assert_eq!(
assert!(general_api_token_allowed(&Method::GET, "/api/metrics/json", "ro")); snapshot["prometheus"]["types"]["rust_proxy_requests_total"],
assert!(general_api_token_allowed(&Method::GET, "/api/metrics/summary", "ro")); "counter"
assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/json", "rw")); );
assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/summary", "rw")); assert!(general_api_token_allowed(
let summary = router.clone().oneshot( &Method::GET,
request("/api/metrics/summary").header("cookie", &cookie) "/api/metrics/json",
.body(Body::empty()).unwrap() "ro"
).await.unwrap(); ));
assert!(general_api_token_allowed(
&Method::GET,
"/api/metrics/summary",
"ro"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/metrics/json",
"rw"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/metrics/summary",
"rw"
));
let summary = router
.clone()
.oneshot(
request("/api/metrics/summary")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(summary.status(), StatusCode::OK); assert_eq!(summary.status(), StatusCode::OK);
assert_eq!(summary.headers()["cache-control"], "no-store"); assert_eq!(summary.headers()["cache-control"], "no-store");
let summary = json_body(summary).await; let summary = json_body(summary).await;
assert!(summary.get("prometheus").is_none()); assert!(summary.get("prometheus").is_none());
assert_eq!(summary["vhosts"]["enabled"], snapshot["summary"]["vhosts"]["enabled"]); assert_eq!(
assert_eq!(summary["backends"]["total"], snapshot["summary"]["backends"]["total"]); summary["vhosts"]["enabled"],
snapshot["summary"]["vhosts"]["enabled"]
);
assert_eq!(
summary["backends"]["total"],
snapshot["summary"]["backends"]["total"]
);
store.close().await; store.close().await;
} }
@@ -3710,12 +3732,20 @@ mod tests {
let (router, store, _dir, _, _) = fixture().await; let (router, store, _dir, _, _) = fixture().await;
let token = "T".repeat(64); let token = "T".repeat(64);
let cidrs = vec!["127.0.0.1/32".to_string()]; let cidrs = vec!["127.0.0.1/32".to_string()];
assert!(store.create_api_token( assert!(store
"api-monitor", &auth::secret_hash(&token), "ro", "monitor test", &cidrs, "owner" .create_api_token(
).await.unwrap()); "api-monitor",
&auth::secret_hash(&token),
"ro",
"monitor test",
&cidrs,
"owner"
)
.await
.unwrap());
let build = |peer: Option<&str>| { let build = |peer: Option<&str>| {
let mut request = request("/api/metrics/json") let mut request =
.header("authorization", format!("Bearer {token}")); request("/api/metrics/json").header("authorization", format!("Bearer {token}"));
if let Some(peer) = peer { if let Some(peer) = peer {
request = request.extension(PeerAddr(peer.parse().unwrap())); request = request.extension(PeerAddr(peer.parse().unwrap()));
} }
@@ -3723,19 +3753,36 @@ mod tests {
}; };
let response = router.clone().oneshot(build(None)).await.unwrap(); let response = router.clone().oneshot(build(None)).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = router.clone().oneshot(build(Some("127.0.0.1:12345"))).await.unwrap(); let response = router
.clone()
.oneshot(build(Some("127.0.0.1:12345")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let response = json_body(response).await; let response = json_body(response).await;
assert!(response["prometheus"]["samples"].is_object()); assert!(response["prometheus"]["samples"].is_object());
assert!(response.get("config").is_none()); assert!(response.get("config").is_none());
let summarize = |peer: Option<&str>| { let summarize = |peer: Option<&str>| {
let mut req = request("/api/metrics/summary") let mut req =
.header("authorization", format!("Bearer {token}")); request("/api/metrics/summary").header("authorization", format!("Bearer {token}"));
if let Some(peer) = peer { req = req.extension(PeerAddr(peer.parse().unwrap())); } if let Some(peer) = peer {
req = req.extension(PeerAddr(peer.parse().unwrap()));
}
req.body(Body::empty()).unwrap() req.body(Body::empty()).unwrap()
}; };
assert_eq!(router.clone().oneshot(summarize(None)).await.unwrap().status(), StatusCode::FORBIDDEN); assert_eq!(
let summary = router.oneshot(summarize(Some("127.0.0.1:12345"))).await.unwrap(); router
.clone()
.oneshot(summarize(None))
.await
.unwrap()
.status(),
StatusCode::FORBIDDEN
);
let summary = router
.oneshot(summarize(Some("127.0.0.1:12345")))
.await
.unwrap();
assert_eq!(summary.status(), StatusCode::OK); assert_eq!(summary.status(), StatusCode::OK);
let summary = json_body(summary).await; let summary = json_body(summary).await;
assert!(summary["traffic"]["requests_total"].is_number()); assert!(summary["traffic"]["requests_total"].is_number());
+1 -4
View File
@@ -1,4 +1,3 @@
//! Local filesystem authority. The process owner can initialize or recover the sole admin.
use anyhow::{ensure, Context, Result}; use anyhow::{ensure, Context, Result};
use fs2::FileExt; use fs2::FileExt;
use rand::{rngs::SysRng, TryRng}; use rand::{rngs::SysRng, TryRng};
@@ -102,8 +101,6 @@ pub fn load_or_create_setup_token(dir: &Path) -> Result<String> {
Ok(token) Ok(token)
} }
/// Validate database/WAL paths before opening SQLite. The private parent directory is
/// the local CLI authorization boundary; files owned by the same OS user are trusted.
pub fn prepare_database(path: &Path) -> Result<()> { pub fn prepare_database(path: &Path) -> Result<()> {
if !path.try_exists()? { if !path.try_exists()? {
private_options().create_new(true).open(path)?.sync_all()?; private_options().create_new(true).open(path)?.sync_all()?;
@@ -134,4 +131,4 @@ pub fn prepare_database(path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(test)] #[cfg(test)]
include!("files/tests.rs"); include!("tests.rs");
-3
View File
@@ -1,5 +1,3 @@
//! Password authentication is confined to the control plane.
//! Expensive Argon2 work runs on bounded blocking workers, not Tokio network workers.
mod files; mod files;
mod store; mod store;
use crate::{db::Store, runtime::ApiError}; use crate::{db::Store, runtime::ApiError};
@@ -151,7 +149,6 @@ impl Auth {
} else { } else {
None None
}; };
// Generate valid dummy work for unknown usernames. This happens only at startup.
let dummy_hash = hash_password_offline(random_secret()).await?; let dummy_hash = hash_password_offline(random_secret()).await?;
Ok(Arc::new(Self { Ok(Arc::new(Self {
store, store,
-15
View File
@@ -1,4 +1,3 @@
//! Transactional administrator operations. Login/reset races are guarded by generation CAS.
use super::{now, random_secret, secret_hash, Session, SessionToken, SESSION_SECONDS}; use super::{now, random_secret, secret_hash, Session, SessionToken, SESSION_SECONDS};
use crate::db::{audit_tx, Store}; use crate::db::{audit_tx, Store};
use anyhow::{ensure, Result}; use anyhow::{ensure, Result};
@@ -35,17 +34,12 @@ impl Store {
tx.commit().await?; tx.commit().await?;
Ok(true) Ok(true)
} }
/// One persisted global bucket for the single administration listener.
/// Fixed upper bound: 10 attempts per 60-second window, including successful attempts.
/// Unknown usernames use the same bucket and password work as known usernames.
pub async fn consume_auth_attempt(&self, time: i64) -> Result<bool> { pub async fn consume_auth_attempt(&self, time: i64) -> Result<bool> {
let changed = sqlx::query("UPDATE auth_throttle SET attempts = CASE WHEN window_start <= ? THEN 1 ELSE attempts + 1 END, window_start = CASE WHEN window_start <= ? THEN ? ELSE window_start END WHERE id = 1 AND (window_start <= ? OR attempts < 10)") let changed = sqlx::query("UPDATE auth_throttle SET attempts = CASE WHEN window_start <= ? THEN 1 ELSE attempts + 1 END, window_start = CASE WHEN window_start <= ? THEN ? ELSE window_start END WHERE id = 1 AND (window_start <= ? OR attempts < 10)")
.bind(time - 60).bind(time - 60).bind(time).bind(time - 60) .bind(time - 60).bind(time - 60).bind(time).bind(time - 60)
.execute(&self.pool).await?; .execute(&self.pool).await?;
Ok(changed.rows_affected() == 1) Ok(changed.rows_affected() == 1)
} }
/// The first UPDATE obtains SQLite's write lock and validates the credential generation.
/// A reset committed during password verification prevents insertion of a stale session.
pub async fn create_session( pub async fn create_session(
&self, &self,
generation: i64, generation: i64,
@@ -115,7 +109,6 @@ impl Store {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
} }
// Independent caps avoid a local CLI burst evicting all browser sessions.
let cap = if kind == "cli" { 8_i64 } else { 16_i64 }; let cap = if kind == "cli" { 8_i64 } else { 16_i64 };
sqlx::query("DELETE FROM admin_sessions WHERE token_hash IN (SELECT token_hash FROM admin_sessions WHERE kind = ? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?)") sqlx::query("DELETE FROM admin_sessions WHERE token_hash IN (SELECT token_hash FROM admin_sessions WHERE kind = ? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?)")
.bind(kind).bind(cap - 1).execute(&mut *tx).await?; .bind(kind).bind(cap - 1).execute(&mut *tx).await?;
@@ -150,12 +143,6 @@ impl Store {
"browser session lifetime must be 7..30 days" "browser session lifetime must be 7..30 days"
); );
let hash = secret_hash(secret); let hash = secret_hash(secret);
// A browser session is bounded by the currently configured lifetime. If the
// setting was increased while an older still-valid session exists, extend its
// absolute expiry from its original creation time without turning it into a
// sliding/immortal session. CLI capabilities keep the fixed expiry chosen
// when they are created (60 seconds for one-shot admin commands, longer
// for the bounded local live-log viewer).
let row = sqlx::query("UPDATE admin_sessions SET last_seen = ?, expires_at = CASE WHEN kind = 'browser' THEN created_at + ? ELSE expires_at END WHERE token_hash = ? AND expires_at > ? AND (kind <> 'browser' OR created_at + ? > ?) AND generation = (SELECT generation FROM administrator WHERE id = 1) RETURNING csrf_token, expires_at, generation, (SELECT username FROM administrator WHERE id = 1) AS username") let row = sqlx::query("UPDATE admin_sessions SET last_seen = ?, expires_at = CASE WHEN kind = 'browser' THEN created_at + ? ELSE expires_at END WHERE token_hash = ? AND expires_at > ? AND (kind <> 'browser' OR created_at + ? > ?) AND generation = (SELECT generation FROM administrator WHERE id = 1) RETURNING csrf_token, expires_at, generation, (SELECT username FROM administrator WHERE id = 1) AS username")
.bind(time) .bind(time)
.bind(browser_lifetime) .bind(browser_lifetime)
@@ -215,8 +202,6 @@ impl Store {
tx.commit().await?; tx.commit().await?;
Ok(()) Ok(())
} }
/// expected_generation protects browser password changes against simultaneous resets.
/// CLI passes the generation it read, so even two offline recoveries cannot silently win.
pub async fn reset_admin_password( pub async fn reset_admin_password(
&self, &self,
hash: &str, hash: &str,
-4
View File
@@ -1,7 +1,3 @@
//! Password-encrypted full backup/restore for RustProxy durable data.
//! The format is streaming, authenticated and deliberately excludes caches,
//! logs, sockets and temporary runtime state.
include!("backup/archive.rs"); include!("backup/archive.rs");
include!("backup/ownership.rs"); include!("backup/ownership.rs");
include!("backup/snapshot.rs"); include!("backup/snapshot.rs");
+3 -4
View File
@@ -37,11 +37,11 @@ fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> {
} }
let raw = CString::new(path.as_os_str().as_bytes()) let raw = CString::new(path.as_os_str().as_bytes())
.context("restore path contains an embedded NUL")?; .context("restore path contains an embedded NUL")?;
// lchown changes the link itself instead of following a restored symlink.
let rc = unsafe { libc::lchown(raw.as_ptr(), owner.uid, owner.gid) }; let rc = unsafe { libc::lchown(raw.as_ptr(), owner.uid, owner.gid) };
if rc != 0 { if rc != 0 {
return Err(std::io::Error::last_os_error()) return Err(std::io::Error::last_os_error()).with_context(|| {
.with_context(|| format!("restore ownership update failed for {}", path.display())); format!("restore ownership update failed for {}", path.display())
});
} }
Ok(()) Ok(())
} }
@@ -53,4 +53,3 @@ fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> {
fn set_tree_owner(_path: &Path, _owner: TreeOwner) -> Result<()> { fn set_tree_owner(_path: &Path, _owner: TreeOwner) -> Result<()> {
Ok(()) Ok(())
} }
-3
View File
@@ -263,9 +263,6 @@ fn validate_snapshot_config(
&certificate.cert_file, &certificate.cert_file,
&certificate.key_file, &certificate.key_file,
)? { )? {
// External certificate material is intentionally outside the RustProxy
// data tree and is therefore referenced by configuration, not copied
// into a RustProxy backup. The operator must restore/mount it separately.
continue; continue;
} }
for name in [&certificate.cert_file, &certificate.key_file] { for name in [&certificate.cert_file, &certificate.key_file] {
-1
View File
@@ -19,7 +19,6 @@ mod tests {
assert_eq!(fs::read(&output).unwrap(), b"private backup payload"); assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
let mut bytes = fs::read(&encrypted).unwrap(); let mut bytes = fs::read(&encrypted).unwrap();
// Corrupt the last authentication-tag byte, before the four-byte EOF marker.
let tag_byte = bytes.len() - 5; let tag_byte = bytes.len() - 5;
bytes[tag_byte] ^= 1; bytes[tag_byte] ^= 1;
fs::write(&tampered, bytes).unwrap(); fs::write(&tampered, bytes).unwrap();
-3
View File
@@ -1,6 +1,3 @@
//! Bounded backend pools: active health checks, passive failures, weighted
//! balancing, opaque affinity and leases held through the complete exchange.
include!("balance/model.rs"); include!("balance/model.rs");
include!("balance/health.rs"); include!("balance/health.rs");
include!("balance/lease_runtime.rs"); include!("balance/lease_runtime.rs");
-12
View File
@@ -112,14 +112,10 @@ impl BackendState {
} }
} }
pub fn healthy(&self) -> bool { pub fn healthy(&self) -> bool {
// No lock/clock acquisition for a healthy backend. Only an unhealthy
// backend enters the quarantine/cooldown recovery path below.
if self.healthy_cached.load(Ordering::Acquire) { if self.healthy_cached.load(Ordering::Acquire) {
return true; return true;
} }
let mut h = lock(&self.health); let mut h = lock(&self.health);
// Passive-only monitoring has no active probes to recover an ejected
// backend. After a bounded quarantine, allow one new traffic attempt.
if !h.passive_healthy && h.active_healthy && !h.checked { if !h.passive_healthy && h.active_healthy && !h.checked {
if h.passive_ejected_at if h.passive_ejected_at
.is_some_and(|at| at.elapsed() >= h.passive_cooldown) .is_some_and(|at| at.elapsed() >= h.passive_cooldown)
@@ -173,8 +169,6 @@ impl BackendState {
.saturating_add(self.bytes_out.load(Ordering::Relaxed)); .saturating_add(self.bytes_out.load(Ordering::Relaxed));
let delta = total.saturating_sub(sample.total); let delta = total.saturating_sub(sample.total);
if delta == 0 { if delta == 0 {
// No bytes moved in this sampling window: report an actual idle backend
// instead of keeping a decaying EWMA tail that looks like live traffic.
sample.rate = 0.0; sample.rate = 0.0;
} else { } else {
let rate = delta as f64 / elapsed; let rate = delta as f64 / elapsed;
@@ -367,8 +361,6 @@ impl BackendState {
self.observe_passive_with_detail(active, passive, reason, reason, reason, elapsed); self.observe_passive_with_detail(active, passive, reason, reason, reason, elapsed);
} }
/// Returns the first failure in a consecutive counted streak only when
/// this observation actually removes the backend from the traffic pool.
fn observe_passive_with_detail( fn observe_passive_with_detail(
&self, &self,
active: &HealthCheck, active: &HealthCheck,
@@ -400,8 +392,6 @@ impl BackendState {
if !passive.counts(reason) { if !passive.counts(reason) {
return None; return None;
} }
// A fan-out of failures from one expired H2 connection must not count
// as independent failed backend observations in a single instant.
if reason == "io_error" { if reason == "io_error" {
let now = Instant::now(); let now = Instant::now();
if h.last_io_counted_at if h.last_io_counted_at
@@ -459,8 +449,6 @@ pub struct Member {
pub backend: Arc<CompiledBackend>, pub backend: Arc<CompiledBackend>,
pub definition: PoolMember, pub definition: PoolMember,
pub state: Arc<BackendState>, pub state: Arc<BackendState>,
// Immutable effective per-backend policies are compiled once per pool
// generation, not deep-cloned for every request/lease.
health_settings: Arc<HealthCheck>, health_settings: Arc<HealthCheck>,
passive_settings: Arc<PassiveHealth>, passive_settings: Arc<PassiveHealth>,
token: String, token: String,
-14
View File
@@ -76,8 +76,6 @@ impl Lease {
self.member.state.errors.fetch_add(1, Ordering::Relaxed); self.member.state.errors.fetch_add(1, Ordering::Relaxed);
} }
self.member.state.record_failure(class, detail); self.member.state.record_failure(class, detail);
// GOAWAY(NO_ERROR) is a graceful H2 connection shutdown, not an
// origin failure. It can still be surfaced in access diagnostics.
if matches!(class, "h2_graceful_goaway" | "h2_graceful_close") { if matches!(class, "h2_graceful_goaway" | "h2_graceful_close") {
return; return;
} }
@@ -368,8 +366,6 @@ async fn probe(member: Arc<Member>, settings: Arc<HealthCheck>) {
tracing::info!(backend = %member.definition.id, healthy = after, reason = result, "backend health changed"); tracing::info!(backend = %member.definition.id, healthy = after, reason = result, "backend health changed");
} }
} }
/// One supervisor, at most 16 probes, at most one outstanding probe per member.
/// DNS workers have their own shared bound and never resolve arbitrary URLs.
pub async fn health_loop(app: Weak<crate::runtime::App>, shutdown: CancellationToken) { pub async fn health_loop(app: Weak<crate::runtime::App>, shutdown: CancellationToken) {
let mut interval = tokio::time::interval(Duration::from_millis(250)); let mut interval = tokio::time::interval(Duration::from_millis(250));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -383,24 +379,14 @@ pub async fn health_loop(app: Weak<crate::runtime::App>, shutdown: CancellationT
_ = interval.tick() => { _ = interval.tick() => {
let Some(app) = app.upgrade() else { break; }; let Some(app) = app.upgrade() else { break; };
let snapshot = app.config.load_full(); let snapshot = app.config.load_full();
// Own an Arc for each candidate. JoinSet tasks require an
// owned, 'static member rather than a reference into snapshot.
// Health settings were already compiled into each member.
// Reuse the immutable Arc instead of cloning each HealthCheck
// and recalculating per-member overrides on every 250ms tick.
let members: Vec<_> = snapshot.pools.iter().flat_map(|pool| let members: Vec<_> = snapshot.pools.iter().flat_map(|pool|
pool.members.iter().map(|member| pool.members.iter().map(|member|
(Arc::clone(member), Arc::clone(&member.health_settings)))).collect(); (Arc::clone(member), Arc::clone(&member.health_settings)))).collect();
for (member, _) in &members { for (member, _) in &members {
member.state.sample_traffic(); member.state.sample_traffic();
// Time-based pool recycling must not depend on receiving a
// request; otherwise idle backends can stay on generation 0
// indefinitely after the configured recycle age elapses.
member.backend.client.maintain_rotation(); member.backend.client.maintain_rotation();
member.backend.websocket_client.maintain_rotation(); member.backend.websocket_client.maintain_rotation();
} }
// Rotate the bounded probe window: slow first members must not
// starve later pools when all intervals equal their timeouts.
let start = cursor; let start = cursor;
for offset in 0..members.len() { for offset in 0..members.len() {
if checks.len() >= 16 { break; } if checks.len() >= 16 { break; }
-15
View File
@@ -1,4 +1,3 @@
// Core pool model and policies.
use crate::{config::CompiledBackend, vhost::PoolMember}; use crate::{config::CompiledBackend, vhost::PoolMember};
use anyhow::{ensure, Result}; use anyhow::{ensure, Result};
use axum::body::Body; use axum::body::Body;
@@ -54,15 +53,8 @@ pub struct Keepalive {
pub idle_timeout_seconds: u64, pub idle_timeout_seconds: u64,
pub max_idle_per_host: usize, pub max_idle_per_host: usize,
pub tcp_keepalive_seconds: u64, pub tcp_keepalive_seconds: u64,
/// Rebuild a backend Hyper client after this many requests. The counter is
/// per backend/client generation, not per TCP connection. Zero disables
/// request-count based recycling.
pub pool_recycle_requests: u64, pub pool_recycle_requests: u64,
/// Maximum age of one backend Hyper client generation. Zero disables
/// time-based recycling.
pub pool_recycle_seconds: u64, pub pool_recycle_seconds: u64,
/// Bound Hyper's adaptive HTTP/1 read buffer per connection. Hyper requires
/// at least 8192 bytes. This affects upstream HTTP/1 only.
pub http1_max_buffer_bytes: usize, pub http1_max_buffer_bytes: usize,
} }
impl Default for Keepalive { impl Default for Keepalive {
@@ -70,9 +62,6 @@ impl Default for Keepalive {
Self { Self {
enabled: true, enabled: true,
idle_timeout_seconds: 30, idle_timeout_seconds: 30,
// Retain enough reusable H1 upstream sockets under concurrent
// small-response traffic. The existing 1..=64 bound, idle
// timeout and pool recycling still limit resource retention.
max_idle_per_host: 64, max_idle_per_host: 64,
tcp_keepalive_seconds: 30, tcp_keepalive_seconds: 30,
pool_recycle_requests: 25_000, pool_recycle_requests: 25_000,
@@ -219,7 +208,6 @@ impl Default for Settings {
} }
} }
impl Settings { impl Settings {
/// Resolve active health per member; a disabled monitor never probes.
pub fn health_for(&self, member: &PoolMember) -> HealthCheck { pub fn health_for(&self, member: &PoolMember) -> HealthCheck {
let mut health = member let mut health = member
.health_check .health_check
@@ -233,7 +221,6 @@ impl Settings {
} }
health health
} }
/// Monitoring off also excludes passive ejection; failures remain logged.
pub fn passive_for(&self, member: &PoolMember) -> PassiveHealth { pub fn passive_for(&self, member: &PoolMember) -> PassiveHealth {
let mut passive = self.passive_health.clone(); let mut passive = self.passive_health.clone();
if !member.health_check_enabled { if !member.health_check_enabled {
@@ -389,8 +376,6 @@ impl Default for FailureObservation {
} }
#[derive(Debug)] #[derive(Debug)]
pub struct BackendState { pub struct BackendState {
// Lock-free healthy lookups on the hot request-selection path. The mutex
// remains authoritative for transitions, probes and quarantine expiry.
healthy_cached: AtomicBool, healthy_cached: AtomicBool,
health: Mutex<Health>, health: Mutex<Health>,
traffic: Mutex<Traffic>, traffic: Mutex<Traffic>,
+15 -26
View File
@@ -72,13 +72,8 @@ impl Pool {
.backend .backend
.websocket_client .websocket_client
.reusable_with(&old.backend.websocket_client); .reusable_with(&old.backend.websocket_client);
// This pool has not been published or cloned into routes yet.
if let Some(member) = Arc::get_mut(member) { if let Some(member) = Arc::get_mut(member) {
if same_clients { if same_clients {
// Preserve the live Hyper pools and, with them, generation
// age/request/rotation state across unrelated snapshot
// rebuilds. Changed origins, address policy, keepalive or
// connect timeout still receive newly compiled clients.
member.backend = old.backend.clone(); member.backend = old.backend.clone();
} }
if same_origin if same_origin
@@ -215,9 +210,6 @@ impl Pool {
tls: bool, tls: bool,
excluded: &[String], excluded: &[String],
) -> Option<Selection> { ) -> Option<Selection> {
// Most deployments have one healthy backend. For non-cookie modes it
// needs neither candidate Vecs, weight scoring nor the shared scheduler
// mutex. Still use the same atomic capacity lease and health gate.
if self.members.len() == 1 && !self.cookie_affinity() { if self.members.len() == 1 && !self.cookie_affinity() {
let member = &self.members[0]; let member = &self.members[0];
if !member.definition.pool_eligible if !member.definition.pool_eligible
@@ -247,8 +239,6 @@ impl Pool {
} else { } else {
None None
}; };
// Drain stops new assignments while existing cookie-affine clients may
// finish their session. Disabled/unhealthy members never receive traffic.
let matched = existing.and_then(|token| { let matched = existing.and_then(|token| {
self.members.iter().find(|m| { self.members.iter().find(|m| {
m.token == token m.token == token
@@ -269,8 +259,6 @@ impl Pool {
if self.settings.algorithm == Algorithm::RoundRobin if self.settings.algorithm == Algorithm::RoundRobin
&& self.settings.sticky.mode != StickyMode::IpHash && self.settings.sticky.mode != StickyMode::IpHash
{ {
// Smooth weighted round-robin keeps its historical state and
// global ordering. Only that algorithm takes the mutex.
let mut scheduling = lock(&self.scheduling); let mut scheduling = lock(&self.scheduling);
let mut chosen = members[0]; let mut chosen = members[0];
let mut best = f64::NEG_INFINITY; let mut best = f64::NEG_INFINITY;
@@ -279,12 +267,13 @@ impl Pool {
let weight = member let weight = member
.state .state
.weight(member.definition.weight, self.settings.slow_start_seconds); .weight(member.definition.weight, self.settings.slow_start_seconds);
let current = if let Some(current) = let current =
scheduling.get_mut(member.definition.id.as_str()) if let Some(current) = scheduling.get_mut(member.definition.id.as_str()) {
{
current current
} else { } else {
scheduling.entry(member.definition.id.clone()).or_insert(0.0) scheduling
.entry(member.definition.id.clone())
.or_insert(0.0)
}; };
*current += weight; *current += weight;
total += weight; total += weight;
@@ -305,8 +294,6 @@ impl Pool {
} else { } else {
self.settings.algorithm self.settings.algorithm
}; };
// Canonicalize the address once, not for every candidate. The
// SHA-256 input and rendezvous scoring remain bit-for-bit equal.
let hash_ip = (algorithm == Algorithm::IpHash) let hash_ip = (algorithm == Algorithm::IpHash)
.then(|| crate::vhost::canonical_ip(peer).to_string()); .then(|| crate::vhost::canonical_ip(peer).to_string());
for offset in 0..members.len() { for offset in 0..members.len() {
@@ -330,16 +317,18 @@ impl Pool {
-unit.max(f64::MIN_POSITIVE).ln() / member.definition.weight as f64 -unit.max(f64::MIN_POSITIVE).ln() / member.definition.weight as f64
} }
_ => { _ => {
let weight = member.state.weight( let weight = member
member.definition.weight, .state
self.settings.slow_start_seconds, .weight(member.definition.weight, self.settings.slow_start_seconds);
);
match algorithm { match algorithm {
Algorithm::LeastTraffic => Algorithm::LeastTraffic => {
(member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight, (member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight
Algorithm::Adaptive => }
Algorithm::Adaptive => {
((active + 1.0) * member.state.latency() ((active + 1.0) * member.state.latency()
+ member.state.traffic_rate() / 16_384.0) / weight, + member.state.traffic_rate() / 16_384.0)
/ weight
}
_ => (active + 1.0) / weight, _ => (active + 1.0) / weight,
} }
} }
+16 -10
View File
@@ -19,10 +19,6 @@ impl Pool {
"down" "down"
}; };
// Keep this as explicit Map construction instead of one very large
// json! invocation. serde_json's json_internal! macro is recursive
// and the diagnostics payload is large enough to hit rustc's default
// macro recursion limit on supported stable toolchains.
let mut item = serde_json::Map::new(); let mut item = serde_json::Map::new();
item.insert("id".into(), json!(m.definition.id)); item.insert("id".into(), json!(m.definition.id));
item.insert("url".into(), json!(m.definition.url)); item.insert("url".into(), json!(m.definition.url));
@@ -85,13 +81,23 @@ impl Pool {
"upstream_client_rotations_total".into(), "upstream_client_rotations_total".into(),
json!(client_rotation.rotations), json!(client_rotation.rotations),
); );
// Negotiated HTTP version from real backend response headers,
// never from the configured Auto/HTTP2 policy or health probes.
let observed = m.backend.client.protocol_stats(); let observed = m.backend.client.protocol_stats();
item.insert("upstream_protocol_observed".into(), json!(observed.last_version)); item.insert(
item.insert("upstream_protocol_last_seen_ms".into(), json!(observed.observed_at_ms)); "upstream_protocol_observed".into(),
item.insert("upstream_protocol_http1_total".into(), json!(observed.http1_responses)); json!(observed.last_version),
item.insert("upstream_protocol_http2_total".into(), json!(observed.http2_responses)); );
item.insert(
"upstream_protocol_last_seen_ms".into(),
json!(observed.observed_at_ms),
);
item.insert(
"upstream_protocol_http1_total".into(),
json!(observed.http1_responses),
);
item.insert(
"upstream_protocol_http2_total".into(),
json!(observed.http2_responses),
);
item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0)); item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0));
item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0)); item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0));
item.insert("last_probe_ms".into(), json!(h.last_probe_ms)); item.insert("last_probe_ms".into(), json!(h.last_probe_ms));
+21 -11
View File
@@ -66,7 +66,7 @@ mod tests {
defaults.health_check_enabled = false; defaults.health_check_enabled = false;
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off); assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off);
assert!(!settings.passive_for(&defaults).enabled); assert!(!settings.passive_for(&defaults).enabled);
assert!(!defaults.pool_eligible); // routing intent is independent assert!(!defaults.pool_eligible);
defaults.health_check_enabled = true; defaults.health_check_enabled = true;
defaults.health_check = Some(HealthCheck { defaults.health_check = Some(HealthCheck {
mode: HealthMode::Http, mode: HealthMode::Http,
@@ -105,7 +105,8 @@ mod tests {
assert_eq!(parsed.keepalive.max_idle_per_host, 64); assert_eq!(parsed.keepalive.max_idle_per_host, 64);
let explicit: Settings = serde_json::from_value(serde_json::json!({ let explicit: Settings = serde_json::from_value(serde_json::json!({
"keepalive": { "max_idle_per_host": 4 } "keepalive": { "max_idle_per_host": 4 }
})).unwrap(); }))
.unwrap();
assert_eq!(explicit.keepalive.max_idle_per_host, 4); assert_eq!(explicit.keepalive.max_idle_per_host, 4);
assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000); assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000);
assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600); assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600);
@@ -356,7 +357,7 @@ mod tests {
} }
assert_eq!(counts, [30, 10]); assert_eq!(counts, [30, 10]);
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0); assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0);
assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0); // Mere cache selection isn't origin traffic. assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0);
} }
#[tokio::test] #[tokio::test]
async fn least_connections_counts_a_lease_until_its_last_owner_drops() { async fn least_connections_counts_a_lease_until_its_last_owner_drops() {
@@ -769,7 +770,6 @@ mod tests {
assert!(state.healthy()); assert!(state.healthy());
} }
#[test] #[test]
fn single_backend_fast_path_observes_capacity_and_health_without_affinity_cookie() { fn single_backend_fast_path_observes_capacity_and_health_without_affinity_cookie() {
let pool = capacity_pool(1, 0); let pool = capacity_pool(1, 0);
@@ -792,7 +792,10 @@ mod tests {
let first = select(&pool); let first = select(&pool);
let second = select(&pool); let second = select(&pool);
assert!(Arc::ptr_eq(&first.lease.health, &second.lease.health)); assert!(Arc::ptr_eq(&first.lease.health, &second.lease.health));
assert!(Arc::ptr_eq(&first.lease.passive_health, &second.lease.passive_health)); assert!(Arc::ptr_eq(
&first.lease.passive_health,
&second.lease.passive_health
));
} }
#[test] #[test]
@@ -800,10 +803,14 @@ mod tests {
let state = BackendState::new(HealthMode::Off); let state = BackendState::new(HealthMode::Off);
assert!(state.healthy()); assert!(state.healthy());
let active = settings(HealthMode::Off); let active = settings(HealthMode::Off);
let passive = PassiveHealth { enabled: true, fall: Some(1), rise: Some(1), ..Default::default() }; let passive = PassiveHealth {
enabled: true,
fall: Some(1),
rise: Some(1),
..Default::default()
};
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(2)); state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(2));
assert!(!state.healthy()); assert!(!state.healthy());
// Preserve quarantine rules; an active probe is not invented here.
state.observe_passive(&active, &passive, "ok", Duration::from_millis(2)); state.observe_passive(&active, &passive, "ok", Duration::from_millis(2));
assert!(state.healthy()); assert!(state.healthy());
} }
@@ -812,14 +819,17 @@ mod tests {
fn successful_lease_response_keeps_passive_observability_and_failure_detail() { fn successful_lease_response_keeps_passive_observability_and_failure_detail() {
let pool = capacity_pool(0, 0); let pool = capacity_pool(0, 0);
let selection = select(&pool); let selection = select(&pool);
selection.lease.response(200, Duration::from_millis(5), true); selection
.lease
.response(200, Duration::from_millis(5), true);
let state = &selection.lease.member.state; let state = &selection.lease.member.state;
assert_eq!(state.last_status.load(Ordering::Relaxed), 200); assert_eq!(state.last_status.load(Ordering::Relaxed), 200);
assert_eq!(lock(&state.health).passive_result, "ok"); assert_eq!(lock(&state.health).passive_result, "ok");
assert!(lock(&state.health).latency_us > 0); assert!(lock(&state.health).latency_us > 0);
selection.lease.response(503, Duration::from_millis(5), true); selection
.lease
.response(503, Duration::from_millis(5), true);
assert_eq!(state.last_status.load(Ordering::Relaxed), 503); assert_eq!(state.last_status.load(Ordering::Relaxed), 503);
assert_eq!(lock(&state.last_failure).detail, "status=503"); assert_eq!(lock(&state.last_failure).detail, "status=503");
} }
}
} // mod tests
+1 -8
View File
@@ -1,5 +1,3 @@
//! Per-vhost RFC 7617 gate. Only password hashes enter desired configuration.
//! ACLs run first; credentials are never cached with content or written to logs.
use anyhow::{ensure, Result}; use anyhow::{ensure, Result};
use argon2::{ use argon2::{
password_hash::{phc::PasswordHash, PasswordVerifier}, password_hash::{phc::PasswordHash, PasswordVerifier},
@@ -78,7 +76,6 @@ impl Settings {
for user in &self.users { for user in &self.users {
validate_username(&user.username)?; validate_username(&user.username)?;
ensure!(names.insert(&user.username), "basic.duplicate_username"); ensure!(names.insert(&user.username), "basic.duplicate_username");
// Bound KDF cost before accepting imported hashes. No arbitrary PHC parameters.
ensure!( ensure!(
user.password_hash.len() <= 256 user.password_hash.len() <= 256
&& user && user
@@ -88,8 +85,6 @@ impl Settings {
); );
let hash = PasswordHash::new(&user.password_hash) let hash = PasswordHash::new(&user.password_hash)
.map_err(|_| anyhow::anyhow!("basic.invalid_hash"))?; .map_err(|_| anyhow::anyhow!("basic.invalid_hash"))?;
// Match the former 16-character minimum for the PHC-encoded salt.
// phc 0.6 Salt::len() now measures decoded bytes instead.
ensure!( ensure!(
hash.salt.is_some_and(|s| s.to_salt_string().len() >= 16) hash.salt.is_some_and(|s| s.to_salt_string().len() >= 16)
&& hash.hash.is_some_and(|h| h.len() == 32), && hash.hash.is_some_and(|h| h.len() == 32),
@@ -157,7 +152,6 @@ fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response {
.headers_mut() .headers_mut()
.insert("retry-after", HeaderValue::from_static("60")); .insert("retry-after", HeaderValue::from_static("60"));
} }
// Custom error pages must not turn an authentication failure into success.
response response
.extensions_mut() .extensions_mut()
.insert(crate::tinyauth::AuthenticationFailure); .insert(crate::tinyauth::AuthenticationFailure);
@@ -214,7 +208,6 @@ impl Compiled {
return Err(denied(StatusCode::TOO_MANY_REQUESTS, None)); return Err(denied(StatusCode::TOO_MANY_REQUESTS, None));
} }
let Some((name, password, original)) = input else { let Some((name, password, original)) = input else {
// A browser's first request normally has no Authorization; do not count that as a bad password.
return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge))); return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge)));
}; };
let found = self.settings.users.iter().find(|u| u.username == name); let found = self.settings.users.iter().find(|u| u.username == name);
@@ -254,4 +247,4 @@ impl Compiled {
} }
#[cfg(test)] #[cfg(test)]
include!("basic_auth/tests.rs"); include!("tests.rs");
+281 -116
View File
@@ -75,13 +75,25 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new()); let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::from("zero-freshness")); let mut response = Response::new(Body::from("zero-freshness"));
*response.headers_mut() = headers(control); *response.headers_mut() = headers(control);
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO) let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.expect("valid zero freshness is distinct from a no-store response"); .expect("valid zero freshness is distinct from a no-store response");
assert_eq!(plan.ttl, 0, "{control}"); assert_eq!(plan.ttl, 0, "{control}");
cache.wrap(response, ticket.clone(), plan, &settings) cache
.into_body().collect().await.unwrap(); .wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.view().entries, 1, "{control}"); assert_eq!(cache.view().entries, 1, "{control}");
assert!(cache.get(&ticket).is_none(), "zero-TTL {control} cannot be an unconditional HIT"); assert!(
cache.get(&ticket).is_none(),
"zero-TTL {control} cannot be an unconditional HIT"
);
} }
} }
#[test] #[test]
@@ -180,7 +192,14 @@ mod tests {
let mut single_range = HeaderMap::new(); let mut single_range = HeaderMap::new();
single_range.insert("range", HeaderValue::from_static("bytes=0-3")); single_range.insert("range", HeaderValue::from_static("bytes=0-3"));
assert_eq!( assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()), request_policy(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
),
RequestPolicy::Use RequestPolicy::Use
); );
assert!(request_cacheable( assert!(request_cacheable(
@@ -193,7 +212,14 @@ mod tests {
)); ));
single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9")); single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9"));
assert_eq!( assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()), request_policy(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
),
RequestPolicy::Bypass("request_range_unsupported") RequestPolicy::Bypass("request_range_unsupported")
); );
} }
@@ -218,11 +244,23 @@ mod tests {
fn request_max_stale_is_bounded_and_respects_revalidation_flags() { fn request_max_stale_is_bounded_and_respects_revalidation_flags() {
let unbounded = headers("max-stale"); let unbounded = headers("max-stale");
let bounded = headers("max-stale=15"); let bounded = headers("max-stale=15");
assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false)); assert_eq!(
assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false)); stale_mode(120, 60, 0, false, false, false, &unbounded),
Some(false)
);
assert_eq!(
stale_mode(70, 60, 0, false, false, false, &bounded),
Some(false)
);
assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None); assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None);
assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true)); assert_eq!(
assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None); stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()),
Some(true)
);
assert_eq!(
stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()),
None
);
assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None); assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None);
assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None); assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None);
} }
@@ -268,9 +306,21 @@ mod tests {
origin_last_modified: None, origin_last_modified: None,
}; };
assert!(base.can_stale_if_error()); assert!(base.can_stale_if_error());
assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error()); assert!(!Revalidation {
assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error()); age: 91,
assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error()); ..base.clone()
}
.can_stale_if_error());
assert!(!Revalidation {
must_revalidate: true,
..base.clone()
}
.can_stale_if_error());
assert!(!Revalidation {
proxy_revalidate: true,
..base
}
.can_stale_if_error());
} }
#[test] #[test]
@@ -378,12 +428,8 @@ mod tests {
json: true, json: true,
..Default::default() ..Default::default()
}; };
let transformed = crate::transform::apply( let transformed =
response, crate::transform::apply(response, "/data.json", &transform_settings, false)
"/data.json",
&transform_settings,
false,
)
.await .await
.unwrap(); .unwrap();
let transformed_plan = plan( let transformed_plan = plan(
@@ -403,7 +449,9 @@ mod tests {
let first = wrapped.into_body().collect().await.unwrap().to_bytes(); let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&first[..], br#"{"message":"hello"}"#); assert_eq!(&first[..], br#"{"message":"hello"}"#);
let hit = cache.get(&ticket).expect("transformed representation should be cached"); let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag); assert_eq!(hit.response.headers()["etag"], expected_etag);
assert_eq!( assert_eq!(
hit.response.headers()["content-length"].to_str().unwrap(), hit.response.headers()["content-length"].to_str().unwrap(),
@@ -460,10 +508,9 @@ mod tests {
"cache-control", "cache-control",
HeaderValue::from_static("public, max-age=60"), HeaderValue::from_static("public, max-age=60"),
); );
response.headers_mut().insert( response
"content-type", .headers_mut()
HeaderValue::from_str(content_type).unwrap(), .insert("content-type", HeaderValue::from_str(content_type).unwrap());
);
response.headers_mut().insert( response.headers_mut().insert(
"content-length", "content-length",
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(), HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
@@ -496,12 +543,7 @@ mod tests {
"json" => transform_settings.json = true, "json" => transform_settings.json = true,
_ => unreachable!(), _ => unreachable!(),
} }
let transformed = crate::transform::apply( let transformed = crate::transform::apply(response, path, &transform_settings, false)
response,
path,
&transform_settings,
false,
)
.await .await
.unwrap(); .unwrap();
let transformed_plan = plan( let transformed_plan = plan(
@@ -521,9 +563,15 @@ mod tests {
); );
let first = wrapped.into_body().collect().await.unwrap().to_bytes(); let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert!(!first.is_empty(), "{kind}"); assert!(!first.is_empty(), "{kind}");
assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture"); assert_ne!(
first.as_ref(),
origin_body,
"{kind} should change the chosen fixture"
);
let hit = cache.get(&ticket).expect("transformed representation should be cached"); let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}"); assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}");
assert_eq!( assert_eq!(
hit.response.headers()["content-length"], hit.response.headers()["content-length"],
@@ -701,7 +749,9 @@ mod tests {
let mut s = Settings::default(); let mut s = Settings::default();
s.transient_max_bytes = 128 * 1024; s.transient_max_bytes = 128 * 1024;
s.cold_queue_max_bytes = 64 * 1024; s.cold_queue_max_bytes = 64 * 1024;
let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation"); let first = cache
.try_reserve_transient(96 * 1024, &s)
.expect("first reservation");
assert!(cache.try_reserve_transient(40 * 1024, &s).is_none()); assert!(cache.try_reserve_transient(40 * 1024, &s).is_none());
assert_eq!(cache.view().transient_bytes, 96 * 1024); assert_eq!(cache.view().transient_bytes, 96 * 1024);
drop(first); drop(first);
@@ -756,9 +806,7 @@ mod tests {
must_revalidate: false, must_revalidate: false,
proxy_revalidate: false, proxy_revalidate: false,
origin_etag: Some(HeaderValue::from_static("W/\"origin\"")), origin_etag: Some(HeaderValue::from_static("W/\"origin\"")),
origin_last_modified: Some(HeaderValue::from_static( origin_last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
"Mon, 26 Jan 2026 23:11:40 GMT",
)),
}; };
let mut matching = HeaderMap::new(); let mut matching = HeaderMap::new();
matching.insert("if-none-match", HeaderValue::from_static("\"origin\"")); matching.insert("if-none-match", HeaderValue::from_static("\"origin\""));
@@ -781,13 +829,7 @@ mod tests {
let mut response_headers = headers("public, max-age=60"); let mut response_headers = headers("public, max-age=60");
response_headers.insert("etag", public_etag.clone()); response_headers.insert("etag", public_etag.clone());
response_headers.insert("last-modified", modified.clone()); response_headers.insert("last-modified", modified.clone());
let plan = plan( let plan = plan(StatusCode::OK, &response_headers, &settings, Duration::ZERO).unwrap();
StatusCode::OK,
&response_headers,
&settings,
Duration::ZERO,
)
.unwrap();
let mut response = Response::new(Body::from("transformed")); let mut response = Response::new(Body::from("transformed"));
*response.headers_mut() = response_headers; *response.headers_mut() = response_headers;
response response
@@ -818,9 +860,15 @@ mod tests {
assert_eq!(client_conditional["if-modified-since"], modified); assert_eq!(client_conditional["if-modified-since"], modified);
let mut validation = HeaderMap::new(); let mut validation = HeaderMap::new();
validation.insert("cache-control", HeaderValue::from_static("public, max-age=60")); validation.insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
validation.insert("etag", HeaderValue::from_static("\"origin-new\"")); validation.insert("etag", HeaderValue::from_static("\"origin-new\""));
validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT")); validation.insert(
"last-modified",
HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"),
);
let merged = cached.merge_validation_headers(&validation); let merged = cached.merge_validation_headers(&validation);
assert_eq!(merged["etag"], public_etag); assert_eq!(merged["etag"], public_etag);
} }
@@ -915,17 +963,12 @@ mod tests {
let mut origin_headers = headers("public, max-age=60"); let mut origin_headers = headers("public, max-age=60");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding")); origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding"));
origin_headers.insert("content-length", HeaderValue::from_static("7")); origin_headers.insert("content-length", HeaderValue::from_static("7"));
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO) let origin_plan =
.expect("origin plan"); plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60"); let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("content-length", HeaderValue::from_static("7")); transformed_headers.insert("content-length", HeaderValue::from_static("7"));
let transformed_plan = plan( let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan"); .expect("transformed plan");
let cache_plan = origin_plan let cache_plan = origin_plan
.without_vary("accept-encoding") .without_vary("accept-encoding")
@@ -962,20 +1005,12 @@ mod tests {
dark.insert("x-theme", HeaderValue::from_static("dark")); dark.insert("x-theme", HeaderValue::from_static("dark"));
let mut origin_headers = headers("public, max-age=60"); let mut origin_headers = headers("public, max-age=60");
origin_headers.insert( origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme"));
"vary", let origin_plan =
HeaderValue::from_static("Accept-Encoding, X-Theme"), plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
);
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
let mut transformed_headers = headers("public, max-age=60"); let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("vary", HeaderValue::from_static("X-Theme")); transformed_headers.insert("vary", HeaderValue::from_static("X-Theme"));
let transformed_plan = plan( let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan"); .expect("transformed plan");
let cache_plan = origin_plan let cache_plan = origin_plan
.without_vary("accept-encoding") .without_vary("accept-encoding")
@@ -1067,11 +1102,20 @@ mod tests {
let entry = store.entries.values_mut().next().expect("stored object"); let entry = store.entries.values_mut().next().expect("stored object");
entry.received = Instant::now() - Duration::from_secs(2); entry.received = Instant::now() - Duration::from_secs(2);
} }
assert!(cache.get(&initial).is_none(), "normal request must not receive stale data"); assert!(
assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object"); cache.get(&initial).is_none(),
"normal request must not receive stale data"
);
assert_eq!(
cache.view().entries,
1,
"freshness expiry must not physically evict the object"
);
let stale = ticket(&cache, &headers("max-stale")); let stale = ticket(&cache, &headers("max-stale"));
let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data"); let hit = cache
.get(&stale)
.expect("explicit max-stale may reuse retained stale data");
assert!(hit.stale); assert!(hit.stale);
assert_eq!( assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(), hit.response.into_body().collect().await.unwrap().to_bytes(),
@@ -1107,7 +1151,14 @@ mod tests {
validation.insert("etag", HeaderValue::from_static("\"origin\"")); validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation); let merged = candidate.merge_validation_headers(&validation);
let hit = cache let hit = cache
.publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO) .publish_revalidated(
t.clone(),
&candidate,
merged,
&validation,
&s,
Duration::ZERO,
)
.expect("validated body can be delivered"); .expect("validated body can be delivered");
assert_eq!( assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(), hit.response.into_body().collect().await.unwrap().to_bytes(),
@@ -1150,7 +1201,14 @@ mod tests {
validation.insert("etag", HeaderValue::from_static("\"origin\"")); validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation); let merged = candidate.merge_validation_headers(&validation);
let hit = cache let hit = cache
.publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2)) .publish_revalidated(
t,
&candidate,
merged,
&validation,
&s,
Duration::from_secs(2),
)
.expect("validated object"); .expect("validated object");
assert!(hit.resident_age_seconds >= 32); assert!(hit.resident_age_seconds >= 32);
} }
@@ -1262,7 +1320,10 @@ mod tests {
fn aged_upstream_freshness_caps_remaining_not_total_age() { fn aged_upstream_freshness_caps_remaining_not_total_age() {
let settings = settings(); let settings = settings();
let mut headers = headers("public, max-age=2592000"); let mut headers = headers("public, max-age=2592000");
headers.insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap()); headers.insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
headers.insert("age", HeaderValue::from_static("1553483")); headers.insert("age", HeaderValue::from_static("1553483"));
headers.insert("etag", HeaderValue::from_static("\"origin-etag\"")); headers.insert("etag", HeaderValue::from_static("\"origin-etag\""));
let original = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap(); let original = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
@@ -1270,20 +1331,29 @@ mod tests {
assert_eq!(original.remaining_ttl(), settings.max_ttl_seconds); assert_eq!(original.remaining_ttl(), settings.max_ttl_seconds);
assert_eq!(original.ttl, 1553483 + settings.max_ttl_seconds); assert_eq!(original.ttl, 1553483 + settings.max_ttl_seconds);
// Local cap never overrides the remaining origin lifetime.
headers.insert("age", HeaderValue::from_static("2591990")); headers.insert("age", HeaderValue::from_static("2591990"));
let almost_expired = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap(); let almost_expired = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
assert_eq!(almost_expired.remaining_ttl(), 10); assert_eq!(almost_expired.remaining_ttl(), 10);
headers.insert("age", HeaderValue::from_static("2592010")); headers.insert("age", HeaderValue::from_static("2592010"));
assert_eq!( assert_eq!(
plan(StatusCode::OK, &headers, &settings, Duration::ZERO) plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap().remaining_ttl(), 0 .unwrap()
.remaining_ttl(),
0
);
headers.insert(
"cache-control",
HeaderValue::from_static("public, no-cache, max-age=2592000"),
);
assert!(
plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap()
.requires_revalidation
);
headers.insert(
"cache-control",
HeaderValue::from_static("private, max-age=2592000"),
); );
// No-cache and private/no-store restrictions must not be relaxed.
headers.insert("cache-control", HeaderValue::from_static("public, no-cache, max-age=2592000"));
assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap().requires_revalidation);
headers.insert("cache-control", HeaderValue::from_static("private, max-age=2592000"));
assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO).is_none()); assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO).is_none());
} }
@@ -1291,8 +1361,12 @@ mod tests {
fn age_uses_rfc9111_max_of_apparent_and_corrected_age() { fn age_uses_rfc9111_max_of_apparent_and_corrected_age() {
let s = settings(); let s = settings();
let mut h = headers("public, max-age=3600"); let mut h = headers("public, max-age=3600");
h.insert("date", httpdate::fmt_http_date(SystemTime::now() h.insert(
- Duration::from_secs(10)).parse().unwrap()); "date",
httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10))
.parse()
.unwrap(),
);
h.insert("age", HeaderValue::from_static("3")); h.insert("age", HeaderValue::from_static("3"));
let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap(); let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap();
assert!(p.initial_age < Duration::from_secs(12)); assert!(p.initial_age < Duration::from_secs(12));
@@ -1310,30 +1384,76 @@ mod tests {
let original_body = b"body { color: red; margin: 0px; }"; let original_body = b"body { color: red; margin: 0px; }";
let mut upstream = Response::new(Body::from(Bytes::from_static(original_body))); let mut upstream = Response::new(Body::from(Bytes::from_static(original_body)));
*upstream.headers_mut() = headers("public, max-age=2592000"); *upstream.headers_mut() = headers("public, max-age=2592000");
upstream.headers_mut().insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap()); upstream.headers_mut().insert(
upstream.headers_mut().insert("age", HeaderValue::from_static("1553483")); "date",
upstream.headers_mut().insert("content-type", HeaderValue::from_static("text/css")); httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
upstream.headers_mut().insert("content-length", );
HeaderValue::from_str(&original_body.len().to_string()).unwrap()); upstream
upstream.headers_mut().insert("etag", HeaderValue::from_static("\"upstream\"")); .headers_mut()
let origin_plan = plan(StatusCode::OK, upstream.headers(), &settings, Duration::ZERO).unwrap(); .insert("age", HeaderValue::from_static("1553483"));
upstream
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
upstream.headers_mut().insert(
"content-length",
HeaderValue::from_str(&original_body.len().to_string()).unwrap(),
);
upstream
.headers_mut()
.insert("etag", HeaderValue::from_static("\"upstream\""));
let origin_plan = plan(
StatusCode::OK,
upstream.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let transformed = crate::transform::apply( let transformed = crate::transform::apply(
upstream, "/theme.css", &crate::transform::Settings { upstream,
enabled: true, css: true, ..Default::default() "/theme.css",
}, false &crate::transform::Settings {
).await.unwrap(); enabled: true,
assert!(transformed.extensions().get::<crate::transform::Applied>().is_some()); css: true,
..Default::default()
},
false,
)
.await
.unwrap();
assert!(transformed
.extensions()
.get::<crate::transform::Applied>()
.is_some());
assert_eq!(transformed.headers()["age"], "1553483"); assert_eq!(transformed.headers()["age"], "1553483");
let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings, let transformed_plan = plan(
Duration::ZERO).unwrap(); StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let combined = origin_plan.restrict(transformed_plan); let combined = origin_plan.restrict(transformed_plan);
assert!(combined.remaining_ttl() > 86_000); assert!(combined.remaining_ttl() > 86_000);
let first = cache.wrap(transformed, ticket.clone(), combined, &settings) let first = cache
.into_body().collect().await.unwrap().to_bytes(); .wrap(transformed, ticket.clone(), combined, &settings)
let hit = cache.get(&ticket).expect("aged, fresh CSS should be cached after transform"); .into_body()
.collect()
.await
.unwrap()
.to_bytes();
let hit = cache
.get(&ticket)
.expect("aged, fresh CSS should be cached after transform");
assert!(hit.transformed); assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000); assert!(hit.remaining_ttl >= 86_000);
assert!(hit.response.headers()["age"].to_str().unwrap().parse::<u64>().unwrap() >= 1553483); assert!(
hit.response.headers()["age"]
.to_str()
.unwrap()
.parse::<u64>()
.unwrap()
>= 1553483
);
let second = hit.response.into_body().collect().await.unwrap().to_bytes(); let second = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(first, second); assert_eq!(first, second);
let dash = cache.dashboard(None, 0, 10); let dash = cache.dashboard(None, 0, 10);
@@ -1379,18 +1499,34 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn hot_hit_updates_lru_without_replacing_the_entry_or_losing_hit_counter() { async fn hot_hit_updates_lru_without_replacing_the_entry_or_losing_hit_counter() {
let cache = Cache::new(1); let cache = Cache::new(1);
let settings = Settings { max_entries: 2, ..settings() }; let settings = Settings {
max_entries: 2,
..settings()
};
let h = HeaderMap::new(); let h = HeaderMap::new();
let a = cache.ticket(1, "http", "example.test", "app", "/a", &h).unwrap(); let a = cache
let b = cache.ticket(1, "http", "example.test", "app", "/b", &h).unwrap(); .ticket(1, "http", "example.test", "app", "/a", &h)
let c = cache.ticket(1, "http", "example.test", "app", "/c", &h).unwrap(); .unwrap();
let b = cache
.ticket(1, "http", "example.test", "app", "/b", &h)
.unwrap();
let c = cache
.ticket(1, "http", "example.test", "app", "/c", &h)
.unwrap();
fill(&cache, a.clone(), "payload-a", &settings).await; fill(&cache, a.clone(), "payload-a", &settings).await;
fill(&cache, b.clone(), "payload-b", &settings).await; fill(&cache, b.clone(), "payload-b", &settings).await;
assert_eq!(cache.get(&a).unwrap().hits, 1); assert_eq!(cache.get(&a).unwrap().hits, 1);
assert_eq!(cache.get(&a).unwrap().hits, 2); assert_eq!(cache.get(&a).unwrap().hits, 2);
fill(&cache, c.clone(), "payload-c", &settings).await; fill(&cache, c.clone(), "payload-c", &settings).await;
assert!(cache.get(&b).is_none(), "the least recently used object is evicted"); assert!(
assert_eq!(cache.get(&a).unwrap().hits, 3, "HOT hits do not reset on LRU refresh"); cache.get(&b).is_none(),
"the least recently used object is evicted"
);
assert_eq!(
cache.get(&a).unwrap().hits,
3,
"HOT hits do not reset on LRU refresh"
);
assert_eq!(cache.get(&c).unwrap().hits, 1); assert_eq!(cache.get(&c).unwrap().hits, 1);
assert_eq!(cache.view().evictions, 1); assert_eq!(cache.view().evictions, 1);
assert_eq!(cache.view().entries, 2); assert_eq!(cache.view().entries, 2);
@@ -1423,7 +1559,10 @@ mod tests {
assert!(after.hot_allocated_bytes <= s.max_bytes); assert!(after.hot_allocated_bytes <= s.max_bytes);
assert_eq!(after.fill_bytes_active, 0); assert_eq!(after.fill_bytes_active, 0);
assert_eq!(after.transient_bytes, 0); assert_eq!(after.transient_bytes, 0);
assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation"); assert!(
cache.get(&first).is_none(),
"LRU entry should make room before the next fill allocation"
);
assert!(cache.get(&second).is_some()); assert!(cache.get(&second).is_some());
assert!(after.evictions >= 1); assert!(after.evictions >= 1);
} }
@@ -1477,8 +1616,6 @@ mod tests {
&a, &a,
) )
.unwrap(); .unwrap();
// Compare unrelated headers/member choice within the same frontend
// scheme. HTTP and HTTPS intentionally have distinct cache identities.
let other_member = cache let other_member = cache
.ticket( .ticket(
1, 1,
@@ -1559,12 +1696,17 @@ mod tests {
assert_eq!(first, Bytes::from_static(b"first-")); assert_eq!(first, Bytes::from_static(b"first-"));
drop(body); drop(body);
assert!(cache.get(&t).is_none(), "partial body must never become a HIT"); assert!(
cache.get(&t).is_none(),
"partial body must never become a HIT"
);
assert_eq!(cache.view().fills_active, 0); assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 1); assert_eq!(cache.view().rejected_fills, 1);
fill(&cache, t.clone(), "complete", &s).await; fill(&cache, t.clone(), "complete", &s).await;
let hit = cache.get(&t).expect("a later complete GET can fill the cache"); let hit = cache
.get(&t)
.expect("a later complete GET can fill the cache");
assert_eq!( assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(), hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"complete") Bytes::from_static(b"complete")
@@ -1604,11 +1746,18 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new()); let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty()); let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600"); *response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("0")); response
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); .headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let response = cache.wrap(response, ticket.clone(), plan, &settings); let response = cache.wrap(response, ticket.clone(), plan, &settings);
assert!(response.body().is_end_stream()); assert!(response.body().is_end_stream());
// HTTP/1.1 Content-Length: 0 does not ask the response body for frames.
drop(response); drop(response);
assert!(cache.get(&ticket).is_some()); assert!(cache.get(&ticket).is_some());
assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().entries, 1);
@@ -1623,8 +1772,16 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new()); let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty()); let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600"); *response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("6")); response
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); .headers_mut()
.insert("content-length", HeaderValue::from_static("6"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
drop(cache.wrap(response, ticket.clone(), plan, &settings)); drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none()); assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().fills_active, 0); assert_eq!(cache.view().fills_active, 0);
@@ -1638,8 +1795,16 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new()); let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty()); let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600"); *response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("0")); response
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); .headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache.purge(None); cache.purge(None);
drop(cache.wrap(response, ticket.clone(), plan, &settings)); drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none()); assert!(cache.get(&ticket).is_none());
-8
View File
@@ -14,10 +14,6 @@ impl Cache {
} }
} }
} }
// 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() { if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() {
tokio::spawn(async move { tokio::spawn(async move {
let _permit = permit; let _permit = permit;
@@ -51,8 +47,6 @@ impl Cache {
let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1)); let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1));
let mut after_sequence = 0u64; let mut after_sequence = 0u64;
// Copy candidate metadata in bounded chunks. Request-path lookups only
// contend with this mutex for a small snapshot, never for sorting or I/O.
loop { loop {
let batch: Vec<_> = { let batch: Vec<_> = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
@@ -152,8 +146,6 @@ impl Cache {
} }
} }
} }
// Keep pending deletion metadata bounded to one selection batch; a
// 10k-entry manual cleanup must not build one large Vec<PathBuf>.
self.enqueue_cold_delete(std::mem::take(&mut delete)); self.enqueue_cold_delete(std::mem::take(&mut delete));
tokio::task::yield_now().await; tokio::task::yield_now().await;
} }
+955
View File
@@ -0,0 +1,955 @@
mod cold_tier_tests {
use super::*;
use http_body_util::BodyExt;
fn cold_settings() -> Settings {
Settings {
enabled: true,
max_entries: 1,
max_bytes: 256 * 1024,
max_object_bytes: 64 * 1024,
cold_enabled: true,
cold_path: "cache/cold-test".into(),
cold_max_bytes: 2 * 1024 * 1024,
cold_max_entries: 16,
cold_after_seconds: 300,
..Settings::default()
}
}
async fn fill(
cache: &Arc<Cache>,
settings: &Settings,
path: &str,
body: &'static str,
) -> Ticket {
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
cache
.wrap(response, ticket.clone(), plan, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
async fn wait_for_cold(cache: &Cache, expected: usize) {
for _ in 0..100 {
if cache.view().cold_entries >= expected {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
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.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().cold_entries, 1);
let result = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Purge,
selector: InvalidationSelector::Exact,
value: "/one?v=1".into(),
host: Some("cold.example".into()),
vhost: Some("cold-test".into()),
ttl_seconds: None,
confirm_wide: false,
})
.unwrap();
assert_eq!(result.affected_entries, 1);
assert_eq!(cache.view().cold_entries, 0);
assert!(cache.get_tiered(&first, &settings).await.is_none());
shutdown.cancel();
}
#[tokio::test]
async fn aged_transformed_css_remains_fresh_when_demoted_to_cold() {
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.clone(),
shutdown.clone(),
);
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/aged-theme.css",
&HeaderMap::new(),
)
.unwrap();
let body = b"body { color: red; margin: 0px; }";
let mut origin = Response::new(Body::from(Bytes::from_static(body)));
origin.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=2592000"),
);
origin
.headers_mut()
.insert("age", HeaderValue::from_static("1553483"));
origin.headers_mut().insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
origin
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
origin.headers_mut().insert(
"content-length",
HeaderValue::from_str(&body.len().to_string()).unwrap(),
);
origin
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan =
plan(StatusCode::OK, origin.headers(), &settings, Duration::ZERO).unwrap();
let transformed = crate::transform::apply(
origin,
"/aged-theme.css",
&crate::transform::Settings {
enabled: true,
css: true,
..Default::default()
},
false,
)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
ticket.clone(),
origin_plan.restrict(transformed_plan),
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.view().entries, 1);
let _another = fill(&cache, &settings, "/another", "another-payload").await;
wait_for_cold(&cache, 1).await;
let row = cache
.dashboard(Some("cold-test"), 0, 20)
.objects
.into_iter()
.find(|v| v.path == "/aged-theme.css")
.unwrap();
assert_eq!(row.tier, "cold");
assert!(row.transformed);
assert!(row.remaining_ttl_seconds >= 86_000);
shutdown.cancel();
drop(cache);
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let hit = cache
.get_tiered(&ticket, &settings)
.await
.expect("aged transformed COLD should HIT after restart");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
let transformed_body = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_ne!(transformed_body.as_ref(), &body[..]);
shutdown.cancel();
}
#[tokio::test]
async fn lru_demotion_persists_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
settings.validate().unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 1);
let cold = cache.dashboard(Some("cold-test"), 0, 50);
assert!(cold
.objects
.iter()
.any(|row| row.path == "/one" && row.tier == "cold"));
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 1);
let hit = restarted
.get_tiered(&first, &settings)
.await
.expect("cold object should be readable after restart");
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(payload, Bytes::from_static(b"first-payload"));
assert_eq!(restarted.view().cold_hits, 1);
assert_eq!(restarted.view().cold_promotions, 1);
restart_shutdown.cancel();
}
#[tokio::test]
async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() {
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.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
{
let mut cold = cache.cold.lock().unwrap();
for entry in cold.entries.values_mut() {
entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
}
}
let dashboard = cache.dashboard(Some("cold-test"), 0, 50);
assert_eq!(dashboard.matching_objects, 2);
let row = dashboard
.objects
.iter()
.find(|row| row.path == "/one")
.unwrap();
assert_eq!(row.tier, "cold");
assert_eq!(row.remaining_ttl_seconds, 0);
assert!(row.age_seconds >= 2 * 60 * 60);
assert_eq!(row.storage_bytes, cache.view().cold_bytes);
assert!(cache.get_tiered(&first, &settings).await.is_none());
let mut stale = first;
stale
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
let hit = cache
.get_tiered(&stale, &settings)
.await
.expect("explicit max-stale can reuse the retained disk object");
assert!(hit.stale);
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
"first-payload"
);
shutdown.cancel();
}
#[tokio::test]
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let transformed_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/transformed.json",
&HeaderMap::new(),
)
.unwrap();
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
transformed.headers_mut().insert(
"cache-control",
HeaderValue::from_static(
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed.headers_mut().insert(
"etag",
HeaderValue::from_static("\"rustproxy-representation\""),
);
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
transformed
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
});
transformed
.extensions_mut()
.insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
transformed_ticket.clone(),
transformed_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
let protected_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/protected.css",
&HeaderMap::new(),
)
.unwrap();
let original_body = "body { color: red; }";
let mut protected = Response::new(Body::from(original_body));
protected.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
);
protected
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-css\""));
protected.extensions_mut().insert(crate::compression::Skip);
let protected_plan = plan(
StatusCode::OK,
protected.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
protected,
protected_ticket.clone(),
protected_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.flush_hot_to_cold().await, 2);
wait_for_cold(&cache, 2).await;
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
let (key, transformed) = {
let cold = restarted.cold.lock().unwrap();
cold.entries
.iter()
.find(|(_, entry)| entry.path == "/transformed.json")
.map(|(key, entry)| (*key, entry.clone()))
.expect("transformed cold entry")
};
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert!(transformed.has_validator);
assert!(transformed.transformed);
let metadata = read_cold_metadata(&key, &transformed)
.await
.expect("persisted transformed metadata");
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
metadata.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
let transformed_hit = restarted
.get_tiered(&transformed_ticket, &settings)
.await
.expect("transformed object after restart");
assert_eq!(
transformed_hit.response.headers()["etag"],
"\"rustproxy-representation\""
);
assert!(transformed_hit.transformed);
assert!(transformed_hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(
transformed_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(br#"{"a":1}"#)
);
let protected_hit = restarted
.get_tiered(&protected_ticket, &settings)
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(
protected_hit.response.headers()
));
assert!(protected_hit
.response
.extensions()
.get::<crate::compression::Skip>()
.is_some());
assert_eq!(
protected_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"body { color: red; }")
);
restart_shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_lru_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.cold_on_lru = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
tokio::time::sleep(Duration::from_millis(30)).await;
let view = cache.view();
assert_eq!(view.entries, 1);
assert_eq!(view.cold_entries, 0);
assert_eq!(view.evictions, 1);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_idle_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/idle", "idle-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.last_access = Instant::now() - Duration::from_secs(600);
}
}
cache.demote_idle();
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
settings.cold_on_idle = true;
cache.configure(settings.clone());
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_demote_by_total_hot_residency_age() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
settings.cold_on_age = true;
settings.cold_max_hot_seconds = 5;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/aged", "aged-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.resident_since = Instant::now() - Duration::from_secs(60);
entry.last_access = Instant::now();
}
}
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
assert_eq!(cache.view().entries, 2);
assert!(cache.view().resident_bytes > 0);
assert_eq!(cache.view().cold_entries, 0);
assert_eq!(cache.flush_hot_to_cold().await, 2);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(
view.transient_bytes, 0,
"persisted ColdEntry must not retain the body allocation"
);
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
restart_shutdown.cancel();
}
#[tokio::test]
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/update", "update-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_can_be_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_cache_uses_two_hex_character_shards() {
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.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let root = dir.path().join(&settings.cold_path);
let mut shards = Vec::new();
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(
item.path().is_dir(),
"cold root must contain shard directories only"
);
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard)
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
}));
shutdown.cancel();
}
#[tokio::test]
async fn legacy_flat_cold_cache_is_not_migrated() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let root = dir.path().join(&settings.cold_path);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
shutdown.cancel();
}
#[tokio::test]
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache
.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
})
.unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
assert_eq!(current.removed_entries, 1);
assert!(current.freed_bytes > 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
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();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let key = {
let mut cold = cache.cold.lock().unwrap();
let key = *cold.entries.keys().next().unwrap();
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
key
};
let request = ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
};
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache
.cold
.lock()
.unwrap()
.entries
.get_mut(&key)
.unwrap()
.pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 1);
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
shutdown.cancel();
}
#[test]
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
let cache = Cache::new(1);
let key = [7_u8; 32];
let template = ColdEntry {
base_key: key,
vary: Vec::new(),
revision: 1,
scope: "app".to_owned(),
scheme: "https".to_owned(),
authority: "example.test".to_owned(),
domain: "example.test".to_owned(),
path: "/asset".to_owned(),
target: "https://example.test/asset".to_owned(),
tags: Vec::new(),
created_at: SystemTime::now(),
body_path: PathBuf::from("old.body"),
meta_path: PathBuf::from("old.json"),
body_bytes: 1,
body_sha256: [0u8; 32],
disk_bytes: 2,
stored_at: SystemTime::now(),
last_access: SystemTime::now(),
age_at_store_seconds: 0,
resident_age_at_store_seconds: 0,
ttl: 60,
stale_while_revalidate: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
has_validator: false,
hits: 0,
transformed: false,
sequence: 1,
generation: 1,
skip_compression: false,
requires_revalidation: false,
pinned_reads: 1,
};
cache
.cold
.lock()
.unwrap()
.entries
.insert(key, template.clone());
let old_guard = ColdReadGuard {
cache: &cache,
key,
generation: 1,
};
let mut replacement = template;
replacement.sequence = 2;
replacement.generation = 2;
replacement.pinned_reads = 1;
cache.cold.lock().unwrap().entries.insert(key, replacement);
drop(old_guard);
let cold = cache.cold.lock().unwrap();
let replacement = cold.entries.get(&key).unwrap();
assert_eq!(replacement.generation, 2);
assert_eq!(replacement.pinned_reads, 1);
}
#[test]
fn cold_path_cannot_escape_data_directory() {
let mut settings = Settings::default();
settings.cold_path = "../outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "/tmp/outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "cache/cold".into();
assert!(settings.validate().is_ok());
}
#[tokio::test]
async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let mut fingerprints = HashMap::new();
fingerprints.insert("cold-test".to_owned(), 77);
let cache = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
fingerprints.clone(),
);
fill(&cache, &settings, "/one", "stable-contents").await;
fill(&cache, &settings, "/two", "second-contents").await;
wait_for_cold(&cache, 1).await;
shutdown.cancel();
drop(cache);
let restart = CancellationToken::new();
let same = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart.clone(),
fingerprints.clone(),
);
assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD");
restart.cancel();
drop(same);
fingerprints.insert("cold-test".to_owned(), 78);
let restart = CancellationToken::new();
let changed = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart.clone(),
fingerprints,
);
assert_eq!(
changed.view().cold_entries,
0,
"old COLD must never survive a content revision"
);
restart.cancel();
}
}
+14 -12
View File
@@ -51,9 +51,13 @@ impl Cache {
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
return false; return false;
}; };
let scope_revision = self.scope_revisions let scope_revision = self
.lock().unwrap_or_else(|e| e.into_inner()) .scope_revisions
.get(&entry.scope).copied().unwrap_or(0); .lock()
.unwrap_or_else(|e| e.into_inner())
.get(&entry.scope)
.copied()
.unwrap_or(0);
self.cold_queue_items.fetch_add(1, Ordering::Relaxed); self.cold_queue_items.fetch_add(1, Ordering::Relaxed);
if tx if tx
.try_send(ColdCommand::Store(ColdDraft { .try_send(ColdCommand::Store(ColdDraft {
@@ -138,9 +142,6 @@ impl Cache {
drop(store); drop(store);
release_allocator_memory(); release_allocator_memory();
} }
/// Best-effort graceful shutdown handoff from RAM to the persistent cold
/// tier. The cold worker shares the process shutdown token, so this path
/// writes directly instead of depending on the background queue.
async fn flush_hot_to_cold_inner(&self, force_shutdown_policy: bool) -> usize { async fn flush_hot_to_cold_inner(&self, force_shutdown_policy: bool) -> usize {
let settings = self let settings = self
.tier_settings .tier_settings
@@ -190,9 +191,13 @@ impl Cache {
}), }),
} }
}); });
let scope_revision = self.scope_revisions let scope_revision = self
.lock().unwrap_or_else(|e| e.into_inner()) .scope_revisions
.get(&entry.scope).copied().unwrap_or(0); .lock()
.unwrap_or_else(|e| e.into_inner())
.get(&entry.scope)
.copied()
.unwrap_or(0);
ColdDraft { ColdDraft {
key, key,
revision, revision,
@@ -215,9 +220,6 @@ impl Cache {
pub async fn flush_hot_to_cold(&self) -> usize { pub async fn flush_hot_to_cold(&self) -> usize {
self.flush_hot_to_cold_inner(false).await self.flush_hot_to_cold_inner(false).await
} }
/// Managed software updates explicitly request a hot-to-cold handoff. The
/// cold tier must still be enabled, but the ordinary cold_on_shutdown toggle
/// does not disable update preservation.
pub async fn flush_hot_to_cold_for_update(&self) -> usize { pub async fn flush_hot_to_cold_for_update(&self) -> usize {
self.flush_hot_to_cold_inner(true).await self.flush_hot_to_cold_inner(true).await
} }
-2
View File
@@ -27,8 +27,6 @@ impl Cache {
return; return;
}; };
// v0.21 intentionally does not migrate the legacy flat cold-cache layout.
// Remove only known cache artifacts from the old root and rebuild naturally.
let mut shard_dirs = Vec::new(); let mut shard_dirs = Vec::new();
for item in read_dir.flatten() { for item in read_dir.flatten() {
let path = item.path(); let path = item.path();
-5
View File
@@ -29,9 +29,6 @@ impl Cache {
loop { loop {
let command = tokio::select! { let command = tokio::select! {
_ = shutdown.cancelled() => { _ = shutdown.cancelled() => {
// The listener shutdown token is shared with the cache. Drain
// already accepted demotions before exiting so a restart does
// not discard buffers that were already handed to cold storage.
rx.close(); rx.close();
while let Ok(command) = rx.try_recv() { while let Ok(command) = rx.try_recv() {
let Some(cache) = weak.upgrade() else { return; }; let Some(cache) = weak.upgrade() else { return; };
@@ -58,8 +55,6 @@ impl Cache {
self.cold_write_bytes_active self.cold_write_bytes_active
.fetch_sub(bytes, Ordering::Relaxed); .fetch_sub(bytes, Ordering::Relaxed);
self.cold_gc_pass().await; self.cold_gc_pass().await;
// The draft body is out of scope before trimming. `Bytes` has released its
// reference and glibc is explicitly asked to return releasable pages.
release_allocator_memory(); release_allocator_memory();
} }
async fn persist_cold_inner(&self, draft: ColdDraft) -> bool { async fn persist_cold_inner(&self, draft: ColdDraft) -> bool {
+10 -34
View File
@@ -36,10 +36,7 @@ impl Drop for CollapseWaiterGuard {
impl Drop for FillLeaderGuard { impl Drop for FillLeaderGuard {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() { if let Some(cache) = self.cache.upgrade() {
let mut flights = cache let mut flights = cache.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let current = flights let current = flights
.get(&self.key) .get(&self.key)
.is_some_and(|flight| flight.id == self.id); .is_some_and(|flight| flight.id == self.id);
@@ -47,25 +44,17 @@ impl Drop for FillLeaderGuard {
flights.remove(&self.key); flights.remove(&self.key);
} }
} }
// Publish completion even when a purge already removed this flight from the map.
self.done.send_replace(true); self.done.send_replace(true);
} }
} }
impl Cache { impl Cache {
pub async fn claim_fill( pub async fn claim_fill(self: &Arc<Self>, ticket: &Ticket, settings: &Settings) -> FillClaim {
self: &Arc<Self>,
ticket: &Ticket,
settings: &Settings,
) -> FillClaim {
if !settings.collapse_enabled { if !settings.collapse_enabled {
return FillClaim::Disabled; return FillClaim::Disabled;
} }
let existing = { let existing = {
let mut flights = self let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(flight) = flights.get(&ticket.key) { if let Some(flight) = flights.get(&ticket.key) {
Some(flight.clone()) Some(flight.clone())
} else { } else {
@@ -96,8 +85,6 @@ impl Cache {
let Some(flight) = existing else { let Some(flight) = existing else {
unreachable!("existing fill resolved above") unreachable!("existing fill resolved above")
}; };
// A watch channel retains the completed state, so a leader finishing between
// map lookup and follower polling cannot lose the wakeup.
let mut done = flight.done.subscribe(); let mut done = flight.done.subscribe();
self.collapse_waiters.fetch_add(1, Ordering::Relaxed); self.collapse_waiters.fetch_add(1, Ordering::Relaxed);
let _waiter = CollapseWaiterGuard { let _waiter = CollapseWaiterGuard {
@@ -131,11 +118,11 @@ impl Cache {
fn cancel_all_fill_flights(&self) { fn cancel_all_fill_flights(&self) {
let flights = { let flights = {
let mut flights = self let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
.fill_flights flights
.lock() .drain()
.unwrap_or_else(|e| e.into_inner()); .map(|(_, flight)| flight)
flights.drain().map(|(_, flight)| flight).collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
for flight in flights { for flight in flights {
flight.done.send_replace(true); flight.done.send_replace(true);
@@ -144,10 +131,7 @@ impl Cache {
fn cancel_scope_fill_flights(&self, scope: &str) { fn cancel_scope_fill_flights(&self, scope: &str) {
let removed = { let removed = {
let mut flights = self let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let keys = flights let keys = flights
.iter() .iter()
.filter(|(_, flight)| flight.scope == scope) .filter(|(_, flight)| flight.scope == scope)
@@ -210,9 +194,6 @@ impl Cache {
.proactive_scheduled_keys .proactive_scheduled_keys
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()); .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 let pending_limit = settings
.proactive_refresh_max_inflight .proactive_refresh_max_inflight
.saturating_mul(64) .saturating_mul(64)
@@ -250,12 +231,7 @@ impl Cache {
} }
if self if self
.proactive_inflight .proactive_inflight
.compare_exchange_weak( .compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Relaxed)
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
)
.is_ok() .is_ok()
{ {
self.counters self.counters
+21 -10
View File
@@ -99,14 +99,22 @@ impl Cache {
("bans_removed_total", "counter", view.bans_removed), ("bans_removed_total", "counter", view.bans_removed),
("ban_matches_total", "counter", view.ban_matches), ("ban_matches_total", "counter", view.ban_matches),
("active_bans", "gauge", view.active_bans as u64), ("active_bans", "gauge", view.active_bans as u64),
("proactive_scheduled_total", "counter", view.proactive_scheduled), (
"proactive_scheduled_total",
"counter",
view.proactive_scheduled,
),
("proactive_started_total", "counter", view.proactive_started), ("proactive_started_total", "counter", view.proactive_started),
("proactive_skipped_total", "counter", view.proactive_skipped), ("proactive_skipped_total", "counter", view.proactive_skipped),
("proactive_inflight", "gauge", view.proactive_inflight), ("proactive_inflight", "gauge", view.proactive_inflight),
("entries", "gauge", view.entries as u64), ("entries", "gauge", view.entries as u64),
("resident_bytes", "gauge", view.resident_bytes as u64), ("resident_bytes", "gauge", view.resident_bytes as u64),
("hot_logical_bytes", "gauge", view.hot_logical_bytes as u64), ("hot_logical_bytes", "gauge", view.hot_logical_bytes as u64),
("hot_allocated_bytes", "gauge", view.hot_allocated_bytes as u64), (
"hot_allocated_bytes",
"gauge",
view.hot_allocated_bytes as u64,
),
("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64), ("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64),
( (
"transient_limit_bytes", "transient_limit_bytes",
@@ -121,7 +129,11 @@ impl Cache {
("cold_entries", "gauge", view.cold_entries as u64), ("cold_entries", "gauge", view.cold_entries as u64),
("cold_bytes", "gauge", view.cold_bytes), ("cold_bytes", "gauge", view.cold_bytes),
("cold_limit_bytes", "gauge", view.cold_limit_bytes), ("cold_limit_bytes", "gauge", view.cold_limit_bytes),
("cold_limit_entries", "gauge", view.cold_limit_entries as u64), (
"cold_limit_entries",
"gauge",
view.cold_limit_entries as u64,
),
("cold_hits_total", "counter", view.cold_hits), ("cold_hits_total", "counter", view.cold_hits),
("cold_promotions_total", "counter", view.cold_promotions), ("cold_promotions_total", "counter", view.cold_promotions),
("hot_demotions_total", "counter", view.hot_demotions), ("hot_demotions_total", "counter", view.hot_demotions),
@@ -161,7 +173,6 @@ impl Cache {
pub fn bypass(&self) { pub fn bypass(&self) {
self.counters.bypasses.fetch_add(1, Ordering::Relaxed); self.counters.bypasses.fetch_add(1, Ordering::Relaxed);
} }
/// Clear all keys and cancel publication of older in-flight fills.
pub fn purge(&self, revision: Option<u64>) -> usize { pub fn purge(&self, revision: Option<u64>) -> usize {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let mut count = store.entries.len(); let mut count = store.entries.len();
@@ -198,11 +209,11 @@ impl Cache {
let _ = tx.try_send(ColdCommand::PurgeDirectory(root)); let _ = tx.try_send(ColdCommand::PurgeDirectory(root));
} }
self.counters.purges.fetch_add(1, Ordering::Relaxed); self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed); self.counters
.purge_entries
.fetch_add(count as u64, Ordering::Relaxed);
count count
} }
/// Purge one vhost without evicting other hosts. Generation bumps also stop
/// an already streaming MISS from repopulating the just-purged scope.
pub fn purge_scope(&self, scope: &str) -> usize { pub fn purge_scope(&self, scope: &str) -> usize {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let keys: Vec<_> = store let keys: Vec<_> = store
@@ -239,7 +250,9 @@ impl Cache {
let _ = tx.try_send(ColdCommand::Delete(delete)); let _ = tx.try_send(ColdCommand::Delete(delete));
} }
self.counters.purges.fetch_add(1, Ordering::Relaxed); self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed); self.counters
.purge_entries
.fetch_add(count as u64, Ordering::Relaxed);
count count
} }
pub fn scope_view(&self, scope: &str) -> ScopeView { pub fn scope_view(&self, scope: &str) -> ScopeView {
@@ -303,8 +316,6 @@ impl Cache {
continue; continue;
}; };
let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs(); let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs();
// This is an inventory of retained objects, not a freshness lookup.
// Expired representations still occupy RAM and may be revalidated.
if !scope.is_none_or(|s| s == e.scope) { if !scope.is_none_or(|s| s == e.scope) {
continue; continue;
} }
@@ -91,9 +91,14 @@ mod dashboard_tests {
assert!(dashboard.objects[0].age_seconds >= 2 * 60 * 60); assert!(dashboard.objects[0].age_seconds >= 2 * 60 * 60);
assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().expired, 0); assert_eq!(cache.view().expired, 0);
assert!(cache.get(&ticket).is_none(), "inventory visibility cannot permit unconditional stale delivery"); assert!(
cache.get(&ticket).is_none(),
"inventory visibility cannot permit unconditional stale delivery"
);
let mut stale = ticket; let mut stale = ticket;
stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale")); stale
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
assert!(cache.get(&stale).unwrap().stale); assert!(cache.get(&stale).unwrap().stale);
} }
#[test] #[test]
@@ -144,13 +149,22 @@ mod dashboard_tests {
}; };
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream")); headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream"));
headers.insert("cache-status", HeaderValue::from_static("untrusted-upstream")); headers.insert(
"cache-status",
HeaderValue::from_static("untrusted-upstream"),
);
global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy"); global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy");
global.annotate_transformed(&mut headers, true); global.annotate_transformed(&mut headers, true);
for name in ["x-cache", "x-cache-hits", "cache-status", "x-cache-reason", "x-cache-resident-age"] { for name in [
"x-cache",
"x-cache-hits",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
assert!(!headers.contains_key(name), "{name} leaked on MISS"); assert!(!headers.contains_key(name), "{name} leaked on MISS");
} }
assert_eq!(headers["via"], "RustProxy"); // independent proxy policy assert_eq!(headers["via"], "RustProxy");
global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie"); global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert!(!headers.contains_key("cache-status")); assert!(!headers.contains_key("cache-status"));
global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit"); global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit");
@@ -159,11 +173,19 @@ mod dashboard_tests {
assert_eq!(headers["x-cache-hits"], "4"); assert_eq!(headers["x-cache-hits"], "4");
assert!(headers["cache-status"].to_str().unwrap().contains("; hit;")); assert!(headers["cache-status"].to_str().unwrap().contains("; hit;"));
assert_eq!(headers["x-cache-resident-age"], "10"); assert_eq!(headers["x-cache-resident-age"], "10");
let mut override_on = Overrides { expose_only_hits: Some(false), ..Default::default() }; let mut override_on = Overrides {
expose_only_hits: Some(false),
..Default::default()
};
let normal = override_on.resolve(&global).unwrap(); let normal = override_on.resolve(&global).unwrap();
assert!(!normal.expose_only_hits); assert!(!normal.expose_only_hits);
override_on.expose_only_hits = Some(true); override_on.expose_only_hits = Some(true);
assert!(override_on.resolve(&Settings::default()).unwrap().expose_only_hits); assert!(
override_on
.resolve(&Settings::default())
.unwrap()
.expose_only_hits
);
} }
#[test] #[test]
fn via_header_is_configurable_and_preserves_upstream_chain() { fn via_header_is_configurable_and_preserves_upstream_chain() {
@@ -178,7 +200,10 @@ mod dashboard_tests {
.collect(); .collect();
assert_eq!(values, vec!["1.1 upstream", "RustProxy"]); assert_eq!(values, vec!["1.1 upstream", "RustProxy"]);
let disabled = Settings { via_header: None, ..Settings::default() }; let disabled = Settings {
via_header: None,
..Settings::default()
};
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
disabled.annotate(&mut headers, "MISS", 0, 0); disabled.annotate(&mut headers, "MISS", 0, 0);
assert!(!headers.contains_key("via")); assert!(!headers.contains_key("via"));
@@ -218,7 +243,6 @@ mod dashboard_tests {
#[test] #[test]
fn cache_domain_metrics_do_not_lock_hot_store_and_preserve_reasons() { fn cache_domain_metrics_do_not_lock_hot_store_and_preserve_reasons() {
let cache = Cache::new(7); let cache = Cache::new(7);
// A BYPASS must be recordable even while the HOT/LRU mutex is held.
let store = cache.store.lock().unwrap(); let store = cache.store.lock().unwrap();
cache.record("alpha", "a.example", "BYPASS", "cache_disabled"); cache.record("alpha", "a.example", "BYPASS", "cache_disabled");
cache.record_reason("alpha", "a.example", "background_refresh_failure"); cache.record_reason("alpha", "a.example", "background_refresh_failure");
@@ -247,7 +271,12 @@ mod dashboard_tests {
assert_eq!(counters.reasons["cache_hit"], workers * each); assert_eq!(counters.reasons["cache_hit"], workers * each);
assert_eq!(counters.reasons["background_refresh_failure"], 1); assert_eq!(counters.reasons["background_refresh_failure"], 1);
assert_eq!(cache.purge_scope("alpha"), 0); assert_eq!(cache.purge_scope("alpha"), 0);
assert_eq!(cache.dashboard(Some("alpha"), 0, 1).domains[0].counters.hits, workers * each); assert_eq!(
cache.dashboard(Some("alpha"), 0, 1).domains[0]
.counters
.hits,
workers * each
);
cache.purge(Some(8)); cache.purge(Some(8));
assert!(cache.dashboard(None, 0, 1).domains.is_empty()); assert!(cache.dashboard(None, 0, 1).domains.is_empty());
cache.record("alpha", "a.example", "MISS", "cache_miss"); cache.record("alpha", "a.example", "MISS", "cache_miss");
@@ -260,7 +289,9 @@ mod dashboard_tests {
#[test] #[test]
fn first_hit_saved_bytes_registers_domain_even_without_prior_record() { fn first_hit_saved_bytes_registers_domain_even_without_prior_record() {
let cache = Cache::new(7); let cache = Cache::new(7);
cache.domain_stats.saved_body_hit("cold", "restored.example", 1040); cache
.domain_stats
.saved_body_hit("cold", "restored.example", 1040);
let result = cache.dashboard(Some("cold"), 0, 1); let result = cache.dashboard(Some("cold"), 0, 1);
assert_eq!(result.domains.len(), 1); assert_eq!(result.domains.len(), 1);
assert_eq!(result.domains[0].counters.saved_body_bytes, 1040); assert_eq!(result.domains[0].counters.saved_body_bytes, 1040);
+15 -28
View File
@@ -63,8 +63,6 @@ struct Candidate {
plan: Plan, plan: Plan,
headers: HeaderMap, headers: HeaderMap,
data: Vec<u8>, data: Vec<u8>,
// A transformed response with one uniquely owned Bytes frame can share its
// allocation with the downstream response, avoiding an extra Vec copy.
shared: Option<Bytes>, shared: Option<Bytes>,
shared_allocated: usize, shared_allocated: usize,
fill_gauge: FillGauge, fill_gauge: FillGauge,
@@ -78,11 +76,9 @@ struct Candidate {
} }
impl Candidate { impl Candidate {
fn finish(mut self) { fn finish(mut self) {
if self if self.plan.expected_length.is_some_and(|length| {
.plan self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length
.expected_length }) {
.is_some_and(|length| self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length)
{
let reason = if self.transformed { let reason = if self.transformed {
"transform_length_mismatch" "transform_length_mismatch"
} else { } else {
@@ -101,7 +97,6 @@ impl Candidate {
self.headers.insert("date", value); self.headers.insert("date", value);
} }
} }
// Each downstream exchange receives a new ID, never a cached origin ID.
for name in [ for name in [
"x-request-id", "x-request-id",
"x-cache", "x-cache",
@@ -113,9 +108,6 @@ impl Candidate {
self.headers.remove(name); self.headers.remove(name);
} }
let vary = std::mem::take(&mut self.plan.vary); let vary = std::mem::take(&mut self.plan.vary);
// Keep the actual backing capacity as the HOT charge. Vec::reserve_exact
// avoids geometric multi-MiB overshoot while Bytes::from(Vec) transfers
// the allocation without copying.
let (body, body_allocation) = if let Some(body) = self.shared.take() { let (body, body_allocation) = if let Some(body) = self.shared.take() {
(body, self.shared_allocated) (body, self.shared_allocated)
} else { } else {
@@ -201,10 +193,6 @@ impl HttpBody for FillBody {
match Pin::new(&mut this.inner).poll_frame(cx) { match Pin::new(&mut this.inner).poll_frame(cx) {
Poll::Ready(Some(Ok(frame))) => { Poll::Ready(Some(Ok(frame))) => {
let mut frame = frame; let mut frame = frame;
// Only the transformed single-frame case is eligible. Taking
// unique ownership lets Bytes reveal its true allocation
// capacity; we must never undercharge an arbitrary shared
// Bytes slice that could retain a much larger parent buffer.
if this.inner.is_end_stream() { if this.inner.is_end_stream() {
if let Some(candidate) = &mut this.candidate { if let Some(candidate) = &mut this.candidate {
let single_frame = candidate.transformed let single_frame = candidate.transformed
@@ -216,17 +204,18 @@ impl HttpBody for FillBody {
}); });
if single_frame { if single_frame {
match frame.into_data() { match frame.into_data() {
Ok(bytes) => { Ok(bytes) => match bytes.try_into_mut() {
match bytes.try_into_mut() {
Ok(mutable) => { Ok(mutable) => {
let allocation = mutable.capacity(); let allocation = mutable.capacity();
let bytes = mutable.freeze(); let bytes = mutable.freeze();
// A very oversized backing Vec is cheaper to let acceptable_spare =
// copy into a smaller HOT allocation instead. bytes.len().saturating_div(8).min(64 * 1024);
let acceptable_spare = bytes.len().saturating_div(8).min(64 * 1024); if allocation
if allocation <= bytes.len().saturating_add(acceptable_spare) <= bytes.len().saturating_add(acceptable_spare)
&& allocation <= candidate.settings.max_object_bytes && allocation <= candidate.settings.max_object_bytes
&& candidate.fill_gauge.grow(allocation, &candidate.settings) && candidate
.fill_gauge
.grow(allocation, &candidate.settings)
{ {
candidate.shared = Some(bytes.clone()); candidate.shared = Some(bytes.clone());
candidate.shared_allocated = allocation; candidate.shared_allocated = allocation;
@@ -234,8 +223,7 @@ impl HttpBody for FillBody {
frame = Frame::data(bytes); frame = Frame::data(bytes);
} }
Err(bytes) => frame = Frame::data(bytes), Err(bytes) => frame = Frame::data(bytes),
} },
}
Err(original) => frame = original, Err(original) => frame = original,
} }
} }
@@ -243,7 +231,6 @@ impl HttpBody for FillBody {
} }
if let Some(candidate) = &mut this.candidate { if let Some(candidate) = &mut this.candidate {
if candidate.shared.is_some() { if candidate.shared.is_some() {
// The cache now owns the very same backing allocation.
} else if let Some(data) = frame.data_ref() { } else if let Some(data) = frame.data_ref() {
let needed = candidate.data.len().saturating_add(data.len()); let needed = candidate.data.len().saturating_add(data.len());
if needed <= candidate.settings.max_object_bytes { if needed <= candidate.settings.max_object_bytes {
@@ -284,7 +271,8 @@ impl HttpBody for FillBody {
this.candidate = None; this.candidate = None;
} else { } else {
let after = candidate.data.capacity(); let after = candidate.data.capacity();
let unbudgeted = after.saturating_sub(before.saturating_add(required_growth)); let unbudgeted =
after.saturating_sub(before.saturating_add(required_growth));
if unbudgeted > 0 if unbudgeted > 0
&& !candidate.fill_gauge.grow(unbudgeted, &candidate.settings) && !candidate.fill_gauge.grow(unbudgeted, &candidate.settings)
{ {
@@ -328,7 +316,7 @@ impl HttpBody for FillBody {
.rejected_fills .rejected_fills
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
this.candidate = None; this.candidate = None;
} // Do not cache responses with trailers. }
} }
if this.inner.is_end_stream() { if this.inner.is_end_stream() {
this.done = true; this.done = true;
@@ -391,4 +379,3 @@ impl Drop for FillBody {
} }
} }
} }
@@ -4,13 +4,20 @@ mod http_invalidation_tests {
fn default_deny_and_strict_ipv4_ipv6_cidr_validation() { fn default_deny_and_strict_ipv4_ipv6_cidr_validation() {
let mut cache = Settings::default(); let mut cache = Settings::default();
assert!(!cache.permits_http_invalidation("127.0.0.1".parse().unwrap())); assert!(!cache.permits_http_invalidation("127.0.0.1".parse().unwrap()));
cache.http_invalidation_allowed_cidrs = vec!["192.0.2.42".into(), "2001:db8:10::/64".into()]; cache.http_invalidation_allowed_cidrs =
vec!["192.0.2.42".into(), "2001:db8:10::/64".into()];
cache.validate().unwrap(); cache.validate().unwrap();
assert!(cache.permits_http_invalidation("192.0.2.42".parse().unwrap())); assert!(cache.permits_http_invalidation("192.0.2.42".parse().unwrap()));
assert!(!cache.permits_http_invalidation("192.0.2.43".parse().unwrap())); assert!(!cache.permits_http_invalidation("192.0.2.43".parse().unwrap()));
assert!(cache.permits_http_invalidation("2001:db8:10::1".parse().unwrap())); assert!(cache.permits_http_invalidation("2001:db8:10::1".parse().unwrap()));
assert!(!cache.permits_http_invalidation("2001:db8:11::1".parse().unwrap())); assert!(!cache.permits_http_invalidation("2001:db8:11::1".parse().unwrap()));
for invalid in ["0.0.0.0/0", "::/0", "127.0.0.1; 8.8.8.8", "not-an-ip", "10.0.0.0/33"] { for invalid in [
"0.0.0.0/0",
"::/0",
"127.0.0.1; 8.8.8.8",
"not-an-ip",
"10.0.0.0/33",
] {
cache.http_invalidation_allowed_cidrs = vec![invalid.into()]; cache.http_invalidation_allowed_cidrs = vec![invalid.into()];
assert!(cache.validate().is_err(), "must reject {invalid}"); assert!(cache.validate().is_err(), "must reject {invalid}");
} }
@@ -37,5 +44,4 @@ mod http_invalidation_tests {
assert!(!host_matches(&matcher, "http", "a.example", "a.example")); assert!(!host_matches(&matcher, "http", "a.example", "a.example"));
assert!(!host_matches(&matcher, "https", "b.example", "b.example")); assert!(!host_matches(&matcher, "https", "b.example", "b.example"));
} }
} }
+12 -19
View File
@@ -6,7 +6,8 @@ impl Cache {
let now = SystemTime::now(); let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now)); bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release); self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
bans.clone() bans.clone()
} }
@@ -17,7 +18,8 @@ impl Cache {
let now = SystemTime::now(); let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now)); bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release); self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
let banned = bans.iter().any(|rule| rule.matches_hot(key, entry, now)); let banned = bans.iter().any(|rule| rule.matches_hot(key, entry, now));
if banned { if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed); self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
@@ -32,7 +34,8 @@ impl Cache {
let now = SystemTime::now(); let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now)); bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release); self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
let banned = bans.iter().any(|rule| rule.matches_cold(key, entry, now)); let banned = bans.iter().any(|rule| rule.matches_cold(key, entry, now));
if banned { if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed); self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
@@ -47,7 +50,8 @@ impl Cache {
let now = SystemTime::now(); let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now)); bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release); self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
bans.iter().any(|rule| rule.matches_hot(key, entry, now)) bans.iter().any(|rule| rule.matches_hot(key, entry, now))
} }
@@ -60,7 +64,8 @@ impl Cache {
let before = bans.len(); let before = bans.len();
bans.retain(|rule| rule.id != id); bans.retain(|rule| rule.id != id);
let removed = bans.len() != before; let removed = bans.len() != before;
self.ban_rule_count.store(bans.len() as u64, Ordering::Release); self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
if removed { if removed {
self.counters.bans_removed.fetch_add(1, Ordering::Relaxed); self.counters.bans_removed.fetch_add(1, Ordering::Relaxed);
} }
@@ -97,7 +102,8 @@ impl Cache {
created_at: now, created_at: now,
expires_at: now.checked_add(Duration::from_secs(ttl)).unwrap_or(now), expires_at: now.checked_add(Duration::from_secs(ttl)).unwrap_or(now),
}); });
self.ban_rule_count.store(bans.len() as u64, Ordering::Release); self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
self.counters.bans_created.fetch_add(1, Ordering::Relaxed); self.counters.bans_created.fetch_add(1, Ordering::Relaxed);
Ok(InvalidationResult { Ok(InvalidationResult {
operation: request.operation, operation: request.operation,
@@ -112,22 +118,9 @@ impl Cache {
}) })
} }
InvalidationOperation::Purge => { InvalidationOperation::Purge => {
// A selective purge is also a publication barrier for every fill that
// started before this operation. Existing unrelated objects remain
// reusable, but an old in-flight response cannot repopulate a target
// that was not yet identifiable (for example Cache-Tag before headers).
{ {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.generation = store.generation.wrapping_add(1); store.generation = store.generation.wrapping_add(1);
// This global publication barrier invalidates every older
// ticket and pending COLD demotion. Per-key generations only
// need to cover the current purge, not every key ever
// purged over the lifetime of the process. Without clearing
// them, repeated PURGEs of distinct URLs grow this map
// indefinitely even after the objects have been evicted.
// Release an unusually large index's backing allocation
// after mass invalidations instead of retaining its peak
// bucket capacity for the rest of the process lifetime.
if store.key_generations.capacity() > 256 { if store.key_generations.capacity() > 256 {
store.key_generations = Default::default(); store.key_generations = Default::default();
} else { } else {
+36 -41
View File
@@ -111,7 +111,9 @@ impl Cache {
let weak = Arc::downgrade(&cache); let weak = Arc::downgrade(&cache);
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
let Some(cache) = weak.upgrade() else { break; }; let Some(cache) = weak.upgrade() else {
break;
};
let interval = cache let interval = cache
.tier_settings .tier_settings
.lock() .lock()
@@ -130,9 +132,6 @@ impl Cache {
cache cache
} }
fn fill_headroom_bytes(max_bytes: usize) -> usize { fn fill_headroom_bytes(max_bytes: usize) -> usize {
// Allocation rounding is allowed a small global cushion, never a whole
// max_object allocation. Use roughly 1/64 of HOT, cap it at 1 MiB and
// never let the cushion itself exceed 1/8 of a very small HOT budget.
let proportional = (max_bytes / 64).clamp(4 * 1024, 1024 * 1024); let proportional = (max_bytes / 64).clamp(4 * 1024, 1024 * 1024);
proportional.min((max_bytes / 8).max(1)) proportional.min((max_bytes / 8).max(1))
} }
@@ -164,17 +163,14 @@ impl Cache {
} }
} }
/// Reserve real allocation for an in-progress fill before growing its buffer.
/// Existing HOT objects are LRU-evicted/demoted first so HOT + active fill
/// allocation cannot race arbitrarily above the configured HOT budget.
fn reserve_fill_allocation(&self, bytes: usize, settings: &Settings) -> Option<TransientLease> { fn reserve_fill_allocation(&self, bytes: usize, settings: &Settings) -> Option<TransientLease> {
if bytes == 0 { if bytes == 0 {
return self.try_reserve_transient(0, settings); return self.try_reserve_transient(0, settings);
} }
let lease = self.try_reserve_transient(bytes, settings)?; let lease = self.try_reserve_transient(bytes, settings)?;
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let fill_now = usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)) let fill_now =
.unwrap_or(usize::MAX); usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)).unwrap_or(usize::MAX);
let allowed = settings let allowed = settings
.max_bytes .max_bytes
.saturating_add(Self::fill_headroom_bytes(settings.max_bytes)); .saturating_add(Self::fill_headroom_bytes(settings.max_bytes));
@@ -182,20 +178,14 @@ impl Cache {
let generation = store.generation; let generation = store.generation;
let mut removed = 0usize; let mut removed = 0usize;
let mut released_hot = false; let mut released_hot = false;
while store while store.bytes.saturating_add(fill_now).saturating_add(bytes) > allowed {
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
> allowed
{
if removed >= settings.hot_eviction_max_objects { if removed >= settings.hot_eviction_max_objects {
self.counters self.counters
.hot_eviction_limited .hot_eviction_limited
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
break; break;
} }
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
else {
break; break;
}; };
let Some(old) = store.remove(&key) else { let Some(old) = store.remove(&key) else {
@@ -227,11 +217,7 @@ impl Cache {
self.counters.evictions.fetch_add(1, Ordering::Relaxed); self.counters.evictions.fetch_add(1, Ordering::Relaxed);
} }
} }
let fits = store let fits = store.bytes.saturating_add(fill_now).saturating_add(bytes) <= allowed;
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
<= allowed;
if fits { if fits {
self.fill_bytes_active self.fill_bytes_active
.fetch_add(bytes as u64, Ordering::Relaxed); .fetch_add(bytes as u64, Ordering::Relaxed);
@@ -251,10 +237,6 @@ impl Cache {
} }
} }
/// Bound long-lived HOT RSS without flushing the working set. Only entries
/// idle for the configured age are considered, and only while HOT usage is
/// above the configured target waterline.
fn trim_hot_idle(&self) { fn trim_hot_idle(&self) {
let settings = self let settings = self
.tier_settings .tier_settings
@@ -285,17 +267,31 @@ impl Cache {
.get(&key) .get(&key)
.is_some_and(|entry| entry.last_access.elapsed() >= idle_cutoff); .is_some_and(|entry| entry.last_access.elapsed() >= idle_cutoff);
if !idle { if !idle {
// LRU order is also last-access order, so newer entries cannot qualify.
break; break;
} }
let Some(entry) = store.remove(&key) else { continue; }; let Some(entry) = store.remove(&key) else {
continue;
};
removed += 1; removed += 1;
let scope_generation = store.scope_generations.get(&entry.scope).copied().unwrap_or(0); let scope_generation = store
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0); .scope_generations
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store
.key_generations
.get(&entry.base_key)
.copied()
.unwrap_or(0);
if !(settings.cold_enabled if !(settings.cold_enabled
&& settings.cold_on_lru && settings.cold_on_lru
&& self.enqueue_demotion( && self.enqueue_demotion(
key, entry, revision, generation, scope_generation, key_generation, key,
entry,
revision,
generation,
scope_generation,
key_generation,
)) ))
{ {
self.counters.evictions.fetch_add(1, Ordering::Relaxed); self.counters.evictions.fetch_add(1, Ordering::Relaxed);
@@ -307,29 +303,28 @@ impl Cache {
} }
} }
/// Block requests bound to an old immutable routing snapshot from creating
/// cache tickets during a concurrent configuration publication. This does
/// not clear HOT or COLD and does not modify the representation revision.
pub fn set_control_revision(&self, revision: u64) { pub fn set_control_revision(&self, revision: u64) {
self.store.lock().unwrap_or_else(|e| e.into_inner()).control_revision = revision; self.store
.lock()
.unwrap_or_else(|e| e.into_inner())
.control_revision = revision;
} }
pub fn replace_scope_revisions(&self, scopes: HashMap<String, u64>) { pub fn replace_scope_revisions(&self, scopes: HashMap<String, u64>) {
*self.scope_revisions.lock().unwrap_or_else(|e| e.into_inner()) = scopes; *self
.scope_revisions
.lock()
.unwrap_or_else(|e| e.into_inner()) = scopes;
} }
pub fn configure(&self, settings: Settings) { pub fn configure(&self, settings: Settings) {
*self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone(); *self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone();
// Hot-applied limit reductions take effect immediately for published HOT
// objects. Demotion is best effort and remains bounded by transient/COLD
// queue budgets; otherwise the LRU object is dropped.
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let revision = store.revision; let revision = store.revision;
let generation = store.generation; let generation = store.generation;
let mut released_hot = false; let mut released_hot = false;
while store.entries.len() > settings.max_entries || store.bytes > settings.max_bytes { while store.entries.len() > settings.max_entries || store.bytes > settings.max_bytes {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
else {
break; break;
}; };
let Some(old) = store.remove(&key) else { let Some(old) = store.remove(&key) else {
+30 -15
View File
@@ -1,20 +1,22 @@
impl Cache { impl Cache {
fn get_hot(&self, ticket: &Ticket, count_miss: bool) -> Option<Hit> { fn get_hot(&self, ticket: &Ticket, count_miss: bool) -> Option<Hit> {
// Only metadata, hit counters and exact LRU ordering require the store
// mutex. Materializing an HTTP response may allocate (headers, Body,
// Age formatting) and must be done *after* dropping that mutex.
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if store.revision != ticket.revision if store.revision != ticket.revision
|| store.generation != ticket.generation || store.generation != ticket.generation
|| store.scope_generations.get(&ticket.scope).copied().unwrap_or(0) || store
.scope_generations
.get(&ticket.scope)
.copied()
.unwrap_or(0)
!= ticket.scope_generation != ticket.scope_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) || store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
!= ticket.key_generation
{ {
return None; return None;
} }
if let Some(storage_key) = matching_hot_key(&store, ticket) { if let Some(storage_key) = matching_hot_key(&store, ticket) {
let banned = store.entries.get(&storage_key) let banned = store
.entries
.get(&storage_key)
.is_some_and(|entry| self.hot_entry_banned(&storage_key, entry)); .is_some_and(|entry| self.hot_entry_banned(&storage_key, entry));
if banned { if banned {
store.remove(&storage_key); store.remove(&storage_key);
@@ -27,7 +29,10 @@ impl Cache {
} }
let entry = store.entries.get(&storage_key)?; let entry = store.entries.get(&storage_key)?;
let resident_age_seconds = hot_resident_age(entry).as_secs(); let resident_age_seconds = hot_resident_age(entry).as_secs();
let age = entry.initial_age.saturating_add(entry.received.elapsed()).as_secs(); let age = entry
.initial_age
.saturating_add(entry.received.elapsed())
.as_secs();
let state = cache_state( let state = cache_state(
age, age,
entry.ttl, entry.ttl,
@@ -39,8 +44,16 @@ impl Cache {
); );
if state.servable() { if state.servable() {
let sequence = store.next_sequence(); let sequence = store.next_sequence();
let (old_sequence, headers, body, hits, transformed, skip_compression, let (
remaining_ttl, body_bytes) = { old_sequence,
headers,
body,
hits,
transformed,
skip_compression,
remaining_ttl,
body_bytes,
) = {
let entry = store.entries.get_mut(&storage_key)?; let entry = store.entries.get_mut(&storage_key)?;
let old_sequence = entry.sequence; let old_sequence = entry.sequence;
entry.hits = entry.hits.saturating_add(1); entry.hits = entry.hits.saturating_add(1);
@@ -59,15 +72,15 @@ impl Cache {
}; };
store.lru.remove(&old_sequence); store.lru.remove(&old_sequence);
store.lru.insert(sequence, storage_key); store.lru.insert(sequence, storage_key);
// Preserve the exact LRU and domain-byte counters, without
// allocating a replacement variant index or String keys.
self.counters.hits.fetch_add(1, Ordering::Relaxed); self.counters.hits.fetch_add(1, Ordering::Relaxed);
drop(store); drop(store);
let mut response = Response::new(Body::from(body.clone())); let mut response = Response::new(Body::from(body.clone()));
*response.headers_mut() = headers.as_ref().clone(); *response.headers_mut() = headers.as_ref().clone();
if transformed { if transformed {
response.extensions_mut().insert(crate::transform::CachedTransformed); response
.extensions_mut()
.insert(crate::transform::CachedTransformed);
} }
if skip_compression { if skip_compression {
response.extensions_mut().insert(crate::compression::Skip); response.extensions_mut().insert(crate::compression::Skip);
@@ -76,7 +89,8 @@ impl Cache {
"age", "age",
HeaderValue::from_str(&age.to_string()).expect("integer header"), HeaderValue::from_str(&age.to_string()).expect("integer header"),
); );
self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes); self.domain_stats
.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
return Some(Hit { return Some(Hit {
response, response,
body, body,
@@ -274,7 +288,8 @@ impl Cache {
"age", "age",
HeaderValue::from_str(&age.to_string()).expect("integer header"), HeaderValue::from_str(&age.to_string()).expect("integer header"),
); );
self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes); self.domain_stats
.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
self.counters.hits.fetch_add(1, Ordering::Relaxed); self.counters.hits.fetch_add(1, Ordering::Relaxed);
self.counters.cold_hits.fetch_add(1, Ordering::Relaxed); self.counters.cold_hits.fetch_add(1, Ordering::Relaxed);
self.counters self.counters
@@ -3,7 +3,10 @@ mod hot_hit_034_tests {
use http_body_util::BodyExt; use http_body_util::BodyExt;
fn settings() -> Settings { fn settings() -> Settings {
Settings { enabled: true, ..Settings::default() } Settings {
enabled: true,
..Settings::default()
}
} }
async fn publish( async fn publish(
@@ -13,16 +16,25 @@ mod hot_hit_034_tests {
body: &'static str, body: &'static str,
transformed: bool, transformed: bool,
) -> Ticket { ) -> Ticket {
let ticket = cache.ticket_scoped( let ticket = cache
34, "app", "http", "cache.example", "origin@127.0.0.1:8080", .ticket_scoped(
path, &HeaderMap::new(), 34,
).expect("cache ticket"); "app",
"http",
"cache.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.expect("cache ticket");
let mut response = Response::new(Body::from(body)); let mut response = Response::new(Body::from(body));
response.headers_mut().insert( response.headers_mut().insert(
"cache-control", HeaderValue::from_static("public, max-age=31536000"), "cache-control",
HeaderValue::from_static("public, max-age=31536000"),
); );
response.headers_mut().insert( response.headers_mut().insert(
"etag", HeaderValue::from_static("\"cached-representation\""), "etag",
HeaderValue::from_static("\"cached-representation\""),
); );
if transformed { if transformed {
response.extensions_mut().insert(crate::transform::Applied { response.extensions_mut().insert(crate::transform::Applied {
@@ -33,8 +45,12 @@ mod hot_hit_034_tests {
} }
let policy = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO) let policy = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO)
.expect("public static object cacheable"); .expect("public static object cacheable");
cache.wrap(response, ticket.clone(), policy, settings) cache
.into_body().collect().await.unwrap(); .wrap(response, ticket.clone(), policy, settings)
.into_body()
.collect()
.await
.unwrap();
ticket ticket
} }
@@ -52,12 +68,21 @@ mod hot_hit_034_tests {
let hit = cache.get(&ticket).expect("HOT cache hit"); let hit = cache.get(&ticket).expect("HOT cache hit");
assert_eq!(hit.hits, i); assert_eq!(hit.hits, i);
assert!(hit.transformed); assert!(hit.transformed);
assert!(hit.response.extensions().get::<crate::transform::CachedTransformed>().is_some()); assert!(hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(hit.response.headers()["etag"], "\"cached-representation\""); assert_eq!(hit.response.headers()["etag"], "\"cached-representation\"");
let mut independent = hit.response; let mut independent = hit.response;
independent.headers_mut().insert("etag", HeaderValue::from_static("\"client-only\"")); independent
.headers_mut()
.insert("etag", HeaderValue::from_static("\"client-only\""));
if i == 500 { if i == 500 {
assert_eq!(independent.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"body{color:red}")); assert_eq!(
independent.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"body{color:red}")
);
} }
} }
let after = cache.view(); let after = cache.view();
@@ -73,7 +98,10 @@ mod hot_hit_034_tests {
let dashboard = cache.dashboard(Some("app"), 0, 10); let dashboard = cache.dashboard(Some("app"), 0, 10);
assert_eq!(dashboard.objects[0].hits, 500); assert_eq!(dashboard.objects[0].hits, 500);
assert_eq!(dashboard.domains[0].counters.saved_body_bytes, 500 * 15); assert_eq!(dashboard.domains[0].counters.saved_body_bytes, 500 * 15);
assert_eq!(cache.get(&ticket).unwrap().response.headers()["etag"], "\"cached-representation\""); assert_eq!(
cache.get(&ticket).unwrap().response.headers()["etag"],
"\"cached-representation\""
);
} }
#[tokio::test] #[tokio::test]
@@ -96,9 +124,15 @@ mod hot_hit_034_tests {
hits hits
})); }));
} }
let mut counts: Vec<_> = threads.into_iter().flat_map(|t| t.join().unwrap()).collect(); let mut counts: Vec<_> = threads
.into_iter()
.flat_map(|t| t.join().unwrap())
.collect();
counts.sort_unstable(); counts.sort_unstable();
assert_eq!(counts, (1..=(workers * per_worker) as u64).collect::<Vec<_>>()); assert_eq!(
counts,
(1..=(workers * per_worker) as u64).collect::<Vec<_>>()
);
assert_eq!(cache.view().hits, (workers * per_worker) as u64); assert_eq!(cache.view().hits, (workers * per_worker) as u64);
let store = cache.store.lock().unwrap(); let store = cache.store.lock().unwrap();
assert_eq!(store.lru.len(), 1); assert_eq!(store.lru.len(), 1);
@@ -108,7 +142,10 @@ mod hot_hit_034_tests {
#[tokio::test] #[tokio::test]
async fn hot_lru_eviction_and_ban_are_preserved() { async fn hot_lru_eviction_and_ban_are_preserved() {
let cache = Cache::new(34); let cache = Cache::new(34);
let settings = Settings { max_entries: 2, ..settings() }; let settings = Settings {
max_entries: 2,
..settings()
};
let a = publish(&cache, &settings, "/a.css", "AAAA", false).await; let a = publish(&cache, &settings, "/a.css", "AAAA", false).await;
let b = publish(&cache, &settings, "/b.css", "BBBB", false).await; let b = publish(&cache, &settings, "/b.css", "BBBB", false).await;
assert!(cache.get(&a).is_some(), "A becomes most recently used"); assert!(cache.get(&a).is_some(), "A becomes most recently used");
@@ -117,7 +154,8 @@ mod hot_hit_034_tests {
assert!(cache.get(&b).is_none(), "B is least recently used"); assert!(cache.get(&b).is_none(), "B is least recently used");
assert!(cache.get(&c).is_some()); assert!(cache.get(&c).is_some());
let ban = cache.invalidate(InvalidationRequest { let ban = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Ban, operation: InvalidationOperation::Ban,
selector: InvalidationSelector::Exact, selector: InvalidationSelector::Exact,
value: "/a.css".into(), value: "/a.css".into(),
@@ -125,7 +163,8 @@ mod hot_hit_034_tests {
vhost: Some("app".into()), vhost: Some("app".into()),
ttl_seconds: Some(3600), ttl_seconds: Some(3600),
confirm_wide: false, confirm_wide: false,
}).unwrap(); })
.unwrap();
assert_eq!(cache.ban_rule_count.load(Ordering::Acquire), 1); assert_eq!(cache.ban_rule_count.load(Ordering::Acquire), 1);
assert!(cache.get(&a).is_none(), "BAN must block cached A"); assert!(cache.get(&a).is_none(), "BAN must block cached A");
assert!(cache.remove_ban(ban.ban_id.unwrap())); assert!(cache.remove_ban(ban.ban_id.unwrap()));
@@ -147,8 +186,11 @@ mod hot_hit_034_tests {
std::hint::black_box(cache.get(&ticket).unwrap()); std::hint::black_box(cache.get(&ticket).unwrap());
} }
let duration = start.elapsed(); let duration = start.elapsed();
eprintln!("HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS", eprintln!(
duration.as_secs_f64() * 1e9 / iterations as f64, iterations); "HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS",
duration.as_secs_f64() * 1e9 / iterations as f64,
iterations
);
assert_eq!(cache.view().hits, iterations + 1_000); assert_eq!(cache.view().hits, iterations + 1_000);
} }
@@ -161,12 +203,19 @@ mod hot_hit_034_tests {
store.entries.values_mut().next().unwrap().received = store.entries.values_mut().next().unwrap().received =
Instant::now() - Duration::from_secs(700_000); Instant::now() - Duration::from_secs(700_000);
} }
assert!(cache.get(&ticket).is_none(), "expired freshness is not an unconditional HIT"); assert!(
assert_eq!(cache.view().entries, 1, "stale representation must survive for revalidation"); cache.get(&ticket).is_none(),
let mut stale_ticket = ticket; "expired freshness is not an unconditional HIT"
stale_ticket.request_headers.insert(
"cache-control", HeaderValue::from_static("max-stale"),
); );
assert_eq!(
cache.view().entries,
1,
"stale representation must survive for revalidation"
);
let mut stale_ticket = ticket;
stale_ticket
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
assert!(cache.get(&stale_ticket).unwrap().stale); assert!(cache.get(&stale_ticket).unwrap().stale);
} }
} }
-3
View File
@@ -44,9 +44,6 @@ impl Cache {
{ {
return false; return false;
} }
// Do not keep objects for incompatible Vary schemas under the same
// Varnish-style host+URL hash. A newer representation policy replaces
// the older schema while preserving sibling values of the same Vary set.
for key in incompatible_hot_variants(&store, &ticket.key, &vary_schema) { for key in incompatible_hot_variants(&store, &ticket.key, &vary_schema) {
store.remove(&key); store.remove(&key);
} }
+9 -7
View File
@@ -170,10 +170,15 @@ impl Cache {
Some(validators) => validators.last_modified.clone(), Some(validators) => validators.last_modified.clone(),
None => headers.get("last-modified").cloned(), None => headers.get("last-modified").cloned(),
}; };
let transformed = parts.extensions.get::<crate::transform::Applied>().is_some(); let transformed = parts
// A transformed, fully buffered body normally arrives as one owned .extensions
// frame. Do not allocate a second fill buffer before inspecting it. .get::<crate::transform::Applied>()
let initial_capacity = if transformed { 0 } else { fill_size.min(64 * 1024) }; .is_some();
let initial_capacity = if transformed {
0
} else {
fill_size.min(64 * 1024)
};
let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else { let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else {
self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed); self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed);
self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure"); self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure");
@@ -205,9 +210,6 @@ impl Cache {
_permit: permit, _permit: permit,
_fill_leader: fill_leader, _fill_leader: fill_leader,
}; };
// A genuinely finished body (including an empty local file) may never
// be polled by Hyper. EOS is the proof here, not Content-Length alone;
// finish still enforces expected length, privacy and ticket validity.
if body.is_end_stream() { if body.is_end_stream() {
candidate.finish(); candidate.finish();
return Response::from_parts(parts, body); return Response::from_parts(parts, body);
-6
View File
@@ -1,9 +1,3 @@
//! Bounded, conservative shared two-tier cache. Fresh objects live in a fast RAM
//! hot tier; optionally, idle/LRU objects are persisted in a bounded cold tier.
//! Bodies are streamed while a bounded copy is collected; only complete successful
//! bodies are published. The implementation is split by responsibility so storage,
//! policy and streaming-fill code can be reviewed independently.
include!("model.rs"); include!("model.rs");
include!("stats.rs"); include!("stats.rs");
include!("lifecycle.rs"); include!("lifecycle.rs");
+1 -24
View File
@@ -28,9 +28,7 @@ pub struct ObjectView {
pub storage_bytes: u64, pub storage_bytes: u64,
pub tier: &'static str, pub tier: &'static str,
pub hits: u64, pub hits: u64,
/// RFC 9111 Age, including time before the object entered this cache.
pub age_seconds: u64, pub age_seconds: u64,
/// Total time held by RustProxy HOT/COLD; excludes any upstream CDN Age.
pub resident_age_seconds: u64, pub resident_age_seconds: u64,
pub remaining_ttl_seconds: u64, pub remaining_ttl_seconds: u64,
} }
@@ -50,11 +48,8 @@ pub struct Dashboard {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct ColdCleanupRequest { pub struct ColdCleanupRequest {
/// Remove objects not used for at least this many seconds.
pub unused_for_seconds: Option<u64>, pub unused_for_seconds: Option<u64>,
/// Additionally remove up to N oldest matching objects.
pub oldest: Option<usize>, pub oldest: Option<usize>,
/// Limit the selection to objects which were never served.
pub hits_zero: bool, pub hits_zero: bool,
} }
impl Default for ColdCleanupRequest { impl Default for ColdCleanupRequest {
@@ -113,7 +108,6 @@ struct Entry {
created_at: SystemTime, created_at: SystemTime,
headers: Arc<HeaderMap>, headers: Arc<HeaderMap>,
body: Bytes, body: Bytes,
/// Backing allocation retained by `body`; can exceed `body.len()`.
body_allocated: usize, body_allocated: usize,
received: Instant, received: Instant,
last_access: Instant, last_access: Instant,
@@ -135,7 +129,7 @@ struct Entry {
requires_revalidation: bool, requires_revalidation: bool,
} }
#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting. #[allow(clippy::ptr_arg)]
fn hot_entry_cost( fn hot_entry_cost(
body_allocated: usize, body_allocated: usize,
headers: &HeaderMap, headers: &HeaderMap,
@@ -150,11 +144,6 @@ fn hot_entry_cost(
origin_etag: Option<&HeaderValue>, origin_etag: Option<&HeaderValue>,
origin_last_modified: Option<&HeaderValue>, origin_last_modified: Option<&HeaderValue>,
) -> usize { ) -> usize {
// The public containers do not expose an exact per-entry allocator charge.
// Account their retained index memory conservatively: HOT HashMap key/control,
// the LRU BTreeMap item/node share and the variants index (including spare
// Vec<Key> capacity). This keeps `max_bytes` closer to the real retained
// allocation instead of pretending Entry/body are the only HOT owners.
let index_heap = std::mem::size_of::<Key>() let index_heap = std::mem::size_of::<Key>()
.saturating_add(std::mem::size_of::<(u64, Key)>()) .saturating_add(std::mem::size_of::<(u64, Key)>())
.saturating_add(32) .saturating_add(32)
@@ -171,8 +160,6 @@ fn hot_entry_cost(
let header_heap = headers let header_heap = headers
.iter() .iter()
.map(|(name, value)| { .map(|(name, value)| {
// HeaderMap does not expose its bucket capacity. Charge a conservative
// per-field bucket/node allowance in addition to visible name/value bytes.
name.as_str() name.as_str()
.len() .len()
.saturating_add(value.as_bytes().len()) .saturating_add(value.as_bytes().len())
@@ -181,7 +168,6 @@ fn hot_entry_cost(
.sum::<usize>(); .sum::<usize>();
body_allocated body_allocated
.saturating_add(std::mem::size_of::<Entry>()) .saturating_add(std::mem::size_of::<Entry>())
// Arc<HeaderMap> owns the actual map on a separate allocation.
.saturating_add(std::mem::size_of::<HeaderMap>()) .saturating_add(std::mem::size_of::<HeaderMap>())
.saturating_add(2 * std::mem::size_of::<usize>()) .saturating_add(2 * std::mem::size_of::<usize>())
.saturating_add(index_heap) .saturating_add(index_heap)
@@ -207,7 +193,6 @@ struct ColdFile {
#[serde(default)] #[serde(default)]
vary: Vec<String>, vary: Vec<String>,
revision: u64, revision: u64,
/// 0 denotes legacy/unscoped entries; production vhosts require an exact match.
#[serde(default)] #[serde(default)]
scope_revision: u64, scope_revision: u64,
scope: String, scope: String,
@@ -284,9 +269,6 @@ struct ColdEntry {
hits: u64, hits: u64,
transformed: bool, transformed: bool,
sequence: u64, sequence: u64,
// Stable identity of this in-memory cold entry. Unlike `sequence`, this
// never changes on access, so an old async reader cannot unpin a newer
// replacement stored under the same cache key.
generation: u64, generation: u64,
skip_compression: bool, skip_compression: bool,
requires_revalidation: bool, requires_revalidation: bool,
@@ -311,8 +293,6 @@ struct ColdDraft {
key_generation: u64, key_generation: u64,
entry: Entry, entry: Entry,
root: PathBuf, root: PathBuf,
/// Keeps this full in-memory object charged to the bounded transient pool
/// until the COLD write completes and the payload is dropped.
_memory_lease: TransientLease, _memory_lease: TransientLease,
} }
@@ -331,8 +311,6 @@ struct Store {
sequence: u64, sequence: u64,
generation: u64, generation: u64,
revision: u64, revision: u64,
/// Current persisted administrative config revision. Distinct from cache
/// representation revision: operational saves do not evict data.
control_revision: u64, control_revision: u64,
scope_generations: HashMap<String, u64>, scope_generations: HashMap<String, u64>,
key_generations: HashMap<Key, u64>, key_generations: HashMap<Key, u64>,
@@ -361,7 +339,6 @@ impl Store {
Some(entry) Some(entry)
} }
fn next_sequence(&mut self) -> u64 { fn next_sequence(&mut self) -> u64 {
// No practical wrapping, but a wrap must not corrupt LRU bookkeeping.
if self.sequence == u64::MAX { if self.sequence == u64::MAX {
self.entries.clear(); self.entries.clear();
self.variants.clear(); self.variants.clear();
+19 -23
View File
@@ -167,7 +167,6 @@ impl Settings {
.any(|item| item.eq_ignore_ascii_case(extension)) .any(|item| item.eq_ignore_ascii_case(extension))
} }
pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) { pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) {
// Origin values must never masquerade as this proxy's diagnostics.
for name in [ for name in [
"x-cache", "x-cache",
"x-cache-hits", "x-cache-hits",
@@ -178,15 +177,15 @@ impl Settings {
] { ] {
headers.remove(name); headers.remove(name);
} }
// A vhost that explicitly disables cache must look like a normal reverse proxy.
// Keep counters internally, but never expose cache diagnostics for that response.
if !self.enabled { if !self.enabled {
return; return;
} }
if let Some(value) = &self.via_header { if let Some(value) = &self.via_header {
headers.append( headers.append(
http::header::VIA, http::header::VIA,
value.parse::<HeaderValue>().expect("validated cache Via header"), value
.parse::<HeaderValue>()
.expect("validated cache Via header"),
); );
} }
if self.expose_only_hits && outcome != "HIT" { if self.expose_only_hits && outcome != "HIT" {
@@ -226,10 +225,15 @@ impl Settings {
} }
} }
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) { pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
if !self.enabled || !self.expose_cache_status || !transformed if !self.enabled
|| (self.expose_only_hits && !headers.get("cache-status") || !self.expose_cache_status
|| !transformed
|| (self.expose_only_hits
&& !headers
.get("cache-status")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.is_some_and(|value| value.starts_with("RustProxy; hit"))) { .is_some_and(|value| value.starts_with("RustProxy; hit")))
{
return; return;
} }
let base = headers let base = headers
@@ -246,7 +250,6 @@ impl Settings {
headers.insert("cache-status", value); headers.insert("cache-status", value);
} }
} }
/// Reasons are fixed internal tokens: never echo request values or secrets.
pub fn annotate_reason( pub fn annotate_reason(
&self, &self,
headers: &mut HeaderMap, headers: &mut HeaderMap,
@@ -284,8 +287,6 @@ fn valid_relative_cache_path(value: &str) -> bool {
.all(|part| matches!(part, Component::Normal(_))) .all(|part| matches!(part, Component::Normal(_)))
} }
/// Explicit, per-vhost-only request-side cache override. It never weakens the
/// response-side privacy guards (Set-Cookie/private/no-store/Vary rules).
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct UnsafeForce { pub struct UnsafeForce {
@@ -294,7 +295,10 @@ pub struct UnsafeForce {
} }
impl UnsafeForce { impl UnsafeForce {
pub fn validate(&self) -> Result<()> { pub fn validate(&self) -> Result<()> {
ensure!(self.targets.len() <= 32, "forced cache supports at most 32 target prefixes"); ensure!(
self.targets.len() <= 32,
"forced cache supports at most 32 target prefixes"
);
for target in &self.targets { for target in &self.targets {
ensure!( ensure!(
!target.is_empty() !target.is_empty()
@@ -305,7 +309,10 @@ impl UnsafeForce {
"forced cache targets must be 1..2048 byte origin-form path/query prefixes" "forced cache targets must be 1..2048 byte origin-form path/query prefixes"
); );
let path = target.split('?').next().unwrap_or(target); let path = target.split('?').next().unwrap_or(target);
ensure!(crate::acl::routing_path(path)? == path, "forced cache target path must be normalized"); ensure!(
crate::acl::routing_path(path)? == path,
"forced cache target path must be normalized"
);
} }
Ok(()) Ok(())
} }
@@ -323,8 +330,6 @@ impl UnsafeForce {
} }
} }
/// Per-vhost behavior, not independent memory pools. None inherits the global
/// default; the global resident-byte/entry/object ceilings remain hard limits.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Overrides { pub struct Overrides {
@@ -341,15 +346,12 @@ pub struct Overrides {
pub expose_cache_status: Option<bool>, pub expose_cache_status: Option<bool>,
pub expose_resident_age: Option<bool>, pub expose_resident_age: Option<bool>,
pub expose_only_hits: Option<bool>, pub expose_only_hits: Option<bool>,
/// Deliberately unavailable in the global cache configuration.
pub unsafe_force: UnsafeForce, pub unsafe_force: UnsafeForce,
} }
impl Overrides { impl Overrides {
pub fn resolve(&self, global: &Settings) -> Result<Settings> { pub fn resolve(&self, global: &Settings) -> Result<Settings> {
self.unsafe_force.validate()?; self.unsafe_force.validate()?;
let mut result = global.clone(); let mut result = global.clone();
// A vhost may narrow the global policy, but can never re-enable a cache
// that the administrator disabled globally.
result.enabled = global.enabled && self.enabled.unwrap_or(true); result.enabled = global.enabled && self.enabled.unwrap_or(true);
if let Some(value) = self.default_ttl_seconds { if let Some(value) = self.default_ttl_seconds {
result.default_ttl_seconds = value; result.default_ttl_seconds = value;
@@ -444,7 +446,6 @@ pub struct View {
pub hot_eviction_limited: u64, pub hot_eviction_limited: u64,
pub purges: u64, pub purges: u64,
pub entries: usize, pub entries: usize,
/// Compatibility alias for HOT allocated/charged bytes.
pub resident_bytes: usize, pub resident_bytes: usize,
pub hot_logical_bytes: usize, pub hot_logical_bytes: usize,
pub hot_allocated_bytes: usize, pub hot_allocated_bytes: usize,
@@ -474,16 +475,11 @@ pub struct View {
pub proactive_skipped: u64, pub proactive_skipped: u64,
pub proactive_inflight: u64, pub proactive_inflight: u64,
pub fills_active: usize, pub fills_active: usize,
/// Bytes currently allocated by in-progress response cache fills.
pub fill_bytes_active: u64, pub fill_bytes_active: u64,
/// Demoted objects accepted by the bounded cold-writer queue.
pub cold_queue_items: u64, pub cold_queue_items: u64,
pub cold_queue_bytes: u64, pub cold_queue_bytes: u64,
/// Body bytes currently owned by the cold writer after leaving the queue.
pub cold_write_bytes_active: u64, pub cold_write_bytes_active: u64,
/// Full cold bodies currently read into RAM for promotion.
pub cold_read_bytes_active: u64, pub cold_read_bytes_active: u64,
/// RustProxy-accounted cache RAM outside the published hot store.
pub transient_bytes: u64, pub transient_bytes: u64,
pub hit_ratio: f64, pub hit_ratio: f64,
} }
+10 -5
View File
@@ -1,11 +1,11 @@
// A strict, human-editable IP/CIDR policy. The caller supplies only a client IP
// produced by the existing forwarding trust boundary, never an arbitrary header.
fn parse_invalidation_cidr(value: &str) -> anyhow::Result<ipnet::IpNet> { fn parse_invalidation_cidr(value: &str) -> anyhow::Result<ipnet::IpNet> {
let value = value.trim(); let value = value.trim();
if let Ok(ip) = value.parse::<std::net::IpAddr>() { if let Ok(ip) = value.parse::<std::net::IpAddr>() {
return Ok(ipnet::IpNet::from(ip)); return Ok(ipnet::IpNet::from(ip));
} }
value.parse::<ipnet::IpNet>().map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}")) value
.parse::<ipnet::IpNet>()
.map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}"))
} }
impl Settings { impl Settings {
fn validate_http_invalidation_allowlist(&self) -> anyhow::Result<()> { fn validate_http_invalidation_allowlist(&self) -> anyhow::Result<()> {
@@ -17,7 +17,10 @@ impl Settings {
for input in &self.http_invalidation_allowed_cidrs { for input in &self.http_invalidation_allowed_cidrs {
ensure!(input.len() <= 64, "HTTP invalidation address too long"); ensure!(input.len() <= 64, "HTTP invalidation address too long");
let net = parse_invalidation_cidr(input)?; let net = parse_invalidation_cidr(input)?;
ensure!(net.prefix_len() > 0, "HTTP invalidation cannot authorize the entire Internet"); ensure!(
net.prefix_len() > 0,
"HTTP invalidation cannot authorize the entire Internet"
);
ensure!(networks.insert(net), "duplicate HTTP invalidation IP/CIDR"); ensure!(networks.insert(net), "duplicate HTTP invalidation IP/CIDR");
} }
Ok(()) Ok(())
@@ -25,7 +28,9 @@ impl Settings {
pub fn permits_http_invalidation(&self, client: std::net::IpAddr) -> bool { pub fn permits_http_invalidation(&self, client: std::net::IpAddr) -> bool {
let client = crate::vhost::canonical_ip(client); let client = crate::vhost::canonical_ip(client);
self.http_invalidation_allowed_cidrs.iter().any(|value| { self.http_invalidation_allowed_cidrs.iter().any(|value| {
parse_invalidation_cidr(value).ok().is_some_and(|network| network.contains(&client)) parse_invalidation_cidr(value)
.ok()
.is_some_and(|network| network.contains(&client))
}) })
} }
} }
+5 -9
View File
@@ -7,8 +7,6 @@ impl ColdStore {
} }
fn next_sequence(&mut self) -> u64 { fn next_sequence(&mut self) -> u64 {
if self.sequence == u64::MAX { if self.sequence == u64::MAX {
// Practically unreachable. Prefer dropping the in-memory index over
// risking ambiguous LRU ordering after wrap.
self.lru.clear(); self.lru.clear();
self.entries.clear(); self.entries.clear();
self.variants.clear(); self.variants.clear();
@@ -56,7 +54,6 @@ pub struct Cache {
revision: AtomicU64, revision: AtomicU64,
cold: Arc<Mutex<ColdStore>>, cold: Arc<Mutex<ColdStore>>,
tier_settings: Mutex<Settings>, tier_settings: Mutex<Settings>,
/// Per-vhost content compatibility used by persisted COLD metadata.
scope_revisions: Mutex<HashMap<String, u64>>, scope_revisions: Mutex<HashMap<String, u64>>,
data_root: Option<PathBuf>, data_root: Option<PathBuf>,
cold_tx: Option<mpsc::Sender<ColdCommand>>, cold_tx: Option<mpsc::Sender<ColdCommand>>,
@@ -143,8 +140,6 @@ pub struct Revalidation {
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
body_allocated: usize, body_allocated: usize,
// COLD revalidation bodies remain charged to transient RAM until every
// cloned revalidation handle is dropped or promoted. HOT revalidations use none.
_transient_leases: Vec<TransientLease>, _transient_leases: Vec<TransientLease>,
hits: u64, hits: u64,
transformed: bool, transformed: bool,
@@ -171,9 +166,6 @@ impl Revalidation {
&self.headers &self.headers
} }
pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool { pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool {
// Client validators refer to the representation RustProxy served. If that
// representation was transformed, translate a matching validator back to
// the origin validator rather than leaking the generated proxy ETag upstream.
if headers.contains_key("if-none-match") { if headers.contains_key("if-none-match") {
let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else { let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else {
return false; return false;
@@ -349,7 +341,11 @@ fn hot_resident_age(entry: &Entry) -> Duration {
} }
fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option<Key> { fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option<Key> {
if store.entries.get(&ticket.key).is_some_and(|entry| entry.vary.is_empty()) { if store
.entries
.get(&ticket.key)
.is_some_and(|entry| entry.vary.is_empty())
{
return Some(ticket.key); return Some(ticket.key);
} }
store store
+5 -59
View File
@@ -24,8 +24,6 @@ const FILL_SLOTS: usize = 8;
const COLD_DELETE_FALLBACKS: usize = 2; const COLD_DELETE_FALLBACKS: usize = 2;
const COLD_CLEANUP_SLOTS: usize = 1; const COLD_CLEANUP_SLOTS: usize = 1;
const BACKGROUND_REVALIDATION_LIMIT: usize = 8; 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_QUEUE: usize = 16;
const COLD_FORMAT_VERSION: u8 = 3; const COLD_FORMAT_VERSION: u8 = 3;
type Key = [u8; 32]; type Key = [u8; 32];
@@ -43,10 +41,6 @@ struct CleanupCandidate {
key: Key, 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
/// high. This is intentionally a no-op on other allocators/platforms.
fn release_allocator_memory() { fn release_allocator_memory() {
#[cfg(all(target_os = "linux", target_env = "gnu"))] #[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe { unsafe {
@@ -93,81 +87,44 @@ pub enum ColdPreset {
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub enabled: bool, 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<MemoryPreset>, pub memory_preset: Option<MemoryPreset>,
/// 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, pub max_bytes: usize,
/// Maximum logical size of a single cacheable object body.
pub max_object_bytes: usize, pub max_object_bytes: usize,
/// Hot tier object count.
pub max_entries: usize, pub max_entries: usize,
/// Bounded Varnish-style transient RAM used by in-progress fills and full
/// bodies moving between HOT and COLD. This is independent of `max_bytes`.
pub transient_max_bytes: usize, pub transient_max_bytes: usize,
/// Byte ceiling for demoted objects waiting in the COLD writer queue.
pub cold_queue_max_bytes: usize, pub cold_queue_max_bytes: usize,
/// Maximum number of LRU objects removed during one admission attempt.
/// Reaching the limit makes the new object pass through uncached.
pub hot_eviction_max_objects: usize, pub hot_eviction_max_objects: usize,
/// Automatically release idle HOT objects once the tier is above the target waterline.
pub hot_auto_trim_enabled: bool, pub hot_auto_trim_enabled: bool,
/// Minimum idle age before automatic HOT trimming can evict an object.
pub hot_auto_trim_after_seconds: u64, pub hot_auto_trim_after_seconds: u64,
/// Target HOT usage percentage reached by each automatic trim pass.
pub hot_auto_trim_target_percent: u8, pub hot_auto_trim_target_percent: u8,
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
pub cold_enabled: bool, 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<ColdPreset>, pub cold_preset: Option<ColdPreset>,
/// Relative to the RustProxy data directory.
pub cold_path: String, pub cold_path: String,
pub cold_max_bytes: u64, pub cold_max_bytes: u64,
pub cold_max_entries: usize, pub cold_max_entries: usize,
/// Interval between asynchronous cold-cache GC checks.
pub cold_gc_interval_seconds: u64, pub cold_gc_interval_seconds: u64,
/// Maximum number of objects removed by one GC pass.
pub cold_gc_max_delete: usize, pub cold_gc_max_delete: usize,
/// Hot objects not touched for this long are demoted to disk.
pub cold_after_seconds: u64, pub cold_after_seconds: u64,
/// Demote objects which have been idle for `cold_after_seconds`.
pub cold_on_idle: bool, pub cold_on_idle: bool,
/// Demote objects after this much continuous residency in the hot tier
/// when `cold_on_age` is enabled, even when they are still being read.
pub cold_max_hot_seconds: u64, pub cold_max_hot_seconds: u64,
/// Apply `cold_max_hot_seconds` as a separate hot-tier age policy.
pub cold_on_age: bool, pub cold_on_age: bool,
/// Preserve objects evicted from the hot LRU in the cold tier.
pub cold_on_lru: bool, pub cold_on_lru: bool,
/// Best-effort flush of hot objects to cold storage on graceful shutdown.
pub cold_on_shutdown: bool, pub cold_on_shutdown: bool,
/// Only used for responses explicitly marked public and without origin TTL.
pub default_ttl_seconds: u64, pub default_ttl_seconds: u64,
pub max_ttl_seconds: u64, pub max_ttl_seconds: u64,
pub bypass_query: bool, pub bypass_query: bool,
pub bypass_prefixes: Vec<String>, pub bypass_prefixes: Vec<String>,
/// Actual client IP/CIDR allowlist for HTTP BAN/PURGE. Empty disables these
/// public-listener methods (independent of authenticated admin API).
pub http_invalidation_allowed_cidrs: Vec<String>, pub http_invalidation_allowed_cidrs: Vec<String>,
/// For explicitly selected static extensions, ignore request Cookie when
/// deciding cache eligibility and when building the shared cache key.
pub static_ignore_cookies: bool, pub static_ignore_cookies: bool,
pub static_extensions: Vec<String>, pub static_extensions: Vec<String>,
/// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key.
pub collapse_enabled: bool, pub collapse_enabled: bool,
/// Maximum time a follower waits for the current fill before applying the configured fallback.
pub collapse_wait_timeout_ms: u64, pub collapse_wait_timeout_ms: u64,
pub collapse_timeout_fallback: CollapseTimeoutFallback, pub collapse_timeout_fallback: CollapseTimeoutFallback,
/// Fine-grained stale-if-error classes. Defaults preserve the 0.21 behavior.
pub stale_if_error_timeout: bool, pub stale_if_error_timeout: bool,
pub stale_if_error_connection: bool, pub stale_if_error_connection: bool,
pub stale_if_error_dns: bool, pub stale_if_error_dns: bool,
pub stale_if_error_tls: bool, pub stale_if_error_tls: bool,
pub stale_if_error_statuses: Vec<u16>, pub stale_if_error_statuses: Vec<u16>,
/// Schedule bounded validator refresh before normal freshness expires.
pub proactive_refresh_enabled: bool, pub proactive_refresh_enabled: bool,
pub proactive_refresh_before_expiry_seconds: u64, pub proactive_refresh_before_expiry_seconds: u64,
pub proactive_refresh_percent: u8, pub proactive_refresh_percent: u8,
@@ -176,10 +133,7 @@ pub struct Settings {
pub expose_hits: bool, pub expose_hits: bool,
pub expose_cache_status: bool, pub expose_cache_status: bool,
pub expose_resident_age: bool, pub expose_resident_age: bool,
/// Emit cache diagnostics only for actual HIT responses, not MISS or BYPASS.
pub expose_only_hits: bool, pub expose_only_hits: bool,
/// Optional response Via field value added by RustProxy cache handling.
/// `None` disables the header; the default is `Via: RustProxy`.
pub via_header: Option<String>, pub via_header: Option<String>,
} }
impl Default for Settings { impl Default for Settings {
@@ -214,21 +168,13 @@ impl Default for Settings {
bypass_query: false, bypass_query: false,
bypass_prefixes: Vec::new(), bypass_prefixes: Vec::new(),
http_invalidation_allowed_cidrs: Vec::new(), http_invalidation_allowed_cidrs: Vec::new(),
// Static assets are normally representation-identical for logged-in and
// anonymous visitors. Keep this enabled by default once the shared cache
// itself is enabled; response-side privacy guards still reject Set-Cookie,
// private/no-store and Vary: Cookie before anything is stored.
static_ignore_cookies: true, static_ignore_cookies: true,
static_extensions: vec![ static_extensions: vec![
// styles / scripts "css", "js", "mjs", "cjs", "wasm", "png", "jpg", "jpeg", "jfif", "jxl", "gif",
"css", "js", "mjs", "cjs", "wasm", // images "webp", "avif", "apng", "svg", "ico", "cur", "bmp", "tif", "tiff", "heic", "heif",
"png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico", "woff", "woff2", "ttf", "otf", "eot", "mp4", "webm", "ogv", "mov", "m4v", "mp3",
"cur", "bmp", "tif", "tiff", "heic", "heif", // fonts "ogg", "oga", "wav", "m4a", "aac", "flac", "opus", "pdf", "txt", "zip", "gz",
"woff", "woff2", "ttf", "otf", "eot", // video "tgz", "bz2", "xz", "7z", "rar", "tar",
"mp4", "webm", "ogv", "mov", "m4v", // audio
"mp3", "ogg", "oga", "wav", "m4a", "aac", "flac", "opus",
// common static downloads
"pdf", "txt", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar",
] ]
.into_iter() .into_iter()
.map(str::to_owned) .map(str::to_owned)
-5
View File
@@ -73,11 +73,6 @@ fn retention_deadline(
_stale_if_error: u64, _stale_if_error: u64,
_has_validator: bool, _has_validator: bool,
) -> u64 { ) -> u64 {
// Freshness and storage retention are deliberately separate. Expiry only
// changes whether an object can be served without validation; it must not
// physically evict the representation because a later request may carry
// Cache-Control: max-stale. Capacity/LRU and Cold Cache GC remain the
// mechanisms that decide how long an otherwise valid object is retained.
u64::MAX u64::MAX
} }
+5 -24
View File
@@ -1,4 +1,3 @@
/// Reject malformed/duplicate directives instead of guessing their meaning.
fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> { fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
let mut result = HashMap::new(); let mut result = HashMap::new();
for header in headers.get_all("cache-control") { for header in headers.get_all("cache-control") {
@@ -11,8 +10,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
Some((k, v)) => { Some((k, v)) => {
let v = v.trim(); let v = v.trim();
let value = if v.starts_with('"') { let value = if v.starts_with('"') {
// Fail closed on truncated/escaped/compound values. Quoted
// commas need not be supported by this conservative cache.
let inner = v.strip_prefix('"')?.strip_suffix('"')?; let inner = v.strip_prefix('"')?.strip_suffix('"')?;
if inner.contains(['"', '\\']) { if inner.contains(['"', '\\']) {
return None; return None;
@@ -39,10 +36,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
return None; return None;
} }
if key == "immutable" { if key == "immutable" {
// RFC 8246: immutable has no meaningful argument and duplicate
// occurrences are equivalent to one occurrence. Preserve only
// the presence of the directive instead of invalidating the
// complete Cache-Control field.
result.entry(key).or_insert(None); result.entry(key).or_insert(None);
} else if result.insert(key, value).is_some() { } else if result.insert(key, value).is_some() {
return None; return None;
@@ -111,7 +104,11 @@ pub fn forced_request_policy(
} }
for key in ["if-match", "if-unmodified-since", "upgrade"] { for key in ["if-match", "if-unmodified-since", "upgrade"] {
if headers.contains_key(key) { if headers.contains_key(key) {
return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" }); return RequestPolicy::Bypass(if key == "upgrade" {
"request_upgrade"
} else {
"request_precondition"
});
} }
} }
RequestPolicy::Use RequestPolicy::Use
@@ -150,8 +147,6 @@ pub fn request_policy(
for (key, reason) in [ for (key, reason) in [
("authorization", "request_authorization"), ("authorization", "request_authorization"),
("cookie", "request_cookie"), ("cookie", "request_cookie"),
// Preconditions other than cache validators can produce 412 and must be
// evaluated by the origin until RustProxy implements the full precondition set.
("if-match", "request_precondition"), ("if-match", "request_precondition"),
("if-unmodified-since", "request_precondition"), ("if-unmodified-since", "request_precondition"),
("upgrade", "request_upgrade"), ("upgrade", "request_upgrade"),
@@ -166,15 +161,9 @@ pub fn request_policy(
let Some(control) = directives(headers) else { let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid"); return RequestPolicy::Bypass("request_cache_control_invalid");
}; };
// RFC 9111: request no-store forbids using a stored response and storing this
// exchange. It is the hard client-side cache bypass.
if control.contains_key("no-store") { if control.contains_key("no-store") {
return RequestPolicy::Bypass("request_no_store"); return RequestPolicy::Bypass("request_no_store");
} }
// no-cache does not mean no-store. It requires successful validation before a
// stored response can be reused. max-age/min-fresh are evaluated against the
// cached representation during lookup; only max-age=0 is an unconditional
// request-side validation signal.
if control.contains_key("no-cache") { if control.contains_key("no-cache") {
return RequestPolicy::Revalidate("request_no_cache"); return RequestPolicy::Revalidate("request_no_cache");
} }
@@ -200,8 +189,6 @@ pub fn request_policy(
return RequestPolicy::Bypass("request_cache_control_invalid"); return RequestPolicy::Bypass("request_cache_control_invalid");
} }
} }
// HTTP/1.0 Pragma: no-cache is a revalidation request when Cache-Control did
// not already define the policy. Other Pragma values do not disable caching.
if !headers.contains_key("cache-control") && pragma_no_cache(headers) { if !headers.contains_key("cache-control") && pragma_no_cache(headers) {
return RequestPolicy::Revalidate("request_pragma_no_cache"); return RequestPolicy::Revalidate("request_pragma_no_cache");
} }
@@ -295,8 +282,6 @@ fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
&& token != "*" && token != "*"
&& weak_etag(token).is_some_and(|candidate| candidate == target) && weak_etag(token).is_some_and(|candidate| candidate == target)
} }
/// Varnish-like client-side conditional delivery for a selected cached response.
/// If-None-Match takes precedence over If-Modified-Since.
pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool { pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool {
if request.contains_key("if-none-match") { if request.contains_key("if-none-match") {
return response return response
@@ -326,11 +311,8 @@ pub fn make_not_modified(mut response: Response) -> Response {
} }
response response
} }
/// Merge a successful backend 304 into the stored representation metadata.
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap { pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
let mut merged = cached.clone(); let mut merged = cached.clone();
// A new validation starts a new age calculation. Preserve origin Age only if
// it is explicitly supplied by the 304 response.
if !validation.contains_key("age") { if !validation.contains_key("age") {
merged.remove("age"); merged.remove("age");
} }
@@ -364,7 +346,6 @@ pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
}) })
} }
/// Explains rejected storage; it never relaxes origin or transformed policies.
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str { pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
if status != StatusCode::OK { if status != StatusCode::OK {
return "response_status"; return "response_status";
+10 -22
View File
@@ -4,7 +4,11 @@ impl Plan {
} }
pub fn with_vary(mut self, name: &str) -> Self { pub fn with_vary(mut self, name: &str) -> Self {
if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) { if !self
.vary
.iter()
.any(|value| value.eq_ignore_ascii_case(name))
{
self.vary.push(name.to_ascii_lowercase()); self.vary.push(name.to_ascii_lowercase());
self.vary.sort(); self.vary.sort();
} }
@@ -28,14 +32,7 @@ impl Plan {
self.vary.extend(transformed.vary); self.vary.extend(transformed.vary);
self.vary.sort(); self.vary.sort();
self.vary.dedup(); self.vary.dedup();
// The transformed response is the representation that is actually
// written to cache. Its Content-Length therefore replaces the origin
// length; comparing transformed bytes with the origin length rejects a
// valid fill and causes a permanent MISS -> MISS loop.
self.expected_length = transformed.expected_length; self.expected_length = transformed.expected_length;
// Express both age clocks at the later observation point before taking
// the stricter value. This avoids resetting or double-counting Age when
// origin headers are transformed inside the proxy.
if transformed.received >= self.received { if transformed.received >= self.received {
let carried = self let carried = self
.initial_age .initial_age
@@ -87,9 +84,6 @@ pub fn plan(
{ {
return None; return None;
} }
// Cache-policy and validator fields govern reuse rather than representation
// identity. Response Vary drives a secondary key, matching Varnish-style
// variants without fragmenting on unrelated request headers.
let vary = vary_names(headers)?; let vary = vary_names(headers)?;
let control = directives(headers)?; let control = directives(headers)?;
if ["no-store", "private", "must-understand"] if ["no-store", "private", "must-understand"]
@@ -141,22 +135,16 @@ pub fn plan(
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?, Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0, None => 0,
}; };
// RFC 9111 Section 4.2.3: the corrected initial age is the greater of
// the Date-derived apparent age and the received Age plus response delay.
// An upstream/CDN Age must be preserved on both transformed and normal HITs.
let apparent_age = now.duration_since(date).unwrap_or_default(); let apparent_age = now.duration_since(date).unwrap_or_default();
let corrected_age_value = Duration::from_secs(age).saturating_add(response_delay); let corrected_age_value = Duration::from_secs(age).saturating_add(response_delay);
let initial_age = apparent_age.max(corrected_age_value); let initial_age = apparent_age.max(corrected_age_value);
// `ttl` is an absolute freshness lifetime (measured from the origin's
// Age clock), not the time since insertion into our HOT/COLD cache.
// Capping it directly to max_ttl_seconds makes an otherwise fresh 30-day
// upstream response with Age=18 days look expired when our own cap is 1 day.
// Limit the REMAINING freshness from admission, but preserve the Age clock.
// Origin freshness still wins: an origin response that has already expired
// must never become fresh here. `no-cache` continues to require validation.
let ttl = match declared_ttl { let ttl = match declared_ttl {
Some(value) => value.min(initial_age.as_secs().saturating_add(settings.max_ttl_seconds)), Some(value) => value.min(
initial_age
.as_secs()
.saturating_add(settings.max_ttl_seconds),
),
None if requires_revalidation => 0, None if requires_revalidation => 0,
None => return None, None => return None,
}; };
@@ -62,8 +62,6 @@ mod scope_tests {
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new() revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new()
); );
assert!(ticket(7).is_some(), "the current snapshot can cache"); assert!(ticket(7).is_some(), "the current snapshot can cache");
// An operational save only advances the config publication barrier.
// It must not change the representation revision or clear any objects.
cache.set_control_revision(8); cache.set_control_revision(8);
assert_eq!(cache.revision(), 1); assert_eq!(cache.revision(), 1);
assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass"); assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass");
+3 -5
View File
@@ -16,13 +16,9 @@ pub fn request_range(method: &Method, headers: &HeaderMap) -> RangeSelection {
let Some(value) = values.next() else { let Some(value) = values.next() else {
return RangeSelection::None; return RangeSelection::None;
}; };
// Multiple field lines are equivalent to a combined range-set; RustProxy
// intentionally delegates those to the origin with multipart ranges.
if values.next().is_some() || headers.get_all("if-range").iter().count() > 1 { if values.next().is_some() || headers.get_all("if-range").iter().count() > 1 {
return RangeSelection::Unsupported; return RangeSelection::Unsupported;
} }
// RustProxy serves one byte range itself. HEAD, unknown units, malformed
// syntax and multipart requests remain transparent origin pass-through.
if method != Method::GET { if method != Method::GET {
return RangeSelection::Unsupported; return RangeSelection::Unsupported;
} }
@@ -132,7 +128,9 @@ impl Hit {
self.response self.response
.headers_mut() .headers_mut()
.insert("accept-ranges", HeaderValue::from_static("bytes")); .insert("accept-ranges", HeaderValue::from_static("bytes"));
self.response.extensions_mut().insert(crate::compression::Skip); self.response
.extensions_mut()
.insert(crate::compression::Skip);
let Some((start, end)) = resolve_byte_range(range, full_len) else { let Some((start, end)) = resolve_byte_range(range, full_len) else {
*self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; *self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
*self.response.body_mut() = Body::empty(); *self.response.body_mut() = Body::empty();
+28 -20
View File
@@ -1,14 +1,12 @@
// Lock-free read path for bounded, per-domain cache dashboard counters.
//
// Cache-disabled proxy requests still contribute to BYPASS and reason totals,
// but must never lock the HOT object/LRU store. Published domain/reason indexes
// are immutable; individual counters are atomics. Only first sight of a new
// domain/reason acquires the registration mutex.
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
const DOMAIN_STATS_LIMIT: usize = 4096; const DOMAIN_STATS_LIMIT: usize = 4096;
const FAST_REASONS: [&str; 4] = ["cache_disabled", "cache_hit", "cache_miss", "request_cookie"]; const FAST_REASONS: [&str; 4] = [
"cache_disabled",
"cache_hit",
"cache_miss",
"request_cookie",
];
type ReasonIndex = BTreeMap<&'static str, Arc<AtomicU64>>; type ReasonIndex = BTreeMap<&'static str, Arc<AtomicU64>>;
type DomainIndex = BTreeMap<String, BTreeMap<String, Arc<DomainStats>>>; type DomainIndex = BTreeMap<String, BTreeMap<String, Arc<DomainStats>>>;
@@ -51,13 +49,12 @@ impl DomainStats {
"HIT" => &self.hits, "HIT" => &self.hits,
"MISS" => &self.misses, "MISS" => &self.misses,
_ => &self.bypasses, _ => &self.bypasses,
}.fetch_add(1, Ordering::Relaxed); }
.fetch_add(1, Ordering::Relaxed);
self.record_reason(reason); self.record_reason(reason);
} }
fn record_reason(&self, reason: &'static str) { fn record_reason(&self, reason: &'static str) {
// Cache OFF is the highest-volume case; avoid reason map lookup and
// any shared lock/allocator activity for its dashboard reason.
if reason == "cache_disabled" { if reason == "cache_disabled" {
self.fast_reasons[0].fetch_add(1, Ordering::Relaxed); self.fast_reasons[0].fetch_add(1, Ordering::Relaxed);
return; return;
@@ -72,7 +69,10 @@ impl DomainStats {
return; return;
} }
drop(published); drop(published);
let _guard = self.register_reason.lock().unwrap_or_else(|e| e.into_inner()); let _guard = self
.register_reason
.lock()
.unwrap_or_else(|e| e.into_inner());
let published = self.reasons.load_full(); let published = self.reasons.load_full();
if let Some(counter) = published.get(reason) { if let Some(counter) = published.get(reason) {
counter.fetch_add(1, Ordering::Relaxed); counter.fetch_add(1, Ordering::Relaxed);
@@ -123,16 +123,22 @@ impl Default for DomainStatsStore {
impl DomainStatsStore { impl DomainStatsStore {
fn with(&self, scope: &str, domain: &str, action: impl FnOnce(&DomainStats)) { fn with(&self, scope: &str, domain: &str, action: impl FnOnce(&DomainStats)) {
let published = self.domains.load(); let published = self.domains.load();
if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) { if let Some(counters) = published
.index
.get(scope)
.and_then(|hosts| hosts.get(domain))
{
action(counters); action(counters);
return; return;
} }
drop(published); drop(published);
// Rare path: register only 4096 distinct (scope, domain) pairs. The
// published map is cloned on first insertion, never on a request hit.
let _guard = self.register.lock().unwrap_or_else(|e| e.into_inner()); let _guard = self.register.lock().unwrap_or_else(|e| e.into_inner());
let published = self.domains.load_full(); let published = self.domains.load_full();
if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) { if let Some(counters) = published
.index
.get(scope)
.and_then(|hosts| hosts.get(domain))
{
action(counters); action(counters);
return; return;
} }
@@ -141,7 +147,9 @@ impl DomainStatsStore {
} }
let mut next = (*published).clone(); let mut next = (*published).clone();
let counters = Arc::new(DomainStats::default()); let counters = Arc::new(DomainStats::default());
next.index.entry(scope.to_owned()).or_default() next.index
.entry(scope.to_owned())
.or_default()
.insert(domain.to_owned(), Arc::clone(&counters)); .insert(domain.to_owned(), Arc::clone(&counters));
next.count += 1; next.count += 1;
self.domains.store(Arc::new(next)); self.domains.store(Arc::new(next));
@@ -163,10 +171,10 @@ impl DomainStatsStore {
} }
fn saved_body_hit(&self, scope: &str, domain: &str, bytes: usize) { fn saved_body_hit(&self, scope: &str, domain: &str, bytes: usize) {
// COLD entries loaded from persisted metadata may be served before a
// domain is registered. Preserve first-hit byte accounting as well.
self.with(scope, domain, |counters| { self.with(scope, domain, |counters| {
counters.saved_body_bytes.fetch_add(bytes as u64, Ordering::Relaxed); counters
.saved_body_bytes
.fetch_add(bytes as u64, Ordering::Relaxed);
}); });
} }
+7 -11
View File
@@ -1,24 +1,20 @@
#[cfg(test)] #[cfg(test)]
include!("tests/tests.rs"); include!("cache_regression_tests.rs");
#[cfg(test)] #[cfg(test)]
include!("tests/scope_tests.rs"); include!("policy/scope_tests.rs");
#[cfg(test)] #[cfg(test)]
include!("tests/dashboard_tests.rs"); include!("dashboard_tests.rs");
#[cfg(test)] #[cfg(test)]
include!("tests/v022_cache_tests.rs"); include!("v022_cache_tests.rs");
#[cfg(test)] #[cfg(test)]
include!("tests/cold_tier_tests.rs"); include!("cold/cold_tier_tests.rs");
#[cfg(test)] #[cfg(test)]
include!("tests/http_invalidation_tests.rs"); include!("invalidation/http_invalidation_tests.rs");
#[cfg(test)] #[cfg(test)]
include!("tests/hot_hit_034_tests.rs"); include!("lookup/hot_hit_034_tests.rs");
-857
View File
@@ -1,857 +0,0 @@
mod cold_tier_tests {
use super::*;
use http_body_util::BodyExt;
fn cold_settings() -> Settings {
Settings {
enabled: true,
max_entries: 1,
max_bytes: 256 * 1024,
max_object_bytes: 64 * 1024,
cold_enabled: true,
cold_path: "cache/cold-test".into(),
cold_max_bytes: 2 * 1024 * 1024,
cold_max_entries: 16,
cold_after_seconds: 300,
..Settings::default()
}
}
async fn fill(cache: &Arc<Cache>, settings: &Settings, path: &str, body: &'static str) -> Ticket {
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
cache
.wrap(response, ticket.clone(), plan, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
async fn wait_for_cold(cache: &Cache, expected: usize) {
for _ in 0..100 {
if cache.view().cold_entries >= expected {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
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.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().cold_entries, 1);
let result = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Purge,
selector: InvalidationSelector::Exact,
value: "/one?v=1".into(),
host: Some("cold.example".into()),
vhost: Some("cold-test".into()),
ttl_seconds: None,
confirm_wide: false,
})
.unwrap();
assert_eq!(result.affected_entries, 1);
assert_eq!(cache.view().cold_entries, 0);
assert!(cache.get_tiered(&first, &settings).await.is_none());
shutdown.cancel();
}
#[tokio::test]
async fn aged_transformed_css_remains_fresh_when_demoted_to_cold() {
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.clone(), shutdown.clone());
let ticket = cache.ticket_scoped(
11, "cold-test", "http", "cold.example", "origin@127.0.0.1:8080",
"/aged-theme.css", &HeaderMap::new(),
).unwrap();
let body = b"body { color: red; margin: 0px; }";
let mut origin = Response::new(Body::from(Bytes::from_static(body)));
origin.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=2592000"));
origin.headers_mut().insert("age", HeaderValue::from_static("1553483"));
origin.headers_mut().insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap());
origin.headers_mut().insert("content-type", HeaderValue::from_static("text/css"));
origin.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap());
origin.headers_mut().insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan = plan(StatusCode::OK, origin.headers(), &settings, Duration::ZERO).unwrap();
let transformed = crate::transform::apply(origin, "/aged-theme.css",
&crate::transform::Settings { enabled: true, css: true, ..Default::default() }, false
).await.unwrap();
let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(transformed, ticket.clone(), origin_plan.restrict(transformed_plan), &settings)
.into_body().collect().await.unwrap();
assert_eq!(cache.view().entries, 1);
let _another = fill(&cache, &settings, "/another", "another-payload").await;
wait_for_cold(&cache, 1).await;
let row = cache.dashboard(Some("cold-test"), 0, 20).objects
.into_iter().find(|v| v.path == "/aged-theme.css").unwrap();
assert_eq!(row.tier, "cold");
assert!(row.transformed);
assert!(row.remaining_ttl_seconds >= 86_000);
shutdown.cancel();
drop(cache);
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let hit = cache.get_tiered(&ticket, &settings).await.expect("aged transformed COLD should HIT after restart");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
let transformed_body = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_ne!(transformed_body.as_ref(), &body[..]);
shutdown.cancel();
}
#[tokio::test]
async fn lru_demotion_persists_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
settings.validate().unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 1);
let cold = cache.dashboard(Some("cold-test"), 0, 50);
assert!(cold
.objects
.iter()
.any(|row| row.path == "/one" && row.tier == "cold"));
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 1);
let hit = restarted
.get_tiered(&first, &settings)
.await
.expect("cold object should be readable after restart");
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(payload, Bytes::from_static(b"first-payload"));
assert_eq!(restarted.view().cold_hits, 1);
assert_eq!(restarted.view().cold_promotions, 1);
restart_shutdown.cancel();
}
#[tokio::test]
async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() {
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.clone(), shutdown.clone());
let first = fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
{
let mut cold = cache.cold.lock().unwrap();
for entry in cold.entries.values_mut() {
entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
}
}
let dashboard = cache.dashboard(Some("cold-test"), 0, 50);
assert_eq!(dashboard.matching_objects, 2);
let row = dashboard.objects.iter().find(|row| row.path == "/one").unwrap();
assert_eq!(row.tier, "cold");
assert_eq!(row.remaining_ttl_seconds, 0);
assert!(row.age_seconds >= 2 * 60 * 60);
assert_eq!(row.storage_bytes, cache.view().cold_bytes);
assert!(cache.get_tiered(&first, &settings).await.is_none());
let mut stale = first;
stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale"));
let hit = cache.get_tiered(&stale, &settings).await.expect("explicit max-stale can reuse the retained disk object");
assert!(hit.stale);
assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "first-payload");
shutdown.cancel();
}
#[tokio::test]
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let transformed_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/transformed.json",
&HeaderMap::new(),
)
.unwrap();
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
transformed.headers_mut().insert(
"cache-control",
HeaderValue::from_static(
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed.headers_mut().insert(
"etag",
HeaderValue::from_static("\"rustproxy-representation\""),
);
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
transformed
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
});
transformed
.extensions_mut()
.insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
transformed_ticket.clone(),
transformed_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
let protected_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/protected.css",
&HeaderMap::new(),
)
.unwrap();
let original_body = "body { color: red; }";
let mut protected = Response::new(Body::from(original_body));
protected.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
);
protected
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-css\""));
protected.extensions_mut().insert(crate::compression::Skip);
let protected_plan = plan(
StatusCode::OK,
protected.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
protected,
protected_ticket.clone(),
protected_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.flush_hot_to_cold().await, 2);
wait_for_cold(&cache, 2).await;
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
// COLD entries intentionally keep validators and headers on disk to bound RAM.
// Inspect them through the same verified metadata loader used on COLD hits.
let (key, transformed) = {
let cold = restarted.cold.lock().unwrap();
cold.entries
.iter()
.find(|(_, entry)| entry.path == "/transformed.json")
.map(|(key, entry)| (*key, entry.clone()))
.expect("transformed cold entry")
};
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert!(transformed.has_validator);
assert!(transformed.transformed);
let metadata = read_cold_metadata(&key, &transformed)
.await
.expect("persisted transformed metadata");
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
metadata.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
let transformed_hit = restarted
.get_tiered(&transformed_ticket, &settings)
.await
.expect("transformed object after restart");
assert_eq!(
transformed_hit.response.headers()["etag"],
"\"rustproxy-representation\""
);
assert!(transformed_hit.transformed);
assert!(transformed_hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(
transformed_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(br#"{"a":1}"#)
);
let protected_hit = restarted
.get_tiered(&protected_ticket, &settings)
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(
protected_hit.response.headers()
));
assert!(protected_hit
.response
.extensions()
.get::<crate::compression::Skip>()
.is_some());
assert_eq!(
protected_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"body { color: red; }")
);
restart_shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_lru_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.cold_on_lru = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
tokio::time::sleep(Duration::from_millis(30)).await;
let view = cache.view();
assert_eq!(view.entries, 1);
assert_eq!(view.cold_entries, 0);
assert_eq!(view.evictions, 1);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_idle_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/idle", "idle-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.last_access = Instant::now() - Duration::from_secs(600);
}
}
cache.demote_idle();
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
settings.cold_on_idle = true;
cache.configure(settings.clone());
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_demote_by_total_hot_residency_age() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
settings.cold_on_age = true;
settings.cold_max_hot_seconds = 5;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/aged", "aged-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.resident_since = Instant::now() - Duration::from_secs(60);
entry.last_access = Instant::now();
}
}
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
assert_eq!(cache.view().entries, 2);
assert!(cache.view().resident_bytes > 0);
assert_eq!(cache.view().cold_entries, 0);
assert_eq!(cache.flush_hot_to_cold().await, 2);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(
view.transient_bytes, 0,
"persisted ColdEntry must not retain the body allocation"
);
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
restart_shutdown.cancel();
}
#[tokio::test]
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/update", "update-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_can_be_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_cache_uses_two_hex_character_shards() {
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.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let root = dir.path().join(&settings.cold_path);
let mut shards = Vec::new();
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(
item.path().is_dir(),
"cold root must contain shard directories only"
);
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard)
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
}));
shutdown.cancel();
}
#[tokio::test]
async fn legacy_flat_cold_cache_is_not_migrated() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let root = dir.path().join(&settings.cold_path);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
shutdown.cancel();
}
#[tokio::test]
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache
.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
})
.unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
assert_eq!(current.removed_entries, 1);
assert!(current.freed_bytes > 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
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();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let key = {
let mut cold = cache.cold.lock().unwrap();
let key = *cold.entries.keys().next().unwrap();
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
key
};
let request = ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
};
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache
.cold
.lock()
.unwrap()
.entries
.get_mut(&key)
.unwrap()
.pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 1);
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
shutdown.cancel();
}
#[test]
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
let cache = Cache::new(1);
let key = [7_u8; 32];
let template = ColdEntry {
base_key: key,
vary: Vec::new(),
revision: 1,
scope: "app".to_owned(),
scheme: "https".to_owned(),
authority: "example.test".to_owned(),
domain: "example.test".to_owned(),
path: "/asset".to_owned(),
target: "https://example.test/asset".to_owned(),
tags: Vec::new(),
created_at: SystemTime::now(),
body_path: PathBuf::from("old.body"),
meta_path: PathBuf::from("old.json"),
body_bytes: 1,
body_sha256: [0u8; 32],
disk_bytes: 2,
stored_at: SystemTime::now(),
last_access: SystemTime::now(),
age_at_store_seconds: 0,
resident_age_at_store_seconds: 0,
ttl: 60,
stale_while_revalidate: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
has_validator: false,
hits: 0,
transformed: false,
sequence: 1,
generation: 1,
skip_compression: false,
requires_revalidation: false,
pinned_reads: 1,
};
cache
.cold
.lock()
.unwrap()
.entries
.insert(key, template.clone());
let old_guard = ColdReadGuard {
cache: &cache,
key,
generation: 1,
};
let mut replacement = template;
replacement.sequence = 2;
replacement.generation = 2;
replacement.pinned_reads = 1;
cache.cold.lock().unwrap().entries.insert(key, replacement);
drop(old_guard);
let cold = cache.cold.lock().unwrap();
let replacement = cold.entries.get(&key).unwrap();
assert_eq!(replacement.generation, 2);
assert_eq!(replacement.pinned_reads, 1);
}
#[test]
fn cold_path_cannot_escape_data_directory() {
let mut settings = Settings::default();
settings.cold_path = "../outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "/tmp/outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "cache/cold".into();
assert!(settings.validate().is_ok());
}
#[tokio::test]
async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let mut fingerprints = HashMap::new();
fingerprints.insert("cold-test".to_owned(), 77);
let cache = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(),
fingerprints.clone(),
);
fill(&cache, &settings, "/one", "stable-contents").await;
fill(&cache, &settings, "/two", "second-contents").await;
wait_for_cold(&cache, 1).await;
shutdown.cancel();
drop(cache);
let restart = CancellationToken::new();
let same = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(),
fingerprints.clone(),
);
assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD");
restart.cancel();
drop(same);
fingerprints.insert("cold-test".to_owned(), 78);
let restart = CancellationToken::new();
let changed = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(), fingerprints,
);
assert_eq!(changed.view().cold_entries, 0, "old COLD must never survive a content revision");
restart.cancel();
}
}
+13 -13
View File
@@ -1,7 +1,4 @@
impl Cache { impl Cache {
/// Varnish-style primary hash: public URL/host identity first, with response
/// `Vary` handled as a secondary variant key. Irrelevant request headers and
/// load-balanced backend members therefore do not fragment the cache.
pub fn ticket( pub fn ticket(
&self, &self,
revision: u64, revision: u64,
@@ -23,12 +20,11 @@ impl Cache {
target: &str, target: &str,
headers: &HeaderMap, headers: &HeaderMap,
) -> Option<Ticket> { ) -> Option<Ticket> {
self.ticket_scoped_impl(None, revision, scope, scheme, authority, _route, target, headers) self.ticket_scoped_impl(
None, revision, scope, scheme, authority, _route, target, headers,
)
} }
/// Production requests bind the cache ticket to the immutable routing
/// configuration used to authorize/route the request. After a config save,
/// stale in-flight snapshots cannot re-fill a scope after its purge.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn ticket_scoped_for_config( pub fn ticket_scoped_for_config(
&self, &self,
@@ -41,7 +37,16 @@ impl Cache {
target: &str, target: &str,
headers: &HeaderMap, headers: &HeaderMap,
) -> Option<Ticket> { ) -> Option<Ticket> {
self.ticket_scoped_impl(Some(control_revision), revision, scope, scheme, authority, route, target, headers) self.ticket_scoped_impl(
Some(control_revision),
revision,
scope,
scheme,
authority,
route,
target,
headers,
)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -61,9 +66,6 @@ impl Cache {
} }
let domain = crate::config::request_host(authority) let domain = crate::config::request_host(authority)
.unwrap_or_else(|_| authority.chars().take(253).collect()); .unwrap_or_else(|_| authority.chars().take(253).collect());
// Varnish built-in hashing lowercases Host before hashing, but otherwise
// keeps the authority (including an explicit port) distinct. RustProxy has
// already validated the authority before this point.
let hash_scheme = scheme.to_ascii_lowercase(); let hash_scheme = scheme.to_ascii_lowercase();
let hash_authority = authority.to_ascii_lowercase(); let hash_authority = authority.to_ascii_lowercase();
let mut digest = Sha256::new(); let mut digest = Sha256::new();
@@ -336,8 +338,6 @@ impl Cache {
self: &Arc<Self>, self: &Arc<Self>,
cached: &Revalidation, cached: &Revalidation,
) -> Option<BackgroundRevalidationGuard> { ) -> Option<BackgroundRevalidationGuard> {
// Vary variants share a base Ticket key. Deduplicate only the exact cached
// representation so an SWR refresh for one variant cannot suppress another.
let key = cached.storage_key; let key = cached.storage_key;
let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner()); let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner());
if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) { if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) {
@@ -83,21 +83,55 @@ mod v022_cache_tests {
] { ] {
let cache = Cache::new(22); let cache = Cache::new(22);
let settings = active_settings(); let settings = active_settings();
let ticket = cache.ticket(22, "http", "example.test", "origin", "/transform.js", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(
22,
"http",
"example.test",
"origin",
"/transform.js",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body.clone())); let mut response = Response::new(Body::from(body.clone()));
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120")); response.headers_mut().insert(
response.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap()); "cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&body.len().to_string()).unwrap(),
);
response.extensions_mut().insert(crate::transform::Applied { response.extensions_mut().insert(crate::transform::Applied {
kind: crate::transform::Kind::Javascript, kind: crate::transform::Kind::Javascript,
original_bytes: body.len(), original_bytes: body.len(),
transformed_bytes: body.len(), transformed_bytes: body.len(),
}); });
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let policy = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(policy.expected_length, Some(body.len())); assert_eq!(policy.expected_length, Some(body.len()));
let downstream = cache.wrap(response, ticket.clone(), policy, &settings) let downstream = cache
.into_body().collect().await.unwrap().to_bytes(); .wrap(response, ticket.clone(), policy, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(downstream, body); assert_eq!(downstream, body);
let cached = cache.get(&ticket).unwrap().response.into_body().collect().await.unwrap().to_bytes(); let cached = cache
.get(&ticket)
.unwrap()
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(cached, body); assert_eq!(cached, body);
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0); assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
} }
@@ -107,26 +141,55 @@ mod v022_cache_tests {
async fn uniquely_owned_transformed_frame_is_cached_without_body_copy() { async fn uniquely_owned_transformed_frame_is_cached_without_body_copy() {
let cache = Cache::new(22); let cache = Cache::new(22);
let settings = active_settings(); let settings = active_settings();
let ticket = cache.ticket(22, "http", "example.test", "origin", "/owned.js", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(
22,
"http",
"example.test",
"origin",
"/owned.js",
&HeaderMap::new(),
)
.unwrap();
let payload = Bytes::from(vec![b'z'; 8192]); let payload = Bytes::from(vec![b'z'; 8192]);
let original_allocation = payload.as_ptr(); let original_allocation = payload.as_ptr();
let mut response = Response::new(Body::from(payload)); let mut response = Response::new(Body::from(payload));
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120")); response.headers_mut().insert(
response.headers_mut().insert("content-length", HeaderValue::from_static("8192")); "cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("8192"));
response.extensions_mut().insert(crate::transform::Applied { response.extensions_mut().insert(crate::transform::Applied {
kind: crate::transform::Kind::Javascript, kind: crate::transform::Kind::Javascript,
original_bytes: 8192, original_bytes: 8192,
transformed_bytes: 8192, transformed_bytes: 8192,
}); });
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let policy = plan(
let downstream = cache.wrap(response, ticket.clone(), policy, &settings) StatusCode::OK,
.into_body().collect().await.unwrap().to_bytes(); response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let downstream = cache
.wrap(response, ticket.clone(), policy, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(downstream.len(), 8192); assert_eq!(downstream.len(), 8192);
assert_eq!(downstream.as_ptr(), original_allocation); assert_eq!(downstream.as_ptr(), original_allocation);
let hit = cache.get(&ticket).unwrap(); let hit = cache.get(&ticket).unwrap();
let cached = hit.response.into_body().collect().await.unwrap().to_bytes(); let cached = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(cached, downstream); assert_eq!(cached, downstream);
assert_eq!(cached.as_ptr(), original_allocation, "cache fill copied the transformed body"); assert_eq!(
cached.as_ptr(),
original_allocation,
"cache fill copied the transformed body"
);
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0); assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
} }
@@ -180,8 +243,22 @@ mod v022_cache_tests {
) )
.await; .await;
let cases = [ let cases = [
(ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"), (
(ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"), ByteRange {
start: None,
end: Some(3),
},
"hij",
"bytes 7-9/10",
),
(
ByteRange {
start: Some(7),
end: None,
},
"hij",
"bytes 7-9/10",
),
]; ];
for (range, expected, content_range) in cases { for (range, expected, content_range) in cases {
let hit = cache.get(&ticket).unwrap().apply_range(range, None); let hit = cache.get(&ticket).unwrap().apply_range(range, None);
@@ -192,38 +269,66 @@ mod v022_cache_tests {
} }
let unsatisfied = cache.get(&ticket).unwrap().apply_range( let unsatisfied = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(50), end: None }, ByteRange {
start: Some(50),
end: None,
},
None, None,
); );
assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10");
assert_eq!( assert_eq!(
unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(), unsatisfied.response.status(),
StatusCode::RANGE_NOT_SATISFIABLE
);
assert_eq!(
unsatisfied.response.headers()["content-range"],
"bytes */10"
);
assert_eq!(
unsatisfied
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.len(),
0 0
); );
let mismatch = HeaderValue::from_static("\"asset-v2\""); let mismatch = HeaderValue::from_static("\"asset-v2\"");
let full = cache.get(&ticket).unwrap().apply_range( let full = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) }, ByteRange {
start: Some(0),
end: Some(2),
},
Some(&mismatch), Some(&mismatch),
); );
assert_eq!(full.response.status(), StatusCode::OK); assert_eq!(full.response.status(), StatusCode::OK);
assert!(!full.response.headers().contains_key("content-range")); assert!(!full.response.headers().contains_key("content-range"));
assert_eq!( assert_eq!(
full.response.into_body().collect().await.unwrap().to_bytes(), full.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"abcdefghij") Bytes::from_static(b"abcdefghij")
); );
let matched = HeaderValue::from_static("\"asset-v1\""); let matched = HeaderValue::from_static("\"asset-v1\"");
let partial = cache.get(&ticket).unwrap().apply_range( let partial = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) }, ByteRange {
start: Some(0),
end: Some(2),
},
Some(&matched), Some(&matched),
); );
assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT);
} }
#[test] #[test]
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() { fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation(
) {
let mut multipart = HeaderMap::new(); let mut multipart = HeaderMap::new();
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5")); multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
assert_eq!( assert_eq!(
@@ -318,13 +423,23 @@ mod v022_cache_tests {
let mut settings = active_settings(); let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 10; settings.collapse_wait_timeout_ms = 10;
let ticket = cache let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new()) .ticket(
22,
"http",
"collapse.example",
"origin",
"/timeout",
&HeaderMap::new(),
)
.unwrap(); .unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await { let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard, FillClaim::Leader(guard) => guard,
_ => panic!("missing leader"), _ => panic!("missing leader"),
}; };
assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout)); assert!(matches!(
cache.claim_fill(&ticket, &settings).await,
FillClaim::Timeout
));
assert_eq!(cache.view().collapse_timeouts, 1); assert_eq!(cache.view().collapse_timeouts, 1);
drop(leader); drop(leader);
assert!(matches!( assert!(matches!(
@@ -339,7 +454,14 @@ mod v022_cache_tests {
let mut settings = active_settings(); let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 5_000; settings.collapse_wait_timeout_ms = 5_000;
let ticket = cache let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new()) .ticket(
22,
"http",
"collapse.example",
"origin",
"/cancel",
&HeaderMap::new(),
)
.unwrap(); .unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await { let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard, FillClaim::Leader(guard) => guard,
@@ -377,10 +499,24 @@ mod v022_cache_tests {
let mut pl_headers = HeaderMap::new(); let mut pl_headers = HeaderMap::new();
pl_headers.insert("accept-language", HeaderValue::from_static("pl")); pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
let en = cache let en = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers) .ticket(
22,
"http",
"vary-collapse.example",
"origin",
"/page",
&en_headers,
)
.unwrap(); .unwrap();
let pl = cache let pl = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers) .ticket(
22,
"http",
"vary-collapse.example",
"origin",
"/page",
&pl_headers,
)
.unwrap(); .unwrap();
assert_eq!(en.key, pl.key, "Vary is learned from the response"); assert_eq!(en.key, pl.key, "Vary is learned from the response");
let leader = match cache.claim_fill(&en, &settings).await { let leader = match cache.claim_fill(&en, &settings).await {
@@ -455,7 +591,14 @@ mod v022_cache_tests {
fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() { fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() {
let cache = Cache::new(22); let cache = Cache::new(22);
let ticket = cache let ticket = cache
.ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new()) .ticket(
22,
"http",
"refresh.example",
"origin",
"/asset",
&HeaderMap::new(),
)
.unwrap(); .unwrap();
let mut settings = active_settings(); let mut settings = active_settings();
settings.proactive_refresh_enabled = true; settings.proactive_refresh_enabled = true;
@@ -511,7 +654,9 @@ mod v022_cache_tests {
&HeaderMap::new(), &HeaderMap::new(),
) )
.unwrap(); .unwrap();
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none()); assert!(cache
.begin_proactive_schedule(&overflow, &settings)
.is_none());
assert!(cache.view().proactive_skipped > 0); assert!(cache.view().proactive_skipped > 0);
drop(guards); drop(guards);
} }
@@ -557,11 +702,17 @@ mod v022_cache_tests {
#[tokio::test] #[tokio::test]
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() { async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
fn current_ticket(cache: &Cache, old: &Ticket) -> Ticket { fn current_ticket(cache: &Cache, old: &Ticket) -> Ticket {
// Each post-PURGE request obtains the current publication generation. cache
// Reusing an old ticket would reject unrelated entries too and make .ticket_scoped(
// assertions about removed objects pass without testing the selector. 22,
cache.ticket_scoped(22, &old.scope, &old.scheme, &old.authority, &old.scope,
"origin@127.0.0.1:8080", &old.target, &old.request_headers).unwrap() &old.scheme,
&old.authority,
"origin@127.0.0.1:8080",
&old.target,
&old.request_headers,
)
.unwrap()
} }
let cache = Cache::new(22); let cache = Cache::new(22);
let one = store( let one = store(
@@ -595,8 +746,19 @@ mod v022_cache_tests {
.unwrap(); .unwrap();
assert_eq!(result.affected_entries, 1); assert_eq!(result.affected_entries, 1);
assert!(cache.get(&current_ticket(&cache, &one)).is_none()); assert!(cache.get(&current_ticket(&cache, &one)).is_none());
let survivor = cache.get(&current_ticket(&cache, &two)).expect("unrelated query remains stored"); let survivor = cache
assert_eq!(survivor.response.into_body().collect().await.unwrap().to_bytes(), "two"); .get(&current_ticket(&cache, &two))
.expect("unrelated query remains stored");
assert_eq!(
survivor
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
"two"
);
let three = store( let three = store(
&cache, &cache,
@@ -832,7 +994,10 @@ mod v022_cache_tests {
"fresh", "fresh",
) )
.await; .await;
assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects"); assert!(
cache.get(&fresh).is_some(),
"old BAN must not reject newer objects"
);
assert_eq!(cache.bans().len(), 1); assert_eq!(cache.bans().len(), 1);
assert!(cache.remove_ban(ban_id)); assert!(cache.remove_ban(ban_id));
assert!(cache.bans().is_empty()); assert!(cache.bans().is_empty());
@@ -898,9 +1063,6 @@ mod v022_cache_tests {
"unchanged", "unchanged",
) )
.await; .await;
// A production cache may see an unbounded stream of PURGEs for URLs
// that will never be cached again. The invalidated-key index must
// not remember all of those removed objects indefinitely.
for index in 0..512 { for index in 0..512 {
let target = format!("/unique-{index}"); let target = format!("/unique-{index}");
let old = store( let old = store(
@@ -924,15 +1086,16 @@ mod v022_cache_tests {
)) ))
.unwrap(); .unwrap();
assert_eq!(result.affected_entries, 1); assert_eq!(result.affected_entries, 1);
assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale"); assert!(
!cache.ticket_current(&old),
"pre-purge ticket must remain stale"
);
let state = cache.store.lock().unwrap_or_else(|e| e.into_inner()); let state = cache.store.lock().unwrap_or_else(|e| e.into_inner());
assert!( assert!(
state.key_generations.len() <= 1, state.key_generations.len() <= 1,
"key generations must not accumulate for already purged URLs" "key generations must not accumulate for already purged URLs"
); );
} }
// A new ticket after the invalidation barrier is valid and can fill
// the same URL again, preserving ordinary cache behavior.
let new = store( let new = store(
&cache, &cache,
"app", "app",
@@ -956,6 +1119,9 @@ mod v022_cache_tests {
&HeaderMap::new(), &HeaderMap::new(),
) )
.unwrap(); .unwrap();
assert!(cache.get(&stable).is_some(), "unrelated entries must survive"); assert!(
cache.get(&stable).is_some(),
"unrelated entries must survive"
);
} }
} }
+5 -17
View File
@@ -1,6 +1,3 @@
//! Streaming content negotiation with deterministic server-side preference.
//! Existing Content-Encoding, ranges, upgrades, event streams and no-transform
//! responses are never recompressed. No full-body aggregation is introduced.
use anyhow::{ensure, Result}; use anyhow::{ensure, Result};
use axum::{body::Body, response::Response}; use axum::{body::Body, response::Response};
use http::{HeaderMap, HeaderValue, Method, Request}; use http::{HeaderMap, HeaderValue, Method, Request};
@@ -12,8 +9,6 @@ use tower_http::compression::{
Compression, CompressionLevel, Compression, CompressionLevel,
}; };
/// A u64 predicate independent of tower-http's version-specific SizeAbove type.
/// Unknown-size streams are compressed without buffering, like tower-http.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct MinimumSize(u64); struct MinimumSize(u64);
impl Predicate for MinimumSize { impl Predicate for MinimumSize {
@@ -72,8 +67,6 @@ impl Settings {
} }
} }
/// Per-vhost/Web-Service compression policy. None inherits the enclosing policy.
/// Global Response settings remain the hard codec ceiling and own minimum size.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Overrides { pub struct Overrides {
@@ -113,9 +106,6 @@ impl Overrides {
#[derive(Clone)] #[derive(Clone)]
pub struct Skip; pub struct Skip;
/// Encoding applied by RustProxy itself. This marker is kept in response
/// extensions so accounting never has to infer the selected codec from a
/// header that later response rules may rewrite.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppliedEncoding { pub enum AppliedEncoding {
Gzip, Gzip,
@@ -173,9 +163,6 @@ pub fn no_transform(headers: &HeaderMap) -> bool {
}) })
} }
/// Preserve exactly the request inputs used by encoding after forwarding has
/// consumed the request. Repeated field values and opaque bytes stay intact;
/// unrelated cookies, authorization and custom headers do not need a copy.
pub(crate) fn request_headers(headers: &HeaderMap) -> HeaderMap { pub(crate) fn request_headers(headers: &HeaderMap) -> HeaderMap {
let mut selected = HeaderMap::new(); let mut selected = HeaderMap::new();
for name in ["accept-encoding", "range", "cache-control"] { for name in ["accept-encoding", "range", "cache-control"] {
@@ -228,7 +215,10 @@ fn quality(raw: &str) -> Option<u16> {
Some((value * 1000.0).round() as u16) Some((value * 1000.0).round() as u16)
} }
pub(crate) fn preferred_encoding(headers: &HeaderMap, settings: &Settings) -> Option<AppliedEncoding> { pub(crate) fn preferred_encoding(
headers: &HeaderMap,
settings: &Settings,
) -> Option<AppliedEncoding> {
let mut explicit = [None; 4]; let mut explicit = [None; 4];
let mut wildcard = None; let mut wildcard = None;
for line in headers.get_all("accept-encoding").iter() { for line in headers.get_all("accept-encoding").iter() {
@@ -330,8 +320,6 @@ pub async fn encode(
{ {
response.extensions_mut().insert(applied); response.extensions_mut().insert(applied);
} }
// A strong validator for identity bytes is not a strong validator of the
// compressed bytes. Preserve semantic validation as a weak ETag.
if let Some(value) = response if let Some(value) = response
.headers() .headers()
.get("etag") .get("etag")
@@ -354,4 +342,4 @@ pub async fn encode(
} }
#[cfg(test)] #[cfg(test)]
include!("compression/tests.rs"); include!("tests.rs");
+5 -1
View File
@@ -1,4 +1,3 @@
//! Desired configuration and its validated, immutable routing snapshot.
include!("config/model.rs"); include!("config/model.rs");
include!("config/snapshot.rs"); include!("config/snapshot.rs");
include!("config/internal_routing.rs"); include!("config/internal_routing.rs");
@@ -8,3 +7,8 @@ include!("config/compile.rs");
#[cfg(test)] #[cfg(test)]
include!("config/tests.rs"); include!("config/tests.rs");
#[cfg(test)]
include!("config/route_policy_tests.rs");
#[cfg(test)]
include!("config/transport_tests.rs");
-13
View File
@@ -60,14 +60,8 @@ pub(crate) fn compile_backend_with_timeout(
Host::Ipv6(ip) => { Host::Ipv6(ip) => {
crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr) crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr)
} }
// DNS destinations remain subject to the normal self-target guard, but
// are never trusted for administration-cookie passthrough. The special
// control-plane path must point at the listener explicitly by IP.
Host::Domain(_) => false, Host::Domain(_) => false,
}; };
// Exact local listener targets are rejected by AddressPolicy by default, including DNS
// answers. An explicit per-backend risk acceptance can override only that self-target
// guard; remote origins on any valid backend port, including 9090, remain valid.
let authority = match url.host().context("backend host missing")? { let authority = match url.host().context("backend host missing")? {
Host::Ipv6(ip) => format!("[{ip}]:{port}"), Host::Ipv6(ip) => format!("[{ip}]:{port}"),
host => format!("{host}:{port}"), host => format!("{host}:{port}"),
@@ -120,7 +114,6 @@ pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Resu
(policy.proxy_addr, policy.tls_addr), (policy.proxy_addr, policy.tls_addr),
) )
} }
/// Validate both active sockets and the sockets that will be used after restart.
pub fn compile_with_defaults( pub fn compile_with_defaults(
config: Config, config: Config,
revision: u64, revision: u64,
@@ -129,8 +122,6 @@ pub fn compile_with_defaults(
) -> Result<Snapshot> { ) -> Result<Snapshot> {
compile_reusing(config, revision, policy, defaults, None) compile_reusing(config, revision, policy, defaults, None)
} }
/// Rebuild route policies and retain compatible backend clients, health observations,
/// full-exchange counters and affinity tokens through unrelated config edits.
pub fn compile_reusing( pub fn compile_reusing(
config: Config, config: Config,
revision: u64, revision: u64,
@@ -154,8 +145,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
let mut claimed_hosts: HashMap<String, Vec<crate::vhost::Listener>> = HashMap::new(); let mut claimed_hosts: HashMap<String, Vec<crate::vhost::Listener>> = HashMap::new();
for redirect in &config.redirects { for redirect in &config.redirects {
let bindings = redirect.frontend.resolved(&config.listeners)?; let bindings = redirect.frontend.resolved(&config.listeners)?;
// Disabled redirect hosts reserve their names as vhosts do, preventing
// an overlapping fallback from unexpectedly taking traffic.
for name in &redirect.frontend.hosts { for name in &redirect.frontend.hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default(); let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() { for a in claimed.iter() {
@@ -181,8 +170,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
} }
for host in &config.not_found_hosts { for host in &config.not_found_hosts {
let bindings = host.frontend.resolved(&config.listeners)?; let bindings = host.frontend.resolved(&config.listeners)?;
// Disabled local-response hosts reserve names too, avoiding accidental
// exposure through a broader wildcard vhost.
for name in &host.frontend.hosts { for name in &host.frontend.hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default(); let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() { for a in claimed.iter() {
+108 -44
View File
@@ -10,9 +10,6 @@ pub fn compile_with_active(
config.logging.validate()?; config.logging.validate()?;
config.error_log.validate()?; config.error_log.validate()?;
let desired_admin = config.administration.resolved_listen(policy.admin_addr); let desired_admin = config.administration.resolved_listen(policy.admin_addr);
// A persisted non-loopback address is only accepted with administration TLS.
// When `listen` is omitted, the startup/CLI address remains authoritative and
// its legacy plain-HTTP risk gate is enforced by main before compilation.
ensure!( ensure!(
config.administration.listen.is_none() config.administration.listen.is_none()
|| desired_admin.ip().is_loopback() || desired_admin.ip().is_loopback()
@@ -48,29 +45,53 @@ pub fn compile_with_active(
config.compression.validate()?; config.compression.validate()?;
config.server_header.validate()?; config.server_header.validate()?;
let forwarding = config.forwarding.compile()?; let forwarding = config.forwarding.compile()?;
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)"); ensure!(
config.php_fpm_profiles.len() <= 128,
"too many PHP-FPM profiles (max 128)"
);
let mut php_profiles = HashMap::new(); let mut php_profiles = HashMap::new();
for profile in &mut config.php_fpm_profiles { for profile in &mut config.php_fpm_profiles {
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?; profile
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(), .validate()
"duplicate PHP-FPM profile id"); .with_context(|| format!("PHP-FPM profile {}", profile.id))?;
ensure!(
php_profiles
.insert(profile.id.clone(), profile.php.clone())
.is_none(),
"duplicate PHP-FPM profile id"
);
} }
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)"); ensure!(
config.web_services.len() <= 128,
"too many Web Services (max 128)"
);
let mut web_services = HashMap::new(); let mut web_services = HashMap::new();
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused let mut php_endpoint_policies: HashMap<
// workers. Different directive sets on one socket can leak to the next vhost. String,
// Report unsafe legacy configurations but keep them loadable for migration. (
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new(); String,
std::collections::BTreeMap<String, String>,
std::collections::BTreeMap<String, String>,
),
> = HashMap::new();
for service in &mut config.web_services { for service in &mut config.web_services {
service.validate().with_context(|| format!("Web Service {}", service.id))?; service
// A profile changes effective runtime settings, not the saved standalone .validate()
// PHP configuration. Detaching a profile restores local settings exactly. .with_context(|| format!("Web Service {}", service.id))?;
let mut effective = service.clone(); let mut effective = service.clone();
if let Some(id) = &service.php_profile_id { if let Some(id) = &service.php_profile_id {
effective.php = php_profiles.get(id).with_context(|| { effective.php = php_profiles
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id) .get(id)
})?.clone(); .with_context(|| {
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?; format!(
"Web Service {} references unknown PHP-FPM profile {}",
service.id, id
)
})?
.clone();
effective
.validate()
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
} }
if effective.php.enabled { if effective.php.enabled {
let php = &effective.php; let php = &effective.php;
@@ -82,13 +103,25 @@ pub fn compile_with_active(
); );
} }
} else { } else {
php_endpoint_policies.insert(php.endpoint.clone(), ( php_endpoint_policies.insert(
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(), php.endpoint.clone(),
)); (
effective.id.clone(),
php.php_values.clone(),
php.php_admin_values.clone(),
),
);
} }
} }
let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?); let compiled = Arc::new(
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id"); effective
.compile()
.with_context(|| format!("Web Service {}", service.id))?,
);
ensure!(
web_services.insert(service.id.clone(), compiled).is_none(),
"duplicate Web Service id"
);
} }
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)"); ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new(); let mut vhost_ids = HashSet::new();
@@ -202,8 +235,6 @@ pub fn compile_with_active(
guarded_bindings.extend(previous.listeners.iter().map(|l| l.address)); guarded_bindings.extend(previous.listeners.iter().map(|l| l.address));
} }
guarded_bindings.extend(active.iter().map(|l| l.address)); guarded_bindings.extend(active.iter().map(|l| l.address));
// Startup/default addresses remain protected backend destinations even when
// the desired named-listener plan changes before a restart.
guarded_bindings.push(defaults.0); guarded_bindings.push(defaults.0);
guarded_bindings.extend(defaults.1); guarded_bindings.extend(defaults.1);
guarded_bindings.extend([ guarded_bindings.extend([
@@ -264,22 +295,29 @@ pub fn compile_with_active(
"duplicate ACL profile id" "duplicate ACL profile id"
); );
} }
// Prevent unbounded DNS ACL work across the entire published configuration.
crate::acl::dynamic::configured_hostnames(&config)?; crate::acl::dynamic::configured_hostnames(&config)?;
for agent in &config.docker_agents.agents { for agent in &config.docker_agents.agents {
for profile_id in crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) { for profile_id in
crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
{
ensure!( ensure!(
acl_profiles.contains_key(profile_id), acl_profiles.contains_key(profile_id),
"Docker agent {} references unknown default ACL profile {}", "Docker agent {} references unknown default ACL profile {}",
agent.id, profile_id agent.id,
profile_id
); );
} }
for assignment in &agent.assignments { for assignment in &agent.assignments {
for profile_id in crate::acl::profile_ids(assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids) { for profile_id in crate::acl::profile_ids(
assignment.acl_profile_id.as_deref(),
&assignment.acl_profile_ids,
) {
ensure!( ensure!(
acl_profiles.contains_key(profile_id), acl_profiles.contains_key(profile_id),
"Docker assignment {} on agent {} references unknown ACL profile {}", "Docker assignment {} on agent {} references unknown ACL profile {}",
assignment.service_key, agent.id, profile_id assignment.service_key,
agent.id,
profile_id
); );
} }
} }
@@ -392,7 +430,9 @@ fn validate_route_rewrite(
Ok(()) Ok(())
} }
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> { fn validate_location_redirect(
redirect: Option<&crate::vhost::LocationRedirect>,
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
let Some(redirect) = redirect else { let Some(redirect) = redirect else {
return Ok(None); return Ok(None);
}; };
@@ -406,26 +446,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>)
&& !redirect.target.chars().any(char::is_control), && !redirect.target.chars().any(char::is_control),
"invalid location redirect target" "invalid location redirect target"
); );
// Replace capture references with a harmless segment so URL structure can let authority_end = redirect
// be validated before requests reach the data plane. .target
let authority_end = redirect.target.find("://").and_then(|start| { .find("://")
.and_then(|start| {
let rest = &redirect.target[start + 3..]; let rest = &redirect.target[start + 3..];
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len())) rest.char_indices()
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?; .find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority"); .map(|(offset, _)| start + 3 + offset)
.or(Some(redirect.target.len()))
})
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(
!redirect.target[..authority_end].contains('$'),
"location redirect captures are not allowed in the URL authority"
);
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?") let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
.expect("static capture regex") .expect("static capture regex")
.replace_all(&redirect.target, "capture"); .replace_all(&redirect.target, "capture");
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?; let parsed =
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host"); url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials"); ensure!(
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
"location redirect target must use http/https and include a host"
);
ensure!(
parsed.username().is_empty() && parsed.password().is_none(),
"location redirect target cannot contain credentials"
);
let rewrite_regex = match redirect.rewrite_pattern.as_deref() { let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
None | Some("") => None, None | Some("") => None,
Some(pattern) => { Some(pattern) => {
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex"); ensure!(
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
"invalid location redirect rewrite regex"
);
let mut builder = regex::RegexBuilder::new(pattern); let mut builder = regex::RegexBuilder::new(pattern);
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024); builder
Some(builder.build().context("invalid location redirect rewrite regex")?) .size_limit(2 * 1024 * 1024)
.dfa_size_limit(2 * 1024 * 1024);
Some(
builder
.build()
.context("invalid location redirect rewrite regex")?,
)
} }
}; };
Ok(Some(crate::config::CompiledLocationRedirect { Ok(Some(crate::config::CompiledLocationRedirect {
+16 -9
View File
@@ -1,7 +1,4 @@
impl Snapshot { impl Snapshot {
/// Resolve a vhost-local internal named location. Internal locations are
/// deliberately excluded from ordinary URI matching and can only be reached
/// by an explicit internal dispatch such as an error fallback.
pub fn select_internal_on( pub fn select_internal_on(
&self, &self,
host: &str, host: &str,
@@ -25,21 +22,31 @@ impl Snapshot {
Self::find_host_pattern(host, |pattern| { Self::find_host_pattern(host, |pattern| {
let owns_host = self.fallbacks.get(pattern).is_some_and(|routes| { let owns_host = self.fallbacks.get(pattern).is_some_and(|routes| {
routes.iter().any(|route| { routes.iter().any(|route| {
route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id) route
&& route.bindings.iter().any(|binding| binding.matches(local, tls)) .frontend
.as_ref()
.is_some_and(|frontend| frontend.id == vhost_id)
&& route
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
}) })
}); });
if !owns_host { if !owns_host {
return None; return None;
} }
// Some(None) stops at the owning hostname even when the named
// location is absent. Do not try a less-specific wildcard.
Some(self.routes.get(pattern).and_then(|routes| { Some(self.routes.get(pattern).and_then(|routes| {
routes.iter().find(|route| { routes.iter().find(|route| {
route.internal route.internal
&& route.name == target && route.name == target
&& route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id) && route
&& route.bindings.iter().any(|binding| binding.matches(local, tls)) .frontend
.as_ref()
.is_some_and(|frontend| frontend.id == vhost_id)
&& route
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
}) })
})) }))
}) })
-26
View File
@@ -36,8 +36,6 @@ pub struct Config {
pub geoip_updates: crate::geoip::UpdateSettings, pub geoip_updates: crate::geoip::UpdateSettings,
#[serde(default)] #[serde(default)]
pub listeners: crate::settings::Listeners, pub listeners: crate::settings::Listeners,
/// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet
/// persist an explicit `binding.http2_settings` object.
#[serde(default)] #[serde(default)]
pub http2: crate::settings::Http2Connections, pub http2: crate::settings::Http2Connections,
#[serde(default)] #[serde(default)]
@@ -112,20 +110,14 @@ pub struct Backend {
pub address_allowlist: Vec<String>, pub address_allowlist: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_ca_pem: Option<String>, pub tls_ca_pem: Option<String>,
/// Optional DNS name used for TLS SNI and certificate hostname verification.
/// TCP/DNS connection targeting remains pinned to `url`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_server_name: Option<String>, pub tls_server_name: Option<String>,
/// Verify the HTTPS backend certificate chain and server identity.
/// Disabled by default for reverse-proxy compatibility; enable explicitly when required.
#[serde(default)] #[serde(default)]
pub tls_verify: bool, pub tls_verify: bool,
pub id: String, pub id: String,
pub url: String, pub url: String,
#[serde(default)] #[serde(default)]
pub allow_private: bool, pub allow_private: bool,
/// Explicitly allow a backend that resolves to a local RustProxy listener.
/// This can expose the control plane or create a proxy loop and is off by default.
#[serde(default)] #[serde(default)]
pub accept_local_listener_risk: bool, pub accept_local_listener_risk: bool,
#[serde(default)] #[serde(default)]
@@ -136,12 +128,8 @@ pub struct Backend {
pub enum UpstreamProtocol { pub enum UpstreamProtocol {
#[default] #[default]
Http1, Http1,
/// Legacy value `http2`: HTTPS uses ALPN H2/H1, HTTP uses H2C prior knowledge.
/// Retain its 0.32.2 meaning when loading unchanged saved configurations.
Http2, Http2,
/// HTTPS negotiates H2/H1; plaintext uses H1 without H2C probing.
Auto, Auto,
/// New explicit mode `h2`: require H2 for HTTPS and H2C for cleartext.
H2, H2,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -159,7 +147,6 @@ pub struct Update {
pub struct ValidationPolicy { pub struct ValidationPolicy {
pub tls_addr: Option<SocketAddr>, pub tls_addr: Option<SocketAddr>,
pub cert_dir: Option<PathBuf>, pub cert_dir: Option<PathBuf>,
/// Resolved runtime data directory. Exposed only through authenticated admin status.
pub data_dir: Option<PathBuf>, pub data_dir: Option<PathBuf>,
pub proxy_addr: SocketAddr, pub proxy_addr: SocketAddr,
pub admin_addr: SocketAddr, pub admin_addr: SocketAddr,
@@ -170,21 +157,14 @@ pub struct CompiledBackend {
pub client: crate::upstream::ProxyClient, pub client: crate::upstream::ProxyClient,
pub websocket_client: crate::upstream::ProxyClient, pub websocket_client: crate::upstream::ProxyClient,
pub id: String, pub id: String,
/// Configured network origin used by raw TCP probes and policy checks.
pub origin: String, pub origin: String,
/// HTTP request origin. For HTTPS this may carry a separate verified TLS name.
pub request_origin: String, pub request_origin: String,
/// Parsed once at configuration publish; reused for every upstream request.
pub request_origin_uri: http::Uri, pub request_origin_uri: http::Uri,
pub host_header: HeaderValue, pub host_header: HeaderValue,
pub preserve_host: bool, pub preserve_host: bool,
pub protocol: UpstreamProtocol, pub protocol: UpstreamProtocol,
/// Whether backend TLS certificate chain/name verification is enabled.
/// Retained in the compiled snapshot for failure diagnostics only.
pub tls_verify: bool, pub tls_verify: bool,
pub accept_local_listener_risk: bool, pub accept_local_listener_risk: bool,
/// True only when this backend URL names the configured administration socket
/// directly. Used by the guarded control-plane reverse-proxy path.
pub targets_admin_listener: bool, pub targets_admin_listener: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -208,7 +188,6 @@ pub struct CompiledRoute {
pub frontend: Option<Arc<CompiledFrontend>>, pub frontend: Option<Arc<CompiledFrontend>>,
pub pool: Option<Arc<crate::balance::Pool>>, pub pool: Option<Arc<crate::balance::Pool>>,
pub cache: Option<bool>, pub cache: Option<bool>,
/// Vhost-local stable location ID (`main` for the main source).
pub name: String, pub name: String,
pub id: String, pub id: String,
pub internal: bool, pub internal: bool,
@@ -237,10 +216,8 @@ pub struct CompiledFrontend {
pub websocket_rfc8441: bool, pub websocket_rfc8441: bool,
pub websocket_rfc9220: bool, pub websocket_rfc9220: bool,
pub force_ssl: bool, pub force_ssl: bool,
/// Optional quick www/non-www canonical redirect (from, to).
pub www_redirect: Option<(String, String)>, pub www_redirect: Option<(String, String)>,
pub forward_client_ip: bool, pub forward_client_ip: bool,
/// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected.
pub https_redirect_port: Option<u16>, pub https_redirect_port: Option<u16>,
pub http2_enabled: bool, pub http2_enabled: bool,
pub http3_enabled: bool, pub http3_enabled: bool,
@@ -264,7 +241,6 @@ pub struct CompiledFrontend {
pub acl_profile_id: Option<String>, pub acl_profile_id: Option<String>,
pub profile_acl: Option<CompiledAcl>, pub profile_acl: Option<CompiledAcl>,
pub profile_country_access: crate::rate_limit::CountryAccess, pub profile_country_access: crate::rate_limit::CountryAccess,
/// All Vhost profile scopes, checked before the inline ACL.
pub profile_policies: Vec<crate::acl::ProfileAccess>, pub profile_policies: Vec<crate::acl::ProfileAccess>,
pub acl: Option<CompiledAcl>, pub acl: Option<CompiledAcl>,
pub headers: CompiledHeaders, pub headers: CompiledHeaders,
@@ -295,7 +271,6 @@ pub struct Snapshot {
pub tls: Option<Arc<rustls::ServerConfig>>, pub tls: Option<Arc<rustls::ServerConfig>>,
pub envelope: Envelope, pub envelope: Envelope,
routes: HashMap<String, Vec<Arc<CompiledRoute>>>, routes: HashMap<String, Vec<Arc<CompiledRoute>>>,
/// Main vhost backend fallback, kept separate from user Custom locations.
fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>>, fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>>,
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>, redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>, not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
@@ -305,7 +280,6 @@ pub struct Snapshot {
} }
impl std::fmt::Debug for Snapshot { impl std::fmt::Debug for Snapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// QUIC crypto has no Debug implementation; never format private keys.
f.debug_struct("Snapshot") f.debug_struct("Snapshot")
.field("revision", &self.envelope.revision) .field("revision", &self.envelope.revision)
.field("listeners", &self.listeners) .field("listeners", &self.listeners)
+99
View File
@@ -0,0 +1,99 @@
mod route_policy_tests {
use crate::{
acl::{routing_path, Acl, Action, Rule as AclRule},
config::{compile, demo_config, ValidationPolicy},
headers::{apply as apply_headers, HeaderRules, Operation, Rule as HeaderRule},
vhost::{LocationResponse, PathRoute},
};
use http::HeaderMap;
fn policy() -> ValidationPolicy {
ValidationPolicy {
tls_addr: None,
cert_dir: None,
data_dir: None,
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
}
}
fn local_route() -> PathRoute {
PathRoute {
id: "local-status".into(),
path_prefix: "/status".into(),
response: Some(LocationResponse {
status: 200,
content_type: "text/plain; charset=utf-8".into(),
body: "ok".into(),
}),
..PathRoute::default()
}
}
#[test]
fn repeated_slashes_route_normally_but_reach_upstream_unchanged() {
let raw_target = "/api//v1/items";
let routing_target = routing_path(raw_target).unwrap();
assert_eq!(routing_target, "/api/v1/items");
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].routes = vec![PathRoute {
id: "api".into(),
path_prefix: "/api".into(),
..PathRoute::default()
}];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", &routing_target).unwrap();
assert_eq!(selected.id, "demo/api");
assert_eq!(raw_target, "/api//v1/items");
}
#[test]
fn local_location_response_uses_response_rules_without_opening_backend() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let mut route = local_route();
route.headers = HeaderRules {
request: vec![],
response: vec![HeaderRule {
op: Operation::Set,
name: "x-local-response".into(),
value: Some("yes".into()),
}],
};
config.vhosts[0].routes = vec![route];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", "/status").unwrap();
let response = selected.response.as_ref().expect("route must remain local");
assert_eq!(response.status, 200);
assert_eq!(response.body, "ok");
let mut headers = HeaderMap::new();
apply_headers(&mut headers, &selected.headers.response);
assert_eq!(headers.get("x-local-response").unwrap(), "yes");
assert!(selected.response.is_some());
}
#[test]
fn local_location_response_still_respects_route_acl() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let mut route = local_route();
route.acl = Some(Acl {
default: Action::Deny,
rules: vec![AclRule {
network: "10.0.0.0/8".into(),
action: Action::Allow,
}],
});
config.vhosts[0].routes = vec![route];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", "/status").unwrap();
let acl = selected.acl.as_ref().expect("route ACL must be compiled");
assert!(acl.allows("10.1.2.3".parse().unwrap()));
assert!(!acl.allows("192.0.2.1".parse().unwrap()));
assert!(selected.response.is_some());
}
}
-6
View File
@@ -77,7 +77,6 @@ fn compile_routing(
|| host_bindings.iter().all(|l| l.tls), || host_bindings.iter().all(|l| l.tls),
"prefixed affinity cookies require TLS on every resolved frontend" "prefixed affinity cookies require TLS on every resolved frontend"
); );
// Disabled vhosts also reserve their name, preventing fallback exposure.
for name in &routing_hosts { for name in &routing_hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default(); let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() { for a in claimed.iter() {
@@ -91,8 +90,6 @@ fn compile_routing(
} }
claimed.extend(host_bindings.iter().copied()); claimed.extend(host_bindings.iter().copied());
} }
// Backend definitions are irrelevant to a Web Server main source unless
// an enabled Custom Location explicitly uses Reverse Proxy.
let needs_proxy_pool = host.source.is_reverse_proxy() let needs_proxy_pool = host.source.is_reverse_proxy()
|| host.routes.iter().any(|route| { || host.routes.iter().any(|route| {
route.enabled route.enabled
@@ -119,8 +116,6 @@ fn compile_routing(
None None
} else { } else {
let mut pool_settings = host.load_balancing.clone(); let mut pool_settings = host.load_balancing.clone();
// Blank health-check Host means the first exact DNS name of the vhost.
// This is important when the backend URL is an IP shared by several vhosts.
if pool_settings.health_check.host.is_none() if pool_settings.health_check.host.is_none()
&& (pool_settings.health_check.mode == crate::balance::HealthMode::Http && (pool_settings.health_check.mode == crate::balance::HealthMode::Http
|| host.backends.iter().any(|member| { || host.backends.iter().any(|member| {
@@ -369,7 +364,6 @@ fn compile_routing(
if host.enabled && route.enabled { if host.enabled && route.enabled {
let (source, route_pool, backend) = let (source, route_pool, backend) =
if route.response.is_some() || route.redirect.is_some() { if route.response.is_some() || route.redirect.is_some() {
// Local return/redirect is handled before source dispatch and needs no origin.
(CompiledSource::ReverseProxy, None, None) (CompiledSource::ReverseProxy, None, None)
} else { } else {
match &route.source { match &route.source {
+21 -36
View File
@@ -1,13 +1,8 @@
impl Snapshot { impl Snapshot {
// Exact-first lookups with stack-buffered wildcard keys for normal hosts. fn find_host_pattern<T>(host: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> {
fn find_host_pattern<T>(
host: &str,
mut lookup: impl FnMut(&str) -> Option<T>,
) -> Option<T> {
if let Some(value) = lookup(host) { if let Some(value) = lookup(host) {
return Some(value); return Some(value);
} }
// Skip the final dot: only one label follows it.
let last_dot = host.rfind('.')?; let last_dot = host.rfind('.')?;
let mut wildcard = [0u8; 256]; let mut wildcard = [0u8; 256];
wildcard[..2].copy_from_slice(b"*."); wildcard[..2].copy_from_slice(b"*.");
@@ -25,7 +20,6 @@ impl Snapshot {
return Some(value); return Some(value);
} }
} else if let Some(value) = lookup(&format!("*.{suffix}")) { } else if let Some(value) = lookup(&format!("*.{suffix}")) {
// Preserve behavior for unusual overlong internal host inputs.
return Some(value); return Some(value);
} }
} }
@@ -40,7 +34,6 @@ impl Snapshot {
}) })
.unwrap_or(true) .unwrap_or(true)
} }
/// RFC 8441 is connection-scoped; requests still enforce vhost opt-in.
pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool { pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool {
self.frontends.values().flatten().any(|frontend| { self.frontends.values().flatten().any(|frontend| {
frontend.websockets frontend.websockets
@@ -51,7 +44,6 @@ impl Snapshot {
}) })
}) })
} }
/// RFC 9220 is connection-scoped; requests still enforce vhost opt-in.
pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool { pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool {
self.frontends.values().flatten().any(|frontend| { self.frontends.values().flatten().any(|frontend| {
frontend.websockets frontend.websockets
@@ -62,8 +54,6 @@ impl Snapshot {
}) })
}) })
} }
/// Production routing always uses the TCP connection's local endpoint.
/// Exact hostnames win over the longest matching wildcard hostname.
pub fn select_on( pub fn select_on(
&self, &self,
host: &str, host: &str,
@@ -73,7 +63,6 @@ impl Snapshot {
) -> Option<&CompiledRoute> { ) -> Option<&CompiledRoute> {
self.select_on_arc(host, path, local, tls).map(Arc::as_ref) self.select_on_arc(host, path, local, tls).map(Arc::as_ref)
} }
/// Shared compiled-route handle for middleware and forward().
pub fn select_on_arc( pub fn select_on_arc(
&self, &self,
host: &str, host: &str,
@@ -87,7 +76,6 @@ impl Snapshot {
.iter() .iter()
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls))) .find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
})?; })?;
// The claimed vhost owns its fallback ahead of broader wildcards.
self.routes self.routes
.get(pattern) .get(pattern)
.and_then(|routes| { .and_then(|routes| {
@@ -112,7 +100,6 @@ impl Snapshot {
}) })
}) })
} }
/// Resolve protocol-error attribution with the routing precedence and bindings.
pub fn resolve_logging_vhost( pub fn resolve_logging_vhost(
&self, &self,
local: SocketAddr, local: SocketAddr,
@@ -136,11 +123,7 @@ impl Snapshot {
}); });
} }
} }
for (candidate, matched_by) in [ for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
(authority, "authority"),
(host, "host"),
(sni, "sni"),
] {
let Some(candidate) = candidate else { let Some(candidate) = candidate else {
continue; continue;
}; };
@@ -156,7 +139,6 @@ impl Snapshot {
} }
None None
} }
/// Select redirects using accepted socket, TLS state and normalized path.
pub fn redirect_on( pub fn redirect_on(
&self, &self,
host: &str, host: &str,
@@ -189,7 +171,6 @@ impl Snapshot {
}) })
}) })
} }
/// Select a host-owned local 404/static response using the accepted socket.
pub fn not_found_host_on( pub fn not_found_host_on(
&self, &self,
host: &str, host: &str,
@@ -209,10 +190,12 @@ impl Snapshot {
}) })
}) })
} }
/// Legacy inspection helper only; network handlers must call select_on.
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> { pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
Self::find_host_pattern(host, |pattern| { Self::find_host_pattern(host, |pattern| {
let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?; let fallback = self
.fallbacks
.get(pattern)
.and_then(|routes| routes.first())?;
self.routes self.routes
.get(pattern) .get(pattern)
.and_then(|routes| select_route(routes, path, |_| true)) .and_then(|routes| select_route(routes, path, |_| true))
@@ -269,17 +252,25 @@ where
prefix prefix
} }
impl CompiledRoute { impl CompiledRoute {
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> { pub fn redirect_location(
&self,
path: &str,
uri: &http::Uri,
) -> Result<Option<http::HeaderValue>> {
let Some(spec) = &self.redirect else { let Some(spec) = &self.redirect else {
return Ok(None); return Ok(None);
}; };
let target = if let Some(regex) = &spec.rewrite_regex { let target = if let Some(regex) = &spec.rewrite_regex {
ensure!(regex.is_match(path), "location redirect rewrite regex did not match"); ensure!(
regex.is_match(path),
"location redirect rewrite regex did not match"
);
regex.replace(path, spec.target.as_str()).into_owned() regex.replace(path, spec.target.as_str()).into_owned()
} else { } else {
spec.target.clone() spec.target.clone()
}; };
let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?; let mut target =
url::Url::parse(&target).context("invalid compiled location redirect target")?;
if spec.preserve_query { if spec.preserve_query {
if let Some(query) = uri.query() { if let Some(query) = uri.query() {
let merged = match target.query() { let merged = match target.query() {
@@ -289,10 +280,11 @@ impl CompiledRoute {
target.set_query(Some(&merged)); target.set_query(Some(&merged));
} }
} }
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?)) Ok(Some(
http::HeaderValue::from_str(target.as_str())
.context("invalid redirect Location header")?,
))
} }
/// Return a normalized replacement path for the upstream hop. `None` means
/// preserve the client's exact origin-form path bytes.
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> { pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
use crate::vhost::RewriteMode; use crate::vhost::RewriteMode;
let rewritten = match self.rewrite.mode { let rewritten = match self.rewrite.mode {
@@ -346,7 +338,6 @@ pub(crate) fn valid_id(id: &str) -> bool {
.bytes() .bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
} }
/// Vhost names are ASCII DNS names (IDNs must be provided as punycode) or IPv4.
pub fn normalize_host(value: &str) -> Result<String> { pub fn normalize_host(value: &str) -> Result<String> {
let host = value let host = value
.strip_suffix('.') .strip_suffix('.')
@@ -370,8 +361,6 @@ pub fn normalize_host(value: &str) -> Result<String> {
); );
Ok(host) Ok(host)
} }
/// Configured frontend patterns may use a single leading `*.` wildcard.
/// Runtime Host values remain exact and are normalized by `normalize_host`.
pub fn normalize_host_pattern(value: &str) -> Result<String> { pub fn normalize_host_pattern(value: &str) -> Result<String> {
let value = value.trim(); let value = value.trim();
if let Some(base) = value.strip_prefix("*.") { if let Some(base) = value.strip_prefix("*.") {
@@ -395,13 +384,10 @@ pub fn normalize_host_pattern(value: &str) -> Result<String> {
pub fn request_host(value: &str) -> Result<String> { pub fn request_host(value: &str) -> Result<String> {
ensure!(!value.contains('@'), "invalid Host"); ensure!(!value.contains('@'), "invalid Host");
let authority: Authority = value.parse().context("invalid Host authority")?; let authority: Authority = value.parse().context("invalid Host authority")?;
// Authority::port() returns None for both an absent and an invalid port.
// Inspect the explicit suffix so an out-of-range port cannot bypass validation.
let raw_host = authority.host(); let raw_host = authority.host();
let suffix = &authority.as_str()[raw_host.len()..]; let suffix = &authority.as_str()[raw_host.len()..];
if !suffix.is_empty() { if !suffix.is_empty() {
let port = suffix.strip_prefix(':').context("invalid Host port")?; let port = suffix.strip_prefix(':').context("invalid Host port")?;
// HTTP permits an empty port suffix, which selects the default port.
if !port.is_empty() { if !port.is_empty() {
let number: u16 = port.parse().context("invalid Host port")?; let number: u16 = port.parse().context("invalid Host port")?;
ensure!(number > 0, "invalid Host port"); ensure!(number > 0, "invalid Host port");
@@ -461,7 +447,6 @@ pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> {
!(s[0] == 0x2001 && s[1] == 0x0db8), !(s[0] == 0x2001 && s[1] == 0x0db8),
"documentation address is not a backend" "documentation address is not a backend"
); );
// Only global unicast, ULA and loopback. Reject IPv4 translation/tunnel special ranges.
let ula = s[0] & 0xfe00 == 0xfc00; let ula = s[0] & 0xfe00 == 0xfc00;
let global = let global =
s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0); s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0);
+121 -46
View File
@@ -11,15 +11,21 @@ fn policy() -> ValidationPolicy {
} }
#[test] #[test]
fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() { fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() {
let snapshot = compile(demo_config("127.0.0.1:9000".parse().unwrap()), 1, &policy()).unwrap(); let snapshot =
compile(demo_config("127.0.0.1:9000".parse().unwrap()), 1, &policy()).unwrap();
let local = "127.0.0.1:8080".parse().unwrap(); let local = "127.0.0.1:8080".parse().unwrap();
let first = snapshot.select_on_arc("localhost", "/", local, false).unwrap(); let first = snapshot
let second = snapshot.select_on_arc("localhost", "/child", local, false).unwrap(); .select_on_arc("localhost", "/", local, false)
// Both selectors point into one immutable compiled snapshot. .unwrap();
let second = snapshot
.select_on_arc("localhost", "/child", local, false)
.unwrap();
assert!(std::sync::Arc::ptr_eq(first, second)); assert!(std::sync::Arc::ptr_eq(first, second));
let legacy = snapshot.select_on("localhost", "/", local, false).unwrap(); let legacy = snapshot.select_on("localhost", "/", local, false).unwrap();
assert!(std::ptr::eq(first.as_ref(), legacy)); assert!(std::ptr::eq(first.as_ref(), legacy));
assert!(snapshot.select_on_arc("not-this-host", "/", local, false).is_none()); assert!(snapshot
.select_on_arc("not-this-host", "/", local, false)
.is_none());
} }
#[test] #[test]
@@ -238,13 +244,27 @@ fn logging_vhost_resolution_reuses_router_host_precedence() {
assert_eq!(routed.matched_by, "router"); assert_eq!(routed.matched_by, "router");
let exact = snapshot let exact = snapshot
.resolve_logging_vhost(local, false, None, Some("API.EXAMPLE.TEST:8080"), None, None) .resolve_logging_vhost(
local,
false,
None,
Some("API.EXAMPLE.TEST:8080"),
None,
None,
)
.unwrap(); .unwrap();
assert_eq!(exact.vhost, "exact"); assert_eq!(exact.vhost, "exact");
assert_eq!(exact.matched_by, "authority"); assert_eq!(exact.matched_by, "authority");
let wildcard = snapshot let wildcard = snapshot
.resolve_logging_vhost(local, false, None, None, Some("www.example.test:8080"), None) .resolve_logging_vhost(
local,
false,
None,
None,
Some("www.example.test:8080"),
None,
)
.unwrap(); .unwrap();
assert_eq!(wildcard.vhost, "wildcard"); assert_eq!(wildcard.vhost, "wildcard");
assert_eq!(wildcard.matched_by, "host"); assert_eq!(wildcard.matched_by, "host");
@@ -449,8 +469,6 @@ fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() {
#[test] #[test]
fn custom_locations_override_but_main_vhost_remains_fallback() { fn custom_locations_override_but_main_vhost_remains_fallback() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
// The demo document still contains the pre-0.16 synthetic root route.
// Validation must normalize it away and keep the main backend internally.
let mut custom = c.vhosts[0].routes[0].clone(); let mut custom = c.vhosts[0].routes[0].clone();
custom.id = "test".into(); custom.id = "test".into();
custom.path_prefix = "/test".into(); custom.path_prefix = "/test".into();
@@ -525,7 +543,6 @@ fn backend_diagnostic_flags_compile_to_frontend() {
assert!(frontend.backend_timing_header); assert!(frontend.backend_timing_header);
} }
#[test] #[test]
fn path_boundaries() { fn path_boundaries() {
assert!(prefix_matches("/api", "/api")); assert!(prefix_matches("/api", "/api"));
@@ -544,7 +561,11 @@ fn invalid_per_backend_health_policy_rejected_without_global_override() {
..Default::default() ..Default::default()
}); });
assert!(compile(c.clone(), 1, &policy()).is_err()); assert!(compile(c.clone(), 1, &policy()).is_err());
c.vhosts[0].backends[0].health_check.as_mut().unwrap().timeout_ms = 200; c.vhosts[0].backends[0]
.health_check
.as_mut()
.unwrap()
.timeout_ms = 200;
assert!(compile(c, 1, &policy()).is_ok()); assert!(compile(c, 1, &policy()).is_ok());
} }
#[test] #[test]
@@ -715,8 +736,6 @@ fn local_response_hosts_reserve_frontends_and_do_not_require_backends() {
#[test] #[test]
fn conflicts_are_rejected() { fn conflicts_are_rejected() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
// Keep both entries as real Custom Locations; the untouched legacy root
// is intentionally normalized away before duplicate-matcher validation.
c.vhosts[0].routes[0].path_prefix = "/api".into(); c.vhosts[0].routes[0].path_prefix = "/api".into();
let mut duplicate = c.vhosts[0].routes[0].clone(); let mut duplicate = c.vhosts[0].routes[0].clone();
duplicate.id = "other".into(); duplicate.id = "other".into();
@@ -770,9 +789,8 @@ fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() {
#[test] #[test]
fn multiple_domains_share_vhost_and_selected_canonical_redirect() { fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec![ c.vhosts[0].frontend.hosts =
"suba.local".into(), "subb.local".into(), "site.test".into(), vec!["suba.local".into(), "subb.local".into(), "site.test".into()];
];
c.vhosts[0].www_redirect = true; c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("site.test".into()); c.vhosts[0].www_redirect_target = Some("site.test".into());
let snapshot = compile(c, 1, &policy()).unwrap(); let snapshot = compile(c, 1, &policy()).unwrap();
@@ -780,9 +798,13 @@ fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}"); assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}");
} }
for hostname in ["suba.local", "subb.local", "www.site.test"] { for hostname in ["suba.local", "subb.local", "www.site.test"] {
let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap(); let frontend = snapshot
assert_eq!(frontend.www_redirect.as_ref(), .frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false)
Some(&("www.site.test".into(), "site.test".into()))); .unwrap();
assert_eq!(
frontend.www_redirect.as_ref(),
Some(&("www.site.test".into(), "site.test".into()))
);
} }
} }
@@ -797,9 +819,13 @@ fn implicit_www_target_is_claimed_and_other_domain_stays_independent() {
assert!(snapshot.select(host, "/path").is_some(), "{host}"); assert!(snapshot.select(host, "/path").is_some(), "{host}");
} }
assert!(snapshot.select("www.suba.local", "/path").is_none()); assert!(snapshot.select("www.suba.local", "/path").is_none());
let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap(); let frontend = snapshot
assert_eq!(frontend.www_redirect.as_ref(), .frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false)
Some(&(String::from("subb.local"), String::from("www.subb.local")))); .unwrap();
assert_eq!(
frontend.www_redirect.as_ref(),
Some(&(String::from("subb.local"), String::from("www.subb.local")))
);
} }
#[test] #[test]
@@ -828,15 +854,25 @@ fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() { fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
// demo_config explicitly disables probes; this case exercises TCP fallback.
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Tcp; c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Tcp;
assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp); assert_eq!(
c.vhosts[0].load_balancing.health_check.mode,
crate::balance::HealthMode::Tcp
);
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck { c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default() mode: crate::balance::HealthMode::Http,
path: "/healthz".into(),
..Default::default()
}); });
let snapshot = compile(c, 1, &policy()).unwrap(); let snapshot = compile(c, 1, &policy()).unwrap();
let pool = &snapshot.pools[0]; let pool = &snapshot.pools[0];
assert_eq!(pool.settings.health_for(&pool.members[0].definition).host.as_deref(), Some("app.example.test")); assert_eq!(
pool.settings
.health_for(&pool.members[0].definition)
.host
.as_deref(),
Some("app.example.test")
);
} }
#[test] #[test]
fn explicit_http_health_check_host_override_wins_over_vhost_hostname() { fn explicit_http_health_check_host_override_wins_over_vhost_hostname() {
@@ -885,7 +921,9 @@ fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'ok';").unwrap(); std::fs::write(tmp.path().join("index.php"), b"<?php echo 'ok';").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.php_fpm_profiles.push(crate::web_server::PhpFpmProfile { config
.php_fpm_profiles
.push(crate::web_server::PhpFpmProfile {
id: "php-default".into(), id: "php-default".into(),
name: "Default PHP pool".into(), name: "Default PHP pool".into(),
php: crate::web_server::Php { php: crate::web_server::Php {
@@ -899,7 +937,6 @@ fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
name: "Site One".into(), name: "Site One".into(),
document_root: tmp.path().to_path_buf(), document_root: tmp.path().to_path_buf(),
php_profile_id: Some("php-default".into()), php_profile_id: Some("php-default".into()),
// Standalone settings remain distinct and disabled.
php: crate::web_server::Php::default(), php: crate::web_server::Php::default(),
..Default::default() ..Default::default()
}; };
@@ -916,7 +953,10 @@ fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
let snapshot = compile(config, 1, &policy()).unwrap(); let snapshot = compile(config, 1, &policy()).unwrap();
for id in ["site-one", "site-two"] { for id in ["site-one", "site-two"] {
let compiled = snapshot.web_php_services.get(id).unwrap(); let compiled = snapshot.web_php_services.get(id).unwrap();
assert!(compiled.php.is_some(), "{id} must use the shared FPM profile"); assert!(
compiled.php.is_some(),
"{id} must use the shared FPM profile"
);
} }
assert_eq!(snapshot.envelope.config.php_fpm_profiles.len(), 1); assert_eq!(snapshot.envelope.config.php_fpm_profiles.len(), 1);
assert!(!snapshot.envelope.config.web_services[0].php.enabled); assert!(!snapshot.envelope.config.web_services[0].php.enabled);
@@ -926,8 +966,18 @@ fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
let mut detached: Config = serde_json::from_str(&serialized).unwrap(); let mut detached: Config = serde_json::from_str(&serialized).unwrap();
detached.web_services[0].php_profile_id = None; detached.web_services[0].php_profile_id = None;
let snapshot = compile(detached, 2, &policy()).unwrap(); let snapshot = compile(detached, 2, &policy()).unwrap();
assert!(snapshot.web_php_services.get("site-one").unwrap().php.is_none()); assert!(snapshot
assert!(snapshot.web_php_services.get("site-two").unwrap().php.is_some()); .web_php_services
.get("site-one")
.unwrap()
.php
.is_none());
assert!(snapshot
.web_php_services
.get("site-two")
.unwrap()
.php
.is_some());
} }
#[test] #[test]
@@ -941,7 +991,9 @@ fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() {
php_profile_id: Some("missing".into()), php_profile_id: Some("missing".into()),
..Default::default() ..Default::default()
}); });
let err = compile(config.clone(), 1, &policy()).unwrap_err().to_string(); let err = compile(config.clone(), 1, &policy())
.unwrap_err()
.to_string();
assert!(err.contains("unknown PHP-FPM profile"), "{err}"); assert!(err.contains("unknown PHP-FPM profile"), "{err}");
config.web_services[0].php_profile_id = None; config.web_services[0].php_profile_id = None;
let profile = crate::web_server::PhpFpmProfile { let profile = crate::web_server::PhpFpmProfile {
@@ -962,9 +1014,10 @@ fn stack_host_lookup_keeps_exact_and_most_specific_wildcard_order() {
(pattern == "*.example.test").then_some("parent") (pattern == "*.example.test").then_some("parent")
}); });
assert_eq!(found, Some("parent")); assert_eq!(found, Some("parent"));
assert_eq!(checked, vec![ assert_eq!(
"api.eu.example.test", "*.eu.example.test", "*.example.test", checked,
]); vec!["api.eu.example.test", "*.eu.example.test", "*.example.test",]
);
checked.clear(); checked.clear();
let found = Snapshot::find_host_pattern("api.eu.example.test", |pattern| { let found = Snapshot::find_host_pattern("api.eu.example.test", |pattern| {
@@ -1007,7 +1060,13 @@ fn internal_routing_uses_host_lookup_without_cross_vhost_dispatch() {
let snapshot = compile(config, 1, &policy()).unwrap(); let snapshot = compile(config, 1, &policy()).unwrap();
let local = "127.0.0.1:8080".parse().unwrap(); let local = "127.0.0.1:8080".parse().unwrap();
let internal = snapshot let internal = snapshot
.select_internal_on_arc("sub.example.test", "wildcard", "internal-page", local, false) .select_internal_on_arc(
"sub.example.test",
"wildcard",
"internal-page",
local,
false,
)
.unwrap(); .unwrap();
assert_eq!(internal.name, "internal-page"); assert_eq!(internal.name, "internal-page");
assert!(internal.internal); assert!(internal.internal);
@@ -1042,8 +1101,6 @@ fn owned_host_without_internal_route_stops_wildcard_search() {
#[test] #[test]
fn stack_host_lookup_preserves_long_internal_host_fallback() { fn stack_host_lookup_preserves_long_internal_host_fallback() {
// Normalized runtime hosts are <=253 bytes. Direct internal callers with
// longer strings still follow the previous pattern order without panics.
let long = format!("a.{}.sub.example.test", "x".repeat(260)); let long = format!("a.{}.sub.example.test", "x".repeat(260));
let mut patterns = Vec::new(); let mut patterns = Vec::new();
let found = Snapshot::find_host_pattern(&long, |pattern| { let found = Snapshot::find_host_pattern(&long, |pattern| {
@@ -1051,7 +1108,10 @@ fn stack_host_lookup_preserves_long_internal_host_fallback() {
(pattern == "*.sub.example.test").then_some(true) (pattern == "*.sub.example.test").then_some(true)
}); });
assert_eq!(found, Some(true)); assert_eq!(found, Some(true));
assert_eq!(patterns[1], format!("*.{}.sub.example.test", "x".repeat(260))); assert_eq!(
patterns[1],
format!("*.{}.sub.example.test", "x".repeat(260))
);
assert_eq!(patterns[2], "*.sub.example.test"); assert_eq!(patterns[2], "*.sub.example.test");
} }
#[test] #[test]
@@ -1064,13 +1124,26 @@ fn multiple_profile_bindings_compile_and_do_not_weaken_inline_acl() {
c.vhosts[0].acl_profile_ids = vec!["office".into(), "home".into()]; c.vhosts[0].acl_profile_ids = vec!["office".into(), "home".into()];
c.vhosts[0].acl = Some(crate::acl::Acl { c.vhosts[0].acl = Some(crate::acl::Acl {
default: crate::acl::Action::Deny, default: crate::acl::Action::Deny,
rules: vec![crate::acl::Rule { network: "10.0.0.0/8".into(), action: crate::acl::Action::Allow }], rules: vec![crate::acl::Rule {
network: "10.0.0.0/8".into(),
action: crate::acl::Action::Allow,
}],
}); });
let snapshot = compile(c.clone(), 1, &policy()).unwrap(); let snapshot = compile(c.clone(), 1, &policy()).unwrap();
let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap(); let frontend = snapshot
.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false)
.unwrap();
assert_eq!(frontend.profile_policies.len(), 2); assert_eq!(frontend.profile_policies.len(), 2);
assert!(crate::acl::allows_profiles_with_country(&frontend.profile_policies, "192.0.2.5".parse().unwrap(), None)); assert!(crate::acl::allows_profiles_with_country(
assert!(!frontend.acl.as_ref().unwrap().allows("192.0.2.5".parse().unwrap())); &frontend.profile_policies,
"192.0.2.5".parse().unwrap(),
None
));
assert!(!frontend
.acl
.as_ref()
.unwrap()
.allows("192.0.2.5".parse().unwrap()));
c.vhosts[0].acl_profile_ids = vec!["office".into(), "unknown".into()]; c.vhosts[0].acl_profile_ids = vec!["office".into(), "unknown".into()];
assert!(compile(c.clone(), 2, &policy()).is_err()); assert!(compile(c.clone(), 2, &policy()).is_err());
@@ -1081,15 +1154,17 @@ fn multiple_profile_bindings_compile_and_do_not_weaken_inline_acl() {
#[test] #[test]
fn old_vhost_profile_field_survives_json_roundtrip() { fn old_vhost_profile_field_survives_json_roundtrip() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.acl_profiles = serde_json::from_value(serde_json::json!([{"id":"office","name":"Office"}])).unwrap(); c.acl_profiles =
serde_json::from_value(serde_json::json!([{"id":"office","name":"Office"}])).unwrap();
c.vhosts[0].acl_profile_id = Some("office".into()); c.vhosts[0].acl_profile_id = Some("office".into());
let json = serde_json::to_string(&c).unwrap(); let json = serde_json::to_string(&c).unwrap();
let decoded: Config = serde_json::from_str(&json).unwrap(); let decoded: Config = serde_json::from_str(&json).unwrap();
assert!(decoded.vhosts[0].acl_profile_ids.is_empty()); assert!(decoded.vhosts[0].acl_profile_ids.is_empty());
let snapshot = compile(decoded, 1, &policy()).unwrap(); let snapshot = compile(decoded, 1, &policy()).unwrap();
let frontend = snapshot.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false).unwrap(); let frontend = snapshot
.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false)
.unwrap();
assert_eq!(frontend.profile_policies.len(), 1); assert_eq!(frontend.profile_policies.len(), 1);
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office")); assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
} }
} }
Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff. Show more