This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 15:31:47 +02:00
1 parent 3ea00045bf
commit 1cd6b7ac99
22 files changed
+546 -145

No files matched your search

+6 -5
View File
@@ -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)
+1 -1
View File
@@ -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
+21 -21
View File
@@ -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
+19 -14
View File
@@ -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
+1 -1
View File
@@ -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
+6 -1
View File
@@ -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)",
+6 -1
View File
@@ -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)",
+8 -1
View File
@@ -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
+2 -2
View File
@@ -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 = {
+16 -1
View File
@@ -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.</p>
<div class="cache-varnish-setting">
<label class="check"><input id="cache-varnish-compatible" type="checkbox" /><span
data-i18n="cacheVarnishCompatible">Varnish-compatible cache policy (recommended; enabled by default)</span></label>
<p class="fine" data-i18n="cacheVarnishCompatibleHelp">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.</p>
<details class="metric-info cache-varnish-info">
<summary data-i18n-aria="cacheVarnishCompatibleInfoLabel"
aria-label="Varnish-compatible cache behavior">i</summary>
<div class="metric-info-popover" role="note">
<p class="fine" data-i18n="cacheVarnishCompatibleHelp">A browser Shift+F5 (no-cache, max-age=0 or Pragma: no-cache) uses a fresh cache HIT without replacing it or resetting Age.</p>
<p class="fine" data-i18n="cacheVarnishCompatibleSafeguards">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.</p>
<p class="fine" data-i18n="cacheVarnishCompatibleBackendHelp">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.</p>
<p class="fine" data-i18n="cacheVarnishCompatibleModeHelp">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.</p>
</div>
</details>
</div>
<div class="cache-varnish-grace-field">
<label for="cache-varnish-grace" data-i18n="cacheVarnishGrace">Default grace (seconds, 0 = off)</label>
<input id="cache-varnish-grace" type="number" min="0" max="604800" step="1" required />
</div>
<div class="card soft-card cache-memory-sizing">
<div class="card-heading">
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
+52
View File
@@ -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;
+4
View File
@@ -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,
+3
View File
@@ -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<MemoryPreset>,
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,
+9 -4
View File
@@ -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,
}
}
+88 -23
View File
@@ -44,19 +44,89 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
}
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::<u64>().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::<Vec<_>>();
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::<u64>() == Ok(0));
if skip { changed = true; }
!skip
}).collect::<Vec<_>>();
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::<u64>().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,14 +474,9 @@ 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) {
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| {
value.split(',').any(|name| {
+25 -19
View File
@@ -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<u64> {
@@ -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::<u64>().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()
+4 -8
View File
@@ -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
+181 -32
View File
@@ -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");
+88 -6
View File
@@ -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;
}
+4 -1
View File
@@ -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
{
+1 -2
View File
@@ -177,8 +177,7 @@ 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
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(),
+1 -2
View File
@@ -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");
}