impl Settings { pub fn validate(&self) -> Result<()> { ensure!( self.max_bytes >= 65_536, "cache.max_bytes must be at least 64 KiB" ); ensure!( self.max_object_bytes >= 1024 && self.max_object_bytes <= self.max_bytes, "cache.max_object_bytes must be at least 1 KiB and no larger than max_bytes" ); ensure!( (1..=16_384).contains(&self.max_entries), "cache.max_entries must be 1..16384" ); ensure!( self.transient_max_bytes >= 65_536, "cache.transient_max_bytes must be at least 64 KiB" ); ensure!( self.cold_queue_max_bytes >= 65_536 && self.cold_queue_max_bytes <= self.transient_max_bytes, "cache.cold_queue_max_bytes must be at least 64 KiB and no larger than transient_max_bytes" ); ensure!( (1..=4096).contains(&self.hot_eviction_max_objects), "cache.hot_eviction_max_objects must be 1..4096" ); ensure!( (5..=86_400).contains(&self.hot_auto_trim_after_seconds), "cache.hot_auto_trim_after_seconds must be 5..86400" ); ensure!( (10..=95).contains(&self.hot_auto_trim_target_percent), "cache.hot_auto_trim_target_percent must be 10..95" ); ensure!( (65_536..=1024_u64 * 1024 * 1024 * 1024).contains(&self.cold_max_bytes), "cache.cold_max_bytes must be 64 KiB..1 TiB" ); ensure!( (1..=1_000_000).contains(&self.cold_max_entries), "cache.cold_max_entries must be 1..1000000" ); ensure!( (5..=86_400).contains(&self.cold_gc_interval_seconds), "cache.cold_gc_interval_seconds must be 5..86400" ); ensure!( (1..=10_000).contains(&self.cold_gc_max_delete), "cache.cold_gc_max_delete must be 1..10000" ); ensure!( (5..=604_800).contains(&self.cold_after_seconds), "cache.cold_after_seconds must be 5..604800" ); ensure!( (5..=2_592_000).contains(&self.cold_max_hot_seconds), "cache.cold_max_hot_seconds must be 5..2592000" ); ensure!( 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, "cache TTL must be at most seven days; default TTL cannot exceed maximum" ); ensure!( self.bypass_prefixes.len() <= 64, "at most 64 cache bypass prefixes" ); for prefix in &self.bypass_prefixes { ensure!( prefix.len() <= 512 && prefix.starts_with('/') && crate::acl::routing_path(prefix)? == *prefix, "cache bypass prefixes must be normalized absolute paths" ); } self.validate_http_invalidation_allowlist()?; ensure!( self.static_extensions.len() <= 64, "at most 64 static cache extensions" ); let mut extensions = std::collections::HashSet::new(); for extension in &self.static_extensions { ensure!( !extension.is_empty() && extension.len() <= 16 && extension.bytes().all(|b| b.is_ascii_alphanumeric()), "static cache extensions must be 1..16 ASCII letters or numbers without a dot" ); ensure!( extensions.insert(extension.to_ascii_lowercase()), "duplicate static cache extension" ); } ensure!( (10..=60_000).contains(&self.collapse_wait_timeout_ms), "cache.collapse_wait_timeout_ms must be 10..60000" ); ensure!( self.stale_if_error_statuses.len() <= 32, "cache.stale_if_error_statuses supports at most 32 statuses" ); let mut statuses = std::collections::HashSet::new(); for status in &self.stale_if_error_statuses { ensure!( (500..=599).contains(status), "cache.stale_if_error_statuses must contain HTTP 5xx status codes" ); ensure!( statuses.insert(*status), "duplicate stale-if-error HTTP status" ); } ensure!( (1..=604_800).contains(&self.proactive_refresh_before_expiry_seconds), "cache.proactive_refresh_before_expiry_seconds must be 1..604800" ); ensure!( self.proactive_refresh_percent <= 100, "cache.proactive_refresh_percent must be 0..100" ); ensure!( (1..=64).contains(&self.proactive_refresh_max_inflight), "cache.proactive_refresh_max_inflight must be 1..64" ); if let Some(value) = &self.via_header { ensure!( !value.is_empty() && value.len() <= 256 && value.bytes().all(|byte| byte >= 0x20 && byte != 0x7f) && value.parse::().is_ok(), "cache.via_header must be a valid 1..256 byte HTTP header value" ); } Ok(()) } pub fn active_for(&self, route: Option) -> bool { self.enabled && route != Some(false) } pub fn allows_stale_error_class(&self, class: StaleErrorClass) -> bool { match class { StaleErrorClass::Timeout => self.stale_if_error_timeout, StaleErrorClass::Connection => self.stale_if_error_connection, StaleErrorClass::Dns => self.stale_if_error_dns, StaleErrorClass::Tls => self.stale_if_error_tls, } } pub fn allows_stale_error_status(&self, status: StatusCode) -> bool { self.stale_if_error_statuses.contains(&status.as_u16()) } pub fn ignore_cookie_for_path(&self, path: &str) -> bool { if !self.static_ignore_cookies { return false; } let segment = path.rsplit('/').next().unwrap_or(path); let Some((_, extension)) = segment.rsplit_once('.') else { return false; }; !extension.is_empty() && self .static_extensions .iter() .any(|item| item.eq_ignore_ascii_case(extension)) } pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) { self.annotate_internal(headers, outcome, hits, ttl as i64, None, false, None); } pub fn annotate_reason( &self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64, reason: &'static str, ) { self.annotate_internal( headers, outcome, hits, ttl as i64, Some(reason), false, None, ); } pub fn annotate_reason_transformed( &self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64, reason: &'static str, transformed: bool, ) { self.annotate_internal( headers, outcome, hits, ttl as i64, Some(reason), transformed, None, ); } pub fn annotate_reason_transformed_freshness( &self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, freshness_ttl: i64, reason: &'static str, transformed: bool, ) { self.annotate_internal( headers, outcome, hits, freshness_ttl, Some(reason), transformed, None, ); } pub fn annotate_reason_transformed_forwarded( &self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64, reason: &'static str, transformed: bool, forward: &'static str, forward_status: Option, ) { self.annotate_internal( headers, outcome, hits, ttl as i64, Some(reason), transformed, Some((forward, forward_status)), ); } fn annotate_internal( &self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: i64, reason: Option<&'static str>, transformed: bool, forwarded: Option<(&'static str, Option)>, ) { if !self.enabled { return; } if let Some(value) = self .via_header .as_deref() .and_then(crate::settings::cached_header_value) { headers.append(http::header::VIA, value); } if self.expose_only_hits && outcome != "HIT" { return; } if self.expose_status { insert_cache_diagnostic( headers, "x-cache", "x-upstream-cache", HeaderValue::from_static(outcome), ); if let Some(reason) = reason { insert_cache_diagnostic( headers, "x-cache-reason", "x-upstream-cache-reason", HeaderValue::from_static(reason), ); } } if self.expose_hits { insert_cache_diagnostic( headers, "x-cache-hits", "x-upstream-cache-hits", HeaderValue::from(hits), ); } if self.expose_cache_status { let forwarded = forwarded.or_else(|| { matches!( reason, Some("cache_revalidated" | "cache_revalidated_uncached") ) .then_some(("stale", Some(StatusCode::NOT_MODIFIED))) }); use std::fmt::Write as _; let mut value = String::with_capacity(96); value.push_str("RustProxy; "); if let Some((why, status)) = forwarded { let _ = write!(value, "fwd={why}"); if let Some(status) = status { let _ = write!(value, "; fwd-status={}", status.as_u16()); } } else { match outcome { "HIT" => { let _ = write!(value, "hit; ttl={ttl}"); } "MISS" => value.push_str("fwd=miss"), "BYPASS" if reason == Some("request_method") => value.push_str("fwd=method"), _ => value.push_str("fwd=bypass"), } } if let Some(reason) = reason { let _ = write!(value, "; detail=\"{reason}\""); } if transformed { value.push_str("; transformed"); } headers.append( ::http::header::CACHE_STATUS, HeaderValue::from_maybe_shared(bytes::Bytes::from(value)) .expect("generated Cache-Status member"), ); } } pub fn annotate_resident_age(&self, headers: &mut HeaderMap, resident_age: u64) { if self.enabled && self.expose_resident_age { insert_cache_diagnostic( headers, "x-cache-resident-age", "x-upstream-cache-resident-age", HeaderValue::from(resident_age), ); } } } fn insert_cache_diagnostic( headers: &mut HeaderMap, name: &'static str, upstream_name: &'static str, value: HeaderValue, ) { let name = http::HeaderName::from_static(name); if headers.contains_key(&name) { let upstream_values: Vec = headers.get_all(&name).iter().cloned().collect(); let upstream_name = http::HeaderName::from_static(upstream_name); for upstream_value in upstream_values { headers.append(&upstream_name, upstream_value); } } headers.insert(name, value); } fn valid_relative_cache_path(value: &str) -> bool { if value.is_empty() || value.len() > 256 { return false; } let path = Path::new(value); !path.is_absolute() && path .components() .all(|part| matches!(part, Component::Normal(_))) } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct UnsafeForce { pub enabled: bool, pub targets: Vec, } impl UnsafeForce { pub fn validate(&self) -> Result<()> { ensure!( self.targets.len() <= 32, "forced cache supports at most 32 target prefixes" ); for target in &self.targets { ensure!( !target.is_empty() && target.len() <= 2048 && target.starts_with('/') && !target.contains('#') && !target.chars().any(char::is_control), "forced cache targets must be 1..2048 byte origin-form path/query prefixes" ); let path = target.split('?').next().unwrap_or(target); ensure!( crate::acl::routing_path(path)? == path, "forced cache target path must be normalized" ); } Ok(()) } pub fn matches(&self, path: &str, target: &str) -> bool { if !self.enabled { return false; } self.targets.iter().any(|configured| { if configured.contains('?') { target.starts_with(configured) } else { crate::config::prefix_matches(configured, path) } }) } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Overrides { pub enabled: Option, pub default_ttl_seconds: Option, pub max_ttl_seconds: Option, pub max_object_bytes: Option, pub bypass_query: Option, pub bypass_prefixes: Option>, pub static_ignore_cookies: Option, pub static_extensions: Option>, pub expose_status: Option, pub expose_hits: Option, pub expose_cache_status: Option, pub expose_resident_age: Option, pub expose_only_hits: Option, pub unsafe_force: UnsafeForce, } impl Overrides { pub fn resolve(&self, global: &Settings) -> Result { self.unsafe_force.validate()?; let mut result = global.clone(); result.enabled = global.enabled && self.enabled.unwrap_or(true); if let Some(value) = self.default_ttl_seconds { result.default_ttl_seconds = value; } if let Some(value) = self.max_ttl_seconds { result.max_ttl_seconds = value; } if let Some(value) = self.max_object_bytes { ensure!( value <= global.max_object_bytes, "vhost object limit cannot exceed the global ceiling" ); result.max_object_bytes = value; } if let Some(value) = self.bypass_query { result.bypass_query = value; } if let Some(value) = &self.bypass_prefixes { result.bypass_prefixes = value.clone(); } if let Some(value) = self.static_ignore_cookies { result.static_ignore_cookies = value; } if let Some(value) = &self.static_extensions { result.static_extensions = value.clone(); } if let Some(value) = self.expose_status { result.expose_status = value; } if let Some(value) = self.expose_hits { result.expose_hits = value; } if let Some(value) = self.expose_cache_status { result.expose_cache_status = value; } if let Some(value) = self.expose_resident_age { result.expose_resident_age = value; } if let Some(value) = self.expose_only_hits { result.expose_only_hits = value; } result.validate()?; Ok(result) } } #[derive(Debug, Clone, Serialize)] pub struct ScopeView { pub entries: usize, pub resident_bytes: usize, pub cold_entries: usize, pub cold_bytes: u64, } #[derive(Default)] struct Counters { hits: AtomicU64, misses: AtomicU64, bypasses: AtomicU64, stored: AtomicU64, evictions: AtomicU64, expired: AtomicU64, rejected_fills: AtomicU64, memory_pressure_rejections: AtomicU64, hot_eviction_limited: AtomicU64, purges: AtomicU64, cold_hits: AtomicU64, cold_promotions: AtomicU64, hot_demotions: AtomicU64, cold_evictions: AtomicU64, cold_errors: AtomicU64, cold_queue_dropped: AtomicU64, collapsed_fills: AtomicU64, collapse_timeouts: AtomicU64, purge_entries: AtomicU64, bans_created: AtomicU64, bans_removed: AtomicU64, ban_matches: AtomicU64, proactive_scheduled: AtomicU64, proactive_started: AtomicU64, proactive_skipped: AtomicU64, } #[derive(Debug, Clone, Serialize)] pub struct View { pub hits: u64, pub misses: u64, pub bypasses: u64, pub stored: u64, pub evictions: u64, pub expired: u64, pub rejected_fills: u64, pub memory_pressure_rejections: u64, pub hot_eviction_limited: u64, pub purges: u64, pub entries: usize, pub resident_bytes: usize, pub hot_logical_bytes: usize, pub hot_allocated_bytes: usize, pub hot_limit_bytes: usize, pub transient_limit_bytes: usize, pub cold_queue_limit_bytes: usize, pub cold_entries: usize, pub cold_bytes: u64, pub cold_limit_bytes: u64, pub cold_limit_entries: usize, pub cold_hits: u64, pub cold_promotions: u64, pub hot_demotions: u64, pub cold_evictions: u64, pub cold_errors: u64, pub cold_queue_dropped: u64, pub collapsed_fills: u64, pub collapse_timeouts: u64, pub collapse_waiters: u64, pub purge_entries: u64, pub bans_created: u64, pub bans_removed: u64, pub ban_matches: u64, pub active_bans: usize, pub proactive_scheduled: u64, pub proactive_started: u64, pub proactive_skipped: u64, pub proactive_inflight: u64, pub fills_active: usize, pub fill_bytes_active: u64, pub cold_queue_items: u64, pub cold_queue_bytes: u64, pub cold_write_bytes_active: u64, pub cold_read_bytes_active: u64, pub transient_bytes: u64, pub hit_ratio: f64, } #[derive(Debug, Clone, Default, Serialize)] pub struct DomainCounters { pub hits: u64, pub misses: u64, pub bypasses: u64, pub stored: u64, pub saved_body_bytes: u64, pub reasons: BTreeMap, }