499 lines
18 KiB
Rust
499 lines
18 KiB
Rust
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!(
|
|
(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::<HeaderValue>().is_ok(),
|
|
"cache.via_header must be a valid 1..256 byte HTTP header value"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn active_for(&self, route: Option<bool>) -> 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) {
|
|
// Origin values must never masquerade as this proxy's diagnostics.
|
|
for name in [
|
|
"x-cache",
|
|
"x-cache-hits",
|
|
"x-cache-status",
|
|
"cache-status",
|
|
"x-cache-reason",
|
|
"x-cache-resident-age",
|
|
] {
|
|
headers.remove(name);
|
|
}
|
|
// A vhost that explicitly disables cache must look like a normal reverse proxy.
|
|
// Keep counters internally, but never expose cache diagnostics for that response.
|
|
if !self.enabled {
|
|
return;
|
|
}
|
|
if let Some(value) = &self.via_header {
|
|
headers.append(
|
|
http::header::VIA,
|
|
value.parse::<HeaderValue>().expect("validated cache Via header"),
|
|
);
|
|
}
|
|
if self.expose_only_hits && outcome != "HIT" {
|
|
return;
|
|
}
|
|
if self.expose_status {
|
|
headers.insert("x-cache", HeaderValue::from_static(outcome));
|
|
}
|
|
if self.expose_hits {
|
|
headers.insert(
|
|
"x-cache-hits",
|
|
HeaderValue::from_str(&hits.to_string()).expect("integer header"),
|
|
);
|
|
}
|
|
if self.expose_cache_status {
|
|
let value = match outcome {
|
|
"HIT" => format!("RustProxy; hit; ttl={ttl}"),
|
|
"MISS" => "RustProxy; fwd=uri-miss".into(),
|
|
_ => "RustProxy; fwd=bypass".into(),
|
|
};
|
|
headers.insert(
|
|
"cache-status",
|
|
HeaderValue::from_str(&value).expect("generated cache status"),
|
|
);
|
|
}
|
|
}
|
|
pub fn annotate_resident_age(&self, headers: &mut HeaderMap, resident_age: u64) {
|
|
headers.remove("x-cache-resident-age");
|
|
if !self.enabled {
|
|
return;
|
|
}
|
|
if self.expose_resident_age {
|
|
headers.insert(
|
|
"x-cache-resident-age",
|
|
HeaderValue::from_str(&resident_age.to_string()).expect("integer header"),
|
|
);
|
|
}
|
|
}
|
|
pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) {
|
|
if !self.enabled || !self.expose_cache_status || !transformed
|
|
|| (self.expose_only_hits && !headers.get("cache-status")
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value.starts_with("RustProxy; hit"))) {
|
|
return;
|
|
}
|
|
let base = headers
|
|
.get("cache-status")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("RustProxy");
|
|
if base
|
|
.split(';')
|
|
.any(|part| part.trim().eq_ignore_ascii_case("transformed"))
|
|
{
|
|
return;
|
|
}
|
|
if let Ok(value) = format!("{base}; transformed").parse() {
|
|
headers.insert("cache-status", value);
|
|
}
|
|
}
|
|
/// Reasons are fixed internal tokens: never echo request values or secrets.
|
|
pub fn annotate_reason(
|
|
&self,
|
|
headers: &mut HeaderMap,
|
|
outcome: &'static str,
|
|
hits: u64,
|
|
ttl: u64,
|
|
reason: &'static str,
|
|
) {
|
|
self.annotate(headers, outcome, hits, ttl);
|
|
if !self.enabled || (self.expose_only_hits && outcome != "HIT") {
|
|
return;
|
|
}
|
|
if self.expose_status {
|
|
headers.insert("x-cache-reason", HeaderValue::from_static(reason));
|
|
}
|
|
if self.expose_cache_status {
|
|
let base = headers
|
|
.get("cache-status")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("RustProxy");
|
|
if let Ok(value) = format!("{base}; detail=\"{reason}\"").parse() {
|
|
headers.insert("cache-status", 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(_)))
|
|
}
|
|
|
|
/// Explicit, per-vhost-only request-side cache override. It never weakens the
|
|
/// response-side privacy guards (Set-Cookie/private/no-store/Vary rules).
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct UnsafeForce {
|
|
pub enabled: bool,
|
|
pub targets: Vec<String>,
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Per-vhost behavior, not independent memory pools. None inherits the global
|
|
/// default; the global resident-byte/entry/object ceilings remain hard limits.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct Overrides {
|
|
pub enabled: Option<bool>,
|
|
pub default_ttl_seconds: Option<u64>,
|
|
pub max_ttl_seconds: Option<u64>,
|
|
pub max_object_bytes: Option<usize>,
|
|
pub bypass_query: Option<bool>,
|
|
pub bypass_prefixes: Option<Vec<String>>,
|
|
pub static_ignore_cookies: Option<bool>,
|
|
pub static_extensions: Option<Vec<String>>,
|
|
pub expose_status: Option<bool>,
|
|
pub expose_hits: Option<bool>,
|
|
pub expose_cache_status: Option<bool>,
|
|
pub expose_resident_age: Option<bool>,
|
|
pub expose_only_hits: Option<bool>,
|
|
/// Deliberately unavailable in the global cache configuration.
|
|
pub unsafe_force: UnsafeForce,
|
|
}
|
|
impl Overrides {
|
|
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
|
|
self.unsafe_force.validate()?;
|
|
let mut result = global.clone();
|
|
// A vhost may narrow the global policy, but can never re-enable a cache
|
|
// that the administrator disabled globally.
|
|
result.enabled = global.enabled && self.enabled.unwrap_or(true);
|
|
if let Some(value) = self.default_ttl_seconds {
|
|
result.default_ttl_seconds = value;
|
|
}
|
|
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,
|
|
/// Compatibility alias for HOT allocated/charged bytes.
|
|
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,
|
|
/// Bytes currently allocated by in-progress response cache fills.
|
|
pub fill_bytes_active: u64,
|
|
/// Demoted objects accepted by the bounded cold-writer queue.
|
|
pub cold_queue_items: u64,
|
|
pub cold_queue_bytes: u64,
|
|
/// Body bytes currently owned by the cold writer after leaving the queue.
|
|
pub cold_write_bytes_active: u64,
|
|
/// Full cold bodies currently read into RAM for promotion.
|
|
pub cold_read_bytes_active: u64,
|
|
/// RustProxy-accounted cache RAM outside the published hot store.
|
|
pub transient_bytes: u64,
|
|
pub hit_ratio: f64,
|
|
}
|
|
#[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<String, u64>,
|
|
}
|