v0.34.7
This commit is contained in:
1 parent
133d2f0f91
commit
3ea00045bf
27 files changed
+446
-136
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.34.6",
|
||||
"version": "0.34.7",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
@@ -2061,6 +2061,11 @@
|
||||
"maxLength": 64
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -13683,4 +13688,4 @@
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
@@ -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-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-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, 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, 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 = {
|
||||
|
||||
@@ -2210,6 +2210,9 @@
|
||||
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>
|
||||
<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>
|
||||
<div class="card soft-card cache-memory-sizing">
|
||||
<div class="card-heading">
|
||||
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
|
||||
|
||||
Vendored
+3
@@ -6,9 +6,12 @@ mod tests {
|
||||
h.insert("cache-control", value.parse().unwrap());
|
||||
h
|
||||
}
|
||||
// Legacy-policy regression tests explicitly opt out of the new 0.34.7
|
||||
// default; varnish-compatible behavior has its own test module.
|
||||
fn settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
varnish_compatible: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -103,7 +103,7 @@ mod dashboard_tests {
|
||||
}
|
||||
#[test]
|
||||
fn bypass_reasons_are_fixed_and_private_inputs_are_not_echoed() {
|
||||
let settings = Settings::default();
|
||||
let settings = Settings { varnish_compatible: false, ..Settings::default() };
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", HeaderValue::from_static("session=very-private"));
|
||||
assert_eq!(
|
||||
|
||||
Vendored
+3
-3
@@ -40,7 +40,7 @@ impl Cache {
|
||||
entry.must_revalidate,
|
||||
entry.proxy_revalidate,
|
||||
entry.requires_revalidation,
|
||||
&ticket.request_headers,
|
||||
ticket.freshness_headers(),
|
||||
);
|
||||
if state.servable() {
|
||||
let sequence = store.next_sequence();
|
||||
@@ -144,7 +144,7 @@ impl Cache {
|
||||
entry.must_revalidate,
|
||||
entry.proxy_revalidate,
|
||||
entry.requires_revalidation,
|
||||
&ticket.request_headers,
|
||||
ticket.freshness_headers(),
|
||||
);
|
||||
if !state.servable() {
|
||||
return None;
|
||||
@@ -217,7 +217,7 @@ impl Cache {
|
||||
entry.must_revalidate,
|
||||
entry.proxy_revalidate,
|
||||
entry.requires_revalidation,
|
||||
&ticket.request_headers,
|
||||
ticket.freshness_headers(),
|
||||
);
|
||||
if !state.servable() {
|
||||
return None;
|
||||
|
||||
Vendored
+1
@@ -88,6 +88,7 @@ pub struct Ticket {
|
||||
target: String,
|
||||
key: Key,
|
||||
request_headers: HeaderMap,
|
||||
freshness_headers: Option<HeaderMap>,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
scope: String,
|
||||
|
||||
Vendored
+3
@@ -87,6 +87,8 @@ pub enum ColdPreset {
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub enabled: bool,
|
||||
/// Ignore client freshness directives on cache lookups, like Varnish builtin VCL.
|
||||
pub varnish_compatible: bool,
|
||||
pub memory_preset: Option<MemoryPreset>,
|
||||
pub max_bytes: usize,
|
||||
pub max_object_bytes: usize,
|
||||
@@ -140,6 +142,7 @@ impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
varnish_compatible: true,
|
||||
memory_preset: None,
|
||||
max_bytes: 256 * 1024 * 1024,
|
||||
max_object_bytes: 16 * 1024 * 1024,
|
||||
|
||||
Vendored
+46
-1
@@ -44,6 +44,21 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn only_if_cached(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get_all("cache-control")
|
||||
@@ -158,6 +173,11 @@ 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;
|
||||
}
|
||||
let Some(control) = directives(headers) else {
|
||||
return RequestPolicy::Bypass("request_cache_control_invalid");
|
||||
};
|
||||
@@ -346,7 +366,24 @@ pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
|
||||
pub fn surrogate_no_store(headers: &HeaderMap) -> bool {
|
||||
headers.get_all("surrogate-control").iter().any(|value| {
|
||||
value.to_str().ok().is_some_and(|text| {
|
||||
text.split([',', ';']).any(|part| {
|
||||
part.trim()
|
||||
.split_once('=')
|
||||
.map_or(part.trim(), |(key, _)| key.trim())
|
||||
.eq_ignore_ascii_case("no-store")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn response_reason(
|
||||
status: StatusCode,
|
||||
headers: &HeaderMap,
|
||||
settings: &Settings,
|
||||
) -> &'static str {
|
||||
if status != StatusCode::OK {
|
||||
return "response_status";
|
||||
}
|
||||
@@ -367,6 +404,14 @@ pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str
|
||||
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";
|
||||
}
|
||||
}
|
||||
for value in headers.get_all("vary") {
|
||||
if value.to_str().ok().is_none_or(|value| {
|
||||
value.split(',').any(|name| {
|
||||
|
||||
Vendored
+21
-1
@@ -92,6 +92,14 @@ 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))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let requires_revalidation = control.contains_key("no-cache");
|
||||
let must_revalidate = control.contains_key("must-revalidate");
|
||||
let proxy_revalidate = control.contains_key("proxy-revalidate");
|
||||
@@ -102,7 +110,7 @@ pub fn plan(
|
||||
Some(None) => None,
|
||||
}
|
||||
};
|
||||
let stale_while_revalidate = parse_window("stale-while-revalidate")?;
|
||||
let mut stale_while_revalidate = parse_window("stale-while-revalidate")?;
|
||||
let stale_if_error = parse_window("stale-if-error")?;
|
||||
if requires_revalidation
|
||||
&& !headers.contains_key("etag")
|
||||
@@ -148,6 +156,18 @@ 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;
|
||||
}
|
||||
if !requires_revalidation
|
||||
&& initial_age.as_secs()
|
||||
>= retention_deadline(
|
||||
|
||||
Vendored
+3
@@ -18,3 +18,6 @@ include!("invalidation/http_invalidation_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("lookup/hot_hit_034_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("varnish_compatible_tests.rs");
|
||||
Vendored
+22
@@ -101,6 +101,7 @@ impl Cache {
|
||||
target: target.to_owned(),
|
||||
key,
|
||||
request_headers: headers.clone(),
|
||||
freshness_headers: None,
|
||||
generation: store.generation,
|
||||
revision,
|
||||
scope: scope.to_owned(),
|
||||
@@ -392,3 +393,24 @@ impl Cache {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ticket {
|
||||
/// In Varnish compatibility mode, request cache directives do not constrain
|
||||
/// freshness. Keep original headers for Vary and downstream conditionals.
|
||||
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"))
|
||||
{
|
||||
let mut freshness = self.request_headers.clone();
|
||||
freshness.remove("cache-control");
|
||||
freshness.remove("pragma");
|
||||
self.freshness_headers = Some(freshness);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn freshness_headers(&self) -> &HeaderMap {
|
||||
self.freshness_headers.as_ref().unwrap_or(&self.request_headers)
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
mod varnish_compatible_tests {
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn headers(cache_control: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("cache-control", HeaderValue::from_str(cache_control).unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_and_legacy_serialization_are_explicit() {
|
||||
assert!(Settings::default().varnish_compatible);
|
||||
let legacy: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
|
||||
assert!(legacy.varnish_compatible);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_freshness_is_ignored_only_in_varnish_mode() {
|
||||
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 {
|
||||
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}"
|
||||
);
|
||||
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");
|
||||
}
|
||||
let mut pragma = HeaderMap::new();
|
||||
pragma.insert("pragma", HeaderValue::from_static("no-cache"));
|
||||
assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use);
|
||||
assert!(!request_freshness_headers(&pragma, &compatible).contains_key("pragma"));
|
||||
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("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]
|
||||
fn vcl_style_request_lookup_preserves_privacy_and_local_rules() {
|
||||
let mut settings = Settings::default();
|
||||
for (name, value) in [
|
||||
("authorization", "Bearer private"),
|
||||
("cookie", "session=private"),
|
||||
("if-match", "\"etag\""),
|
||||
("upgrade", "websocket"),
|
||||
] {
|
||||
let mut h = headers("no-cache");
|
||||
h.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderValue::from_str(value).unwrap());
|
||||
assert!(matches!(request_policy(&Method::GET, &h, true, "/index.html", false, &settings), RequestPolicy::Bypass(_)));
|
||||
}
|
||||
settings.bypass_query = true;
|
||||
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/style.css", true, &settings), RequestPolicy::Bypass("query_excluded"));
|
||||
settings.bypass_query = false;
|
||||
settings.bypass_prefixes = vec!["/private".into()];
|
||||
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/private/account", false, &settings), RequestPolicy::Bypass("path_excluded"));
|
||||
assert_eq!(request_policy(&Method::POST, &HeaderMap::new(), true, "/style.css", false, &settings), RequestPolicy::Bypass("request_method"));
|
||||
assert!(settings.ignore_cookie_for_path("/style.css"), "existing static cookie feature remains enabled");
|
||||
assert!(!settings.ignore_cookie_for_path("/index.html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_response_policy_matches_varnish_without_weakening_privacy() {
|
||||
let compatible = Settings::default();
|
||||
let mut strict = compatible.clone();
|
||||
strict.varnish_compatible = false;
|
||||
for control in ["private, max-age=60", "no-store, max-age=60"] {
|
||||
assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none());
|
||||
assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_none());
|
||||
}
|
||||
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());
|
||||
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");
|
||||
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());
|
||||
}
|
||||
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());
|
||||
let mut vary = headers("max-age=60");
|
||||
vary.insert("vary", HeaderValue::from_static("*"));
|
||||
assert!(plan(StatusCode::OK, &vary, &compatible, Duration::ZERO).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn varnish_style_hard_reload_keeps_hot_hit_and_age_without_origin_validator() {
|
||||
let cache = Cache::new(1);
|
||||
let settings = Settings::default();
|
||||
let ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &HeaderMap::new()).unwrap();
|
||||
let mut response = Response::new(Body::from("public css"));
|
||||
*response.headers_mut() = headers("public, max-age=600");
|
||||
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
cache.wrap(response, ticket.clone(), plan, &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 mut request = headers("no-cache, max-age=0, min-fresh=900");
|
||||
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");
|
||||
assert!(first.response.headers().get("age").unwrap().to_str().unwrap().parse::<u64>().unwrap() >= 45);
|
||||
assert_eq!(first.hits, 1);
|
||||
let again = cache.get(&compatibility_ticket).unwrap();
|
||||
assert_eq!(again.hits, 2, "hard reload must not reset HIT counter");
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
let strict = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap();
|
||||
assert!(cache.get(&strict).is_none(), "strict max-age=0 requires revalidation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ten_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);
|
||||
cache.wrap(response, ticket.clone(), plan, &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(65);
|
||||
}
|
||||
}
|
||||
let hit = cache.get(&ticket).expect("grace HIT");
|
||||
assert!(hit.stale && hit.background_revalidate);
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap();
|
||||
for entry in store.entries.values_mut() {
|
||||
entry.received = Instant::now() - Duration::from_secs(75);
|
||||
}
|
||||
}
|
||||
assert!(cache.get(&ticket).is_none(), "expired beyond grace does not produce HIT");
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,8 @@ let original_policy = cache_plan.request_policy(
|
||||
);
|
||||
|
||||
let only_cached =
|
||||
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
|
||||
cache_settings.active_for(route.cache) && !cache_settings.varnish_compatible
|
||||
&& crate::cache::only_if_cached(request.headers());
|
||||
let origin_suffix = match route.rewritten_path(&path) {
|
||||
Ok(None) => public_suffix.clone(),
|
||||
Ok(Some(rewritten)) => {
|
||||
@@ -296,7 +297,7 @@ let cache_ticket = if effective_cacheable {
|
||||
cache_route,
|
||||
&public_suffix,
|
||||
key_headers,
|
||||
)
|
||||
).map(|ticket| ticket.with_varnish_compatible(cache_settings.varnish_compatible))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -308,14 +309,17 @@ let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let freshness_headers = crate::cache::request_freshness_headers(
|
||||
&client_conditionals, cache_settings,
|
||||
);
|
||||
let freshness_revalidation_reason = cached_revalidation
|
||||
.as_ref()
|
||||
.and_then(|cached| cached.request_freshness_revalidation_reason(&client_conditionals));
|
||||
.and_then(|cached| cached.request_freshness_revalidation_reason(&freshness_headers));
|
||||
let pending_revalidation = revalidation_reason.is_some()
|
||||
|| freshness_revalidation_reason.is_some()
|
||||
|| cached_revalidation
|
||||
.as_ref()
|
||||
.is_some_and(|cached| !cached.reusable_for_request(&client_conditionals));
|
||||
.is_some_and(|cached| !cached.reusable_for_request(&freshness_headers));
|
||||
let miss_recorded = false;
|
||||
(
|
||||
backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings,
|
||||
|
||||
@@ -130,7 +130,7 @@ let origin_reason = (cache_ticket.is_some() && origin_plan.is_none()).then(|| {
|
||||
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
|
||||
"response_vary_cookie"
|
||||
} else {
|
||||
crate::cache::response_reason(response.status(), response.headers())
|
||||
crate::cache::response_reason(response.status(), response.headers(), cache_settings)
|
||||
}
|
||||
});
|
||||
let origin_no_transform = crate::transform::no_transform(response.headers());
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
state.cache.revision(), scope, context.scheme,
|
||||
original_host.to_str().unwrap_or(""), &route.id, &public_suffix,
|
||||
key_headers.as_ref().unwrap_or(&request_headers))
|
||||
.map(|ticket| ticket.with_varnish_compatible(cache_settings.varnish_compatible))
|
||||
} else { None };
|
||||
let range = cache_plan::range_selection(eligible, &request_method, &request_headers);
|
||||
let if_range = if eligible { request_headers.get("if-range").cloned() } else { None };
|
||||
@@ -71,9 +72,12 @@
|
||||
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
|
||||
state.cache.revalidation_tiered(ticket).await
|
||||
} else { None };
|
||||
let freshness_headers = crate::cache::request_freshness_headers(
|
||||
&request_headers, cache_settings,
|
||||
);
|
||||
let needs_validation = policy.revalidation_reason().is_some()
|
||||
|| cached_revalidation.as_ref().is_some_and(|cached|
|
||||
!cached.reusable_for_request(&request_headers));
|
||||
!cached.reusable_for_request(&freshness_headers));
|
||||
if let Some(ticket) = &cache_ticket {
|
||||
if !needs_validation {
|
||||
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
|
||||
@@ -109,7 +113,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
if cache_ticket.is_some() && crate::cache::only_if_cached(&request_headers) {
|
||||
if cache_ticket.is_some() && !cache_settings.varnish_compatible
|
||||
&& crate::cache::only_if_cached(&request_headers) {
|
||||
return error(StatusCode::GATEWAY_TIMEOUT,
|
||||
"RustProxy: only-if-cached request has no usable Web Server representation\n");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user