From 1cd6b7ac999042e3ac02097e0ed980aad0d34848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 22 Oct 2026 15:31:47 +0200 Subject: [PATCH] v0.34.7 --- CHANGELOG.md | 11 +- README.md | 2 +- SHA256SUMS | 42 ++-- docs/CACHE.md | 33 ++-- docs/CONFIGURATION.md | 2 +- langs/en_US.json | 7 +- langs/pl_pl.json | 7 +- src/admin/openapi.json | 9 +- src/admin/web/app/settings.js | 4 +- src/admin/web/index.html | 21 +- src/admin/web/style.css | 52 +++++ src/cache/model/entry.rs | 4 + src/cache/model/settings.rs | 3 + src/cache/model/views.rs | 13 +- src/cache/policy/request.rs | 113 ++++++++--- src/cache/policy/response.rs | 44 +++-- src/cache/tickets.rs | 12 +- src/cache/varnish_compatible_tests.rs | 213 +++++++++++++++++---- src/proxy/forward/cache_fast_path_tests.rs | 94 ++++++++- src/proxy/forward/cache_lookup.rs | 5 +- src/proxy/forward/route_cache.rs | 5 +- src/proxy/forward/web_source.rs | 3 +- 22 files changed, 550 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e78c70a..c102000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,12 @@ Release history focused on **features, settings, default behavior, compatibility ## 0.34.7 — Varnish-compatible cache lookup (source candidate) -- **Settings → Cache → Varnish-compatible cache policy (`varnish_compatible`): ON by default**, including for existing configurations without the new field. OFF preserves the legacy stricter interpretation of incoming `Cache-Control` and `Pragma`; the toggle is applied live (no service restart), invalidates existing cache entries for storage-policy safety, and affects both reverse-proxy and embedded Web Server cache, HOT and COLD. -- Varnish-style lookup ignores client `no-cache`, `no-store`, `max-age`, `min-fresh`, `max-stale`, `only-if-cached` and `Pragma` **only as cache control signals**; it does not remove request headers from forwarding, Vary keying, downstream conditions or user identity. Hard reloads of a fresh asset remain `HIT`, retaining `Age` and counters instead of refetching without origin validators. -- For **origin responses**, default-compatible mode rejects `Cache-Control: no-cache`, `Surrogate-Control: no-store`, and responses with no remaining TTL. Previously protected `private`, `no-store`, `Set-Cookie`, Authorization, Vary and all explicit RustProxy cookie exceptions remain intact; an eligible stale object may be served during up to 10 seconds of background-refresh grace unless RustProxy's stronger revalidation restrictions apply. -- Scope deliberately excludes full Varnish/VCL or hit-for-miss emulation and the 120-second implicit origin TTL default; RustProxy keeps its configured default TTL and existing safety limits. To compare live behavior, enable diagnostic headers and inspect `Age`, `X-Cache`, `X-Cache-Hits` and `X-Cache-Reason` before/after hard reload. -- **Delivery:** source candidate, requiring native Rust compilation, integration and production acceptance tests before deployment. No binary release is claimed. +- **Settings → Cache → Varnish-compatible cache policy (`varnish_compatible`)** stays **ON by default**, including upgraded configurations without the field. An adjacent **i** popover (PL/EN) explains its effects, limitations and safety controls. OFF retains strict client hard-reload behavior. The live toggle invalidates existing entries across HOT/COLD. +- Only client hard-reload `Cache-Control: no-cache`, `max-age=0` and `Pragma: no-cache` are ignored when considering a **fresh HIT**, retaining `Age` and hit counters. Kept the original `Cache-Control: no-store` bypass, `only-if-cached` 504 on cache MISS and no background origin refresh on HIT, and `min-fresh`/`max-stale`/positive `max-age` freshness constraints (including explicit `max-stale=0` during grace). Original request headers, Vary, access/privacy rules and Web Server handling remain intact. +- **Origin `no-cache` regression fix:** ETag/Last-Modified entries remain cacheable for conditional revalidation when fallback TTL is disabled. With **explicit positive fallback TTL**, only origin responses marked **`public`** may be reused for that TTL (capped by max TTL), even without origin validators. This is an explicit administrative override of origin `no-cache`, not the default. `private`, `no-store`, `Set-Cookie` and compatibility-mode `Surrogate-Control: no-store` remain vetoes. +- **Zero TTL regression fix:** keep expired representations for revalidation, client `max-stale` and configured `stale-if-error`; an expired object does not become a fresh HIT. Implicit grace defaults to **60 seconds**, can be set in Settings → Cache (`varnish_grace_seconds`, 0 disables it), and applies only to eligible objects stored fresh with no `stale-while-revalidate` directive; explicit `stale-while-revalidate=0` is never overwritten. +- **UI layout:** nested Settings tab bars no longer stick over Cache checkboxes while scrolling; the top-level Settings navigation remains sticky. +- Source-level regression cases cover hard reload, fallback TTL, validator revalidation, no-store/private/Surrogate protections, zero-TTL stale-if-error, min-fresh/max-stale, and `only-if-cached` for proxy and Web Server. Native Rust compilation and runtime acceptance are required before a production release claim. This remains a Varnish-inspired policy subset, not a VCL clone. ## 0.34.6 — Docker live logs, neutral Publish switch and Vhost Content-Type rules (source candidate) diff --git a/README.md b/README.md index a2ec2f6..c103f07 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Features and default settings vary by component; configure only the capabilities ## 0.34.7 -- **Settings → Cache:** Varnish-compatible cache policy (on by default), with legacy strict semantics when disabled. Prevents client hard-reloads from needlessly refilling still-fresh HOT/COLD objects; respects origin cache privacy and preserves RustProxy static-cookie handling. See [Cache](docs/CACHE.md) for the scope and important exceptions. +- **Settings → Cache:** Varnish-inspired cache policy (on by default) ignores hard-reload `no-cache` / `max-age=0` / `Pragma: no-cache` for fresh HITs, without disabling `only-if-cached`, `no-store`, `max-stale` or `min-fresh`. Public origin `no-cache` responses can use an explicit fallback TTL; other responses retain revalidation, `stale-if-error`, Surrogate-Control vetoes and privacy protections. The behavior is explained by an **i** icon next to the setting. See [Cache](docs/CACHE.md) for examples and safety implications. ## 0.34.6 diff --git a/SHA256SUMS b/SHA256SUMS index 9330110..3514ed8 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -2,11 +2,11 @@ 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 10e5382f535df61cc2e5905d83a61d40ac3e60572e7ff8e05bad6271d871b6c5 AGENTS.md -d88bcb686fed1626510867d9b44a9db4e6020a65e9c2369ced781e5744f64022 CHANGELOG.md +71b520eb7169feb726ba057417156bd74faa71dc4cbd3e3f994bcfee19f851fd CHANGELOG.md 32a192c94173ec66cce81890c71f235febeda7cf59bc8581fc982e1a0759b296 Cargo.lock 82922877469166aff8eec6805e56f9c7f07b95dba2dc02d17c9c9fd5f39747fa Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -626f18ec4028ae66ec4b65eb8dab83405e568d64396a3e58d25a247851fecb90 README.md +b9a2fb9e884f26840fbd9c84b1564682ef2093a08b32a502b9b19711c19a3724 README.md ae6d14ada6fde7d0b8514840b821678418cf5b1816c18434e3b7fb2ae27a0b21 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml 7309cfaa188c7a1fcf30be1b8313ee99c00b7fe5d6d626ddefcc7345fa066112 build.rs @@ -32,8 +32,8 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 9268ab7fe86683dacd64bafbd9e0edc8956ce9b9ef6277cc6ada61c3fcb84746 docs/API.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md 728b15b9825f547f34c4121219784798dd046fbbdd72ece8906d0cd77e8efc64 docs/BACKUP.md -e072271ddba43e3759b9ea7702c41a8357d13008f8b664c62fa860eadf2f1e1e docs/CACHE.md -c3f4cb4e32d90a2f334d864cc3fb051f6a7d662f37daa554a8988530b6dec56f docs/CONFIGURATION.md +6dcfa83377d10169a3b6cd7e6f79c500f0707242874f2d21212be7ca6a81d3fa docs/CACHE.md +f24767c2f9d4db4db5005b700d03323dd20f0a1d180ae939be4fe54a126aaf32 docs/CONFIGURATION.md 80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md 9fb2d0bcac70d1883dd16557622851a1f0527b3d3569a4b5177f5e6dedd3c74d docs/DASHBOARD.md @@ -76,8 +76,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 13189fa546c3488dddd8efc80c47f6da5241e5fc28b79cbfe795414ea05c7a4f langs/README.md -d08c2f6ea2abc0fc848300c2bfd911885863de81bceda533d10991cc55a93e97 langs/en_US.json -1cf4e525442e65e6007c54304197e71853b8991d9a5990dfdae6e72b1de389e1 langs/pl_pl.json +c04792fcc2e2e237b28e22d64f2db44964f26b779db09622f2162d0f068d5526 langs/en_US.json +95564b46ad8526610dc04d5696b4f26d4c5b46fdbd38290c4850d57d45e90c49 langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -143,7 +143,7 @@ d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file 282352eda7957477e7da44f16b90984b26a4a67a8755833bc0a6b34eaf93e3af src/admin/file_logs_query.rs f66ff718f82a326f80808b0a5088bd05a48625be802e5ba305fff3c344b59948 src/admin/logging_api.rs 1ba0520c6348246cbb35b60e596e99dc600137c0696de86508cf06587bd86563 src/admin/mod.rs -4cd8f528ef3be20d7b58c928e368afca1c4edc4db22840d2e9effc309aa43bcd src/admin/openapi.json +228560ed081bce7a490f401404083c79ecefcdfadac54a7256396ef2f40c0da6 src/admin/openapi.json 7cfd6593ac9bcea3d1b9e61bbcb7ed98d65ed3190f54f94c1e104541f4fbb4f5 src/admin/operations.rs f14afb6ae141053d83d0dc59df835b20e38a37a9e83f86f0f2053f2bd5d9ce57 src/admin/panel_assets.rs f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs @@ -179,7 +179,7 @@ d997f62e8bd7ac9d4f16bb302800c98ad943afab663bb0b212ea26c35b58de11 src/admin/web/ 43f4dddd1c3ea7fa42eeb9d7b655a8db48b5ddcc6c68213e08dfc2fcfbe8b17d src/admin/web/app/refresh.js f1a764691f3e2cdb7c6c43e37dae78249561c017a1b74e973a378cb780b72835 src/admin/web/app/runtime.js 31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js -4596247d22cefe250345240942807fbcfebf4e9393457c37d4a97078f255e86c src/admin/web/app/settings.js +fa00bce2651308a4e0b1303cc0146be17ed4794b67b2f0cebfaf861558d8c78c src/admin/web/app/settings.js 8386710827e5ccc9432fb46413368f94afc1016425404a6092b460903636837e src/admin/web/app/stream-dashboard.js 7f89bfae66e2a08a05a112733864e908f036bb1ee3d8b5b9ff23783b870acd96 src/admin/web/app/web-overview.js 849b2ca9d183bdf6cb1e04651cda52da55b8a631e11ab1d827a052c8436bbd76 src/admin/web/cache-panel.js @@ -207,7 +207,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js -efe0560058dc4addd71f69a9642088f06c245099920b7511caa538adf4684443 src/admin/web/index.html +43ea73b9f4e87025aa5c74cbe6062bf419b44956ea929d9132088583d5bf2dde src/admin/web/index.html 7915a06cebb3dfa2c51833c326586920691ba4431b1f984429b039333f9c2a93 src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html @@ -220,7 +220,7 @@ a8afc6e70950bc70b98725eb049d73cff35dc3a3ee659229a15a8e08d3eb4f12 src/admin/web/ be9b78bee4ef0026608191d36a2aed831f93c1baaa5bc73da86bf09bc217ce76 src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html 6feb1996b1caa9d19e0dc56eae7ca5b48b7e7cdf0a93bb2e4b527fb8f13a2fe5 src/admin/web/streams.js -9e135460e63f2f46568f0a30a4e32c88f16d57f817fa2528b79577d7db70fc3f src/admin/web/style.css +642b6e14cef880f7fb2b93644234e82db4e5392ff9e871c08becc8ae9ecf8e26 src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js dfb4ded2fefb90fc1e2d33370cdef10b040a85cb43235ea9dbc1d73bef823a79 src/admin/web/transform-panel.js a55f45dad5b540def419f8487161fb506b7e52744c76a5e661c7a5fa0a98a426 src/admin/web/vhosts/core.js @@ -277,21 +277,21 @@ e9c6ab0c2be9f96573f826e6a1b9e4c2b8b526f779e72e95a6463ae07e45ab55 src/cache/look 0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs c409174a9aaf20c69dc7b329108d1c26b5672a7341236cf639480df48800d262 src/cache/model/cache.rs 0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs -000d82a19cda1501f12a8ef539c1e807529e104a77181fe541738fe6b22908c4 src/cache/model/entry.rs +60b186c980afec29fc867a32b07b1360d9b68ba471ad1f9b8ada29ad309b1414 src/cache/model/entry.rs ff1c3a1a3bc0d26218dcc943ef951c2a212c180bbf6e4bf1c542ffbf12a9f150 src/cache/model/http_invalidation.rs dc1153685671be70c28bef814d72d8ef09afe070598f528ea62260c54d43b26f src/cache/model/maintenance.rs -e9a17446038508275e72a85bf2a4fdc03aae1f8da98433f57de902a8aa22171b src/cache/model/settings.rs -c2efe75f740ba31e83c49bf76e7fc179e897f41c140eea700e2675ae648e4a1c src/cache/model/views.rs +19aaf66abe2ad30b973131a020b7ca8ec6d0646a51df5c604c5f8914db898416 src/cache/model/settings.rs +395a82091ae520b78fa606553a0ec590ffb14540b83234cef5bbdcd2e57d39d5 src/cache/model/views.rs e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs -b6dd187fe9a6f540653c9b7a0bac4e789f1c606b58bf99691e027bce776322ea src/cache/policy/request.rs -130ebd918dace9552eee4f9633b8ad3fe5a1b7945bb00ae6f6683017cf10f527 src/cache/policy/response.rs +896b1869f94973b30a0de0faa62a2c89fbc8991b02de0e5988ab4ce1f77b392d src/cache/policy/request.rs +9dd790a35dbc15df8465664ed9fff296fe22847371d667aefe320504ab952b2b src/cache/policy/response.rs e59efd322f518ebd7b5b154115188f382c2cf7882ca0a401736ba5d685981ea5 src/cache/policy/scope_tests.rs 2b556ac9179b8a92de368f5e907e4114c2fc21a89155fe2a05665c45c08b3883 src/cache/range.rs e1d3f0512d6290b0ac4998f47398a0a7c404db249c5bbdbc4ad4767071013a72 src/cache/stats.rs 8a300f5dad44815401eb23f4f5a157893761852ba4f3005041e0eb3f16892ce3 src/cache/tests.rs -d0f7a9726c0a06553c91ca4d29eca207e9a9c3201fab371bec9dc5f6457ba12e src/cache/tickets.rs +895b589b9866fafafce9f464c4a0d3d2ced55989a1d909bc152ab1529c2a1550 src/cache/tickets.rs b7c063ed4c8ca96714e1dc5d279f38fc521da2132faad7bd4f40311352a71e7c src/cache/v022_cache_tests.rs -6e503de08f3ea67608e534f5ceff58173eb2351d2fa7777e8fb646ed1c5bb7ce src/cache/varnish_compatible_tests.rs +452ff2f162f70c3e4d9259de4b13a7751874742c6922c5e0e0196eeadee4ccaa src/cache/varnish_compatible_tests.rs 5a8022f6f4721f9fabdc715e01127d9299d47ee9981a79d26df1981448487d20 src/compression/mod.rs 2bb1ea392dc18553a03a6ae1c53f52712ae86571ea31b1bcd93c8fdec2cb60ec src/compression/tests.rs e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs @@ -375,15 +375,15 @@ d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/cach a03b1735daa8fa8e40cf5bd9c6e200d211baf19c30f62522d4237ed56642bce7 src/proxy/cache_plan/mod.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs a0b9e2e9ab0bc89c7b64bd19365198234b1c5c13f455ea47be122a5660639b17 src/proxy/error_fallback.rs -fee36419c7e308cedff3b73ed1e8c5fab1fa054aaa6e0ccc016dd0b043c32c42 src/proxy/forward/cache_fast_path_tests.rs -a20cd11a504e423e5b1af5cebb5b4186530d4eaacaa02c3212a1174cdbfb6498 src/proxy/forward/cache_lookup.rs +34a5c28cf8b719d39561bd0fc28f3ae40505d6ca8fc6b1532e5ea9ce318e7363 src/proxy/forward/cache_fast_path_tests.rs +6b1ae807b0efa480207d9c5baf6e86b1f9dc6becb52600f14e4d1a84e8efcda1 src/proxy/forward/cache_lookup.rs 287012c81144d8f15af4a148ce3ca911d431bc59adff44e697567561aa372bb3 src/proxy/forward/h3_navigation_tests.rs ce97cfcb07d90415937f0520ee7175502b22e60694c1ea8e3d2b47678d190e5f src/proxy/forward/response.rs -f3c3b8c979c06bd84a72969f2908be1052097e32f3a21f0494dbd3ce72f17796 src/proxy/forward/route_cache.rs +1a7322aadbe90e2a6940a27882c13e274b1c92f14de3ebad717054a9614d4ba8 src/proxy/forward/route_cache.rs 3f91a40a01378d1fbac56ec376df6626e0c4fb6c3b8c8d42583b9c398d368ad9 src/proxy/forward/success_finish.rs aa30766a5d584c2143488f16caef19c1d369a2c7c00b5ff960eaf2ccf4045c31 src/proxy/forward/success_prepare.rs e9ac9be64f6f83c4ca2cd5d299649f24dd16197712083c6dc10e55c662c65b80 src/proxy/forward/upstream.rs -de548bd191c57b76b595f0a932195d303b43355982080a185c44f1973d00702c src/proxy/forward/web_source.rs +b7b46ef0c530a0e9d541238e50ec848d80c799b363fed30e0acbccdd459b0ffa src/proxy/forward/web_source.rs 3340583d395fcc859cf3421f4e33cf8347915ee4c468f7d0e4abebce8156fdca src/proxy/forward_entry.rs 9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs 5b07b98d855453d5a4272f5fc98a75973c3e6032416e0fe4f7f151c1bf239fd8 src/proxy/headers/authority_tests.rs diff --git a/docs/CACHE.md b/docs/CACHE.md index cb4e9c7..b2dd498 100644 --- a/docs/CACHE.md +++ b/docs/CACHE.md @@ -12,24 +12,29 @@ RustProxy provides an optional shared HTTP response cache with a bounded in-memo ## Varnish-compatible cache policy (0.34.7) -**Settings → Cache → Varnish-compatible cache policy** (`cache.varnish_compatible`, **on by default**) changes *request-side* cache lookup, not authentication or the cache key. Disabling it restores the earlier strict policy without a service restart. **Changing this policy invalidates cached representations** to avoid mixing storage semantics across modes; allow time for HOT/COLD to refill. Effective settings also apply to eligible embedded Web Server resources and HOT/COLD entries. Existing JSON configs with no new field inherit `true`. +**Settings → Cache → Varnish-compatible cache policy** (`cache.varnish_compatible`, **enabled by default**) avoids needless cache replacement on browser hard reloads. On a fresh object it ignores only the client's `Cache-Control: no-cache`, `max-age=0` and `Pragma: no-cache` for cache **lookup/freshness**. It does not remove or modify these request headers when fetching from the origin, and it does not alter cache identity or request conditionals. -| Condition | Enabled: Varnish-style | Disabled: legacy strict | +Set **Default grace (seconds)** in Settings → Cache (default **60**, range 0..604800). This affects only eligible objects in compatibility mode and never overwrites explicit origin `stale-while-revalidate`, including `=0`. Use the adjacent **i** information icon for the short operator explanation (PL/EN). Disabling the option restores the original strict client reload semantics. Changing modes invalidates currently stored representations across HOT/COLD so they are refilled under one consistent policy; no process restart is needed. Existing JSON configurations with no field inherit `true`. The setting also applies to eligible resources of the embedded Web Server. + +| Condition | Enabled: Varnish-style optimized, RustProxy protections retained | Disabled: legacy strict | |---|---|---| -| Client `Cache-Control: no-cache`, `no-store`, `max-age=0`, `min-fresh`, `max-stale` | Ignore client cache directives for lookup and freshness; a **fresh** object stays HIT and keeps `Age` and hit count | Revalidate/bypass or constrain freshness as instructed by the client | -| Client `Pragma: no-cache`, `only-if-cached` | Ignore both; do not force backend or a synthetic 504 purely because of these directives | Revalidate or give 504 for a missing only-if-cached representation | -| Origin `Cache-Control: no-cache` | Do not store, like Varnish's built-in VCL | Store only with a validator and require revalidation per request | -| Origin `Surrogate-Control: no-store` | Do not store | Existing RustProxy behavior | -| Origin zero/nonpositive *remaining* TTL | Do not store | May retain a zero-TTL representation for revalidation; never a fresh HIT | -| Expired but eligible object | Allow up to 10 seconds of background-refresh grace (or explicit larger `stale-while-revalidate`), unless `must-revalidate` / `proxy-revalidate` forbids stale delivery | Only explicitly configured stale/grace conditions | -| Origin `private`, `no-store`, `Set-Cookie`, `Vary: *`; client Authorization | Existing RustProxy restrictions **always** apply | Same | -| Static-file cookie-ignore allowlist, custom bypass/force rules, TTL caps, BAN/PURGE, request collapse, compression/transform, HOT/COLD, tenant isolation | Unchanged RustProxy mechanisms | Same | +| Client `no-cache`, `max-age=0`, `Pragma: no-cache` | **Fresh HIT** without replacing object or resetting `Age` / HIT counter | Can force origin revalidation (and `200` replacement without an origin validator) | +| Client `no-store` | **BYPASS**: do not use or populate a shared representation | Same | +| Client `only-if-cached` | **Fresh/stale eligible HIT**, or **504** if no reusable representation; never fetch origin on MISS | Same | +| Client `min-fresh`, `max-stale`, positive `max-age` | Keep constraints; an otherwise fresh object can be rejected by `min-fresh`, while eligible stale entries can be served by `max-stale` | Same | +| Origin `no-cache` with `ETag`/`Last-Modified`, fallback TTL = `0` | Store for **conditional revalidation** (`304`) but never fresh-HIT without checking origin | Same | +| Origin **`public, no-cache`** and explicit fallback TTL `> 0` | Treat the administrator's configured **Fallback TTL for public responses** as shared freshness (capped by max TTL), even when the origin has no validator; this explicitly overrides origin `no-cache` | Validator-only revalidation, no override | +| Origin `max-age=0` / already expired, with `stale-if-error`, a validator or client `max-stale` | Retain the expired representation; it does not become a fresh HIT. Can serve stale on an eligible error, revalidate, or honor explicit client allowance | Same | +| Origin `Surrogate-Control: no-store` | Do not store, even when a fallback TTL is configured; this is an additional compatibility-mode safeguard | Legacy response policy | +| Origin `private`, `no-store`, `Set-Cookie`, invalid `Vary` | Must not enter shared cache; configured static-cookie exceptions do not override response privacy | Same | +| Eligible cached object after TTL | Implicit background-refresh grace up to **60 seconds by default** (configurable with `cache.varnish_grace_seconds`, `0` disables it) **only** for entries initially fresh with **no** origin `stale-while-revalidate` directive; explicit `stale-while-revalidate=0` disables implicit grace. Existing `must-revalidate` / `proxy-revalidate` restrictions still apply | No implicit grace | +| Cookies, Authorization, `Vary`, per-vhost exclusions/force, query/range restrictions, BAN/PURGE, request collapse, transforms, compression, HOT/COLD | Existing RustProxy mechanisms remain in effect | Same | -For `/ui/style.css` with `Cache-Control: public, max-age=600` and **no** origin ETag/Last-Modified, a hard reload with `Cache-Control: no-cache` should now remain `HIT` while its cached representation is fresh. The request headers are *not* stripped before forwarding when an origin fetch actually occurs; RustProxy only ignores them for its own lookup/freshness checks. Client `If-None-Match`/`If-Modified-Since`, range conditions, URL query, `Host` and `Vary` are preserved. Strict mode remains available for installations requiring clients to force validation. +**Important:** using a positive fallback TTL to reuse an origin response marked `public, no-cache` is an **explicit administrative freshness override**. It can serve outdated content for that duration; leave fallback TTL at `0` if the backend's `no-cache` must always be honored. The fallback override applies only to responses carrying `public`, not to private/no-store/Set-Cookie responses or `Surrogate-Control: no-store`. The configured `max_ttl_seconds` still caps remaining freshness. Without fallback TTL, origin `no-cache` still requires an origin validator to be stored. An origin `max-age=0` is never made fresh merely because `stale-if-error` is present. -This is **a documented policy subset, not a Varnish binary/VCL clone**. RustProxy deliberately keeps an explicit zero-by-default fallback TTL (no implicit Varnish 120s TTL), configured cookie exceptions, multi-tenant security, stale prohibitions for `must-revalidate`, and no Varnish-style hit-for-miss/hit-for-pass markers. Not every Varnish configuration behaves like the default built-in VCL. Cookies continue to be omitted from the cache key *only* for the already-configured static-file exception; do not enable that exception for personalized static endpoints. A no-cache **origin response** must not be made cacheable merely to gain HITs. +This is **a Varnish-inspired policy subset, not full Varnish binary/VCL compatibility**. RustProxy deliberately keeps an explicit zero-by-default fallback TTL instead of Varnish's implicit 120-second default, retains client controls beyond hard reload, enforces multi-tenant and cookie/Authorization safety rules, and does not implement hit-for-miss/hit-for-pass markers. The current compatibility-specific `Surrogate-Control` safeguard handles `no-store`; it does **not** interpret every Surrogate-Control extension or use Surrogate-Control `max-age` as a TTL override. Different VCL configurations can behave differently. -Use a three-request GET comparison with `curl` (replace URL with your static resource): +To verify with real GET requests (not `curl -I -X GET`): ```bash URL=https://example.com/ui/style.css @@ -38,7 +43,7 @@ curl -sS --compressed -H 'Cache-Control: no-cache' -H 'Pragma: no-cache' -D - -o curl -sS --compressed -D - -o /dev/null "$URL" | grep -iE '^(HTTP/|age:|x-cache:|x-cache-hits:|x-cache-reason:)' ``` -Enable diagnostic headers if needed. With compatibility **on**, the middle response should be `HIT`, and Age should not reset on a fresh object. With compatibility **off**, it can be `MISS` and reset if no origin validator is available. Explicit PURGE/BAN and stale/expired TTL remain authoritative. +With the policy enabled, the middle request stays `HIT` if the entry is fresh. Check safeguards separately: an uncached URL with `Cache-Control: only-if-cached` must return **504** without reaching the backend; a stale `only-if-cached` HIT must **not** start background validation/proactive refresh. `min-fresh` and `max-stale` still constrain stale reuse: an explicit `max-stale=0` must not be bypassed by the configured implicit grace. `Cache-Control: no-store` must bypass an existing fresh entry. ## Vhost editor options diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 10d89d0..407e264 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -2,7 +2,7 @@ Most settings are managed in the authenticated panel. Save changes after validation; use **Configuration history** to compare or restore an earlier revision. Some listeners are process-owned and require restart to change. Configuration updates are not a replacement for encrypted full backups. -> Cache compatibility: **Settings → Cache → Varnish-compatible cache policy** (`cache.varnish_compatible`) defaults to `true` in 0.34.7, also for configs without the field. Turning it off restores legacy client freshness handling. See [HTTP cache](CACHE.md#varnish-compatible-cache-policy-0347) for semantics and safety exceptions. +> Cache compatibility: **Settings → Cache → Varnish-compatible cache policy** (`cache.varnish_compatible`) defaults to `true` in 0.34.7, also for configs without the field. Turning it off restores legacy client freshness handling. Implicit grace defaults to **60 seconds** (`cache.varnish_grace_seconds`, editable in Settings → Cache). See [HTTP cache](CACHE.md#varnish-compatible-cache-policy-0347) for semantics and safety exceptions. ## Configuration storage: SQLite or JSON file diff --git a/langs/en_US.json b/langs/en_US.json index 5abd1e6..33c7fc4 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -289,8 +289,13 @@ "saveResponse": "Apply response settings", "cacheSettings": "Cache", "enableCache": "Enable cache", + "cacheVarnishGrace": "Default grace (seconds, 0 = off)", "cacheVarnishCompatible": "Varnish-compatible cache policy (recommended; enabled by default)", - "cacheVarnishCompatibleHelp": "On: ignore client Cache-Control/Pragma when looking up cached objects. Shift+F5, no-cache, max-age=0 and only-if-cached cannot force an origin fetch while a fresh HIT exists. Respect origin private/no-store/no-cache and Surrogate-Control no-store; do not cache responses with zero remaining TTL; permit up to 10 s grace for eligible stale entries. Keep RustProxy static-cookie exceptions, Authorization, Vary, explicit TTL, invalidation, transforms and HOT/COLD unchanged. Off: original strict client cache directives. Changing modes invalidates existing cached objects. This is not full Varnish VCL emulation.", + "cacheVarnishCompatibleHelp": "A browser hard reload (Shift+F5, Cache-Control: no-cache or max-age=0, Pragma: no-cache) keeps a fresh cache HIT, including Age and the hit count.", + "cacheVarnishCompatibleInfoLabel": "Varnish-compatible cache behavior", + "cacheVarnishCompatibleSafeguards": "Still honors client no-store, only-if-cached (504 on MISS, no origin access or background refresh), min-fresh and max-stale (including max-stale=0 during grace). Cookie exceptions for static files, Authorization, Vary, PURGE/BAN, HOT/COLD and transforms work as before.", + "cacheVarnishCompatibleBackendHelp": "Origin no-cache with ETag/Last-Modified remains revalidatable. Setting a positive fallback TTL explicitly permits public no-cache responses to be reused for that TTL, even without validators. Origin private/no-store/Set-Cookie and Surrogate-Control: no-store are never overridden. Zero-TTL entries may still provide stale-if-error or validator revalidation.", + "cacheVarnishCompatibleModeHelp": "Grace defaults to 60 seconds and can be changed in the field below. It only applies to eligible fresh-at-storage objects without an origin stale-while-revalidate directive; explicit =0 is honored. Off restores strict client reload semantics. Switching modes invalidates existing cached entries. This is a compatibility subset, not full Varnish VCL.", "cacheSafety": "Complete public-safe GET responses are cached. Authorization, cookies and private/no-store responses still bypass. One byte range can be served from a complete cached object; multipart/unsupported ranges pass through.", "cacheMemory": "HOT allocated budget (MiB)", "cacheObject": "Maximum object (KiB)", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 39877fe..5646ef0 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -289,8 +289,13 @@ "saveResponse": "Zastosuj ustawienia odpowiedzi", "cacheSettings": "Cache", "enableCache": "Włącz cache", + "cacheVarnishGrace": "Domyślny grace (sekundy, 0 = wył.)", "cacheVarnishCompatible": "Polityka cache zgodna z Varnish (zalecana; domyślnie włączona)", - "cacheVarnishCompatibleHelp": "Włączona: ignoruje Cache-Control/Pragma klienta przy odczycie cache. Shift+F5, no-cache, max-age=0 i only-if-cached nie wymuszają pobrania z backendu, jeśli obiekt jest świeży. Nadal blokuje cache dla private/no-store/no-cache i Surrogate-Control: no-store backendu oraz odpowiedzi bez pozostałego TTL. Dla uprawnionych wygasłych obiektów dopuszcza do 10 s grace z odświeżaniem w tle. Zachowuje wyjątki cookies plików statycznych, Authorization, Vary, limity TTL, unieważnianie, transformacje i HOT/COLD. Wyłączona: dotychczasowa ścisła polityka dyrektyw klienta. Zmiana trybu unieważnia aktualne obiekty cache. To nie jest pełna emulacja VCL Varnish.", + "cacheVarnishCompatibleHelp": "Twarde odświeżenie przeglądarki (Shift+F5, Cache-Control: no-cache lub max-age=0, Pragma: no-cache) nie wymusza odświeżenia świeżego wpisu HIT; Age i licznik trafień pozostają zachowane.", + "cacheVarnishCompatibleInfoLabel": "Zachowanie cache w trybie zgodnym z Varnish", + "cacheVarnishCompatibleSafeguards": "Nadal działają: no-store klienta, only-if-cached (504 przy MISS; bez żądania do backendu, także w tle), min-fresh i max-stale (także max-stale=0 przy grace). Zachowane są wyjątki cookies dla statycznych plików, Authorization, Vary, PURGE/BAN, HOT/COLD i transformacje.", + "cacheVarnishCompatibleBackendHelp": "Odpowiedzi no-cache backendu z ETag/Last-Modified nadal mogą być rewalidowane. Dodatni zapasowy TTL świadomie pozwala cache’ować publiczne odpowiedzi no-cache przez wskazany czas, także bez walidatora. Nie nadpisuje private/no-store/Set-Cookie ani Surrogate-Control: no-store. Wpisy z TTL=0 mogą nadal obsługiwać stale-if-error i rewalidację.", + "cacheVarnishCompatibleModeHelp": "Grace wynosi domyślnie 60 s i można go ustawić w polu poniżej. Dotyczy tylko kwalifikujących się wpisów zapisanych jako świeże, bez dyrektywy stale-while-revalidate; jawne =0 jest respektowane. Wyłączenie przywraca ścisłe odświeżanie przez klienta. Zmiana trybu unieważnia obecne wpisy. Nie jest to pełna emulacja VCL Varnish.", "cacheSafety": "Cache zapisuje kompletne, bezpieczne odpowiedzi GET. Authorization, cookies oraz odpowiedzi private/no-store nadal omijają cache. Pojedynczy zakres bajtów może być obsłużony z pełnego obiektu cache; multipart i nieobsługiwane zakresy przechodzą do originu.", "cacheMemory": "Limit alokowanej pamięci HOT (MiB)", "cacheObject": "Limit obiektu (KiB)", diff --git a/src/admin/openapi.json b/src/admin/openapi.json index a7d1f4c..d0c7c12 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2062,10 +2062,17 @@ }, "default": [] }, + "varnish_grace_seconds": { + "type": "integer", + "minimum": 0, + "maximum": 604800, + "default": 60, + "description": "Implicit grace window in seconds for eligible positive-TTL objects in Varnish-compatible mode without an explicit origin stale-while-revalidate directive. Zero disables implicit grace. Explicit origin stale-while-revalidate, including =0, takes precedence." + }, "varnish_compatible": { "type": "boolean", "default": true, - "description": "Default true: Varnish-style request cache policy (ignore client Cache-Control/Pragma directives including no-cache, max-age=0, only-if-cached); reject origin no-cache, Surrogate-Control no-store and nonpositive remaining TTL; 10-second stale-while-revalidate grace for eligible stored objects. Existing RustProxy privacy, cookies, Vary, explicit TTL and invalidation options remain in force. False restores strict client-cache directive handling; switching modes invalidates cache; not full VCL emulation." + "description": "Default true: ignore only client hard-reload no-cache, max-age=0 and Pragma: no-cache on fresh cache lookups. Preserve client no-store, only-if-cached (504 on MISS), min-fresh/max-stale, origin ETag revalidation and zero-TTL stale-if-error storage. A positive default_ttl_seconds explicitly allows public origin no-cache responses to be reused for that fallback TTL, even without validators; zero keeps validator-only revalidation. Surrogate-Control: no-store and all private/no-store/Set-Cookie/Vary restrictions remain active. Eligible positive-TTL entries without an explicit stale-while-revalidate directive get configurable grace (varnish_grace_seconds, default 60s; explicit =0 is honored). False restores strict browser reload. Switching modes invalidates cache; not full Varnish VCL emulation." } }, "additionalProperties": false diff --git a/src/admin/web/app/settings.js b/src/admin/web/app/settings.js index b7a0c69..ac3bf52 100644 --- a/src/admin/web/app/settings.js +++ b/src/admin/web/app/settings.js @@ -11,7 +11,7 @@ function renderSettings() { $("compress-min").value = compression.min_bytes ?? 1024; syncServerMode(); } if (!settingsDirty.has("cache-form")) { - const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-varnish-compatible").checked = c.varnish_compatible !== false; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576; + const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-varnish-compatible").checked = c.varnish_compatible !== false; $("cache-varnish-grace").value = c.varnish_grace_seconds ?? 60; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576; $("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576; $("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096; $("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64; @@ -286,7 +286,7 @@ bindSettingsForm("response-form", next => { next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked; }, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression })); bindSettingsForm("cache-form", next => { - next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, varnish_compatible: $("cache-varnish-compatible").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null }; + next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, varnish_compatible: $("cache-varnish-compatible").checked, varnish_grace_seconds: Number($("cache-varnish-grace").value), memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null }; }, "cache"); bindSettingsForm("transform-form", next => { next.transform = { diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 9c01854..70ce7f0 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -2210,9 +2210,24 @@ Authorization, cookies and private/no-store responses still bypass the cache. One byte range can be served from a complete cached object; multipart/unsupported ranges pass through.

