0.34.6_repo_cleanup
This commit is contained in:
1 parent
ce1a90397c
commit
133d2f0f91
273 files changed
+4856
-5813
No files matched your search
+253
-253
@@ -9,7 +9,7 @@ fbba365b98bfc31b891d8ef240270b76987d67778b4d795552069e6b94f3f9ea Cargo.toml
|
||||
fcd1ace66c47e43e13b8cfadd76a9ae02ad545af944fa684c489034227776432 README.md
|
||||
ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
||||
7309cfaa188c7a1fcf30be1b8313ee99c00b7fe5d6d626ddefcc7345fa066112 build.rs
|
||||
2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md
|
||||
13d514de5042a230cacf8c10abe1ef183c94ac799a89b8e2c111152ac7c69b48 deploy/rustproxy.service
|
||||
c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh
|
||||
@@ -106,7 +106,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
|
||||
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
|
||||
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
|
||||
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
|
||||
26abaf315e087d2c14d477c80baef82453068dd2d258d091aaa2d7b238fa9428 scripts/preflight.py
|
||||
91c5ab0f82af5a4eb5589f5ebb19975f053eacaa46fa3d59b74f3ddde6370562 scripts/preflight.py
|
||||
3d6a0503afc25beab79ddc86864bcbb15248ded7368d33aa64321b8cdff59933 scripts/resolve_dependencies_0312.sh
|
||||
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
|
||||
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
||||
@@ -114,52 +114,52 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim
|
||||
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
|
||||
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
|
||||
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
|
||||
7f953ac05756e6e5d08e1b73835ed6a1226ec8e9439bc519e5ed04bcf3f4b492 src/acl.rs
|
||||
63efab1e95d41134fc30cc57320c32810bbae9f81a640bdb24b1be2059e63d8a src/acl/dynamic.rs
|
||||
4803e44de39b872a5b3b55f9ca9e81c066ba52cb8f6adb879fd57c5f94b93e89 src/acl/dynamic/tests.rs
|
||||
5caa062edfa2eb9e53ba0193089a7cd3204b4c9851564dbd4881c26b7a29347a src/acl/tests.rs
|
||||
6f5d3137bf24451da05c1db9f0f9e2a451305db17b6bc2590815ad54a6511829 src/acme.rs
|
||||
587097620b2f25ce7c6f59f907b47660184b095e3040c7615e74509a82e7cdd8 src/acme/jobs.rs
|
||||
3f158f46e80b403b658b4e9b1e3710e38c70e47b2d476b8223d9c394ffeeba95 src/acme/model.rs
|
||||
c5334de6c732d76b2bea7bfc17b3ac305a250bbaad31a5420d6a7d7abb200449 src/acme/providers.rs
|
||||
5130529d8f55ba9d89c29235a7eac133a80c9e9b2591267b1d082207568abbeb src/acl.rs
|
||||
adab6c8cd7c3f826e300effbd7d10c1bf12ee49253b3e5ad445e4ac04900f5dc src/acl/dynamic/mod.rs
|
||||
1eee2ea2c8dd6068dd0bbb058a06d26436f14767d8efbc2010bbdb4e3e523672 src/acl/dynamic/tests.rs
|
||||
75120cd3115aea6fc40665086617eab9816f08f556cef8db370806195c7641bf src/acl/tests.rs
|
||||
6189029ba493982acc38c9dc1f47fa1f0c933dec5ecfb95b60f8be83d33b5aa3 src/acme.rs
|
||||
b9eff08fddf9b8787aa04e72288ea2ca0033965e4563d060ca51f267e4963918 src/acme/jobs.rs
|
||||
f7f737aa28d2d209f4f20a221885e3bb3596dfc8e3c8c9a22ba53bb975c52df8 src/acme/model.rs
|
||||
40cd44f2b0d43ba45471ef5a012b3737ca3e16e33f6404b4f13b9cd9bd57f0ac src/acme/providers/mod.rs
|
||||
d1dcb7bdfb7cfdc9c3b88cfb6908201c58532bc888744a13f45853079f214659 src/acme/providers/tests.rs
|
||||
29ff0647417b0f1d50a4cc075ab51afecff9ddf47db3eb7948d51db5a38f33f3 src/acme/runtime_bootstrap.rs
|
||||
87f64198770ae026c03a242fa542db2590ca734b02e756f1e5c28d66236f753a src/acme/runtime_env.rs
|
||||
f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs
|
||||
e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs
|
||||
b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs
|
||||
12883df8e5a88d9da6026959b4736838ba9dd276f900ca14e33622dceeedbb25 src/admin/api_token_access.rs
|
||||
0a8a6fb22b90b11ab8ed329779aa3813f6a63a39edac7fe8d821402821073635 src/admin/api_tokens.rs
|
||||
b4a2a068acfa1143b0032b9cd072c19cf3fdda2580f0b932d11d6368cd2ab3ad src/admin/certificate_api.rs
|
||||
51f46a2cb3167c4e3a784dfbb59b8fe0e9d5d3d51365b26699498e31d0afb6a9 src/admin/certificates.rs
|
||||
3ec53c7f6e1feb1dcd23bf4ab54cabd8b3601d48977cc01a0ec47c3b2bd88182 src/acme/runtime_bootstrap.rs
|
||||
069b79d455d1126fd3967837493af896a2da9c584ceb582f2ffcceff321795eb src/acme/runtime_env.rs
|
||||
579ddf69314d7f1e11270b00135de64762d77d5ebc25bf98a52945086a4cea72 src/acme/runtime_versions.rs
|
||||
9c1fa211bfd307ce301ea7ef5cdac15269d69d5e01f47bc08a4cfc26502c56ac src/acme/tests.rs
|
||||
1171a1854708210e2f2107445a7c39620b8048478ff0f252f20193be5385cdf9 src/admin/access.rs
|
||||
bcf131abdae425a85b5a54ade51346f623de1e10fa08e18f7c10f1fd2dd0aec1 src/admin/api_token_access.rs
|
||||
5f5b150bb8f51f57462e519650093354ae7b40fcb80c4380fd3391a33f4b30f2 src/admin/api_tokens.rs
|
||||
852820625bd330f6eb87aa734d02ef9f42d1eb5ce31b0f10a8cc06b1287b5bf1 src/admin/certificate_api.rs
|
||||
a402209141667fdce69698a60dc0b867248effa191494d0a0d93d468d7df9476 src/admin/certificates.rs
|
||||
521ac794501570cfa2e8e50c3a5cec2ca08ee4dbce8ac9c0e0310c09612f1062 src/admin/config_reload.rs
|
||||
b5f6d49f1add583cfc8cd4aa36ffe4db56dac4de3b151fe8239fe42f507019cc src/admin/dashboard_presets.rs
|
||||
5e150d4ef0413a2f92db5b6631669933b84d4b4796633d01476c4b39188cd05c src/admin/documentation.rs
|
||||
ea7aafb4466caa66c6147f0e08d189c42222c43212bd7acd8e8842fd935a48ef src/admin/documentation/documents.rs
|
||||
159c3970d25fa29acf2483fc9422b003e5d63884713f62baa8375e4a4020730e src/admin/dashboard_presets.rs
|
||||
e9b03088840689636cba66eae0e4e53c495900aae3c2021add6ea3a2ef91977b src/admin/documentation.rs
|
||||
d4a249e8c2bf06a12fbd561fcdf5a5211aa5ed0e0dcd5111b84bbb13b3640c49 src/admin/documentation/documents.rs
|
||||
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
|
||||
1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs
|
||||
ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs
|
||||
c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs
|
||||
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
||||
4e15827a896c30e8aeba4677bc138695cce61c9feb847de0f3de9d66f542ff7a src/admin/logging_api.rs
|
||||
8d1cf7855e2b95798819af33a47a3209cf2438a6108c814cd291fe2ca89fa006 src/admin/mod.rs
|
||||
b4f225850d3d137ef23569d69774a46ccd5b02b21f7a035133cd14f45179d8ea src/admin/file_logs_io.rs
|
||||
75a51d477283f98d1b0d9b0aba1d593681fe4d120e243d9807c7ab3aa3ec21b5 src/admin/file_logs_query.rs
|
||||
f66ff718f82a326f80808b0a5088bd05a48625be802e5ba305fff3c344b59948 src/admin/logging_api.rs
|
||||
1ba0520c6348246cbb35b60e596e99dc600137c0696de86508cf06587bd86563 src/admin/mod.rs
|
||||
fff31f340853b3f1a26b443e483a0a3937acb1a2cdaff67643933a32a06887ac src/admin/openapi.json
|
||||
d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs
|
||||
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
||||
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
||||
92b3e05f04711d8001b56816405859631d9e917868e15be965c7e76ef4ddef99 src/admin/preferences.rs
|
||||
4a5cb4c3a3aa8b9a519020854506ce4be5e688a6c4298260322ae641dded7fe9 src/admin/preferences_columns.rs
|
||||
668cab06d8c8ddbeda2595bcb7aec236e0c56b22ca6adfe9f19c3d160de0fb39 src/admin/preferences_tests.rs
|
||||
4a7a2017ba57c9e7a8b29e73764d439f6d03653403909ef198ded8c48d152784 src/admin/public.rs
|
||||
74ad6cebcdfdd5ded422c6a720b5ad7d5f9566f1d995efeaec95319e2fe5a452 src/admin/operations.rs
|
||||
d75c1d31c8b57d64b0bf8d925b0b7630411b6a4b3e4be239fd9b8c82b5b97a82 src/admin/panel_assets.rs
|
||||
1a54d9c0594070d24e0faaf9497208085a5c7e0345dd30125e700727be34e010 src/admin/panel_content.rs
|
||||
730c559afaa8a50affc3eb6dc6fbde7005c0356237352fcf8c5423b65725a8fe src/admin/preferences.rs
|
||||
35a7f99c7448c90b8637f4b3a7becca138f1f378fe5dc3f87122151525bb20f9 src/admin/preferences_columns.rs
|
||||
51dbfdf8b228cd4a381aa720b19146418d23763670fca74ff729bd88b13d6782 src/admin/preferences_tests.rs
|
||||
62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs
|
||||
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
|
||||
9926827131254677a848c178c80850b3ca1993c05b4bf913687dc0c7992ad048 src/admin/refresh_preferences.rs
|
||||
ae6cc6020b896a54b9ba2dcbc0fbed9f13d289ebd7ef8804008386fca8533e85 src/admin/router.rs
|
||||
0ca8c45132b11c926967a0f78fc7e5e36c379a99597e13096ad453140c372cc2 src/admin/session.rs
|
||||
978f50b9ec93dc9bd6736f3dfd9a4c60ae732c9764a0cb232abcb1d42d9315d4 src/admin/status.rs
|
||||
6d3224349d08b1052ca66eb153aba513de53bcb666c6f57f00075509c6b6cc47 src/admin/status_live.rs
|
||||
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
|
||||
e81a0357d88ee3e3f9031755f4b0e2f5d381936abbbe569c5cde4cbc421b64e3 src/admin/refresh_preferences.rs
|
||||
00b5dd3090792986217b28c69bf826748145129e5517b260d95019b1d7b2ebb2 src/admin/router.rs
|
||||
2e7b0871e4af18ed3410f5b79c5fca6c75e6593d71b70b90aa3d80e3770bbb89 src/admin/session.rs
|
||||
0c86d94b714cfff26e184541ed3bfcb317c88e45afe084d5f3b28558946dd9b3 src/admin/status.rs
|
||||
2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs
|
||||
060e0b378cc48ce17c7e007675467d369ebaf113b64074e595cc471bbfe41800 src/admin/status_system.rs
|
||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||
5e52252a8e886d8e28eed1d456cdfb7387ea6a193007043662889ff366f6d6f5 src/admin/tests.rs
|
||||
fd38c6843e09508e90937362719da67a3678c657642ad5147cbce53ef891b011 src/admin/tests.rs
|
||||
c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js
|
||||
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
|
||||
86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js
|
||||
@@ -231,278 +231,282 @@ b3239e67901f1796d7a974ade0796f2e3590ff8064628a5629b78c7f6ce2f32e src/admin/web/
|
||||
69c8848159dcc7c4d93f49de3be75553cbecd0731d64302e2b644ae61196e704 src/admin/web/vhosts/render.js
|
||||
4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.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
|
||||
775178ee5781e64574bdd7c1fb7d64e7e283977fbd9c98c99896532a7f1df612 src/auth/mod.rs
|
||||
c96a87e82ff0c1c9ce3d0cd5135903bb53af78d1c4fb6ebfe5fea503bf5644ea src/auth/store.rs
|
||||
50ef648351271d78310e00caa6d246e0009171ed036a3e4e1144737df8c064c2 src/auth/mod.rs
|
||||
25b9f78a7eee277902d375266bd0f023b79794bcdf646214fc161c5e6dab72d1 src/auth/store.rs
|
||||
745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs
|
||||
09c3a622a7d2169e49e3369ea5f0f816eab2f717eaf1675c625d518c8f34d367 src/backup.rs
|
||||
32a25e53ac9542424debeaf1b637155bd4e60abd8692df082729d81232eb90b3 src/backup.rs
|
||||
76aa2afe9e7302be39b3512f30d0448d9134300f8ae930d64ada40cf3ad4a8a3 src/backup/archive.rs
|
||||
732cd4f9ed38ae69f8bbae61b1db8baaa6f86b07ca21c41f24a206a30f0a96fc src/backup/ownership.rs
|
||||
076350506215faec3cfe7d9de4e60baf29672cc22a41eabe601dcc510c257799 src/backup/snapshot.rs
|
||||
5c19490d834d9971166b6b784a75b081bb95fd50c8c634ef3c87924dfffd4a19 src/backup/tests.rs
|
||||
bb9de9d12dd57289b1941fd05b25f7de1249c3f114d663071b702a05b135879b src/backup/ownership.rs
|
||||
37f44cf3ecd71c7e817c76462c4c7de7ca6ecd68c0544f6429a861cdcde1fbdd src/backup/snapshot.rs
|
||||
ece7a5eda4b57bae8f5d819a309cb411b95b1845f5746ac162dee51456365857 src/backup/tests.rs
|
||||
f5afae218f3a6f91bc2f1f5a90094630ce5b7ca574de61807b5a317b13937ec2 src/backup/validation.rs
|
||||
426a946b2c9e244f09f6cbc5f9664b8ad0737529a9bb5a3d549d27196a1f8043 src/balance.rs
|
||||
ca1e03b56a872dca37ed9d05ead41c1538a808e80b09fb9494823c443cd51c61 src/balance/health.rs
|
||||
70c44291960b835c7a456cf066469b0002e1e35ba497663361d659d452a52e5e src/balance/lease_runtime.rs
|
||||
aba0f4ae46f05b07bfc434b7174553ce20e5187e1b40a6cf946eb2aefbe1840b src/balance/model.rs
|
||||
8550e5d64aa6fee442cea3e8e243fc4cfc1e8aa39726bc5cc6b2afa9307e3d05 src/balance/pool_core.rs
|
||||
eec564a7ef886d18ab75289025a47d62bb702f07d63f92d2f83b7afeecebbb15 src/balance.rs
|
||||
8f7975b488fad31f8d83ffedf8a5b6774b86b59290ce0711e779bf2496d5503f src/balance/health.rs
|
||||
344631248c71164187497a10d177d8eaa3e2f4a08111520d2536ec89ae55e1e0 src/balance/lease_runtime.rs
|
||||
ebe2b95f8fc5904850a88207f0690449ced49b28dc14ce5608b706659e338fca src/balance/model.rs
|
||||
aebcdcc1aaf8e38c4f286e6744c09525eef99625d1050479780c0f3c5b429c21 src/balance/pool_core.rs
|
||||
477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs
|
||||
8abb6048303ee8c022546a528d5693167766e7450efd8e20f37ec6fd0fa9ef5e src/balance/pool_view.rs
|
||||
814be4263c35b84de2b3c3bb3c60d7ead9f593154ac85aa6c8ae35700ee591c7 src/balance/tests.rs
|
||||
2b1d2da46b132c8f5e75784bc1d458e231a08342b76dc2aebfbe8124a5ddc959 src/basic_auth.rs
|
||||
a801a99646441cb748ab05f01101cc9c7a680788eb803c5b786ec6c5a7533e24 src/balance/pool_view.rs
|
||||
05227b05c37af2aebbde1ae37d81c2789088590ca45dcce3ae926c4fcf027bc9 src/balance/tests.rs
|
||||
16a22aa72f311d1fea6d8da03c3ea1590bc1f248b21876354eecc0da0fd93308 src/basic_auth/mod.rs
|
||||
9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs
|
||||
fb4bcb9d8852d2aeff41569291fe96d31c719d60789e860629a18cd15b8f24bf src/cache/cache_regression_tests.rs
|
||||
7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs
|
||||
a9eeb93519aae2de7a1282296ced91d2eb8307cc966f8fbf6085af0fd21b0ad2 src/cache/cold/cleanup.rs
|
||||
8354ae90c1a0857773a0c6d79427925e0115d94ccfafb809ccc1ebf4ef10f9b9 src/cache/cold/demotion.rs
|
||||
2eaba3261da2a547e9af91330319a3abd3a8ad8b5b44a337a84040720209017d src/cache/cold/startup.rs
|
||||
e7c7fd71727d542c2df3e03a4a1cba9d839c516963f66bbfde1c29ec81053500 src/cache/cold/worker.rs
|
||||
d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs
|
||||
563919c53d38e9b83505a5715469833569efb2a09e91a2b9a89c335187368dee src/cache/dashboard.rs
|
||||
7eaf3947cfc89cc6ef55a64fef0b3fedf1d9a1c33322aba2b5170c0ea34a5f2c src/cache/fill.rs
|
||||
b75ff512a1c6344483e00d2c67578ceea6b5472c279da0246f3622daa68d7e16 src/cache/cold/cleanup.rs
|
||||
cd5cace53675f995f526e7296822ee5c7fb4b05d01c0ebb641d3efceb04175f4 src/cache/cold/cold_tier_tests.rs
|
||||
79547f697246651c89aac685b26f0701dd3a1db6b028695fa5ecc3d2ae14f643 src/cache/cold/demotion.rs
|
||||
e80f84449412c4ae7bc753672a554160519bf1f186b5707252c3286cbe13f02e src/cache/cold/startup.rs
|
||||
83095bc93132f306c537b31ba929669fedac99af3c4f133d0533b5ed7ac4e6ed src/cache/cold/worker.rs
|
||||
c1ce42ca57844c9f80234c1995f1b151dcc9373a0d55387ee887a8098d434f9c src/cache/collapse.rs
|
||||
81d61fad3b5d3d6cc131cb3cbed8438528e7473b23026440ff1b993ee69b5876 src/cache/dashboard.rs
|
||||
5820a33f7be9473b2a67613462e95db029c2e44385e460323936d314963d6b10 src/cache/dashboard_tests.rs
|
||||
7c2549ce80a50f6e998b8f74a5fb86300f7ebfbf8a24b4c35f855bf6a40a7880 src/cache/fill.rs
|
||||
675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs
|
||||
c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs
|
||||
3b8f77bde71253a3ca600be5438b7cc72f7da258f3afa5de2cf64553e49922d1 src/cache/invalidation/invalidate.rs
|
||||
f3c5ec56f8d81a285877f6a07239081c2b1b064fc9143ba3bf36d616b0239d86 src/cache/lifecycle.rs
|
||||
d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/invalidation/http_invalidation_tests.rs
|
||||
ba90f7ae78b15c9cd13f9b75bccfb290d99e9cbba2023de73c7198ae24ca5637 src/cache/invalidation/invalidate.rs
|
||||
bfe59157d1656cdaf8e7cd82e659b25c7fcfd21c9b7ad17f7390466d7393957d src/cache/lifecycle.rs
|
||||
bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs
|
||||
bbd5659a3197e443b3b5b726d39d7b7934bb9aeaedcdc04285bbf4321d1359d4 src/cache/lookup/hot.rs
|
||||
5c3f749b3028e436a99614c22dbd05907b63c12a5c7c6baeda24c8f506db4f65 src/cache/lookup/insert.rs
|
||||
b38f93fa2eac694aac4dd2f1d69b55688928b53b78bf095d479843956c78e605 src/cache/lookup/tiered.rs
|
||||
5d4338ecccdbe9d78476e28491d92d4a1f96ab3c1d40590a7cfd1ba131b3ee12 src/cache/mod.rs
|
||||
d5d01ffcc68e5ec6a913f20df108964a5b325f1aa972c078ba5e6defde81591d src/cache/lookup/hot.rs
|
||||
a0a23e7ec1baa403524811f8096bbc026b1027b7b1b79357ad920b0a4be770b7 src/cache/lookup/hot_hit_034_tests.rs
|
||||
e9c6ab0c2be9f96573f826e6a1b9e4c2b8b526f779e72e95a6463ae07e45ab55 src/cache/lookup/insert.rs
|
||||
0338e0066e134b9e8032f117d9557b9bc5edc574f11710c2d36a6ca1c7467a99 src/cache/lookup/tiered.rs
|
||||
639d5be6258699ec7e9f260ec33bd2c366203b6655bf0e98f30517c2e12ceeeb src/cache/mod.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
|
||||
29bfce96b2ffd102d9c848c7b717a45fedb9aa44511f62f7a1f28a53dafd063a src/cache/model/entry.rs
|
||||
88d74fc1b7b9ca15a54a8f4893ad3993105952148bcfee099e9bc9727b622aa3 src/cache/model/http_invalidation.rs
|
||||
5ea7d8e72c714e5c5330dd81b5a6e2c8ed79e4367af85f133e63a756ad9788a3 src/cache/model/maintenance.rs
|
||||
5441fa3a384add28b6fdd15300cf2648f8237dbfc211cad9117754203be76911 src/cache/model/settings.rs
|
||||
55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs
|
||||
4a44e2161c2e8b2d6decca6caf1f9315a723c888e0894236c88c656b01b8442d src/cache/model/entry.rs
|
||||
eee9e6614cc5a185e07686f9ad24525728cc49dfe8b9ce6a6bad0c14f3f0bcd3 src/cache/model/http_invalidation.rs
|
||||
a7b4b861127fe2b1527f12e5ce01ebacf60fe266f913467a2cc66d7f80e98c60 src/cache/model/maintenance.rs
|
||||
298fa122c7583bf8447d08255c4d0ab335e23c7e48cac3f621f235c201046fb7 src/cache/model/settings.rs
|
||||
c2efe75f740ba31e83c49bf76e7fc179e897f41c140eea700e2675ae648e4a1c src/cache/model/views.rs
|
||||
e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs
|
||||
989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs
|
||||
d6051d55168fe1db97980a54c22c708f4f325ba72bfc36f35a1851eb5dcc051d src/cache/policy/response.rs
|
||||
7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs
|
||||
cf2513043813421e10260cef63645fdb8c5249114f16d91eae9f919b0214242a src/cache/stats.rs
|
||||
93bd3e9e5c1a045c784cee0e1fdb9e1c5d862ed70d5d91c54c5a769bca8fd949 src/cache/tests.rs
|
||||
9183fa8e6086e92fb148fc278fd866d44e3dd988c848a071e32066e44a1cbf0a src/cache/tests/cold_tier_tests.rs
|
||||
25ed7635bfd5e34de55b319bf163db9a0af26642664ce7685ebe1241bab52cac src/cache/tests/dashboard_tests.rs
|
||||
a0a23e7ec1baa403524811f8096bbc026b1027b7b1b79357ad920b0a4be770b7 src/cache/tests/hot_hit_034_tests.rs
|
||||
d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/tests/http_invalidation_tests.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
|
||||
d01b8443c6d02a9b5d7f1477719865ea105126b8620e8fe13d7b4711e47fffb1 src/cache/policy/request.rs
|
||||
7cf0318d2b6dfc5f00806d608d78eeae9535b4fbb22b28826aad11b72579f9f0 src/cache/policy/response.rs
|
||||
e59efd322f518ebd7b5b154115188f382c2cf7882ca0a401736ba5d685981ea5 src/cache/policy/scope_tests.rs
|
||||
982d4b32f5ef2f80e6e18dd7334152a903631d73edd2db532915e272b6bd2f59 src/cache/range.rs
|
||||
48c260e8dd75c89fb4518f6a20ece21017f69b0f5c9f9d80222ae34fab62b3b3 src/cache/stats.rs
|
||||
42a6bd984ee8ab69ba03030a113818252b26d5b922262b7759367d67899353e0 src/cache/tests.rs
|
||||
276a557d0ad85c4d5a7a3dae1e49e748591c79879f4c0d5784ec277ce8d08023 src/cache/tickets.rs
|
||||
9547a2fc3cf5220aa3edf3283e09b091f8b0fb6ccbe36e53f84c625c7482e08e src/cache/v022_cache_tests.rs
|
||||
c2d8548d12aeb2aaf02d47f6162904170526c9592aad9839a6bd5490feb269bc src/compression/mod.rs
|
||||
2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs
|
||||
7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs
|
||||
56b3d37e7447995bec2fdda1c17dcc74b60958a63ca88b88ff57e56347d8b0b9 src/config/backend.rs
|
||||
cb882e390bc429df11a1ca7c3a1d4175ffbb70c4e821b168c5d7a11f55163d14 src/config/compile.rs
|
||||
a4bb70f4da2ae1fa890e65ec15f87e919e2288637dc0eb2c383234e8babbcb05 src/config/internal_routing.rs
|
||||
8ab99c684da56a8efd7a44ab6e7de2877097150fa163b17e5cfedbf30bca5eeb src/config/model.rs
|
||||
25efab17c6955e33a3b3807b147ef25f1e8de352939fa60f5c9f24a5ef7602ef src/config/routing.rs
|
||||
48ecd9de5a54b0a4aa484f38a7e171f60a033b725e14b158d1ef07b67997f9eb src/config/snapshot.rs
|
||||
76af9d85af2bf16e98a02af25f818fc449e2aacba8c17689c2b7c3e59fe77b29 src/config/tests.rs
|
||||
f3c39dc4ac09b9aa7fa8ff01da933f67f25f5030c05f54eef1c7d1e064d1f3c1 src/db.rs
|
||||
ba82a4dede3dadd4b6c62434b0a0086420278067a46e93286d09b13e9ebc9d36 src/db/api_tokens.rs
|
||||
30f0b7269224a64e3f1ec9e6fbe9e27ddb1d308dc84cc9882e50545cf612695d src/db/certificate_api.rs
|
||||
20ba52d3755c359d670968efa53b0604ff7cbed63a416f9f765b50a1f45074ac src/db/dashboard_presets.rs
|
||||
e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs
|
||||
9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs
|
||||
7261770b504b5d2bb4637a2a755c8ddd8301636aaf703ce9c8a01b0623bf339b src/config/compile.rs
|
||||
4febd62f15f33079b843f59fed94ffb1e9cc8693f58e54f4ab15ee03fd653b9f src/config/internal_routing.rs
|
||||
0d9f62fe4600b230e200f41652effc22ac7c1b649fbe63505ed6ea34c644feea src/config/model.rs
|
||||
5a2b888c2b2b3e506a8f1ee846ebdef60c1b3a3befe312d05431d4591a78c709 src/config/route_policy_tests.rs
|
||||
40e9483d7b2450f55d29b46c408727f4e331b85ecda44fbd7b96bd9a09f02655 src/config/routing.rs
|
||||
422c43ef9fbbf6b9b8a44c162a95cda890ead6dfa1ccd24da4782844cbcb8834 src/config/snapshot.rs
|
||||
e09a3a8ca70ce7e42007553d48798bcb8b4fbdf791e338b36f238d7ab99fd05e src/config/tests.rs
|
||||
596a6697e8eea1d78fc86baaf8e9eb906ac38db6c1fe9a7ac62ecac85875940d src/config/transport_tests.rs
|
||||
eb04a8922da26e45c9b6005c0cdf1b1ddf2da3c3b680650b22a1d1f16d17a4b2 src/db.rs
|
||||
e37168285f1c7e17a17a028a151784743e2ed6b9b5daa5560eacd6c0403eefb6 src/db/api_tokens.rs
|
||||
111baffc0fc7d2001d6b7d2542f95593ba5723bf37af7b209a273bc56545436c src/db/certificate_api.rs
|
||||
5491dd7bb9b3fceac97c4dea63aba6d5c7abcb0fbd06e36ae986e00c79bc07d0 src/db/dashboard_presets.rs
|
||||
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
|
||||
c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs
|
||||
19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs
|
||||
e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs
|
||||
b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
|
||||
dada314e1709e960eb2e225b4d10c3cd12606c62c4bc2c0220aec1ba133e4ade src/demo.rs
|
||||
5808205f03401e3f2c7f5c826d5de76e83e4b6f94c64ad3e50f505f985f6d26e src/diagnostics.rs
|
||||
045593a42a6e4f632e5052c655788f0843faa313fc929265b89509f6dfff91ed src/docker.rs
|
||||
93a8a3f6ba12761ddcbd19f22f32fb2c0e6948b660fb774ddd7ebdba9b6562a5 src/docker/client.rs
|
||||
61ee4067da4824513791155326ea3f78d4dd711a486f21cddc44671a3bd0f1d9 src/docker/model.rs
|
||||
eef783112c8c45c64133edb1205f088179cd394f79f39cd452eb1f53dbffb00d src/docker/reconcile.rs
|
||||
f0bd0e64bdce72ac77d69805bee17d29776392187a09fd0087f019cc8c3e232f src/docker/tests.rs
|
||||
61cad5d547c4aa56017d7a7ab80f356670fdd4f5b232dc04a50dbb5969645dac src/forwarding.rs
|
||||
f0c8ce98426ed5e8e09dae48af4ade1cc328b76577cdaa92c8f0a5ff9eb4e284 src/forwarding/peer_formatting.rs
|
||||
01db5b1387755fb156f89f6dcec8031023483dd9b7eb17ba6c387e8179af4bd1 src/forwarding/tests.rs
|
||||
af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs
|
||||
da83e954040b281e99a9cacdf750d1745333b6c8ea1a5c3b6793ff01168e49e4 src/docker/model.rs
|
||||
a5239889203ac3fd6c82824ec3a8dfb1f250648ab05e3dea02a09183f82c1db2 src/docker/reconcile.rs
|
||||
3c0d97c9f6b8a5ccdf779ac2a965c39b9bd899dbb28a84e128d81fa75ccf696c src/docker/tests.rs
|
||||
e36db1ed3188ffe54df7fc4ef76c4b453f23683bb7b8cdf601046e2bd50e0403 src/forwarding.rs
|
||||
08d2e08a798abb944a53570f0bdaa08f1266b1107677ceda070091af4c2fc121 src/forwarding/peer_formatting.rs
|
||||
b821e2efd2939cb13d01e402532f33feba6682acb6a7a3223be086c1cd171820 src/forwarding/tests.rs
|
||||
5cef023035309caa5ca2035a11a77cd485f72092ab1c7670398d415460f84bc2 src/geoip.rs
|
||||
80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs
|
||||
06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs
|
||||
d3dc58628683472ef4d205fa5c42f0b7ef83cf90846146d96941cd3b36a40766 src/geoip/tests.rs
|
||||
70091bdf6f38f03b6df8c8c4407c300677e57f293130122503f936500963d37c src/geoip/update.rs
|
||||
c1b49bef28d87712e4c46138e70b52c4bcfb1a00196579031fac043d0b4aaa7b src/geoip/update_http.rs
|
||||
9a912dad0b8d1e10410fd95d0da3684656092ef85cf8b23eda4ffe7fb92921d8 src/geoip/update_tests.rs
|
||||
f6ebafafa82391f8cb3b5a09331f3762b6ca5a9fb64bafb5772cde3ab814dfcf src/headers.rs
|
||||
61a2b01db9010f9debfb38b43a26bf588438a5e470dff938e4e54f9d7ba46732 src/headers/mod.rs
|
||||
e4f07bfb7eb75b6897ef812870caff158679a3046d64582428abe8c6df490dbe src/headers/tests.rs
|
||||
502b170c1a708abc0e0b6b5579c66f4c0da816dd53f5ff82b2b97a1506644c01 src/http3.rs
|
||||
eb57117938a447d9a6c2ec1806ebd9fecc640a68c88950a8b35fa9bad14d0dc6 src/http3/body.rs
|
||||
ed25da264a2d25b40c4fcddc97c626a3a43d4f3a9be6c6d31af58b9e4925c198 src/http3/diagnostics.rs
|
||||
37f54961d8f52b79a75fb2be31e81f4fdb1e66b06350345c4483d6197b8a0bbc src/http3/identity.rs
|
||||
9d31c89ed7139d13f12f2031183f9542ee25e3f9d56303617c086747fe1487f6 src/http3/settings.rs
|
||||
8b7e840c93f08f49a5b15b2f594366930536070d76391db9fd2dd1e884ce88a8 src/http3/tests.rs
|
||||
d7c764af29c132e3d8d1fae1bdbe6bf20a27046dc294f83761fea335726f2306 src/http3.rs
|
||||
4b56bddbfaa1db9151807c7bc016e573e78afce2ecf593df628e2e311df61658 src/http3/body.rs
|
||||
dd4e4200743b43895c0ee2071dc9ce36659b10251ce23d1082be169594a20d76 src/http3/diagnostics.rs
|
||||
49ce4452ef7973cafa2e4071e2e14be0e16000e931a6501e4dc613652e49e230 src/http3/identity.rs
|
||||
0b41072f6bcf28725c80f471584cd55d4900424ce5d55ac7f6909813788c63fd src/http3/settings.rs
|
||||
01ed8610a8d169bafecf5ef91d325042d9b59a228caa8a89f76005cee20e6811 src/http3/tests.rs
|
||||
68fb5ab6a889995a66ad12cb6acf88c5d04f0aca572e2ae15f1a9cd171a5f24b src/lib.rs
|
||||
2551189a3e5701371026c9abd2c5d44d37495f28fb67e220180421bfd315e3dd src/logging.rs
|
||||
21ddb01b8f9c7fab60840d18103ef8de4716ff0c890cc1525b472b69af7916ec src/logging/error_runtime.rs
|
||||
add1293410e87ddae85ba4b8bec57b56ff54415b8112576c450f85920ab6c38c src/logging/format.rs
|
||||
f5815d778cc1785723faed4abb15f9880f22b6524b5289fe1e7158905aac0986 src/logging/model.rs
|
||||
b9bef61d332df0dec47f15d864a7aaea240935c432bf935e40144916e37553bc src/logging/runtime.rs
|
||||
106f769f45a832079b170e1cf11d7e6a78adba354bb0a88bfb8e3aff14f8cce6 src/logging/tests.rs
|
||||
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs
|
||||
7b81540a0f035f99bf2765a7d3ed75f9fcb8b72895a1ee51bc1aae3ae0c09869 src/main/admin_runtime.rs
|
||||
22e8ec9b36c66ec53a78b582f54aa320b2eb301c96ae4f23f92bcb84d4f1ebd7 src/main/bootstrap.rs
|
||||
e8399753a6ff49b7de19edb0a268bf871c9979a518354160cf07b4669051fcbb src/logging.rs
|
||||
4310aa77bec91dc473808787cebef664d2c9bda8d78742ad0fd410670f0f4f56 src/logging/error_runtime.rs
|
||||
9f9705e07cba9f3c2888babb2eef63b706549042e0f4ce29fb6a1646ae58f9b8 src/logging/format.rs
|
||||
e51524d45ddf54574bd33d71dd4ab18abbb642390fb6b6dda2a2c6e5439fe9a2 src/logging/model.rs
|
||||
3afbf5083695f21ae4a8bff10b81ec3dec55124007c31b8a0d36c3014e4873a0 src/logging/runtime.rs
|
||||
48187ebfebeaf6863207ce338df2d6d0f418fc3aa0689481c23c2a8ad9970b52 src/logging/tests.rs
|
||||
0e9993aa6679381d91f3a22068b30e1611439d7fe50900f49a4a73ffdfbdb3b1 src/main.rs
|
||||
04ad738f90c67754f9f133863a0e2ae94665357f527c2fc3db05863087ae4d83 src/main/admin_runtime.rs
|
||||
2c32c9b2014f9fcb111b64dd85f2106a8731b6d57fc3721522b2ec5f4349f4fd src/main/bootstrap.rs
|
||||
5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs
|
||||
33935951388699d3a78bcd4314fbde8226bc535e9c05932e7edef65e894bd7b9 src/main/shutdown.rs
|
||||
b240033715323e7cb7b9bcaf92edd481f7089b4da6bab25005ca6733da8756dc src/main/shutdown/mod.rs
|
||||
0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs
|
||||
e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs
|
||||
1def53b16ae1a498e6d31e4ecd7337af240121abe36ca505fed087bd4e426ab8 src/metrics.rs
|
||||
6eb36a1a8a2bd04833aba17cca9645a8c55fcf93d512c9ac44c5368b83fe4059 src/metrics/global.rs
|
||||
2715e2aa3068d9a4dd8f9cbf44e31917c4166111e6fd770219fc2e26cbbec3e0 src/metrics/rate_sampling.rs
|
||||
78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs
|
||||
e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs
|
||||
a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs
|
||||
ca01c97cc52a50797306193a546e4379fe3ce9401b0c936c756b881180315ab0 src/main/tasks.rs
|
||||
a719c51aebcba0f16f8810c37618201954e79d6806da085ee6c5e4697a05b521 src/metrics.rs
|
||||
9c7ed78345abc33d260f8d5cd52339bf8de748c08b53eaa2bdea8a32a0ea2a20 src/metrics/global.rs
|
||||
def9a23ea4edd87fe142ac597387f1c0235088c95c693809f5793279d4cbe02f src/metrics/rate_sampling.rs
|
||||
f27f1f8bed769883d1a7ae6a48f89f7712394dfab65d05702b0f944f0a45bd8e src/metrics/vhost/mod.rs
|
||||
40cc25ed1993127dece24857221b6da2debac37faf403abcb451ec442a240273 src/metrics/vhost/tests.rs
|
||||
e7ddf1f453a3e207ece18a4358cb44cc02ee68333ecc0b6c68028ad17661cc8b src/not_found/mod.rs
|
||||
c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs
|
||||
3567c2b65b09553e8aa46fb223dcab885b358fee9fc69cd0faf148059433b9e4 src/pages.rs
|
||||
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
|
||||
63a84af77b6c3dd390aa9dfde71c527eec6e1a3c0a38c1f02c08055e525f250c src/prometheus.rs
|
||||
d32d757ace8753eccb842c24b7af77061141478d10fe0acdfb05263d19e9a64c src/pages/mod.rs
|
||||
8416712d3e8ce81a1794152ef98220313066527d613142cc0958c4915add8c39 src/pages/tests.rs
|
||||
5650a3f70162abd97021e0a51a92cac2c4dd39004426ef99dff36cbb0741aeac src/prometheus.rs
|
||||
192a780d5874afc158d62a7dbe1687d734635afe9342fff526b8a2f6db4e5633 src/prometheus/core.rs
|
||||
74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs
|
||||
97926653a7c00e884784c2739a998d666abb3a891f1642bcf1ecc6c49d0efa62 src/prometheus/json.rs
|
||||
c34c3204bfb07a0de95250dfc1208979a5e701ccf5b4093cfbfd9b996ff3ba15 src/prometheus/model.rs
|
||||
67ea91c74c9e7860ee16da8f3543f642d5b6549a148a50a35e4d8f660510517f src/prometheus/tests.rs
|
||||
2321ee667569803dfe6c92c0e98adba0ac8fcdc6a3eacd5857233f1acae93daf src/prometheus/docker_misc.rs
|
||||
cc909edf740a091f9057912f555dd888b644fa1cb49647fe0e2e1f5dba8ff5af src/prometheus/json.rs
|
||||
f9aab3297fcf647295849f77cbf99197f4dd27efd6817e54c03451e04972430c src/prometheus/model.rs
|
||||
2ea975c96fb45bfeeda95583e909abb26328a16e411fa46a1e31b2a359f7e711 src/prometheus/tests.rs
|
||||
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
|
||||
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
|
||||
fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs
|
||||
2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs
|
||||
66b7f2cb47f2e70dc26b7b19eb8650872e34d3e44cf250d2effdb758295f8b8a src/proxy/body/tests.rs
|
||||
2c65303210bc6783e4c75d0ecedc9be5a1bbeb063ceea8e1ac9983edbe9f5d31 src/proxy/cache_helpers.rs
|
||||
aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs
|
||||
676c1b325fd8e55bcdd86451c7d1d8b8d347ca00e8da6b3e5b148a58c376a9b4 src/proxy/cache_plan.rs
|
||||
f1b0254f53b1a4336bd8170ad441ba072b49a114e497b985cb20699b4e375044 src/prometheus/web_php.rs
|
||||
0e8db3facf4f4df7b05f058e2dccc8521d88bea9da67ac0889b77d349d524d9c src/proxy/body/mod.rs
|
||||
57a71c9c6e4e0deba5d05963b9870af8f4c4d8dbd321ed4e52cee88746c997f0 src/proxy/body/tests.rs
|
||||
dff5d29ad6d126fde26f2b5e770c3b35275a82b6d59e41dd85edf28ac5eb2740 src/proxy/cache_helpers.rs
|
||||
7e3837d2f6ba7d07ca2cbabda57b315f5a7f4d75a13e5eddf6861a150d0d2ed6 src/proxy/cache_http_invalidation.rs
|
||||
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/cache_http_invalidation_tests.rs
|
||||
a679baaa062beadff9ddb61a3bd6cde1ce27cc62147d2e27b468c0a8cd32ceee src/proxy/cache_plan/mod.rs
|
||||
3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs
|
||||
a8848cbdf85368548b003af2b43823c453866c472af4c7d2ff0ec7da14c44c8d src/proxy/error_fallback.rs
|
||||
f2f9cae1c74eaf66fc365c0709a6eb64e0b27457d4a00728e02cff8a641a1295 src/proxy/forward/cache_lookup.rs
|
||||
cfa941b60b5d167ec9d8e2566ea3342b588d1a9078a6ca711ad6062ddf00fec9 src/proxy/forward/response.rs
|
||||
44e1bae0a35ddae6a084c1d50b936fd10dd51059f331525b758447b526a2bddf src/proxy/forward/route_cache.rs
|
||||
691b54a0cc33089e67d61ffc715b5c015976bae3fbd453b26cb06764be2482e5 src/proxy/forward/success_finish.rs
|
||||
0a921474ef2b93eabdaf3888651cf29130eace5ccb1e0f5d6bc25e6aa69280be src/proxy/forward/success_prepare.rs
|
||||
3f21439bbe4966fff43b3ebce8b59fa76ecce376197b4c56a2dce3a6d98f5556 src/proxy/forward/upstream.rs
|
||||
bb8c9117e05b0cb4bbfe029ef4d1a031fc755cdce255b15dad072afd1c845737 src/proxy/forward/web_source.rs
|
||||
d21cefaeb3b4e4bd187705124e281f44bb0d2b597fbe076f3b2ca00a1c024302 src/proxy/forward_entry.rs
|
||||
a0b9e2e9ab0bc89c7b64bd19365198234b1c5c13f455ea47be122a5660639b17 src/proxy/error_fallback.rs
|
||||
c825f436ce0730ce542f51a1363a0fdfb0b1a4113a2257384d052de1afd56359 src/proxy/forward/cache_fast_path_tests.rs
|
||||
a20cd11a504e423e5b1af5cebb5b4186530d4eaacaa02c3212a1174cdbfb6498 src/proxy/forward/cache_lookup.rs
|
||||
0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/forward/h3_navigation_tests.rs
|
||||
ce97cfcb07d90415937f0520ee7175502b22e60694c1ea8e3d2b47678d190e5f src/proxy/forward/response.rs
|
||||
02e1fbe16e59c746d57dfbabf655a729a2cde8131886689532a414c47467a53f src/proxy/forward/route_cache.rs
|
||||
3f91a40a01378d1fbac56ec376df6626e0c4fb6c3b8c8d42583b9c398d368ad9 src/proxy/forward/success_finish.rs
|
||||
e631c63fc10a23e4533bbd158659710b184656b323d0e6169a8627294c6972ba src/proxy/forward/success_prepare.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
|
||||
cc7fe83f42acb19d79abe83107427cdc6b0ed14083702f4fe66af22f7cdfc4cc src/proxy/headers.rs
|
||||
5b07b98d855453d5a4272f5fc98a75973c3e6032416e0fe4f7f151c1bf239fd8 src/proxy/headers/authority_tests.rs
|
||||
3e642599bb0aaa3ab36d83a69d9e04541cddde90f38661934ee0255011ceefb5 src/proxy/headers/mod.rs
|
||||
0f9314d1380febe436def36b5f381731e97cbac90625637575f38f3d72eaaace src/proxy/headers/tests.rs
|
||||
9ed6577b381285d2b7b874f60fd38d8192a0a5f3dbabdb5b737091158c43ac31 src/proxy/identity.rs
|
||||
050c66311f71cac75c1d4c8547351a1d6f1111ed0db128691366fdd73647e90a src/proxy/mod.rs
|
||||
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
|
||||
2280841494c3e14ccc5e3f02b026fe3c4328a3dab04ab449b9286bb69e39c028 src/proxy/request_id.rs
|
||||
c04451ac8abebf4662ccc3ee4610d5342ad5382da2098f7af5db274d079770b6 src/proxy/identity.rs
|
||||
a08ee061e146cb7a08311106f73a04468c89f77b6ad05a2c623621a2ae9faf2a src/proxy/mod.rs
|
||||
5559efb5984e8da34235e2e5cb34cd6865a34ede20e159bc9f04fc9190a5c09f src/proxy/redirect_helpers.rs
|
||||
de1763b89d98607e20f0f6e3c9b546483927cbe5450efc2e5634fe691fd52ad6 src/proxy/request_id/mod.rs
|
||||
b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs
|
||||
6a46cc0245175db56384ad793065141dece2213657ca73620fcc66fbe53a4b1b src/proxy/tests/authority_tests.rs
|
||||
91776c89a0ac3560902ec751846325ae5168289d2cd8c568808c89fa82aa5f69 src/proxy/tests/cache_fast_path_tests.rs
|
||||
0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs
|
||||
d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs
|
||||
fecc1c7ffff740caa7168370e59234a17b9836a6dce6d08b770ee9648c22709b src/proxy/types.rs
|
||||
1455d33b16daf9f88bba76255795fcab6e050fb9c2bbea0bd601ac90e8ec30d4 src/proxy/upstream_helpers.rs
|
||||
2d9142e3ae95895b5271b4009ab48236a3a2074ce51179c021193b35777560e4 src/proxy/web_cache_revalidation.rs
|
||||
d7b4b809b12ee89293df45e9a64b1c9981fb3f89deaab3762f4a3ad3992dd7b1 src/proxy/web_cache_revalidation/tests.rs
|
||||
69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs
|
||||
d866e7e72dee6ff54c2418c34e9899cbcd7e4e027d9cbe2b625044dc9062e4ee src/proxy/websocket/handshake.rs
|
||||
580fefdc7dfb27da73f86f4d2067d46a1f856d2f398b0598d37a743e880d3dac src/proxy/types.rs
|
||||
2ed96bc94003a881ab60f6871e3215598a3a519dc9f2480137f57292b70dc888 src/proxy/upstream_helpers.rs
|
||||
991bcc5caebc882849b6abc928230c0a6ec6e25f6aff6a8856e8a9807b9de72d src/proxy/web_cache_revalidation/mod.rs
|
||||
c827ad43de9546dbc5d9744b1e8209da0f70d2be0304a60a776f9a71d75f57d8 src/proxy/web_cache_revalidation/tests.rs
|
||||
3d591201595a3b1e928ded20a8736477a47e5acc3bcbde158f7c17547ce155ba src/proxy/websocket.rs
|
||||
f8972bedb1a487fac86177774cd98cf2619fb25af97e57cfac2853db5cbf91a9 src/proxy/websocket/handshake.rs
|
||||
c02d42b5c7eeedbc54c58e98691f7d8e39f8e9be63a48f6d6b4ad6b8ea431b7e src/proxy/websocket/http2_tests.rs
|
||||
faedf6cc2bd4c42f342a0aa188b634d6b8d280ca0e121032977eadaecb88e9d7 src/proxy/websocket/http3_tests.rs
|
||||
7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs
|
||||
a44421714aab37f7ceda19e3ec2512c645d2a3ce5dcb3da5bc7a0d87a442d6a8 src/proxy/websocket/tunnel.rs
|
||||
15fabd38576b61f650c039acdbf7ac5a5bd24330062475b94315285b2dc258b6 src/proxy/websocket/validation.rs
|
||||
545247eb00b0116efbd4cc913cc251195a55feded1530e0d8d8945abbae78039 src/rate_limit.rs
|
||||
e7e3a898e2c769b1ef11e58a13c1053104ada93dd694f8a03ea4b647c44c7d69 src/rate_limit/tests.rs
|
||||
d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.rs
|
||||
999cbf85cb6e917db529e30ba4a5eed1cc9e95a054da7b3039c730bb80061449 src/proxy/websocket/validation.rs
|
||||
00aff5b5b0719930230eff4d7e3adbe998cf0258bf7361d0913679c863afd3fa src/rate_limit/mod.rs
|
||||
2fcfa9706eb0708519d5181debaf3c85a86e401a8898df72f9eaf0ab72c9df85 src/rate_limit/tests.rs
|
||||
e53c40b85792d4804a432e3c97dc13af8e29304bb0d250ee9ab9018bde522eeb src/redirect/mod.rs
|
||||
811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs
|
||||
244d72b3dab36ec505c96d26a967420714fd3807268820f680fb3dcea0e88c0f src/runtime.rs
|
||||
8d0d7b01871c8c588c40a54330a1053c85024655fbe38516ca6c94c3c19a8956 src/runtime/certificates.rs
|
||||
02c598ffcf6af5aea95cd6bdd1a173bc63db8afdeb9153ec87bf0964b7244714 src/runtime/constructors.rs
|
||||
92b4b284f1b69dca58e4e3f76199f0aecc397eed26d10123b427af1bb8a4a57e src/runtime/docker.rs
|
||||
9015732f5592cb8d5503aee9199a0ead6c42ae20687d506e8752bd1065eafd82 src/runtime/external_config.rs
|
||||
ee2050b9f8efb45a209c91d76043a1ba29c4289978896aaf7e7d344d48fdc369 src/runtime/tests.rs
|
||||
0a585647b205290d0f7e52f9fe83b7ece1403cd87ca0e726669e27a3fb9faaa9 src/runtime/types.rs
|
||||
78b734fc804bb3d673f6cc0ab0bc8862612072ca7f4423d7c9bdacbe78223fc2 src/runtime/updates.rs
|
||||
208a378b14ca80e1412ffa19ef1b140594182971e2e797ee0bb95bcdb8404da7 src/secret_store.rs
|
||||
3c71d8eef9ccb49ad93cf6014c5c27756ce0eaeddfca116048f24c483fda68e2 src/runtime/certificates.rs
|
||||
2d610dc36f746215ad3bc1d860ee624a9233886d73a019fd243e51901503fc53 src/runtime/constructors.rs
|
||||
eb2b0eeeb617cad704732d87acb667530144836e26edb0189929b1f25f35c6b5 src/runtime/docker.rs
|
||||
18f7460e3738386e070f66932cd77240001255447a769d6e19a40573254a1473 src/runtime/external_config.rs
|
||||
a3b65ac6133979b2905b878e5634537994816abb79480af219a17896c6dc4e33 src/runtime/tests.rs
|
||||
d7a65497d1faa2f09a09c0a0ef77b444990cfb5ab379c85f6c27c1f9c0c143cc src/runtime/types.rs
|
||||
11e99eb425887ad12b73d949f4b497fabc6897fee8fc7647fcb3766269c2fdc0 src/runtime/updates.rs
|
||||
e3dbd60953db5a976985dd796e4268b41a0f3df59764c4664015bd41e2578058 src/secret_store/mod.rs
|
||||
6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs
|
||||
6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs
|
||||
98854bfeb35cc5266210b1c33dffb36b8441e27e61d90ded8b681430696d156f src/security/mod.rs
|
||||
0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs
|
||||
98c52aa3b50d2e211ab752ccaf353c9a8c1500a87f9c1684b5dd0ba9d10e4619 src/server.rs
|
||||
f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/http.rs
|
||||
164384468c933320abf1c656f1e07a687aa869f5794d3e5a9b429885b5272266 src/server/http1_connection.rs
|
||||
cd33fa634a5f1ee1d425e9e78269f9575b2e324967115383c80c6883c878ccfe src/server/listeners.rs
|
||||
4424a99b0b688d1119ee25626778b4804aedfaa0799a4a9bc0ee99bc5c7ba96b src/server/sendfile.rs
|
||||
71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs
|
||||
e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs
|
||||
960a82e02f09d75141fe3c1eecca915b66f17fdc8167196cd2fe21d5e6859376 src/server/tls_runtime.rs
|
||||
73ec453ac2850f6963f5bc33401cb8c92aa26d5123f15d68e6633860d91f265e src/settings.rs
|
||||
240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs
|
||||
1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs
|
||||
9dbf814870b8636770f579c3d36d16f5b6afe8bc66adfad356c26d7ae7dbce45 src/stream.rs
|
||||
254840126be2310426f6332f5efbe98147d9d8de1999731f40a3d13325c1dc6c src/stream/health.rs
|
||||
bdc56be7dae85a97a4d5b8d46ef31ec4177292b1cadd507fe423b6d4ee1403bd src/stream/model.rs
|
||||
de138a4ac68bcddba3a4f17ceba008bab643359d76a68726a755069f19334bed src/stream/pool.rs
|
||||
0e8589f56e3229dec556b427b87c2cc5a5a9ffaa9ea84fc2522dc72d3239027d src/server.rs
|
||||
ad4cfd4a7d6f331a8ce3bf17a6c461dba788bb41994f667033c6963df3b96332 src/server/http.rs
|
||||
00dbce1ff1ea08774bcfd79d900d71704256e06ab0cc46c379c3d107c99ba50e src/server/http1_connection.rs
|
||||
1cb8260c74de89c3e13bc225da32cd86fe883acd3c720d0cb2fc44c0c55e940c src/server/listeners.rs
|
||||
a64bc0d4352ec8d97895978a98b6b66e0459842d1ec410a1d123d5d29d9ef058 src/server/sendfile.rs
|
||||
5f6a59c6085c1250a93a70020661d1e8174d3b600b494d16f8eaf7941ee3bb99 src/server/serve/mod.rs
|
||||
048a05a46108f50bef7da50b0c03b752d76e586b411de8ca3f55313bbdb08aee src/server/serve/tests.rs
|
||||
669ea1041c103cb952f7a516363c8fe7baf51622f721aabdd57015e0e0279530 src/server/tls_runtime.rs
|
||||
d0af5ee91ff7f0315d347b3fd41d60b41e9dd095e0d015ecd99f17c4f3a923f7 src/settings.rs
|
||||
298673b6407b0ceda6ebc2ad4f4d787688cb0a5f144164f74895c745bcc53aa0 src/settings/public_origin.rs
|
||||
a4fdf54f87bcc96b31e8a0ea400398a70011a044bf2ff3de78a0489b767f9de3 src/settings/tests.rs
|
||||
2ad7888b263e1dd9f0bd588dc35590c9eb232f8fbacd10ea7554cb55b4dbd25b src/stream.rs
|
||||
c7771539fbb94544947e958e410c46b73f9647011e56b1b229ce7035fe21851f src/stream/health.rs
|
||||
c02e85af45590866f20933561f85ea9b4f2ead62c2ce41db75113632670a7b5f src/stream/model.rs
|
||||
5b74acf26aad3b73a10501866b716de5dace753453d5f09c4f22c414e5ad880c src/stream/pool.rs
|
||||
6ec42b8647c95a0c2025462e0be29c71481ce1c3b1f4d4193926471f1cf06be0 src/stream/runtime.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
|
||||
751d59582fb92c84e0c0cc7c0855c9d0a545805c9ba3be61826e50fb96c297ed src/system_stats/mod.rs
|
||||
2c7da96822d605193f0208df70de670612259fd8c762c998ae828848cb210e11 src/system_stats/mod.rs
|
||||
050aa831b1012c4aaade63863f433f01bfacf5d3f95787e57975572116a30ab2 src/system_stats/tests.rs
|
||||
53d40d0e169dffe18c802f07764cc0b4bfdf61d758eadb8f0d12e55d7438a7cd src/tinyauth.rs
|
||||
e7ed064a1706de9a7345e41fb8b6288b4467c12d1653e8afba6dc392c7a3a36a src/tinyauth/mod.rs
|
||||
0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs
|
||||
1084cccd9de28acd6cb0f0813de7c0953910f51726f73f4acdb2e3ada4593976 src/tls.rs
|
||||
1d7a6e0df1ca4d4acfbc1d059e60d80c9ccf31fa7d130091df44bff9f8ad4d31 src/tls/certificates.rs
|
||||
436575144aa352498a4696f12c2258bef3a898f3f50e6fbc6d67c3b93afa099f src/tls/external_paths.rs
|
||||
627c973870f6da0b18a90f84d3e92ed3f9fbdd5587490e4424dd77baaab3674c src/tls/server_config.rs
|
||||
5816e5e5b870a29d3f96d9e409fb2b667c1b2f4f599fd2b0ee25b522166df49e src/tls/tests.rs
|
||||
2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/tests/acme.rs
|
||||
93b359e9defd62506729bc90e56c96783e2f14dd477dd67f337ca83149da7168 src/tls/tests/external_paths.rs
|
||||
3292498e7122bb9fbee49d29e51e37cbb337c51af62ce367a06f6b01f6c0491b src/tls/types.rs
|
||||
6ae584944343b83ee3a2772cd93c985ddeaece7a9e6467b7cdbf5da22ee88028 src/traffic.rs
|
||||
e796efe9d94e2e79220287196fe2d2d9f4a1c7640b9f71652d409478e25d3c09 src/traffic/tests.rs
|
||||
d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs
|
||||
87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs
|
||||
b2c5236589093454ec60984b785ad6a4af6149d47f6db37d8e08c17fec7a2c1b src/transform/css.rs
|
||||
5478670cef87777b14a2dab308f5a9182df56d7ea2f5625bdc94aa7e3d661503 src/transform/html.rs
|
||||
df98becda799b6bf4ff5e2fec7d0e0a486e6b7eca6f9f5ad49ffdafd49a4f498 src/tls.rs
|
||||
2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/acme_tests.rs
|
||||
7425ee6c839f85d792e9251c6b9039e72fb49a5820a2266f751e6f4967a5871c src/tls/certificates.rs
|
||||
2ff3c219f87bd82677045a4809d3d94921c5b230bbc2f3e7131b07548f73ea7e src/tls/external_paths.rs
|
||||
60c16793b3d6115596e79e2cfe5436ad774709d20ca1cd55883e0f44e40ec871 src/tls/external_paths_tests.rs
|
||||
7b4c0f58b3a1e1791bb3c682a001b78bfdbbdafdbc143db8063b3f22dc168616 src/tls/server_config.rs
|
||||
9d6f048e791a3b7152904d1728258d298ad9d34aa3f83d13baf1b09a6fb7601d src/tls/tests.rs
|
||||
d9d79edf5a8ef6e3b39a21d35dd5ec6e5a336047117918949e7ec6f5b20ed3f1 src/tls/types.rs
|
||||
d637a77a9271736993f2e49022f752750364d4c6ba3cfd5cf7c9c3c8f37154b3 src/traffic/mod.rs
|
||||
dc8a83fae47da0d86e6ce0a329cb5274233264df56eebdb56b9bf37cb7be2bb4 src/traffic/tests.rs
|
||||
65fcac840f8b5f1508213e3f374aa318b450b0f9f90ba827c5ea935b9c60bee7 src/transform/apply.rs
|
||||
34a06c2dc635e80690961ef61fe84110f9578f55adf89f92a04668783ccb0a98 src/transform/compile.rs
|
||||
5a16b628af31fced425903a9d4b6ee83e1fe06ba023d574db16467baefe00ad3 src/transform/css.rs
|
||||
2b9d5c11d4c02b4a3ad3b9bb1a18c63693f245b854084f5eba16595df47b47f8 src/transform/html/mod.rs
|
||||
aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs
|
||||
a1d2fec07617c8a8dc425bfaeaf77c844152a0a174568a88a5baf4c317cc6c5c src/transform/js.rs
|
||||
047acd7209c1db19709801abbe3ee1076c1fc33f0fe80cc5d9b60669d5f1bae4 src/transform/js/mod.rs
|
||||
cf5195ab6bacccae0082da7b3f1bd0382b73fdeb3607f4ae569ca6daab9d10d0 src/transform/js/tests.rs
|
||||
226ae561dc7b0243c04e55e28cdfd2b76acd48cf614d1919b81c196660b03cfb src/transform/json.rs
|
||||
b0d20cf6784bf31457ea5a853e2fa5d85f6452395a78a538392703d97e687a11 src/transform/json/mod.rs
|
||||
216e24e29ea87be4a124f95f898e155f127fd959d7d5d9b30f8cd0b2b5018f30 src/transform/json/tests.rs
|
||||
6a8c28be93fb590942493c075b29ace1527981542bd183f232aa82b62e951eca src/transform/mod.rs
|
||||
fde2fa3aa94b30d8536afec1c11348cfbd922d0b5ff89e91d34266e8e3863e26 src/transform/model.rs
|
||||
a4e1d2aa2913587c250f251356431097b76995aee6e8bf9e35c4978a95491de1 src/transform/svg.rs
|
||||
d13fa462d6df516a4cfaee9f4e184044c5b6345e311aeb3d9058efafe54e4c5e src/transform/mod.rs
|
||||
954b86300c3ea47281acb2f43b0771e838f83ac09eb55e882e42de26aee3128f src/transform/model.rs
|
||||
4b52257e1a3643910b5e7043852d7218230c0021c31215d90d0f8e384f2ae233 src/transform/svg/mod.rs
|
||||
0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs
|
||||
d1f3f53fedae0d8ef69eeee457f7d30ed5a670a1ed4ced60683400ee46680260 src/transform/tests.rs
|
||||
6146c59f36cf419623747d497b3de685da6c84964df43317bebdcb21deb9ee09 src/update_check.rs
|
||||
64f711d2a0063036e3a2cba470b44eb165f3574bc28cf273296c2c3adb9ed4a5 src/update_check/mod.rs
|
||||
c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs
|
||||
92efcdaafb9f5a8514daf63d7fcde29be977d7bdcd90162eda0ce00d6967ffbd src/upstream.rs
|
||||
ca695e246bee5389b3757fe8588e48bff5d0ad473bc8588ad4ba7b52c99d7dfa src/upstream/client.rs
|
||||
56c8a9d1c801be0f41b35c75cb42716c46976951948009c8707122c0a6a1eb58 src/upstream/client_tests.rs
|
||||
75fef0a488d3f3dabfa03c2eabc5995442dfb3c9a1fde8eb6653a124dda25d43 src/upstream/protocol_tests.rs
|
||||
8a112154b9631f62a54584302a3ff6a64e4f177ce79aba678b4cb23e466c6ea3 src/upstream/tests.rs
|
||||
a6b4905c245a22eafb1aa535d303d0fa24e4353db94e1d67fd42b825bf8c5adb src/user_agent.rs
|
||||
b7ee3629a8fde0972bc1822b764f5fa2c813a760dd5dfb7f1ce643a7db8f9f75 src/upstream.rs
|
||||
fe9d4deb34cea2408ac5a1030315989f29a6a7d078a46ac1b59d12aa07f67878 src/upstream/client.rs
|
||||
eb513923f8951c8bf30bad3cbac77d34f5ebbc7af3ba69a08a25bae834f8a016 src/upstream/client_tests.rs
|
||||
8c88d928b823e4b6ba6d852f74664c770d8b3fce32bc76c583dc1f672fe33f14 src/upstream/protocol_tests.rs
|
||||
ae880b40197454ea3fbf4d2113984dc372098b2e54b97e87c83cc319de253377 src/upstream/tests.rs
|
||||
b88cb47a009be4924bcf069b05c3e788d1eaf2b15cbdcf3099036ece89cd0d66 src/user_agent/mod.rs
|
||||
6d7cb73de7d8c6f3780736480f5f81ef08813f4626fd5ed494aca0552cf214a1 src/user_agent/tests.rs
|
||||
3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs
|
||||
6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs
|
||||
3c24678359cc6e2ec94c2a0e1cc399b591ef683adab9b965633c898c6241130f src/vhost/model.rs
|
||||
35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs
|
||||
94bb735627f2c4a0af5c4df45bb2b41299caf37b7e6e4d0d7a860d3890eb40e0 src/vhost/tests.rs
|
||||
e912d5dd09fa172032ca04d89572b845dd8f38ab5bb8598433b07629181f791d src/vhost/validation.rs
|
||||
d61c427c1e9cf457f1232b822d73e846e9f329cfbe61215e7ca061d53762155d src/vhost.rs
|
||||
54b0c1d2f04e5c654123c3422645eb5a0a9609141cc4a1c6addca2d028392772 src/vhost/error_fallback.rs
|
||||
9d358be766f767d9e1a42f585edcf7fc1cba8f22e5eaac294a83920425ec03c7 src/vhost/model.rs
|
||||
e003b935d91791f20d80c59bb46f9bb0820694dcea5d95e65364b53b016284ef src/vhost/redirect.rs
|
||||
dd6e121445402a2ac8744f0e4f23d879b4356f47c1bb00737bd15ba459661978 src/vhost/tests.rs
|
||||
c95cc8f4549460917633a12ccb352a00a83c0f004b23c0b9ddecba42eee3ea17 src/vhost/validation.rs
|
||||
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.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
|
||||
86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs
|
||||
200cba8cb3482e49db73d2c60cb60786175177c7d4be2a566cd281fec7ba7fca src/web_server/fastcgi/protocol.rs
|
||||
cef44ccdf882d84892a64bdbfdc1ba01380fce5e5f86d2829b0f9613f738b964 src/web_server/fastcgi/response.rs
|
||||
e15361622683268c5606a39a9e5db3938e630999767898391226e94ad2e421e8 src/web_server/filesystem.rs
|
||||
cd032866a9d74d6802c2d99f2799ce971c6a8400e39d7ceaf542cac8c4ee97fc src/web_server/fastcgi/protocol.rs
|
||||
e954308d94848471f6a9d70a50cd2181d17b9c41b2705a3e6c6c0e882a03733b src/web_server/fastcgi/response.rs
|
||||
b98666353e4af560ab8423d4772d9ad26451dabb1c6d60f5faf547116a2bcd24 src/web_server/filesystem.rs
|
||||
d4fec3682532b0f2d11a0aefcd024f69fb623a39922ddb6c528a2d2de265347d src/web_server/htaccess.rs
|
||||
ca3cd376b313af1ce0ee65081b3327eb213f000d5a215aa4ee6b764de9702126 src/web_server/htaccess/model.rs
|
||||
76de1d11df75f4a64556b2821fd0a1774d26c9f0ca0e5f2b5fcfad0722d43d47 src/web_server/htaccess/parse.rs
|
||||
66b846cce61345ea6d182db03e4f8eddc5d4a3cbe87f2046523310f1ea13a5a3 src/web_server/htaccess/model.rs
|
||||
9c57b7c556984f79404d4afeb9841d0280a198b94c42d887bc6d96b7748a0e67 src/web_server/htaccess/parse.rs
|
||||
66260d159986d41eab0dfde2cb9f0713fc1cc209c2fb47e9023bd24fd37919e8 src/web_server/htaccess/rewrite.rs
|
||||
8ca74d9cc4470045301f70eb4a9fbabd0dbfe74434429148d2230df9f996c06c src/web_server/htaccess/tests.rs
|
||||
3341264f6e8041ac5a5d3c4b1601a69787386116019b5febc1a5e22bdb1186f9 src/web_server/metrics.rs
|
||||
0dbef9b71869fc52cf01e9cb400ffd48fb433ae625383edee6c6c2d735608a76 src/web_server/mod.rs
|
||||
b4a75519d9c7bb4f176126a284ea5da68fe512674200765da0984b245e4278ba src/web_server/metrics.rs
|
||||
029bc2b277b2fd7528d5c9f5637453ce6d1d3ec2df7e441f6844cf55edef6b5b src/web_server/mod.rs
|
||||
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
|
||||
d98c80663847d6d83073df074ebff65952755415574708abcc153bd8d3adfaa9 src/web_server/response.rs
|
||||
833c9fea744b9649046e1a64d6fc6972fbf6cea640e1943b0db52b7ccfa9faad src/web_server/response/file.rs
|
||||
ba793a140699956a9aef6bc79b0c731c481af8ab7ac3d816a28695743febcb30 src/web_server/response/file_body.rs
|
||||
c2e54fd803a10f74490f183e8c030fd0e3e72f5de4e79c55bc393ab57d90ab9a src/web_server/response/file_body/tests.rs
|
||||
b35e00e4084b4de2125e9b6fff9699358b881277427bed8706fb2e8286e3cf28 src/web_server/response/headers.rs
|
||||
305c0109b4f869a99381a142853f9e2205f5c7c4f904b2dfa4643274bfc542af src/web_server/routing.rs
|
||||
c69502a28eec224eeded7cad93159aae3e7c136721a19f4542d5f682e055f61b src/web_server/response/file.rs
|
||||
cbeb022847e64d30e9d63aac92bee00d29281b3602e1a0589f3c978cbc7c2238 src/web_server/response/file_body/mod.rs
|
||||
4e91d09fa086d388e384d14b347eff8c74c010915ca7c84171e074872323e341 src/web_server/response/file_body/tests.rs
|
||||
c63a5935bf8e101f1813e029dd62da7d073eb598453ee98aafe9ffa6f85157c2 src/web_server/response/headers.rs
|
||||
f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server/routing.rs
|
||||
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
|
||||
c645740e5175e32a9ee743700e99a2f16b6fd894de5f734b84021b774dd747eb src/web_server/serve.rs
|
||||
dc3a18c14c3cc28519103e860b0f42a59d163db15df3f68b76778cdfa6786e46 src/web_server/tests.rs
|
||||
b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs
|
||||
df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs
|
||||
cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_picker_corner_styles.cjs
|
||||
2467a6c2295d3fb2ad84cdb4a85176372953034fef2f4d95dc3bf58f019efe21 tests/acl_picker_dom.cjs
|
||||
8bfee0027ce317f3427ecdf9193549d4a52db58a7b2b99db7cdeebc458d7d4c0 tests/cache_outcomes.cjs
|
||||
@@ -510,11 +514,7 @@ cf729dc5d00132736521887e77ab0ab5280ede6840e1fb225736ccab09748f22 tests/acl_pick
|
||||
2e957134ae1b041ab1f1cac6149892a2704bd61a37fbe4afc0810e2911b5cedd tests/docker_vhost_actions.cjs
|
||||
c1b806e756a84913d0a1cd2ace0b0f94fb5f5817e0480870a65279b4d7fc5bbb tests/docker_vhost_metrics.cjs
|
||||
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
|
||||
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
|
||||
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
|
||||
930cc63183479400d01195574ec0246a482618915bc3c24b4d48a11faa286018 tests/modal_frontend_0346.cjs
|
||||
eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs
|
||||
7a28cfdb43a3cecf90136cf8898c060c382af557ce4ff168a6672391a0e5c26b tests/transport_config.rs
|
||||
071d590439bba50b526e1b2344b5ee414210af8f4f6a318d1611640386de576f tests/ux_0345.cjs
|
||||
b312f9f5d5f4b3d30f935ab3f3c893fd8b8f2d571acc8198a1a89ed9ecd1f0ac tests/ux_0345_followup.cjs
|
||||
67353441435c6378780e4b6c5dd8732d7160f7fbce8df9cf4791cbd6037f5840 tests/vhost_content_type_ui.cjs
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Build extensible language packs and pinned, authenticated Swagger UI assets.
|
||||
use serde_json::{json, Value};
|
||||
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(
|
||||
root: &std::path::Path,
|
||||
out: &std::path::Path,
|
||||
|
||||
@@ -108,8 +108,7 @@ def read_assets(path: Path, checksum: str | None) -> tuple[dict[str, bytes], str
|
||||
snapshot = stream.read(MAX_ARCHIVE + 1)
|
||||
if not 0 < len(snapshot) <= MAX_ARCHIVE:
|
||||
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()
|
||||
if checksum and digest != checksum:
|
||||
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"))
|
||||
offline = os.environ.get("CARGO_NET_OFFLINE", "").lower() in ("1", "true")
|
||||
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
|
||||
atomic_write(args.out_dir.resolve() / "asset-build-id.txt", secrets.token_hex(8).encode("ascii"))
|
||||
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"):
|
||||
raise AssetError("Build input/output path contains an invalid control character.")
|
||||
print(f"cargo:rerun-if-changed={path}")
|
||||
|
||||
+1
-12
@@ -12,10 +12,6 @@ LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85"
|
||||
|
||||
def check(root: Path, repair_known: bool = False) -> 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 = (
|
||||
"src/admin/web/app.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.parent.mkdir(parents=True, exist_ok=True)
|
||||
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():
|
||||
raise ValueError(
|
||||
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")
|
||||
if flat.is_file():
|
||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/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():
|
||||
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"
|
||||
if not registry.is_file():
|
||||
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):
|
||||
if not (root / doc_path).is_file():
|
||||
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"):
|
||||
source = parent.read_text(encoding="utf-8")
|
||||
for relative in re.findall(r'\binclude!\s*\(\s*"([^"\n]+\.rs)"\s*\)', source):
|
||||
|
||||
-31
@@ -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 ipnet::IpNet;
|
||||
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)]
|
||||
pub struct ProfileAccess {
|
||||
pub acl: Option<CompiledAcl>,
|
||||
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(
|
||||
profiles: &[ProfileAccess],
|
||||
peer: IpAddr,
|
||||
@@ -120,8 +111,6 @@ pub fn allows_profiles_with_country(
|
||||
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> {
|
||||
legacy.into_iter().chain(ids.iter().map(String::as_str))
|
||||
}
|
||||
@@ -157,8 +146,6 @@ impl CompiledAcl {
|
||||
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 {
|
||||
match self.explicit_action(peer) {
|
||||
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(
|
||||
acl: Option<&CompiledAcl>,
|
||||
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<()> {
|
||||
ensure!(
|
||||
path.starts_with('/') && path.len() <= 8192,
|
||||
@@ -225,10 +204,6 @@ pub fn validate_decoded_routing_path(path: &str) -> Result<()> {
|
||||
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>> {
|
||||
ensure!(
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
ensure!(
|
||||
raw.starts_with('/') && raw.len() <= 8192,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Bounded, fail-closed DNS/DDNS ACL address cache. No resolver runs in the request path.
|
||||
use crate::config::Config;
|
||||
use anyhow::{ensure, Result};
|
||||
use arc_swap::ArcSwap;
|
||||
@@ -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> {
|
||||
let candidate = value.strip_suffix('.').unwrap_or(value);
|
||||
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())
|
||||
}
|
||||
|
||||
/// Walk every ACL scope, including standalone host policies, ACL profiles,
|
||||
/// inline vhost/location ACLs and Docker overrides. Limit process-wide DNS work.
|
||||
pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
|
||||
let mut names = BTreeSet::new();
|
||||
let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> {
|
||||
@@ -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() {
|
||||
continue;
|
||||
}
|
||||
// A slash is always a CIDR error, never a DNS name.
|
||||
let name = hostname(&rule.network)?;
|
||||
ensure!(rule.action == crate::acl::Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use static IP/CIDR for deny");
|
||||
@@ -86,8 +80,6 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
|
||||
}
|
||||
|
||||
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 answer = tokio::time::timeout(
|
||||
Duration::from_secs(LOOKUP_SECONDS),
|
||||
@@ -129,7 +121,6 @@ pub(crate) async fn poll_loop(
|
||||
let snapshot = state.config.load();
|
||||
let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; };
|
||||
drop(snapshot);
|
||||
// Resolve concurrently with hard bounds; write one immutable snapshot.
|
||||
let results: HashMap<_, _> = tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
result = stream::iter(names.iter().cloned())
|
||||
@@ -138,7 +129,6 @@ pub(crate) async fn poll_loop(
|
||||
.filter_map(|(name, answer)| async move { answer.map(|value| (name, value)) })
|
||||
.collect() => result,
|
||||
};
|
||||
// A concurrently changed configuration must not publish obsolete DNS data.
|
||||
let current = state.config.load();
|
||||
if configured_hostnames(¤t.envelope.config).ok().as_ref() != Some(&names) {
|
||||
CHANGED.notify_one();
|
||||
@@ -146,9 +136,8 @@ pub(crate) async fn poll_loop(
|
||||
}
|
||||
CACHE.store(Arc::new(results));
|
||||
}
|
||||
// Do not retain permit decisions when the resolver lifecycle stops.
|
||||
CACHE.store(Arc::new(HashMap::new()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("dynamic/tests.rs");
|
||||
include!("tests.rs");
|
||||
@@ -13,19 +13,16 @@ mod tests {
|
||||
let ip: IpAddr = "198.51.100.4".parse().unwrap();
|
||||
let entry = Addresses { ips: vec![ip], valid_until: Instant::now() + Duration::from_secs(1) };
|
||||
assert!(entry.valid_until > Instant::now());
|
||||
// The request path accepts only cached exact addresses, never a subnet.
|
||||
let mut cache = HashMap::new(); cache.insert(name.to_owned(), entry);
|
||||
CACHE.store(Arc::new(cache));
|
||||
assert!(matches(name, ip));
|
||||
assert!(!matches(name, "198.51.100.5".parse().unwrap()));
|
||||
// A formerly resolved IP cannot remain permitted after expiration.
|
||||
let mut expired = HashMap::new();
|
||||
expired.insert(name.to_owned(), Addresses {
|
||||
ips: vec![ip], valid_until: Instant::now() - Duration::from_secs(1),
|
||||
});
|
||||
CACHE.store(Arc::new(expired));
|
||||
assert!(!matches(name, ip));
|
||||
// IPv6 addresses have the same exact-match and rotation semantics.
|
||||
let new_ip: IpAddr = "2001:db8::8".parse().unwrap();
|
||||
let mut rotated = HashMap::new();
|
||||
rotated.insert(name.to_owned(), Addresses {
|
||||
|
||||
@@ -360,4 +360,12 @@ mod tests {
|
||||
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
@@ -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/runtime_env.rs");
|
||||
include!("acme/providers.rs");
|
||||
include!("acme/providers/mod.rs");
|
||||
include!("acme/jobs.rs");
|
||||
#[cfg(test)]
|
||||
include!("acme/tests.rs");
|
||||
@@ -159,7 +159,6 @@ impl Manager {
|
||||
"acme.not_due"
|
||||
}
|
||||
.into();
|
||||
// A dry run must not postpone a due production renewal.
|
||||
if !dry_run {
|
||||
entry.failures = 0;
|
||||
entry.next_check = now + 43200 + (rand::random::<u16>() as i64 % 1800);
|
||||
@@ -231,8 +230,6 @@ async fn deploy_renewal(
|
||||
.await
|
||||
.map_err(|_| "acme.validation_worker_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 {
|
||||
let snapshot = state.config.load_full();
|
||||
let Some(current) = snapshot
|
||||
@@ -280,16 +277,10 @@ async fn deploy_renewal(
|
||||
}
|
||||
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(
|
||||
weak: std::sync::Weak<App>,
|
||||
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);
|
||||
tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(delay)) => {} }
|
||||
loop {
|
||||
|
||||
@@ -394,7 +394,6 @@ impl Manager {
|
||||
finished.message = message;
|
||||
}
|
||||
}
|
||||
// Never write provider stderr, request JSON or TSIG material to audit.
|
||||
let _ = state
|
||||
.store
|
||||
.audit(
|
||||
|
||||
@@ -35,8 +35,6 @@ async fn execute(
|
||||
.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(
|
||||
axum::extract::State(state): axum::extract::State<Arc<App>>,
|
||||
request: axum::extract::Request,
|
||||
@@ -140,10 +138,8 @@ pub async fn challenge(
|
||||
}
|
||||
|
||||
#[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)]
|
||||
#[serde(default)]
|
||||
pub struct RenewalState {
|
||||
@@ -13,7 +13,6 @@ async fn create_virtual_environment(
|
||||
return false;
|
||||
}
|
||||
let _ = tokio::fs::remove_dir_all(staging).await;
|
||||
// Fall back to an application-local virtualenv bootstrap.
|
||||
let bootstrap = staging.with_extension("virtualenv-bootstrap");
|
||||
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
|
||||
let install_bootstrap = |break_system_packages: bool| {
|
||||
@@ -30,30 +29,20 @@ async fn create_virtual_environment(
|
||||
"--timeout",
|
||||
"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 {
|
||||
install.arg("--break-system-packages");
|
||||
}
|
||||
install.arg("--target").arg(&bootstrap).arg("virtualenv>=20,<21");
|
||||
install
|
||||
.arg("--target")
|
||||
.arg(&bootstrap)
|
||||
.arg("virtualenv>=20,<21");
|
||||
install
|
||||
};
|
||||
let installed = command_success(
|
||||
install_bootstrap(false),
|
||||
Duration::from_secs(300),
|
||||
shutdown,
|
||||
)
|
||||
.await;
|
||||
let installed =
|
||||
command_success(install_bootstrap(false), Duration::from_secs(300), shutdown).await;
|
||||
if !installed {
|
||||
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
|
||||
if !command_success(
|
||||
install_bootstrap(true),
|
||||
Duration::from_secs(300),
|
||||
shutdown,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if !command_success(install_bootstrap(true), Duration::from_secs(300), shutdown).await {
|
||||
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
|
||||
return false;
|
||||
}
|
||||
|
||||
+11
-8
@@ -24,7 +24,6 @@ pub struct RuntimeStatus {
|
||||
pub python: bool,
|
||||
pub pip: bool,
|
||||
pub certbot: bool,
|
||||
/// Versions of the executables actually selected for ACME, never a guessed system default.
|
||||
pub python_version: Option<String>,
|
||||
pub pip_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 {
|
||||
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);
|
||||
if path.is_file() {
|
||||
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)]
|
||||
if path.extension().is_none() {
|
||||
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_directory = Some(managed.to_string_lossy().into_owned());
|
||||
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 {
|
||||
status: "ready",
|
||||
mode: "managed",
|
||||
@@ -274,7 +275,9 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
|
||||
let system_versions = runtime_versions(
|
||||
&python,
|
||||
system_pip,
|
||||
distro_certbot.as_deref().filter(|_| distro_certbot_available),
|
||||
distro_certbot
|
||||
.as_deref()
|
||||
.filter(|_| distro_certbot_available),
|
||||
);
|
||||
state.acme.set_runtime(RuntimeStatus {
|
||||
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;
|
||||
if build.is_ok() {
|
||||
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 {
|
||||
status: "ready",
|
||||
mode: "managed",
|
||||
@@ -383,7 +387,6 @@ impl Drop for ProcessGroup {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(unix)]
|
||||
if let Some(id) = self.0 {
|
||||
// SAFETY: this process group belongs only to the managed child.
|
||||
unsafe {
|
||||
libc::kill(-(id as i32), libc::SIGKILL);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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> {
|
||||
let version = raw.trim();
|
||||
(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
|
||||
.bytes()
|
||||
.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> {
|
||||
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 line in content.lines() {
|
||||
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 major = parts.next()?;
|
||||
let minor = parts.next()?;
|
||||
(major.bytes().all(|b| b.is_ascii_digit())
|
||||
&& minor.bytes().all(|b| b.is_ascii_digit()))
|
||||
(major.bytes().all(|b| b.is_ascii_digit()) && minor.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| format!("python{major}.{minor}"))
|
||||
}
|
||||
|
||||
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")] {
|
||||
if let Some(minor) = python_version.and_then(python_minor) {
|
||||
sites.push(base.join(&minor).join("site-packages"));
|
||||
sites.push(base.join(&minor).join("dist-packages"));
|
||||
} 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() {
|
||||
let path = entry.path();
|
||||
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 mut result: Option<String> = None;
|
||||
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() {
|
||||
let name = entry.file_name();
|
||||
let Some(dir_name) = name.to_str() else { continue };
|
||||
let Some(suffix) = dir_name.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 Some(dir_name) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let Some(suffix) = dir_name
|
||||
.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_version = None;
|
||||
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: ") {
|
||||
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))
|
||||
|| reported_version.as_deref() != Some(directory_version.as_str()) {
|
||||
|| reported_version.as_deref() != Some(directory_version.as_str())
|
||||
{
|
||||
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;
|
||||
}
|
||||
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 python_version = root.as_deref().and_then(interpreter_version);
|
||||
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 {
|
||||
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)
|
||||
});
|
||||
RuntimeVersions { python: python_version, pip: pip_version, certbot: certbot_version }
|
||||
RuntimeVersions {
|
||||
python: python_version,
|
||||
pip: pip_version,
|
||||
certbot: certbot_version,
|
||||
}
|
||||
}
|
||||
|
||||
+23
-7
@@ -111,7 +111,6 @@ mod renewal_regressions {
|
||||
"acme.configuration_changed"
|
||||
);
|
||||
assert_eq!(app.config.load().envelope.revision, 0);
|
||||
// Explicit Check now remains allowed when scheduled checks are disabled.
|
||||
deploy_renewal(&app, &original, &output, false)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -147,7 +146,6 @@ mod renewal_regressions {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
mod runtime_metadata_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -166,13 +164,22 @@ mod runtime_metadata_tests {
|
||||
std::fs::create_dir_all(venv_bin(root)).unwrap();
|
||||
std::fs::write(&python, "").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) {
|
||||
root.join("Lib/site-packages")
|
||||
} else {
|
||||
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");
|
||||
let versions = runtime_versions(&python, true, Some(&certbot));
|
||||
assert_eq!(versions.python.as_deref(), Some("3.13.5"));
|
||||
@@ -202,9 +209,18 @@ mod runtime_metadata_tests {
|
||||
let executable = root.join("bin/python3");
|
||||
std::fs::create_dir_all(root.join("bin")).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(&root.join("lib/python3.13/site-packages"), "pip", "25.0", "Name: pip\nVersion: 25.0\n");
|
||||
write_package(
|
||||
&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());
|
||||
}
|
||||
}
|
||||
|
||||
+7
-34
@@ -46,8 +46,6 @@ fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
}
|
||||
}
|
||||
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)
|
||||
&& 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) {
|
||||
// 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();
|
||||
if let Some(scheme) = request.uri().scheme_str() {
|
||||
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());
|
||||
}
|
||||
|
||||
// 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() {
|
||||
return Err(());
|
||||
}
|
||||
@@ -131,14 +123,7 @@ fn admin_host_allowed(
|
||||
|
||||
!a.as_str().contains('@')
|
||||
&& ((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
|
||||
// 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()))
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
let mut hops = 0;
|
||||
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(',') {
|
||||
hops += 1;
|
||||
if hops > 8 { return false; }
|
||||
if hops > 8 {
|
||||
return false;
|
||||
}
|
||||
let scheme = part.trim();
|
||||
if (hops == 1 && scheme != "https")
|
||||
|| !matches!(scheme, "http" | "https")
|
||||
{
|
||||
if (hops == 1 && scheme != "https") || !matches!(scheme, "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -251,11 +234,6 @@ fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
|
||||
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(
|
||||
state: &App,
|
||||
headers: &HeaderMap,
|
||||
@@ -267,7 +245,6 @@ fn configured_admin_public_authority(
|
||||
return Ok(None);
|
||||
};
|
||||
if !trusted_admin_proxy(state, peer) {
|
||||
// Never apply the pin to arbitrary direct/untrusted connections.
|
||||
return Ok(None);
|
||||
}
|
||||
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 {
|
||||
Router::new().fallback(|request: Request| async move {
|
||||
let raw_host = request
|
||||
|
||||
@@ -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 {
|
||||
let read = method == Method::GET
|
||||
&& matches!(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// General-purpose API credentials are independent of browser sessions,
|
||||
// certificate deployment credentials, and the Prometheus scrape credential.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateApiToken {
|
||||
|
||||
@@ -119,8 +119,6 @@ async fn certificate_api_rotate(
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateApiEdit {
|
||||
@@ -134,7 +132,10 @@ async fn certificate_api_detail(
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
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
|
||||
.store
|
||||
@@ -152,11 +153,17 @@ async fn certificate_api_edit(
|
||||
Json(input): Json<CertificateApiEdit>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
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();
|
||||
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
|
||||
.certificate_ids
|
||||
@@ -168,7 +175,9 @@ async fn certificate_api_edit(
|
||||
certificate_ids.dedup();
|
||||
if certificate_ids.is_empty()
|
||||
|| 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(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
@@ -178,7 +187,11 @@ async fn certificate_api_edit(
|
||||
let credential = state
|
||||
.store
|
||||
.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
|
||||
.map_err(auth::internal)?
|
||||
@@ -192,7 +205,10 @@ async fn certificate_api_regenerate(
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
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 hash = auth::secret_hash(&token);
|
||||
@@ -202,7 +218,6 @@ async fn certificate_api_regenerate(
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.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 })))
|
||||
}
|
||||
|
||||
@@ -242,4 +257,3 @@ async fn certificate_api_revoke_all(
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({"configured": false, "revoked": revoked})))
|
||||
}
|
||||
|
||||
@@ -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(|| {
|
||||
ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
@@ -106,8 +105,6 @@ async fn certificate_deploy(
|
||||
let key_file = config.tls.certificates[index].key_file.clone();
|
||||
|
||||
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 {
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
@@ -423,7 +420,6 @@ async fn basic_hash(
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
crate::basic_auth::validate_username(&input.username)
|
||||
.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
|
||||
.basic_workers
|
||||
.clone()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/// Account-scoped snapshots of Overview order, visibility, widths and display.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -63,8 +62,6 @@ async fn dashboard_presets(
|
||||
tracing::warn!("skipped invalid saved Overview preset");
|
||||
continue;
|
||||
};
|
||||
// Compatibility with future/retired widgets: use the same registry
|
||||
// reconciliation logic as ordinary saved administrator preferences.
|
||||
let mut prefs = AdminPreferences::default();
|
||||
prefs.dashboard_widgets = layout.dashboard_widgets;
|
||||
prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets;
|
||||
|
||||
@@ -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::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
Json,
|
||||
@@ -8,9 +6,6 @@ use http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
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;
|
||||
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()))
|
||||
}
|
||||
|
||||
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}");
|
||||
match super::fingerprinted_path(&full, "/docs/").as_deref() {
|
||||
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 {
|
||||
// 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 {
|
||||
"swagger-ui-bundle.js" => (
|
||||
"text/javascript; charset=utf-8",
|
||||
|
||||
@@ -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 {
|
||||
($path:literal, $category:literal) => {
|
||||
(
|
||||
|
||||
+53
-21
@@ -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(
|
||||
candidates: impl Iterator<Item = (String, std::path::PathBuf, u64)>,
|
||||
limit: usize,
|
||||
) -> Vec<(String, std::path::PathBuf, u64)> {
|
||||
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);
|
||||
for (name, path, modified_ms) in candidates {
|
||||
let item = (Reverse(modified_ms), name, path);
|
||||
@@ -17,14 +16,14 @@ fn newest_log_files(
|
||||
bounded.push(item);
|
||||
}
|
||||
}
|
||||
let mut result = bounded.into_iter()
|
||||
let mut result = bounded
|
||||
.into_iter()
|
||||
.map(|(Reverse(modified), name, path)| (name, path, modified))
|
||||
.collect::<Vec<_>>();
|
||||
result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
|
||||
result
|
||||
}
|
||||
|
||||
// Bounded file-log inventory helpers and gzip tail reader.
|
||||
fn rotated_gzip_sources(
|
||||
active: &std::path::Path,
|
||||
id_prefix: &str,
|
||||
@@ -33,25 +32,52 @@ fn rotated_gzip_sources(
|
||||
enabled: bool,
|
||||
format: &'static str,
|
||||
) -> Vec<FileLogSource> {
|
||||
let Some(parent) = active.parent() else { 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 Some(parent) = active.parent() else {
|
||||
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 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();
|
||||
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()?;
|
||||
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()?;
|
||||
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!("{label_prefix} · {name}"),
|
||||
id_prefix.to_owned(),
|
||||
kind, path, enabled, format
|
||||
)).collect()
|
||||
kind,
|
||||
path,
|
||||
enabled,
|
||||
format,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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 length = file.metadata()?.len();
|
||||
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 {
|
||||
return Ok((Vec::new(), length.min(budget), true));
|
||||
}
|
||||
@@ -108,7 +132,11 @@ fn read_tail(
|
||||
}
|
||||
let text = String::from_utf8_lossy(&content);
|
||||
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()));
|
||||
}
|
||||
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 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()))
|
||||
}
|
||||
@@ -50,8 +50,6 @@ fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchCl
|
||||
true
|
||||
}
|
||||
|
||||
// Order the entire bounded matching scan before applying offset/limit. Sorting
|
||||
// only the current browser page would give misleading navigation results.
|
||||
#[derive(Debug)]
|
||||
enum FileLogSortKey {
|
||||
Number(serde_json::Number),
|
||||
@@ -59,29 +57,43 @@ enum FileLogSortKey {
|
||||
}
|
||||
|
||||
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")) {
|
||||
return reason.to_owned();
|
||||
}
|
||||
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("path").unwrap_or("")).trim().to_owned();
|
||||
field("path").unwrap_or("")
|
||||
)
|
||||
.trim()
|
||||
.to_owned();
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> {
|
||||
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" {
|
||||
record.get("time_ms").filter(|value| value.is_number())
|
||||
record
|
||||
.get("time_ms")
|
||||
.filter(|value| value.is_number())
|
||||
.or_else(|| record.get("timestamp"))
|
||||
} else {
|
||||
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::String(text) if !text.is_empty() => Some(FileLogSortKey::Text(text.to_lowercase())),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
_ => 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("")),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_file_log_records(records: &mut Vec<Value>, field: &str, descending: bool) {
|
||||
use std::cmp::Ordering;
|
||||
// Build one key per record; do not repeatedly allocate/parse strings in
|
||||
// the O(n log n) comparison loop. The scan itself is limited to 20k lines.
|
||||
let mut keyed = std::mem::take(records).into_iter().enumerate()
|
||||
let mut keyed = std::mem::take(records)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ordinal, record)| (stored_log_sort_key(&record, field), ordinal, record))
|
||||
.collect::<Vec<_>>();
|
||||
keyed.sort_by(|left, right| {
|
||||
let order = match (&left.0, &right.0) {
|
||||
(None, None) => Ordering::Equal,
|
||||
(None, Some(_)) => return Ordering::Greater, // missing values always last
|
||||
(None, Some(_)) => return Ordering::Greater,
|
||||
(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()) {
|
||||
(Some(left), Some(right)) => left.cmp(&right),
|
||||
_ => match (a.as_u64(), b.as_u64()) {
|
||||
(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::Number(_)), Some(FileLogSortKey::Text(_))) => Ordering::Less,
|
||||
(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.client_ip, 128)
|
||||
|| !text_ok(&query.vhost, 256)
|
||||
|| query.aggregate_field.as_ref().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
|
||||
.aggregate_field
|
||||
.as_ref()
|
||||
.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
|
||||
.limit
|
||||
@@ -187,8 +221,11 @@ async fn file_logs_query(
|
||||
let requested = query.source.as_deref().unwrap_or("all");
|
||||
let mut truncated = false;
|
||||
if requested != "all" {
|
||||
let consolidate_vhosts = query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:");
|
||||
sources.retain(|source| source.id == requested || (consolidate_vhosts && source.group_id == requested));
|
||||
let consolidate_vhosts =
|
||||
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() {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -210,9 +247,15 @@ async fn file_logs_query(
|
||||
let mut scanned_lines = 0usize;
|
||||
let mut records = Vec::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_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_truncated = false;
|
||||
for (index, source) in sources.iter().enumerate() {
|
||||
@@ -241,8 +284,13 @@ async fn file_logs_query(
|
||||
let mut values = Vec::new();
|
||||
file_log_aggregate_values(field_value, &mut values);
|
||||
for aggregate_value in values {
|
||||
if aggregate_counts.contains_key(&aggregate_value) || aggregate_counts.len() < FILE_LOG_MAX_AGGREGATE_VALUES {
|
||||
*aggregate_counts.entry(aggregate_value).or_insert(0) += 1;
|
||||
if aggregate_counts.contains_key(&aggregate_value)
|
||||
|| aggregate_counts.len()
|
||||
< FILE_LOG_MAX_AGGREGATE_VALUES
|
||||
{
|
||||
*aggregate_counts
|
||||
.entry(aggregate_value)
|
||||
.or_insert(0) += 1;
|
||||
} else {
|
||||
aggregate_truncated = true;
|
||||
}
|
||||
@@ -257,9 +305,13 @@ async fn file_logs_query(
|
||||
Err(_) => truncated = true,
|
||||
}
|
||||
}
|
||||
sort_file_log_records(&mut records, query.sort_by.as_deref().unwrap_or("time"),
|
||||
query.sort_descending.unwrap_or(true));
|
||||
let mut aggregation = aggregate_counts.into_iter()
|
||||
sort_file_log_records(
|
||||
&mut records,
|
||||
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)
|
||||
.map(|(value, count)| json!({"value": value, "count": count}))
|
||||
.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 right_count = right.get("count").and_then(Value::as_u64).unwrap_or(0);
|
||||
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(""))
|
||||
})
|
||||
});
|
||||
|
||||
@@ -52,8 +52,6 @@ async fn update_section(
|
||||
config.logging = part.logging;
|
||||
config.error_log = part.error_log;
|
||||
} else {
|
||||
// Backward compatibility for clients that still send only the
|
||||
// historical access-logging object to /api/config/logging.
|
||||
config.logging =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?;
|
||||
}
|
||||
@@ -334,8 +332,6 @@ async fn events(
|
||||
"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(
|
||||
(state, secret, permit, false, interval),
|
||||
|(state, secret, permit, delayed, interval)| async move {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Local control plane. Never mounted on the public proxy listener.
|
||||
|
||||
include!("router.rs");
|
||||
include!("access.rs");
|
||||
include!("api_token_access.rs");
|
||||
|
||||
+30
-10
@@ -79,8 +79,16 @@ async fn cache_purge(
|
||||
));
|
||||
}
|
||||
}
|
||||
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
|
||||
let permit = state
|
||||
.cache_invalidation_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"cache maintenance busy".into(),
|
||||
)
|
||||
})?;
|
||||
let detail = format!(
|
||||
"scope={}; in-flight fills invalidated",
|
||||
scope.vhost.as_deref().unwrap_or("all")
|
||||
@@ -98,7 +106,9 @@ async fn cache_purge(
|
||||
Some(id) => cache.purge_scope(id),
|
||||
None => cache.purge(None),
|
||||
}
|
||||
}).await.map_err(auth::internal)?;
|
||||
})
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(
|
||||
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()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
|
||||
let permit = state
|
||||
.cache_invalidation_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"cache maintenance busy".into(),
|
||||
)
|
||||
})?;
|
||||
let detail = format!(
|
||||
"operation={:?}; selector={:?}; vhost={}; host={}; value={:?}",
|
||||
request.operation,
|
||||
@@ -145,7 +163,9 @@ async fn cache_invalidate(
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
cache.invalidate(request)
|
||||
}).await.map_err(auth::internal)?
|
||||
})
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
|
||||
Ok(Json(json!({
|
||||
"result": result,
|
||||
@@ -163,8 +183,6 @@ async fn cache_ban_delete(
|
||||
Extension(session): Extension<Session>,
|
||||
Path(id): Path<u64>,
|
||||
) -> 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) {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -181,7 +199,10 @@ async fn cache_ban_delete(
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
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()})))
|
||||
}
|
||||
@@ -322,7 +343,6 @@ async fn certificate_upload(
|
||||
.audit(&session.username, "certificate.upload", &certificate.id)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
// Never serialize the PEM request or any key material, including on success.
|
||||
Ok(Json(json!({"certificate":certificate, "staged":true})))
|
||||
}
|
||||
|
||||
|
||||
+75
-16
@@ -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 crate::runtime::App;
|
||||
use axum::{extract::{Request, State}, response::{IntoResponse, Response}};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use http::StatusCode;
|
||||
use std::{collections::HashMap, sync::{Arc, LazyLock, OnceLock}};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, LazyLock, OnceLock},
|
||||
};
|
||||
|
||||
struct Asset {
|
||||
mime: &'static str,
|
||||
@@ -20,21 +24,57 @@ const SVG: &str = "image/svg+xml";
|
||||
static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
|
||||
let entries: [(&'static str, &'static str, &'static str); 22] = [
|
||||
("/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/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/integrations.js", 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/integrations.js",
|
||||
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/settings.js", JS, include_str!("web/settings.js")),
|
||||
("/assets/acme.js", JS, include_str!("web/acme.js")),
|
||||
("/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/streams.js", JS, include_str!("web/streams.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")),
|
||||
("/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()
|
||||
});
|
||||
|
||||
@@ -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 path = match resolved.as_deref() {
|
||||
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(),
|
||||
};
|
||||
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 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)
|
||||
}
|
||||
+33
-11
@@ -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 axum::{body::Body, response::Response};
|
||||
use bytes::Bytes;
|
||||
@@ -22,16 +20,31 @@ impl PanelContent {
|
||||
let mut br = Vec::new();
|
||||
{
|
||||
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");
|
||||
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 {
|
||||
gzip: codecs.gzip, br: codecs.br, zstd: codecs.zstd,
|
||||
deflate: false, min_bytes: 1024,
|
||||
gzip: codecs.gzip,
|
||||
br: codecs.br,
|
||||
zstd: codecs.zstd,
|
||||
deflate: false,
|
||||
min_bytes: 1024,
|
||||
};
|
||||
let encoding = if crate::compression::no_transform(headers) {
|
||||
None
|
||||
@@ -45,12 +58,21 @@ impl PanelContent {
|
||||
_ => (self.plain.clone(), None),
|
||||
};
|
||||
let mut response = Response::new(Body::from(body.clone()));
|
||||
response.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"));
|
||||
response
|
||||
.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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,6 @@ fn default_admin_table_density() -> String {
|
||||
fn default_admin_dashboard_density() -> String {
|
||||
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) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
prefs.dashboard_widgets.retain(|id| {
|
||||
@@ -131,8 +128,6 @@ struct AdminPreferences {
|
||||
dashboard_widgets: Vec<String>,
|
||||
#[serde(default = "default_hidden_dashboard_widgets")]
|
||||
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)]
|
||||
dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>,
|
||||
#[serde(default = "default_file_log_columns")]
|
||||
@@ -473,8 +468,6 @@ async fn update_preferences(
|
||||
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<AdminPreferences>, ApiError> {
|
||||
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
|
||||
.dashboard_widgets
|
||||
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
|
||||
|
||||
@@ -75,8 +75,6 @@ const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"maxConnections",
|
||||
"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)]
|
||||
struct DashboardWidgetDefinition {
|
||||
id: String,
|
||||
@@ -89,9 +87,6 @@ static DASHBOARD_WIDGETS: std::sync::LazyLock<Vec<DashboardWidgetDefinition>> =
|
||||
.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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DashboardDefaultPreset {
|
||||
@@ -147,8 +142,6 @@ static DASHBOARD_DEFAULT_PRESET: std::sync::LazyLock<DashboardDefaultPreset> =
|
||||
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 {
|
||||
match display_type {
|
||||
"table" => matches!(span, 6 | 12),
|
||||
|
||||
@@ -123,7 +123,6 @@ fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() {
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"extra_process_cpu".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 [
|
||||
"www_status",
|
||||
"www_kind",
|
||||
@@ -186,7 +185,6 @@ fn saved_dashboard_order_and_visibility_survive_new_factory_preset() {
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"backend_traffic".to_owned()));
|
||||
// WWW was absent from the saved document and is introduced only as opt-in.
|
||||
assert!(prefs
|
||||
.dashboard_hidden_widgets
|
||||
.contains(&"www_status".to_owned()));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Deliberately small anonymous surface. Operational templates/assets are private.
|
||||
use crate::{
|
||||
auth::Session,
|
||||
runtime::{ApiError, App},
|
||||
@@ -18,22 +17,19 @@ static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
|
||||
|
||||
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
|
||||
let content = ADMIN_INDEX.get_or_init(|| {
|
||||
// Compile-time embedded HTML fragments are assembled once at startup.
|
||||
// No additional browser requests, changed DOM nodes or script ordering.
|
||||
let (metrics, web_metrics) = include_str!("web/partials/overview-metrics.html")
|
||||
let (metrics, web_metrics) = include_str!("../web/partials/overview-metrics.html")
|
||||
.split_once("<!-- RP_METRICS_SPLIT -->")
|
||||
.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 -->")
|
||||
.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_CHARTS_MAIN -->", charts)
|
||||
.replace("<!-- RP_METRICS_WEB -->", web_metrics)
|
||||
.replace("<!-- RP_CHARTS_WEB -->", web_charts)
|
||||
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())
|
||||
.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()))
|
||||
});
|
||||
content.response(headers, compression, "text/html; charset=utf-8")
|
||||
@@ -45,7 +41,7 @@ pub(super) async fn index(
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
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());
|
||||
return Html(html).into_response();
|
||||
}
|
||||
@@ -57,7 +53,6 @@ pub(super) async fn index(
|
||||
}
|
||||
|
||||
pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
|
||||
// Keep useful protocol statuses without returning serde's input excerpts.
|
||||
let status = match error.status() {
|
||||
StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE => StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
@@ -1,4 +1,3 @@
|
||||
// Stored inside the existing per-account administration preferences document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct RefreshPreferences {
|
||||
@@ -36,6 +35,8 @@ fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiE
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ mod panel_assets;
|
||||
mod panel_content;
|
||||
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 {
|
||||
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(",
|
||||
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/core.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))
|
||||
.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 {
|
||||
use std::sync::atomic::Ordering;
|
||||
state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -325,14 +319,10 @@ fn secure(mut response: Response) -> Response {
|
||||
"permissions-policy",
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
// Do not let downstream middleware add implementation/version fingerprints.
|
||||
h.remove("server");
|
||||
h.remove("x-powered-by");
|
||||
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 {
|
||||
matches!(
|
||||
path,
|
||||
|
||||
+87
-42
@@ -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 {
|
||||
if value.len() <= 255 && !value.contains('@')
|
||||
&& value.parse::<http::uri::Authority>().is_ok()
|
||||
{
|
||||
if value.len() <= 255 && !value.contains('@') && value.parse::<http::uri::Authority>().is_ok() {
|
||||
value
|
||||
} else {
|
||||
"(invalid)"
|
||||
@@ -47,7 +43,11 @@ fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted:
|
||||
return "(invalid)";
|
||||
};
|
||||
if name == "x-forwarded-proto" {
|
||||
if matches!(value, "http" | "https") { value } else { "(invalid)" }
|
||||
if matches!(value, "http" | "https") {
|
||||
value
|
||||
} else {
|
||||
"(invalid)"
|
||||
}
|
||||
} else {
|
||||
diagnostic_admin_authority(value)
|
||||
}
|
||||
@@ -63,14 +63,19 @@ fn invalid_admin_origin(
|
||||
) -> Response {
|
||||
let headers = request.headers();
|
||||
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())
|
||||
.map(diagnostic_admin_authority)
|
||||
.unwrap_or("(absent_or_duplicate)");
|
||||
let browser_fetch_site = match single_header(headers, "sec-fetch-site") {
|
||||
None => "(absent)",
|
||||
Some("same-origin" | "same-site" | "cross-site" | "none") =>
|
||||
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)"),
|
||||
Some("same-origin" | "same-site" | "cross-site" | "none") => {
|
||||
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)")
|
||||
}
|
||||
_ => "(invalid)",
|
||||
};
|
||||
tracing::warn!(
|
||||
@@ -88,12 +93,15 @@ fn invalid_admin_origin(
|
||||
forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto),
|
||||
"administration origin rejected"
|
||||
);
|
||||
// When a browser navigates to the dashboard document, JSON here would
|
||||
// replace the entire page with a raw API error. Keep the 403 (no shell or
|
||||
// protected data) but present a standalone recovery message instead.
|
||||
let wants_html = request.headers().get(http::header::ACCEPT)
|
||||
let wants_html = request
|
||||
.headers()
|
||||
.get(http::header::ACCEPT)
|
||||
.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 {
|
||||
return secure((
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -117,10 +125,8 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
|
||||
let headers = request.headers();
|
||||
let trusted_host = trusted_admin_proxy(&state, peer);
|
||||
let trusted_proto = trusted_admin_forwarded_proto(&state, peer);
|
||||
// Pinned browser origin is panel-only. Certificate automation using API
|
||||
// credentials keeps the original forwarding/TLS authorization semantics.
|
||||
let automation_credentials = 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 {
|
||||
Ok(None)
|
||||
} 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 {
|
||||
Ok(value) => value,
|
||||
Err(()) => return invalid_admin_origin(&request, "pinned_origin_mismatch", Some("https"),
|
||||
state.active_administration.public_origin.as_deref().and_then(|value| value.strip_prefix("https://")),
|
||||
trusted_host, trusted_proto),
|
||||
Err(()) => {
|
||||
return invalid_admin_origin(
|
||||
&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 {
|
||||
// HTTPS is a trusted, administrator-pinned identity here. Do not select
|
||||
// any list element of an ambiguous forwarded-proto chain.
|
||||
("https", authority)
|
||||
} else {
|
||||
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
|
||||
Ok(value) => value,
|
||||
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,
|
||||
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))
|
||||
})
|
||||
{
|
||||
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
|
||||
.get("sec-fetch-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 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 certificate_api_session = if headers.contains_key(CERTIFICATE_API_KEY_HEADER) {
|
||||
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) {
|
||||
Ok(None) => None,
|
||||
@@ -243,8 +283,8 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let general_api_session = if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) {
|
||||
// No cookies are accepted as a fallback when a malformed Bearer header is supplied.
|
||||
let general_api_session =
|
||||
if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) {
|
||||
let bearer = single_header(headers, AUTHORIZATION.as_str())
|
||||
.and_then(|header| header.strip_prefix("Bearer "));
|
||||
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 grant = match state.store.verify_api_token(&hash, peer_ip).await {
|
||||
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()),
|
||||
};
|
||||
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 {
|
||||
token_hash: hash, csrf: String::new(),
|
||||
token_hash: hash,
|
||||
csrf: String::new(),
|
||||
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 session_seconds = state
|
||||
.config
|
||||
@@ -280,9 +332,6 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
|
||||
.config
|
||||
.administration
|
||||
.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() {
|
||||
certificate_api_session
|
||||
} 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() {
|
||||
// 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(
|
||||
"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
|
||||
}
|
||||
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(
|
||||
json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}),
|
||||
))
|
||||
|
||||
@@ -228,8 +228,6 @@ fn status_payload(state: &App) -> Value {
|
||||
});
|
||||
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");
|
||||
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
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 configured_certificates = config.tls.certificates.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 || {
|
||||
configured_certificates
|
||||
.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"]}),
|
||||
)
|
||||
}
|
||||
// Storage metadata belongs to GET /api/config, not to the editable `config`
|
||||
// document or the revisioned envelopes returned by update/restore endpoints.
|
||||
#[derive(Serialize)]
|
||||
struct ConfigurationRead {
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -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 {
|
||||
let view = state.diagnostics.view();
|
||||
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 {
|
||||
let mut services = serde_json::Map::new();
|
||||
for service in &snapshot.envelope.config.web_services {
|
||||
|
||||
@@ -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> {
|
||||
let value = value.trim();
|
||||
(!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> {
|
||||
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
VALUE.get_or_init(|| {
|
||||
VALUE
|
||||
.get_or_init(|| {
|
||||
sysinfo::System::long_os_version()
|
||||
.or_else(|| {
|
||||
let name = sysinfo::System::name()?;
|
||||
@@ -19,20 +17,21 @@ fn os_release() -> Option<&'static str> {
|
||||
})
|
||||
})
|
||||
.and_then(valid_host_text)
|
||||
}).as_deref()
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
fn kernel_release() -> Option<&'static str> {
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 system = state.diagnostics.view();
|
||||
let cache_ram_bytes =
|
||||
|
||||
+110
-63
@@ -315,7 +315,6 @@ mod tests {
|
||||
.unwrap();
|
||||
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")] {
|
||||
let mut request = Request::builder()
|
||||
.uri("/api/auth/session")
|
||||
@@ -902,7 +901,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
if path == "/logs" {
|
||||
// The operational log route is private even on browser refresh.
|
||||
assert_eq!(public.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(json_body(public).await, json!({"error":"auth.required"}));
|
||||
} else {
|
||||
@@ -1243,7 +1241,6 @@ mod tests {
|
||||
.body(Body::from("{}"))
|
||||
.unwrap()
|
||||
};
|
||||
// Certificate API credentials cannot trigger an all-certificate reload.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external(
|
||||
@@ -1254,8 +1251,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
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
|
||||
.clone()
|
||||
.oneshot(external(
|
||||
@@ -1266,7 +1261,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
// A reload-only token cannot use the deploy endpoint at all.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -1281,7 +1275,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
// A different ID is rejected by the credential grant before the handler.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external(
|
||||
@@ -1320,8 +1313,6 @@ mod tests {
|
||||
.unwrap();
|
||||
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!(
|
||||
json_body(response).await,
|
||||
json!({"error":"certificate_api.certificate_forbidden"})
|
||||
@@ -1482,7 +1473,6 @@ mod tests {
|
||||
assert!(detail["credential"].get("token_hash").is_none());
|
||||
let created_at = detail["credential"]["created_at"].clone();
|
||||
|
||||
// A bearer-only automation client cannot read or modify management records.
|
||||
let denied = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -1526,7 +1516,6 @@ mod tests {
|
||||
.unwrap();
|
||||
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(
|
||||
Method::PATCH, "/api/certificate-api/certbot-prod",
|
||||
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_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(
|
||||
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())
|
||||
.unwrap();
|
||||
assert_eq!(saved, normalized);
|
||||
// Preferences must also survive a subsequent authenticated read.
|
||||
let persisted = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -1976,7 +1962,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
assert_eq!(json_body(persisted).await, upgraded);
|
||||
// Wrong allowlist value is rejected even if the rest is valid.
|
||||
let mut invalid = selected.clone();
|
||||
invalid["refresh"]["traffic_seconds"] = json!(4);
|
||||
let rejected = router
|
||||
@@ -2120,8 +2105,6 @@ mod tests {
|
||||
if valid_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
|
||||
.clone()
|
||||
.oneshot(req.body(Body::from("{}")).unwrap())
|
||||
@@ -2898,7 +2881,6 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
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);
|
||||
sort_file_log_records(&mut records, "status", true);
|
||||
assert_eq!(
|
||||
@@ -3099,8 +3081,6 @@ mod tests {
|
||||
assert_eq!(live.status(), StatusCode::OK);
|
||||
let live = json_body(live).await;
|
||||
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("process_cpu_percent").is_some());
|
||||
assert!(live["live_system"].get("process_rss_bytes").is_some());
|
||||
@@ -3170,7 +3150,7 @@ mod tests {
|
||||
}
|
||||
value.overview_seconds = 3;
|
||||
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());
|
||||
value.overview_seconds = 1;
|
||||
for seconds in [1, 2, 4, 31] {
|
||||
@@ -3256,8 +3236,6 @@ mod tests {
|
||||
"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
|
||||
.oneshot(
|
||||
request("/api/auth/logout")
|
||||
@@ -3341,8 +3319,6 @@ mod tests {
|
||||
config.administration.public_origin = Some("https://panel.example.test".into());
|
||||
let (router, store, _dir, secret, csrf) = fixture_with_config(config).await;
|
||||
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
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -3367,8 +3343,6 @@ mod tests {
|
||||
.unwrap();
|
||||
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()
|
||||
.uri("/api/auth/session")
|
||||
.header("host", "panel.example.test")
|
||||
@@ -3383,8 +3357,6 @@ mod tests {
|
||||
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
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -3484,8 +3456,6 @@ mod tests {
|
||||
assert_eq!(result.status(), StatusCode::FORBIDDEN,
|
||||
"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
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -3582,10 +3552,8 @@ mod tests {
|
||||
assert!(payload["config"].get("storage").is_none());
|
||||
assert_eq!(payload["storage"]["mode"], "sqlite");
|
||||
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
|
||||
.oneshot(
|
||||
request("/api/config/storage")
|
||||
@@ -3669,15 +3637,38 @@ mod tests {
|
||||
async fn json_metrics_api_is_authenticated_and_matches_prometheus_families() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let snapshot = router.clone().oneshot(
|
||||
request("/api/metrics/json").header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap()
|
||||
).await.unwrap();
|
||||
let snapshot = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/metrics/json")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
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;
|
||||
for field in ["summary", "vhosts", "requests", "system", "cache", "backends", "web_server",
|
||||
"streams", "docker", "traffic", "transform", "logging", "capacity", "prometheus"] {
|
||||
for field in [
|
||||
"summary",
|
||||
"vhosts",
|
||||
"requests",
|
||||
"system",
|
||||
"cache",
|
||||
"backends",
|
||||
"web_server",
|
||||
"streams",
|
||||
"docker",
|
||||
"traffic",
|
||||
"transform",
|
||||
"logging",
|
||||
"capacity",
|
||||
"prometheus",
|
||||
] {
|
||||
assert!(snapshot.get(field).is_some(), "missing: {field}");
|
||||
}
|
||||
assert_eq!(snapshot["vhosts"]["total"], 0);
|
||||
@@ -3687,21 +3678,52 @@ mod tests {
|
||||
assert_eq!(snapshot["summary"]["backends"]["total"], 0);
|
||||
assert!(snapshot["summary"]["traffic"]["bytes_total"].is_number());
|
||||
assert!(snapshot["prometheus"]["samples"]["rust_proxy_requests_total"].is_array());
|
||||
assert_eq!(snapshot["prometheus"]["types"]["rust_proxy_requests_total"], "counter");
|
||||
assert!(general_api_token_allowed(&Method::GET, "/api/metrics/json", "ro"));
|
||||
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!(
|
||||
snapshot["prometheus"]["types"]["rust_proxy_requests_total"],
|
||||
"counter"
|
||||
);
|
||||
assert!(general_api_token_allowed(
|
||||
&Method::GET,
|
||||
"/api/metrics/json",
|
||||
"ro"
|
||||
));
|
||||
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.headers()["cache-control"], "no-store");
|
||||
let summary = json_body(summary).await;
|
||||
assert!(summary.get("prometheus").is_none());
|
||||
assert_eq!(summary["vhosts"]["enabled"], snapshot["summary"]["vhosts"]["enabled"]);
|
||||
assert_eq!(summary["backends"]["total"], snapshot["summary"]["backends"]["total"]);
|
||||
assert_eq!(
|
||||
summary["vhosts"]["enabled"],
|
||||
snapshot["summary"]["vhosts"]["enabled"]
|
||||
);
|
||||
assert_eq!(
|
||||
summary["backends"]["total"],
|
||||
snapshot["summary"]["backends"]["total"]
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
@@ -3710,12 +3732,20 @@ mod tests {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
let token = "T".repeat(64);
|
||||
let cidrs = vec!["127.0.0.1/32".to_string()];
|
||||
assert!(store.create_api_token(
|
||||
"api-monitor", &auth::secret_hash(&token), "ro", "monitor test", &cidrs, "owner"
|
||||
).await.unwrap());
|
||||
assert!(store
|
||||
.create_api_token(
|
||||
"api-monitor",
|
||||
&auth::secret_hash(&token),
|
||||
"ro",
|
||||
"monitor test",
|
||||
&cidrs,
|
||||
"owner"
|
||||
)
|
||||
.await
|
||||
.unwrap());
|
||||
let build = |peer: Option<&str>| {
|
||||
let mut request = request("/api/metrics/json")
|
||||
.header("authorization", format!("Bearer {token}"));
|
||||
let mut request =
|
||||
request("/api/metrics/json").header("authorization", format!("Bearer {token}"));
|
||||
if let Some(peer) = peer {
|
||||
request = request.extension(PeerAddr(peer.parse().unwrap()));
|
||||
}
|
||||
@@ -3723,19 +3753,36 @@ mod tests {
|
||||
};
|
||||
let response = router.clone().oneshot(build(None)).await.unwrap();
|
||||
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);
|
||||
let response = json_body(response).await;
|
||||
assert!(response["prometheus"]["samples"].is_object());
|
||||
assert!(response.get("config").is_none());
|
||||
let summarize = |peer: Option<&str>| {
|
||||
let mut req = request("/api/metrics/summary")
|
||||
.header("authorization", format!("Bearer {token}"));
|
||||
if let Some(peer) = peer { req = req.extension(PeerAddr(peer.parse().unwrap())); }
|
||||
let mut req =
|
||||
request("/api/metrics/summary").header("authorization", format!("Bearer {token}"));
|
||||
if let Some(peer) = peer {
|
||||
req = req.extension(PeerAddr(peer.parse().unwrap()));
|
||||
}
|
||||
req.body(Body::empty()).unwrap()
|
||||
};
|
||||
assert_eq!(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!(
|
||||
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);
|
||||
let summary = json_body(summary).await;
|
||||
assert!(summary["traffic"]["requests_total"].is_number());
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Local filesystem authority. The process owner can initialize or recover the sole admin.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use fs2::FileExt;
|
||||
use rand::{rngs::SysRng, TryRng};
|
||||
@@ -102,8 +101,6 @@ pub fn load_or_create_setup_token(dir: &Path) -> Result<String> {
|
||||
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<()> {
|
||||
if !path.try_exists()? {
|
||||
private_options().create_new(true).open(path)?.sync_all()?;
|
||||
@@ -134,4 +131,4 @@ pub fn prepare_database(path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
include!("files/tests.rs");
|
||||
include!("tests.rs");
|
||||
@@ -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 store;
|
||||
use crate::{db::Store, runtime::ApiError};
|
||||
@@ -151,7 +149,6 @@ impl Auth {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Generate valid dummy work for unknown usernames. This happens only at startup.
|
||||
let dummy_hash = hash_password_offline(random_secret()).await?;
|
||||
Ok(Arc::new(Self {
|
||||
store,
|
||||
|
||||
@@ -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 crate::db::{audit_tx, Store};
|
||||
use anyhow::{ensure, Result};
|
||||
@@ -35,17 +34,12 @@ impl Store {
|
||||
tx.commit().await?;
|
||||
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> {
|
||||
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)
|
||||
.execute(&self.pool).await?;
|
||||
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(
|
||||
&self,
|
||||
generation: i64,
|
||||
@@ -115,7 +109,6 @@ impl Store {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
// Independent caps avoid a local CLI burst evicting all browser sessions.
|
||||
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 ?)")
|
||||
.bind(kind).bind(cap - 1).execute(&mut *tx).await?;
|
||||
@@ -150,12 +143,6 @@ impl Store {
|
||||
"browser session lifetime must be 7..30 days"
|
||||
);
|
||||
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")
|
||||
.bind(time)
|
||||
.bind(browser_lifetime)
|
||||
@@ -215,8 +202,6 @@ impl Store {
|
||||
tx.commit().await?;
|
||||
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(
|
||||
&self,
|
||||
hash: &str,
|
||||
|
||||
@@ -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/ownership.rs");
|
||||
include!("backup/snapshot.rs");
|
||||
|
||||
@@ -37,11 +37,11 @@ fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> {
|
||||
}
|
||||
let raw = CString::new(path.as_os_str().as_bytes())
|
||||
.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) };
|
||||
if rc != 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.with_context(|| format!("restore ownership update failed for {}", path.display()));
|
||||
return Err(std::io::Error::last_os_error()).with_context(|| {
|
||||
format!("restore ownership update failed for {}", path.display())
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -53,4 +53,3 @@ fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> {
|
||||
fn set_tree_owner(_path: &Path, _owner: TreeOwner) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -263,9 +263,6 @@ fn validate_snapshot_config(
|
||||
&certificate.cert_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;
|
||||
}
|
||||
for name in [&certificate.cert_file, &certificate.key_file] {
|
||||
|
||||
@@ -19,7 +19,6 @@ mod tests {
|
||||
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
|
||||
|
||||
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;
|
||||
bytes[tag_byte] ^= 1;
|
||||
fs::write(&tampered, bytes).unwrap();
|
||||
|
||||
@@ -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/health.rs");
|
||||
include!("balance/lease_runtime.rs");
|
||||
|
||||
@@ -112,14 +112,10 @@ impl BackendState {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
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_ejected_at
|
||||
.is_some_and(|at| at.elapsed() >= h.passive_cooldown)
|
||||
@@ -173,8 +169,6 @@ impl BackendState {
|
||||
.saturating_add(self.bytes_out.load(Ordering::Relaxed));
|
||||
let delta = total.saturating_sub(sample.total);
|
||||
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;
|
||||
} else {
|
||||
let rate = delta as f64 / elapsed;
|
||||
@@ -367,8 +361,6 @@ impl BackendState {
|
||||
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(
|
||||
&self,
|
||||
active: &HealthCheck,
|
||||
@@ -400,8 +392,6 @@ impl BackendState {
|
||||
if !passive.counts(reason) {
|
||||
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" {
|
||||
let now = Instant::now();
|
||||
if h.last_io_counted_at
|
||||
@@ -459,8 +449,6 @@ pub struct Member {
|
||||
pub backend: Arc<CompiledBackend>,
|
||||
pub definition: PoolMember,
|
||||
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>,
|
||||
passive_settings: Arc<PassiveHealth>,
|
||||
token: String,
|
||||
|
||||
@@ -76,8 +76,6 @@ impl Lease {
|
||||
self.member.state.errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
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") {
|
||||
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");
|
||||
}
|
||||
}
|
||||
/// 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) {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(250));
|
||||
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() => {
|
||||
let Some(app) = app.upgrade() else { break; };
|
||||
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|
|
||||
pool.members.iter().map(|member|
|
||||
(Arc::clone(member), Arc::clone(&member.health_settings)))).collect();
|
||||
for (member, _) in &members {
|
||||
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.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;
|
||||
for offset in 0..members.len() {
|
||||
if checks.len() >= 16 { break; }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Core pool model and policies.
|
||||
use crate::{config::CompiledBackend, vhost::PoolMember};
|
||||
use anyhow::{ensure, Result};
|
||||
use axum::body::Body;
|
||||
@@ -54,15 +53,8 @@ pub struct Keepalive {
|
||||
pub idle_timeout_seconds: u64,
|
||||
pub max_idle_per_host: usize,
|
||||
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,
|
||||
/// Maximum age of one backend Hyper client generation. Zero disables
|
||||
/// time-based recycling.
|
||||
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,
|
||||
}
|
||||
impl Default for Keepalive {
|
||||
@@ -70,9 +62,6 @@ impl Default for Keepalive {
|
||||
Self {
|
||||
enabled: true,
|
||||
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,
|
||||
tcp_keepalive_seconds: 30,
|
||||
pool_recycle_requests: 25_000,
|
||||
@@ -219,7 +208,6 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
/// Resolve active health per member; a disabled monitor never probes.
|
||||
pub fn health_for(&self, member: &PoolMember) -> HealthCheck {
|
||||
let mut health = member
|
||||
.health_check
|
||||
@@ -233,7 +221,6 @@ impl Settings {
|
||||
}
|
||||
health
|
||||
}
|
||||
/// Monitoring off also excludes passive ejection; failures remain logged.
|
||||
pub fn passive_for(&self, member: &PoolMember) -> PassiveHealth {
|
||||
let mut passive = self.passive_health.clone();
|
||||
if !member.health_check_enabled {
|
||||
@@ -389,8 +376,6 @@ impl Default for FailureObservation {
|
||||
}
|
||||
#[derive(Debug)]
|
||||
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,
|
||||
health: Mutex<Health>,
|
||||
traffic: Mutex<Traffic>,
|
||||
|
||||
+15
-26
@@ -72,13 +72,8 @@ impl Pool {
|
||||
.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 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();
|
||||
}
|
||||
if same_origin
|
||||
@@ -215,9 +210,6 @@ impl Pool {
|
||||
tls: bool,
|
||||
excluded: &[String],
|
||||
) -> 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() {
|
||||
let member = &self.members[0];
|
||||
if !member.definition.pool_eligible
|
||||
@@ -247,8 +239,6 @@ impl Pool {
|
||||
} else {
|
||||
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| {
|
||||
self.members.iter().find(|m| {
|
||||
m.token == token
|
||||
@@ -269,8 +259,6 @@ impl Pool {
|
||||
if self.settings.algorithm == Algorithm::RoundRobin
|
||||
&& 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 chosen = members[0];
|
||||
let mut best = f64::NEG_INFINITY;
|
||||
@@ -279,12 +267,13 @@ impl Pool {
|
||||
let weight = member
|
||||
.state
|
||||
.weight(member.definition.weight, self.settings.slow_start_seconds);
|
||||
let current = if let Some(current) =
|
||||
scheduling.get_mut(member.definition.id.as_str())
|
||||
{
|
||||
let current =
|
||||
if let Some(current) = scheduling.get_mut(member.definition.id.as_str()) {
|
||||
current
|
||||
} else {
|
||||
scheduling.entry(member.definition.id.clone()).or_insert(0.0)
|
||||
scheduling
|
||||
.entry(member.definition.id.clone())
|
||||
.or_insert(0.0)
|
||||
};
|
||||
*current += weight;
|
||||
total += weight;
|
||||
@@ -305,8 +294,6 @@ impl Pool {
|
||||
} else {
|
||||
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)
|
||||
.then(|| crate::vhost::canonical_ip(peer).to_string());
|
||||
for offset in 0..members.len() {
|
||||
@@ -330,16 +317,18 @@ impl Pool {
|
||||
-unit.max(f64::MIN_POSITIVE).ln() / member.definition.weight as f64
|
||||
}
|
||||
_ => {
|
||||
let weight = member.state.weight(
|
||||
member.definition.weight,
|
||||
self.settings.slow_start_seconds,
|
||||
);
|
||||
let weight = member
|
||||
.state
|
||||
.weight(member.definition.weight, self.settings.slow_start_seconds);
|
||||
match algorithm {
|
||||
Algorithm::LeastTraffic =>
|
||||
(member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight,
|
||||
Algorithm::Adaptive =>
|
||||
Algorithm::LeastTraffic => {
|
||||
(member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight
|
||||
}
|
||||
Algorithm::Adaptive => {
|
||||
((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,
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -19,10 +19,6 @@ impl Pool {
|
||||
"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();
|
||||
item.insert("id".into(), json!(m.definition.id));
|
||||
item.insert("url".into(), json!(m.definition.url));
|
||||
@@ -85,13 +81,23 @@ impl Pool {
|
||||
"upstream_client_rotations_total".into(),
|
||||
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();
|
||||
item.insert("upstream_protocol_observed".into(), json!(observed.last_version));
|
||||
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(
|
||||
"upstream_protocol_observed".into(),
|
||||
json!(observed.last_version),
|
||||
);
|
||||
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("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0));
|
||||
item.insert("last_probe_ms".into(), json!(h.last_probe_ms));
|
||||
|
||||
+21
-11
@@ -66,7 +66,7 @@ mod tests {
|
||||
defaults.health_check_enabled = false;
|
||||
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off);
|
||||
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 = Some(HealthCheck {
|
||||
mode: HealthMode::Http,
|
||||
@@ -105,7 +105,8 @@ mod tests {
|
||||
assert_eq!(parsed.keepalive.max_idle_per_host, 64);
|
||||
let explicit: Settings = serde_json::from_value(serde_json::json!({
|
||||
"keepalive": { "max_idle_per_host": 4 }
|
||||
})).unwrap();
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(explicit.keepalive.max_idle_per_host, 4);
|
||||
assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000);
|
||||
assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600);
|
||||
@@ -356,7 +357,7 @@ mod tests {
|
||||
}
|
||||
assert_eq!(counts, [30, 10]);
|
||||
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]
|
||||
async fn least_connections_counts_a_lease_until_its_last_owner_drops() {
|
||||
@@ -769,7 +770,6 @@ mod tests {
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn single_backend_fast_path_observes_capacity_and_health_without_affinity_cookie() {
|
||||
let pool = capacity_pool(1, 0);
|
||||
@@ -792,7 +792,10 @@ mod tests {
|
||||
let first = select(&pool);
|
||||
let second = select(&pool);
|
||||
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]
|
||||
@@ -800,10 +803,14 @@ mod tests {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
assert!(state.healthy());
|
||||
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));
|
||||
assert!(!state.healthy());
|
||||
// Preserve quarantine rules; an active probe is not invented here.
|
||||
state.observe_passive(&active, &passive, "ok", Duration::from_millis(2));
|
||||
assert!(state.healthy());
|
||||
}
|
||||
@@ -812,14 +819,17 @@ mod tests {
|
||||
fn successful_lease_response_keeps_passive_observability_and_failure_detail() {
|
||||
let pool = capacity_pool(0, 0);
|
||||
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;
|
||||
assert_eq!(state.last_status.load(Ordering::Relaxed), 200);
|
||||
assert_eq!(lock(&state.health).passive_result, "ok");
|
||||
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!(lock(&state.last_failure).detail, "status=503");
|
||||
}
|
||||
|
||||
} // mod tests
|
||||
}
|
||||
@@ -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 argon2::{
|
||||
password_hash::{phc::PasswordHash, PasswordVerifier},
|
||||
@@ -78,7 +76,6 @@ impl Settings {
|
||||
for user in &self.users {
|
||||
validate_username(&user.username)?;
|
||||
ensure!(names.insert(&user.username), "basic.duplicate_username");
|
||||
// Bound KDF cost before accepting imported hashes. No arbitrary PHC parameters.
|
||||
ensure!(
|
||||
user.password_hash.len() <= 256
|
||||
&& user
|
||||
@@ -88,8 +85,6 @@ impl Settings {
|
||||
);
|
||||
let hash = PasswordHash::new(&user.password_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!(
|
||||
hash.salt.is_some_and(|s| s.to_salt_string().len() >= 16)
|
||||
&& hash.hash.is_some_and(|h| h.len() == 32),
|
||||
@@ -157,7 +152,6 @@ fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response {
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("60"));
|
||||
}
|
||||
// Custom error pages must not turn an authentication failure into success.
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::tinyauth::AuthenticationFailure);
|
||||
@@ -214,7 +208,6 @@ impl Compiled {
|
||||
return Err(denied(StatusCode::TOO_MANY_REQUESTS, None));
|
||||
}
|
||||
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)));
|
||||
};
|
||||
let found = self.settings.users.iter().find(|u| u.username == name);
|
||||
@@ -254,4 +247,4 @@ impl Compiled {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("basic_auth/tests.rs");
|
||||
include!("tests.rs");
|
||||
+281
-116
@@ -75,13 +75,25 @@ mod tests {
|
||||
let ticket = ticket(&cache, &HeaderMap::new());
|
||||
let mut response = Response::new(Body::from("zero-freshness"));
|
||||
*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");
|
||||
assert_eq!(plan.ttl, 0, "{control}");
|
||||
cache.wrap(response, ticket.clone(), plan, &settings)
|
||||
.into_body().collect().await.unwrap();
|
||||
cache
|
||||
.wrap(response, ticket.clone(), plan, &settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
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]
|
||||
@@ -180,7 +192,14 @@ mod tests {
|
||||
let mut single_range = HeaderMap::new();
|
||||
single_range.insert("range", HeaderValue::from_static("bytes=0-3"));
|
||||
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
|
||||
);
|
||||
assert!(request_cacheable(
|
||||
@@ -193,7 +212,14 @@ mod tests {
|
||||
));
|
||||
single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9"));
|
||||
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")
|
||||
);
|
||||
}
|
||||
@@ -218,11 +244,23 @@ mod tests {
|
||||
fn request_max_stale_is_bounded_and_respects_revalidation_flags() {
|
||||
let unbounded = headers("max-stale");
|
||||
let bounded = headers("max-stale=15");
|
||||
assert_eq!(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(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(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, 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, false, true, false, &unbounded), None);
|
||||
}
|
||||
@@ -268,9 +306,21 @@ mod tests {
|
||||
origin_last_modified: None,
|
||||
};
|
||||
assert!(base.can_stale_if_error());
|
||||
assert!(!Revalidation { age: 91, ..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());
|
||||
assert!(!Revalidation {
|
||||
age: 91,
|
||||
..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]
|
||||
@@ -378,12 +428,8 @@ mod tests {
|
||||
json: true,
|
||||
..Default::default()
|
||||
};
|
||||
let transformed = crate::transform::apply(
|
||||
response,
|
||||
"/data.json",
|
||||
&transform_settings,
|
||||
false,
|
||||
)
|
||||
let transformed =
|
||||
crate::transform::apply(response, "/data.json", &transform_settings, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let transformed_plan = plan(
|
||||
@@ -403,7 +449,9 @@ mod tests {
|
||||
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
|
||||
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()["content-length"].to_str().unwrap(),
|
||||
@@ -460,10 +508,9 @@ mod tests {
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"content-type",
|
||||
HeaderValue::from_str(content_type).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", HeaderValue::from_str(content_type).unwrap());
|
||||
response.headers_mut().insert(
|
||||
"content-length",
|
||||
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
|
||||
@@ -496,12 +543,7 @@ mod tests {
|
||||
"json" => transform_settings.json = true,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let transformed = crate::transform::apply(
|
||||
response,
|
||||
path,
|
||||
&transform_settings,
|
||||
false,
|
||||
)
|
||||
let transformed = crate::transform::apply(response, path, &transform_settings, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let transformed_plan = plan(
|
||||
@@ -521,9 +563,15 @@ mod tests {
|
||||
);
|
||||
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
|
||||
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()["content-length"],
|
||||
@@ -701,7 +749,9 @@ mod tests {
|
||||
let mut s = Settings::default();
|
||||
s.transient_max_bytes = 128 * 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_eq!(cache.view().transient_bytes, 96 * 1024);
|
||||
drop(first);
|
||||
@@ -756,9 +806,7 @@ mod tests {
|
||||
must_revalidate: false,
|
||||
proxy_revalidate: false,
|
||||
origin_etag: Some(HeaderValue::from_static("W/\"origin\"")),
|
||||
origin_last_modified: Some(HeaderValue::from_static(
|
||||
"Mon, 26 Jan 2026 23:11:40 GMT",
|
||||
)),
|
||||
origin_last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
|
||||
};
|
||||
let mut matching = HeaderMap::new();
|
||||
matching.insert("if-none-match", HeaderValue::from_static("\"origin\""));
|
||||
@@ -781,13 +829,7 @@ mod tests {
|
||||
let mut response_headers = headers("public, max-age=60");
|
||||
response_headers.insert("etag", public_etag.clone());
|
||||
response_headers.insert("last-modified", modified.clone());
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
&response_headers,
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let plan = plan(StatusCode::OK, &response_headers, &settings, Duration::ZERO).unwrap();
|
||||
let mut response = Response::new(Body::from("transformed"));
|
||||
*response.headers_mut() = response_headers;
|
||||
response
|
||||
@@ -818,9 +860,15 @@ mod tests {
|
||||
assert_eq!(client_conditional["if-modified-since"], modified);
|
||||
|
||||
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("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);
|
||||
assert_eq!(merged["etag"], public_etag);
|
||||
}
|
||||
@@ -915,17 +963,12 @@ mod tests {
|
||||
let mut origin_headers = headers("public, max-age=60");
|
||||
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding"));
|
||||
origin_headers.insert("content-length", HeaderValue::from_static("7"));
|
||||
let origin_plan = 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");
|
||||
transformed_headers.insert("content-length", HeaderValue::from_static("7"));
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
&transformed_headers,
|
||||
&s,
|
||||
Duration::ZERO,
|
||||
)
|
||||
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
|
||||
.expect("transformed plan");
|
||||
let cache_plan = origin_plan
|
||||
.without_vary("accept-encoding")
|
||||
@@ -962,20 +1005,12 @@ mod tests {
|
||||
dark.insert("x-theme", HeaderValue::from_static("dark"));
|
||||
|
||||
let mut origin_headers = headers("public, max-age=60");
|
||||
origin_headers.insert(
|
||||
"vary",
|
||||
HeaderValue::from_static("Accept-Encoding, X-Theme"),
|
||||
);
|
||||
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
|
||||
.expect("origin plan");
|
||||
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme"));
|
||||
let origin_plan =
|
||||
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
|
||||
let mut transformed_headers = headers("public, max-age=60");
|
||||
transformed_headers.insert("vary", HeaderValue::from_static("X-Theme"));
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
&transformed_headers,
|
||||
&s,
|
||||
Duration::ZERO,
|
||||
)
|
||||
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
|
||||
.expect("transformed plan");
|
||||
let cache_plan = origin_plan
|
||||
.without_vary("accept-encoding")
|
||||
@@ -1067,11 +1102,20 @@ mod tests {
|
||||
let entry = store.entries.values_mut().next().expect("stored object");
|
||||
entry.received = Instant::now() - Duration::from_secs(2);
|
||||
}
|
||||
assert!(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");
|
||||
assert!(
|
||||
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 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_eq!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
@@ -1107,7 +1151,14 @@ mod tests {
|
||||
validation.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let merged = candidate.merge_validation_headers(&validation);
|
||||
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");
|
||||
assert_eq!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
@@ -1150,7 +1201,14 @@ mod tests {
|
||||
validation.insert("etag", HeaderValue::from_static("\"origin\""));
|
||||
let merged = candidate.merge_validation_headers(&validation);
|
||||
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");
|
||||
assert!(hit.resident_age_seconds >= 32);
|
||||
}
|
||||
@@ -1262,7 +1320,10 @@ mod tests {
|
||||
fn aged_upstream_freshness_caps_remaining_not_total_age() {
|
||||
let settings = settings();
|
||||
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("etag", HeaderValue::from_static("\"origin-etag\""));
|
||||
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.ttl, 1553483 + settings.max_ttl_seconds);
|
||||
|
||||
// Local cap never overrides the remaining origin lifetime.
|
||||
headers.insert("age", HeaderValue::from_static("2591990"));
|
||||
let almost_expired = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
|
||||
assert_eq!(almost_expired.remaining_ttl(), 10);
|
||||
headers.insert("age", HeaderValue::from_static("2592010"));
|
||||
assert_eq!(
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -1291,8 +1361,12 @@ mod tests {
|
||||
fn age_uses_rfc9111_max_of_apparent_and_corrected_age() {
|
||||
let s = settings();
|
||||
let mut h = headers("public, max-age=3600");
|
||||
h.insert("date", httpdate::fmt_http_date(SystemTime::now()
|
||||
- Duration::from_secs(10)).parse().unwrap());
|
||||
h.insert(
|
||||
"date",
|
||||
httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10))
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
h.insert("age", HeaderValue::from_static("3"));
|
||||
let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap();
|
||||
assert!(p.initial_age < Duration::from_secs(12));
|
||||
@@ -1310,30 +1384,76 @@ mod tests {
|
||||
let original_body = b"body { color: red; margin: 0px; }";
|
||||
let mut upstream = Response::new(Body::from(Bytes::from_static(original_body)));
|
||||
*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("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();
|
||||
upstream.headers_mut().insert(
|
||||
"date",
|
||||
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
|
||||
);
|
||||
upstream
|
||||
.headers_mut()
|
||||
.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(
|
||||
upstream, "/theme.css", &crate::transform::Settings {
|
||||
enabled: true, css: true, ..Default::default()
|
||||
}, false
|
||||
).await.unwrap();
|
||||
assert!(transformed.extensions().get::<crate::transform::Applied>().is_some());
|
||||
upstream,
|
||||
"/theme.css",
|
||||
&crate::transform::Settings {
|
||||
enabled: true,
|
||||
css: true,
|
||||
..Default::default()
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(transformed
|
||||
.extensions()
|
||||
.get::<crate::transform::Applied>()
|
||||
.is_some());
|
||||
assert_eq!(transformed.headers()["age"], "1553483");
|
||||
let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings,
|
||||
Duration::ZERO).unwrap();
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
transformed.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let combined = origin_plan.restrict(transformed_plan);
|
||||
assert!(combined.remaining_ttl() > 86_000);
|
||||
let first = cache.wrap(transformed, ticket.clone(), combined, &settings)
|
||||
.into_body().collect().await.unwrap().to_bytes();
|
||||
let hit = cache.get(&ticket).expect("aged, fresh CSS should be cached after transform");
|
||||
let first = cache
|
||||
.wrap(transformed, ticket.clone(), combined, &settings)
|
||||
.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.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();
|
||||
assert_eq!(first, second);
|
||||
let dash = cache.dashboard(None, 0, 10);
|
||||
@@ -1379,18 +1499,34 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn hot_hit_updates_lru_without_replacing_the_entry_or_losing_hit_counter() {
|
||||
let cache = Cache::new(1);
|
||||
let settings = Settings { max_entries: 2, ..settings() };
|
||||
let settings = Settings {
|
||||
max_entries: 2,
|
||||
..settings()
|
||||
};
|
||||
let h = HeaderMap::new();
|
||||
let a = cache.ticket(1, "http", "example.test", "app", "/a", &h).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();
|
||||
let a = cache
|
||||
.ticket(1, "http", "example.test", "app", "/a", &h)
|
||||
.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, b.clone(), "payload-b", &settings).await;
|
||||
assert_eq!(cache.get(&a).unwrap().hits, 1);
|
||||
assert_eq!(cache.get(&a).unwrap().hits, 2);
|
||||
fill(&cache, c.clone(), "payload-c", &settings).await;
|
||||
assert!(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!(
|
||||
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.view().evictions, 1);
|
||||
assert_eq!(cache.view().entries, 2);
|
||||
@@ -1423,7 +1559,10 @@ mod tests {
|
||||
assert!(after.hot_allocated_bytes <= s.max_bytes);
|
||||
assert_eq!(after.fill_bytes_active, 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!(after.evictions >= 1);
|
||||
}
|
||||
@@ -1477,8 +1616,6 @@ mod tests {
|
||||
&a,
|
||||
)
|
||||
.unwrap();
|
||||
// Compare unrelated headers/member choice within the same frontend
|
||||
// scheme. HTTP and HTTPS intentionally have distinct cache identities.
|
||||
let other_member = cache
|
||||
.ticket(
|
||||
1,
|
||||
@@ -1559,12 +1696,17 @@ mod tests {
|
||||
assert_eq!(first, Bytes::from_static(b"first-"));
|
||||
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().rejected_fills, 1);
|
||||
|
||||
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!(
|
||||
hit.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"complete")
|
||||
@@ -1604,11 +1746,18 @@ mod tests {
|
||||
let ticket = ticket(&cache, &HeaderMap::new());
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.headers_mut() = headers("public, max-age=600");
|
||||
response.headers_mut().insert("content-length", HeaderValue::from_static("0"));
|
||||
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
response
|
||||
.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);
|
||||
assert!(response.body().is_end_stream());
|
||||
// HTTP/1.1 Content-Length: 0 does not ask the response body for frames.
|
||||
drop(response);
|
||||
assert!(cache.get(&ticket).is_some());
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
@@ -1623,8 +1772,16 @@ mod tests {
|
||||
let ticket = ticket(&cache, &HeaderMap::new());
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.headers_mut() = headers("public, max-age=600");
|
||||
response.headers_mut().insert("content-length", HeaderValue::from_static("6"));
|
||||
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
response
|
||||
.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));
|
||||
assert!(cache.get(&ticket).is_none());
|
||||
assert_eq!(cache.view().fills_active, 0);
|
||||
@@ -1638,8 +1795,16 @@ mod tests {
|
||||
let ticket = ticket(&cache, &HeaderMap::new());
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.headers_mut() = headers("public, max-age=600");
|
||||
response.headers_mut().insert("content-length", HeaderValue::from_static("0"));
|
||||
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-length", HeaderValue::from_static("0"));
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache.purge(None);
|
||||
drop(cache.wrap(response, ticket.clone(), plan, &settings));
|
||||
assert!(cache.get(&ticket).is_none());
|
||||
Vendored
-8
@@ -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() {
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
@@ -51,8 +47,6 @@ impl Cache {
|
||||
let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1));
|
||||
let mut after_sequence = 0u64;
|
||||
|
||||
// Copy candidate metadata in bounded chunks. Request-path lookups only
|
||||
// contend with this mutex for a small snapshot, never for sorting or I/O.
|
||||
loop {
|
||||
let batch: Vec<_> = {
|
||||
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));
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
Vendored
+955
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+14
-12
@@ -51,9 +51,13 @@ impl Cache {
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
};
|
||||
let scope_revision = self.scope_revisions
|
||||
.lock().unwrap_or_else(|e| e.into_inner())
|
||||
.get(&entry.scope).copied().unwrap_or(0);
|
||||
let scope_revision = self
|
||||
.scope_revisions
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&entry.scope)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
self.cold_queue_items.fetch_add(1, Ordering::Relaxed);
|
||||
if tx
|
||||
.try_send(ColdCommand::Store(ColdDraft {
|
||||
@@ -138,9 +142,6 @@ impl Cache {
|
||||
drop(store);
|
||||
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 {
|
||||
let settings = self
|
||||
.tier_settings
|
||||
@@ -190,9 +191,13 @@ impl Cache {
|
||||
}),
|
||||
}
|
||||
});
|
||||
let scope_revision = self.scope_revisions
|
||||
.lock().unwrap_or_else(|e| e.into_inner())
|
||||
.get(&entry.scope).copied().unwrap_or(0);
|
||||
let scope_revision = self
|
||||
.scope_revisions
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&entry.scope)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
ColdDraft {
|
||||
key,
|
||||
revision,
|
||||
@@ -215,9 +220,6 @@ impl Cache {
|
||||
pub async fn flush_hot_to_cold(&self) -> usize {
|
||||
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 {
|
||||
self.flush_hot_to_cold_inner(true).await
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -27,8 +27,6 @@ impl Cache {
|
||||
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();
|
||||
for item in read_dir.flatten() {
|
||||
let path = item.path();
|
||||
|
||||
Vendored
-5
@@ -29,9 +29,6 @@ impl Cache {
|
||||
loop {
|
||||
let command = tokio::select! {
|
||||
_ = 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();
|
||||
while let Ok(command) = rx.try_recv() {
|
||||
let Some(cache) = weak.upgrade() else { return; };
|
||||
@@ -58,8 +55,6 @@ impl Cache {
|
||||
self.cold_write_bytes_active
|
||||
.fetch_sub(bytes, Ordering::Relaxed);
|
||||
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();
|
||||
}
|
||||
async fn persist_cold_inner(&self, draft: ColdDraft) -> bool {
|
||||
|
||||
Vendored
+10
-34
@@ -36,10 +36,7 @@ impl Drop for CollapseWaiterGuard {
|
||||
impl Drop for FillLeaderGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(cache) = self.cache.upgrade() {
|
||||
let mut flights = cache
|
||||
.fill_flights
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let mut flights = cache.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let current = flights
|
||||
.get(&self.key)
|
||||
.is_some_and(|flight| flight.id == self.id);
|
||||
@@ -47,25 +44,17 @@ impl Drop for FillLeaderGuard {
|
||||
flights.remove(&self.key);
|
||||
}
|
||||
}
|
||||
// Publish completion even when a purge already removed this flight from the map.
|
||||
self.done.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub async fn claim_fill(
|
||||
self: &Arc<Self>,
|
||||
ticket: &Ticket,
|
||||
settings: &Settings,
|
||||
) -> FillClaim {
|
||||
pub async fn claim_fill(self: &Arc<Self>, ticket: &Ticket, settings: &Settings) -> FillClaim {
|
||||
if !settings.collapse_enabled {
|
||||
return FillClaim::Disabled;
|
||||
}
|
||||
let existing = {
|
||||
let mut flights = self
|
||||
.fill_flights
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(flight) = flights.get(&ticket.key) {
|
||||
Some(flight.clone())
|
||||
} else {
|
||||
@@ -96,8 +85,6 @@ impl Cache {
|
||||
let Some(flight) = existing else {
|
||||
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();
|
||||
self.collapse_waiters.fetch_add(1, Ordering::Relaxed);
|
||||
let _waiter = CollapseWaiterGuard {
|
||||
@@ -131,11 +118,11 @@ impl Cache {
|
||||
|
||||
fn cancel_all_fill_flights(&self) {
|
||||
let flights = {
|
||||
let mut flights = self
|
||||
.fill_flights
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
flights.drain().map(|(_, flight)| flight).collect::<Vec<_>>()
|
||||
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
|
||||
flights
|
||||
.drain()
|
||||
.map(|(_, flight)| flight)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for flight in flights {
|
||||
flight.done.send_replace(true);
|
||||
@@ -144,10 +131,7 @@ impl Cache {
|
||||
|
||||
fn cancel_scope_fill_flights(&self, scope: &str) {
|
||||
let removed = {
|
||||
let mut flights = self
|
||||
.fill_flights
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let keys = flights
|
||||
.iter()
|
||||
.filter(|(_, flight)| flight.scope == scope)
|
||||
@@ -210,9 +194,6 @@ impl Cache {
|
||||
.proactive_scheduled_keys
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
// Each scheduled key owns a sleeping Tokio task plus refresh context.
|
||||
// Bound the pending set independently from the cache entry count so a
|
||||
// large cache with long TTLs cannot turn into thousands of resident tasks.
|
||||
let pending_limit = settings
|
||||
.proactive_refresh_max_inflight
|
||||
.saturating_mul(64)
|
||||
@@ -250,12 +231,7 @@ impl Cache {
|
||||
}
|
||||
if self
|
||||
.proactive_inflight
|
||||
.compare_exchange_weak(
|
||||
current,
|
||||
current + 1,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Relaxed,
|
||||
)
|
||||
.compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
self.counters
|
||||
|
||||
Vendored
+21
-10
@@ -99,14 +99,22 @@ impl Cache {
|
||||
("bans_removed_total", "counter", view.bans_removed),
|
||||
("ban_matches_total", "counter", view.ban_matches),
|
||||
("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_skipped_total", "counter", view.proactive_skipped),
|
||||
("proactive_inflight", "gauge", view.proactive_inflight),
|
||||
("entries", "gauge", view.entries as u64),
|
||||
("resident_bytes", "gauge", view.resident_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),
|
||||
(
|
||||
"transient_limit_bytes",
|
||||
@@ -121,7 +129,11 @@ impl Cache {
|
||||
("cold_entries", "gauge", view.cold_entries as u64),
|
||||
("cold_bytes", "gauge", view.cold_bytes),
|
||||
("cold_limit_bytes", "gauge", view.cold_limit_bytes),
|
||||
("cold_limit_entries", "gauge", view.cold_limit_entries as u64),
|
||||
(
|
||||
"cold_limit_entries",
|
||||
"gauge",
|
||||
view.cold_limit_entries as u64,
|
||||
),
|
||||
("cold_hits_total", "counter", view.cold_hits),
|
||||
("cold_promotions_total", "counter", view.cold_promotions),
|
||||
("hot_demotions_total", "counter", view.hot_demotions),
|
||||
@@ -161,7 +173,6 @@ impl Cache {
|
||||
pub fn bypass(&self) {
|
||||
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 {
|
||||
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut count = store.entries.len();
|
||||
@@ -198,11 +209,11 @@ impl Cache {
|
||||
let _ = tx.try_send(ColdCommand::PurgeDirectory(root));
|
||||
}
|
||||
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
|
||||
}
|
||||
/// 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 {
|
||||
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let keys: Vec<_> = store
|
||||
@@ -239,7 +250,9 @@ impl Cache {
|
||||
let _ = tx.try_send(ColdCommand::Delete(delete));
|
||||
}
|
||||
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
|
||||
}
|
||||
pub fn scope_view(&self, scope: &str) -> ScopeView {
|
||||
@@ -303,8 +316,6 @@ impl Cache {
|
||||
continue;
|
||||
};
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -91,9 +91,14 @@ mod dashboard_tests {
|
||||
assert!(dashboard.objects[0].age_seconds >= 2 * 60 * 60);
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
#[test]
|
||||
@@ -144,13 +149,22 @@ mod dashboard_tests {
|
||||
};
|
||||
let mut headers = HeaderMap::new();
|
||||
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_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_eq!(headers["via"], "RustProxy"); // independent proxy policy
|
||||
assert_eq!(headers["via"], "RustProxy");
|
||||
global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
|
||||
assert!(!headers.contains_key("cache-status"));
|
||||
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!(headers["cache-status"].to_str().unwrap().contains("; hit;"));
|
||||
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();
|
||||
assert!(!normal.expose_only_hits);
|
||||
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]
|
||||
fn via_header_is_configurable_and_preserves_upstream_chain() {
|
||||
@@ -178,7 +200,10 @@ mod dashboard_tests {
|
||||
.collect();
|
||||
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();
|
||||
disabled.annotate(&mut headers, "MISS", 0, 0);
|
||||
assert!(!headers.contains_key("via"));
|
||||
@@ -218,7 +243,6 @@ mod dashboard_tests {
|
||||
#[test]
|
||||
fn cache_domain_metrics_do_not_lock_hot_store_and_preserve_reasons() {
|
||||
let cache = Cache::new(7);
|
||||
// A BYPASS must be recordable even while the HOT/LRU mutex is held.
|
||||
let store = cache.store.lock().unwrap();
|
||||
cache.record("alpha", "a.example", "BYPASS", "cache_disabled");
|
||||
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["background_refresh_failure"], 1);
|
||||
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));
|
||||
assert!(cache.dashboard(None, 0, 1).domains.is_empty());
|
||||
cache.record("alpha", "a.example", "MISS", "cache_miss");
|
||||
@@ -260,7 +289,9 @@ mod dashboard_tests {
|
||||
#[test]
|
||||
fn first_hit_saved_bytes_registers_domain_even_without_prior_record() {
|
||||
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);
|
||||
assert_eq!(result.domains.len(), 1);
|
||||
assert_eq!(result.domains[0].counters.saved_body_bytes, 1040);
|
||||
Vendored
+15
-28
@@ -63,8 +63,6 @@ struct Candidate {
|
||||
plan: Plan,
|
||||
headers: HeaderMap,
|
||||
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_allocated: usize,
|
||||
fill_gauge: FillGauge,
|
||||
@@ -78,11 +76,9 @@ struct Candidate {
|
||||
}
|
||||
impl Candidate {
|
||||
fn finish(mut self) {
|
||||
if self
|
||||
.plan
|
||||
.expected_length
|
||||
.is_some_and(|length| self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length)
|
||||
{
|
||||
if self.plan.expected_length.is_some_and(|length| {
|
||||
self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length
|
||||
}) {
|
||||
let reason = if self.transformed {
|
||||
"transform_length_mismatch"
|
||||
} else {
|
||||
@@ -101,7 +97,6 @@ impl Candidate {
|
||||
self.headers.insert("date", value);
|
||||
}
|
||||
}
|
||||
// Each downstream exchange receives a new ID, never a cached origin ID.
|
||||
for name in [
|
||||
"x-request-id",
|
||||
"x-cache",
|
||||
@@ -113,9 +108,6 @@ impl Candidate {
|
||||
self.headers.remove(name);
|
||||
}
|
||||
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() {
|
||||
(body, self.shared_allocated)
|
||||
} else {
|
||||
@@ -201,10 +193,6 @@ impl HttpBody for FillBody {
|
||||
match Pin::new(&mut this.inner).poll_frame(cx) {
|
||||
Poll::Ready(Some(Ok(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 let Some(candidate) = &mut this.candidate {
|
||||
let single_frame = candidate.transformed
|
||||
@@ -216,17 +204,18 @@ impl HttpBody for FillBody {
|
||||
});
|
||||
if single_frame {
|
||||
match frame.into_data() {
|
||||
Ok(bytes) => {
|
||||
match bytes.try_into_mut() {
|
||||
Ok(bytes) => match bytes.try_into_mut() {
|
||||
Ok(mutable) => {
|
||||
let allocation = mutable.capacity();
|
||||
let bytes = mutable.freeze();
|
||||
// A very oversized backing Vec is cheaper to
|
||||
// copy into a smaller HOT allocation instead.
|
||||
let acceptable_spare = bytes.len().saturating_div(8).min(64 * 1024);
|
||||
if allocation <= bytes.len().saturating_add(acceptable_spare)
|
||||
let acceptable_spare =
|
||||
bytes.len().saturating_div(8).min(64 * 1024);
|
||||
if allocation
|
||||
<= bytes.len().saturating_add(acceptable_spare)
|
||||
&& 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_allocated = allocation;
|
||||
@@ -234,8 +223,7 @@ impl HttpBody for FillBody {
|
||||
frame = Frame::data(bytes);
|
||||
}
|
||||
Err(bytes) => frame = Frame::data(bytes),
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(original) => frame = original,
|
||||
}
|
||||
}
|
||||
@@ -243,7 +231,6 @@ impl HttpBody for FillBody {
|
||||
}
|
||||
if let Some(candidate) = &mut this.candidate {
|
||||
if candidate.shared.is_some() {
|
||||
// The cache now owns the very same backing allocation.
|
||||
} else if let Some(data) = frame.data_ref() {
|
||||
let needed = candidate.data.len().saturating_add(data.len());
|
||||
if needed <= candidate.settings.max_object_bytes {
|
||||
@@ -284,7 +271,8 @@ impl HttpBody for FillBody {
|
||||
this.candidate = None;
|
||||
} else {
|
||||
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
|
||||
&& !candidate.fill_gauge.grow(unbudgeted, &candidate.settings)
|
||||
{
|
||||
@@ -328,7 +316,7 @@ impl HttpBody for FillBody {
|
||||
.rejected_fills
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
this.candidate = None;
|
||||
} // Do not cache responses with trailers.
|
||||
}
|
||||
}
|
||||
if this.inner.is_end_stream() {
|
||||
this.done = true;
|
||||
@@ -391,4 +379,3 @@ impl Drop for FillBody {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+9
-3
@@ -4,13 +4,20 @@ mod http_invalidation_tests {
|
||||
fn default_deny_and_strict_ipv4_ipv6_cidr_validation() {
|
||||
let mut cache = Settings::default();
|
||||
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();
|
||||
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("2001:db8:10::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()];
|
||||
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, "https", "b.example", "b.example"));
|
||||
}
|
||||
|
||||
}
|
||||
Vendored
+12
-19
@@ -6,7 +6,8 @@ impl Cache {
|
||||
let now = SystemTime::now();
|
||||
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -17,7 +18,8 @@ impl Cache {
|
||||
let now = SystemTime::now();
|
||||
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
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));
|
||||
if banned {
|
||||
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -32,7 +34,8 @@ impl Cache {
|
||||
let now = SystemTime::now();
|
||||
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
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));
|
||||
if banned {
|
||||
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -47,7 +50,8 @@ impl Cache {
|
||||
let now = SystemTime::now();
|
||||
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -60,7 +64,8 @@ impl Cache {
|
||||
let before = bans.len();
|
||||
bans.retain(|rule| rule.id != id);
|
||||
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 {
|
||||
self.counters.bans_removed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -97,7 +102,8 @@ impl Cache {
|
||||
created_at: 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);
|
||||
Ok(InvalidationResult {
|
||||
operation: request.operation,
|
||||
@@ -112,22 +118,9 @@ impl Cache {
|
||||
})
|
||||
}
|
||||
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());
|
||||
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 {
|
||||
store.key_generations = Default::default();
|
||||
} else {
|
||||
|
||||
Vendored
+36
-41
@@ -111,7 +111,9 @@ impl Cache {
|
||||
let weak = Arc::downgrade(&cache);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Some(cache) = weak.upgrade() else { break; };
|
||||
let Some(cache) = weak.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let interval = cache
|
||||
.tier_settings
|
||||
.lock()
|
||||
@@ -130,9 +132,6 @@ impl Cache {
|
||||
cache
|
||||
}
|
||||
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);
|
||||
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> {
|
||||
if bytes == 0 {
|
||||
return self.try_reserve_transient(0, settings);
|
||||
}
|
||||
let lease = self.try_reserve_transient(bytes, settings)?;
|
||||
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))
|
||||
.unwrap_or(usize::MAX);
|
||||
let fill_now =
|
||||
usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)).unwrap_or(usize::MAX);
|
||||
let allowed = settings
|
||||
.max_bytes
|
||||
.saturating_add(Self::fill_headroom_bytes(settings.max_bytes));
|
||||
@@ -182,20 +178,14 @@ impl Cache {
|
||||
let generation = store.generation;
|
||||
let mut removed = 0usize;
|
||||
let mut released_hot = false;
|
||||
while store
|
||||
.bytes
|
||||
.saturating_add(fill_now)
|
||||
.saturating_add(bytes)
|
||||
> allowed
|
||||
{
|
||||
while store.bytes.saturating_add(fill_now).saturating_add(bytes) > allowed {
|
||||
if removed >= settings.hot_eviction_max_objects {
|
||||
self.counters
|
||||
.hot_eviction_limited
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
|
||||
else {
|
||||
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
|
||||
break;
|
||||
};
|
||||
let Some(old) = store.remove(&key) else {
|
||||
@@ -227,11 +217,7 @@ impl Cache {
|
||||
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
let fits = store
|
||||
.bytes
|
||||
.saturating_add(fill_now)
|
||||
.saturating_add(bytes)
|
||||
<= allowed;
|
||||
let fits = store.bytes.saturating_add(fill_now).saturating_add(bytes) <= allowed;
|
||||
if fits {
|
||||
self.fill_bytes_active
|
||||
.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) {
|
||||
let settings = self
|
||||
.tier_settings
|
||||
@@ -285,17 +267,31 @@ impl Cache {
|
||||
.get(&key)
|
||||
.is_some_and(|entry| entry.last_access.elapsed() >= idle_cutoff);
|
||||
if !idle {
|
||||
// LRU order is also last-access order, so newer entries cannot qualify.
|
||||
break;
|
||||
}
|
||||
let Some(entry) = store.remove(&key) else { continue; };
|
||||
let Some(entry) = store.remove(&key) else {
|
||||
continue;
|
||||
};
|
||||
removed += 1;
|
||||
let scope_generation = store.scope_generations.get(&entry.scope).copied().unwrap_or(0);
|
||||
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
|
||||
let scope_generation = store
|
||||
.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
|
||||
&& settings.cold_on_lru
|
||||
&& 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);
|
||||
@@ -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) {
|
||||
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>) {
|
||||
*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) {
|
||||
*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 revision = store.revision;
|
||||
let generation = store.generation;
|
||||
let mut released_hot = false;
|
||||
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))
|
||||
else {
|
||||
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
|
||||
break;
|
||||
};
|
||||
let Some(old) = store.remove(&key) else {
|
||||
|
||||
Vendored
+30
-15
@@ -1,20 +1,22 @@
|
||||
impl Cache {
|
||||
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());
|
||||
if store.revision != ticket.revision
|
||||
|| 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
|
||||
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0)
|
||||
!= ticket.key_generation
|
||||
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
|
||||
{
|
||||
return None;
|
||||
}
|
||||
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));
|
||||
if banned {
|
||||
store.remove(&storage_key);
|
||||
@@ -27,7 +29,10 @@ impl Cache {
|
||||
}
|
||||
let entry = store.entries.get(&storage_key)?;
|
||||
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(
|
||||
age,
|
||||
entry.ttl,
|
||||
@@ -39,8 +44,16 @@ impl Cache {
|
||||
);
|
||||
if state.servable() {
|
||||
let sequence = store.next_sequence();
|
||||
let (old_sequence, headers, body, hits, transformed, skip_compression,
|
||||
remaining_ttl, body_bytes) = {
|
||||
let (
|
||||
old_sequence,
|
||||
headers,
|
||||
body,
|
||||
hits,
|
||||
transformed,
|
||||
skip_compression,
|
||||
remaining_ttl,
|
||||
body_bytes,
|
||||
) = {
|
||||
let entry = store.entries.get_mut(&storage_key)?;
|
||||
let old_sequence = entry.sequence;
|
||||
entry.hits = entry.hits.saturating_add(1);
|
||||
@@ -59,15 +72,15 @@ impl Cache {
|
||||
};
|
||||
store.lru.remove(&old_sequence);
|
||||
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);
|
||||
drop(store);
|
||||
|
||||
let mut response = Response::new(Body::from(body.clone()));
|
||||
*response.headers_mut() = headers.as_ref().clone();
|
||||
if transformed {
|
||||
response.extensions_mut().insert(crate::transform::CachedTransformed);
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::transform::CachedTransformed);
|
||||
}
|
||||
if skip_compression {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
@@ -76,7 +89,8 @@ impl Cache {
|
||||
"age",
|
||||
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 {
|
||||
response,
|
||||
body,
|
||||
@@ -274,7 +288,8 @@ impl Cache {
|
||||
"age",
|
||||
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.cold_hits.fetch_add(1, Ordering::Relaxed);
|
||||
self.counters
|
||||
|
||||
+74
-25
@@ -3,7 +3,10 @@ mod hot_hit_034_tests {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn settings() -> Settings {
|
||||
Settings { enabled: true, ..Settings::default() }
|
||||
Settings {
|
||||
enabled: true,
|
||||
..Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish(
|
||||
@@ -13,16 +16,25 @@ mod hot_hit_034_tests {
|
||||
body: &'static str,
|
||||
transformed: bool,
|
||||
) -> Ticket {
|
||||
let ticket = cache.ticket_scoped(
|
||||
34, "app", "http", "cache.example", "origin@127.0.0.1:8080",
|
||||
path, &HeaderMap::new(),
|
||||
).expect("cache ticket");
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
34,
|
||||
"app",
|
||||
"http",
|
||||
"cache.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
path,
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.expect("cache ticket");
|
||||
let mut response = Response::new(Body::from(body));
|
||||
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(
|
||||
"etag", HeaderValue::from_static("\"cached-representation\""),
|
||||
"etag",
|
||||
HeaderValue::from_static("\"cached-representation\""),
|
||||
);
|
||||
if transformed {
|
||||
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)
|
||||
.expect("public static object cacheable");
|
||||
cache.wrap(response, ticket.clone(), policy, settings)
|
||||
.into_body().collect().await.unwrap();
|
||||
cache
|
||||
.wrap(response, ticket.clone(), policy, settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
ticket
|
||||
}
|
||||
|
||||
@@ -52,12 +68,21 @@ mod hot_hit_034_tests {
|
||||
let hit = cache.get(&ticket).expect("HOT cache hit");
|
||||
assert_eq!(hit.hits, i);
|
||||
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\"");
|
||||
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 {
|
||||
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();
|
||||
@@ -73,7 +98,10 @@ mod hot_hit_034_tests {
|
||||
let dashboard = cache.dashboard(Some("app"), 0, 10);
|
||||
assert_eq!(dashboard.objects[0].hits, 500);
|
||||
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]
|
||||
@@ -96,9 +124,15 @@ mod hot_hit_034_tests {
|
||||
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();
|
||||
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);
|
||||
let store = cache.store.lock().unwrap();
|
||||
assert_eq!(store.lru.len(), 1);
|
||||
@@ -108,7 +142,10 @@ mod hot_hit_034_tests {
|
||||
#[tokio::test]
|
||||
async fn hot_lru_eviction_and_ban_are_preserved() {
|
||||
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 b = publish(&cache, &settings, "/b.css", "BBBB", false).await;
|
||||
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(&c).is_some());
|
||||
|
||||
let ban = cache.invalidate(InvalidationRequest {
|
||||
let ban = cache
|
||||
.invalidate(InvalidationRequest {
|
||||
operation: InvalidationOperation::Ban,
|
||||
selector: InvalidationSelector::Exact,
|
||||
value: "/a.css".into(),
|
||||
@@ -125,7 +163,8 @@ mod hot_hit_034_tests {
|
||||
vhost: Some("app".into()),
|
||||
ttl_seconds: Some(3600),
|
||||
confirm_wide: false,
|
||||
}).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(cache.ban_rule_count.load(Ordering::Acquire), 1);
|
||||
assert!(cache.get(&a).is_none(), "BAN must block cached A");
|
||||
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());
|
||||
}
|
||||
let duration = start.elapsed();
|
||||
eprintln!("HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS",
|
||||
duration.as_secs_f64() * 1e9 / iterations as f64, iterations);
|
||||
eprintln!(
|
||||
"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);
|
||||
}
|
||||
|
||||
@@ -161,12 +203,19 @@ mod hot_hit_034_tests {
|
||||
store.entries.values_mut().next().unwrap().received =
|
||||
Instant::now() - Duration::from_secs(700_000);
|
||||
}
|
||||
assert!(cache.get(&ticket).is_none(), "expired freshness is not an unconditional HIT");
|
||||
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(&ticket).is_none(),
|
||||
"expired freshness is not an unconditional HIT"
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Vendored
-3
@@ -44,9 +44,6 @@ impl Cache {
|
||||
{
|
||||
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) {
|
||||
store.remove(&key);
|
||||
}
|
||||
|
||||
Vendored
+9
-7
@@ -170,10 +170,15 @@ impl Cache {
|
||||
Some(validators) => validators.last_modified.clone(),
|
||||
None => headers.get("last-modified").cloned(),
|
||||
};
|
||||
let transformed = parts.extensions.get::<crate::transform::Applied>().is_some();
|
||||
// A transformed, fully buffered body normally arrives as one owned
|
||||
// frame. Do not allocate a second fill buffer before inspecting it.
|
||||
let initial_capacity = if transformed { 0 } else { fill_size.min(64 * 1024) };
|
||||
let transformed = parts
|
||||
.extensions
|
||||
.get::<crate::transform::Applied>()
|
||||
.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 {
|
||||
self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed);
|
||||
self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure");
|
||||
@@ -205,9 +210,6 @@ impl Cache {
|
||||
_permit: permit,
|
||||
_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() {
|
||||
candidate.finish();
|
||||
return Response::from_parts(parts, body);
|
||||
|
||||
Vendored
-6
@@ -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!("stats.rs");
|
||||
include!("lifecycle.rs");
|
||||
|
||||
Vendored
+1
-24
@@ -28,9 +28,7 @@ pub struct ObjectView {
|
||||
pub storage_bytes: u64,
|
||||
pub tier: &'static str,
|
||||
pub hits: u64,
|
||||
/// RFC 9111 Age, including time before the object entered this cache.
|
||||
pub age_seconds: u64,
|
||||
/// Total time held by RustProxy HOT/COLD; excludes any upstream CDN Age.
|
||||
pub resident_age_seconds: u64,
|
||||
pub remaining_ttl_seconds: u64,
|
||||
}
|
||||
@@ -50,11 +48,8 @@ pub struct Dashboard {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ColdCleanupRequest {
|
||||
/// Remove objects not used for at least this many seconds.
|
||||
pub unused_for_seconds: Option<u64>,
|
||||
/// Additionally remove up to N oldest matching objects.
|
||||
pub oldest: Option<usize>,
|
||||
/// Limit the selection to objects which were never served.
|
||||
pub hits_zero: bool,
|
||||
}
|
||||
impl Default for ColdCleanupRequest {
|
||||
@@ -113,7 +108,6 @@ struct Entry {
|
||||
created_at: SystemTime,
|
||||
headers: Arc<HeaderMap>,
|
||||
body: Bytes,
|
||||
/// Backing allocation retained by `body`; can exceed `body.len()`.
|
||||
body_allocated: usize,
|
||||
received: Instant,
|
||||
last_access: Instant,
|
||||
@@ -135,7 +129,7 @@ struct Entry {
|
||||
requires_revalidation: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting.
|
||||
#[allow(clippy::ptr_arg)]
|
||||
fn hot_entry_cost(
|
||||
body_allocated: usize,
|
||||
headers: &HeaderMap,
|
||||
@@ -150,11 +144,6 @@ fn hot_entry_cost(
|
||||
origin_etag: Option<&HeaderValue>,
|
||||
origin_last_modified: Option<&HeaderValue>,
|
||||
) -> 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>()
|
||||
.saturating_add(std::mem::size_of::<(u64, Key)>())
|
||||
.saturating_add(32)
|
||||
@@ -171,8 +160,6 @@ fn hot_entry_cost(
|
||||
let header_heap = headers
|
||||
.iter()
|
||||
.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()
|
||||
.len()
|
||||
.saturating_add(value.as_bytes().len())
|
||||
@@ -181,7 +168,6 @@ fn hot_entry_cost(
|
||||
.sum::<usize>();
|
||||
body_allocated
|
||||
.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(2 * std::mem::size_of::<usize>())
|
||||
.saturating_add(index_heap)
|
||||
@@ -207,7 +193,6 @@ struct ColdFile {
|
||||
#[serde(default)]
|
||||
vary: Vec<String>,
|
||||
revision: u64,
|
||||
/// 0 denotes legacy/unscoped entries; production vhosts require an exact match.
|
||||
#[serde(default)]
|
||||
scope_revision: u64,
|
||||
scope: String,
|
||||
@@ -284,9 +269,6 @@ struct ColdEntry {
|
||||
hits: u64,
|
||||
transformed: bool,
|
||||
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,
|
||||
skip_compression: bool,
|
||||
requires_revalidation: bool,
|
||||
@@ -311,8 +293,6 @@ struct ColdDraft {
|
||||
key_generation: u64,
|
||||
entry: Entry,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -331,8 +311,6 @@ struct Store {
|
||||
sequence: u64,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
/// Current persisted administrative config revision. Distinct from cache
|
||||
/// representation revision: operational saves do not evict data.
|
||||
control_revision: u64,
|
||||
scope_generations: HashMap<String, u64>,
|
||||
key_generations: HashMap<Key, u64>,
|
||||
@@ -361,7 +339,6 @@ impl Store {
|
||||
Some(entry)
|
||||
}
|
||||
fn next_sequence(&mut self) -> u64 {
|
||||
// No practical wrapping, but a wrap must not corrupt LRU bookkeeping.
|
||||
if self.sequence == u64::MAX {
|
||||
self.entries.clear();
|
||||
self.variants.clear();
|
||||
|
||||
Vendored
+19
-23
@@ -167,7 +167,6 @@ impl Settings {
|
||||
.any(|item| item.eq_ignore_ascii_case(extension))
|
||||
}
|
||||
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 [
|
||||
"x-cache",
|
||||
"x-cache-hits",
|
||||
@@ -178,15 +177,15 @@ impl Settings {
|
||||
] {
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
if let Some(value) = &self.via_header {
|
||||
headers.append(
|
||||
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" {
|
||||
@@ -226,10 +225,15 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
|
||||
if !self.enabled || !self.expose_cache_status || !transformed
|
||||
|| (self.expose_only_hits && !headers.get("cache-status")
|
||||
if !self.enabled
|
||||
|| !self.expose_cache_status
|
||||
|| !transformed
|
||||
|| (self.expose_only_hits
|
||||
&& !headers
|
||||
.get("cache-status")
|
||||
.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;
|
||||
}
|
||||
let base = headers
|
||||
@@ -246,7 +250,6 @@ impl Settings {
|
||||
headers.insert("cache-status", value);
|
||||
}
|
||||
}
|
||||
/// Reasons are fixed internal tokens: never echo request values or secrets.
|
||||
pub fn annotate_reason(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
@@ -284,8 +287,6 @@ fn valid_relative_cache_path(value: &str) -> bool {
|
||||
.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)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct UnsafeForce {
|
||||
@@ -294,7 +295,10 @@ pub struct UnsafeForce {
|
||||
}
|
||||
impl UnsafeForce {
|
||||
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 {
|
||||
ensure!(
|
||||
!target.is_empty()
|
||||
@@ -305,7 +309,10 @@ impl UnsafeForce {
|
||||
"forced cache targets must be 1..2048 byte origin-form path/query prefixes"
|
||||
);
|
||||
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(())
|
||||
}
|
||||
@@ -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)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Overrides {
|
||||
@@ -341,15 +346,12 @@ pub struct Overrides {
|
||||
pub expose_cache_status: Option<bool>,
|
||||
pub expose_resident_age: Option<bool>,
|
||||
pub expose_only_hits: Option<bool>,
|
||||
/// Deliberately unavailable in the global cache configuration.
|
||||
pub unsafe_force: UnsafeForce,
|
||||
}
|
||||
impl Overrides {
|
||||
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
|
||||
self.unsafe_force.validate()?;
|
||||
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);
|
||||
if let Some(value) = self.default_ttl_seconds {
|
||||
result.default_ttl_seconds = value;
|
||||
@@ -444,7 +446,6 @@ pub struct View {
|
||||
pub hot_eviction_limited: u64,
|
||||
pub purges: u64,
|
||||
pub entries: usize,
|
||||
/// Compatibility alias for HOT allocated/charged bytes.
|
||||
pub resident_bytes: usize,
|
||||
pub hot_logical_bytes: usize,
|
||||
pub hot_allocated_bytes: usize,
|
||||
@@ -474,16 +475,11 @@ pub struct View {
|
||||
pub proactive_skipped: u64,
|
||||
pub proactive_inflight: u64,
|
||||
pub fills_active: usize,
|
||||
/// Bytes currently allocated by in-progress response cache fills.
|
||||
pub fill_bytes_active: u64,
|
||||
/// Demoted objects accepted by the bounded cold-writer queue.
|
||||
pub cold_queue_items: u64,
|
||||
pub cold_queue_bytes: u64,
|
||||
/// Body bytes currently owned by the cold writer after leaving the queue.
|
||||
pub cold_write_bytes_active: u64,
|
||||
/// Full cold bodies currently read into RAM for promotion.
|
||||
pub cold_read_bytes_active: u64,
|
||||
/// RustProxy-accounted cache RAM outside the published hot store.
|
||||
pub transient_bytes: u64,
|
||||
pub hit_ratio: f64,
|
||||
}
|
||||
|
||||
Vendored
+10
-5
@@ -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> {
|
||||
let value = value.trim();
|
||||
if let Ok(ip) = value.parse::<std::net::IpAddr>() {
|
||||
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 {
|
||||
fn validate_http_invalidation_allowlist(&self) -> anyhow::Result<()> {
|
||||
@@ -17,7 +17,10 @@ impl Settings {
|
||||
for input in &self.http_invalidation_allowed_cidrs {
|
||||
ensure!(input.len() <= 64, "HTTP invalidation address too long");
|
||||
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");
|
||||
}
|
||||
Ok(())
|
||||
@@ -25,7 +28,9 @@ impl Settings {
|
||||
pub fn permits_http_invalidation(&self, client: std::net::IpAddr) -> bool {
|
||||
let client = crate::vhost::canonical_ip(client);
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+5
-9
@@ -7,8 +7,6 @@ impl ColdStore {
|
||||
}
|
||||
fn next_sequence(&mut self) -> u64 {
|
||||
if self.sequence == u64::MAX {
|
||||
// Practically unreachable. Prefer dropping the in-memory index over
|
||||
// risking ambiguous LRU ordering after wrap.
|
||||
self.lru.clear();
|
||||
self.entries.clear();
|
||||
self.variants.clear();
|
||||
@@ -56,7 +54,6 @@ pub struct Cache {
|
||||
revision: AtomicU64,
|
||||
cold: Arc<Mutex<ColdStore>>,
|
||||
tier_settings: Mutex<Settings>,
|
||||
/// Per-vhost content compatibility used by persisted COLD metadata.
|
||||
scope_revisions: Mutex<HashMap<String, u64>>,
|
||||
data_root: Option<PathBuf>,
|
||||
cold_tx: Option<mpsc::Sender<ColdCommand>>,
|
||||
@@ -143,8 +140,6 @@ pub struct Revalidation {
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
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>,
|
||||
hits: u64,
|
||||
transformed: bool,
|
||||
@@ -171,9 +166,6 @@ impl Revalidation {
|
||||
&self.headers
|
||||
}
|
||||
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") {
|
||||
let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else {
|
||||
return false;
|
||||
@@ -349,7 +341,11 @@ fn hot_resident_age(entry: &Entry) -> Duration {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
store
|
||||
|
||||
Vendored
+5
-59
@@ -24,8 +24,6 @@ const FILL_SLOTS: usize = 8;
|
||||
const COLD_DELETE_FALLBACKS: usize = 2;
|
||||
const COLD_CLEANUP_SLOTS: usize = 1;
|
||||
const BACKGROUND_REVALIDATION_LIMIT: usize = 8;
|
||||
// Cold writes can contain complete response bodies. Keep the queue deliberately
|
||||
// small so demotion cannot become a second, hidden RAM cache while disk is slow.
|
||||
const COLD_QUEUE: usize = 16;
|
||||
const COLD_FORMAT_VERSION: u8 = 3;
|
||||
type Key = [u8; 32];
|
||||
@@ -43,10 +41,6 @@ struct CleanupCandidate {
|
||||
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() {
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
unsafe {
|
||||
@@ -93,81 +87,44 @@ pub enum ColdPreset {
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub enabled: bool,
|
||||
/// UI sizing profile last explicitly selected by the administrator. The
|
||||
/// actual numeric limits remain authoritative; this only preserves intent
|
||||
/// across reloads so the dashboard can show which preset was applied.
|
||||
pub memory_preset: Option<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,
|
||||
/// Maximum logical size of a single cacheable object body.
|
||||
pub max_object_bytes: usize,
|
||||
/// Hot tier object count.
|
||||
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,
|
||||
/// Byte ceiling for demoted objects waiting in the COLD writer queue.
|
||||
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,
|
||||
/// Automatically release idle HOT objects once the tier is above the target waterline.
|
||||
pub hot_auto_trim_enabled: bool,
|
||||
/// Minimum idle age before automatic HOT trimming can evict an object.
|
||||
pub hot_auto_trim_after_seconds: u64,
|
||||
/// Target HOT usage percentage reached by each automatic trim pass.
|
||||
pub hot_auto_trim_target_percent: u8,
|
||||
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
|
||||
pub cold_enabled: bool,
|
||||
/// Last explicitly selected COLD policy profile, persisted with the cache
|
||||
/// configuration for the same reason as `memory_preset`.
|
||||
pub cold_preset: Option<ColdPreset>,
|
||||
/// Relative to the RustProxy data directory.
|
||||
pub cold_path: String,
|
||||
pub cold_max_bytes: u64,
|
||||
pub cold_max_entries: usize,
|
||||
/// Interval between asynchronous cold-cache GC checks.
|
||||
pub cold_gc_interval_seconds: u64,
|
||||
/// Maximum number of objects removed by one GC pass.
|
||||
pub cold_gc_max_delete: usize,
|
||||
/// Hot objects not touched for this long are demoted to disk.
|
||||
pub cold_after_seconds: u64,
|
||||
/// Demote objects which have been idle for `cold_after_seconds`.
|
||||
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,
|
||||
/// Apply `cold_max_hot_seconds` as a separate hot-tier age policy.
|
||||
pub cold_on_age: bool,
|
||||
/// Preserve objects evicted from the hot LRU in the cold tier.
|
||||
pub cold_on_lru: bool,
|
||||
/// Best-effort flush of hot objects to cold storage on graceful shutdown.
|
||||
pub cold_on_shutdown: bool,
|
||||
/// Only used for responses explicitly marked public and without origin TTL.
|
||||
pub default_ttl_seconds: u64,
|
||||
pub max_ttl_seconds: u64,
|
||||
pub bypass_query: bool,
|
||||
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>,
|
||||
/// 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_extensions: Vec<String>,
|
||||
/// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key.
|
||||
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_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_connection: bool,
|
||||
pub stale_if_error_dns: bool,
|
||||
pub stale_if_error_tls: bool,
|
||||
pub stale_if_error_statuses: Vec<u16>,
|
||||
/// Schedule bounded validator refresh before normal freshness expires.
|
||||
pub proactive_refresh_enabled: bool,
|
||||
pub proactive_refresh_before_expiry_seconds: u64,
|
||||
pub proactive_refresh_percent: u8,
|
||||
@@ -176,10 +133,7 @@ pub struct Settings {
|
||||
pub expose_hits: bool,
|
||||
pub expose_cache_status: bool,
|
||||
pub expose_resident_age: bool,
|
||||
/// Emit cache diagnostics only for actual HIT responses, not MISS or BYPASS.
|
||||
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>,
|
||||
}
|
||||
impl Default for Settings {
|
||||
@@ -214,21 +168,13 @@ impl Default for Settings {
|
||||
bypass_query: false,
|
||||
bypass_prefixes: 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_extensions: vec![
|
||||
// styles / scripts
|
||||
"css", "js", "mjs", "cjs", "wasm", // images
|
||||
"png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico",
|
||||
"cur", "bmp", "tif", "tiff", "heic", "heif", // fonts
|
||||
"woff", "woff2", "ttf", "otf", "eot", // video
|
||||
"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",
|
||||
"css", "js", "mjs", "cjs", "wasm", "png", "jpg", "jpeg", "jfif", "jxl", "gif",
|
||||
"webp", "avif", "apng", "svg", "ico", "cur", "bmp", "tif", "tiff", "heic", "heif",
|
||||
"woff", "woff2", "ttf", "otf", "eot", "mp4", "webm", "ogv", "mov", "m4v", "mp3",
|
||||
"ogg", "oga", "wav", "m4a", "aac", "flac", "opus", "pdf", "txt", "zip", "gz",
|
||||
"tgz", "bz2", "xz", "7z", "rar", "tar",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
|
||||
Vendored
-5
@@ -73,11 +73,6 @@ fn retention_deadline(
|
||||
_stale_if_error: u64,
|
||||
_has_validator: bool,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+5
-24
@@ -1,4 +1,3 @@
|
||||
/// Reject malformed/duplicate directives instead of guessing their meaning.
|
||||
fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
|
||||
let mut result = HashMap::new();
|
||||
for header in headers.get_all("cache-control") {
|
||||
@@ -11,8 +10,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
|
||||
Some((k, v)) => {
|
||||
let v = v.trim();
|
||||
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('"')?;
|
||||
if inner.contains(['"', '\\']) {
|
||||
return None;
|
||||
@@ -39,10 +36,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
|
||||
return None;
|
||||
}
|
||||
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);
|
||||
} else if result.insert(key, value).is_some() {
|
||||
return None;
|
||||
@@ -111,7 +104,11 @@ pub fn forced_request_policy(
|
||||
}
|
||||
for key in ["if-match", "if-unmodified-since", "upgrade"] {
|
||||
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
|
||||
@@ -150,8 +147,6 @@ pub fn request_policy(
|
||||
for (key, reason) in [
|
||||
("authorization", "request_authorization"),
|
||||
("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-unmodified-since", "request_precondition"),
|
||||
("upgrade", "request_upgrade"),
|
||||
@@ -166,15 +161,9 @@ pub fn request_policy(
|
||||
let Some(control) = directives(headers) else {
|
||||
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") {
|
||||
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") {
|
||||
return RequestPolicy::Revalidate("request_no_cache");
|
||||
}
|
||||
@@ -200,8 +189,6 @@ pub fn request_policy(
|
||||
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) {
|
||||
return RequestPolicy::Revalidate("request_pragma_no_cache");
|
||||
}
|
||||
@@ -295,8 +282,6 @@ fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
|
||||
&& token != "*"
|
||||
&& 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 {
|
||||
if request.contains_key("if-none-match") {
|
||||
return response
|
||||
@@ -326,11 +311,8 @@ pub fn make_not_modified(mut response: Response) -> Response {
|
||||
}
|
||||
response
|
||||
}
|
||||
/// Merge a successful backend 304 into the stored representation metadata.
|
||||
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
|
||||
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") {
|
||||
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 {
|
||||
if status != StatusCode::OK {
|
||||
return "response_status";
|
||||
|
||||
Vendored
+10
-22
@@ -4,7 +4,11 @@ impl Plan {
|
||||
}
|
||||
|
||||
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.sort();
|
||||
}
|
||||
@@ -28,14 +32,7 @@ impl Plan {
|
||||
self.vary.extend(transformed.vary);
|
||||
self.vary.sort();
|
||||
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;
|
||||
// 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 {
|
||||
let carried = self
|
||||
.initial_age
|
||||
@@ -87,9 +84,6 @@ pub fn plan(
|
||||
{
|
||||
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 control = directives(headers)?;
|
||||
if ["no-store", "private", "must-understand"]
|
||||
@@ -141,22 +135,16 @@ pub fn plan(
|
||||
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
|
||||
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 corrected_age_value = Duration::from_secs(age).saturating_add(response_delay);
|
||||
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 {
|
||||
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 => return None,
|
||||
};
|
||||
|
||||
@@ -62,8 +62,6 @@ mod scope_tests {
|
||||
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new()
|
||||
);
|
||||
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);
|
||||
assert_eq!(cache.revision(), 1);
|
||||
assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass");
|
||||
Vendored
+3
-5
@@ -16,13 +16,9 @@ pub fn request_range(method: &Method, headers: &HeaderMap) -> RangeSelection {
|
||||
let Some(value) = values.next() else {
|
||||
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 {
|
||||
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 {
|
||||
return RangeSelection::Unsupported;
|
||||
}
|
||||
@@ -132,7 +128,9 @@ impl Hit {
|
||||
self.response
|
||||
.headers_mut()
|
||||
.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 {
|
||||
*self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
||||
*self.response.body_mut() = Body::empty();
|
||||
|
||||
Vendored
+28
-20
@@ -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;
|
||||
|
||||
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 DomainIndex = BTreeMap<String, BTreeMap<String, Arc<DomainStats>>>;
|
||||
@@ -51,13 +49,12 @@ impl DomainStats {
|
||||
"HIT" => &self.hits,
|
||||
"MISS" => &self.misses,
|
||||
_ => &self.bypasses,
|
||||
}.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.record_reason(reason);
|
||||
}
|
||||
|
||||
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" {
|
||||
self.fast_reasons[0].fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
@@ -72,7 +69,10 @@ impl DomainStats {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
if let Some(counter) = published.get(reason) {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -123,16 +123,22 @@ impl Default for DomainStatsStore {
|
||||
impl DomainStatsStore {
|
||||
fn with(&self, scope: &str, domain: &str, action: impl FnOnce(&DomainStats)) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
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 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);
|
||||
return;
|
||||
}
|
||||
@@ -141,7 +147,9 @@ impl DomainStatsStore {
|
||||
}
|
||||
let mut next = (*published).clone();
|
||||
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));
|
||||
next.count += 1;
|
||||
self.domains.store(Arc::new(next));
|
||||
@@ -163,10 +171,10 @@ impl DomainStatsStore {
|
||||
}
|
||||
|
||||
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| {
|
||||
counters.saved_body_bytes.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||
counters
|
||||
.saved_body_bytes
|
||||
.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Vendored
+7
-11
@@ -1,24 +1,20 @@
|
||||
#[cfg(test)]
|
||||
include!("tests/tests.rs");
|
||||
include!("cache_regression_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/scope_tests.rs");
|
||||
|
||||
include!("policy/scope_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/dashboard_tests.rs");
|
||||
|
||||
include!("dashboard_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/v022_cache_tests.rs");
|
||||
|
||||
include!("v022_cache_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/cold_tier_tests.rs");
|
||||
|
||||
include!("cold/cold_tier_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/http_invalidation_tests.rs");
|
||||
include!("invalidation/http_invalidation_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/hot_hit_034_tests.rs");
|
||||
include!("lookup/hot_hit_034_tests.rs");
|
||||
Vendored
-857
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+13
-13
@@ -1,7 +1,4 @@
|
||||
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(
|
||||
&self,
|
||||
revision: u64,
|
||||
@@ -23,12 +20,11 @@ impl Cache {
|
||||
target: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> 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)]
|
||||
pub fn ticket_scoped_for_config(
|
||||
&self,
|
||||
@@ -41,7 +37,16 @@ impl Cache {
|
||||
target: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> 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)]
|
||||
@@ -61,9 +66,6 @@ impl Cache {
|
||||
}
|
||||
let domain = crate::config::request_host(authority)
|
||||
.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_authority = authority.to_ascii_lowercase();
|
||||
let mut digest = Sha256::new();
|
||||
@@ -336,8 +338,6 @@ impl Cache {
|
||||
self: &Arc<Self>,
|
||||
cached: &Revalidation,
|
||||
) -> 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 mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) {
|
||||
|
||||
+212
-46
@@ -83,21 +83,55 @@ mod v022_cache_tests {
|
||||
] {
|
||||
let cache = Cache::new(22);
|
||||
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()));
|
||||
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120"));
|
||||
response.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap());
|
||||
response.headers_mut().insert(
|
||||
"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 {
|
||||
kind: crate::transform::Kind::Javascript,
|
||||
original_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()));
|
||||
let downstream = cache.wrap(response, ticket.clone(), policy, &settings)
|
||||
.into_body().collect().await.unwrap().to_bytes();
|
||||
let downstream = cache
|
||||
.wrap(response, ticket.clone(), policy, &settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes();
|
||||
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!(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() {
|
||||
let cache = Cache::new(22);
|
||||
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 original_allocation = payload.as_ptr();
|
||||
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("content-length", HeaderValue::from_static("8192"));
|
||||
response.headers_mut().insert(
|
||||
"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 {
|
||||
kind: crate::transform::Kind::Javascript,
|
||||
original_bytes: 8192,
|
||||
transformed_bytes: 8192,
|
||||
});
|
||||
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
let downstream = cache.wrap(response, ticket.clone(), policy, &settings)
|
||||
.into_body().collect().await.unwrap().to_bytes();
|
||||
let policy = plan(
|
||||
StatusCode::OK,
|
||||
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.as_ptr(), original_allocation);
|
||||
let hit = cache.get(&ticket).unwrap();
|
||||
let cached = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -180,8 +243,22 @@ mod v022_cache_tests {
|
||||
)
|
||||
.await;
|
||||
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 {
|
||||
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(
|
||||
ByteRange { start: Some(50), end: None },
|
||||
ByteRange {
|
||||
start: Some(50),
|
||||
end: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert_eq!(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(),
|
||||
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
|
||||
);
|
||||
|
||||
let mismatch = HeaderValue::from_static("\"asset-v2\"");
|
||||
let full = cache.get(&ticket).unwrap().apply_range(
|
||||
ByteRange { start: Some(0), end: Some(2) },
|
||||
ByteRange {
|
||||
start: Some(0),
|
||||
end: Some(2),
|
||||
},
|
||||
Some(&mismatch),
|
||||
);
|
||||
assert_eq!(full.response.status(), StatusCode::OK);
|
||||
assert!(!full.response.headers().contains_key("content-range"));
|
||||
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")
|
||||
);
|
||||
|
||||
let matched = HeaderValue::from_static("\"asset-v1\"");
|
||||
let partial = cache.get(&ticket).unwrap().apply_range(
|
||||
ByteRange { start: Some(0), end: Some(2) },
|
||||
ByteRange {
|
||||
start: Some(0),
|
||||
end: Some(2),
|
||||
},
|
||||
Some(&matched),
|
||||
);
|
||||
assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
}
|
||||
|
||||
#[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();
|
||||
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
|
||||
assert_eq!(
|
||||
@@ -318,13 +423,23 @@ mod v022_cache_tests {
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 10;
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new())
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"collapse.example",
|
||||
"origin",
|
||||
"/timeout",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => 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);
|
||||
drop(leader);
|
||||
assert!(matches!(
|
||||
@@ -339,7 +454,14 @@ mod v022_cache_tests {
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 5_000;
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new())
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"collapse.example",
|
||||
"origin",
|
||||
"/cancel",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
@@ -377,10 +499,24 @@ mod v022_cache_tests {
|
||||
let mut pl_headers = HeaderMap::new();
|
||||
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
|
||||
let en = cache
|
||||
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers)
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"vary-collapse.example",
|
||||
"origin",
|
||||
"/page",
|
||||
&en_headers,
|
||||
)
|
||||
.unwrap();
|
||||
let pl = cache
|
||||
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers)
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"vary-collapse.example",
|
||||
"origin",
|
||||
"/page",
|
||||
&pl_headers,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(en.key, pl.key, "Vary is learned from the response");
|
||||
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() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new())
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"refresh.example",
|
||||
"origin",
|
||||
"/asset",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut settings = active_settings();
|
||||
settings.proactive_refresh_enabled = true;
|
||||
@@ -511,7 +654,9 @@ mod v022_cache_tests {
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.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);
|
||||
drop(guards);
|
||||
}
|
||||
@@ -557,11 +702,17 @@ mod v022_cache_tests {
|
||||
#[tokio::test]
|
||||
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
|
||||
fn current_ticket(cache: &Cache, old: &Ticket) -> Ticket {
|
||||
// Each post-PURGE request obtains the current publication generation.
|
||||
// Reusing an old ticket would reject unrelated entries too and make
|
||||
// assertions about removed objects pass without testing the selector.
|
||||
cache.ticket_scoped(22, &old.scope, &old.scheme, &old.authority,
|
||||
"origin@127.0.0.1:8080", &old.target, &old.request_headers).unwrap()
|
||||
cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
&old.scope,
|
||||
&old.scheme,
|
||||
&old.authority,
|
||||
"origin@127.0.0.1:8080",
|
||||
&old.target,
|
||||
&old.request_headers,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
let cache = Cache::new(22);
|
||||
let one = store(
|
||||
@@ -595,8 +746,19 @@ mod v022_cache_tests {
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 1);
|
||||
assert!(cache.get(¤t_ticket(&cache, &one)).is_none());
|
||||
let survivor = cache.get(¤t_ticket(&cache, &two)).expect("unrelated query remains stored");
|
||||
assert_eq!(survivor.response.into_body().collect().await.unwrap().to_bytes(), "two");
|
||||
let survivor = cache
|
||||
.get(¤t_ticket(&cache, &two))
|
||||
.expect("unrelated query remains stored");
|
||||
assert_eq!(
|
||||
survivor
|
||||
.response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
"two"
|
||||
);
|
||||
|
||||
let three = store(
|
||||
&cache,
|
||||
@@ -832,7 +994,10 @@ mod v022_cache_tests {
|
||||
"fresh",
|
||||
)
|
||||
.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!(cache.remove_ban(ban_id));
|
||||
assert!(cache.bans().is_empty());
|
||||
@@ -898,9 +1063,6 @@ mod v022_cache_tests {
|
||||
"unchanged",
|
||||
)
|
||||
.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 {
|
||||
let target = format!("/unique-{index}");
|
||||
let old = store(
|
||||
@@ -924,15 +1086,16 @@ mod v022_cache_tests {
|
||||
))
|
||||
.unwrap();
|
||||
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());
|
||||
assert!(
|
||||
state.key_generations.len() <= 1,
|
||||
"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(
|
||||
&cache,
|
||||
"app",
|
||||
@@ -956,6 +1119,9 @@ mod v022_cache_tests {
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cache.get(&stable).is_some(), "unrelated entries must survive");
|
||||
assert!(
|
||||
cache.get(&stable).is_some(),
|
||||
"unrelated entries must survive"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 axum::{body::Body, response::Response};
|
||||
use http::{HeaderMap, HeaderValue, Method, Request};
|
||||
@@ -12,8 +9,6 @@ use tower_http::compression::{
|
||||
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)]
|
||||
struct MinimumSize(u64);
|
||||
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)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Overrides {
|
||||
@@ -113,9 +106,6 @@ impl Overrides {
|
||||
#[derive(Clone)]
|
||||
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)]
|
||||
pub enum AppliedEncoding {
|
||||
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 {
|
||||
let mut selected = HeaderMap::new();
|
||||
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)
|
||||
}
|
||||
|
||||
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 wildcard = None;
|
||||
for line in headers.get_all("accept-encoding").iter() {
|
||||
@@ -330,8 +320,6 @@ pub async fn encode(
|
||||
{
|
||||
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
|
||||
.headers()
|
||||
.get("etag")
|
||||
@@ -354,4 +342,4 @@ pub async fn encode(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("compression/tests.rs");
|
||||
include!("tests.rs");
|
||||
+5
-1
@@ -1,4 +1,3 @@
|
||||
//! Desired configuration and its validated, immutable routing snapshot.
|
||||
include!("config/model.rs");
|
||||
include!("config/snapshot.rs");
|
||||
include!("config/internal_routing.rs");
|
||||
@@ -8,3 +7,8 @@ include!("config/compile.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("config/tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("config/route_policy_tests.rs");
|
||||
#[cfg(test)]
|
||||
include!("config/transport_tests.rs");
|
||||
@@ -60,14 +60,8 @@ pub(crate) fn compile_backend_with_timeout(
|
||||
Host::Ipv6(ip) => {
|
||||
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,
|
||||
};
|
||||
// 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")? {
|
||||
Host::Ipv6(ip) => format!("[{ip}]:{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),
|
||||
)
|
||||
}
|
||||
/// Validate both active sockets and the sockets that will be used after restart.
|
||||
pub fn compile_with_defaults(
|
||||
config: Config,
|
||||
revision: u64,
|
||||
@@ -129,8 +122,6 @@ pub fn compile_with_defaults(
|
||||
) -> Result<Snapshot> {
|
||||
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(
|
||||
config: Config,
|
||||
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();
|
||||
for redirect in &config.redirects {
|
||||
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 {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
@@ -181,8 +170,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
}
|
||||
for host in &config.not_found_hosts {
|
||||
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 {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
|
||||
+108
-44
@@ -10,9 +10,6 @@ pub fn compile_with_active(
|
||||
config.logging.validate()?;
|
||||
config.error_log.validate()?;
|
||||
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!(
|
||||
config.administration.listen.is_none()
|
||||
|| desired_admin.ip().is_loopback()
|
||||
@@ -48,29 +45,53 @@ pub fn compile_with_active(
|
||||
config.compression.validate()?;
|
||||
config.server_header.validate()?;
|
||||
let forwarding = config.forwarding.compile()?;
|
||||
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)");
|
||||
ensure!(
|
||||
config.php_fpm_profiles.len() <= 128,
|
||||
"too many PHP-FPM profiles (max 128)"
|
||||
);
|
||||
let mut php_profiles = HashMap::new();
|
||||
for profile in &mut config.php_fpm_profiles {
|
||||
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?;
|
||||
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(),
|
||||
"duplicate PHP-FPM profile id");
|
||||
profile
|
||||
.validate()
|
||||
.with_context(|| format!("PHP-FPM profile {}", profile.id))?;
|
||||
ensure!(
|
||||
php_profiles
|
||||
.insert(profile.id.clone(), profile.php.clone())
|
||||
.is_none(),
|
||||
"duplicate PHP-FPM profile id"
|
||||
);
|
||||
}
|
||||
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
|
||||
ensure!(
|
||||
config.web_services.len() <= 128,
|
||||
"too many Web Services (max 128)"
|
||||
);
|
||||
let mut web_services = HashMap::new();
|
||||
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused
|
||||
// workers. Different directive sets on one socket can leak to the next vhost.
|
||||
// Report unsafe legacy configurations but keep them loadable for migration.
|
||||
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new();
|
||||
let mut php_endpoint_policies: HashMap<
|
||||
String,
|
||||
(
|
||||
String,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
std::collections::BTreeMap<String, String>,
|
||||
),
|
||||
> = HashMap::new();
|
||||
for service in &mut config.web_services {
|
||||
service.validate().with_context(|| format!("Web Service {}", service.id))?;
|
||||
// A profile changes effective runtime settings, not the saved standalone
|
||||
// PHP configuration. Detaching a profile restores local settings exactly.
|
||||
service
|
||||
.validate()
|
||||
.with_context(|| format!("Web Service {}", service.id))?;
|
||||
let mut effective = service.clone();
|
||||
if let Some(id) = &service.php_profile_id {
|
||||
effective.php = php_profiles.get(id).with_context(|| {
|
||||
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
|
||||
})?.clone();
|
||||
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
|
||||
effective.php = php_profiles
|
||||
.get(id)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Web Service {} references unknown PHP-FPM profile {}",
|
||||
service.id, id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
effective
|
||||
.validate()
|
||||
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
|
||||
}
|
||||
if effective.php.enabled {
|
||||
let php = &effective.php;
|
||||
@@ -82,13 +103,25 @@ pub fn compile_with_active(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
php_endpoint_policies.insert(php.endpoint.clone(), (
|
||||
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(),
|
||||
));
|
||||
php_endpoint_policies.insert(
|
||||
php.endpoint.clone(),
|
||||
(
|
||||
effective.id.clone(),
|
||||
php.php_values.clone(),
|
||||
php.php_admin_values.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?);
|
||||
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id");
|
||||
let compiled = Arc::new(
|
||||
effective
|
||||
.compile()
|
||||
.with_context(|| format!("Web Service {}", service.id))?,
|
||||
);
|
||||
ensure!(
|
||||
web_services.insert(service.id.clone(), compiled).is_none(),
|
||||
"duplicate Web Service id"
|
||||
);
|
||||
}
|
||||
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
|
||||
let mut vhost_ids = HashSet::new();
|
||||
@@ -202,8 +235,6 @@ pub fn compile_with_active(
|
||||
guarded_bindings.extend(previous.listeners.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.extend(defaults.1);
|
||||
guarded_bindings.extend([
|
||||
@@ -264,22 +295,29 @@ pub fn compile_with_active(
|
||||
"duplicate ACL profile id"
|
||||
);
|
||||
}
|
||||
// Prevent unbounded DNS ACL work across the entire published configuration.
|
||||
crate::acl::dynamic::configured_hostnames(&config)?;
|
||||
for agent in &config.docker_agents.agents {
|
||||
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!(
|
||||
acl_profiles.contains_key(profile_id),
|
||||
"Docker agent {} references unknown default ACL profile {}",
|
||||
agent.id, profile_id
|
||||
agent.id,
|
||||
profile_id
|
||||
);
|
||||
}
|
||||
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!(
|
||||
acl_profiles.contains_key(profile_id),
|
||||
"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(())
|
||||
}
|
||||
|
||||
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
||||
fn validate_location_redirect(
|
||||
redirect: Option<&crate::vhost::LocationRedirect>,
|
||||
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
|
||||
let Some(redirect) = redirect else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -406,26 +446,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>)
|
||||
&& !redirect.target.chars().any(char::is_control),
|
||||
"invalid location redirect target"
|
||||
);
|
||||
// Replace capture references with a harmless segment so URL structure can
|
||||
// be validated before requests reach the data plane.
|
||||
let authority_end = redirect.target.find("://").and_then(|start| {
|
||||
let authority_end = redirect
|
||||
.target
|
||||
.find("://")
|
||||
.and_then(|start| {
|
||||
let rest = &redirect.target[start + 3..];
|
||||
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
|
||||
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
|
||||
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
|
||||
rest.char_indices()
|
||||
.find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
|
||||
.map(|(offset, _)| start + 3 + offset)
|
||||
.or(Some(redirect.target.len()))
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
|
||||
ensure!(
|
||||
!redirect.target[..authority_end].contains('$'),
|
||||
"location redirect captures are not allowed in the URL authority"
|
||||
);
|
||||
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
|
||||
.expect("static capture regex")
|
||||
.replace_all(&redirect.target, "capture");
|
||||
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
|
||||
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
|
||||
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
|
||||
let parsed =
|
||||
url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
|
||||
ensure!(
|
||||
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
|
||||
"location redirect target must use http/https and include a host"
|
||||
);
|
||||
ensure!(
|
||||
parsed.username().is_empty() && parsed.password().is_none(),
|
||||
"location redirect target cannot contain credentials"
|
||||
);
|
||||
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
|
||||
None | Some("") => None,
|
||||
Some(pattern) => {
|
||||
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
|
||||
ensure!(
|
||||
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
|
||||
"invalid location redirect rewrite regex"
|
||||
);
|
||||
let mut builder = regex::RegexBuilder::new(pattern);
|
||||
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(builder.build().context("invalid location redirect rewrite regex")?)
|
||||
builder
|
||||
.size_limit(2 * 1024 * 1024)
|
||||
.dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(
|
||||
builder
|
||||
.build()
|
||||
.context("invalid location redirect rewrite regex")?,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Some(crate::config::CompiledLocationRedirect {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -25,21 +22,31 @@ impl Snapshot {
|
||||
Self::find_host_pattern(host, |pattern| {
|
||||
let owns_host = self.fallbacks.get(pattern).is_some_and(|routes| {
|
||||
routes.iter().any(|route| {
|
||||
route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
});
|
||||
if !owns_host {
|
||||
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| {
|
||||
routes.iter().find(|route| {
|
||||
route.internal
|
||||
&& route.name == target
|
||||
&& route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
&& route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.matches(local, tls))
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -36,8 +36,6 @@ pub struct Config {
|
||||
pub geoip_updates: crate::geoip::UpdateSettings,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub http2: crate::settings::Http2Connections,
|
||||
#[serde(default)]
|
||||
@@ -112,20 +110,14 @@ pub struct Backend {
|
||||
pub address_allowlist: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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")]
|
||||
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)]
|
||||
pub tls_verify: bool,
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub accept_local_listener_risk: bool,
|
||||
#[serde(default)]
|
||||
@@ -136,12 +128,8 @@ pub struct Backend {
|
||||
pub enum UpstreamProtocol {
|
||||
#[default]
|
||||
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,
|
||||
/// HTTPS negotiates H2/H1; plaintext uses H1 without H2C probing.
|
||||
Auto,
|
||||
/// New explicit mode `h2`: require H2 for HTTPS and H2C for cleartext.
|
||||
H2,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -159,7 +147,6 @@ pub struct Update {
|
||||
pub struct ValidationPolicy {
|
||||
pub tls_addr: Option<SocketAddr>,
|
||||
pub cert_dir: Option<PathBuf>,
|
||||
/// Resolved runtime data directory. Exposed only through authenticated admin status.
|
||||
pub data_dir: Option<PathBuf>,
|
||||
pub proxy_addr: SocketAddr,
|
||||
pub admin_addr: SocketAddr,
|
||||
@@ -170,21 +157,14 @@ pub struct CompiledBackend {
|
||||
pub client: crate::upstream::ProxyClient,
|
||||
pub websocket_client: crate::upstream::ProxyClient,
|
||||
pub id: String,
|
||||
/// Configured network origin used by raw TCP probes and policy checks.
|
||||
pub origin: String,
|
||||
/// HTTP request origin. For HTTPS this may carry a separate verified TLS name.
|
||||
pub request_origin: String,
|
||||
/// Parsed once at configuration publish; reused for every upstream request.
|
||||
pub request_origin_uri: http::Uri,
|
||||
pub host_header: HeaderValue,
|
||||
pub preserve_host: bool,
|
||||
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 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,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -208,7 +188,6 @@ pub struct CompiledRoute {
|
||||
pub frontend: Option<Arc<CompiledFrontend>>,
|
||||
pub pool: Option<Arc<crate::balance::Pool>>,
|
||||
pub cache: Option<bool>,
|
||||
/// Vhost-local stable location ID (`main` for the main source).
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub internal: bool,
|
||||
@@ -237,10 +216,8 @@ pub struct CompiledFrontend {
|
||||
pub websocket_rfc8441: bool,
|
||||
pub websocket_rfc9220: bool,
|
||||
pub force_ssl: bool,
|
||||
/// Optional quick www/non-www canonical redirect (from, to).
|
||||
pub www_redirect: Option<(String, String)>,
|
||||
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 http2_enabled: bool,
|
||||
pub http3_enabled: bool,
|
||||
@@ -264,7 +241,6 @@ pub struct CompiledFrontend {
|
||||
pub acl_profile_id: Option<String>,
|
||||
pub profile_acl: Option<CompiledAcl>,
|
||||
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 acl: Option<CompiledAcl>,
|
||||
pub headers: CompiledHeaders,
|
||||
@@ -295,7 +271,6 @@ pub struct Snapshot {
|
||||
pub tls: Option<Arc<rustls::ServerConfig>>,
|
||||
pub envelope: Envelope,
|
||||
routes: HashMap<String, Vec<Arc<CompiledRoute>>>,
|
||||
/// Main vhost backend fallback, kept separate from user Custom locations.
|
||||
fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>>,
|
||||
redirects: HashMap<String, Vec<Arc<crate::redirect::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 {
|
||||
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")
|
||||
.field("revision", &self.envelope.revision)
|
||||
.field("listeners", &self.listeners)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@ fn compile_routing(
|
||||
|| host_bindings.iter().all(|l| l.tls),
|
||||
"prefixed affinity cookies require TLS on every resolved frontend"
|
||||
);
|
||||
// Disabled vhosts also reserve their name, preventing fallback exposure.
|
||||
for name in &routing_hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
@@ -91,8 +90,6 @@ fn compile_routing(
|
||||
}
|
||||
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()
|
||||
|| host.routes.iter().any(|route| {
|
||||
route.enabled
|
||||
@@ -119,8 +116,6 @@ fn compile_routing(
|
||||
None
|
||||
} else {
|
||||
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()
|
||||
&& (pool_settings.health_check.mode == crate::balance::HealthMode::Http
|
||||
|| host.backends.iter().any(|member| {
|
||||
@@ -369,7 +364,6 @@ fn compile_routing(
|
||||
if host.enabled && route.enabled {
|
||||
let (source, route_pool, backend) =
|
||||
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)
|
||||
} else {
|
||||
match &route.source {
|
||||
|
||||
+21
-36
@@ -1,13 +1,8 @@
|
||||
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) {
|
||||
return Some(value);
|
||||
}
|
||||
// Skip the final dot: only one label follows it.
|
||||
let last_dot = host.rfind('.')?;
|
||||
let mut wildcard = [0u8; 256];
|
||||
wildcard[..2].copy_from_slice(b"*.");
|
||||
@@ -25,7 +20,6 @@ impl Snapshot {
|
||||
return Some(value);
|
||||
}
|
||||
} else if let Some(value) = lookup(&format!("*.{suffix}")) {
|
||||
// Preserve behavior for unusual overlong internal host inputs.
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +34,6 @@ impl Snapshot {
|
||||
})
|
||||
.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 {
|
||||
self.frontends.values().flatten().any(|frontend| {
|
||||
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 {
|
||||
self.frontends.values().flatten().any(|frontend| {
|
||||
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(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -73,7 +63,6 @@ impl Snapshot {
|
||||
) -> Option<&CompiledRoute> {
|
||||
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(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -87,7 +76,6 @@ impl Snapshot {
|
||||
.iter()
|
||||
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
|
||||
})?;
|
||||
// The claimed vhost owns its fallback ahead of broader wildcards.
|
||||
self.routes
|
||||
.get(pattern)
|
||||
.and_then(|routes| {
|
||||
@@ -112,7 +100,6 @@ impl Snapshot {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Resolve protocol-error attribution with the routing precedence and bindings.
|
||||
pub fn resolve_logging_vhost(
|
||||
&self,
|
||||
local: SocketAddr,
|
||||
@@ -136,11 +123,7 @@ impl Snapshot {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (candidate, matched_by) in [
|
||||
(authority, "authority"),
|
||||
(host, "host"),
|
||||
(sni, "sni"),
|
||||
] {
|
||||
for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
|
||||
let Some(candidate) = candidate else {
|
||||
continue;
|
||||
};
|
||||
@@ -156,7 +139,6 @@ impl Snapshot {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Select redirects using accepted socket, TLS state and normalized path.
|
||||
pub fn redirect_on(
|
||||
&self,
|
||||
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(
|
||||
&self,
|
||||
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> {
|
||||
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
|
||||
.get(pattern)
|
||||
.and_then(|routes| select_route(routes, path, |_| true))
|
||||
@@ -269,17 +252,25 @@ where
|
||||
prefix
|
||||
}
|
||||
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 {
|
||||
return Ok(None);
|
||||
};
|
||||
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()
|
||||
} else {
|
||||
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 let Some(query) = uri.query() {
|
||||
let merged = match target.query() {
|
||||
@@ -289,10 +280,11 @@ impl CompiledRoute {
|
||||
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>> {
|
||||
use crate::vhost::RewriteMode;
|
||||
let rewritten = match self.rewrite.mode {
|
||||
@@ -346,7 +338,6 @@ pub(crate) fn valid_id(id: &str) -> bool {
|
||||
.bytes()
|
||||
.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> {
|
||||
let host = value
|
||||
.strip_suffix('.')
|
||||
@@ -370,8 +361,6 @@ pub fn normalize_host(value: &str) -> Result<String> {
|
||||
);
|
||||
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> {
|
||||
let value = value.trim();
|
||||
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> {
|
||||
ensure!(!value.contains('@'), "invalid Host");
|
||||
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 suffix = &authority.as_str()[raw_host.len()..];
|
||||
if !suffix.is_empty() {
|
||||
let port = suffix.strip_prefix(':').context("invalid Host port")?;
|
||||
// HTTP permits an empty port suffix, which selects the default port.
|
||||
if !port.is_empty() {
|
||||
let number: u16 = port.parse().context("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),
|
||||
"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 global =
|
||||
s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0);
|
||||
|
||||
+121
-46
@@ -11,15 +11,21 @@ fn policy() -> ValidationPolicy {
|
||||
}
|
||||
#[test]
|
||||
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 first = snapshot.select_on_arc("localhost", "/", local, false).unwrap();
|
||||
let second = snapshot.select_on_arc("localhost", "/child", local, false).unwrap();
|
||||
// Both selectors point into one immutable compiled snapshot.
|
||||
let first = snapshot
|
||||
.select_on_arc("localhost", "/", local, false)
|
||||
.unwrap();
|
||||
let second = snapshot
|
||||
.select_on_arc("localhost", "/child", local, false)
|
||||
.unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(first, second));
|
||||
let legacy = snapshot.select_on("localhost", "/", local, false).unwrap();
|
||||
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]
|
||||
@@ -238,13 +244,27 @@ fn logging_vhost_resolution_reuses_router_host_precedence() {
|
||||
assert_eq!(routed.matched_by, "router");
|
||||
|
||||
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();
|
||||
assert_eq!(exact.vhost, "exact");
|
||||
assert_eq!(exact.matched_by, "authority");
|
||||
|
||||
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();
|
||||
assert_eq!(wildcard.vhost, "wildcard");
|
||||
assert_eq!(wildcard.matched_by, "host");
|
||||
@@ -449,8 +469,6 @@ fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() {
|
||||
#[test]
|
||||
fn custom_locations_override_but_main_vhost_remains_fallback() {
|
||||
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();
|
||||
custom.id = "test".into();
|
||||
custom.path_prefix = "/test".into();
|
||||
@@ -525,7 +543,6 @@ fn backend_diagnostic_flags_compile_to_frontend() {
|
||||
assert!(frontend.backend_timing_header);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn path_boundaries() {
|
||||
assert!(prefix_matches("/api", "/api"));
|
||||
@@ -544,7 +561,11 @@ fn invalid_per_backend_health_policy_rejected_without_global_override() {
|
||||
..Default::default()
|
||||
});
|
||||
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());
|
||||
}
|
||||
#[test]
|
||||
@@ -715,8 +736,6 @@ fn local_response_hosts_reserve_frontends_and_do_not_require_backends() {
|
||||
#[test]
|
||||
fn conflicts_are_rejected() {
|
||||
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();
|
||||
let mut duplicate = c.vhosts[0].routes[0].clone();
|
||||
duplicate.id = "other".into();
|
||||
@@ -770,9 +789,8 @@ fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() {
|
||||
#[test]
|
||||
fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec![
|
||||
"suba.local".into(), "subb.local".into(), "site.test".into(),
|
||||
];
|
||||
c.vhosts[0].frontend.hosts =
|
||||
vec!["suba.local".into(), "subb.local".into(), "site.test".into()];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
c.vhosts[0].www_redirect_target = Some("site.test".into());
|
||||
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}");
|
||||
}
|
||||
for hostname in ["suba.local", "subb.local", "www.site.test"] {
|
||||
let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap();
|
||||
assert_eq!(frontend.www_redirect.as_ref(),
|
||||
Some(&("www.site.test".into(), "site.test".into())));
|
||||
let frontend = snapshot
|
||||
.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false)
|
||||
.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("www.suba.local", "/path").is_none());
|
||||
let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
|
||||
assert_eq!(frontend.www_redirect.as_ref(),
|
||||
Some(&(String::from("subb.local"), String::from("www.subb.local"))));
|
||||
let frontend = snapshot
|
||||
.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
frontend.www_redirect.as_ref(),
|
||||
Some(&(String::from("subb.local"), String::from("www.subb.local")))
|
||||
);
|
||||
}
|
||||
|
||||
#[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() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
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;
|
||||
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 {
|
||||
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 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]
|
||||
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();
|
||||
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());
|
||||
config.php_fpm_profiles.push(crate::web_server::PhpFpmProfile {
|
||||
config
|
||||
.php_fpm_profiles
|
||||
.push(crate::web_server::PhpFpmProfile {
|
||||
id: "php-default".into(),
|
||||
name: "Default PHP pool".into(),
|
||||
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(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php_profile_id: Some("php-default".into()),
|
||||
// Standalone settings remain distinct and disabled.
|
||||
php: crate::web_server::Php::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();
|
||||
for id in ["site-one", "site-two"] {
|
||||
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!(!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();
|
||||
detached.web_services[0].php_profile_id = None;
|
||||
let snapshot = compile(detached, 2, &policy()).unwrap();
|
||||
assert!(snapshot.web_php_services.get("site-one").unwrap().php.is_none());
|
||||
assert!(snapshot.web_php_services.get("site-two").unwrap().php.is_some());
|
||||
assert!(snapshot
|
||||
.web_php_services
|
||||
.get("site-one")
|
||||
.unwrap()
|
||||
.php
|
||||
.is_none());
|
||||
assert!(snapshot
|
||||
.web_php_services
|
||||
.get("site-two")
|
||||
.unwrap()
|
||||
.php
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -941,7 +991,9 @@ fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() {
|
||||
php_profile_id: Some("missing".into()),
|
||||
..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}");
|
||||
config.web_services[0].php_profile_id = None;
|
||||
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")
|
||||
});
|
||||
assert_eq!(found, Some("parent"));
|
||||
assert_eq!(checked, vec![
|
||||
"api.eu.example.test", "*.eu.example.test", "*.example.test",
|
||||
]);
|
||||
assert_eq!(
|
||||
checked,
|
||||
vec!["api.eu.example.test", "*.eu.example.test", "*.example.test",]
|
||||
);
|
||||
|
||||
checked.clear();
|
||||
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 local = "127.0.0.1:8080".parse().unwrap();
|
||||
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();
|
||||
assert_eq!(internal.name, "internal-page");
|
||||
assert!(internal.internal);
|
||||
@@ -1042,8 +1101,6 @@ fn owned_host_without_internal_route_stops_wildcard_search() {
|
||||
|
||||
#[test]
|
||||
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 mut patterns = Vec::new();
|
||||
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)
|
||||
});
|
||||
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");
|
||||
}
|
||||
#[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 = Some(crate::acl::Acl {
|
||||
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 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!(crate::acl::allows_profiles_with_country(&frontend.profile_policies, "192.0.2.5".parse().unwrap(), None));
|
||||
assert!(!frontend.acl.as_ref().unwrap().allows("192.0.2.5".parse().unwrap()));
|
||||
assert!(crate::acl::allows_profiles_with_country(
|
||||
&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()];
|
||||
assert!(compile(c.clone(), 2, &policy()).is_err());
|
||||
@@ -1081,15 +1154,17 @@ fn multiple_profile_bindings_compile_and_do_not_weaken_inline_acl() {
|
||||
#[test]
|
||||
fn old_vhost_profile_field_survives_json_roundtrip() {
|
||||
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());
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let decoded: Config = serde_json::from_str(&json).unwrap();
|
||||
assert!(decoded.vhosts[0].acl_profile_ids.is_empty());
|
||||
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.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
Reference in new issue
Block a user