- -

When enabled, client Cache-Control and Pragma are ignored for cache lookup: Shift+F5/no-cache/max-age=0 does not replace a fresh HIT or reset its Age. Response private/no-store/no-cache and Surrogate-Control no-store stay uncacheable, responses with no remaining TTL stay out of cache, and eligible expired objects may use up to 10 seconds of background-refresh grace. Existing static-cookie exceptions, Authorization protection, Vary, explicit TTL limits, BAN/PURGE, HOT/COLD, transforms and unsafe-force rules remain independent. Off restores the previous strict client HTTP caching policy. Changing modes invalidates stored objects. This is not full Varnish VCL emulation.

+
+ +
+ i +
+

A browser Shift+F5 (no-cache, max-age=0 or Pragma: no-cache) uses a fresh cache HIT without replacing it or resetting Age.

+

Preserves client no-store, only-if-cached (504 on MISS; no origin contact or background refresh), min-fresh and max-stale (including max-stale=0 during grace); existing cookie rules, Authorization, Vary, PURGE/BAN, HOT/COLD and transformations stay active.

+

Origin no-cache with a validator remains revalidatable. If an explicit fallback TTL is set, public no-cache responses may instead be reused for that TTL, even without a validator. Private/no-store/Set-Cookie and Surrogate-Control: no-store remain blocked. Zero-TTL objects may be retained for stale-if-error or revalidation; explicit stale-while-revalidate=0 is respected.

+

Implicit grace (60 seconds by default, configurable below) only applies to eligible fresh-at-storage responses without an explicit stale-while-revalidate directive. Off restores strict client reload semantics. Changing the mode invalidates existing cached objects. This is not full Varnish VCL emulation.

+
+
+
+
+ + +

Memory sizing

diff --git a/src/admin/web/style.css b/src/admin/web/style.css index c00c054..e552dba 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -5580,6 +5580,13 @@ dialog .window-heading { white-space: normal } +/* Nested Settings tab bars must scroll with their content. The global sticky + rule otherwise lets cache subtabs paint across checkboxes while scrolling. */ +#panel-settings .settings-tabs.sub-tabs { + position: static; + top: auto; +} + #panel-settings .settings-pages { min-width: 0 } @@ -9387,6 +9394,51 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { white-space: nowrap; } +.cache-varnish-setting { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + position: relative; +} + +.cache-varnish-setting .check { + min-width: 0; + flex: 0 1 auto; + margin: 0 !important; +} + +.cache-varnish-info { + position: static; + flex-shrink: 0; +} + +.cache-varnish-grace-field { + width: min(320px, 100%); + min-width: 0; + margin-top: 12px; +} + +.cache-varnish-grace-field label { + display: block; +} + +.cache-varnish-grace-field input { + display: block; + width: 100%; + box-sizing: border-box; +} + +.cache-varnish-info .metric-info-popover { + width: calc(min(440px, 100vw - 56px)); + max-height: calc(min(65vh, 450px)); + overflow-y: auto; +} + +.cache-varnish-info .metric-info-popover .fine + .fine { + margin-top: 8px; +} + .cache-maintenance-help { display: flex; align-items: center; diff --git a/src/cache/model/entry.rs b/src/cache/model/entry.rs index f2cf9fc..fc38f0b 100644 --- a/src/cache/model/entry.rs +++ b/src/cache/model/entry.rs @@ -61,6 +61,10 @@ impl Settings { valid_relative_cache_path(&self.cold_path), "cache.cold_path must be a simple relative path inside the data directory" ); + ensure!( + self.varnish_grace_seconds <= 604_800, + "cache.varnish_grace_seconds must be 0..604800" + ); ensure!( (1..=604_800).contains(&self.max_ttl_seconds) && self.default_ttl_seconds <= self.max_ttl_seconds, diff --git a/src/cache/model/settings.rs b/src/cache/model/settings.rs index 1d914ce..c14a18a 100644 --- a/src/cache/model/settings.rs +++ b/src/cache/model/settings.rs @@ -89,6 +89,8 @@ pub struct Settings { pub enabled: bool, /// Ignore client freshness directives on cache lookups, like Varnish builtin VCL. pub varnish_compatible: bool, + /// Implicit grace in seconds for eligible fresh-at-storage cached responses. + pub varnish_grace_seconds: u64, pub memory_preset: Option, pub max_bytes: usize, pub max_object_bytes: usize, @@ -143,6 +145,7 @@ impl Default for Settings { Self { enabled: true, varnish_compatible: true, + varnish_grace_seconds: 60, memory_preset: None, max_bytes: 256 * 1024 * 1024, max_object_bytes: 16 * 1024 * 1024, diff --git a/src/cache/model/views.rs b/src/cache/model/views.rs index 297c6e9..8a6a7cd 100644 --- a/src/cache/model/views.rs +++ b/src/cache/model/views.rs @@ -132,13 +132,18 @@ fn stale_mode( return None; } let stale_for = age.saturating_sub(ttl); + let max_stale = client_max_stale(request_headers); + // An explicit client limit must also constrain implicit Varnish grace. + // In particular, max-stale=0 must not serve a stale representation. + if matches!(max_stale, Some(Some(limit)) if stale_for > limit) { + return None; + } if stale_while_revalidate > 0 && stale_for <= stale_while_revalidate { return Some(true); } - match client_max_stale(request_headers) { - Some(None) => Some(false), - Some(Some(limit)) if stale_for <= limit => Some(false), - _ => None, + match max_stale { + Some(None) | Some(Some(_)) => Some(false), + None => None, } } diff --git a/src/cache/policy/request.rs b/src/cache/policy/request.rs index 6295a2d..691cbdb 100644 --- a/src/cache/policy/request.rs +++ b/src/cache/policy/request.rs @@ -44,19 +44,89 @@ fn directives(headers: &HeaderMap) -> Option>> { } Some(result) } +/// Keep all client cache restrictions except the hard-reload directives that +/// Varnish normally disregards for a fresh shared object. Never mutate the +/// actual request (it is still forwarded to the origin and used for Vary). +fn compatible_freshness_headers<'a>( + headers: &'a HeaderMap, + compatible: bool, +) -> std::borrow::Cow<'a, HeaderMap> { + if !compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) { + return std::borrow::Cow::Borrowed(headers); + } + // An invalid cache-control header cannot be made valid by filtering it. + // Also avoid cloning the full map for ordinary requests with no reload + // directives, including min-fresh, max-stale and only-if-cached. + let Some(control) = directives(headers) else { + return std::borrow::Cow::Borrowed(headers); + }; + let remove_reload = control.contains_key("no-cache") + || control.get("max-age").and_then(|value| value.as_deref()) + .and_then(|value| value.parse::().ok()) == Some(0); + if !remove_reload && !pragma_no_cache(headers) { + return std::borrow::Cow::Borrowed(headers); + } + let mut freshness = headers.clone(); + let mut changed = false; + if pragma_no_cache(headers) { + // Pragma only affects freshness through its `no-cache` token in this + // implementation. Preserve other tokens, if any. + freshness.remove("pragma"); + for header in headers.get_all("pragma") { + let Ok(text) = header.to_str() else { + return std::borrow::Cow::Borrowed(headers); + }; + let retained = text.split(',') + .map(str::trim) + .filter(|token| !token.eq_ignore_ascii_case("no-cache")) + .collect::>(); + if !retained.is_empty() { + let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else { + return std::borrow::Cow::Borrowed(headers); + }; + freshness.append("pragma", value); + } + } + changed = true; + } + if headers.contains_key("cache-control") { + let mut retained_values = Vec::new(); + for header in headers.get_all("cache-control") { + let Ok(text) = header.to_str() else { + return std::borrow::Cow::Borrowed(headers); + }; + let retained = text.split(',').map(str::trim).filter(|token| { + let (name, value) = token.split_once('=').unwrap_or((token, "")); + let name = name.trim(); + let skip = name.eq_ignore_ascii_case("no-cache") + || (name.eq_ignore_ascii_case("max-age") + && value.trim().trim_matches('"').parse::() == Ok(0)); + if skip { changed = true; } + !skip + }).collect::>(); + if !retained.is_empty() { + let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else { + return std::borrow::Cow::Borrowed(headers); + }; + retained_values.push(value); + } + } + if changed { + freshness.remove("cache-control"); + for value in retained_values { + freshness.append("cache-control", value); + } + } + } + if changed { std::borrow::Cow::Owned(freshness) } + else { std::borrow::Cow::Borrowed(headers) } +} + pub fn request_freshness_headers<'a>( headers: &'a HeaderMap, settings: &Settings, ) -> std::borrow::Cow<'a, HeaderMap> { - if !settings.varnish_compatible - || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) - { - return std::borrow::Cow::Borrowed(headers); - } - let mut freshness = headers.clone(); - freshness.remove("cache-control"); - freshness.remove("pragma"); - std::borrow::Cow::Owned(freshness) + compatible_freshness_headers(headers, settings.varnish_compatible) } pub fn only_if_cached(headers: &HeaderMap) -> bool { @@ -173,25 +243,22 @@ pub fn request_policy( return RequestPolicy::Bypass(reason); } } - // Varnish builtin.vcl performs a cache lookup without interpreting - // client Cache-Control or Pragma. Security bypasses above still apply. - if settings.varnish_compatible { - return RequestPolicy::Use; - } + // Compatibility only relaxes browser hard-reload freshness requests. + // Retain no-store, only-if-cached, min-fresh and max-stale semantics. let Some(control) = directives(headers) else { return RequestPolicy::Bypass("request_cache_control_invalid"); }; if control.contains_key("no-store") { return RequestPolicy::Bypass("request_no_store"); } - if control.contains_key("no-cache") { + if !settings.varnish_compatible && control.contains_key("no-cache") { return RequestPolicy::Revalidate("request_no_cache"); } if let Some(value) = control.get("max-age") { let Some(value) = value.as_deref().and_then(|value| value.parse::().ok()) else { return RequestPolicy::Bypass("request_cache_control_invalid"); }; - if value == 0 { + if value == 0 && !settings.varnish_compatible { return RequestPolicy::Revalidate("request_max_age"); } } @@ -209,7 +276,10 @@ pub fn request_policy( return RequestPolicy::Bypass("request_cache_control_invalid"); } } - if !headers.contains_key("cache-control") && pragma_no_cache(headers) { + if !settings.varnish_compatible + && !headers.contains_key("cache-control") + && pragma_no_cache(headers) + { return RequestPolicy::Revalidate("request_pragma_no_cache"); } RequestPolicy::Use @@ -404,13 +474,8 @@ pub fn response_reason( return "response_cache_control"; } } - if settings.varnish_compatible { - if control.contains_key("no-cache") { - return "response_cache_control"; - } - if surrogate_no_store(headers) { - return "response_surrogate_control"; - } + if settings.varnish_compatible && surrogate_no_store(headers) { + return "response_surrogate_control"; } for value in headers.get_all("vary") { if value.to_str().ok().is_none_or(|value| { diff --git a/src/cache/policy/response.rs b/src/cache/policy/response.rs index 8ea63ae..f884f2b 100644 --- a/src/cache/policy/response.rs +++ b/src/cache/policy/response.rs @@ -92,15 +92,19 @@ pub fn plan( { return None; } - // Varnish builtin.vcl treats these as uncacheable origin responses - // (hit-for-miss is not implemented by RustProxy). Never weaken private - // or no-store handling even if Surrogate-Control is also present. - if settings.varnish_compatible - && (control.contains_key("no-cache") || surrogate_no_store(headers)) - { + // Surrogate-Control: no-store is a shared-cache veto in compatibility + // mode, regardless of public/fallback TTL or any other directive. + if settings.varnish_compatible && surrogate_no_store(headers) { return None; } - let requires_revalidation = control.contains_key("no-cache"); + // Explicitly configured fallback TTL is an opt-in origin no-cache + // override *only* for public responses. With fallback disabled, keep + // the pre-0.34.7 validator-based revalidation and never grant a HIT. + let override_public_no_cache = settings.varnish_compatible + && control.contains_key("no-cache") + && control.contains_key("public") + && settings.default_ttl_seconds > 0; + let requires_revalidation = control.contains_key("no-cache") && !override_public_no_cache; let must_revalidate = control.contains_key("must-revalidate"); let proxy_revalidate = control.contains_key("proxy-revalidate"); let parse_window = |name: &str| -> Option { @@ -124,7 +128,9 @@ pub fn plan( None => now, }; let declared_ttl = - if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) { + if override_public_no_cache { + Some(settings.default_ttl_seconds) + } else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) { Some(value.as_ref()?.parse::().ok()?) } else if let Some(expires) = headers.get("expires") { Some( @@ -156,17 +162,17 @@ pub fn plan( None if requires_revalidation => 0, None => return None, }; - // Like Varnish's built-in VCL, a backend response with no remaining - // freshness is not a reusable shared object (Varnish stores a hit-for-miss - // marker instead; RustProxy does not implement those markers). - if settings.varnish_compatible && initial_age.as_secs() >= ttl { - return None; - } - // Varnish defaults to ten seconds of grace after a *positive* TTL. - // Keep RustProxy's existing must-revalidate/proxy-revalidate safety checks; - // never turn a no-cache or zero-TTL response into a grace HIT. - if settings.varnish_compatible && stale_while_revalidate == 0 { - stale_while_revalidate = 10; + // Retain zero-TTL and already-expired representations for conditional + // revalidation, client max-stale or stale-if-error. They must not be + // served as fresh HITs. Varnish-style grace is only implicit when the + // origin did not explicitly set stale-while-revalidate (including =0), + // the object arrived fresh and no origin no-cache rule is in effect. + if settings.varnish_compatible + && !control.contains_key("stale-while-revalidate") + && !control.contains_key("no-cache") + && initial_age.as_secs() < ttl + { + stale_while_revalidate = settings.varnish_grace_seconds; } if !requires_revalidation && initial_age.as_secs() diff --git a/src/cache/tickets.rs b/src/cache/tickets.rs index 2eac134..313b42a 100644 --- a/src/cache/tickets.rs +++ b/src/cache/tickets.rs @@ -395,16 +395,12 @@ impl Cache { } impl Ticket { - /// In Varnish compatibility mode, request cache directives do not constrain - /// freshness. Keep original headers for Vary and downstream conditionals. + /// Ignore only browser forced reload for freshness; keep min-fresh, + /// max-stale and other client constraints, plus original Vary headers. pub fn with_varnish_compatible(mut self, compatible: bool) -> Self { - if compatible - && (self.request_headers.contains_key("cache-control") - || self.request_headers.contains_key("pragma")) + if let std::borrow::Cow::Owned(freshness) = + compatible_freshness_headers(&self.request_headers, compatible) { - let mut freshness = self.request_headers.clone(); - freshness.remove("cache-control"); - freshness.remove("pragma"); self.freshness_headers = Some(freshness); } self diff --git a/src/cache/varnish_compatible_tests.rs b/src/cache/varnish_compatible_tests.rs index e12b714..1cb00eb 100644 --- a/src/cache/varnish_compatible_tests.rs +++ b/src/cache/varnish_compatible_tests.rs @@ -11,40 +11,56 @@ mod varnish_compatible_tests { #[test] fn default_and_legacy_serialization_are_explicit() { assert!(Settings::default().varnish_compatible); + assert_eq!(Settings::default().varnish_grace_seconds, 60); let legacy: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap(); assert!(legacy.varnish_compatible); + assert_eq!(legacy.varnish_grace_seconds, 60); let strict: Settings = serde_json::from_str(r#"{"varnish_compatible":false}"#).unwrap(); assert!(!strict.varnish_compatible); let enabled = serde_json::to_value(&legacy).unwrap(); assert_eq!(enabled["varnish_compatible"], true); + assert_eq!(enabled["varnish_grace_seconds"], 60); + let customized: Settings = serde_json::from_str(r#"{"varnish_grace_seconds":17}"#).unwrap(); + assert_eq!(customized.varnish_grace_seconds, 17); + let mut invalid = customized.clone(); + invalid.varnish_grace_seconds = 604_801; + assert!(invalid.validate().is_err(), "oversized grace must be rejected"); } #[test] - fn client_freshness_is_ignored_only_in_varnish_mode() { + fn compatible_mode_ignores_hard_reload_but_preserves_client_restrictions() { let mut compatible = Settings::default(); - let directives = [ - "no-cache", "no-store", "max-age=0", "max-age=1", - "min-fresh=600", "max-stale=9999", "only-if-cached", "invalid directive", - ]; - for value in directives { + for value in ["no-cache", "max-age=0", "max-age=\"0\"", "max-age=000"] { let h = headers(value); - assert_eq!( - request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), - RequestPolicy::Use, - "varnish mode should ignore client directive: {value}" - ); + assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use); let freshness = request_freshness_headers(&h, &compatible); - assert!(!freshness.contains_key("cache-control")); - assert!(h.contains_key("cache-control"), "do not mutate origin request headers"); + assert!(!freshness.contains_key("cache-control"), "hard reload must not constrain lookup: {value}"); + assert!(h.contains_key("cache-control"), "origin request and Vary must remain unchanged"); } + for value in ["max-age=1", "min-fresh=600", "max-stale=9999", "only-if-cached"] { + let h = headers(value); + assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use); + assert!(request_freshness_headers(&h, &compatible).contains_key("cache-control"), "preserve {value}"); + } + assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store")); + assert_eq!(request_policy(&Method::GET, &headers("invalid directive"), true, "/", false, &compatible), RequestPolicy::Bypass("request_cache_control_invalid")); + assert!(only_if_cached(&headers("only-if-cached"))); + let h = headers("no-cache, max-age=0, min-fresh=45, max-stale=90, only-if-cached"); + let freshness = request_freshness_headers(&h, &compatible); + let parsed = directives(&freshness).unwrap(); + assert!(!parsed.contains_key("no-cache")); + assert!(!parsed.contains_key("max-age")); + assert_eq!(parsed["min-fresh"].as_deref(), Some("45")); + assert_eq!(parsed["max-stale"].as_deref(), Some("90")); + assert!(parsed.contains_key("only-if-cached")); let mut pragma = HeaderMap::new(); - pragma.insert("pragma", HeaderValue::from_static("no-cache")); + pragma.insert("pragma", HeaderValue::from_static("no-cache, custom-token")); assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use); - assert!(!request_freshness_headers(&pragma, &compatible).contains_key("pragma")); + assert_eq!(request_freshness_headers(&pragma, &compatible)["pragma"], "custom-token"); compatible.varnish_compatible = false; assert_eq!(request_policy(&Method::GET, &headers("no-cache"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_no_cache")); + assert_eq!(request_policy(&Method::GET, &headers("max-age=0"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_max_age")); assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store")); - assert!(only_if_cached(&headers("only-if-cached")), "strict-only 504 guard still recognizes directive"); } #[test] @@ -71,7 +87,7 @@ mod varnish_compatible_tests { } #[test] - fn backend_response_policy_matches_varnish_without_weakening_privacy() { + fn backend_response_policy_preserves_validators_fallback_and_surrogate() { let compatible = Settings::default(); let mut strict = compatible.clone(); strict.varnish_compatible = false; @@ -81,24 +97,150 @@ mod varnish_compatible_tests { } let mut no_cache = headers("no-cache, max-age=60"); no_cache.insert("etag", HeaderValue::from_static("\"origin\"")); - assert!(plan(StatusCode::OK, &no_cache, &compatible, Duration::ZERO).is_none()); + let validator_plan = plan(StatusCode::OK, &no_cache, &compatible, Duration::ZERO) + .expect("no-cache with ETag remains revalidatable without fallback"); + assert!(validator_plan.requires_revalidation); assert!(plan(StatusCode::OK, &no_cache, &strict, Duration::ZERO).is_some()); - assert_eq!(response_reason(StatusCode::OK, &no_cache, &compatible), "response_cache_control"); - let mut surrogate = headers("public, max-age=60"); + assert!(plan(StatusCode::OK, &headers("no-cache, max-age=60"), &compatible, Duration::ZERO).is_none()); + + let mut fallback = compatible.clone(); + fallback.default_ttl_seconds = 42; + let public_no_cache = headers("public, no-cache, max-age=600"); + let fallback_plan = plan(StatusCode::OK, &public_no_cache, &fallback, Duration::ZERO) + .expect("explicit fallback TTL for public origin no-cache"); + assert_eq!(fallback_plan.ttl, 42); + assert!(!fallback_plan.requires_revalidation); + let nonpublic_no_cache = headers("no-cache, max-age=60"); + assert!(plan(StatusCode::OK, &nonpublic_no_cache, &fallback, Duration::ZERO).is_none()); + let mut surrogate = public_no_cache.clone(); surrogate.insert("surrogate-control", HeaderValue::from_static("max-age=120; NO-STORE")); - assert!(plan(StatusCode::OK, &surrogate, &compatible, Duration::ZERO).is_none()); - assert_eq!(response_reason(StatusCode::OK, &surrogate, &compatible), "response_surrogate_control"); - assert!(plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_some()); - for control in ["max-age=0", "max-age=60, s-maxage=0"] { - assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none()); - assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_some()); + assert!(plan(StatusCode::OK, &surrogate, &fallback, Duration::ZERO).is_none()); + assert_eq!(response_reason(StatusCode::OK, &surrogate, &fallback), "response_surrogate_control"); + assert!(plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_none(), "strict no-cache without ETag remains uncached"); + let mut private = headers("public, private, no-cache"); + private.insert("etag", HeaderValue::from_static("\"secret\"")); + assert!(plan(StatusCode::OK, &private, &fallback, Duration::ZERO).is_none()); + + for control in ["max-age=0", "max-age=60, s-maxage=0", "max-age=0, stale-if-error=120"] { + let p = plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO) + .expect("zero TTL is retained for max-stale, validation or stale-if-error"); + assert_eq!(p.ttl, 0, "{control}"); + assert_eq!(p.stale_while_revalidate, 0, "zero TTL must not receive implicit grace"); } let mut cookie = headers("max-age=60"); cookie.insert("set-cookie", HeaderValue::from_static("session=private")); - assert!(plan(StatusCode::OK, &cookie, &compatible, Duration::ZERO).is_none()); + assert!(plan(StatusCode::OK, &cookie, &fallback, Duration::ZERO).is_none()); let mut vary = headers("max-age=60"); vary.insert("vary", HeaderValue::from_static("*")); - assert!(plan(StatusCode::OK, &vary, &compatible, Duration::ZERO).is_none()); + assert!(plan(StatusCode::OK, &vary, &fallback, Duration::ZERO).is_none()); + } + + #[test] + fn explicit_zero_grace_and_stale_if_error_are_preserved() { + let compatible = Settings::default(); + let with_zero = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=0"), &compatible, Duration::ZERO).unwrap(); + assert_eq!(with_zero.stale_while_revalidate, 0); + let with_grace = plan(StatusCode::OK, &headers("public, max-age=60"), &compatible, Duration::ZERO).unwrap(); + assert_eq!(with_grace.stale_while_revalidate, 60); + let mut custom_settings = compatible.clone(); + custom_settings.varnish_grace_seconds = 25; + let with_custom_default = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); + assert_eq!(with_custom_default.stale_while_revalidate, 25); + custom_settings.varnish_grace_seconds = 0; + let grace_disabled = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); + assert_eq!(grace_disabled.stale_while_revalidate, 0); + custom_settings.varnish_compatible = false; + custom_settings.varnish_grace_seconds = 60; + let strict = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); + assert_eq!(strict.stale_while_revalidate, 0); + let with_custom = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=30"), &compatible, Duration::ZERO).unwrap(); + assert_eq!(with_custom.stale_while_revalidate, 30); + let zero = plan(StatusCode::OK, &headers("public, max-age=0, stale-if-error=120"), &compatible, Duration::ZERO).unwrap(); + assert_eq!(zero.ttl, 0); + assert_eq!(zero.stale_if_error, 120); + assert_eq!(zero.stale_while_revalidate, 0); + } + + #[tokio::test] + async fn origin_no_cache_etag_can_still_revalidate_without_fallback() { + let cache = Cache::new(1); + let settings = Settings::default(); + let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap(); + let mut response = Response::new(Body::from("etag css")); + *response.headers_mut() = headers("public, no-cache, max-age=600"); + response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\"")); + let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + assert!(p.requires_revalidation); + cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); + assert!(cache.get(&ticket).is_none(), "backend no-cache cannot produce an unvalidated HIT"); + let stored = cache.revalidation(&ticket).expect("validator and body retained for 304"); + let mut request = HeaderMap::new(); + assert!(stored.apply_validators(&mut request), "conditional request can validate stored object"); + assert_eq!(request["if-none-match"], "\"origin-v1\""); + } + + #[tokio::test] + async fn positive_fallback_ttl_reuses_only_public_no_cache_entries() { + let cache = Cache::new(1); + let mut settings = Settings::default(); + settings.default_ttl_seconds = 75; + let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap(); + let mut response = Response::new(Body::from("public css")); + *response.headers_mut() = headers("public, no-cache"); + let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + assert_eq!(p.ttl, 75); + assert!(!p.requires_revalidation); + cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); + let hit = cache.get(&ticket).expect("explicit public override allows fresh HIT without ETag"); + assert!(hit.remaining_ttl > 0); + } + + #[tokio::test] + async fn zero_ttl_stale_if_error_fallback_survives_in_varnish_mode() { + let cache = Cache::new(1); + let settings = Settings::default(); + let ticket = cache.ticket(1, "https", "example.test", "app", "/fallback.css", &HeaderMap::new()).unwrap(); + let mut response = Response::new(Body::from("public fallback")); + *response.headers_mut() = headers("public, max-age=0, stale-if-error=120"); + let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); + assert!(cache.get(&ticket).is_none(), "zero TTL must never create a fresh HIT"); + assert!(cache.revalidation(&ticket).unwrap().can_stale_if_error(), "body remains available on backend failure"); + } + + #[tokio::test] + async fn min_fresh_and_max_stale_still_constrain_compatibility_hits() { + let cache = Cache::new(1); + let settings = Settings::default(); + let ticket = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap(); + let mut response = Response::new(Body::from("css")); + *response.headers_mut() = headers("public, max-age=60"); + let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.received = Instant::now() - Duration::from_secs(45); + } + } + let too_fresh = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-age=0, min-fresh=20")).unwrap().with_varnish_compatible(true); + assert!(cache.get(&too_fresh).is_none(), "min-fresh requires 20s but only 15s remain"); + let enough = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("min-fresh=10")).unwrap().with_varnish_compatible(true); + assert!(cache.get(&enough).is_some(), "10s remaining freshness is available"); + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.received = Instant::now() - Duration::from_secs(90); + } + } + let max_stale = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-stale=45")).unwrap().with_varnish_compatible(true); + let stale = cache.get(&max_stale).expect("max-stale explicitly allows retained stale object"); + assert!(stale.stale); + assert!(!stale.background_revalidate, "explicit max-stale never triggers implicit background refresh"); + let too_old = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("max-stale=10")).unwrap().with_varnish_compatible(true); + assert!(cache.get(&too_old).is_none()); + let default = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap().with_varnish_compatible(true); + assert!(cache.get(&default).is_none(), "expired object cannot be served without stale allowance"); } #[tokio::test] @@ -116,7 +258,7 @@ mod varnish_compatible_tests { entry.received = Instant::now() - Duration::from_secs(45); } } - let mut request = headers("no-cache, max-age=0, min-fresh=900"); + let mut request = headers("no-cache, max-age=0"); request.insert("pragma", HeaderValue::from_static("no-cache")); let compatibility_ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap().with_varnish_compatible(true); let first = cache.get(&compatibility_ticket).expect("hard reload should keep a fresh HIT"); @@ -130,14 +272,14 @@ mod varnish_compatible_tests { } #[tokio::test] - async fn ten_second_grace_and_no_unconditional_stale_outside_window() { + async fn sixty_second_grace_and_no_unconditional_stale_outside_window() { let cache = Cache::new(1); let settings = Settings::default(); let ticket = cache.ticket(1, "https", "example.test", "app", "/asset.js", &HeaderMap::new()).unwrap(); let mut response = Response::new(Body::from("js")); *response.headers_mut() = headers("public, max-age=60"); let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); - assert_eq!(plan.stale_while_revalidate, 10); + assert_eq!(plan.stale_while_revalidate, 60); cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap(); { let mut store = cache.store.lock().unwrap(); @@ -147,10 +289,17 @@ mod varnish_compatible_tests { } let hit = cache.get(&ticket).expect("grace HIT"); assert!(hit.stale && hit.background_revalidate); + let max_stale_zero = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("max-stale=0")).unwrap().with_varnish_compatible(true); + assert!(cache.get(&max_stale_zero).is_none(), "explicit max-stale=0 must override implicit grace"); + let max_stale_small = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=3")).unwrap().with_varnish_compatible(true); + assert!(cache.get(&max_stale_small).is_none(), "max-stale=3 rejects object stale by 5s, even in grace"); + let max_stale_enough = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=9")).unwrap().with_varnish_compatible(true); + let accepted = cache.get(&max_stale_enough).expect("max-stale=9 admits object 5s stale"); + assert!(accepted.stale); { let mut store = cache.store.lock().unwrap(); for entry in store.entries.values_mut() { - entry.received = Instant::now() - Duration::from_secs(75); + entry.received = Instant::now() - Duration::from_secs(125); } } assert!(cache.get(&ticket).is_none(), "expired beyond grace does not produce HIT"); diff --git a/src/proxy/forward/cache_fast_path_tests.rs b/src/proxy/forward/cache_fast_path_tests.rs index 06581e0..3f17aab 100644 --- a/src/proxy/forward/cache_fast_path_tests.rs +++ b/src/proxy/forward/cache_fast_path_tests.rs @@ -470,10 +470,12 @@ mod cache_fast_path_tests { .insert("etag", HeaderValue::from_static("\"origin-v1\"")); response.headers_mut().insert( "cache-control", - HeaderValue::from_static(if parts.uri.path() == "/private" { - "private, max-age=600" - } else { - "public, max-age=600" + HeaderValue::from_static(match parts.uri.path() { + "/private" => "private, max-age=600", + "/backend-no-cache" => "public, no-cache, max-age=600", + "/backend-zero-stale" => "public, max-age=0, stale-if-error=120", + "/grace" => "public, max-age=1", + _ => "public, max-age=600", }), ); response @@ -612,7 +614,7 @@ mod cache_fast_path_tests { .await; assert_eq!(validated.status, StatusCode::OK); assert_eq!(validated.headers["x-cache"], "HIT"); - assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated"); + assert_eq!(validated.headers["x-cache-reason"], "cache_hit", "default compatibility mode must not revalidate a fresh object"); assert_eq!(validated.body, "origin-a"); let conditional = fixture .get( @@ -631,7 +633,87 @@ mod cache_fast_path_tests { "MISS" ); } - assert_eq!(origin.requests.load(Ordering::Relaxed), 6); + assert_eq!(origin.requests.load(Ordering::Relaxed), 5); + fixture.stop().await; + } + + #[tokio::test] + async fn compatible_only_if_cached_on_miss_never_contacts_origin() { + let origin = origin().await; + let fixture = Fixture::new(config_for(origin.addr, true)).await; + let miss = fixture.get("/missing", &[("cache-control", "no-cache, only-if-cached")]).await; + assert_eq!(miss.status, StatusCode::GATEWAY_TIMEOUT); + assert_eq!(origin.requests.load(Ordering::Relaxed), 0, "only-if-cached MISS must not contact backend"); + let primed = fixture.get("/vary", &[]).await; + assert_eq!(primed.headers["x-cache"], "MISS"); + let hit = fixture.get("/vary", &[("cache-control", "only-if-cached")]).await; + assert_eq!(hit.headers["x-cache"], "HIT"); + assert_eq!(origin.requests.load(Ordering::Relaxed), 1); + let blocked = fixture.get("/vary", &[("cache-control", "no-store")]).await; + assert_eq!(blocked.headers["x-cache-reason"], "request_no_store"); + assert_eq!(origin.requests.load(Ordering::Relaxed), 2, "no-store must bypass even a fresh HIT"); + fixture.stop().await; + } + + #[tokio::test] + async fn compatible_origin_no_cache_preserves_etag_revalidation_and_fallback_ttl() { + let origin = origin().await; + let mut config = config_for(origin.addr, true); + config.cache.default_ttl_seconds = 0; + let fixture = Fixture::new(config).await; + assert_eq!(fixture.get("/backend-no-cache", &[]).await.headers["x-cache"], "MISS"); + let cached_only = fixture + .get("/backend-no-cache", &[("cache-control", "only-if-cached")]) + .await; + assert_eq!(cached_only.status, StatusCode::GATEWAY_TIMEOUT); + assert_eq!(origin.requests.load(Ordering::Relaxed), 1); + let validated = fixture.get("/backend-no-cache", &[]).await; + assert_eq!(validated.status, StatusCode::OK); + assert_eq!(validated.headers["x-cache"], "HIT"); + assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated"); + assert_eq!(origin.requests.load(Ordering::Relaxed), 2); + fixture.stop().await; + + let origin = origin().await; + let mut config = config_for(origin.addr, true); + config.cache.default_ttl_seconds = 45; + let fixture = Fixture::new(config).await; + assert_eq!(fixture.get("/backend-no-cache", &[]).await.headers["x-cache"], "MISS"); + let reused = fixture.get("/backend-no-cache", &[]).await; + assert_eq!(reused.headers["x-cache"], "HIT"); + assert_eq!(reused.headers["x-cache-reason"], "cache_hit"); + assert_eq!(origin.requests.load(Ordering::Relaxed), 1, "configured fallback TTL avoids origin revalidation"); + fixture.stop().await; + } + + #[tokio::test] + async fn only_if_cached_stale_grace_never_background_refreshes_origin() { + let origin = origin().await; + let fixture = Fixture::new(config_for(origin.addr, true)).await; + assert_eq!(fixture.get("/grace", &[]).await.headers["x-cache"], "MISS"); + tokio::time::sleep(Duration::from_millis(1200)).await; + let stale = fixture.get("/grace", &[("cache-control", "only-if-cached")]).await; + assert_eq!(stale.status, StatusCode::OK); + assert_eq!(stale.headers["x-cache"], "HIT"); + assert_eq!(stale.headers["x-cache-reason"], "cache_stale_while_revalidate"); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(origin.requests.load(Ordering::Relaxed), 1, "only-if-cached must not start async validation"); + fixture.stop().await; + } + + #[tokio::test] + async fn web_server_only_if_cached_on_miss_never_reads_origin() { + let directory = TempDir::new().unwrap(); + let fixture = Fixture::new(web_config(&directory, true)).await; + let miss = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await; + assert_eq!(miss.status, StatusCode::GATEWAY_TIMEOUT); + assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS"); + let hit = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await; + assert_eq!(hit.headers["x-cache"], "HIT"); + let bypass = fixture.get("/style.css", &[("cache-control", "no-store, only-if-cached")]).await; + assert_eq!(bypass.status, StatusCode::GATEWAY_TIMEOUT, "uncacheable only-if-cached must not read the Web Server file"); + let reload = fixture.get("/style.css", &[("cache-control", "no-cache")]).await; + assert_eq!(reload.headers["x-cache-reason"], "cache_hit"); fixture.stop().await; } diff --git a/src/proxy/forward/cache_lookup.rs b/src/proxy/forward/cache_lookup.rs index 1c2274c..4163794 100644 --- a/src/proxy/forward/cache_lookup.rs +++ b/src/proxy/forward/cache_lookup.rs @@ -26,7 +26,9 @@ if let Some(ticket) = &cache_ticket { } else { "cache_hit" }; - if hit.background_revalidate { + // only-if-cached forbids any origin communication, including + // asynchronous grace validation and proactive refresh. + if hit.background_revalidate && !only_cached { if let (Some(cached), Some(guard)) = ( cached_revalidation.clone(), cached_revalidation @@ -66,6 +68,7 @@ if let Some(ticket) = &cache_ticket { } } if cache_settings.proactive_refresh_enabled + && !only_cached && !hit.stale && hit.remaining_ttl > 0 { diff --git a/src/proxy/forward/route_cache.rs b/src/proxy/forward/route_cache.rs index d254e29..682567e 100644 --- a/src/proxy/forward/route_cache.rs +++ b/src/proxy/forward/route_cache.rs @@ -177,9 +177,8 @@ let original_policy = cache_plan.request_policy( request.uri().query().is_some(), cache_settings, ); -let only_cached = - cache_settings.active_for(route.cache) && !cache_settings.varnish_compatible - && crate::cache::only_if_cached(request.headers()); +let only_cached = cache_settings.active_for(route.cache) + && crate::cache::only_if_cached(request.headers()); let origin_suffix = match route.rewritten_path(&path) { Ok(None) => public_suffix.clone(), Ok(Some(rewritten)) => { diff --git a/src/proxy/forward/web_source.rs b/src/proxy/forward/web_source.rs index 0f8c8ba..88ba006 100644 --- a/src/proxy/forward/web_source.rs +++ b/src/proxy/forward/web_source.rs @@ -113,8 +113,7 @@ } } } - if cache_ticket.is_some() && !cache_settings.varnish_compatible - && crate::cache::only_if_cached(&request_headers) { + if enabled && crate::cache::only_if_cached(&request_headers) { return error(StatusCode::GATEWAY_TIMEOUT, "RustProxy: only-if-cached request has no usable Web Server representation\n"); }