0.34.6_repo_cleanup

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 09:31:15 +02:00
1 parent ce1a90397c
commit 133d2f0f91
273 files changed
+5969 -6926

No files matched your search

-31
View File
@@ -1,4 +1,3 @@
//! Explicit network ACLs. Peer IP is default; only the central explicit trusted-proxy policy may resolve a forwarded client IP.
use anyhow::{ensure, Context, Result};
use ipnet::IpNet;
use serde::{Deserialize, Serialize};
@@ -76,20 +75,12 @@ impl Acl {
})
}
}
/// One reusable profile. Only the set of profiles attached to the same Vhost
/// is combined; local Vhost ACLs, locations and global host rules remain
/// independent restrictions.
#[derive(Debug, Clone)]
pub struct ProfileAccess {
pub acl: Option<CompiledAcl>,
pub countries: crate::rate_limit::CountryAccess,
}
/// Keep the single-profile rules unchanged. With multiple profiles, explicit
/// IP or country denies prevail across all profiles, explicit allows are a
/// union, and unmatched defaults allow only when every profile allows by
/// default. This avoids interpreting an unmatched default-deny as a veto of
/// another profile's explicit exception.
pub fn allows_profiles_with_country(
profiles: &[ProfileAccess],
peer: IpAddr,
@@ -120,8 +111,6 @@ pub fn allows_profiles_with_country(
explicit_allow || defaults_allow
}
/// The legacy singular reference remains supported. Modern clients write the
/// array only; if both exist we require both policies to avoid silent bypass.
pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator<Item = &'a str> {
legacy.into_iter().chain(ids.iter().map(String::as_str))
}
@@ -157,8 +146,6 @@ impl CompiledAcl {
allowed.then_some(Action::Allow)
}
/// Explicit deny beats all matching allow rules. Otherwise, allow or the default applies.
/// Host policy and route policy are both evaluated, so route allow cannot override host deny.
pub fn allows(&self, peer: IpAddr) -> bool {
match self.explicit_action(peer) {
Some(Action::Deny) => false,
@@ -168,10 +155,6 @@ impl CompiledAcl {
}
}
/// Evaluate the IP and country controls that belong to one access-policy scope.
/// Explicit IP denies and blocked countries always deny. In allow-only country mode,
/// a listed country or an explicit IP allow is sufficient; this lets a default-deny
/// policy express e.g. "private networks OR PL/DE" without weakening other scopes.
pub fn allows_with_country(
acl: Option<&CompiledAcl>,
countries: &crate::rate_limit::CountryAccess,
@@ -203,10 +186,6 @@ pub fn allows_with_country(
}
}
}
/// Validate the already-decoded routing representation used internally after
/// matching/rewriting. Encoded spaces from a valid request are literal spaces
/// at this stage; reserved separators, percent and dot-segment ambiguity stay
/// forbidden.
pub fn validate_decoded_routing_path(path: &str) -> Result<()> {
ensure!(
path.starts_with('/') && path.len() <= 8192,
@@ -225,10 +204,6 @@ pub fn validate_decoded_routing_path(path: &str) -> Result<()> {
Ok(())
}
/// Validate a routing path without allocating for the common already-normalized
/// form. The slow path delegates to `routing_path` to preserve percent decode,
/// UTF-8 validation, slash folding and ambiguity rejection exactly as before.
/// Callers must not use this value as the upstream URL: the raw URI is forwarded.
pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
ensure!(
raw.starts_with('/') && raw.len() <= 8192,
@@ -251,12 +226,6 @@ pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
Ok(std::borrow::Cow::Borrowed(raw))
}
/// Routing/ACL use one decoded representation; upstream still receives the original path.
/// Reject encoded separators, percent (double decoding), controls, ambiguous dot segments,
/// matrix parameters and malformed UTF-8. Repeated literal separators are
/// collapsed for routing/ACL decisions (matching nginx's default merge_slashes
/// behavior), while the upstream request still retains its original target.
/// No silent fallback to a less-restrictive route.
pub fn routing_path(raw: &str) -> Result<String> {
ensure!(
raw.starts_with('/') && raw.len() <= 8192,
+1 -12
View File
@@ -1,4 +1,3 @@
//! Bounded, fail-closed DNS/DDNS ACL address cache. No resolver runs in the request path.
use crate::config::Config;
use anyhow::{ensure, Result};
use arc_swap::ArcSwap;
@@ -36,8 +35,6 @@ pub(crate) fn matches(name: &str, ip: IpAddr) -> bool {
})
}
/// Strict ASCII DNS hostnames only (IDNs can be supplied in punycode).
/// A and AAAA results are eligible. IPs and CIDRs remain separate static rules.
pub(crate) fn hostname(value: &str) -> Result<String> {
let candidate = value.strip_suffix('.').unwrap_or(value);
ensure!(candidate.len() <= 253 && candidate.contains('.'), "ACL DNS name requires a fully qualified hostname");
@@ -53,8 +50,6 @@ pub(crate) fn hostname(value: &str) -> Result<String> {
Ok(candidate.to_ascii_lowercase())
}
/// Walk every ACL scope, including standalone host policies, ACL profiles,
/// inline vhost/location ACLs and Docker overrides. Limit process-wide DNS work.
pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
let mut names = BTreeSet::new();
let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> {
@@ -63,7 +58,6 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
if rule.network.parse::<ipnet::IpNet>().is_ok() || rule.network.parse::<IpAddr>().is_ok() {
continue;
}
// A slash is always a CIDR error, never a DNS name.
let name = hostname(&rule.network)?;
ensure!(rule.action == crate::acl::Action::Allow,
"dynamic DNS ACL rules only support allow; use static IP/CIDR for deny");
@@ -86,8 +80,6 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result<Vec<String>> {
}
async fn resolve(name: String) -> (String, Option<Addresses>) {
// Keep the returned cache key owned independently of the DNS lookup future.
// The lookup result can retain the borrowed hostname until it is dropped.
let lookup_name = name.clone();
let answer = tokio::time::timeout(
Duration::from_secs(LOOKUP_SECONDS),
@@ -129,7 +121,6 @@ pub(crate) async fn poll_loop(
let snapshot = state.config.load();
let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; };
drop(snapshot);
// Resolve concurrently with hard bounds; write one immutable snapshot.
let results: HashMap<_, _> = tokio::select! {
_ = shutdown.cancelled() => break,
result = stream::iter(names.iter().cloned())
@@ -138,7 +129,6 @@ pub(crate) async fn poll_loop(
.filter_map(|(name, answer)| async move { answer.map(|value| (name, value)) })
.collect() => result,
};
// A concurrently changed configuration must not publish obsolete DNS data.
let current = state.config.load();
if configured_hostnames(&current.envelope.config).ok().as_ref() != Some(&names) {
CHANGED.notify_one();
@@ -146,9 +136,8 @@ pub(crate) async fn poll_loop(
}
CACHE.store(Arc::new(results));
}
// Do not retain permit decisions when the resolver lifecycle stops.
CACHE.store(Arc::new(HashMap::new()));
}
#[cfg(test)]
include!("dynamic/tests.rs");
include!("tests.rs");
-3
View File
@@ -13,19 +13,16 @@ mod tests {
let ip: IpAddr = "198.51.100.4".parse().unwrap();
let entry = Addresses { ips: vec![ip], valid_until: Instant::now() + Duration::from_secs(1) };
assert!(entry.valid_until > Instant::now());
// The request path accepts only cached exact addresses, never a subnet.
let mut cache = HashMap::new(); cache.insert(name.to_owned(), entry);
CACHE.store(Arc::new(cache));
assert!(matches(name, ip));
assert!(!matches(name, "198.51.100.5".parse().unwrap()));
// A formerly resolved IP cannot remain permitted after expiration.
let mut expired = HashMap::new();
expired.insert(name.to_owned(), Addresses {
ips: vec![ip], valid_until: Instant::now() - Duration::from_secs(1),
});
CACHE.store(Arc::new(expired));
assert!(!matches(name, ip));
// IPv6 addresses have the same exact-match and rotation semantics.
let new_ip: IpAddr = "2001:db8::8".parse().unwrap();
let mut rotated = HashMap::new();
rotated.insert(name.to_owned(), Addresses {
+8
View File
@@ -360,4 +360,12 @@ mod tests {
assert!(validate_profile_ids(Some("old"), &(0..8).map(|v| format!("profile{v}")).collect::<Vec<_>>()).is_err());
}
#[test]
fn percent_encoded_spaces_are_valid_request_paths() {
assert_eq!(
routing_path("/Ubuntu%2014.04%20LTS/").unwrap(),
"/Ubuntu 14.04 LTS/"
);
}
}
+1 -5
View File
@@ -1,10 +1,6 @@
//! Bounded control-plane adapter for Certbot HTTP-01/DNS-01 providers. Never on traffic paths.
//! Provider credentials enter via stdin, are encrypted in Secret Store, and never
//! travel through argv, logs or API responses.
include!("acme/model.rs");
include!("acme/runtime_env.rs");
include!("acme/providers.rs");
include!("acme/providers/mod.rs");
include!("acme/jobs.rs");
#[cfg(test)]
include!("acme/tests.rs");
-9
View File
@@ -159,7 +159,6 @@ impl Manager {
"acme.not_due"
}
.into();
// A dry run must not postpone a due production renewal.
if !dry_run {
entry.failures = 0;
entry.next_check = now + 43200 + (rand::random::<u16>() as i64 % 1800);
@@ -231,8 +230,6 @@ async fn deploy_renewal(
.await
.map_err(|_| "acme.validation_worker_failed".to_owned())?
.map_err(|_| "acme.certificate_validation_failed".to_owned())?;
// CAS retries preserve edits to other parts of the document. A simultaneous
// change of this certificate wins over the worker; never silently overwrite it.
for _ in 0..3 {
let snapshot = state.config.load_full();
let Some(current) = snapshot
@@ -280,16 +277,10 @@ async fn deploy_renewal(
}
Err("acme.configuration_changed".into())
}
/// One scheduler per process, sharing the issuance semaphore. It does not run
/// its own shell hooks or stop listeners; validated snapshots rotate TCP and QUIC TLS.
pub async fn renewal_loop(
weak: std::sync::Weak<App>,
shutdown: tokio_util::sync::CancellationToken,
) {
// Observations are restored from SQLite before listeners are exposed. If an
// older installation has no SQLite observations, the normal scheduler will
// safely reconstruct them on its first check; certificate/key state is not
// derived from historical observation sidecars.
let delay = 300 + (rand::random::<u16>() as u64 % 300);
tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(delay)) => {} }
loop {
-1
View File
@@ -394,7 +394,6 @@ impl Manager {
finished.message = message;
}
}
// Never write provider stderr, request JSON or TSIG material to audit.
let _ = state
.store
.audit(
@@ -35,8 +35,6 @@ async fn execute(
.map_err(|_| "acme.validation_worker_failed".to_owned())?
}
/// A reserved validation path: only an active HTTP-01 job and its exact DNS
/// names can read bounded tokens. No origin, cache, auth or custom pages here.
pub async fn challenge(
axum::extract::State(state): axum::extract::State<Arc<App>>,
request: axum::extract::Request,
@@ -140,10 +138,8 @@ pub async fn challenge(
}
#[cfg(test)]
include!("providers/tests.rs");
include!("tests.rs");
/// Public diagnostics contain no account/credential material. Desired renewal
/// permission lives with the certificate; observations never overwrite it.
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RenewalState {
+7 -18
View File
@@ -13,7 +13,6 @@ async fn create_virtual_environment(
return false;
}
let _ = tokio::fs::remove_dir_all(staging).await;
// Fall back to an application-local virtualenv bootstrap.
let bootstrap = staging.with_extension("virtualenv-bootstrap");
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
let install_bootstrap = |break_system_packages: bool| {
@@ -30,30 +29,20 @@ async fn create_virtual_environment(
"--timeout",
"30",
]);
// Some distro pip builds enforce PEP 668 even for --target. Retrying with
// --break-system-packages is safe here because the destination is the
// private bootstrap directory; distribution site-packages are untouched.
if break_system_packages {
install.arg("--break-system-packages");
}
install.arg("--target").arg(&bootstrap).arg("virtualenv>=20,<21");
install
.arg("--target")
.arg(&bootstrap)
.arg("virtualenv>=20,<21");
install
};
let installed = command_success(
install_bootstrap(false),
Duration::from_secs(300),
shutdown,
)
.await;
let installed =
command_success(install_bootstrap(false), Duration::from_secs(300), shutdown).await;
if !installed {
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
if !command_success(
install_bootstrap(true),
Duration::from_secs(300),
shutdown,
)
.await
{
if !command_success(install_bootstrap(true), Duration::from_secs(300), shutdown).await {
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
return false;
}
+11 -8
View File
@@ -24,7 +24,6 @@ pub struct RuntimeStatus {
pub python: bool,
pub pip: bool,
pub certbot: bool,
/// Versions of the executables actually selected for ACME, never a guessed system default.
pub python_version: Option<String>,
pub pip_version: Option<String>,
pub certbot_version: Option<String>,
@@ -75,13 +74,14 @@ fn find_on_path(name: &str) -> Option<PathBuf> {
if candidate.components().count() > 1 {
return candidate.is_file().then(|| candidate.to_owned());
}
for directory in env::var_os("PATH").into_iter().flat_map(|paths| env::split_paths(&paths).collect::<Vec<_>>()) {
for directory in env::var_os("PATH")
.into_iter()
.flat_map(|paths| env::split_paths(&paths).collect::<Vec<_>>())
{
let path = directory.join(name);
if path.is_file() {
return Some(path);
}
// Windows PATH lookup uses PATHEXT; Path::is_file alone does not.
// Python and Certbot are installed as .exe launchers on Windows.
#[cfg(windows)]
if path.extension().is_none() {
let executable = path.with_extension("exe");
@@ -230,7 +230,8 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
let managed = data_dir.join("acme-python");
let managed_directory = Some(managed.to_string_lossy().into_owned());
if managed_environment_valid(&managed, &state.shutdown).await {
let versions = runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)));
let versions =
runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)));
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "managed",
@@ -274,7 +275,9 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
let system_versions = runtime_versions(
&python,
system_pip,
distro_certbot.as_deref().filter(|_| distro_certbot_available),
distro_certbot
.as_deref()
.filter(|_| distro_certbot_available),
);
state.acme.set_runtime(RuntimeStatus {
status: "initializing",
@@ -291,7 +294,8 @@ async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
let build = build_managed_environment(&python, &managed, system_pip, &state.shutdown).await;
if build.is_ok() {
tracing::info!(path = %managed.display(), "managed ACME runtime ready");
let versions = runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)));
let versions =
runtime_versions(&venv_python(&managed), true, Some(&venv_certbot(&managed)));
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "managed",
@@ -383,7 +387,6 @@ impl Drop for ProcessGroup {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(id) = self.0 {
// SAFETY: this process group belongs only to the managed child.
unsafe {
libc::kill(-(id as i32), libc::SIGKILL);
}
+44 -25
View File
@@ -1,7 +1,3 @@
// Version information is diagnostic, not a health check. Read package metadata
// for the *selected* runtime without launching Python, pip, Certbot or a shell.
// A missing/ambiguous version stays None; do not guess an interpreter patch
// version from an executable name or report a hard-coded Certbot pin.
use std::io::Read;
const MAX_VERSION_METADATA_BYTES: u64 = 16 * 1024;
@@ -21,7 +17,10 @@ fn metadata_text(path: &Path) -> Option<String> {
fn safe_version(raw: &str) -> Option<String> {
let version = raw.trim();
(version.len() <= 48
&& version.bytes().next().is_some_and(|byte| byte.is_ascii_digit())
&& version
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_digit())
&& version
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".+-_".contains(&byte)))
@@ -41,8 +40,6 @@ fn python_environment_root(executable: &Path) -> Option<std::path::PathBuf> {
fn interpreter_version(root: &Path) -> Option<String> {
let content = metadata_text(&root.join("pyvenv.cfg"))?;
// CPython venv writes `version = X.Y.Z`. `version_info` from virtualenv
// is a fallback when its standard `version` field is not present.
for key in ["version", "version_info"] {
for line in content.lines() {
if let Some((name, value)) = line.split_once('=') {
@@ -61,20 +58,20 @@ fn python_minor(version: &str) -> Option<String> {
let mut parts = version.split('.');
let major = parts.next()?;
let minor = parts.next()?;
(major.bytes().all(|b| b.is_ascii_digit())
&& minor.bytes().all(|b| b.is_ascii_digit()))
.then(|| format!("python{major}.{minor}"))
(major.bytes().all(|b| b.is_ascii_digit()) && minor.bytes().all(|b| b.is_ascii_digit()))
.then(|| format!("python{major}.{minor}"))
}
fn package_sites(root: &Path, python_version: Option<&str>) -> Vec<std::path::PathBuf> {
let mut sites = vec![root.join("Lib/site-packages"), root.join("lib/python3/dist-packages")];
let mut sites = vec![
root.join("Lib/site-packages"),
root.join("lib/python3/dist-packages"),
];
for base in [root.join("lib"), root.join("lib64")] {
if let Some(minor) = python_version.and_then(python_minor) {
sites.push(base.join(&minor).join("site-packages"));
sites.push(base.join(&minor).join("dist-packages"));
} else if let Ok(entries) = std::fs::read_dir(&base) {
// Python installations with no pyvenv.cfg may contain multiple
// interpreter versions. Never choose between conflicting results.
for entry in entries.take(MAX_SITE_DIRECTORIES).flatten() {
let path = entry.path();
if entry.file_name().to_string_lossy().starts_with("python3.") && path.is_dir() {
@@ -91,14 +88,26 @@ fn package_version(root: &Path, package: &str, python_version: Option<&str>) ->
let prefix = format!("{package}-");
let mut result: Option<String> = None;
for site in package_sites(root, python_version) {
let Ok(entries) = std::fs::read_dir(site) else { continue };
let Ok(entries) = std::fs::read_dir(site) else {
continue;
};
for entry in entries.take(MAX_PACKAGES_PER_SITE).flatten() {
let name = entry.file_name();
let Some(dir_name) = name.to_str() else { continue };
let Some(suffix) = dir_name.strip_prefix(prefix.as_str())
.and_then(|name| name.strip_suffix(".dist-info")) else { continue };
let Some(directory_version) = safe_version(suffix) else { continue };
let Some(content) = metadata_text(&entry.path().join("METADATA")) else { continue };
let Some(dir_name) = name.to_str() else {
continue;
};
let Some(suffix) = dir_name
.strip_prefix(prefix.as_str())
.and_then(|name| name.strip_suffix(".dist-info"))
else {
continue;
};
let Some(directory_version) = safe_version(suffix) else {
continue;
};
let Some(content) = metadata_text(&entry.path().join("METADATA")) else {
continue;
};
let mut reported_name = None;
let mut reported_version = None;
for line in content.lines() {
@@ -107,13 +116,19 @@ fn package_version(root: &Path, package: &str, python_version: Option<&str>) ->
} else if let Some(value) = line.strip_prefix("Version: ") {
reported_version = safe_version(value);
}
if reported_name.is_some() && reported_version.is_some() { break; }
if reported_name.is_some() && reported_version.is_some() {
break;
}
}
if !reported_name.is_some_and(|name| name.eq_ignore_ascii_case(package))
|| reported_version.as_deref() != Some(directory_version.as_str()) {
|| reported_version.as_deref() != Some(directory_version.as_str())
{
continue;
}
if result.as_ref().is_some_and(|previous| previous != &directory_version) {
if result
.as_ref()
.is_some_and(|previous| previous != &directory_version)
{
return None;
}
result = Some(directory_version);
@@ -133,7 +148,8 @@ fn runtime_versions(python: &Path, has_pip: bool, certbot: Option<&Path>) -> Run
let root = python_environment_root(python);
let python_version = root.as_deref().and_then(interpreter_version);
let pip_version = if has_pip {
root.as_deref().and_then(|root| package_version(root, "pip", python_version.as_deref()))
root.as_deref()
.and_then(|root| package_version(root, "pip", python_version.as_deref()))
} else {
None
};
@@ -146,6 +162,9 @@ fn runtime_versions(python: &Path, has_pip: bool, certbot: Option<&Path>) -> Run
};
package_version(&certbot_root, "certbot", certbot_python_version)
});
RuntimeVersions { python: python_version, pip: pip_version, certbot: certbot_version }
RuntimeVersions {
python: python_version,
pip: pip_version,
certbot: certbot_version,
}
}
+23 -7
View File
@@ -111,7 +111,6 @@ mod renewal_regressions {
"acme.configuration_changed"
);
assert_eq!(app.config.load().envelope.revision, 0);
// Explicit Check now remains allowed when scheduled checks are disabled.
deploy_renewal(&app, &original, &output, false)
.await
.unwrap();
@@ -147,7 +146,6 @@ mod renewal_regressions {
}
}
mod runtime_metadata_tests {
use super::*;
@@ -166,13 +164,22 @@ mod runtime_metadata_tests {
std::fs::create_dir_all(venv_bin(root)).unwrap();
std::fs::write(&python, "").unwrap();
std::fs::write(&certbot, "").unwrap();
std::fs::write(root.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.13.5\n").unwrap();
std::fs::write(
root.join("pyvenv.cfg"),
"home = /usr/bin\nversion = 3.13.5\n",
)
.unwrap();
let site = if cfg!(windows) {
root.join("Lib/site-packages")
} else {
root.join("lib/python3.13/site-packages")
};
write_package(&site, "pip", "25.2", "Metadata-Version: 2.3\nName: pip\nVersion: 25.2\n");
write_package(
&site,
"pip",
"25.2",
"Metadata-Version: 2.3\nName: pip\nVersion: 25.2\n",
);
write_package(&site, "certbot", "5.8.0", "Name: certbot\nVersion: 5.8.0\n");
let versions = runtime_versions(&python, true, Some(&certbot));
assert_eq!(versions.python.as_deref(), Some("3.13.5"));
@@ -202,9 +209,18 @@ mod runtime_metadata_tests {
let executable = root.join("bin/python3");
std::fs::create_dir_all(root.join("bin")).unwrap();
std::fs::write(&executable, "").unwrap();
write_package(&root.join("lib/python3/dist-packages"), "pip", "23.0", "Name: pip\nVersion: 23.0\n");
write_package(&root.join("lib/python3.13/site-packages"), "pip", "25.0", "Name: pip\nVersion: 25.0\n");
write_package(
&root.join("lib/python3/dist-packages"),
"pip",
"23.0",
"Name: pip\nVersion: 23.0\n",
);
write_package(
&root.join("lib/python3.13/site-packages"),
"pip",
"25.0",
"Name: pip\nVersion: 25.0\n",
);
assert!(runtime_versions(&executable, true, None).pip.is_none());
}
}
+7 -34
View File
@@ -46,8 +46,6 @@ fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
}
}
fn unauthorized(request: &Request) -> Response {
// Only top-level documentation navigation gets a fixed, same-origin login
// redirect. API/spec/assets ALWAYS return a small 401, never a login HTML body.
if (request.method() == Method::GET || request.method() == Method::HEAD)
&& matches!(request.uri().path(), "/docs" | "/docs/")
{
@@ -65,9 +63,6 @@ fn admin_request_authority(request: &Request, tls: bool) -> Result<String, ()> {
};
if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) {
// Hyper exposes :authority through the request URI for H2/H3. Browsers do
// not have to send a legacy Host field, so requiring one here rejects
// perfectly valid administration requests negotiated over ALPN h2.
let authority = request.uri().authority().ok_or(())?.as_str();
if let Some(scheme) = request.uri().scheme_str() {
if scheme != if tls { "https" } else { "http" } {
@@ -86,9 +81,6 @@ fn admin_request_authority(request: &Request, tls: bool) -> Result<String, ()> {
return Ok(authority.to_owned());
}
// The administration listener is an origin server, never a forward proxy.
// HTTP/1.x therefore keeps the existing exact single-Host requirement and
// rejects absolute-form targets.
if request.uri().scheme().is_some() || request.uri().authority().is_some() {
return Err(());
}
@@ -131,14 +123,7 @@ fn admin_host_allowed(
!a.as_str().contains('@')
&& ((direct_host && port == configured_addr.port())
// An explicit DNS allow-list describes the browser-facing
// authority. A reverse proxy is therefore free to terminate
// on 80/443 (or another external port) while forwarding to
// the private administration socket on its configured port.
|| explicit_allowed
// Preserve the historical direct-TLS convenience when no
// strict DNS allow-list exists; unlike the explicit list it
// remains bound to the actual administration listener port.
|| (dns_without_allowlist && port == configured_addr.port()))
})
}
@@ -229,21 +214,19 @@ fn external_admin_authority(
}
}
// In pinned-origin mode accept only bounded, consistent proxy-hop metadata:
// the first hop must report browser HTTPS, later internal hops may use HTTP.
// We still do NOT pick a trusted scheme from these values; the explicit pin
// defines the panel origin. A clear public HTTP or unknown value fails closed.
fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
let mut hops = 0;
for value in headers.get_all("x-forwarded-proto").iter() {
let Ok(value) = value.to_str() else { return false; };
let Ok(value) = value.to_str() else {
return false;
};
for part in value.split(',') {
hops += 1;
if hops > 8 { return false; }
if hops > 8 {
return false;
}
let scheme = part.trim();
if (hops == 1 && scheme != "https")
|| !matches!(scheme, "http" | "https")
{
if (hops == 1 && scheme != "https") || !matches!(scheme, "http" | "https") {
return false;
}
}
@@ -251,11 +234,6 @@ fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
true
}
/// A configured panel URL is authoritative only for an explicitly trusted
/// direct reverse-proxy peer. In this mode X-Forwarded-Proto is deliberately
/// ignored: conflicting hop values such as "https, http" are not evidence of
/// the browser scheme. The public URL comes from administrator configuration,
/// not from a user-controlled header.
fn configured_admin_public_authority(
state: &App,
headers: &HeaderMap,
@@ -267,7 +245,6 @@ fn configured_admin_public_authority(
return Ok(None);
};
if !trusted_admin_proxy(state, peer) {
// Never apply the pin to arbitrary direct/untrusted connections.
return Ok(None);
}
if !admin_host_allowed(
@@ -314,10 +291,6 @@ fn external_admin_scheme(
}
}
/// Plain HTTP requests accepted on an HTTPS administration socket are redirected
/// to the same authority/path. The TLS server only selects this router after it
/// has positively identified a non-TLS first byte, so admin content is never
/// served over this path.
pub fn https_redirect_router() -> Router {
Router::new().fallback(|request: Request| async move {
let raw_host = request
-2
View File
@@ -1,5 +1,3 @@
// Narrow method/path allowlist: tokens never inherit browser-only administration
// routes (password/session, credentials, backups, docs, preferences or SSE).
fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> bool {
let read = method == Method::GET
&& matches!(
-2
View File
@@ -1,5 +1,3 @@
// General-purpose API credentials are independent of browser sessions,
// certificate deployment credentials, and the Prometheus scrape credential.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateApiToken {
+24 -10
View File
@@ -119,8 +119,6 @@ async fn certificate_api_rotate(
Ok(Json(value))
}
// Per-credential settings are intentionally separate from token rotation.
// The input cannot include token/token_hash, and every read returns only metadata.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateApiEdit {
@@ -134,7 +132,10 @@ async fn certificate_api_detail(
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let credential = state
.store
@@ -152,11 +153,17 @@ async fn certificate_api_edit(
Json(input): Json<CertificateApiEdit>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let comment = input.comment.trim();
if !valid_certificate_api_comment(comment) {
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "certificate_api.invalid_comment".into()));
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"certificate_api.invalid_comment".into(),
));
}
let mut certificate_ids: Vec<String> = input
.certificate_ids
@@ -168,7 +175,9 @@ async fn certificate_api_edit(
certificate_ids.dedup();
if certificate_ids.is_empty()
|| certificate_ids.len() > 32
|| certificate_ids.iter().any(|value| value == "*" || !crate::config::valid_id(value))
|| certificate_ids
.iter()
.any(|value| value == "*" || !crate::config::valid_id(value))
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -178,7 +187,11 @@ async fn certificate_api_edit(
let credential = state
.store
.edit_certificate_api_credential(
&key_id, comment, input.allow_deploy, &certificate_ids, &session.username,
&key_id,
comment,
input.allow_deploy,
&certificate_ids,
&session.username,
)
.await
.map_err(auth::internal)?
@@ -192,7 +205,10 @@ async fn certificate_api_regenerate(
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let token = auth::random_secret();
let hash = auth::secret_hash(&token);
@@ -202,7 +218,6 @@ async fn certificate_api_regenerate(
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()))?;
// The only endpoint in this flow that returns the new bearer secret.
Ok(Json(json!({ "credential": credential, "token": token })))
}
@@ -242,4 +257,3 @@ async fn certificate_api_revoke_all(
.map_err(auth::internal)?;
Ok(Json(json!({"configured": false, "revoked": revoked})))
}
-4
View File
@@ -24,7 +24,6 @@ async fn certificate_deploy(
));
}
}
// Check the credential scope before disclosing certificate storage state.
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
ApiError(
StatusCode::CONFLICT,
@@ -106,8 +105,6 @@ async fn certificate_deploy(
let key_file = config.tls.certificates[index].key_file.clone();
if config.tls.certificates[index].covered_hosts == covered_hosts {
// A no-op does not pass through update_as(), so explicitly retain
// the same optimistic-concurrency guarantee as a real publish.
if state.config.load().envelope.revision != expected_revision {
return Err(ApiError(
StatusCode::CONFLICT,
@@ -423,7 +420,6 @@ async fn basic_hash(
) -> Result<Json<Value>, ApiError> {
crate::basic_auth::validate_username(&input.username)
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "basic.invalid_username".into()))?;
// The semaphore is held by the blocking worker even if the HTTP client disconnects.
let permit = state
.basic_workers
.clone()
-3
View File
@@ -1,4 +1,3 @@
/// Account-scoped snapshots of Overview order, visibility, widths and display.
use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -63,8 +62,6 @@ async fn dashboard_presets(
tracing::warn!("skipped invalid saved Overview preset");
continue;
};
// Compatibility with future/retired widgets: use the same registry
// reconciliation logic as ordinary saved administrator preferences.
let mut prefs = AdminPreferences::default();
prefs.dashboard_widgets = layout.dashboard_widgets;
prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets;
+3 -8
View File
@@ -1,5 +1,3 @@
//! Authenticated documentation Reader and OpenAPI/Swagger UI on the admin listener.
//! Documentation and vendor assets are embedded at compile time; no runtime filesystem or CDN access.
use axum::{
response::{Html, IntoResponse, Response},
Json,
@@ -8,9 +6,6 @@ use http::StatusCode;
use serde_json::{json, Value};
use std::sync::OnceLock;
// Keep Reader input independent from build-script output. `include_str!` makes every
// document a direct compiler input, so a clean or partially removed target directory
// cannot leave the crate pointing at a missing OUT_DIR artifact.
mod documents;
use documents::READER_DOCUMENTS;
@@ -18,7 +13,9 @@ pub(super) async fn index() -> impl IntoResponse {
Html(include_str!("web/docs.html").replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()))
}
pub(super) async fn fingerprinted(axum::extract::Path(name): axum::extract::Path<String>) -> Response {
pub(super) async fn fingerprinted(
axum::extract::Path(name): axum::extract::Path<String>,
) -> Response {
let full = format!("/docs/{name}");
match super::fingerprinted_path(&full, "/docs/").as_deref() {
Some("/docs/init.js") => script().await.into_response(),
@@ -93,8 +90,6 @@ pub(super) async fn schema() -> Json<Value> {
}
pub(super) fn asset(name: &'static str) -> Response {
// Bytes are embedded for debug AND release, and always served through the
// authenticated router. Never read a file or fetch a CDN on an HTTP request.
let (content_type, bytes): (&'static str, &'static [u8]) = match name {
"swagger-ui-bundle.js" => (
"text/javascript; charset=utf-8",
-3
View File
@@ -1,6 +1,3 @@
//! Embedded current-version Markdown guides for the authenticated documentation Reader.
//! Keep this list in sync with docs/README.md. The embedded files must exist at build time.
macro_rules! reader_doc {
($path:literal, $category:literal) => {
(
+60 -28
View File
@@ -1,12 +1,11 @@
// Bound candidate retention while scanning potentially enormous rotation folders.
// The heap's top is the least desirable result: oldest timestamp, then
// lexicographically last name. Final output preserves newest/name order.
fn newest_log_files(
candidates: impl Iterator<Item = (String, std::path::PathBuf, u64)>,
limit: usize,
) -> Vec<(String, std::path::PathBuf, u64)> {
use std::{cmp::Reverse, collections::BinaryHeap};
if limit == 0 { return Vec::new(); }
if limit == 0 {
return Vec::new();
}
let mut bounded = BinaryHeap::with_capacity(limit);
for (name, path, modified_ms) in candidates {
let item = (Reverse(modified_ms), name, path);
@@ -17,14 +16,14 @@ fn newest_log_files(
bounded.push(item);
}
}
let mut result = bounded.into_iter()
let mut result = bounded
.into_iter()
.map(|(Reverse(modified), name, path)| (name, path, modified))
.collect::<Vec<_>>();
result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
result
}
// Bounded file-log inventory helpers and gzip tail reader.
fn rotated_gzip_sources(
active: &std::path::Path,
id_prefix: &str,
@@ -33,25 +32,52 @@ fn rotated_gzip_sources(
enabled: bool,
format: &'static str,
) -> Vec<FileLogSource> {
let Some(parent) = active.parent() else { return Vec::new(); };
let Some(base) = active.file_name().and_then(|name| name.to_str()) else { return Vec::new(); };
let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if !safe { return Vec::new(); }
let Some(parent) = active.parent() else {
return Vec::new();
};
let Some(base) = active.file_name().and_then(|name| name.to_str()) else {
return Vec::new();
};
let safe = std::fs::symlink_metadata(parent)
.ok()
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if !safe {
return Vec::new();
}
let prefix = format!("{base}.");
let entries = newest_log_files(std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; }
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() { return None; }
let meta = entry.metadata().ok()?;
Some((name, entry.path(), file_modified_ms(&meta)))
}), 64);
entries.into_iter().map(|(name,path,_)| source_from_path(
format!("{id_prefix}:gz:{name}"),
format!("{label_prefix} · {name}"),
id_prefix.to_owned(),
kind, path, enabled, format
)).collect()
let entries = newest_log_files(
std::fs::read_dir(parent)
.ok()
.into_iter()
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") {
return None;
}
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() {
return None;
}
let meta = entry.metadata().ok()?;
Some((name, entry.path(), file_modified_ms(&meta)))
}),
64,
);
entries
.into_iter()
.map(|(name, path, _)| {
source_from_path(
format!("{id_prefix}:gz:{name}"),
format!("{label_prefix} · {name}"),
id_prefix.to_owned(),
kind,
path,
enabled,
format,
)
})
.collect()
}
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
@@ -81,8 +107,6 @@ fn read_tail(
let mut file = open_file_log(&source.path)?;
let length = file.metadata()?.len();
if source.compressed {
// gzip streams are not seekable. Read a complete bounded rotation, then
// keep only the same 2 MiB decompressed tail used for plain files.
if length > budget || length > FILE_LOG_GZIP_INPUT_MAX {
return Ok((Vec::new(), length.min(budget), true));
}
@@ -108,7 +132,11 @@ fn read_tail(
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
return Ok((lines, length, truncated || iter.next().is_some()));
}
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
@@ -126,6 +154,10 @@ fn read_tail(
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some()))
}
+88 -34
View File
@@ -50,8 +50,6 @@ fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchCl
true
}
// Order the entire bounded matching scan before applying offset/limit. Sorting
// only the current browser page would give misleading navigation results.
#[derive(Debug)]
enum FileLogSortKey {
Number(serde_json::Number),
@@ -59,29 +57,43 @@ enum FileLogSortKey {
}
fn stored_log_summary(value: &Value) -> String {
let field = |name| value.get(name).and_then(Value::as_str).filter(|text| !text.is_empty());
let field = |name| {
value
.get(name)
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
};
if let Some(reason) = field("reason").or_else(|| field("protocol_error")) {
return reason.to_owned();
}
if field("method").is_some() || field("path").is_some() {
return format!("{} {}{}", field("method").unwrap_or(""),
return format!(
"{} {}{}",
field("method").unwrap_or(""),
field("host").or_else(|| field("authority")).unwrap_or(""),
field("path").unwrap_or("")).trim().to_owned();
field("path").unwrap_or("")
)
.trim()
.to_owned();
}
for key in ["error", "error_detail", "message", "raw", "outcome"] {
if let Some(text) = field(key) { return text.to_owned(); }
if let Some(text) = field(key) {
return text.to_owned();
}
}
String::new()
}
fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> {
if field == "record" {
return Some(FileLogSortKey::Text(stored_log_summary(record).to_lowercase()));
return Some(FileLogSortKey::Text(
stored_log_summary(record).to_lowercase(),
));
}
// Prefer millisecond timestamps for numeric chronological sorting. RAW
// file timestamps use the existing ISO/text fallback.
let value = if field == "time" {
record.get("time_ms").filter(|value| value.is_number())
record
.get("time_ms")
.filter(|value| value.is_number())
.or_else(|| record.get("timestamp"))
} else {
file_log_field(record, field)
@@ -91,39 +103,51 @@ fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> {
Value::Number(number) => Some(FileLogSortKey::Number(number.clone())),
Value::String(text) if !text.is_empty() => Some(FileLogSortKey::Text(text.to_lowercase())),
Value::Bool(boolean) => Some(FileLogSortKey::Text(boolean.to_string())),
Value::Array(_) | Value::Object(_) => Some(FileLogSortKey::Text(value.to_string().to_lowercase())),
Value::Array(_) | Value::Object(_) => {
Some(FileLogSortKey::Text(value.to_string().to_lowercase()))
}
_ => None,
}
}
fn file_log_timestamp_cmp_desc(left: &Value, right: &Value) -> std::cmp::Ordering {
match (left.get("time_ms").and_then(Value::as_u64), right.get("time_ms").and_then(Value::as_u64)) {
match (
left.get("time_ms").and_then(Value::as_u64),
right.get("time_ms").and_then(Value::as_u64),
) {
(Some(a), Some(b)) => b.cmp(&a),
_ => right.get("timestamp").and_then(Value::as_str).unwrap_or("")
_ => right
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.cmp(left.get("timestamp").and_then(Value::as_str).unwrap_or("")),
}
}
fn sort_file_log_records(records: &mut Vec<Value>, field: &str, descending: bool) {
use std::cmp::Ordering;
// Build one key per record; do not repeatedly allocate/parse strings in
// the O(n log n) comparison loop. The scan itself is limited to 20k lines.
let mut keyed = std::mem::take(records).into_iter().enumerate()
let mut keyed = std::mem::take(records)
.into_iter()
.enumerate()
.map(|(ordinal, record)| (stored_log_sort_key(&record, field), ordinal, record))
.collect::<Vec<_>>();
keyed.sort_by(|left, right| {
let order = match (&left.0, &right.0) {
(None, None) => Ordering::Equal,
(None, Some(_)) => return Ordering::Greater, // missing values always last
(None, Some(_)) => return Ordering::Greater,
(Some(_), None) => return Ordering::Less,
(Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) =>
(Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) => {
match (a.as_i64(), b.as_i64()) {
(Some(left), Some(right)) => left.cmp(&right),
_ => match (a.as_u64(), b.as_u64()) {
(Some(left), Some(right)) => left.cmp(&right),
_ => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(Ordering::Equal),
_ => a
.as_f64()
.partial_cmp(&b.as_f64())
.unwrap_or(Ordering::Equal),
},
},
}
}
(Some(FileLogSortKey::Text(a)), Some(FileLogSortKey::Text(b))) => a.cmp(b),
(Some(FileLogSortKey::Number(_)), Some(FileLogSortKey::Text(_))) => Ordering::Less,
(Some(FileLogSortKey::Text(_)), Some(FileLogSortKey::Number(_))) => Ordering::Greater,
@@ -145,10 +169,20 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|| !text_ok(&query.q, 512)
|| !text_ok(&query.client_ip, 128)
|| !text_ok(&query.vhost, 256)
|| query.aggregate_field.as_ref().is_some_and(|field| !valid_raw_field_key(field))
|| query.sort_by.as_ref().is_some_and(|field| !valid_raw_field_key(field))
|| query.aggregate_limit.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS)
|| query.aggregate_min_count.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES)
|| query
.aggregate_field
.as_ref()
.is_some_and(|field| !valid_raw_field_key(field))
|| query
.sort_by
.as_ref()
.is_some_and(|field| !valid_raw_field_key(field))
|| query
.aggregate_limit
.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS)
|| query
.aggregate_min_count
.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES)
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|| query
.limit
@@ -187,8 +221,11 @@ async fn file_logs_query(
let requested = query.source.as_deref().unwrap_or("all");
let mut truncated = false;
if requested != "all" {
let consolidate_vhosts = query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:");
sources.retain(|source| source.id == requested || (consolidate_vhosts && source.group_id == requested));
let consolidate_vhosts =
query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:");
sources.retain(|source| {
source.id == requested || (consolidate_vhosts && source.group_id == requested)
});
if sources.is_empty() {
return Err(ApiError(
StatusCode::BAD_REQUEST,
@@ -210,9 +247,15 @@ async fn file_logs_query(
let mut scanned_lines = 0usize;
let mut records = Vec::new();
let mut fields = std::collections::BTreeSet::new();
let aggregate_field = query.aggregate_field.as_deref().map(str::to_ascii_lowercase);
let aggregate_field = query
.aggregate_field
.as_deref()
.map(str::to_ascii_lowercase);
let aggregate_min_count = query.aggregate_min_count.unwrap_or(2);
let aggregate_limit = query.aggregate_limit.unwrap_or(50).clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS);
let aggregate_limit = query
.aggregate_limit
.unwrap_or(50)
.clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS);
let mut aggregate_counts = std::collections::HashMap::<String, usize>::new();
let mut aggregate_truncated = false;
for (index, source) in sources.iter().enumerate() {
@@ -241,8 +284,13 @@ async fn file_logs_query(
let mut values = Vec::new();
file_log_aggregate_values(field_value, &mut values);
for aggregate_value in values {
if aggregate_counts.contains_key(&aggregate_value) || aggregate_counts.len() < FILE_LOG_MAX_AGGREGATE_VALUES {
*aggregate_counts.entry(aggregate_value).or_insert(0) += 1;
if aggregate_counts.contains_key(&aggregate_value)
|| aggregate_counts.len()
< FILE_LOG_MAX_AGGREGATE_VALUES
{
*aggregate_counts
.entry(aggregate_value)
.or_insert(0) += 1;
} else {
aggregate_truncated = true;
}
@@ -257,9 +305,13 @@ async fn file_logs_query(
Err(_) => truncated = true,
}
}
sort_file_log_records(&mut records, query.sort_by.as_deref().unwrap_or("time"),
query.sort_descending.unwrap_or(true));
let mut aggregation = aggregate_counts.into_iter()
sort_file_log_records(
&mut records,
query.sort_by.as_deref().unwrap_or("time"),
query.sort_descending.unwrap_or(true),
);
let mut aggregation = aggregate_counts
.into_iter()
.filter(|(_, count)| *count >= aggregate_min_count)
.map(|(value, count)| json!({"value": value, "count": count}))
.collect::<Vec<_>>();
@@ -267,7 +319,9 @@ async fn file_logs_query(
let left_count = left.get("count").and_then(Value::as_u64).unwrap_or(0);
let right_count = right.get("count").and_then(Value::as_u64).unwrap_or(0);
right_count.cmp(&left_count).then_with(|| {
left.get("value").and_then(Value::as_str).unwrap_or("")
left.get("value")
.and_then(Value::as_str)
.unwrap_or("")
.cmp(right.get("value").and_then(Value::as_str).unwrap_or(""))
})
});
-4
View File
@@ -52,8 +52,6 @@ async fn update_section(
config.logging = part.logging;
config.error_log = part.error_log;
} else {
// Backward compatibility for clients that still send only the
// historical access-logging object to /api/config/logging.
config.logging =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?;
}
@@ -334,8 +332,6 @@ async fn events(
"events.too_many_viewers".into(),
)
})?;
// One bounded snapshot, produced only when the consumer polls. No per-client queue.
// Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions.
let stream = futures_util::stream::unfold(
(state, secret, permit, false, interval),
|(state, secret, permit, delayed, interval)| async move {
-2
View File
@@ -1,5 +1,3 @@
//! Local control plane. Never mounted on the public proxy listener.
include!("router.rs");
include!("access.rs");
include!("api_token_access.rs");
+31 -11
View File
@@ -79,8 +79,16 @@ async fn cache_purge(
));
}
}
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
let permit = state
.cache_invalidation_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"cache maintenance busy".into(),
)
})?;
let detail = format!(
"scope={}; in-flight fills invalidated",
scope.vhost.as_deref().unwrap_or("all")
@@ -98,7 +106,9 @@ async fn cache_purge(
Some(id) => cache.purge_scope(id),
None => cache.purge(None),
}
}).await.map_err(auth::internal)?;
})
.await
.map_err(auth::internal)?;
Ok(Json(
json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}),
))
@@ -126,8 +136,16 @@ async fn cache_invalidate(
));
}
}
let permit = state.cache_invalidation_slots.clone().try_acquire_owned()
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "cache maintenance busy".into()))?;
let permit = state
.cache_invalidation_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"cache maintenance busy".into(),
)
})?;
let detail = format!(
"operation={:?}; selector={:?}; vhost={}; host={}; value={:?}",
request.operation,
@@ -145,8 +163,10 @@ async fn cache_invalidate(
let result = tokio::task::spawn_blocking(move || {
let _permit = permit;
cache.invalidate(request)
}).await.map_err(auth::internal)?
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
})
.await
.map_err(auth::internal)?
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
Ok(Json(json!({
"result": result,
"metrics": state.cache.view(),
@@ -163,8 +183,6 @@ async fn cache_ban_delete(
Extension(session): Extension<Session>,
Path(id): Path<u64>,
) -> Result<Json<Value>, ApiError> {
// This is a destructive operation: fail closed if the audit store is
// unavailable, rather than removing a ban and only then trying to log it.
if !state.cache.bans().iter().any(|rule| rule.id == id) {
return Err(ApiError(
StatusCode::NOT_FOUND,
@@ -181,7 +199,10 @@ async fn cache_ban_delete(
.await
.map_err(auth::internal)?;
if !state.cache.remove_ban(id) {
return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"unknown cache BAN rule".into(),
));
}
Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
}
@@ -322,7 +343,6 @@ async fn certificate_upload(
.audit(&session.username, "certificate.upload", &certificate.id)
.await
.map_err(auth::internal)?;
// Never serialize the PEM request or any key material, including on success.
Ok(Json(json!({"certificate":certificate, "staged":true})))
}
+75 -16
View File
@@ -1,10 +1,14 @@
// Fixed private admin assets, shared by all admins. The existing route/access
// allowlist decides which paths are reachable. Do not expand routes dynamically.
use super::panel_content::PanelContent;
use crate::runtime::App;
use axum::{extract::{Request, State}, response::{IntoResponse, Response}};
use axum::{
extract::{Request, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use std::{collections::HashMap, sync::{Arc, LazyLock, OnceLock}};
use std::{
collections::HashMap,
sync::{Arc, LazyLock, OnceLock},
};
struct Asset {
mime: &'static str,
@@ -20,21 +24,57 @@ const SVG: &str = "image/svg+xml";
static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
let entries: [(&'static str, &'static str, &'static str); 22] = [
("/assets/login.js", JS, include_str!("web/login.js")),
("/assets/login-langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/login-langs.json"))),
("/assets/langs.json", JSON, include_str!(concat!(env!("OUT_DIR"), "/langs.json"))),
(
"/assets/login-langs.json",
JSON,
include_str!(concat!(env!("OUT_DIR"), "/login-langs.json")),
),
(
"/assets/langs.json",
JSON,
include_str!(concat!(env!("OUT_DIR"), "/langs.json")),
),
("/assets/app.js", JS, super::admin_app_javascript()),
("/assets/management.js", JS, include_str!("web/management.js")),
(
"/assets/management.js",
JS,
include_str!("web/management.js"),
),
("/assets/drafts.js", JS, include_str!("web/drafts.js")),
("/assets/integrations.js", JS, include_str!("web/integrations.js")),
("/assets/traffic-panel.js", JS, include_str!("web/traffic-panel.js")),
("/assets/cache-panel.js", JS, include_str!("web/cache-panel.js")),
("/assets/transform-panel.js", JS, include_str!("web/transform-panel.js")),
(
"/assets/integrations.js",
JS,
include_str!("web/integrations.js"),
),
(
"/assets/traffic-panel.js",
JS,
include_str!("web/traffic-panel.js"),
),
(
"/assets/cache-panel.js",
JS,
include_str!("web/cache-panel.js"),
),
(
"/assets/transform-panel.js",
JS,
include_str!("web/transform-panel.js"),
),
("/assets/file-logs.js", JS, include_str!("web/file-logs.js")),
("/assets/settings.js", JS, include_str!("web/settings.js")),
("/assets/acme.js", JS, include_str!("web/acme.js")),
("/assets/vhosts.js", JS, super::admin_vhosts_javascript()),
("/assets/web-server-profiles.js", JS, include_str!("web/web-server-profiles.js")),
("/assets/web-server.js", JS, include_str!("web/web-server.js")),
(
"/assets/web-server-profiles.js",
JS,
include_str!("web/web-server-profiles.js"),
),
(
"/assets/web-server.js",
JS,
include_str!("web/web-server.js"),
),
("/assets/docker.js", JS, include_str!("web/docker.js")),
("/assets/streams.js", JS, include_str!("web/streams.js")),
("/assets/redirects.js", JS, include_str!("web/redirects.js")),
@@ -42,7 +82,18 @@ static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
("/assets/style.css", CSS, include_str!("web/style.css")),
("/favicon.svg", SVG, include_str!("web/favicon.svg")),
];
entries.into_iter().map(|(path, mime, raw)| (path, Asset { mime, raw, content: OnceLock::new() }))
entries
.into_iter()
.map(|(path, mime, raw)| {
(
path,
Asset {
mime,
raw,
content: OnceLock::new(),
},
)
})
.collect()
});
@@ -50,7 +101,13 @@ pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Re
let resolved = super::fingerprinted_path(request.uri().path(), "/assets/");
let path = match resolved.as_deref() {
Some(path) => path,
None if matches!(request.uri().path(), "/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg") => request.uri().path(),
None if matches!(
request.uri().path(),
"/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg"
) =>
{
request.uri().path()
}
None => return StatusCode::NOT_FOUND.into_response(),
};
let Some(asset) = ASSETS.get(path) else {
@@ -58,6 +115,8 @@ pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Re
};
let snapshot = state.config.load();
let codecs = &snapshot.envelope.config.administration.panel_compression;
asset.content.get_or_init(|| PanelContent::new(asset.raw))
asset
.content
.get_or_init(|| PanelContent::new(asset.raw))
.response(request.headers(), codecs, asset.mime)
}
+33 -11
View File
@@ -1,5 +1,3 @@
// Shared immutable precompressed responses for private panel HTML and assets.
// Each embedded document is encoded once on first request, never per visitor.
use crate::settings::PanelCompression;
use axum::{body::Body, response::Response};
use bytes::Bytes;
@@ -22,16 +20,31 @@ impl PanelContent {
let mut br = Vec::new();
{
let mut writer = brotli::CompressorWriter::new(&mut br, 4096, 5, 22);
writer.write_all(raw.as_bytes()).expect("compress panel Brotli");
writer
.write_all(raw.as_bytes())
.expect("compress panel Brotli");
}
let zstd = zstd::stream::encode_all(raw.as_bytes(), 3).expect("compress panel zstd");
Self { plain: Bytes::from_static(raw.as_bytes()), gzip, brotli: Bytes::from(br), zstd: Bytes::from(zstd) }
Self {
plain: Bytes::from_static(raw.as_bytes()),
gzip,
brotli: Bytes::from(br),
zstd: Bytes::from(zstd),
}
}
pub(super) fn response(&self, headers: &HeaderMap, codecs: &PanelCompression, mime: &'static str) -> Response {
pub(super) fn response(
&self,
headers: &HeaderMap,
codecs: &PanelCompression,
mime: &'static str,
) -> Response {
let settings = crate::compression::Settings {
gzip: codecs.gzip, br: codecs.br, zstd: codecs.zstd,
deflate: false, min_bytes: 1024,
gzip: codecs.gzip,
br: codecs.br,
zstd: codecs.zstd,
deflate: false,
min_bytes: 1024,
};
let encoding = if crate::compression::no_transform(headers) {
None
@@ -45,12 +58,21 @@ impl PanelContent {
_ => (self.plain.clone(), None),
};
let mut response = Response::new(Body::from(body.clone()));
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
response.headers_mut().insert(header::CONTENT_LENGTH, HeaderValue::from_str(&body.len().to_string()).expect("valid length"));
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&body.len().to_string()).expect("valid length"),
);
if let Some(name) = name {
response.headers_mut().insert(header::CONTENT_ENCODING, HeaderValue::from_static(name));
response
.headers_mut()
.insert(header::CONTENT_ENCODING, HeaderValue::from_static(name));
}
response.headers_mut().insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
response
.headers_mut()
.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
response
}
}
-7
View File
@@ -27,9 +27,6 @@ fn default_admin_table_density() -> String {
fn default_admin_dashboard_density() -> String {
DASHBOARD_DEFAULT_PRESET.dashboard_density.clone()
}
// Preserve the user's saved order/visibility, drop identifiers unknown to this
// binary, and append new registry widgets according to their opt-in policy.
// Stored preferences from older/newer releases are normalized on read.
fn reconcile_dashboard_widgets(prefs: &mut AdminPreferences) {
let mut seen = std::collections::HashSet::new();
prefs.dashboard_widgets.retain(|id| {
@@ -131,8 +128,6 @@ struct AdminPreferences {
dashboard_widgets: Vec<String>,
#[serde(default = "default_hidden_dashboard_widgets")]
dashboard_hidden_widgets: Vec<String>,
// Null/absent: retain legacy visual grouping for existing installations.
// A map (including {}) means that order is fully user-controlled.
#[serde(default)]
dashboard_widget_sizes: Option<std::collections::BTreeMap<String, u8>>,
#[serde(default = "default_file_log_columns")]
@@ -473,8 +468,6 @@ async fn update_preferences(
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, ApiError> {
let Json(mut preferences) = input.map_err(public::invalid_json)?;
// Do not rewrite the caller's saved order on PUT; drop only stale IDs.
// Legacy partial lists are expanded on the next GET, as before.
preferences
.dashboard_widgets
.retain(|id| DASHBOARD_WIDGETS.iter().any(|widget| widget.id == *id));
-7
View File
@@ -75,8 +75,6 @@ const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
"maxConnections",
"idleTimeout",
];
// The same JSON registry is embedded into the authenticated admin JS bundle.
// No independent allowlist is maintained by the browser or backend.
#[derive(serde::Deserialize)]
struct DashboardWidgetDefinition {
id: String,
@@ -89,9 +87,6 @@ static DASHBOARD_WIDGETS: std::sync::LazyLock<Vec<DashboardWidgetDefinition>> =
.expect("invalid bundled Overview Cards registry")
});
// A single operator-curated factory preset feeds both the Rust API defaults
// and the authenticated dashboard. New registry widgets remain opt-in unless
// explicitly added to the preset; saved user preferences are never rewritten.
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct DashboardDefaultPreset {
@@ -147,8 +142,6 @@ static DASHBOARD_DEFAULT_PRESET: std::sync::LazyLock<DashboardDefaultPreset> =
preset
});
// None denotes the legacy grouped layout. Some({}) switches to freely
// ordered 12-column CSS Grid while retaining every historical card width.
fn valid_dashboard_span(display_type: &str, span: u8) -> bool {
match display_type {
"table" => matches!(span, 6 | 12),
-2
View File
@@ -123,7 +123,6 @@ fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() {
.dashboard_hidden_widgets
.contains(&"extra_process_cpu".into()));
assert!(prefs.dashboard_widgets.contains(&"backend_table".into()));
// 0.33.0 WWW cards stay opt-in when upgrading an old saved custom layout.
for id in [
"www_status",
"www_kind",
@@ -186,7 +185,6 @@ fn saved_dashboard_order_and_visibility_survive_new_factory_preset() {
assert!(prefs
.dashboard_hidden_widgets
.contains(&"backend_traffic".to_owned()));
// WWW was absent from the saved document and is introduced only as opt-in.
assert!(prefs
.dashboard_hidden_widgets
.contains(&"www_status".to_owned()));
@@ -1,4 +1,3 @@
//! Deliberately small anonymous surface. Operational templates/assets are private.
use crate::{
auth::Session,
runtime::{ApiError, App},
@@ -18,22 +17,19 @@ static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
let content = ADMIN_INDEX.get_or_init(|| {
// Compile-time embedded HTML fragments are assembled once at startup.
// No additional browser requests, changed DOM nodes or script ordering.
let (metrics, web_metrics) = include_str!("web/partials/overview-metrics.html")
let (metrics, web_metrics) = include_str!("../web/partials/overview-metrics.html")
.split_once("<!-- RP_METRICS_SPLIT -->")
.expect("invalid Overview metrics partial");
let (charts, web_charts) = include_str!("web/partials/overview-charts.html")
let (charts, web_charts) = include_str!("../web/partials/overview-charts.html")
.split_once("<!-- RP_CHARTS_SPLIT -->")
.expect("invalid Overview charts partial");
let html = include_str!("web/index.html")
let html = include_str!("../web/index.html")
.replace("<!-- RP_METRICS_MAIN -->", metrics)
.replace("<!-- RP_CHARTS_MAIN -->", charts)
.replace("<!-- RP_METRICS_WEB -->", web_metrics)
.replace("<!-- RP_CHARTS_WEB -->", web_charts)
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"));
// Exactly one static panel document shared between all administrator accounts.
PanelContent::new(Box::leak(html.into_boxed_str()))
});
content.response(headers, compression, "text/html; charset=utf-8")
@@ -45,7 +41,7 @@ pub(super) async fn index(
headers: HeaderMap,
) -> Response {
if session.is_none() {
let html = include_str!("web/login.html")
let html = include_str!("../web/login.html")
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id());
return Html(html).into_response();
}
@@ -57,7 +53,6 @@ pub(super) async fn index(
}
pub(super) fn invalid_json(error: JsonRejection) -> ApiError {
// Keep useful protocol statuses without returning serde's input excerpts.
let status = match error.status() {
StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE,
StatusCode::UNSUPPORTED_MEDIA_TYPE => StatusCode::UNSUPPORTED_MEDIA_TYPE,
+4 -3
View File
@@ -1,4 +1,3 @@
// Stored inside the existing per-account administration preferences document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
struct RefreshPreferences {
@@ -36,6 +35,8 @@ fn validate_refresh_preferences(refresh: &RefreshPreferences) -> Result<(), ApiE
}
fn default_live_log_columns() -> Vec<String> {
LIVE_LOG_COLUMNS.iter().map(|column| (*column).to_owned()).collect()
LIVE_LOG_COLUMNS
.iter()
.map(|column| (*column).to_owned())
.collect()
}
-10
View File
@@ -3,8 +3,6 @@ mod panel_assets;
mod panel_content;
mod public;
// Embedded asset URLs change with every build, independent of Cargo package version.
// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404.
fn asset_build_id() -> &'static str {
include_str!(concat!(env!("OUT_DIR"), "/asset-build-id.txt"))
}
@@ -57,8 +55,6 @@ fn admin_app_javascript() -> &'static str {
"const ADMIN_DASHBOARD_DEFAULTS = Object.freeze(",
include_str!("web/dashboard-defaults.json"),
");",
// The samples controller is needed by top-level listeners in core.js.
// Define it first; DOM initialization and translations happen later.
include_str!("web/app/overview-samples.js"),
include_str!("web/app/core.js"),
include_str!("web/app/session-resume.js"),
@@ -286,8 +282,6 @@ pub fn router(state: Arc<App>) -> Router {
.layer(middleware::from_fn_with_state(state.clone(), observe_admin))
.with_state(state)
}
// Separate from proxy exchanges: measure only time to admin response headers,
// including access denials. SSE stream lifetime is intentionally not included.
async fn observe_admin(State(state): State<Arc<App>>, request: Request, next: Next) -> Response {
use std::sync::atomic::Ordering;
state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed);
@@ -325,14 +319,10 @@ fn secure(mut response: Response) -> Response {
"permissions-policy",
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
// Do not let downstream middleware add implementation/version fingerprints.
h.remove("server");
h.remove("x-powered-by");
response
}
// This is a method-and-path allowlist, NOT a path prefix. Everything else,
// including unknown routes, schema, metrics, assets and method mismatches,
// requires an active administrator session before extractors read a body.
fn panel_document_path(path: &str) -> bool {
matches!(
path,
+111 -66
View File
@@ -1,9 +1,5 @@
// Only log browser origin metadata: never cookies, CSRF tokens, Authorization,
// query strings, or untrusted forwarding data. Malformed origins are redacted.
fn diagnostic_admin_authority(value: &str) -> &str {
if value.len() <= 255 && !value.contains('@')
&& value.parse::<http::uri::Authority>().is_ok()
{
if value.len() <= 255 && !value.contains('@') && value.parse::<http::uri::Authority>().is_ok() {
value
} else {
"(invalid)"
@@ -47,7 +43,11 @@ fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted:
return "(invalid)";
};
if name == "x-forwarded-proto" {
if matches!(value, "http" | "https") { value } else { "(invalid)" }
if matches!(value, "http" | "https") {
value
} else {
"(invalid)"
}
} else {
diagnostic_admin_authority(value)
}
@@ -63,14 +63,19 @@ fn invalid_admin_origin(
) -> Response {
let headers = request.headers();
let path = request.uri().path();
let path = if path.len() <= 160 { path } else { "(long_path)" };
let path = if path.len() <= 160 {
path
} else {
"(long_path)"
};
let browser_host = single_header(headers, HOST.as_str())
.map(diagnostic_admin_authority)
.unwrap_or("(absent_or_duplicate)");
let browser_fetch_site = match single_header(headers, "sec-fetch-site") {
None => "(absent)",
Some("same-origin" | "same-site" | "cross-site" | "none") =>
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)"),
Some("same-origin" | "same-site" | "cross-site" | "none") => {
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)")
}
_ => "(invalid)",
};
tracing::warn!(
@@ -88,12 +93,15 @@ fn invalid_admin_origin(
forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto),
"administration origin rejected"
);
// When a browser navigates to the dashboard document, JSON here would
// replace the entire page with a raw API error. Keep the 403 (no shell or
// protected data) but present a standalone recovery message instead.
let wants_html = request.headers().get(http::header::ACCEPT)
let wants_html = request
.headers()
.get(http::header::ACCEPT)
.and_then(|header| header.to_str().ok())
.is_some_and(|value| value.split(',').any(|entry| entry.trim_start().starts_with("text/html")));
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim_start().starts_with("text/html"))
});
if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html {
return secure((
StatusCode::FORBIDDEN,
@@ -117,10 +125,8 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let headers = request.headers();
let trusted_host = trusted_admin_proxy(&state, peer);
let trusted_proto = trusted_admin_forwarded_proto(&state, peer);
// Pinned browser origin is panel-only. Certificate automation using API
// credentials keeps the original forwarding/TLS authorization semantics.
let automation_credentials = headers.contains_key(CERTIFICATE_API_KEY_HEADER)
|| headers.contains_key(AUTHORIZATION);
let automation_credentials =
headers.contains_key(CERTIFICATE_API_KEY_HEADER) || headers.contains_key(AUTHORIZATION);
let pinned_authority_result = if automation_credentials {
Ok(None)
} else {
@@ -128,25 +134,42 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
};
let pinned_authority = match pinned_authority_result {
Ok(value) => value,
Err(()) => return invalid_admin_origin(&request, "pinned_origin_mismatch", Some("https"),
state.active_administration.public_origin.as_deref().and_then(|value| value.strip_prefix("https://")),
trusted_host, trusted_proto),
Err(()) => {
return invalid_admin_origin(
&request,
"pinned_origin_mismatch",
Some("https"),
state
.active_administration
.public_origin
.as_deref()
.and_then(|value| value.strip_prefix("https://")),
trusted_host,
trusted_proto,
)
}
};
let (scheme, external_authority) = if let Some(authority) = pinned_authority {
// HTTPS is a trusted, administrator-pinned identity here. Do not select
// any list element of an ambiguous forwarded-proto chain.
("https", authority)
} else {
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
Ok(value) => value,
Err(()) => {
return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto);
return invalid_admin_origin(
&request,
"external_scheme",
None,
None,
trusted_host,
trusted_proto,
);
}
};
let authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
};
let authority =
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
};
(scheme, authority)
};
if headers.get_all(ORIGIN).iter().count() > 1
@@ -156,13 +179,27 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
})
{
return invalid_admin_origin(&request, "origin_mismatch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto);
return invalid_admin_origin(
&request,
"origin_mismatch",
Some(scheme),
Some(&external_authority),
trusted_host,
trusted_proto,
);
}
if headers
.get("sec-fetch-site")
.is_some_and(|h| h == "cross-site")
{
return invalid_admin_origin(&request, "cross_site_fetch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto);
return invalid_admin_origin(
&request,
"cross_site_fetch",
Some(scheme),
Some(&external_authority),
trusted_host,
trusted_proto,
);
}
let path = request.uri().path().to_owned();
let public = anonymous_allowed(request.method(), &path);
@@ -174,7 +211,10 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let mut certificate_api_access = None;
let certificate_api_session = if headers.contains_key(CERTIFICATE_API_KEY_HEADER) {
let Some(operation) = certificate_api_operation.as_ref() else {
return secure(reject(StatusCode::FORBIDDEN, "certificate_api.invalid_route"));
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.invalid_route",
));
};
match certificate_api_credentials(headers) {
Ok(None) => None,
@@ -243,35 +283,47 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
} else {
None
};
let general_api_session = if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) {
// No cookies are accepted as a fallback when a malformed Bearer header is supplied.
let bearer = single_header(headers, AUTHORIZATION.as_str())
.and_then(|header| header.strip_prefix("Bearer "));
let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else {
return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token"));
let general_api_session =
if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) {
let bearer = single_header(headers, AUTHORIZATION.as_str())
.and_then(|header| header.strip_prefix("Bearer "));
let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else {
return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token"));
};
let Some(peer) = peer else {
return secure(reject(StatusCode::FORBIDDEN, "api_token.peer_required"));
};
if !peer.0.ip().is_loopback() && scheme != "https" {
return secure(reject(StatusCode::FORBIDDEN, "api_token.https_required"));
}
let hash = auth::secret_hash(token);
let peer_ip = crate::vhost::canonical_ip(peer.0.ip());
let grant = match state.store.verify_api_token(&hash, peer_ip).await {
Ok(Some(grant)) => grant,
Ok(None) => {
return secure(reject(
StatusCode::UNAUTHORIZED,
"api_token.invalid_token_or_ip",
))
}
Err(error) => return secure(auth::internal(error).into_response()),
};
if !general_api_token_allowed(request.method(), &path, &grant.permission) {
return secure(reject(
StatusCode::FORBIDDEN,
"api_token.insufficient_permission",
));
}
Some(Session {
token_hash: hash,
csrf: String::new(),
username: format!("api-token:{}", grant.id),
expires_at: auth::now().saturating_add(60),
generation: 0,
})
} else {
None
};
let Some(peer) = peer else {
return secure(reject(StatusCode::FORBIDDEN, "api_token.peer_required"));
};
if !peer.0.ip().is_loopback() && scheme != "https" {
return secure(reject(StatusCode::FORBIDDEN, "api_token.https_required"));
}
let hash = auth::secret_hash(token);
let peer_ip = crate::vhost::canonical_ip(peer.0.ip());
let grant = match state.store.verify_api_token(&hash, peer_ip).await {
Ok(Some(grant)) => grant,
Ok(None) => return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token_or_ip")),
Err(error) => return secure(auth::internal(error).into_response()),
};
if !general_api_token_allowed(request.method(), &path, &grant.permission) {
return secure(reject(StatusCode::FORBIDDEN, "api_token.insufficient_permission"));
}
Some(Session {
token_hash: hash, csrf: String::new(),
username: format!("api-token:{}", grant.id),
expires_at: auth::now().saturating_add(60), generation: 0,
})
} else { None };
let external_credential = certificate_api_session.is_some() || general_api_session.is_some();
let session_seconds = state
.config
@@ -280,9 +332,6 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
.config
.administration
.session_seconds();
// Panel document routes optionally authenticate so a direct refresh can choose
// the application shell when the browser still has a valid cookie. Public static
// login resources and capability discovery never load operational state.
let session = if certificate_api_session.is_some() {
certificate_api_session
} else if general_api_session.is_some() {
@@ -352,16 +401,12 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
}
}
if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() {
// Swagger UI sets inline layout styles. This exception is limited to the
// authenticated documentation document; scripts remain self-only/no-eval.
response.headers_mut().insert("content-security-policy", HeaderValue::from_static(
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"));
}
response
}
async fn auth_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
// The one bit needed to render first-account setup; no version, paths, names,
// listeners, provider state, metrics or configuration before authentication.
Ok(Json(
json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}),
))
-7
View File
@@ -228,8 +228,6 @@ fn status_payload(state: &App) -> Value {
});
payload
}
// SSE and emergency polling use only volatile telemetry. Never serialize the
// whole configuration, OS diagnostics or certificate metadata on each tick.
include!("status_live.rs");
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
let snapshot = state.config.load_full();
@@ -275,9 +273,6 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
let config = &snapshot.envelope.config;
let configured_certificates = config.tls.certificates.clone();
let certificate_directory = state.policy.cert_dir.clone();
// Inspect only public certificate files on a blocking worker; never read keys.
// Live PEM coverage wins so in-place renewals are reflected immediately; stored
// metadata is only a fallback when the public certificate file cannot be read.
let inspected_metadata = tokio::task::spawn_blocking(move || {
configured_certificates
.into_iter()
@@ -313,8 +308,6 @@ async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}),
)
}
// Storage metadata belongs to GET /api/config, not to the editable `config`
// document or the revisioned envelopes returned by update/restore endpoints.
#[derive(Serialize)]
struct ConfigurationRead {
#[serde(flatten)]
-5
View File
@@ -1,6 +1,3 @@
// The OS collector already samples in the background. Expose just the
// chart inputs in compact SSE/fallback snapshots; the full, slower System
// information document remains behind /api/system-info.
fn live_chart_system_payload(state: &App) -> Value {
let view = state.diagnostics.view();
json!({
@@ -14,8 +11,6 @@ fn live_chart_system_payload(state: &App) -> Value {
})
}
// Snapshot already holds bounded, configured Web Service IDs. Do not expose
// paths, FastCGI addresses or PHP INI parameters in a status poll.
fn web_server_metrics_payload(snapshot: &crate::config::Snapshot) -> Value {
let mut services = serde_json::Map::new();
for service in &snapshot.envelope.config.web_services {
+16 -17
View File
@@ -1,6 +1,3 @@
// Reuse sysinfo's native OS APIs on Linux, BSD, macOS and Windows. The
// diagnostics endpoint must never spawn system commands or parse platform files.
// Host identification is cached because it changes only after a reboot.
fn valid_host_text(value: String) -> Option<String> {
let value = value.trim();
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
@@ -9,30 +6,32 @@ fn valid_host_text(value: String) -> Option<String> {
fn os_release() -> Option<&'static str> {
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
VALUE.get_or_init(|| {
sysinfo::System::long_os_version()
.or_else(|| {
let name = sysinfo::System::name()?;
Some(match sysinfo::System::os_version() {
Some(version) if !version.is_empty() => format!("{name} {version}"),
_ => name,
VALUE
.get_or_init(|| {
sysinfo::System::long_os_version()
.or_else(|| {
let name = sysinfo::System::name()?;
Some(match sysinfo::System::os_version() {
Some(version) if !version.is_empty() => format!("{name} {version}"),
_ => name,
})
})
})
.and_then(valid_host_text)
}).as_deref()
.and_then(valid_host_text)
})
.as_deref()
}
fn kernel_release() -> Option<&'static str> {
static VALUE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
VALUE.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
VALUE
.get_or_init(|| sysinfo::System::kernel_version().and_then(valid_host_text))
.as_deref()
}
// Cached diagnostics and ACME metadata are requested separately from high-rate SSE.
// Preserve the legacy full /api/status shape for existing API clients.
fn system_info_payload(state: &App) -> Value {
let cache_view = state.cache.view();
let transform_resources = crate::transform::resource_usage(&state.config.load().envelope.config.transform);
let transform_resources =
crate::transform::resource_usage(&state.config.load().envelope.config.transform);
let mut payload = json!({});
let system = state.diagnostics.view();
let cache_ram_bytes =
+110 -63
View File
@@ -315,7 +315,6 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// Origin on an ordinary same-origin GET may be absent, or may match.
for origin in [None, Some("http://127.0.0.1:9090")] {
let mut request = Request::builder()
.uri("/api/auth/session")
@@ -902,7 +901,6 @@ mod tests {
.await
.unwrap();
if path == "/logs" {
// The operational log route is private even on browser refresh.
assert_eq!(public.status(), StatusCode::UNAUTHORIZED);
assert_eq!(json_body(public).await, json!({"error":"auth.required"}));
} else {
@@ -1243,7 +1241,6 @@ mod tests {
.body(Body::from("{}"))
.unwrap()
};
// Certificate API credentials cannot trigger an all-certificate reload.
let response = router
.clone()
.oneshot(external(
@@ -1254,8 +1251,6 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// The allowlisted ID passes the auth gate and reaches the handler; this
// fixture intentionally has no such configured certificate, so it is 404.
let response = router
.clone()
.oneshot(external(
@@ -1266,7 +1261,6 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// A reload-only token cannot use the deploy endpoint at all.
let response = router
.clone()
.oneshot(
@@ -1281,7 +1275,6 @@ mod tests {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// A different ID is rejected by the credential grant before the handler.
let response = router
.clone()
.oneshot(external(
@@ -1320,8 +1313,6 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// This fixture has no certificate directory. An out-of-scope credential
// must see only the grant error, never the storage availability error.
assert_eq!(
json_body(response).await,
json!({"error":"certificate_api.certificate_forbidden"})
@@ -1482,7 +1473,6 @@ mod tests {
assert!(detail["credential"].get("token_hash").is_none());
let created_at = detail["credential"]["created_at"].clone();
// A bearer-only automation client cannot read or modify management records.
let denied = router
.clone()
.oneshot(
@@ -1526,7 +1516,6 @@ mod tests {
.unwrap();
assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY);
// An edit changes only metadata and grants, not the current bearer secret.
let edited = router.clone().oneshot(admin(
Method::PATCH, "/api/certificate-api/certbot-prod",
json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}),
@@ -1895,8 +1884,6 @@ mod tests {
"backend_table_sort":"requests",
"backend_table_descending":true
});
// Older clients may omit fields added since their preferences were
// saved. The API returns and stores the typed, defaulted document.
let normalized = serde_json::to_value(
serde_json::from_value::<AdminPreferences>(selected.clone()).unwrap(),
)
@@ -1943,7 +1930,6 @@ mod tests {
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
.unwrap();
assert_eq!(saved, normalized);
// Preferences must also survive a subsequent authenticated read.
let persisted = router
.clone()
.oneshot(
@@ -1976,7 +1962,6 @@ mod tests {
}
}
assert_eq!(json_body(persisted).await, upgraded);
// Wrong allowlist value is rejected even if the rest is valid.
let mut invalid = selected.clone();
invalid["refresh"]["traffic_seconds"] = json!(4);
let rejected = router
@@ -2120,8 +2105,6 @@ mod tests {
if valid_csrf {
req = req.header("x-csrf-token", &csrf);
}
// This fixture has no certificate directory: valid auth reaches the
// 409 precondition, never a child process, DNS update or CA request.
let response = router
.clone()
.oneshot(req.body(Body::from("{}")).unwrap())
@@ -2898,7 +2881,6 @@ mod tests {
.collect::<Vec<_>>(),
vec![Some(200), Some(404), Some(500), None]
);
// Page two must reflect the sorted complete scan, not a local page sort.
assert_eq!(records[1]["status"], 404);
sort_file_log_records(&mut records, "status", true);
assert_eq!(
@@ -3099,8 +3081,6 @@ mod tests {
assert_eq!(live.status(), StatusCode::OK);
let live = json_body(live).await;
assert!(live.get("metrics").is_some());
// Compact snapshots contain only already-collected data required for
// fast Overview charts, not the expensive System details document.
assert!(live["live_system"].get("sampled_at_ms").is_some());
assert!(live["live_system"].get("process_cpu_percent").is_some());
assert!(live["live_system"].get("process_rss_bytes").is_some());
@@ -3170,7 +3150,7 @@ mod tests {
}
value.overview_seconds = 3;
assert!(validate_refresh_preferences(&value).is_ok());
value.overview_seconds = 2; // previous installations stay valid
value.overview_seconds = 2;
assert!(validate_refresh_preferences(&value).is_ok());
value.overview_seconds = 1;
for seconds in [1, 2, 4, 31] {
@@ -3256,8 +3236,6 @@ mod tests {
"CSRF {method} {path}"
);
}
// The valid session and CSRF continue to work for a benign mutation,
// proving the failed requests above did not revoke the cookie.
let response = router
.oneshot(
request("/api/auth/logout")
@@ -3341,8 +3319,6 @@ mod tests {
config.administration.public_origin = Some("https://panel.example.test".into());
let (router, store, _dir, secret, csrf) = fixture_with_config(config).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
// Without an explicit origin pin, 0.31.3 rejected this session GET as
// external_scheme although the browser address remained correct.
let result = router
.clone()
.oneshot(
@@ -3367,8 +3343,6 @@ mod tests {
.unwrap();
assert!(session_cookie.contains("; Secure"));
// The incoming admin Host may itself be the pinned public DNS name.
// There is no separate allowed_hosts entry in the saved configuration.
let pinned_host = Request::builder()
.uri("/api/auth/session")
.header("host", "panel.example.test")
@@ -3383,8 +3357,6 @@ mod tests {
StatusCode::OK
);
// Explicit Origin, CSRF token and app request marker remain mandatory
// for a state-changing request even when XFP contains multiple hops.
let unauthorized = router
.clone()
.oneshot(
@@ -3484,8 +3456,6 @@ mod tests {
assert_eq!(result.status(), StatusCode::FORBIDDEN,
"must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}");
}
// Credential-bearing certificate automation does not inherit the
// browser-origin override, even through the same trusted admin socket.
let automation = router
.clone()
.oneshot(
@@ -3582,10 +3552,8 @@ mod tests {
assert!(payload["config"].get("storage").is_none());
assert_eq!(payload["storage"]["mode"], "sqlite");
assert!(payload["storage"]["config_path"].is_null());
assert!(payload["storage"]["database_path"].is_null()); // fixture omits data_dir
assert!(payload["storage"]["database_path"].is_null());
// The old GET endpoint is gone. A dynamic PUT-only section route may
// match the path, but it must not respond to GET.
let old_route = router
.oneshot(
request("/api/config/storage")
@@ -3669,15 +3637,38 @@ mod tests {
async fn json_metrics_api_is_authenticated_and_matches_prometheus_families() {
let (router, store, _dir, secret, _) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let snapshot = router.clone().oneshot(
request("/api/metrics/json").header("cookie", &cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
let snapshot = router
.clone()
.oneshot(
request("/api/metrics/json")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(snapshot.status(), StatusCode::OK);
assert!(snapshot.headers()["content-type"].to_str().unwrap().starts_with("application/json"));
assert!(snapshot.headers()["content-type"]
.to_str()
.unwrap()
.starts_with("application/json"));
let snapshot = json_body(snapshot).await;
for field in ["summary", "vhosts", "requests", "system", "cache", "backends", "web_server",
"streams", "docker", "traffic", "transform", "logging", "capacity", "prometheus"] {
for field in [
"summary",
"vhosts",
"requests",
"system",
"cache",
"backends",
"web_server",
"streams",
"docker",
"traffic",
"transform",
"logging",
"capacity",
"prometheus",
] {
assert!(snapshot.get(field).is_some(), "missing: {field}");
}
assert_eq!(snapshot["vhosts"]["total"], 0);
@@ -3687,21 +3678,52 @@ mod tests {
assert_eq!(snapshot["summary"]["backends"]["total"], 0);
assert!(snapshot["summary"]["traffic"]["bytes_total"].is_number());
assert!(snapshot["prometheus"]["samples"]["rust_proxy_requests_total"].is_array());
assert_eq!(snapshot["prometheus"]["types"]["rust_proxy_requests_total"], "counter");
assert!(general_api_token_allowed(&Method::GET, "/api/metrics/json", "ro"));
assert!(general_api_token_allowed(&Method::GET, "/api/metrics/summary", "ro"));
assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/json", "rw"));
assert!(!general_api_token_allowed(&Method::POST, "/api/metrics/summary", "rw"));
let summary = router.clone().oneshot(
request("/api/metrics/summary").header("cookie", &cookie)
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(
snapshot["prometheus"]["types"]["rust_proxy_requests_total"],
"counter"
);
assert!(general_api_token_allowed(
&Method::GET,
"/api/metrics/json",
"ro"
));
assert!(general_api_token_allowed(
&Method::GET,
"/api/metrics/summary",
"ro"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/metrics/json",
"rw"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/metrics/summary",
"rw"
));
let summary = router
.clone()
.oneshot(
request("/api/metrics/summary")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(summary.status(), StatusCode::OK);
assert_eq!(summary.headers()["cache-control"], "no-store");
let summary = json_body(summary).await;
assert!(summary.get("prometheus").is_none());
assert_eq!(summary["vhosts"]["enabled"], snapshot["summary"]["vhosts"]["enabled"]);
assert_eq!(summary["backends"]["total"], snapshot["summary"]["backends"]["total"]);
assert_eq!(
summary["vhosts"]["enabled"],
snapshot["summary"]["vhosts"]["enabled"]
);
assert_eq!(
summary["backends"]["total"],
snapshot["summary"]["backends"]["total"]
);
store.close().await;
}
@@ -3710,12 +3732,20 @@ mod tests {
let (router, store, _dir, _, _) = fixture().await;
let token = "T".repeat(64);
let cidrs = vec!["127.0.0.1/32".to_string()];
assert!(store.create_api_token(
"api-monitor", &auth::secret_hash(&token), "ro", "monitor test", &cidrs, "owner"
).await.unwrap());
assert!(store
.create_api_token(
"api-monitor",
&auth::secret_hash(&token),
"ro",
"monitor test",
&cidrs,
"owner"
)
.await
.unwrap());
let build = |peer: Option<&str>| {
let mut request = request("/api/metrics/json")
.header("authorization", format!("Bearer {token}"));
let mut request =
request("/api/metrics/json").header("authorization", format!("Bearer {token}"));
if let Some(peer) = peer {
request = request.extension(PeerAddr(peer.parse().unwrap()));
}
@@ -3723,19 +3753,36 @@ mod tests {
};
let response = router.clone().oneshot(build(None)).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = router.clone().oneshot(build(Some("127.0.0.1:12345"))).await.unwrap();
let response = router
.clone()
.oneshot(build(Some("127.0.0.1:12345")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = json_body(response).await;
assert!(response["prometheus"]["samples"].is_object());
assert!(response.get("config").is_none());
let summarize = |peer: Option<&str>| {
let mut req = request("/api/metrics/summary")
.header("authorization", format!("Bearer {token}"));
if let Some(peer) = peer { req = req.extension(PeerAddr(peer.parse().unwrap())); }
let mut req =
request("/api/metrics/summary").header("authorization", format!("Bearer {token}"));
if let Some(peer) = peer {
req = req.extension(PeerAddr(peer.parse().unwrap()));
}
req.body(Body::empty()).unwrap()
};
assert_eq!(router.clone().oneshot(summarize(None)).await.unwrap().status(), StatusCode::FORBIDDEN);
let summary = router.oneshot(summarize(Some("127.0.0.1:12345"))).await.unwrap();
assert_eq!(
router
.clone()
.oneshot(summarize(None))
.await
.unwrap()
.status(),
StatusCode::FORBIDDEN
);
let summary = router
.oneshot(summarize(Some("127.0.0.1:12345")))
.await
.unwrap();
assert_eq!(summary.status(), StatusCode::OK);
let summary = json_body(summary).await;
assert!(summary["traffic"]["requests_total"].is_number());
+1 -4
View File
@@ -1,4 +1,3 @@
//! Local filesystem authority. The process owner can initialize or recover the sole admin.
use anyhow::{ensure, Context, Result};
use fs2::FileExt;
use rand::{rngs::SysRng, TryRng};
@@ -102,8 +101,6 @@ pub fn load_or_create_setup_token(dir: &Path) -> Result<String> {
Ok(token)
}
/// Validate database/WAL paths before opening SQLite. The private parent directory is
/// the local CLI authorization boundary; files owned by the same OS user are trusted.
pub fn prepare_database(path: &Path) -> Result<()> {
if !path.try_exists()? {
private_options().create_new(true).open(path)?.sync_all()?;
@@ -134,4 +131,4 @@ pub fn prepare_database(path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
include!("files/tests.rs");
include!("tests.rs");
-3
View File
@@ -1,5 +1,3 @@
//! Password authentication is confined to the control plane.
//! Expensive Argon2 work runs on bounded blocking workers, not Tokio network workers.
mod files;
mod store;
use crate::{db::Store, runtime::ApiError};
@@ -151,7 +149,6 @@ impl Auth {
} else {
None
};
// Generate valid dummy work for unknown usernames. This happens only at startup.
let dummy_hash = hash_password_offline(random_secret()).await?;
Ok(Arc::new(Self {
store,
-15
View File
@@ -1,4 +1,3 @@
//! Transactional administrator operations. Login/reset races are guarded by generation CAS.
use super::{now, random_secret, secret_hash, Session, SessionToken, SESSION_SECONDS};
use crate::db::{audit_tx, Store};
use anyhow::{ensure, Result};
@@ -35,17 +34,12 @@ impl Store {
tx.commit().await?;
Ok(true)
}
/// One persisted global bucket for the single administration listener.
/// Fixed upper bound: 10 attempts per 60-second window, including successful attempts.
/// Unknown usernames use the same bucket and password work as known usernames.
pub async fn consume_auth_attempt(&self, time: i64) -> Result<bool> {
let changed = sqlx::query("UPDATE auth_throttle SET attempts = CASE WHEN window_start <= ? THEN 1 ELSE attempts + 1 END, window_start = CASE WHEN window_start <= ? THEN ? ELSE window_start END WHERE id = 1 AND (window_start <= ? OR attempts < 10)")
.bind(time - 60).bind(time - 60).bind(time).bind(time - 60)
.execute(&self.pool).await?;
Ok(changed.rows_affected() == 1)
}
/// The first UPDATE obtains SQLite's write lock and validates the credential generation.
/// A reset committed during password verification prevents insertion of a stale session.
pub async fn create_session(
&self,
generation: i64,
@@ -115,7 +109,6 @@ impl Store {
.execute(&mut *tx)
.await?;
}
// Independent caps avoid a local CLI burst evicting all browser sessions.
let cap = if kind == "cli" { 8_i64 } else { 16_i64 };
sqlx::query("DELETE FROM admin_sessions WHERE token_hash IN (SELECT token_hash FROM admin_sessions WHERE kind = ? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?)")
.bind(kind).bind(cap - 1).execute(&mut *tx).await?;
@@ -150,12 +143,6 @@ impl Store {
"browser session lifetime must be 7..30 days"
);
let hash = secret_hash(secret);
// A browser session is bounded by the currently configured lifetime. If the
// setting was increased while an older still-valid session exists, extend its
// absolute expiry from its original creation time without turning it into a
// sliding/immortal session. CLI capabilities keep the fixed expiry chosen
// when they are created (60 seconds for one-shot admin commands, longer
// for the bounded local live-log viewer).
let row = sqlx::query("UPDATE admin_sessions SET last_seen = ?, expires_at = CASE WHEN kind = 'browser' THEN created_at + ? ELSE expires_at END WHERE token_hash = ? AND expires_at > ? AND (kind <> 'browser' OR created_at + ? > ?) AND generation = (SELECT generation FROM administrator WHERE id = 1) RETURNING csrf_token, expires_at, generation, (SELECT username FROM administrator WHERE id = 1) AS username")
.bind(time)
.bind(browser_lifetime)
@@ -215,8 +202,6 @@ impl Store {
tx.commit().await?;
Ok(())
}
/// expected_generation protects browser password changes against simultaneous resets.
/// CLI passes the generation it read, so even two offline recoveries cannot silently win.
pub async fn reset_admin_password(
&self,
hash: &str,
-4
View File
@@ -1,7 +1,3 @@
//! Password-encrypted full backup/restore for RustProxy durable data.
//! The format is streaming, authenticated and deliberately excludes caches,
//! logs, sockets and temporary runtime state.
include!("backup/archive.rs");
include!("backup/ownership.rs");
include!("backup/snapshot.rs");
+3 -4
View File
@@ -37,11 +37,11 @@ fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> {
}
let raw = CString::new(path.as_os_str().as_bytes())
.context("restore path contains an embedded NUL")?;
// lchown changes the link itself instead of following a restored symlink.
let rc = unsafe { libc::lchown(raw.as_ptr(), owner.uid, owner.gid) };
if rc != 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("restore ownership update failed for {}", path.display()));
return Err(std::io::Error::last_os_error()).with_context(|| {
format!("restore ownership update failed for {}", path.display())
});
}
Ok(())
}
@@ -53,4 +53,3 @@ fn set_tree_owner(path: &Path, owner: TreeOwner) -> Result<()> {
fn set_tree_owner(_path: &Path, _owner: TreeOwner) -> Result<()> {
Ok(())
}
-3
View File
@@ -263,9 +263,6 @@ fn validate_snapshot_config(
&certificate.cert_file,
&certificate.key_file,
)? {
// External certificate material is intentionally outside the RustProxy
// data tree and is therefore referenced by configuration, not copied
// into a RustProxy backup. The operator must restore/mount it separately.
continue;
}
for name in [&certificate.cert_file, &certificate.key_file] {
-1
View File
@@ -19,7 +19,6 @@ mod tests {
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
let mut bytes = fs::read(&encrypted).unwrap();
// Corrupt the last authentication-tag byte, before the four-byte EOF marker.
let tag_byte = bytes.len() - 5;
bytes[tag_byte] ^= 1;
fs::write(&tampered, bytes).unwrap();
-3
View File
@@ -1,6 +1,3 @@
//! Bounded backend pools: active health checks, passive failures, weighted
//! balancing, opaque affinity and leases held through the complete exchange.
include!("balance/model.rs");
include!("balance/health.rs");
include!("balance/lease_runtime.rs");
-12
View File
@@ -112,14 +112,10 @@ impl BackendState {
}
}
pub fn healthy(&self) -> bool {
// No lock/clock acquisition for a healthy backend. Only an unhealthy
// backend enters the quarantine/cooldown recovery path below.
if self.healthy_cached.load(Ordering::Acquire) {
return true;
}
let mut h = lock(&self.health);
// Passive-only monitoring has no active probes to recover an ejected
// backend. After a bounded quarantine, allow one new traffic attempt.
if !h.passive_healthy && h.active_healthy && !h.checked {
if h.passive_ejected_at
.is_some_and(|at| at.elapsed() >= h.passive_cooldown)
@@ -173,8 +169,6 @@ impl BackendState {
.saturating_add(self.bytes_out.load(Ordering::Relaxed));
let delta = total.saturating_sub(sample.total);
if delta == 0 {
// No bytes moved in this sampling window: report an actual idle backend
// instead of keeping a decaying EWMA tail that looks like live traffic.
sample.rate = 0.0;
} else {
let rate = delta as f64 / elapsed;
@@ -367,8 +361,6 @@ impl BackendState {
self.observe_passive_with_detail(active, passive, reason, reason, reason, elapsed);
}
/// Returns the first failure in a consecutive counted streak only when
/// this observation actually removes the backend from the traffic pool.
fn observe_passive_with_detail(
&self,
active: &HealthCheck,
@@ -400,8 +392,6 @@ impl BackendState {
if !passive.counts(reason) {
return None;
}
// A fan-out of failures from one expired H2 connection must not count
// as independent failed backend observations in a single instant.
if reason == "io_error" {
let now = Instant::now();
if h.last_io_counted_at
@@ -459,8 +449,6 @@ pub struct Member {
pub backend: Arc<CompiledBackend>,
pub definition: PoolMember,
pub state: Arc<BackendState>,
// Immutable effective per-backend policies are compiled once per pool
// generation, not deep-cloned for every request/lease.
health_settings: Arc<HealthCheck>,
passive_settings: Arc<PassiveHealth>,
token: String,
-14
View File
@@ -76,8 +76,6 @@ impl Lease {
self.member.state.errors.fetch_add(1, Ordering::Relaxed);
}
self.member.state.record_failure(class, detail);
// GOAWAY(NO_ERROR) is a graceful H2 connection shutdown, not an
// origin failure. It can still be surfaced in access diagnostics.
if matches!(class, "h2_graceful_goaway" | "h2_graceful_close") {
return;
}
@@ -368,8 +366,6 @@ async fn probe(member: Arc<Member>, settings: Arc<HealthCheck>) {
tracing::info!(backend = %member.definition.id, healthy = after, reason = result, "backend health changed");
}
}
/// One supervisor, at most 16 probes, at most one outstanding probe per member.
/// DNS workers have their own shared bound and never resolve arbitrary URLs.
pub async fn health_loop(app: Weak<crate::runtime::App>, shutdown: CancellationToken) {
let mut interval = tokio::time::interval(Duration::from_millis(250));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -383,24 +379,14 @@ pub async fn health_loop(app: Weak<crate::runtime::App>, shutdown: CancellationT
_ = interval.tick() => {
let Some(app) = app.upgrade() else { break; };
let snapshot = app.config.load_full();
// Own an Arc for each candidate. JoinSet tasks require an
// owned, 'static member rather than a reference into snapshot.
// Health settings were already compiled into each member.
// Reuse the immutable Arc instead of cloning each HealthCheck
// and recalculating per-member overrides on every 250ms tick.
let members: Vec<_> = snapshot.pools.iter().flat_map(|pool|
pool.members.iter().map(|member|
(Arc::clone(member), Arc::clone(&member.health_settings)))).collect();
for (member, _) in &members {
member.state.sample_traffic();
// Time-based pool recycling must not depend on receiving a
// request; otherwise idle backends can stay on generation 0
// indefinitely after the configured recycle age elapses.
member.backend.client.maintain_rotation();
member.backend.websocket_client.maintain_rotation();
}
// Rotate the bounded probe window: slow first members must not
// starve later pools when all intervals equal their timeouts.
let start = cursor;
for offset in 0..members.len() {
if checks.len() >= 16 { break; }
-15
View File
@@ -1,4 +1,3 @@
// Core pool model and policies.
use crate::{config::CompiledBackend, vhost::PoolMember};
use anyhow::{ensure, Result};
use axum::body::Body;
@@ -54,15 +53,8 @@ pub struct Keepalive {
pub idle_timeout_seconds: u64,
pub max_idle_per_host: usize,
pub tcp_keepalive_seconds: u64,
/// Rebuild a backend Hyper client after this many requests. The counter is
/// per backend/client generation, not per TCP connection. Zero disables
/// request-count based recycling.
pub pool_recycle_requests: u64,
/// Maximum age of one backend Hyper client generation. Zero disables
/// time-based recycling.
pub pool_recycle_seconds: u64,
/// Bound Hyper's adaptive HTTP/1 read buffer per connection. Hyper requires
/// at least 8192 bytes. This affects upstream HTTP/1 only.
pub http1_max_buffer_bytes: usize,
}
impl Default for Keepalive {
@@ -70,9 +62,6 @@ impl Default for Keepalive {
Self {
enabled: true,
idle_timeout_seconds: 30,
// Retain enough reusable H1 upstream sockets under concurrent
// small-response traffic. The existing 1..=64 bound, idle
// timeout and pool recycling still limit resource retention.
max_idle_per_host: 64,
tcp_keepalive_seconds: 30,
pool_recycle_requests: 25_000,
@@ -219,7 +208,6 @@ impl Default for Settings {
}
}
impl Settings {
/// Resolve active health per member; a disabled monitor never probes.
pub fn health_for(&self, member: &PoolMember) -> HealthCheck {
let mut health = member
.health_check
@@ -233,7 +221,6 @@ impl Settings {
}
health
}
/// Monitoring off also excludes passive ejection; failures remain logged.
pub fn passive_for(&self, member: &PoolMember) -> PassiveHealth {
let mut passive = self.passive_health.clone();
if !member.health_check_enabled {
@@ -389,8 +376,6 @@ impl Default for FailureObservation {
}
#[derive(Debug)]
pub struct BackendState {
// Lock-free healthy lookups on the hot request-selection path. The mutex
// remains authoritative for transitions, probes and quarantine expiry.
healthy_cached: AtomicBool,
health: Mutex<Health>,
traffic: Mutex<Traffic>,
+18 -29
View File
@@ -72,13 +72,8 @@ impl Pool {
.backend
.websocket_client
.reusable_with(&old.backend.websocket_client);
// This pool has not been published or cloned into routes yet.
if let Some(member) = Arc::get_mut(member) {
if same_clients {
// Preserve the live Hyper pools and, with them, generation
// age/request/rotation state across unrelated snapshot
// rebuilds. Changed origins, address policy, keepalive or
// connect timeout still receive newly compiled clients.
member.backend = old.backend.clone();
}
if same_origin
@@ -215,9 +210,6 @@ impl Pool {
tls: bool,
excluded: &[String],
) -> Option<Selection> {
// Most deployments have one healthy backend. For non-cookie modes it
// needs neither candidate Vecs, weight scoring nor the shared scheduler
// mutex. Still use the same atomic capacity lease and health gate.
if self.members.len() == 1 && !self.cookie_affinity() {
let member = &self.members[0];
if !member.definition.pool_eligible
@@ -247,8 +239,6 @@ impl Pool {
} else {
None
};
// Drain stops new assignments while existing cookie-affine clients may
// finish their session. Disabled/unhealthy members never receive traffic.
let matched = existing.and_then(|token| {
self.members.iter().find(|m| {
m.token == token
@@ -269,8 +259,6 @@ impl Pool {
if self.settings.algorithm == Algorithm::RoundRobin
&& self.settings.sticky.mode != StickyMode::IpHash
{
// Smooth weighted round-robin keeps its historical state and
// global ordering. Only that algorithm takes the mutex.
let mut scheduling = lock(&self.scheduling);
let mut chosen = members[0];
let mut best = f64::NEG_INFINITY;
@@ -279,13 +267,14 @@ impl Pool {
let weight = member
.state
.weight(member.definition.weight, self.settings.slow_start_seconds);
let current = if let Some(current) =
scheduling.get_mut(member.definition.id.as_str())
{
current
} else {
scheduling.entry(member.definition.id.clone()).or_insert(0.0)
};
let current =
if let Some(current) = scheduling.get_mut(member.definition.id.as_str()) {
current
} else {
scheduling
.entry(member.definition.id.clone())
.or_insert(0.0)
};
*current += weight;
total += weight;
if *current > best {
@@ -305,8 +294,6 @@ impl Pool {
} else {
self.settings.algorithm
};
// Canonicalize the address once, not for every candidate. The
// SHA-256 input and rendezvous scoring remain bit-for-bit equal.
let hash_ip = (algorithm == Algorithm::IpHash)
.then(|| crate::vhost::canonical_ip(peer).to_string());
for offset in 0..members.len() {
@@ -330,16 +317,18 @@ impl Pool {
-unit.max(f64::MIN_POSITIVE).ln() / member.definition.weight as f64
}
_ => {
let weight = member.state.weight(
member.definition.weight,
self.settings.slow_start_seconds,
);
let weight = member
.state
.weight(member.definition.weight, self.settings.slow_start_seconds);
match algorithm {
Algorithm::LeastTraffic =>
(member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight,
Algorithm::Adaptive =>
Algorithm::LeastTraffic => {
(member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight
}
Algorithm::Adaptive => {
((active + 1.0) * member.state.latency()
+ member.state.traffic_rate() / 16_384.0) / weight,
+ member.state.traffic_rate() / 16_384.0)
/ weight
}
_ => (active + 1.0) / weight,
}
}
+16 -10
View File
@@ -19,10 +19,6 @@ impl Pool {
"down"
};
// Keep this as explicit Map construction instead of one very large
// json! invocation. serde_json's json_internal! macro is recursive
// and the diagnostics payload is large enough to hit rustc's default
// macro recursion limit on supported stable toolchains.
let mut item = serde_json::Map::new();
item.insert("id".into(), json!(m.definition.id));
item.insert("url".into(), json!(m.definition.url));
@@ -85,13 +81,23 @@ impl Pool {
"upstream_client_rotations_total".into(),
json!(client_rotation.rotations),
);
// Negotiated HTTP version from real backend response headers,
// never from the configured Auto/HTTP2 policy or health probes.
let observed = m.backend.client.protocol_stats();
item.insert("upstream_protocol_observed".into(), json!(observed.last_version));
item.insert("upstream_protocol_last_seen_ms".into(), json!(observed.observed_at_ms));
item.insert("upstream_protocol_http1_total".into(), json!(observed.http1_responses));
item.insert("upstream_protocol_http2_total".into(), json!(observed.http2_responses));
item.insert(
"upstream_protocol_observed".into(),
json!(observed.last_version),
);
item.insert(
"upstream_protocol_last_seen_ms".into(),
json!(observed.observed_at_ms),
);
item.insert(
"upstream_protocol_http1_total".into(),
json!(observed.http1_responses),
);
item.insert(
"upstream_protocol_http2_total".into(),
json!(observed.http2_responses),
);
item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0));
item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0));
item.insert("last_probe_ms".into(), json!(h.last_probe_ms));
+21 -11
View File
@@ -66,7 +66,7 @@ mod tests {
defaults.health_check_enabled = false;
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off);
assert!(!settings.passive_for(&defaults).enabled);
assert!(!defaults.pool_eligible); // routing intent is independent
assert!(!defaults.pool_eligible);
defaults.health_check_enabled = true;
defaults.health_check = Some(HealthCheck {
mode: HealthMode::Http,
@@ -105,7 +105,8 @@ mod tests {
assert_eq!(parsed.keepalive.max_idle_per_host, 64);
let explicit: Settings = serde_json::from_value(serde_json::json!({
"keepalive": { "max_idle_per_host": 4 }
})).unwrap();
}))
.unwrap();
assert_eq!(explicit.keepalive.max_idle_per_host, 4);
assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000);
assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600);
@@ -356,7 +357,7 @@ mod tests {
}
assert_eq!(counts, [30, 10]);
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0);
assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0); // Mere cache selection isn't origin traffic.
assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn least_connections_counts_a_lease_until_its_last_owner_drops() {
@@ -769,7 +770,6 @@ mod tests {
assert!(state.healthy());
}
#[test]
fn single_backend_fast_path_observes_capacity_and_health_without_affinity_cookie() {
let pool = capacity_pool(1, 0);
@@ -792,7 +792,10 @@ mod tests {
let first = select(&pool);
let second = select(&pool);
assert!(Arc::ptr_eq(&first.lease.health, &second.lease.health));
assert!(Arc::ptr_eq(&first.lease.passive_health, &second.lease.passive_health));
assert!(Arc::ptr_eq(
&first.lease.passive_health,
&second.lease.passive_health
));
}
#[test]
@@ -800,10 +803,14 @@ mod tests {
let state = BackendState::new(HealthMode::Off);
assert!(state.healthy());
let active = settings(HealthMode::Off);
let passive = PassiveHealth { enabled: true, fall: Some(1), rise: Some(1), ..Default::default() };
let passive = PassiveHealth {
enabled: true,
fall: Some(1),
rise: Some(1),
..Default::default()
};
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(2));
assert!(!state.healthy());
// Preserve quarantine rules; an active probe is not invented here.
state.observe_passive(&active, &passive, "ok", Duration::from_millis(2));
assert!(state.healthy());
}
@@ -812,14 +819,17 @@ mod tests {
fn successful_lease_response_keeps_passive_observability_and_failure_detail() {
let pool = capacity_pool(0, 0);
let selection = select(&pool);
selection.lease.response(200, Duration::from_millis(5), true);
selection
.lease
.response(200, Duration::from_millis(5), true);
let state = &selection.lease.member.state;
assert_eq!(state.last_status.load(Ordering::Relaxed), 200);
assert_eq!(lock(&state.health).passive_result, "ok");
assert!(lock(&state.health).latency_us > 0);
selection.lease.response(503, Duration::from_millis(5), true);
selection
.lease
.response(503, Duration::from_millis(5), true);
assert_eq!(state.last_status.load(Ordering::Relaxed), 503);
assert_eq!(lock(&state.last_failure).detail, "status=503");
}
} // mod tests
}
+1 -8
View File
@@ -1,5 +1,3 @@
//! Per-vhost RFC 7617 gate. Only password hashes enter desired configuration.
//! ACLs run first; credentials are never cached with content or written to logs.
use anyhow::{ensure, Result};
use argon2::{
password_hash::{phc::PasswordHash, PasswordVerifier},
@@ -78,7 +76,6 @@ impl Settings {
for user in &self.users {
validate_username(&user.username)?;
ensure!(names.insert(&user.username), "basic.duplicate_username");
// Bound KDF cost before accepting imported hashes. No arbitrary PHC parameters.
ensure!(
user.password_hash.len() <= 256
&& user
@@ -88,8 +85,6 @@ impl Settings {
);
let hash = PasswordHash::new(&user.password_hash)
.map_err(|_| anyhow::anyhow!("basic.invalid_hash"))?;
// Match the former 16-character minimum for the PHC-encoded salt.
// phc 0.6 Salt::len() now measures decoded bytes instead.
ensure!(
hash.salt.is_some_and(|s| s.to_salt_string().len() >= 16)
&& hash.hash.is_some_and(|h| h.len() == 32),
@@ -157,7 +152,6 @@ fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response {
.headers_mut()
.insert("retry-after", HeaderValue::from_static("60"));
}
// Custom error pages must not turn an authentication failure into success.
response
.extensions_mut()
.insert(crate::tinyauth::AuthenticationFailure);
@@ -214,7 +208,6 @@ impl Compiled {
return Err(denied(StatusCode::TOO_MANY_REQUESTS, None));
}
let Some((name, password, original)) = input else {
// A browser's first request normally has no Authorization; do not count that as a bad password.
return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge)));
};
let found = self.settings.users.iter().find(|u| u.username == name);
@@ -254,4 +247,4 @@ impl Compiled {
}
#[cfg(test)]
include!("basic_auth/tests.rs");
include!("tests.rs");
+288 -123
View File
@@ -75,13 +75,25 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::from("zero-freshness"));
*response.headers_mut() = headers(control);
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO)
.expect("valid zero freshness is distinct from a no-store response");
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.expect("valid zero freshness is distinct from a no-store response");
assert_eq!(plan.ttl, 0, "{control}");
cache.wrap(response, ticket.clone(), plan, &settings)
.into_body().collect().await.unwrap();
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.view().entries, 1, "{control}");
assert!(cache.get(&ticket).is_none(), "zero-TTL {control} cannot be an unconditional HIT");
assert!(
cache.get(&ticket).is_none(),
"zero-TTL {control} cannot be an unconditional HIT"
);
}
}
#[test]
@@ -180,7 +192,14 @@ mod tests {
let mut single_range = HeaderMap::new();
single_range.insert("range", HeaderValue::from_static("bytes=0-3"));
assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
request_policy(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
),
RequestPolicy::Use
);
assert!(request_cacheable(
@@ -193,7 +212,14 @@ mod tests {
));
single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9"));
assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
request_policy(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
),
RequestPolicy::Bypass("request_range_unsupported")
);
}
@@ -218,11 +244,23 @@ mod tests {
fn request_max_stale_is_bounded_and_respects_revalidation_flags() {
let unbounded = headers("max-stale");
let bounded = headers("max-stale=15");
assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false));
assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false));
assert_eq!(
stale_mode(120, 60, 0, false, false, false, &unbounded),
Some(false)
);
assert_eq!(
stale_mode(70, 60, 0, false, false, false, &bounded),
Some(false)
);
assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None);
assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true));
assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None);
assert_eq!(
stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()),
Some(true)
);
assert_eq!(
stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()),
None
);
assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None);
assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None);
}
@@ -268,9 +306,21 @@ mod tests {
origin_last_modified: None,
};
assert!(base.can_stale_if_error());
assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error());
assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error());
assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error());
assert!(!Revalidation {
age: 91,
..base.clone()
}
.can_stale_if_error());
assert!(!Revalidation {
must_revalidate: true,
..base.clone()
}
.can_stale_if_error());
assert!(!Revalidation {
proxy_revalidate: true,
..base
}
.can_stale_if_error());
}
#[test]
@@ -378,14 +428,10 @@ mod tests {
json: true,
..Default::default()
};
let transformed = crate::transform::apply(
response,
"/data.json",
&transform_settings,
false,
)
.await
.unwrap();
let transformed =
crate::transform::apply(response, "/data.json", &transform_settings, false)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
@@ -403,7 +449,9 @@ mod tests {
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&first[..], br#"{"message":"hello"}"#);
let hit = cache.get(&ticket).expect("transformed representation should be cached");
let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag);
assert_eq!(
hit.response.headers()["content-length"].to_str().unwrap(),
@@ -460,10 +508,9 @@ mod tests {
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
response.headers_mut().insert(
"content-type",
HeaderValue::from_str(content_type).unwrap(),
);
response
.headers_mut()
.insert("content-type", HeaderValue::from_str(content_type).unwrap());
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
@@ -496,14 +543,9 @@ mod tests {
"json" => transform_settings.json = true,
_ => unreachable!(),
}
let transformed = crate::transform::apply(
response,
path,
&transform_settings,
false,
)
.await
.unwrap();
let transformed = crate::transform::apply(response, path, &transform_settings, false)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
@@ -521,9 +563,15 @@ mod tests {
);
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert!(!first.is_empty(), "{kind}");
assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture");
assert_ne!(
first.as_ref(),
origin_body,
"{kind} should change the chosen fixture"
);
let hit = cache.get(&ticket).expect("transformed representation should be cached");
let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}");
assert_eq!(
hit.response.headers()["content-length"],
@@ -701,7 +749,9 @@ mod tests {
let mut s = Settings::default();
s.transient_max_bytes = 128 * 1024;
s.cold_queue_max_bytes = 64 * 1024;
let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation");
let first = cache
.try_reserve_transient(96 * 1024, &s)
.expect("first reservation");
assert!(cache.try_reserve_transient(40 * 1024, &s).is_none());
assert_eq!(cache.view().transient_bytes, 96 * 1024);
drop(first);
@@ -756,9 +806,7 @@ mod tests {
must_revalidate: false,
proxy_revalidate: false,
origin_etag: Some(HeaderValue::from_static("W/\"origin\"")),
origin_last_modified: Some(HeaderValue::from_static(
"Mon, 26 Jan 2026 23:11:40 GMT",
)),
origin_last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
};
let mut matching = HeaderMap::new();
matching.insert("if-none-match", HeaderValue::from_static("\"origin\""));
@@ -781,13 +829,7 @@ mod tests {
let mut response_headers = headers("public, max-age=60");
response_headers.insert("etag", public_etag.clone());
response_headers.insert("last-modified", modified.clone());
let plan = plan(
StatusCode::OK,
&response_headers,
&settings,
Duration::ZERO,
)
.unwrap();
let plan = plan(StatusCode::OK, &response_headers, &settings, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("transformed"));
*response.headers_mut() = response_headers;
response
@@ -818,9 +860,15 @@ mod tests {
assert_eq!(client_conditional["if-modified-since"], modified);
let mut validation = HeaderMap::new();
validation.insert("cache-control", HeaderValue::from_static("public, max-age=60"));
validation.insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
validation.insert("etag", HeaderValue::from_static("\"origin-new\""));
validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"));
validation.insert(
"last-modified",
HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"),
);
let merged = cached.merge_validation_headers(&validation);
assert_eq!(merged["etag"], public_etag);
}
@@ -915,18 +963,13 @@ mod tests {
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding"));
origin_headers.insert("content-length", HeaderValue::from_static("7"));
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
let origin_plan =
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("content-length", HeaderValue::from_static("7"));
let transformed_plan = plan(
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan");
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
@@ -962,21 +1005,13 @@ mod tests {
dark.insert("x-theme", HeaderValue::from_static("dark"));
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert(
"vary",
HeaderValue::from_static("Accept-Encoding, X-Theme"),
);
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme"));
let origin_plan =
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("vary", HeaderValue::from_static("X-Theme"));
let transformed_plan = plan(
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan");
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
@@ -1067,11 +1102,20 @@ mod tests {
let entry = store.entries.values_mut().next().expect("stored object");
entry.received = Instant::now() - Duration::from_secs(2);
}
assert!(cache.get(&initial).is_none(), "normal request must not receive stale data");
assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object");
assert!(
cache.get(&initial).is_none(),
"normal request must not receive stale data"
);
assert_eq!(
cache.view().entries,
1,
"freshness expiry must not physically evict the object"
);
let stale = ticket(&cache, &headers("max-stale"));
let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data");
let hit = cache
.get(&stale)
.expect("explicit max-stale may reuse retained stale data");
assert!(hit.stale);
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
@@ -1107,7 +1151,14 @@ mod tests {
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO)
.publish_revalidated(
t.clone(),
&candidate,
merged,
&validation,
&s,
Duration::ZERO,
)
.expect("validated body can be delivered");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
@@ -1150,7 +1201,14 @@ mod tests {
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2))
.publish_revalidated(
t,
&candidate,
merged,
&validation,
&s,
Duration::from_secs(2),
)
.expect("validated object");
assert!(hit.resident_age_seconds >= 32);
}
@@ -1262,7 +1320,10 @@ mod tests {
fn aged_upstream_freshness_caps_remaining_not_total_age() {
let settings = settings();
let mut headers = headers("public, max-age=2592000");
headers.insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap());
headers.insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
headers.insert("age", HeaderValue::from_static("1553483"));
headers.insert("etag", HeaderValue::from_static("\"origin-etag\""));
let original = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
@@ -1270,20 +1331,29 @@ mod tests {
assert_eq!(original.remaining_ttl(), settings.max_ttl_seconds);
assert_eq!(original.ttl, 1553483 + settings.max_ttl_seconds);
// Local cap never overrides the remaining origin lifetime.
headers.insert("age", HeaderValue::from_static("2591990"));
let almost_expired = plan(StatusCode::OK, &headers, &settings, Duration::ZERO).unwrap();
assert_eq!(almost_expired.remaining_ttl(), 10);
headers.insert("age", HeaderValue::from_static("2592010"));
assert_eq!(
plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap().remaining_ttl(), 0
.unwrap()
.remaining_ttl(),
0
);
headers.insert(
"cache-control",
HeaderValue::from_static("public, no-cache, max-age=2592000"),
);
assert!(
plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap()
.requires_revalidation
);
headers.insert(
"cache-control",
HeaderValue::from_static("private, max-age=2592000"),
);
// No-cache and private/no-store restrictions must not be relaxed.
headers.insert("cache-control", HeaderValue::from_static("public, no-cache, max-age=2592000"));
assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO)
.unwrap().requires_revalidation);
headers.insert("cache-control", HeaderValue::from_static("private, max-age=2592000"));
assert!(plan(StatusCode::OK, &headers, &settings, Duration::ZERO).is_none());
}
@@ -1291,8 +1361,12 @@ mod tests {
fn age_uses_rfc9111_max_of_apparent_and_corrected_age() {
let s = settings();
let mut h = headers("public, max-age=3600");
h.insert("date", httpdate::fmt_http_date(SystemTime::now()
- Duration::from_secs(10)).parse().unwrap());
h.insert(
"date",
httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10))
.parse()
.unwrap(),
);
h.insert("age", HeaderValue::from_static("3"));
let p = plan(StatusCode::OK, &h, &s, Duration::from_secs(2)).unwrap();
assert!(p.initial_age < Duration::from_secs(12));
@@ -1310,30 +1384,76 @@ mod tests {
let original_body = b"body { color: red; margin: 0px; }";
let mut upstream = Response::new(Body::from(Bytes::from_static(original_body)));
*upstream.headers_mut() = headers("public, max-age=2592000");
upstream.headers_mut().insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap());
upstream.headers_mut().insert("age", HeaderValue::from_static("1553483"));
upstream.headers_mut().insert("content-type", HeaderValue::from_static("text/css"));
upstream.headers_mut().insert("content-length",
HeaderValue::from_str(&original_body.len().to_string()).unwrap());
upstream.headers_mut().insert("etag", HeaderValue::from_static("\"upstream\""));
let origin_plan = plan(StatusCode::OK, upstream.headers(), &settings, Duration::ZERO).unwrap();
upstream.headers_mut().insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
upstream
.headers_mut()
.insert("age", HeaderValue::from_static("1553483"));
upstream
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
upstream.headers_mut().insert(
"content-length",
HeaderValue::from_str(&original_body.len().to_string()).unwrap(),
);
upstream
.headers_mut()
.insert("etag", HeaderValue::from_static("\"upstream\""));
let origin_plan = plan(
StatusCode::OK,
upstream.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let transformed = crate::transform::apply(
upstream, "/theme.css", &crate::transform::Settings {
enabled: true, css: true, ..Default::default()
}, false
).await.unwrap();
assert!(transformed.extensions().get::<crate::transform::Applied>().is_some());
upstream,
"/theme.css",
&crate::transform::Settings {
enabled: true,
css: true,
..Default::default()
},
false,
)
.await
.unwrap();
assert!(transformed
.extensions()
.get::<crate::transform::Applied>()
.is_some());
assert_eq!(transformed.headers()["age"], "1553483");
let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings,
Duration::ZERO).unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let combined = origin_plan.restrict(transformed_plan);
assert!(combined.remaining_ttl() > 86_000);
let first = cache.wrap(transformed, ticket.clone(), combined, &settings)
.into_body().collect().await.unwrap().to_bytes();
let hit = cache.get(&ticket).expect("aged, fresh CSS should be cached after transform");
let first = cache
.wrap(transformed, ticket.clone(), combined, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
let hit = cache
.get(&ticket)
.expect("aged, fresh CSS should be cached after transform");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
assert!(hit.response.headers()["age"].to_str().unwrap().parse::<u64>().unwrap() >= 1553483);
assert!(
hit.response.headers()["age"]
.to_str()
.unwrap()
.parse::<u64>()
.unwrap()
>= 1553483
);
let second = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(first, second);
let dash = cache.dashboard(None, 0, 10);
@@ -1379,18 +1499,34 @@ mod tests {
#[tokio::test]
async fn hot_hit_updates_lru_without_replacing_the_entry_or_losing_hit_counter() {
let cache = Cache::new(1);
let settings = Settings { max_entries: 2, ..settings() };
let settings = Settings {
max_entries: 2,
..settings()
};
let h = HeaderMap::new();
let a = cache.ticket(1, "http", "example.test", "app", "/a", &h).unwrap();
let b = cache.ticket(1, "http", "example.test", "app", "/b", &h).unwrap();
let c = cache.ticket(1, "http", "example.test", "app", "/c", &h).unwrap();
let a = cache
.ticket(1, "http", "example.test", "app", "/a", &h)
.unwrap();
let b = cache
.ticket(1, "http", "example.test", "app", "/b", &h)
.unwrap();
let c = cache
.ticket(1, "http", "example.test", "app", "/c", &h)
.unwrap();
fill(&cache, a.clone(), "payload-a", &settings).await;
fill(&cache, b.clone(), "payload-b", &settings).await;
assert_eq!(cache.get(&a).unwrap().hits, 1);
assert_eq!(cache.get(&a).unwrap().hits, 2);
fill(&cache, c.clone(), "payload-c", &settings).await;
assert!(cache.get(&b).is_none(), "the least recently used object is evicted");
assert_eq!(cache.get(&a).unwrap().hits, 3, "HOT hits do not reset on LRU refresh");
assert!(
cache.get(&b).is_none(),
"the least recently used object is evicted"
);
assert_eq!(
cache.get(&a).unwrap().hits,
3,
"HOT hits do not reset on LRU refresh"
);
assert_eq!(cache.get(&c).unwrap().hits, 1);
assert_eq!(cache.view().evictions, 1);
assert_eq!(cache.view().entries, 2);
@@ -1423,7 +1559,10 @@ mod tests {
assert!(after.hot_allocated_bytes <= s.max_bytes);
assert_eq!(after.fill_bytes_active, 0);
assert_eq!(after.transient_bytes, 0);
assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation");
assert!(
cache.get(&first).is_none(),
"LRU entry should make room before the next fill allocation"
);
assert!(cache.get(&second).is_some());
assert!(after.evictions >= 1);
}
@@ -1477,8 +1616,6 @@ mod tests {
&a,
)
.unwrap();
// Compare unrelated headers/member choice within the same frontend
// scheme. HTTP and HTTPS intentionally have distinct cache identities.
let other_member = cache
.ticket(
1,
@@ -1559,12 +1696,17 @@ mod tests {
assert_eq!(first, Bytes::from_static(b"first-"));
drop(body);
assert!(cache.get(&t).is_none(), "partial body must never become a HIT");
assert!(
cache.get(&t).is_none(),
"partial body must never become a HIT"
);
assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 1);
fill(&cache, t.clone(), "complete", &s).await;
let hit = cache.get(&t).expect("a later complete GET can fill the cache");
let hit = cache
.get(&t)
.expect("a later complete GET can fill the cache");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"complete")
@@ -1604,11 +1746,18 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("0"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let response = cache.wrap(response, ticket.clone(), plan, &settings);
assert!(response.body().is_end_stream());
// HTTP/1.1 Content-Length: 0 does not ask the response body for frames.
drop(response);
assert!(cache.get(&ticket).is_some());
assert_eq!(cache.view().entries, 1);
@@ -1623,8 +1772,16 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("6"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("6"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().fills_active, 0);
@@ -1638,8 +1795,16 @@ mod tests {
let ticket = ticket(&cache, &HeaderMap::new());
let mut response = Response::new(Body::empty());
*response.headers_mut() = headers("public, max-age=600");
response.headers_mut().insert("content-length", HeaderValue::from_static("0"));
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("0"));
let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache.purge(None);
drop(cache.wrap(response, ticket.clone(), plan, &settings));
assert!(cache.get(&ticket).is_none());
-8
View File
@@ -14,10 +14,6 @@ impl Cache {
}
}
}
// Do not create an unbounded number of detached delete tasks when the
// writer queue is saturated. A tiny fallback pool absorbs short bursts;
// sustained saturation falls back to synchronous removal so stale files
// cannot be resurrected on restart at the cost of bounded backpressure.
if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() {
tokio::spawn(async move {
let _permit = permit;
@@ -51,8 +47,6 @@ impl Cache {
let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1));
let mut after_sequence = 0u64;
// Copy candidate metadata in bounded chunks. Request-path lookups only
// contend with this mutex for a small snapshot, never for sorting or I/O.
loop {
let batch: Vec<_> = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
@@ -152,8 +146,6 @@ impl Cache {
}
}
}
// Keep pending deletion metadata bounded to one selection batch; a
// 10k-entry manual cleanup must not build one large Vec<PathBuf>.
self.enqueue_cold_delete(std::mem::take(&mut delete));
tokio::task::yield_now().await;
}
+955
View File
@@ -0,0 +1,955 @@
mod cold_tier_tests {
use super::*;
use http_body_util::BodyExt;
fn cold_settings() -> Settings {
Settings {
enabled: true,
max_entries: 1,
max_bytes: 256 * 1024,
max_object_bytes: 64 * 1024,
cold_enabled: true,
cold_path: "cache/cold-test".into(),
cold_max_bytes: 2 * 1024 * 1024,
cold_max_entries: 16,
cold_after_seconds: 300,
..Settings::default()
}
}
async fn fill(
cache: &Arc<Cache>,
settings: &Settings,
path: &str,
body: &'static str,
) -> Ticket {
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
cache
.wrap(response, ticket.clone(), plan, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
async fn wait_for_cold(cache: &Cache, expected: usize) {
for _ in 0..100 {
if cache.view().cold_entries >= expected {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().cold_entries, 1);
let result = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Purge,
selector: InvalidationSelector::Exact,
value: "/one?v=1".into(),
host: Some("cold.example".into()),
vhost: Some("cold-test".into()),
ttl_seconds: None,
confirm_wide: false,
})
.unwrap();
assert_eq!(result.affected_entries, 1);
assert_eq!(cache.view().cold_entries, 0);
assert!(cache.get_tiered(&first, &settings).await.is_none());
shutdown.cancel();
}
#[tokio::test]
async fn aged_transformed_css_remains_fresh_when_demoted_to_cold() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/aged-theme.css",
&HeaderMap::new(),
)
.unwrap();
let body = b"body { color: red; margin: 0px; }";
let mut origin = Response::new(Body::from(Bytes::from_static(body)));
origin.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=2592000"),
);
origin
.headers_mut()
.insert("age", HeaderValue::from_static("1553483"));
origin.headers_mut().insert(
"date",
httpdate::fmt_http_date(SystemTime::now()).parse().unwrap(),
);
origin
.headers_mut()
.insert("content-type", HeaderValue::from_static("text/css"));
origin.headers_mut().insert(
"content-length",
HeaderValue::from_str(&body.len().to_string()).unwrap(),
);
origin
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan =
plan(StatusCode::OK, origin.headers(), &settings, Duration::ZERO).unwrap();
let transformed = crate::transform::apply(
origin,
"/aged-theme.css",
&crate::transform::Settings {
enabled: true,
css: true,
..Default::default()
},
false,
)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
ticket.clone(),
origin_plan.restrict(transformed_plan),
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.view().entries, 1);
let _another = fill(&cache, &settings, "/another", "another-payload").await;
wait_for_cold(&cache, 1).await;
let row = cache
.dashboard(Some("cold-test"), 0, 20)
.objects
.into_iter()
.find(|v| v.path == "/aged-theme.css")
.unwrap();
assert_eq!(row.tier, "cold");
assert!(row.transformed);
assert!(row.remaining_ttl_seconds >= 86_000);
shutdown.cancel();
drop(cache);
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let hit = cache
.get_tiered(&ticket, &settings)
.await
.expect("aged transformed COLD should HIT after restart");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
let transformed_body = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_ne!(transformed_body.as_ref(), &body[..]);
shutdown.cancel();
}
#[tokio::test]
async fn lru_demotion_persists_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
settings.validate().unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 1);
let cold = cache.dashboard(Some("cold-test"), 0, 50);
assert!(cold
.objects
.iter()
.any(|row| row.path == "/one" && row.tier == "cold"));
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 1);
let hit = restarted
.get_tiered(&first, &settings)
.await
.expect("cold object should be readable after restart");
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(payload, Bytes::from_static(b"first-payload"));
assert_eq!(restarted.view().cold_hits, 1);
assert_eq!(restarted.view().cold_promotions, 1);
restart_shutdown.cancel();
}
#[tokio::test]
async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
{
let mut cold = cache.cold.lock().unwrap();
for entry in cold.entries.values_mut() {
entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
}
}
let dashboard = cache.dashboard(Some("cold-test"), 0, 50);
assert_eq!(dashboard.matching_objects, 2);
let row = dashboard
.objects
.iter()
.find(|row| row.path == "/one")
.unwrap();
assert_eq!(row.tier, "cold");
assert_eq!(row.remaining_ttl_seconds, 0);
assert!(row.age_seconds >= 2 * 60 * 60);
assert_eq!(row.storage_bytes, cache.view().cold_bytes);
assert!(cache.get_tiered(&first, &settings).await.is_none());
let mut stale = first;
stale
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
let hit = cache
.get_tiered(&stale, &settings)
.await
.expect("explicit max-stale can reuse the retained disk object");
assert!(hit.stale);
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
"first-payload"
);
shutdown.cancel();
}
#[tokio::test]
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let transformed_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/transformed.json",
&HeaderMap::new(),
)
.unwrap();
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
transformed.headers_mut().insert(
"cache-control",
HeaderValue::from_static(
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed.headers_mut().insert(
"etag",
HeaderValue::from_static("\"rustproxy-representation\""),
);
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
transformed
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
});
transformed
.extensions_mut()
.insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
transformed_ticket.clone(),
transformed_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
let protected_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/protected.css",
&HeaderMap::new(),
)
.unwrap();
let original_body = "body { color: red; }";
let mut protected = Response::new(Body::from(original_body));
protected.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
);
protected
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-css\""));
protected.extensions_mut().insert(crate::compression::Skip);
let protected_plan = plan(
StatusCode::OK,
protected.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
protected,
protected_ticket.clone(),
protected_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.flush_hot_to_cold().await, 2);
wait_for_cold(&cache, 2).await;
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
let (key, transformed) = {
let cold = restarted.cold.lock().unwrap();
cold.entries
.iter()
.find(|(_, entry)| entry.path == "/transformed.json")
.map(|(key, entry)| (*key, entry.clone()))
.expect("transformed cold entry")
};
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert!(transformed.has_validator);
assert!(transformed.transformed);
let metadata = read_cold_metadata(&key, &transformed)
.await
.expect("persisted transformed metadata");
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
metadata.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
let transformed_hit = restarted
.get_tiered(&transformed_ticket, &settings)
.await
.expect("transformed object after restart");
assert_eq!(
transformed_hit.response.headers()["etag"],
"\"rustproxy-representation\""
);
assert!(transformed_hit.transformed);
assert!(transformed_hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(
transformed_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(br#"{"a":1}"#)
);
let protected_hit = restarted
.get_tiered(&protected_ticket, &settings)
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(
protected_hit.response.headers()
));
assert!(protected_hit
.response
.extensions()
.get::<crate::compression::Skip>()
.is_some());
assert_eq!(
protected_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"body { color: red; }")
);
restart_shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_lru_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.cold_on_lru = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
tokio::time::sleep(Duration::from_millis(30)).await;
let view = cache.view();
assert_eq!(view.entries, 1);
assert_eq!(view.cold_entries, 0);
assert_eq!(view.evictions, 1);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_idle_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/idle", "idle-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.last_access = Instant::now() - Duration::from_secs(600);
}
}
cache.demote_idle();
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
settings.cold_on_idle = true;
cache.configure(settings.clone());
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_demote_by_total_hot_residency_age() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
settings.cold_on_age = true;
settings.cold_max_hot_seconds = 5;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/aged", "aged-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.resident_since = Instant::now() - Duration::from_secs(60);
entry.last_access = Instant::now();
}
}
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
assert_eq!(cache.view().entries, 2);
assert!(cache.view().resident_bytes > 0);
assert_eq!(cache.view().cold_entries, 0);
assert_eq!(cache.flush_hot_to_cold().await, 2);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(
view.transient_bytes, 0,
"persisted ColdEntry must not retain the body allocation"
);
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
restart_shutdown.cancel();
}
#[tokio::test]
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/update", "update-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_can_be_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_cache_uses_two_hex_character_shards() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let root = dir.path().join(&settings.cold_path);
let mut shards = Vec::new();
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(
item.path().is_dir(),
"cold root must contain shard directories only"
);
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard)
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
}));
shutdown.cancel();
}
#[tokio::test]
async fn legacy_flat_cold_cache_is_not_migrated() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let root = dir.path().join(&settings.cold_path);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
shutdown.cancel();
}
#[tokio::test]
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache
.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
})
.unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
assert_eq!(current.removed_entries, 1);
assert!(current.freed_bytes > 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold cleanup did not finish");
}
#[tokio::test]
async fn manual_cold_cleanup_is_single_flight() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
let result = cache.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
});
assert!(result.is_err());
shutdown.cancel();
}
#[tokio::test]
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let key = {
let mut cold = cache.cold.lock().unwrap();
let key = *cold.entries.keys().next().unwrap();
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
key
};
let request = ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
};
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache
.cold
.lock()
.unwrap()
.entries
.get_mut(&key)
.unwrap()
.pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 1);
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
shutdown.cancel();
}
#[test]
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
let cache = Cache::new(1);
let key = [7_u8; 32];
let template = ColdEntry {
base_key: key,
vary: Vec::new(),
revision: 1,
scope: "app".to_owned(),
scheme: "https".to_owned(),
authority: "example.test".to_owned(),
domain: "example.test".to_owned(),
path: "/asset".to_owned(),
target: "https://example.test/asset".to_owned(),
tags: Vec::new(),
created_at: SystemTime::now(),
body_path: PathBuf::from("old.body"),
meta_path: PathBuf::from("old.json"),
body_bytes: 1,
body_sha256: [0u8; 32],
disk_bytes: 2,
stored_at: SystemTime::now(),
last_access: SystemTime::now(),
age_at_store_seconds: 0,
resident_age_at_store_seconds: 0,
ttl: 60,
stale_while_revalidate: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
has_validator: false,
hits: 0,
transformed: false,
sequence: 1,
generation: 1,
skip_compression: false,
requires_revalidation: false,
pinned_reads: 1,
};
cache
.cold
.lock()
.unwrap()
.entries
.insert(key, template.clone());
let old_guard = ColdReadGuard {
cache: &cache,
key,
generation: 1,
};
let mut replacement = template;
replacement.sequence = 2;
replacement.generation = 2;
replacement.pinned_reads = 1;
cache.cold.lock().unwrap().entries.insert(key, replacement);
drop(old_guard);
let cold = cache.cold.lock().unwrap();
let replacement = cold.entries.get(&key).unwrap();
assert_eq!(replacement.generation, 2);
assert_eq!(replacement.pinned_reads, 1);
}
#[test]
fn cold_path_cannot_escape_data_directory() {
let mut settings = Settings::default();
settings.cold_path = "../outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "/tmp/outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "cache/cold".into();
assert!(settings.validate().is_ok());
}
#[tokio::test]
async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let mut fingerprints = HashMap::new();
fingerprints.insert("cold-test".to_owned(), 77);
let cache = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
fingerprints.clone(),
);
fill(&cache, &settings, "/one", "stable-contents").await;
fill(&cache, &settings, "/two", "second-contents").await;
wait_for_cold(&cache, 1).await;
shutdown.cancel();
drop(cache);
let restart = CancellationToken::new();
let same = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart.clone(),
fingerprints.clone(),
);
assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD");
restart.cancel();
drop(same);
fingerprints.insert("cold-test".to_owned(), 78);
let restart = CancellationToken::new();
let changed = Cache::start_with_scope_revisions(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart.clone(),
fingerprints,
);
assert_eq!(
changed.view().cold_entries,
0,
"old COLD must never survive a content revision"
);
restart.cancel();
}
}
+14 -12
View File
@@ -51,9 +51,13 @@ impl Cache {
.fetch_add(1, Ordering::Relaxed);
return false;
};
let scope_revision = self.scope_revisions
.lock().unwrap_or_else(|e| e.into_inner())
.get(&entry.scope).copied().unwrap_or(0);
let scope_revision = self
.scope_revisions
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&entry.scope)
.copied()
.unwrap_or(0);
self.cold_queue_items.fetch_add(1, Ordering::Relaxed);
if tx
.try_send(ColdCommand::Store(ColdDraft {
@@ -138,9 +142,6 @@ impl Cache {
drop(store);
release_allocator_memory();
}
/// Best-effort graceful shutdown handoff from RAM to the persistent cold
/// tier. The cold worker shares the process shutdown token, so this path
/// writes directly instead of depending on the background queue.
async fn flush_hot_to_cold_inner(&self, force_shutdown_policy: bool) -> usize {
let settings = self
.tier_settings
@@ -190,9 +191,13 @@ impl Cache {
}),
}
});
let scope_revision = self.scope_revisions
.lock().unwrap_or_else(|e| e.into_inner())
.get(&entry.scope).copied().unwrap_or(0);
let scope_revision = self
.scope_revisions
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&entry.scope)
.copied()
.unwrap_or(0);
ColdDraft {
key,
revision,
@@ -215,9 +220,6 @@ impl Cache {
pub async fn flush_hot_to_cold(&self) -> usize {
self.flush_hot_to_cold_inner(false).await
}
/// Managed software updates explicitly request a hot-to-cold handoff. The
/// cold tier must still be enabled, but the ordinary cold_on_shutdown toggle
/// does not disable update preservation.
pub async fn flush_hot_to_cold_for_update(&self) -> usize {
self.flush_hot_to_cold_inner(true).await
}
-2
View File
@@ -27,8 +27,6 @@ impl Cache {
return;
};
// v0.21 intentionally does not migrate the legacy flat cold-cache layout.
// Remove only known cache artifacts from the old root and rebuild naturally.
let mut shard_dirs = Vec::new();
for item in read_dir.flatten() {
let path = item.path();
-5
View File
@@ -29,9 +29,6 @@ impl Cache {
loop {
let command = tokio::select! {
_ = shutdown.cancelled() => {
// The listener shutdown token is shared with the cache. Drain
// already accepted demotions before exiting so a restart does
// not discard buffers that were already handed to cold storage.
rx.close();
while let Ok(command) = rx.try_recv() {
let Some(cache) = weak.upgrade() else { return; };
@@ -58,8 +55,6 @@ impl Cache {
self.cold_write_bytes_active
.fetch_sub(bytes, Ordering::Relaxed);
self.cold_gc_pass().await;
// The draft body is out of scope before trimming. `Bytes` has released its
// reference and glibc is explicitly asked to return releasable pages.
release_allocator_memory();
}
async fn persist_cold_inner(&self, draft: ColdDraft) -> bool {
+10 -34
View File
@@ -36,10 +36,7 @@ impl Drop for CollapseWaiterGuard {
impl Drop for FillLeaderGuard {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
let mut flights = cache
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = cache.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
let current = flights
.get(&self.key)
.is_some_and(|flight| flight.id == self.id);
@@ -47,25 +44,17 @@ impl Drop for FillLeaderGuard {
flights.remove(&self.key);
}
}
// Publish completion even when a purge already removed this flight from the map.
self.done.send_replace(true);
}
}
impl Cache {
pub async fn claim_fill(
self: &Arc<Self>,
ticket: &Ticket,
settings: &Settings,
) -> FillClaim {
pub async fn claim_fill(self: &Arc<Self>, ticket: &Ticket, settings: &Settings) -> FillClaim {
if !settings.collapse_enabled {
return FillClaim::Disabled;
}
let existing = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
if let Some(flight) = flights.get(&ticket.key) {
Some(flight.clone())
} else {
@@ -96,8 +85,6 @@ impl Cache {
let Some(flight) = existing else {
unreachable!("existing fill resolved above")
};
// A watch channel retains the completed state, so a leader finishing between
// map lookup and follower polling cannot lose the wakeup.
let mut done = flight.done.subscribe();
self.collapse_waiters.fetch_add(1, Ordering::Relaxed);
let _waiter = CollapseWaiterGuard {
@@ -131,11 +118,11 @@ impl Cache {
fn cancel_all_fill_flights(&self) {
let flights = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
flights.drain().map(|(_, flight)| flight).collect::<Vec<_>>()
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
flights
.drain()
.map(|(_, flight)| flight)
.collect::<Vec<_>>()
};
for flight in flights {
flight.done.send_replace(true);
@@ -144,10 +131,7 @@ impl Cache {
fn cancel_scope_fill_flights(&self, scope: &str) {
let removed = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
let keys = flights
.iter()
.filter(|(_, flight)| flight.scope == scope)
@@ -210,9 +194,6 @@ impl Cache {
.proactive_scheduled_keys
.lock()
.unwrap_or_else(|e| e.into_inner());
// Each scheduled key owns a sleeping Tokio task plus refresh context.
// Bound the pending set independently from the cache entry count so a
// large cache with long TTLs cannot turn into thousands of resident tasks.
let pending_limit = settings
.proactive_refresh_max_inflight
.saturating_mul(64)
@@ -250,12 +231,7 @@ impl Cache {
}
if self
.proactive_inflight
.compare_exchange_weak(
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
)
.compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
self.counters
+21 -10
View File
@@ -99,14 +99,22 @@ impl Cache {
("bans_removed_total", "counter", view.bans_removed),
("ban_matches_total", "counter", view.ban_matches),
("active_bans", "gauge", view.active_bans as u64),
("proactive_scheduled_total", "counter", view.proactive_scheduled),
(
"proactive_scheduled_total",
"counter",
view.proactive_scheduled,
),
("proactive_started_total", "counter", view.proactive_started),
("proactive_skipped_total", "counter", view.proactive_skipped),
("proactive_inflight", "gauge", view.proactive_inflight),
("entries", "gauge", view.entries as u64),
("resident_bytes", "gauge", view.resident_bytes as u64),
("hot_logical_bytes", "gauge", view.hot_logical_bytes as u64),
("hot_allocated_bytes", "gauge", view.hot_allocated_bytes as u64),
(
"hot_allocated_bytes",
"gauge",
view.hot_allocated_bytes as u64,
),
("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64),
(
"transient_limit_bytes",
@@ -121,7 +129,11 @@ impl Cache {
("cold_entries", "gauge", view.cold_entries as u64),
("cold_bytes", "gauge", view.cold_bytes),
("cold_limit_bytes", "gauge", view.cold_limit_bytes),
("cold_limit_entries", "gauge", view.cold_limit_entries as u64),
(
"cold_limit_entries",
"gauge",
view.cold_limit_entries as u64,
),
("cold_hits_total", "counter", view.cold_hits),
("cold_promotions_total", "counter", view.cold_promotions),
("hot_demotions_total", "counter", view.hot_demotions),
@@ -161,7 +173,6 @@ impl Cache {
pub fn bypass(&self) {
self.counters.bypasses.fetch_add(1, Ordering::Relaxed);
}
/// Clear all keys and cancel publication of older in-flight fills.
pub fn purge(&self, revision: Option<u64>) -> usize {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let mut count = store.entries.len();
@@ -198,11 +209,11 @@ impl Cache {
let _ = tx.try_send(ColdCommand::PurgeDirectory(root));
}
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
self.counters
.purge_entries
.fetch_add(count as u64, Ordering::Relaxed);
count
}
/// Purge one vhost without evicting other hosts. Generation bumps also stop
/// an already streaming MISS from repopulating the just-purged scope.
pub fn purge_scope(&self, scope: &str) -> usize {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let keys: Vec<_> = store
@@ -239,7 +250,9 @@ impl Cache {
let _ = tx.try_send(ColdCommand::Delete(delete));
}
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
self.counters
.purge_entries
.fetch_add(count as u64, Ordering::Relaxed);
count
}
pub fn scope_view(&self, scope: &str) -> ScopeView {
@@ -303,8 +316,6 @@ impl Cache {
continue;
};
let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs();
// This is an inventory of retained objects, not a freshness lookup.
// Expired representations still occupy RAM and may be revalidated.
if !scope.is_none_or(|s| s == e.scope) {
continue;
}
@@ -91,9 +91,14 @@ mod dashboard_tests {
assert!(dashboard.objects[0].age_seconds >= 2 * 60 * 60);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().expired, 0);
assert!(cache.get(&ticket).is_none(), "inventory visibility cannot permit unconditional stale delivery");
assert!(
cache.get(&ticket).is_none(),
"inventory visibility cannot permit unconditional stale delivery"
);
let mut stale = ticket;
stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale"));
stale
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
assert!(cache.get(&stale).unwrap().stale);
}
#[test]
@@ -144,13 +149,22 @@ mod dashboard_tests {
};
let mut headers = HeaderMap::new();
headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream"));
headers.insert("cache-status", HeaderValue::from_static("untrusted-upstream"));
headers.insert(
"cache-status",
HeaderValue::from_static("untrusted-upstream"),
);
global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy");
global.annotate_transformed(&mut headers, true);
for name in ["x-cache", "x-cache-hits", "cache-status", "x-cache-reason", "x-cache-resident-age"] {
for name in [
"x-cache",
"x-cache-hits",
"cache-status",
"x-cache-reason",
"x-cache-resident-age",
] {
assert!(!headers.contains_key(name), "{name} leaked on MISS");
}
assert_eq!(headers["via"], "RustProxy"); // independent proxy policy
assert_eq!(headers["via"], "RustProxy");
global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie");
assert!(!headers.contains_key("cache-status"));
global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit");
@@ -159,11 +173,19 @@ mod dashboard_tests {
assert_eq!(headers["x-cache-hits"], "4");
assert!(headers["cache-status"].to_str().unwrap().contains("; hit;"));
assert_eq!(headers["x-cache-resident-age"], "10");
let mut override_on = Overrides { expose_only_hits: Some(false), ..Default::default() };
let mut override_on = Overrides {
expose_only_hits: Some(false),
..Default::default()
};
let normal = override_on.resolve(&global).unwrap();
assert!(!normal.expose_only_hits);
override_on.expose_only_hits = Some(true);
assert!(override_on.resolve(&Settings::default()).unwrap().expose_only_hits);
assert!(
override_on
.resolve(&Settings::default())
.unwrap()
.expose_only_hits
);
}
#[test]
fn via_header_is_configurable_and_preserves_upstream_chain() {
@@ -178,7 +200,10 @@ mod dashboard_tests {
.collect();
assert_eq!(values, vec!["1.1 upstream", "RustProxy"]);
let disabled = Settings { via_header: None, ..Settings::default() };
let disabled = Settings {
via_header: None,
..Settings::default()
};
let mut headers = HeaderMap::new();
disabled.annotate(&mut headers, "MISS", 0, 0);
assert!(!headers.contains_key("via"));
@@ -218,7 +243,6 @@ mod dashboard_tests {
#[test]
fn cache_domain_metrics_do_not_lock_hot_store_and_preserve_reasons() {
let cache = Cache::new(7);
// A BYPASS must be recordable even while the HOT/LRU mutex is held.
let store = cache.store.lock().unwrap();
cache.record("alpha", "a.example", "BYPASS", "cache_disabled");
cache.record_reason("alpha", "a.example", "background_refresh_failure");
@@ -247,7 +271,12 @@ mod dashboard_tests {
assert_eq!(counters.reasons["cache_hit"], workers * each);
assert_eq!(counters.reasons["background_refresh_failure"], 1);
assert_eq!(cache.purge_scope("alpha"), 0);
assert_eq!(cache.dashboard(Some("alpha"), 0, 1).domains[0].counters.hits, workers * each);
assert_eq!(
cache.dashboard(Some("alpha"), 0, 1).domains[0]
.counters
.hits,
workers * each
);
cache.purge(Some(8));
assert!(cache.dashboard(None, 0, 1).domains.is_empty());
cache.record("alpha", "a.example", "MISS", "cache_miss");
@@ -260,7 +289,9 @@ mod dashboard_tests {
#[test]
fn first_hit_saved_bytes_registers_domain_even_without_prior_record() {
let cache = Cache::new(7);
cache.domain_stats.saved_body_hit("cold", "restored.example", 1040);
cache
.domain_stats
.saved_body_hit("cold", "restored.example", 1040);
let result = cache.dashboard(Some("cold"), 0, 1);
assert_eq!(result.domains.len(), 1);
assert_eq!(result.domains[0].counters.saved_body_bytes, 1040);
+24 -37
View File
@@ -63,8 +63,6 @@ struct Candidate {
plan: Plan,
headers: HeaderMap,
data: Vec<u8>,
// A transformed response with one uniquely owned Bytes frame can share its
// allocation with the downstream response, avoiding an extra Vec copy.
shared: Option<Bytes>,
shared_allocated: usize,
fill_gauge: FillGauge,
@@ -78,11 +76,9 @@ struct Candidate {
}
impl Candidate {
fn finish(mut self) {
if self
.plan
.expected_length
.is_some_and(|length| self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length)
{
if self.plan.expected_length.is_some_and(|length| {
self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length
}) {
let reason = if self.transformed {
"transform_length_mismatch"
} else {
@@ -101,7 +97,6 @@ impl Candidate {
self.headers.insert("date", value);
}
}
// Each downstream exchange receives a new ID, never a cached origin ID.
for name in [
"x-request-id",
"x-cache",
@@ -113,9 +108,6 @@ impl Candidate {
self.headers.remove(name);
}
let vary = std::mem::take(&mut self.plan.vary);
// Keep the actual backing capacity as the HOT charge. Vec::reserve_exact
// avoids geometric multi-MiB overshoot while Bytes::from(Vec) transfers
// the allocation without copying.
let (body, body_allocation) = if let Some(body) = self.shared.take() {
(body, self.shared_allocated)
} else {
@@ -201,10 +193,6 @@ impl HttpBody for FillBody {
match Pin::new(&mut this.inner).poll_frame(cx) {
Poll::Ready(Some(Ok(frame))) => {
let mut frame = frame;
// Only the transformed single-frame case is eligible. Taking
// unique ownership lets Bytes reveal its true allocation
// capacity; we must never undercharge an arbitrary shared
// Bytes slice that could retain a much larger parent buffer.
if this.inner.is_end_stream() {
if let Some(candidate) = &mut this.candidate {
let single_frame = candidate.transformed
@@ -216,26 +204,26 @@ impl HttpBody for FillBody {
});
if single_frame {
match frame.into_data() {
Ok(bytes) => {
match bytes.try_into_mut() {
Ok(mutable) => {
let allocation = mutable.capacity();
let bytes = mutable.freeze();
// A very oversized backing Vec is cheaper to
// copy into a smaller HOT allocation instead.
let acceptable_spare = bytes.len().saturating_div(8).min(64 * 1024);
if allocation <= bytes.len().saturating_add(acceptable_spare)
&& allocation <= candidate.settings.max_object_bytes
&& candidate.fill_gauge.grow(allocation, &candidate.settings)
{
candidate.shared = Some(bytes.clone());
candidate.shared_allocated = allocation;
}
frame = Frame::data(bytes);
Ok(bytes) => match bytes.try_into_mut() {
Ok(mutable) => {
let allocation = mutable.capacity();
let bytes = mutable.freeze();
let acceptable_spare =
bytes.len().saturating_div(8).min(64 * 1024);
if allocation
<= bytes.len().saturating_add(acceptable_spare)
&& allocation <= candidate.settings.max_object_bytes
&& candidate
.fill_gauge
.grow(allocation, &candidate.settings)
{
candidate.shared = Some(bytes.clone());
candidate.shared_allocated = allocation;
}
Err(bytes) => frame = Frame::data(bytes),
frame = Frame::data(bytes);
}
}
Err(bytes) => frame = Frame::data(bytes),
},
Err(original) => frame = original,
}
}
@@ -243,7 +231,6 @@ impl HttpBody for FillBody {
}
if let Some(candidate) = &mut this.candidate {
if candidate.shared.is_some() {
// The cache now owns the very same backing allocation.
} else if let Some(data) = frame.data_ref() {
let needed = candidate.data.len().saturating_add(data.len());
if needed <= candidate.settings.max_object_bytes {
@@ -284,7 +271,8 @@ impl HttpBody for FillBody {
this.candidate = None;
} else {
let after = candidate.data.capacity();
let unbudgeted = after.saturating_sub(before.saturating_add(required_growth));
let unbudgeted =
after.saturating_sub(before.saturating_add(required_growth));
if unbudgeted > 0
&& !candidate.fill_gauge.grow(unbudgeted, &candidate.settings)
{
@@ -328,7 +316,7 @@ impl HttpBody for FillBody {
.rejected_fills
.fetch_add(1, Ordering::Relaxed);
this.candidate = None;
} // Do not cache responses with trailers.
}
}
if this.inner.is_end_stream() {
this.done = true;
@@ -391,4 +379,3 @@ impl Drop for FillBody {
}
}
}
@@ -4,13 +4,20 @@ mod http_invalidation_tests {
fn default_deny_and_strict_ipv4_ipv6_cidr_validation() {
let mut cache = Settings::default();
assert!(!cache.permits_http_invalidation("127.0.0.1".parse().unwrap()));
cache.http_invalidation_allowed_cidrs = vec!["192.0.2.42".into(), "2001:db8:10::/64".into()];
cache.http_invalidation_allowed_cidrs =
vec!["192.0.2.42".into(), "2001:db8:10::/64".into()];
cache.validate().unwrap();
assert!(cache.permits_http_invalidation("192.0.2.42".parse().unwrap()));
assert!(!cache.permits_http_invalidation("192.0.2.43".parse().unwrap()));
assert!(cache.permits_http_invalidation("2001:db8:10::1".parse().unwrap()));
assert!(!cache.permits_http_invalidation("2001:db8:11::1".parse().unwrap()));
for invalid in ["0.0.0.0/0", "::/0", "127.0.0.1; 8.8.8.8", "not-an-ip", "10.0.0.0/33"] {
for invalid in [
"0.0.0.0/0",
"::/0",
"127.0.0.1; 8.8.8.8",
"not-an-ip",
"10.0.0.0/33",
] {
cache.http_invalidation_allowed_cidrs = vec![invalid.into()];
assert!(cache.validate().is_err(), "must reject {invalid}");
}
@@ -37,5 +44,4 @@ mod http_invalidation_tests {
assert!(!host_matches(&matcher, "http", "a.example", "a.example"));
assert!(!host_matches(&matcher, "https", "b.example", "b.example"));
}
}
+12 -19
View File
@@ -6,7 +6,8 @@ impl Cache {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
bans.clone()
}
@@ -17,7 +18,8 @@ impl Cache {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
let banned = bans.iter().any(|rule| rule.matches_hot(key, entry, now));
if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
@@ -32,7 +34,8 @@ impl Cache {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
let banned = bans.iter().any(|rule| rule.matches_cold(key, entry, now));
if banned {
self.counters.ban_matches.fetch_add(1, Ordering::Relaxed);
@@ -47,7 +50,8 @@ impl Cache {
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
bans.iter().any(|rule| rule.matches_hot(key, entry, now))
}
@@ -60,7 +64,8 @@ impl Cache {
let before = bans.len();
bans.retain(|rule| rule.id != id);
let removed = bans.len() != before;
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
if removed {
self.counters.bans_removed.fetch_add(1, Ordering::Relaxed);
}
@@ -97,7 +102,8 @@ impl Cache {
created_at: now,
expires_at: now.checked_add(Duration::from_secs(ttl)).unwrap_or(now),
});
self.ban_rule_count.store(bans.len() as u64, Ordering::Release);
self.ban_rule_count
.store(bans.len() as u64, Ordering::Release);
self.counters.bans_created.fetch_add(1, Ordering::Relaxed);
Ok(InvalidationResult {
operation: request.operation,
@@ -112,22 +118,9 @@ impl Cache {
})
}
InvalidationOperation::Purge => {
// A selective purge is also a publication barrier for every fill that
// started before this operation. Existing unrelated objects remain
// reusable, but an old in-flight response cannot repopulate a target
// that was not yet identifiable (for example Cache-Tag before headers).
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.generation = store.generation.wrapping_add(1);
// This global publication barrier invalidates every older
// ticket and pending COLD demotion. Per-key generations only
// need to cover the current purge, not every key ever
// purged over the lifetime of the process. Without clearing
// them, repeated PURGEs of distinct URLs grow this map
// indefinitely even after the objects have been evicted.
// Release an unusually large index's backing allocation
// after mass invalidations instead of retaining its peak
// bucket capacity for the rest of the process lifetime.
if store.key_generations.capacity() > 256 {
store.key_generations = Default::default();
} else {
+36 -41
View File
@@ -111,7 +111,9 @@ impl Cache {
let weak = Arc::downgrade(&cache);
tokio::spawn(async move {
loop {
let Some(cache) = weak.upgrade() else { break; };
let Some(cache) = weak.upgrade() else {
break;
};
let interval = cache
.tier_settings
.lock()
@@ -130,9 +132,6 @@ impl Cache {
cache
}
fn fill_headroom_bytes(max_bytes: usize) -> usize {
// Allocation rounding is allowed a small global cushion, never a whole
// max_object allocation. Use roughly 1/64 of HOT, cap it at 1 MiB and
// never let the cushion itself exceed 1/8 of a very small HOT budget.
let proportional = (max_bytes / 64).clamp(4 * 1024, 1024 * 1024);
proportional.min((max_bytes / 8).max(1))
}
@@ -164,17 +163,14 @@ impl Cache {
}
}
/// Reserve real allocation for an in-progress fill before growing its buffer.
/// Existing HOT objects are LRU-evicted/demoted first so HOT + active fill
/// allocation cannot race arbitrarily above the configured HOT budget.
fn reserve_fill_allocation(&self, bytes: usize, settings: &Settings) -> Option<TransientLease> {
if bytes == 0 {
return self.try_reserve_transient(0, settings);
}
let lease = self.try_reserve_transient(bytes, settings)?;
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let fill_now = usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed))
.unwrap_or(usize::MAX);
let fill_now =
usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)).unwrap_or(usize::MAX);
let allowed = settings
.max_bytes
.saturating_add(Self::fill_headroom_bytes(settings.max_bytes));
@@ -182,20 +178,14 @@ impl Cache {
let generation = store.generation;
let mut removed = 0usize;
let mut released_hot = false;
while store
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
> allowed
{
while store.bytes.saturating_add(fill_now).saturating_add(bytes) > allowed {
if removed >= settings.hot_eviction_max_objects {
self.counters
.hot_eviction_limited
.fetch_add(1, Ordering::Relaxed);
break;
}
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
break;
};
let Some(old) = store.remove(&key) else {
@@ -227,11 +217,7 @@ impl Cache {
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
let fits = store
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
<= allowed;
let fits = store.bytes.saturating_add(fill_now).saturating_add(bytes) <= allowed;
if fits {
self.fill_bytes_active
.fetch_add(bytes as u64, Ordering::Relaxed);
@@ -251,10 +237,6 @@ impl Cache {
}
}
/// Bound long-lived HOT RSS without flushing the working set. Only entries
/// idle for the configured age are considered, and only while HOT usage is
/// above the configured target waterline.
fn trim_hot_idle(&self) {
let settings = self
.tier_settings
@@ -285,17 +267,31 @@ impl Cache {
.get(&key)
.is_some_and(|entry| entry.last_access.elapsed() >= idle_cutoff);
if !idle {
// LRU order is also last-access order, so newer entries cannot qualify.
break;
}
let Some(entry) = store.remove(&key) else { continue; };
let Some(entry) = store.remove(&key) else {
continue;
};
removed += 1;
let scope_generation = store.scope_generations.get(&entry.scope).copied().unwrap_or(0);
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
let scope_generation = store
.scope_generations
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store
.key_generations
.get(&entry.base_key)
.copied()
.unwrap_or(0);
if !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(
key, entry, revision, generation, scope_generation, key_generation,
key,
entry,
revision,
generation,
scope_generation,
key_generation,
))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
@@ -307,29 +303,28 @@ impl Cache {
}
}
/// Block requests bound to an old immutable routing snapshot from creating
/// cache tickets during a concurrent configuration publication. This does
/// not clear HOT or COLD and does not modify the representation revision.
pub fn set_control_revision(&self, revision: u64) {
self.store.lock().unwrap_or_else(|e| e.into_inner()).control_revision = revision;
self.store
.lock()
.unwrap_or_else(|e| e.into_inner())
.control_revision = revision;
}
pub fn replace_scope_revisions(&self, scopes: HashMap<String, u64>) {
*self.scope_revisions.lock().unwrap_or_else(|e| e.into_inner()) = scopes;
*self
.scope_revisions
.lock()
.unwrap_or_else(|e| e.into_inner()) = scopes;
}
pub fn configure(&self, settings: Settings) {
*self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone();
// Hot-applied limit reductions take effect immediately for published HOT
// objects. Demotion is best effort and remains bounded by transient/COLD
// queue budgets; otherwise the LRU object is dropped.
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let revision = store.revision;
let generation = store.generation;
let mut released_hot = false;
while store.entries.len() > settings.max_entries || store.bytes > settings.max_bytes {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
break;
};
let Some(old) = store.remove(&key) else {
+30 -15
View File
@@ -1,20 +1,22 @@
impl Cache {
fn get_hot(&self, ticket: &Ticket, count_miss: bool) -> Option<Hit> {
// Only metadata, hit counters and exact LRU ordering require the store
// mutex. Materializing an HTTP response may allocate (headers, Body,
// Age formatting) and must be done *after* dropping that mutex.
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if store.revision != ticket.revision
|| store.generation != ticket.generation
|| store.scope_generations.get(&ticket.scope).copied().unwrap_or(0)
|| store
.scope_generations
.get(&ticket.scope)
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0)
!= ticket.key_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
{
return None;
}
if let Some(storage_key) = matching_hot_key(&store, ticket) {
let banned = store.entries.get(&storage_key)
let banned = store
.entries
.get(&storage_key)
.is_some_and(|entry| self.hot_entry_banned(&storage_key, entry));
if banned {
store.remove(&storage_key);
@@ -27,7 +29,10 @@ impl Cache {
}
let entry = store.entries.get(&storage_key)?;
let resident_age_seconds = hot_resident_age(entry).as_secs();
let age = entry.initial_age.saturating_add(entry.received.elapsed()).as_secs();
let age = entry
.initial_age
.saturating_add(entry.received.elapsed())
.as_secs();
let state = cache_state(
age,
entry.ttl,
@@ -39,8 +44,16 @@ impl Cache {
);
if state.servable() {
let sequence = store.next_sequence();
let (old_sequence, headers, body, hits, transformed, skip_compression,
remaining_ttl, body_bytes) = {
let (
old_sequence,
headers,
body,
hits,
transformed,
skip_compression,
remaining_ttl,
body_bytes,
) = {
let entry = store.entries.get_mut(&storage_key)?;
let old_sequence = entry.sequence;
entry.hits = entry.hits.saturating_add(1);
@@ -59,15 +72,15 @@ impl Cache {
};
store.lru.remove(&old_sequence);
store.lru.insert(sequence, storage_key);
// Preserve the exact LRU and domain-byte counters, without
// allocating a replacement variant index or String keys.
self.counters.hits.fetch_add(1, Ordering::Relaxed);
drop(store);
let mut response = Response::new(Body::from(body.clone()));
*response.headers_mut() = headers.as_ref().clone();
if transformed {
response.extensions_mut().insert(crate::transform::CachedTransformed);
response
.extensions_mut()
.insert(crate::transform::CachedTransformed);
}
if skip_compression {
response.extensions_mut().insert(crate::compression::Skip);
@@ -76,7 +89,8 @@ impl Cache {
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
self.domain_stats
.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
return Some(Hit {
response,
body,
@@ -274,7 +288,8 @@ impl Cache {
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
self.domain_stats
.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
self.counters.hits.fetch_add(1, Ordering::Relaxed);
self.counters.cold_hits.fetch_add(1, Ordering::Relaxed);
self.counters
@@ -3,7 +3,10 @@ mod hot_hit_034_tests {
use http_body_util::BodyExt;
fn settings() -> Settings {
Settings { enabled: true, ..Settings::default() }
Settings {
enabled: true,
..Settings::default()
}
}
async fn publish(
@@ -13,16 +16,25 @@ mod hot_hit_034_tests {
body: &'static str,
transformed: bool,
) -> Ticket {
let ticket = cache.ticket_scoped(
34, "app", "http", "cache.example", "origin@127.0.0.1:8080",
path, &HeaderMap::new(),
).expect("cache ticket");
let ticket = cache
.ticket_scoped(
34,
"app",
"http",
"cache.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.expect("cache ticket");
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control", HeaderValue::from_static("public, max-age=31536000"),
"cache-control",
HeaderValue::from_static("public, max-age=31536000"),
);
response.headers_mut().insert(
"etag", HeaderValue::from_static("\"cached-representation\""),
"etag",
HeaderValue::from_static("\"cached-representation\""),
);
if transformed {
response.extensions_mut().insert(crate::transform::Applied {
@@ -33,8 +45,12 @@ mod hot_hit_034_tests {
}
let policy = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO)
.expect("public static object cacheable");
cache.wrap(response, ticket.clone(), policy, settings)
.into_body().collect().await.unwrap();
cache
.wrap(response, ticket.clone(), policy, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
@@ -52,12 +68,21 @@ mod hot_hit_034_tests {
let hit = cache.get(&ticket).expect("HOT cache hit");
assert_eq!(hit.hits, i);
assert!(hit.transformed);
assert!(hit.response.extensions().get::<crate::transform::CachedTransformed>().is_some());
assert!(hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(hit.response.headers()["etag"], "\"cached-representation\"");
let mut independent = hit.response;
independent.headers_mut().insert("etag", HeaderValue::from_static("\"client-only\""));
independent
.headers_mut()
.insert("etag", HeaderValue::from_static("\"client-only\""));
if i == 500 {
assert_eq!(independent.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"body{color:red}"));
assert_eq!(
independent.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"body{color:red}")
);
}
}
let after = cache.view();
@@ -73,7 +98,10 @@ mod hot_hit_034_tests {
let dashboard = cache.dashboard(Some("app"), 0, 10);
assert_eq!(dashboard.objects[0].hits, 500);
assert_eq!(dashboard.domains[0].counters.saved_body_bytes, 500 * 15);
assert_eq!(cache.get(&ticket).unwrap().response.headers()["etag"], "\"cached-representation\"");
assert_eq!(
cache.get(&ticket).unwrap().response.headers()["etag"],
"\"cached-representation\""
);
}
#[tokio::test]
@@ -96,9 +124,15 @@ mod hot_hit_034_tests {
hits
}));
}
let mut counts: Vec<_> = threads.into_iter().flat_map(|t| t.join().unwrap()).collect();
let mut counts: Vec<_> = threads
.into_iter()
.flat_map(|t| t.join().unwrap())
.collect();
counts.sort_unstable();
assert_eq!(counts, (1..=(workers * per_worker) as u64).collect::<Vec<_>>());
assert_eq!(
counts,
(1..=(workers * per_worker) as u64).collect::<Vec<_>>()
);
assert_eq!(cache.view().hits, (workers * per_worker) as u64);
let store = cache.store.lock().unwrap();
assert_eq!(store.lru.len(), 1);
@@ -108,7 +142,10 @@ mod hot_hit_034_tests {
#[tokio::test]
async fn hot_lru_eviction_and_ban_are_preserved() {
let cache = Cache::new(34);
let settings = Settings { max_entries: 2, ..settings() };
let settings = Settings {
max_entries: 2,
..settings()
};
let a = publish(&cache, &settings, "/a.css", "AAAA", false).await;
let b = publish(&cache, &settings, "/b.css", "BBBB", false).await;
assert!(cache.get(&a).is_some(), "A becomes most recently used");
@@ -117,15 +154,17 @@ mod hot_hit_034_tests {
assert!(cache.get(&b).is_none(), "B is least recently used");
assert!(cache.get(&c).is_some());
let ban = cache.invalidate(InvalidationRequest {
operation: InvalidationOperation::Ban,
selector: InvalidationSelector::Exact,
value: "/a.css".into(),
host: Some("cache.example".into()),
vhost: Some("app".into()),
ttl_seconds: Some(3600),
confirm_wide: false,
}).unwrap();
let ban = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Ban,
selector: InvalidationSelector::Exact,
value: "/a.css".into(),
host: Some("cache.example".into()),
vhost: Some("app".into()),
ttl_seconds: Some(3600),
confirm_wide: false,
})
.unwrap();
assert_eq!(cache.ban_rule_count.load(Ordering::Acquire), 1);
assert!(cache.get(&a).is_none(), "BAN must block cached A");
assert!(cache.remove_ban(ban.ban_id.unwrap()));
@@ -147,8 +186,11 @@ mod hot_hit_034_tests {
std::hint::black_box(cache.get(&ticket).unwrap());
}
let duration = start.elapsed();
eprintln!("HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS",
duration.as_secs_f64() * 1e9 / iterations as f64, iterations);
eprintln!(
"HOT lookup incl. response materialization: {:.1} ns/op ({} hits); NOT network RPS",
duration.as_secs_f64() * 1e9 / iterations as f64,
iterations
);
assert_eq!(cache.view().hits, iterations + 1_000);
}
@@ -161,12 +203,19 @@ mod hot_hit_034_tests {
store.entries.values_mut().next().unwrap().received =
Instant::now() - Duration::from_secs(700_000);
}
assert!(cache.get(&ticket).is_none(), "expired freshness is not an unconditional HIT");
assert_eq!(cache.view().entries, 1, "stale representation must survive for revalidation");
let mut stale_ticket = ticket;
stale_ticket.request_headers.insert(
"cache-control", HeaderValue::from_static("max-stale"),
assert!(
cache.get(&ticket).is_none(),
"expired freshness is not an unconditional HIT"
);
assert_eq!(
cache.view().entries,
1,
"stale representation must survive for revalidation"
);
let mut stale_ticket = ticket;
stale_ticket
.request_headers
.insert("cache-control", HeaderValue::from_static("max-stale"));
assert!(cache.get(&stale_ticket).unwrap().stale);
}
}
-3
View File
@@ -44,9 +44,6 @@ impl Cache {
{
return false;
}
// Do not keep objects for incompatible Vary schemas under the same
// Varnish-style host+URL hash. A newer representation policy replaces
// the older schema while preserving sibling values of the same Vary set.
for key in incompatible_hot_variants(&store, &ticket.key, &vary_schema) {
store.remove(&key);
}
+9 -7
View File
@@ -170,10 +170,15 @@ impl Cache {
Some(validators) => validators.last_modified.clone(),
None => headers.get("last-modified").cloned(),
};
let transformed = parts.extensions.get::<crate::transform::Applied>().is_some();
// A transformed, fully buffered body normally arrives as one owned
// frame. Do not allocate a second fill buffer before inspecting it.
let initial_capacity = if transformed { 0 } else { fill_size.min(64 * 1024) };
let transformed = parts
.extensions
.get::<crate::transform::Applied>()
.is_some();
let initial_capacity = if transformed {
0
} else {
fill_size.min(64 * 1024)
};
let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else {
self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed);
self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure");
@@ -205,9 +210,6 @@ impl Cache {
_permit: permit,
_fill_leader: fill_leader,
};
// A genuinely finished body (including an empty local file) may never
// be polled by Hyper. EOS is the proof here, not Content-Length alone;
// finish still enforces expected length, privacy and ticket validity.
if body.is_end_stream() {
candidate.finish();
return Response::from_parts(parts, body);
-6
View File
@@ -1,9 +1,3 @@
//! Bounded, conservative shared two-tier cache. Fresh objects live in a fast RAM
//! hot tier; optionally, idle/LRU objects are persisted in a bounded cold tier.
//! Bodies are streamed while a bounded copy is collected; only complete successful
//! bodies are published. The implementation is split by responsibility so storage,
//! policy and streaming-fill code can be reviewed independently.
include!("model.rs");
include!("stats.rs");
include!("lifecycle.rs");
+1 -24
View File
@@ -28,9 +28,7 @@ pub struct ObjectView {
pub storage_bytes: u64,
pub tier: &'static str,
pub hits: u64,
/// RFC 9111 Age, including time before the object entered this cache.
pub age_seconds: u64,
/// Total time held by RustProxy HOT/COLD; excludes any upstream CDN Age.
pub resident_age_seconds: u64,
pub remaining_ttl_seconds: u64,
}
@@ -50,11 +48,8 @@ pub struct Dashboard {
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ColdCleanupRequest {
/// Remove objects not used for at least this many seconds.
pub unused_for_seconds: Option<u64>,
/// Additionally remove up to N oldest matching objects.
pub oldest: Option<usize>,
/// Limit the selection to objects which were never served.
pub hits_zero: bool,
}
impl Default for ColdCleanupRequest {
@@ -113,7 +108,6 @@ struct Entry {
created_at: SystemTime,
headers: Arc<HeaderMap>,
body: Bytes,
/// Backing allocation retained by `body`; can exceed `body.len()`.
body_allocated: usize,
received: Instant,
last_access: Instant,
@@ -135,7 +129,7 @@ struct Entry {
requires_revalidation: bool,
}
#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting.
#[allow(clippy::ptr_arg)]
fn hot_entry_cost(
body_allocated: usize,
headers: &HeaderMap,
@@ -150,11 +144,6 @@ fn hot_entry_cost(
origin_etag: Option<&HeaderValue>,
origin_last_modified: Option<&HeaderValue>,
) -> usize {
// The public containers do not expose an exact per-entry allocator charge.
// Account their retained index memory conservatively: HOT HashMap key/control,
// the LRU BTreeMap item/node share and the variants index (including spare
// Vec<Key> capacity). This keeps `max_bytes` closer to the real retained
// allocation instead of pretending Entry/body are the only HOT owners.
let index_heap = std::mem::size_of::<Key>()
.saturating_add(std::mem::size_of::<(u64, Key)>())
.saturating_add(32)
@@ -171,8 +160,6 @@ fn hot_entry_cost(
let header_heap = headers
.iter()
.map(|(name, value)| {
// HeaderMap does not expose its bucket capacity. Charge a conservative
// per-field bucket/node allowance in addition to visible name/value bytes.
name.as_str()
.len()
.saturating_add(value.as_bytes().len())
@@ -181,7 +168,6 @@ fn hot_entry_cost(
.sum::<usize>();
body_allocated
.saturating_add(std::mem::size_of::<Entry>())
// Arc<HeaderMap> owns the actual map on a separate allocation.
.saturating_add(std::mem::size_of::<HeaderMap>())
.saturating_add(2 * std::mem::size_of::<usize>())
.saturating_add(index_heap)
@@ -207,7 +193,6 @@ struct ColdFile {
#[serde(default)]
vary: Vec<String>,
revision: u64,
/// 0 denotes legacy/unscoped entries; production vhosts require an exact match.
#[serde(default)]
scope_revision: u64,
scope: String,
@@ -284,9 +269,6 @@ struct ColdEntry {
hits: u64,
transformed: bool,
sequence: u64,
// Stable identity of this in-memory cold entry. Unlike `sequence`, this
// never changes on access, so an old async reader cannot unpin a newer
// replacement stored under the same cache key.
generation: u64,
skip_compression: bool,
requires_revalidation: bool,
@@ -311,8 +293,6 @@ struct ColdDraft {
key_generation: u64,
entry: Entry,
root: PathBuf,
/// Keeps this full in-memory object charged to the bounded transient pool
/// until the COLD write completes and the payload is dropped.
_memory_lease: TransientLease,
}
@@ -331,8 +311,6 @@ struct Store {
sequence: u64,
generation: u64,
revision: u64,
/// Current persisted administrative config revision. Distinct from cache
/// representation revision: operational saves do not evict data.
control_revision: u64,
scope_generations: HashMap<String, u64>,
key_generations: HashMap<Key, u64>,
@@ -361,7 +339,6 @@ impl Store {
Some(entry)
}
fn next_sequence(&mut self) -> u64 {
// No practical wrapping, but a wrap must not corrupt LRU bookkeeping.
if self.sequence == u64::MAX {
self.entries.clear();
self.variants.clear();
+20 -24
View File
@@ -167,7 +167,6 @@ impl Settings {
.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",
@@ -178,15 +177,15 @@ impl Settings {
] {
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"),
value
.parse::<HeaderValue>()
.expect("validated cache Via header"),
);
}
if self.expose_only_hits && outcome != "HIT" {
@@ -226,10 +225,15 @@ impl Settings {
}
}
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"))) {
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
@@ -246,7 +250,6 @@ impl Settings {
headers.insert("cache-status", value);
}
}
/// Reasons are fixed internal tokens: never echo request values or secrets.
pub fn annotate_reason(
&self,
headers: &mut HeaderMap,
@@ -284,8 +287,6 @@ fn valid_relative_cache_path(value: &str) -> bool {
.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 {
@@ -294,7 +295,10 @@ pub struct UnsafeForce {
}
impl UnsafeForce {
pub fn validate(&self) -> Result<()> {
ensure!(self.targets.len() <= 32, "forced cache supports at most 32 target prefixes");
ensure!(
self.targets.len() <= 32,
"forced cache supports at most 32 target prefixes"
);
for target in &self.targets {
ensure!(
!target.is_empty()
@@ -305,7 +309,10 @@ impl UnsafeForce {
"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");
ensure!(
crate::acl::routing_path(path)? == path,
"forced cache target path must be normalized"
);
}
Ok(())
}
@@ -323,8 +330,6 @@ impl UnsafeForce {
}
}
/// 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 {
@@ -341,15 +346,12 @@ pub struct Overrides {
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;
@@ -444,7 +446,6 @@ pub struct View {
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,
@@ -474,16 +475,11 @@ pub struct View {
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,
}
+10 -5
View File
@@ -1,11 +1,11 @@
// A strict, human-editable IP/CIDR policy. The caller supplies only a client IP
// produced by the existing forwarding trust boundary, never an arbitrary header.
fn parse_invalidation_cidr(value: &str) -> anyhow::Result<ipnet::IpNet> {
let value = value.trim();
if let Ok(ip) = value.parse::<std::net::IpAddr>() {
return Ok(ipnet::IpNet::from(ip));
}
value.parse::<ipnet::IpNet>().map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}"))
value
.parse::<ipnet::IpNet>()
.map_err(|_| anyhow::anyhow!("invalid HTTP invalidation IP/CIDR: {value}"))
}
impl Settings {
fn validate_http_invalidation_allowlist(&self) -> anyhow::Result<()> {
@@ -17,7 +17,10 @@ impl Settings {
for input in &self.http_invalidation_allowed_cidrs {
ensure!(input.len() <= 64, "HTTP invalidation address too long");
let net = parse_invalidation_cidr(input)?;
ensure!(net.prefix_len() > 0, "HTTP invalidation cannot authorize the entire Internet");
ensure!(
net.prefix_len() > 0,
"HTTP invalidation cannot authorize the entire Internet"
);
ensure!(networks.insert(net), "duplicate HTTP invalidation IP/CIDR");
}
Ok(())
@@ -25,7 +28,9 @@ impl Settings {
pub fn permits_http_invalidation(&self, client: std::net::IpAddr) -> bool {
let client = crate::vhost::canonical_ip(client);
self.http_invalidation_allowed_cidrs.iter().any(|value| {
parse_invalidation_cidr(value).ok().is_some_and(|network| network.contains(&client))
parse_invalidation_cidr(value)
.ok()
.is_some_and(|network| network.contains(&client))
})
}
}
+5 -9
View File
@@ -7,8 +7,6 @@ impl ColdStore {
}
fn next_sequence(&mut self) -> u64 {
if self.sequence == u64::MAX {
// Practically unreachable. Prefer dropping the in-memory index over
// risking ambiguous LRU ordering after wrap.
self.lru.clear();
self.entries.clear();
self.variants.clear();
@@ -56,7 +54,6 @@ pub struct Cache {
revision: AtomicU64,
cold: Arc<Mutex<ColdStore>>,
tier_settings: Mutex<Settings>,
/// Per-vhost content compatibility used by persisted COLD metadata.
scope_revisions: Mutex<HashMap<String, u64>>,
data_root: Option<PathBuf>,
cold_tx: Option<mpsc::Sender<ColdCommand>>,
@@ -143,8 +140,6 @@ pub struct Revalidation {
headers: HeaderMap,
body: Bytes,
body_allocated: usize,
// COLD revalidation bodies remain charged to transient RAM until every
// cloned revalidation handle is dropped or promoted. HOT revalidations use none.
_transient_leases: Vec<TransientLease>,
hits: u64,
transformed: bool,
@@ -171,9 +166,6 @@ impl Revalidation {
&self.headers
}
pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool {
// Client validators refer to the representation RustProxy served. If that
// representation was transformed, translate a matching validator back to
// the origin validator rather than leaking the generated proxy ETag upstream.
if headers.contains_key("if-none-match") {
let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else {
return false;
@@ -349,7 +341,11 @@ fn hot_resident_age(entry: &Entry) -> Duration {
}
fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option<Key> {
if store.entries.get(&ticket.key).is_some_and(|entry| entry.vary.is_empty()) {
if store
.entries
.get(&ticket.key)
.is_some_and(|entry| entry.vary.is_empty())
{
return Some(ticket.key);
}
store
+5 -59
View File
@@ -24,8 +24,6 @@ const FILL_SLOTS: usize = 8;
const COLD_DELETE_FALLBACKS: usize = 2;
const COLD_CLEANUP_SLOTS: usize = 1;
const BACKGROUND_REVALIDATION_LIMIT: usize = 8;
// Cold writes can contain complete response bodies. Keep the queue deliberately
// small so demotion cannot become a second, hidden RAM cache while disk is slow.
const COLD_QUEUE: usize = 16;
const COLD_FORMAT_VERSION: u8 = 3;
type Key = [u8; 32];
@@ -43,10 +41,6 @@ struct CleanupCandidate {
key: Key,
}
/// `Bytes` releases its allocation when the last reference is dropped, but
/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges,
/// return releasable pages to the OS instead of letting RSS stay artificially
/// high. This is intentionally a no-op on other allocators/platforms.
fn release_allocator_memory() {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
@@ -93,81 +87,44 @@ pub enum ColdPreset {
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
/// UI sizing profile last explicitly selected by the administrator. The
/// actual numeric limits remain authoritative; this only preserves intent
/// across reloads so the dashboard can show which preset was applied.
pub memory_preset: Option<MemoryPreset>,
/// Hard RAM budget for published HOT entries, charged by backing allocation
/// plus directly owned metadata rather than only logical body length.
pub max_bytes: usize,
/// Maximum logical size of a single cacheable object body.
pub max_object_bytes: usize,
/// Hot tier object count.
pub max_entries: usize,
/// Bounded Varnish-style transient RAM used by in-progress fills and full
/// bodies moving between HOT and COLD. This is independent of `max_bytes`.
pub transient_max_bytes: usize,
/// Byte ceiling for demoted objects waiting in the COLD writer queue.
pub cold_queue_max_bytes: usize,
/// Maximum number of LRU objects removed during one admission attempt.
/// Reaching the limit makes the new object pass through uncached.
pub hot_eviction_max_objects: usize,
/// Automatically release idle HOT objects once the tier is above the target waterline.
pub hot_auto_trim_enabled: bool,
/// Minimum idle age before automatic HOT trimming can evict an object.
pub hot_auto_trim_after_seconds: u64,
/// Target HOT usage percentage reached by each automatic trim pass.
pub hot_auto_trim_target_percent: u8,
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
pub cold_enabled: bool,
/// Last explicitly selected COLD policy profile, persisted with the cache
/// configuration for the same reason as `memory_preset`.
pub cold_preset: Option<ColdPreset>,
/// Relative to the RustProxy data directory.
pub cold_path: String,
pub cold_max_bytes: u64,
pub cold_max_entries: usize,
/// Interval between asynchronous cold-cache GC checks.
pub cold_gc_interval_seconds: u64,
/// Maximum number of objects removed by one GC pass.
pub cold_gc_max_delete: usize,
/// Hot objects not touched for this long are demoted to disk.
pub cold_after_seconds: u64,
/// Demote objects which have been idle for `cold_after_seconds`.
pub cold_on_idle: bool,
/// Demote objects after this much continuous residency in the hot tier
/// when `cold_on_age` is enabled, even when they are still being read.
pub cold_max_hot_seconds: u64,
/// Apply `cold_max_hot_seconds` as a separate hot-tier age policy.
pub cold_on_age: bool,
/// Preserve objects evicted from the hot LRU in the cold tier.
pub cold_on_lru: bool,
/// Best-effort flush of hot objects to cold storage on graceful shutdown.
pub cold_on_shutdown: bool,
/// Only used for responses explicitly marked public and without origin TTL.
pub default_ttl_seconds: u64,
pub max_ttl_seconds: u64,
pub bypass_query: bool,
pub bypass_prefixes: Vec<String>,
/// Actual client IP/CIDR allowlist for HTTP BAN/PURGE. Empty disables these
/// public-listener methods (independent of authenticated admin API).
pub http_invalidation_allowed_cidrs: Vec<String>,
/// For explicitly selected static extensions, ignore request Cookie when
/// deciding cache eligibility and when building the shared cache key.
pub static_ignore_cookies: bool,
pub static_extensions: Vec<String>,
/// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key.
pub collapse_enabled: bool,
/// Maximum time a follower waits for the current fill before applying the configured fallback.
pub collapse_wait_timeout_ms: u64,
pub collapse_timeout_fallback: CollapseTimeoutFallback,
/// Fine-grained stale-if-error classes. Defaults preserve the 0.21 behavior.
pub stale_if_error_timeout: bool,
pub stale_if_error_connection: bool,
pub stale_if_error_dns: bool,
pub stale_if_error_tls: bool,
pub stale_if_error_statuses: Vec<u16>,
/// Schedule bounded validator refresh before normal freshness expires.
pub proactive_refresh_enabled: bool,
pub proactive_refresh_before_expiry_seconds: u64,
pub proactive_refresh_percent: u8,
@@ -176,10 +133,7 @@ pub struct Settings {
pub expose_hits: bool,
pub expose_cache_status: bool,
pub expose_resident_age: bool,
/// Emit cache diagnostics only for actual HIT responses, not MISS or BYPASS.
pub expose_only_hits: bool,
/// Optional response Via field value added by RustProxy cache handling.
/// `None` disables the header; the default is `Via: RustProxy`.
pub via_header: Option<String>,
}
impl Default for Settings {
@@ -214,21 +168,13 @@ impl Default for Settings {
bypass_query: false,
bypass_prefixes: Vec::new(),
http_invalidation_allowed_cidrs: Vec::new(),
// Static assets are normally representation-identical for logged-in and
// anonymous visitors. Keep this enabled by default once the shared cache
// itself is enabled; response-side privacy guards still reject Set-Cookie,
// private/no-store and Vary: Cookie before anything is stored.
static_ignore_cookies: true,
static_extensions: vec![
// styles / scripts
"css", "js", "mjs", "cjs", "wasm", // images
"png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico",
"cur", "bmp", "tif", "tiff", "heic", "heif", // fonts
"woff", "woff2", "ttf", "otf", "eot", // video
"mp4", "webm", "ogv", "mov", "m4v", // audio
"mp3", "ogg", "oga", "wav", "m4a", "aac", "flac", "opus",
// common static downloads
"pdf", "txt", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar",
"css", "js", "mjs", "cjs", "wasm", "png", "jpg", "jpeg", "jfif", "jxl", "gif",
"webp", "avif", "apng", "svg", "ico", "cur", "bmp", "tif", "tiff", "heic", "heif",
"woff", "woff2", "ttf", "otf", "eot", "mp4", "webm", "ogv", "mov", "m4v", "mp3",
"ogg", "oga", "wav", "m4a", "aac", "flac", "opus", "pdf", "txt", "zip", "gz",
"tgz", "bz2", "xz", "7z", "rar", "tar",
]
.into_iter()
.map(str::to_owned)
-5
View File
@@ -73,11 +73,6 @@ fn retention_deadline(
_stale_if_error: u64,
_has_validator: bool,
) -> u64 {
// Freshness and storage retention are deliberately separate. Expiry only
// changes whether an object can be served without validation; it must not
// physically evict the representation because a later request may carry
// Cache-Control: max-stale. Capacity/LRU and Cold Cache GC remain the
// mechanisms that decide how long an otherwise valid object is retained.
u64::MAX
}
+5 -24
View File
@@ -1,4 +1,3 @@
/// Reject malformed/duplicate directives instead of guessing their meaning.
fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
let mut result = HashMap::new();
for header in headers.get_all("cache-control") {
@@ -11,8 +10,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
Some((k, v)) => {
let v = v.trim();
let value = if v.starts_with('"') {
// Fail closed on truncated/escaped/compound values. Quoted
// commas need not be supported by this conservative cache.
let inner = v.strip_prefix('"')?.strip_suffix('"')?;
if inner.contains(['"', '\\']) {
return None;
@@ -39,10 +36,6 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
return None;
}
if key == "immutable" {
// RFC 8246: immutable has no meaningful argument and duplicate
// occurrences are equivalent to one occurrence. Preserve only
// the presence of the directive instead of invalidating the
// complete Cache-Control field.
result.entry(key).or_insert(None);
} else if result.insert(key, value).is_some() {
return None;
@@ -111,7 +104,11 @@ pub fn forced_request_policy(
}
for key in ["if-match", "if-unmodified-since", "upgrade"] {
if headers.contains_key(key) {
return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" });
return RequestPolicy::Bypass(if key == "upgrade" {
"request_upgrade"
} else {
"request_precondition"
});
}
}
RequestPolicy::Use
@@ -150,8 +147,6 @@ pub fn request_policy(
for (key, reason) in [
("authorization", "request_authorization"),
("cookie", "request_cookie"),
// Preconditions other than cache validators can produce 412 and must be
// evaluated by the origin until RustProxy implements the full precondition set.
("if-match", "request_precondition"),
("if-unmodified-since", "request_precondition"),
("upgrade", "request_upgrade"),
@@ -166,15 +161,9 @@ pub fn request_policy(
let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
// RFC 9111: request no-store forbids using a stored response and storing this
// exchange. It is the hard client-side cache bypass.
if control.contains_key("no-store") {
return RequestPolicy::Bypass("request_no_store");
}
// no-cache does not mean no-store. It requires successful validation before a
// stored response can be reused. max-age/min-fresh are evaluated against the
// cached representation during lookup; only max-age=0 is an unconditional
// request-side validation signal.
if control.contains_key("no-cache") {
return RequestPolicy::Revalidate("request_no_cache");
}
@@ -200,8 +189,6 @@ pub fn request_policy(
return RequestPolicy::Bypass("request_cache_control_invalid");
}
}
// HTTP/1.0 Pragma: no-cache is a revalidation request when Cache-Control did
// not already define the policy. Other Pragma values do not disable caching.
if !headers.contains_key("cache-control") && pragma_no_cache(headers) {
return RequestPolicy::Revalidate("request_pragma_no_cache");
}
@@ -295,8 +282,6 @@ fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
&& token != "*"
&& weak_etag(token).is_some_and(|candidate| candidate == target)
}
/// Varnish-like client-side conditional delivery for a selected cached response.
/// If-None-Match takes precedence over If-Modified-Since.
pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool {
if request.contains_key("if-none-match") {
return response
@@ -326,11 +311,8 @@ pub fn make_not_modified(mut response: Response) -> Response {
}
response
}
/// Merge a successful backend 304 into the stored representation metadata.
pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap {
let mut merged = cached.clone();
// A new validation starts a new age calculation. Preserve origin Age only if
// it is explicitly supplied by the 304 response.
if !validation.contains_key("age") {
merged.remove("age");
}
@@ -364,7 +346,6 @@ pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
})
}
/// Explains rejected storage; it never relaxes origin or transformed policies.
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
if status != StatusCode::OK {
return "response_status";
+10 -22
View File
@@ -4,7 +4,11 @@ impl Plan {
}
pub fn with_vary(mut self, name: &str) -> Self {
if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) {
if !self
.vary
.iter()
.any(|value| value.eq_ignore_ascii_case(name))
{
self.vary.push(name.to_ascii_lowercase());
self.vary.sort();
}
@@ -28,14 +32,7 @@ impl Plan {
self.vary.extend(transformed.vary);
self.vary.sort();
self.vary.dedup();
// The transformed response is the representation that is actually
// written to cache. Its Content-Length therefore replaces the origin
// length; comparing transformed bytes with the origin length rejects a
// valid fill and causes a permanent MISS -> MISS loop.
self.expected_length = transformed.expected_length;
// Express both age clocks at the later observation point before taking
// the stricter value. This avoids resetting or double-counting Age when
// origin headers are transformed inside the proxy.
if transformed.received >= self.received {
let carried = self
.initial_age
@@ -87,9 +84,6 @@ pub fn plan(
{
return None;
}
// Cache-policy and validator fields govern reuse rather than representation
// identity. Response Vary drives a secondary key, matching Varnish-style
// variants without fragmenting on unrelated request headers.
let vary = vary_names(headers)?;
let control = directives(headers)?;
if ["no-store", "private", "must-understand"]
@@ -141,22 +135,16 @@ pub fn plan(
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0,
};
// RFC 9111 Section 4.2.3: the corrected initial age is the greater of
// the Date-derived apparent age and the received Age plus response delay.
// An upstream/CDN Age must be preserved on both transformed and normal HITs.
let apparent_age = now.duration_since(date).unwrap_or_default();
let corrected_age_value = Duration::from_secs(age).saturating_add(response_delay);
let initial_age = apparent_age.max(corrected_age_value);
// `ttl` is an absolute freshness lifetime (measured from the origin's
// Age clock), not the time since insertion into our HOT/COLD cache.
// Capping it directly to max_ttl_seconds makes an otherwise fresh 30-day
// upstream response with Age=18 days look expired when our own cap is 1 day.
// Limit the REMAINING freshness from admission, but preserve the Age clock.
// Origin freshness still wins: an origin response that has already expired
// must never become fresh here. `no-cache` continues to require validation.
let ttl = match declared_ttl {
Some(value) => value.min(initial_age.as_secs().saturating_add(settings.max_ttl_seconds)),
Some(value) => value.min(
initial_age
.as_secs()
.saturating_add(settings.max_ttl_seconds),
),
None if requires_revalidation => 0,
None => return None,
};
@@ -62,8 +62,6 @@ mod scope_tests {
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new()
);
assert!(ticket(7).is_some(), "the current snapshot can cache");
// An operational save only advances the config publication barrier.
// It must not change the representation revision or clear any objects.
cache.set_control_revision(8);
assert_eq!(cache.revision(), 1);
assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass");
+3 -5
View File
@@ -16,13 +16,9 @@ pub fn request_range(method: &Method, headers: &HeaderMap) -> RangeSelection {
let Some(value) = values.next() else {
return RangeSelection::None;
};
// Multiple field lines are equivalent to a combined range-set; RustProxy
// intentionally delegates those to the origin with multipart ranges.
if values.next().is_some() || headers.get_all("if-range").iter().count() > 1 {
return RangeSelection::Unsupported;
}
// RustProxy serves one byte range itself. HEAD, unknown units, malformed
// syntax and multipart requests remain transparent origin pass-through.
if method != Method::GET {
return RangeSelection::Unsupported;
}
@@ -132,7 +128,9 @@ impl Hit {
self.response
.headers_mut()
.insert("accept-ranges", HeaderValue::from_static("bytes"));
self.response.extensions_mut().insert(crate::compression::Skip);
self.response
.extensions_mut()
.insert(crate::compression::Skip);
let Some((start, end)) = resolve_byte_range(range, full_len) else {
*self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
*self.response.body_mut() = Body::empty();
+28 -20
View File
@@ -1,14 +1,12 @@
// Lock-free read path for bounded, per-domain cache dashboard counters.
//
// Cache-disabled proxy requests still contribute to BYPASS and reason totals,
// but must never lock the HOT object/LRU store. Published domain/reason indexes
// are immutable; individual counters are atomics. Only first sight of a new
// domain/reason acquires the registration mutex.
use arc_swap::ArcSwap;
const DOMAIN_STATS_LIMIT: usize = 4096;
const FAST_REASONS: [&str; 4] = ["cache_disabled", "cache_hit", "cache_miss", "request_cookie"];
const FAST_REASONS: [&str; 4] = [
"cache_disabled",
"cache_hit",
"cache_miss",
"request_cookie",
];
type ReasonIndex = BTreeMap<&'static str, Arc<AtomicU64>>;
type DomainIndex = BTreeMap<String, BTreeMap<String, Arc<DomainStats>>>;
@@ -51,13 +49,12 @@ impl DomainStats {
"HIT" => &self.hits,
"MISS" => &self.misses,
_ => &self.bypasses,
}.fetch_add(1, Ordering::Relaxed);
}
.fetch_add(1, Ordering::Relaxed);
self.record_reason(reason);
}
fn record_reason(&self, reason: &'static str) {
// Cache OFF is the highest-volume case; avoid reason map lookup and
// any shared lock/allocator activity for its dashboard reason.
if reason == "cache_disabled" {
self.fast_reasons[0].fetch_add(1, Ordering::Relaxed);
return;
@@ -72,7 +69,10 @@ impl DomainStats {
return;
}
drop(published);
let _guard = self.register_reason.lock().unwrap_or_else(|e| e.into_inner());
let _guard = self
.register_reason
.lock()
.unwrap_or_else(|e| e.into_inner());
let published = self.reasons.load_full();
if let Some(counter) = published.get(reason) {
counter.fetch_add(1, Ordering::Relaxed);
@@ -123,16 +123,22 @@ impl Default for DomainStatsStore {
impl DomainStatsStore {
fn with(&self, scope: &str, domain: &str, action: impl FnOnce(&DomainStats)) {
let published = self.domains.load();
if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) {
if let Some(counters) = published
.index
.get(scope)
.and_then(|hosts| hosts.get(domain))
{
action(counters);
return;
}
drop(published);
// Rare path: register only 4096 distinct (scope, domain) pairs. The
// published map is cloned on first insertion, never on a request hit.
let _guard = self.register.lock().unwrap_or_else(|e| e.into_inner());
let published = self.domains.load_full();
if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) {
if let Some(counters) = published
.index
.get(scope)
.and_then(|hosts| hosts.get(domain))
{
action(counters);
return;
}
@@ -141,7 +147,9 @@ impl DomainStatsStore {
}
let mut next = (*published).clone();
let counters = Arc::new(DomainStats::default());
next.index.entry(scope.to_owned()).or_default()
next.index
.entry(scope.to_owned())
.or_default()
.insert(domain.to_owned(), Arc::clone(&counters));
next.count += 1;
self.domains.store(Arc::new(next));
@@ -163,10 +171,10 @@ impl DomainStatsStore {
}
fn saved_body_hit(&self, scope: &str, domain: &str, bytes: usize) {
// COLD entries loaded from persisted metadata may be served before a
// domain is registered. Preserve first-hit byte accounting as well.
self.with(scope, domain, |counters| {
counters.saved_body_bytes.fetch_add(bytes as u64, Ordering::Relaxed);
counters
.saved_body_bytes
.fetch_add(bytes as u64, Ordering::Relaxed);
});
}
+7 -11
View File
@@ -1,24 +1,20 @@
#[cfg(test)]
include!("tests/tests.rs");
include!("cache_regression_tests.rs");
#[cfg(test)]
include!("tests/scope_tests.rs");
include!("policy/scope_tests.rs");
#[cfg(test)]
include!("tests/dashboard_tests.rs");
include!("dashboard_tests.rs");
#[cfg(test)]
include!("tests/v022_cache_tests.rs");
include!("v022_cache_tests.rs");
#[cfg(test)]
include!("tests/cold_tier_tests.rs");
include!("cold/cold_tier_tests.rs");
#[cfg(test)]
include!("tests/http_invalidation_tests.rs");
include!("invalidation/http_invalidation_tests.rs");
#[cfg(test)]
include!("tests/hot_hit_034_tests.rs");
include!("lookup/hot_hit_034_tests.rs");
-857
View File
@@ -1,857 +0,0 @@
mod cold_tier_tests {
use super::*;
use http_body_util::BodyExt;
fn cold_settings() -> Settings {
Settings {
enabled: true,
max_entries: 1,
max_bytes: 256 * 1024,
max_object_bytes: 64 * 1024,
cold_enabled: true,
cold_path: "cache/cold-test".into(),
cold_max_bytes: 2 * 1024 * 1024,
cold_max_entries: 16,
cold_after_seconds: 300,
..Settings::default()
}
}
async fn fill(cache: &Arc<Cache>, settings: &Settings, path: &str, body: &'static str) -> Ticket {
let ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
path,
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
cache
.wrap(response, ticket.clone(), plan, settings)
.into_body()
.collect()
.await
.unwrap();
ticket
}
async fn wait_for_cold(cache: &Cache, expected: usize) {
for _ in 0..100 {
if cache.view().cold_entries >= expected {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold tier did not persist in time");
}
#[tokio::test]
async fn selective_purge_removes_matching_cold_object() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().cold_entries, 1);
let result = cache
.invalidate(InvalidationRequest {
operation: InvalidationOperation::Purge,
selector: InvalidationSelector::Exact,
value: "/one?v=1".into(),
host: Some("cold.example".into()),
vhost: Some("cold-test".into()),
ttl_seconds: None,
confirm_wide: false,
})
.unwrap();
assert_eq!(result.affected_entries, 1);
assert_eq!(cache.view().cold_entries, 0);
assert!(cache.get_tiered(&first, &settings).await.is_none());
shutdown.cancel();
}
#[tokio::test]
async fn aged_transformed_css_remains_fresh_when_demoted_to_cold() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let ticket = cache.ticket_scoped(
11, "cold-test", "http", "cold.example", "origin@127.0.0.1:8080",
"/aged-theme.css", &HeaderMap::new(),
).unwrap();
let body = b"body { color: red; margin: 0px; }";
let mut origin = Response::new(Body::from(Bytes::from_static(body)));
origin.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=2592000"));
origin.headers_mut().insert("age", HeaderValue::from_static("1553483"));
origin.headers_mut().insert("date", httpdate::fmt_http_date(SystemTime::now()).parse().unwrap());
origin.headers_mut().insert("content-type", HeaderValue::from_static("text/css"));
origin.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap());
origin.headers_mut().insert("etag", HeaderValue::from_static("\"origin\""));
let origin_plan = plan(StatusCode::OK, origin.headers(), &settings, Duration::ZERO).unwrap();
let transformed = crate::transform::apply(origin, "/aged-theme.css",
&crate::transform::Settings { enabled: true, css: true, ..Default::default() }, false
).await.unwrap();
let transformed_plan = plan(StatusCode::OK, transformed.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(transformed, ticket.clone(), origin_plan.restrict(transformed_plan), &settings)
.into_body().collect().await.unwrap();
assert_eq!(cache.view().entries, 1);
let _another = fill(&cache, &settings, "/another", "another-payload").await;
wait_for_cold(&cache, 1).await;
let row = cache.dashboard(Some("cold-test"), 0, 20).objects
.into_iter().find(|v| v.path == "/aged-theme.css").unwrap();
assert_eq!(row.tier, "cold");
assert!(row.transformed);
assert!(row.remaining_ttl_seconds >= 86_000);
shutdown.cancel();
drop(cache);
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let hit = cache.get_tiered(&ticket, &settings).await.expect("aged transformed COLD should HIT after restart");
assert!(hit.transformed);
assert!(hit.remaining_ttl >= 86_000);
let transformed_body = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_ne!(transformed_body.as_ref(), &body[..]);
shutdown.cancel();
}
#[tokio::test]
async fn lru_demotion_persists_and_survives_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
settings.validate().unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let first = fill(&cache, &settings, "/one", "first-payload").await;
let _second = fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 1);
let cold = cache.dashboard(Some("cold-test"), 0, 50);
assert!(cold
.objects
.iter()
.any(|row| row.path == "/one" && row.tier == "cold"));
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 1);
let hit = restarted
.get_tiered(&first, &settings)
.await
.expect("cold object should be readable after restart");
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(payload, Bytes::from_static(b"first-payload"));
assert_eq!(restarted.view().cold_hits, 1);
assert_eq!(restarted.view().cold_promotions, 1);
restart_shutdown.cancel();
}
#[tokio::test]
async fn dashboard_lists_retained_expired_cold_objects_without_permitting_fresh_hits() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let first = fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
{
let mut cold = cache.cold.lock().unwrap();
for entry in cold.entries.values_mut() {
entry.stored_at = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
}
}
let dashboard = cache.dashboard(Some("cold-test"), 0, 50);
assert_eq!(dashboard.matching_objects, 2);
let row = dashboard.objects.iter().find(|row| row.path == "/one").unwrap();
assert_eq!(row.tier, "cold");
assert_eq!(row.remaining_ttl_seconds, 0);
assert!(row.age_seconds >= 2 * 60 * 60);
assert_eq!(row.storage_bytes, cache.view().cold_bytes);
assert!(cache.get_tiered(&first, &settings).await.is_none());
let mut stale = first;
stale.request_headers.insert("cache-control", HeaderValue::from_static("max-stale"));
let hit = cache.get_tiered(&stale, &settings).await.expect("explicit max-stale can reuse the retained disk object");
assert!(hit.stale);
assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "first-payload");
shutdown.cancel();
}
#[tokio::test]
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
let transformed_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/transformed.json",
&HeaderMap::new(),
)
.unwrap();
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
transformed.headers_mut().insert(
"cache-control",
HeaderValue::from_static(
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed.headers_mut().insert(
"etag",
HeaderValue::from_static("\"rustproxy-representation\""),
);
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
transformed
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
});
transformed
.extensions_mut()
.insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
transformed,
transformed_ticket.clone(),
transformed_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
let protected_ticket = cache
.ticket_scoped(
11,
"cold-test",
"http",
"cold.example",
"origin@127.0.0.1:8080",
"/protected.css",
&HeaderMap::new(),
)
.unwrap();
let original_body = "body { color: red; }";
let mut protected = Response::new(Body::from(original_body));
protected.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
);
protected
.headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-css\""));
protected.extensions_mut().insert(crate::compression::Skip);
let protected_plan = plan(
StatusCode::OK,
protected.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(
protected,
protected_ticket.clone(),
protected_plan,
&settings,
)
.into_body()
.collect()
.await
.unwrap();
assert_eq!(cache.flush_hot_to_cold().await, 2);
wait_for_cold(&cache, 2).await;
shutdown.cancel();
drop(cache);
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
// COLD entries intentionally keep validators and headers on disk to bound RAM.
// Inspect them through the same verified metadata loader used on COLD hits.
let (key, transformed) = {
let cold = restarted.cold.lock().unwrap();
cold.entries
.iter()
.find(|(_, entry)| entry.path == "/transformed.json")
.map(|(key, entry)| (*key, entry.clone()))
.expect("transformed cold entry")
};
assert_eq!(transformed.stale_while_revalidate, 30);
assert_eq!(transformed.stale_if_error, 90);
assert!(transformed.has_validator);
assert!(transformed.transformed);
let metadata = read_cold_metadata(&key, &transformed)
.await
.expect("persisted transformed metadata");
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
assert_eq!(
metadata.origin_last_modified.as_ref().unwrap(),
"Mon, 26 Jan 2026 23:11:40 GMT"
);
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
let transformed_hit = restarted
.get_tiered(&transformed_ticket, &settings)
.await
.expect("transformed object after restart");
assert_eq!(
transformed_hit.response.headers()["etag"],
"\"rustproxy-representation\""
);
assert!(transformed_hit.transformed);
assert!(transformed_hit
.response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some());
assert_eq!(
transformed_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(br#"{"a":1}"#)
);
let protected_hit = restarted
.get_tiered(&protected_ticket, &settings)
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(
protected_hit.response.headers()
));
assert!(protected_hit
.response
.extensions()
.get::<crate::compression::Skip>()
.is_some());
assert_eq!(
protected_hit
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"body { color: red; }")
);
restart_shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_lru_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.cold_on_lru = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
tokio::time::sleep(Duration::from_millis(30)).await;
let view = cache.view();
assert_eq!(view.entries, 1);
assert_eq!(view.cold_entries, 0);
assert_eq!(view.evictions, 1);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_disable_idle_demotion() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/idle", "idle-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.last_access = Instant::now() - Duration::from_secs(600);
}
}
cache.demote_idle();
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
settings.cold_on_idle = true;
cache.configure(settings.clone());
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_policy_can_demote_by_total_hot_residency_age() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_idle = false;
settings.cold_on_age = true;
settings.cold_max_hot_seconds = 5;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/aged", "aged-payload").await;
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.resident_since = Instant::now() - Duration::from_secs(60);
entry.last_access = Instant::now();
}
}
cache.demote_idle();
wait_for_cold(&cache, 1).await;
assert_eq!(cache.view().entries, 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
assert_eq!(cache.view().entries, 2);
assert!(cache.view().resident_bytes > 0);
assert_eq!(cache.view().cold_entries, 0);
assert_eq!(cache.flush_hot_to_cold().await, 2);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(
view.transient_bytes, 0,
"persisted ColdEntry must not retain the body allocation"
);
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
let restart_shutdown = CancellationToken::new();
let restarted = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
restart_shutdown.clone(),
);
assert_eq!(restarted.view().cold_entries, 2);
restart_shutdown.cancel();
}
#[tokio::test]
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/update", "update-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
let view = cache.view();
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.cold_entries, 1);
shutdown.cancel();
}
#[tokio::test]
async fn graceful_shutdown_flush_can_be_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 8;
settings.cold_on_shutdown = false;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
assert_eq!(cache.flush_hot_to_cold().await, 0);
assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().cold_entries, 0);
shutdown.cancel();
}
#[tokio::test]
async fn cold_cache_uses_two_hex_character_shards() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let root = dir.path().join(&settings.cold_path);
let mut shards = Vec::new();
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(
item.path().is_dir(),
"cold root must contain shard directories only"
);
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard)
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
}));
shutdown.cancel();
}
#[tokio::test]
async fn legacy_flat_cold_cache_is_not_migrated() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let root = dir.path().join(&settings.cold_path);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
shutdown.cancel();
}
#[tokio::test]
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache
.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
})
.unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
assert_eq!(current.removed_entries, 1);
assert!(current.freed_bytes > 0);
assert_eq!(cache.view().cold_entries, 1);
shutdown.cancel();
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("cold cleanup did not finish");
}
#[tokio::test]
async fn manual_cold_cleanup_is_single_flight() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
let result = cache.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
});
assert!(result.is_err());
shutdown.cancel();
}
#[tokio::test]
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
let dir = tempfile::tempdir().unwrap();
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
wait_for_cold(&cache, 1).await;
let key = {
let mut cold = cache.cold.lock().unwrap();
let key = *cold.entries.keys().next().unwrap();
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
key
};
let request = ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
};
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache
.cold
.lock()
.unwrap()
.entries
.get_mut(&key)
.unwrap()
.pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
assert_eq!(result.removed_entries, 1);
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
shutdown.cancel();
}
#[test]
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
let cache = Cache::new(1);
let key = [7_u8; 32];
let template = ColdEntry {
base_key: key,
vary: Vec::new(),
revision: 1,
scope: "app".to_owned(),
scheme: "https".to_owned(),
authority: "example.test".to_owned(),
domain: "example.test".to_owned(),
path: "/asset".to_owned(),
target: "https://example.test/asset".to_owned(),
tags: Vec::new(),
created_at: SystemTime::now(),
body_path: PathBuf::from("old.body"),
meta_path: PathBuf::from("old.json"),
body_bytes: 1,
body_sha256: [0u8; 32],
disk_bytes: 2,
stored_at: SystemTime::now(),
last_access: SystemTime::now(),
age_at_store_seconds: 0,
resident_age_at_store_seconds: 0,
ttl: 60,
stale_while_revalidate: 0,
stale_if_error: 0,
must_revalidate: false,
proxy_revalidate: false,
has_validator: false,
hits: 0,
transformed: false,
sequence: 1,
generation: 1,
skip_compression: false,
requires_revalidation: false,
pinned_reads: 1,
};
cache
.cold
.lock()
.unwrap()
.entries
.insert(key, template.clone());
let old_guard = ColdReadGuard {
cache: &cache,
key,
generation: 1,
};
let mut replacement = template;
replacement.sequence = 2;
replacement.generation = 2;
replacement.pinned_reads = 1;
cache.cold.lock().unwrap().entries.insert(key, replacement);
drop(old_guard);
let cold = cache.cold.lock().unwrap();
let replacement = cold.entries.get(&key).unwrap();
assert_eq!(replacement.generation, 2);
assert_eq!(replacement.pinned_reads, 1);
}
#[test]
fn cold_path_cannot_escape_data_directory() {
let mut settings = Settings::default();
settings.cold_path = "../outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "/tmp/outside".into();
assert!(settings.validate().is_err());
settings.cold_path = "cache/cold".into();
assert!(settings.validate().is_ok());
}
#[tokio::test]
async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let mut fingerprints = HashMap::new();
fingerprints.insert("cold-test".to_owned(), 77);
let cache = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(),
fingerprints.clone(),
);
fill(&cache, &settings, "/one", "stable-contents").await;
fill(&cache, &settings, "/two", "second-contents").await;
wait_for_cold(&cache, 1).await;
shutdown.cancel();
drop(cache);
let restart = CancellationToken::new();
let same = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(),
fingerprints.clone(),
);
assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD");
restart.cancel();
drop(same);
fingerprints.insert("cold-test".to_owned(), 78);
let restart = CancellationToken::new();
let changed = Cache::start_with_scope_revisions(
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(), fingerprints,
);
assert_eq!(changed.view().cold_entries, 0, "old COLD must never survive a content revision");
restart.cancel();
}
}
+14 -14
View File
@@ -1,7 +1,4 @@
impl Cache {
/// Varnish-style primary hash: public URL/host identity first, with response
/// `Vary` handled as a secondary variant key. Irrelevant request headers and
/// load-balanced backend members therefore do not fragment the cache.
pub fn ticket(
&self,
revision: u64,
@@ -23,12 +20,11 @@ impl Cache {
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
self.ticket_scoped_impl(None, revision, scope, scheme, authority, _route, target, headers)
self.ticket_scoped_impl(
None, revision, scope, scheme, authority, _route, target, headers,
)
}
/// Production requests bind the cache ticket to the immutable routing
/// configuration used to authorize/route the request. After a config save,
/// stale in-flight snapshots cannot re-fill a scope after its purge.
#[allow(clippy::too_many_arguments)]
pub fn ticket_scoped_for_config(
&self,
@@ -41,7 +37,16 @@ impl Cache {
target: &str,
headers: &HeaderMap,
) -> Option<Ticket> {
self.ticket_scoped_impl(Some(control_revision), revision, scope, scheme, authority, route, target, headers)
self.ticket_scoped_impl(
Some(control_revision),
revision,
scope,
scheme,
authority,
route,
target,
headers,
)
}
#[allow(clippy::too_many_arguments)]
@@ -61,9 +66,6 @@ impl Cache {
}
let domain = crate::config::request_host(authority)
.unwrap_or_else(|_| authority.chars().take(253).collect());
// Varnish built-in hashing lowercases Host before hashing, but otherwise
// keeps the authority (including an explicit port) distinct. RustProxy has
// already validated the authority before this point.
let hash_scheme = scheme.to_ascii_lowercase();
let hash_authority = authority.to_ascii_lowercase();
let mut digest = Sha256::new();
@@ -85,7 +87,7 @@ impl Cache {
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
(store.revision == revision
&& expected_control_revision.is_none_or(|expected| expected == store.control_revision))
.then_some(Ticket {
.then_some(Ticket {
scheme: hash_scheme,
authority: hash_authority,
domain,
@@ -336,8 +338,6 @@ impl Cache {
self: &Arc<Self>,
cached: &Revalidation,
) -> Option<BackgroundRevalidationGuard> {
// Vary variants share a base Ticket key. Deduplicate only the exact cached
// representation so an SWR refresh for one variant cannot suppress another.
let key = cached.storage_key;
let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner());
if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) {
@@ -83,21 +83,55 @@ mod v022_cache_tests {
] {
let cache = Cache::new(22);
let settings = active_settings();
let ticket = cache.ticket(22, "http", "example.test", "origin", "/transform.js", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
22,
"http",
"example.test",
"origin",
"/transform.js",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from(body.clone()));
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120"));
response.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap());
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&body.len().to_string()).unwrap(),
);
response.extensions_mut().insert(crate::transform::Applied {
kind: crate::transform::Kind::Javascript,
original_bytes: body.len(),
transformed_bytes: body.len(),
});
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let policy = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(policy.expected_length, Some(body.len()));
let downstream = cache.wrap(response, ticket.clone(), policy, &settings)
.into_body().collect().await.unwrap().to_bytes();
let downstream = cache
.wrap(response, ticket.clone(), policy, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(downstream, body);
let cached = cache.get(&ticket).unwrap().response.into_body().collect().await.unwrap().to_bytes();
let cached = cache
.get(&ticket)
.unwrap()
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(cached, body);
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
}
@@ -107,26 +141,55 @@ mod v022_cache_tests {
async fn uniquely_owned_transformed_frame_is_cached_without_body_copy() {
let cache = Cache::new(22);
let settings = active_settings();
let ticket = cache.ticket(22, "http", "example.test", "origin", "/owned.js", &HeaderMap::new()).unwrap();
let ticket = cache
.ticket(
22,
"http",
"example.test",
"origin",
"/owned.js",
&HeaderMap::new(),
)
.unwrap();
let payload = Bytes::from(vec![b'z'; 8192]);
let original_allocation = payload.as_ptr();
let mut response = Response::new(Body::from(payload));
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120"));
response.headers_mut().insert("content-length", HeaderValue::from_static("8192"));
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("public, max-age=120"),
);
response
.headers_mut()
.insert("content-length", HeaderValue::from_static("8192"));
response.extensions_mut().insert(crate::transform::Applied {
kind: crate::transform::Kind::Javascript,
original_bytes: 8192,
transformed_bytes: 8192,
});
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
let downstream = cache.wrap(response, ticket.clone(), policy, &settings)
.into_body().collect().await.unwrap().to_bytes();
let policy = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let downstream = cache
.wrap(response, ticket.clone(), policy, &settings)
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(downstream.len(), 8192);
assert_eq!(downstream.as_ptr(), original_allocation);
let hit = cache.get(&ticket).unwrap();
let cached = hit.response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(cached, downstream);
assert_eq!(cached.as_ptr(), original_allocation, "cache fill copied the transformed body");
assert_eq!(
cached.as_ptr(),
original_allocation,
"cache fill copied the transformed body"
);
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
}
@@ -180,8 +243,22 @@ mod v022_cache_tests {
)
.await;
let cases = [
(ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"),
(ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"),
(
ByteRange {
start: None,
end: Some(3),
},
"hij",
"bytes 7-9/10",
),
(
ByteRange {
start: Some(7),
end: None,
},
"hij",
"bytes 7-9/10",
),
];
for (range, expected, content_range) in cases {
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
@@ -192,38 +269,66 @@ mod v022_cache_tests {
}
let unsatisfied = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(50), end: None },
ByteRange {
start: Some(50),
end: None,
},
None,
);
assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10");
assert_eq!(
unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(),
unsatisfied.response.status(),
StatusCode::RANGE_NOT_SATISFIABLE
);
assert_eq!(
unsatisfied.response.headers()["content-range"],
"bytes */10"
);
assert_eq!(
unsatisfied
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.len(),
0
);
let mismatch = HeaderValue::from_static("\"asset-v2\"");
let full = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) },
ByteRange {
start: Some(0),
end: Some(2),
},
Some(&mismatch),
);
assert_eq!(full.response.status(), StatusCode::OK);
assert!(!full.response.headers().contains_key("content-range"));
assert_eq!(
full.response.into_body().collect().await.unwrap().to_bytes(),
full.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"abcdefghij")
);
let matched = HeaderValue::from_static("\"asset-v1\"");
let partial = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) },
ByteRange {
start: Some(0),
end: Some(2),
},
Some(&matched),
);
assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT);
}
#[test]
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() {
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation(
) {
let mut multipart = HeaderMap::new();
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
assert_eq!(
@@ -318,13 +423,23 @@ mod v022_cache_tests {
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 10;
let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new())
.ticket(
22,
"http",
"collapse.example",
"origin",
"/timeout",
&HeaderMap::new(),
)
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
_ => panic!("missing leader"),
};
assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout));
assert!(matches!(
cache.claim_fill(&ticket, &settings).await,
FillClaim::Timeout
));
assert_eq!(cache.view().collapse_timeouts, 1);
drop(leader);
assert!(matches!(
@@ -339,7 +454,14 @@ mod v022_cache_tests {
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 5_000;
let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new())
.ticket(
22,
"http",
"collapse.example",
"origin",
"/cancel",
&HeaderMap::new(),
)
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
@@ -377,10 +499,24 @@ mod v022_cache_tests {
let mut pl_headers = HeaderMap::new();
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
let en = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers)
.ticket(
22,
"http",
"vary-collapse.example",
"origin",
"/page",
&en_headers,
)
.unwrap();
let pl = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers)
.ticket(
22,
"http",
"vary-collapse.example",
"origin",
"/page",
&pl_headers,
)
.unwrap();
assert_eq!(en.key, pl.key, "Vary is learned from the response");
let leader = match cache.claim_fill(&en, &settings).await {
@@ -455,7 +591,14 @@ mod v022_cache_tests {
fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() {
let cache = Cache::new(22);
let ticket = cache
.ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new())
.ticket(
22,
"http",
"refresh.example",
"origin",
"/asset",
&HeaderMap::new(),
)
.unwrap();
let mut settings = active_settings();
settings.proactive_refresh_enabled = true;
@@ -511,7 +654,9 @@ mod v022_cache_tests {
&HeaderMap::new(),
)
.unwrap();
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
assert!(cache
.begin_proactive_schedule(&overflow, &settings)
.is_none());
assert!(cache.view().proactive_skipped > 0);
drop(guards);
}
@@ -557,11 +702,17 @@ mod v022_cache_tests {
#[tokio::test]
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
fn current_ticket(cache: &Cache, old: &Ticket) -> Ticket {
// Each post-PURGE request obtains the current publication generation.
// Reusing an old ticket would reject unrelated entries too and make
// assertions about removed objects pass without testing the selector.
cache.ticket_scoped(22, &old.scope, &old.scheme, &old.authority,
"origin@127.0.0.1:8080", &old.target, &old.request_headers).unwrap()
cache
.ticket_scoped(
22,
&old.scope,
&old.scheme,
&old.authority,
"origin@127.0.0.1:8080",
&old.target,
&old.request_headers,
)
.unwrap()
}
let cache = Cache::new(22);
let one = store(
@@ -595,8 +746,19 @@ mod v022_cache_tests {
.unwrap();
assert_eq!(result.affected_entries, 1);
assert!(cache.get(&current_ticket(&cache, &one)).is_none());
let survivor = cache.get(&current_ticket(&cache, &two)).expect("unrelated query remains stored");
assert_eq!(survivor.response.into_body().collect().await.unwrap().to_bytes(), "two");
let survivor = cache
.get(&current_ticket(&cache, &two))
.expect("unrelated query remains stored");
assert_eq!(
survivor
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
"two"
);
let three = store(
&cache,
@@ -832,7 +994,10 @@ mod v022_cache_tests {
"fresh",
)
.await;
assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects");
assert!(
cache.get(&fresh).is_some(),
"old BAN must not reject newer objects"
);
assert_eq!(cache.bans().len(), 1);
assert!(cache.remove_ban(ban_id));
assert!(cache.bans().is_empty());
@@ -898,9 +1063,6 @@ mod v022_cache_tests {
"unchanged",
)
.await;
// A production cache may see an unbounded stream of PURGEs for URLs
// that will never be cached again. The invalidated-key index must
// not remember all of those removed objects indefinitely.
for index in 0..512 {
let target = format!("/unique-{index}");
let old = store(
@@ -924,15 +1086,16 @@ mod v022_cache_tests {
))
.unwrap();
assert_eq!(result.affected_entries, 1);
assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale");
assert!(
!cache.ticket_current(&old),
"pre-purge ticket must remain stale"
);
let state = cache.store.lock().unwrap_or_else(|e| e.into_inner());
assert!(
state.key_generations.len() <= 1,
"key generations must not accumulate for already purged URLs"
);
}
// A new ticket after the invalidation barrier is valid and can fill
// the same URL again, preserving ordinary cache behavior.
let new = store(
&cache,
"app",
@@ -956,6 +1119,9 @@ mod v022_cache_tests {
&HeaderMap::new(),
)
.unwrap();
assert!(cache.get(&stable).is_some(), "unrelated entries must survive");
assert!(
cache.get(&stable).is_some(),
"unrelated entries must survive"
);
}
}
+5 -17
View File
@@ -1,6 +1,3 @@
//! Streaming content negotiation with deterministic server-side preference.
//! Existing Content-Encoding, ranges, upgrades, event streams and no-transform
//! responses are never recompressed. No full-body aggregation is introduced.
use anyhow::{ensure, Result};
use axum::{body::Body, response::Response};
use http::{HeaderMap, HeaderValue, Method, Request};
@@ -12,8 +9,6 @@ use tower_http::compression::{
Compression, CompressionLevel,
};
/// A u64 predicate independent of tower-http's version-specific SizeAbove type.
/// Unknown-size streams are compressed without buffering, like tower-http.
#[derive(Clone, Copy)]
struct MinimumSize(u64);
impl Predicate for MinimumSize {
@@ -72,8 +67,6 @@ impl Settings {
}
}
/// Per-vhost/Web-Service compression policy. None inherits the enclosing policy.
/// Global Response settings remain the hard codec ceiling and own minimum size.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Overrides {
@@ -113,9 +106,6 @@ impl Overrides {
#[derive(Clone)]
pub struct Skip;
/// Encoding applied by RustProxy itself. This marker is kept in response
/// extensions so accounting never has to infer the selected codec from a
/// header that later response rules may rewrite.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppliedEncoding {
Gzip,
@@ -173,9 +163,6 @@ pub fn no_transform(headers: &HeaderMap) -> bool {
})
}
/// Preserve exactly the request inputs used by encoding after forwarding has
/// consumed the request. Repeated field values and opaque bytes stay intact;
/// unrelated cookies, authorization and custom headers do not need a copy.
pub(crate) fn request_headers(headers: &HeaderMap) -> HeaderMap {
let mut selected = HeaderMap::new();
for name in ["accept-encoding", "range", "cache-control"] {
@@ -228,7 +215,10 @@ fn quality(raw: &str) -> Option<u16> {
Some((value * 1000.0).round() as u16)
}
pub(crate) fn preferred_encoding(headers: &HeaderMap, settings: &Settings) -> Option<AppliedEncoding> {
pub(crate) fn preferred_encoding(
headers: &HeaderMap,
settings: &Settings,
) -> Option<AppliedEncoding> {
let mut explicit = [None; 4];
let mut wildcard = None;
for line in headers.get_all("accept-encoding").iter() {
@@ -330,8 +320,6 @@ pub async fn encode(
{
response.extensions_mut().insert(applied);
}
// A strong validator for identity bytes is not a strong validator of the
// compressed bytes. Preserve semantic validation as a weak ETag.
if let Some(value) = response
.headers()
.get("etag")
@@ -354,4 +342,4 @@ pub async fn encode(
}
#[cfg(test)]
include!("compression/tests.rs");
include!("tests.rs");
+5 -1
View File
@@ -1,4 +1,3 @@
//! Desired configuration and its validated, immutable routing snapshot.
include!("config/model.rs");
include!("config/snapshot.rs");
include!("config/internal_routing.rs");
@@ -8,3 +7,8 @@ include!("config/compile.rs");
#[cfg(test)]
include!("config/tests.rs");
#[cfg(test)]
include!("config/route_policy_tests.rs");
#[cfg(test)]
include!("config/transport_tests.rs");
-13
View File
@@ -60,14 +60,8 @@ pub(crate) fn compile_backend_with_timeout(
Host::Ipv6(ip) => {
crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr)
}
// DNS destinations remain subject to the normal self-target guard, but
// are never trusted for administration-cookie passthrough. The special
// control-plane path must point at the listener explicitly by IP.
Host::Domain(_) => false,
};
// Exact local listener targets are rejected by AddressPolicy by default, including DNS
// answers. An explicit per-backend risk acceptance can override only that self-target
// guard; remote origins on any valid backend port, including 9090, remain valid.
let authority = match url.host().context("backend host missing")? {
Host::Ipv6(ip) => format!("[{ip}]:{port}"),
host => format!("{host}:{port}"),
@@ -120,7 +114,6 @@ pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Resu
(policy.proxy_addr, policy.tls_addr),
)
}
/// Validate both active sockets and the sockets that will be used after restart.
pub fn compile_with_defaults(
config: Config,
revision: u64,
@@ -129,8 +122,6 @@ pub fn compile_with_defaults(
) -> Result<Snapshot> {
compile_reusing(config, revision, policy, defaults, None)
}
/// Rebuild route policies and retain compatible backend clients, health observations,
/// full-exchange counters and affinity tokens through unrelated config edits.
pub fn compile_reusing(
config: Config,
revision: u64,
@@ -154,8 +145,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
let mut claimed_hosts: HashMap<String, Vec<crate::vhost::Listener>> = HashMap::new();
for redirect in &config.redirects {
let bindings = redirect.frontend.resolved(&config.listeners)?;
// Disabled redirect hosts reserve their names as vhosts do, preventing
// an overlapping fallback from unexpectedly taking traffic.
for name in &redirect.frontend.hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
@@ -181,8 +170,6 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
}
for host in &config.not_found_hosts {
let bindings = host.frontend.resolved(&config.listeners)?;
// Disabled local-response hosts reserve names too, avoiding accidental
// exposure through a broader wildcard vhost.
for name in &host.frontend.hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
+109 -45
View File
@@ -10,9 +10,6 @@ pub fn compile_with_active(
config.logging.validate()?;
config.error_log.validate()?;
let desired_admin = config.administration.resolved_listen(policy.admin_addr);
// A persisted non-loopback address is only accepted with administration TLS.
// When `listen` is omitted, the startup/CLI address remains authoritative and
// its legacy plain-HTTP risk gate is enforced by main before compilation.
ensure!(
config.administration.listen.is_none()
|| desired_admin.ip().is_loopback()
@@ -48,29 +45,53 @@ pub fn compile_with_active(
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)");
ensure!(
config.php_fpm_profiles.len() <= 128,
"too many PHP-FPM profiles (max 128)"
);
let mut php_profiles = HashMap::new();
for profile in &mut config.php_fpm_profiles {
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?;
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(),
"duplicate PHP-FPM profile id");
profile
.validate()
.with_context(|| format!("PHP-FPM profile {}", profile.id))?;
ensure!(
php_profiles
.insert(profile.id.clone(), profile.php.clone())
.is_none(),
"duplicate PHP-FPM profile id"
);
}
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
ensure!(
config.web_services.len() <= 128,
"too many Web Services (max 128)"
);
let mut web_services = HashMap::new();
// PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused
// workers. Different directive sets on one socket can leak to the next vhost.
// Report unsafe legacy configurations but keep them loadable for migration.
let mut php_endpoint_policies: HashMap<String, (String, std::collections::BTreeMap<String, String>, std::collections::BTreeMap<String, String>)> = HashMap::new();
let mut php_endpoint_policies: HashMap<
String,
(
String,
std::collections::BTreeMap<String, String>,
std::collections::BTreeMap<String, String>,
),
> = HashMap::new();
for service in &mut config.web_services {
service.validate().with_context(|| format!("Web Service {}", service.id))?;
// A profile changes effective runtime settings, not the saved standalone
// PHP configuration. Detaching a profile restores local settings exactly.
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
let mut effective = service.clone();
if let Some(id) = &service.php_profile_id {
effective.php = php_profiles.get(id).with_context(|| {
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
})?.clone();
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
effective.php = php_profiles
.get(id)
.with_context(|| {
format!(
"Web Service {} references unknown PHP-FPM profile {}",
service.id, id
)
})?
.clone();
effective
.validate()
.with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
}
if effective.php.enabled {
let php = &effective.php;
@@ -82,13 +103,25 @@ pub fn compile_with_active(
);
}
} else {
php_endpoint_policies.insert(php.endpoint.clone(), (
effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(),
));
php_endpoint_policies.insert(
php.endpoint.clone(),
(
effective.id.clone(),
php.php_values.clone(),
php.php_admin_values.clone(),
),
);
}
}
let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?);
ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id");
let compiled = Arc::new(
effective
.compile()
.with_context(|| format!("Web Service {}", service.id))?,
);
ensure!(
web_services.insert(service.id.clone(), compiled).is_none(),
"duplicate Web Service id"
);
}
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new();
@@ -202,8 +235,6 @@ pub fn compile_with_active(
guarded_bindings.extend(previous.listeners.iter().map(|l| l.address));
}
guarded_bindings.extend(active.iter().map(|l| l.address));
// Startup/default addresses remain protected backend destinations even when
// the desired named-listener plan changes before a restart.
guarded_bindings.push(defaults.0);
guarded_bindings.extend(defaults.1);
guarded_bindings.extend([
@@ -264,22 +295,29 @@ pub fn compile_with_active(
"duplicate ACL profile id"
);
}
// Prevent unbounded DNS ACL work across the entire published configuration.
crate::acl::dynamic::configured_hostnames(&config)?;
for agent in &config.docker_agents.agents {
for profile_id in crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids) {
for profile_id in
crate::acl::profile_ids(agent.acl_profile_id.as_deref(), &agent.acl_profile_ids)
{
ensure!(
acl_profiles.contains_key(profile_id),
"Docker agent {} references unknown default ACL profile {}",
agent.id, profile_id
agent.id,
profile_id
);
}
for assignment in &agent.assignments {
for profile_id in crate::acl::profile_ids(assignment.acl_profile_id.as_deref(), &assignment.acl_profile_ids) {
for profile_id in crate::acl::profile_ids(
assignment.acl_profile_id.as_deref(),
&assignment.acl_profile_ids,
) {
ensure!(
acl_profiles.contains_key(profile_id),
"Docker assignment {} on agent {} references unknown ACL profile {}",
assignment.service_key, agent.id, profile_id
assignment.service_key,
agent.id,
profile_id
);
}
}
@@ -392,7 +430,9 @@ fn validate_route_rewrite(
Ok(())
}
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
fn validate_location_redirect(
redirect: Option<&crate::vhost::LocationRedirect>,
) -> Result<Option<crate::config::CompiledLocationRedirect>> {
let Some(redirect) = redirect else {
return Ok(None);
};
@@ -406,26 +446,50 @@ fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>)
&& !redirect.target.chars().any(char::is_control),
"invalid location redirect target"
);
// Replace capture references with a harmless segment so URL structure can
// be validated before requests reach the data plane.
let authority_end = redirect.target.find("://").and_then(|start| {
let rest = &redirect.target[start + 3..];
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
let authority_end = redirect
.target
.find("://")
.and_then(|start| {
let rest = &redirect.target[start + 3..];
rest.char_indices()
.find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
.map(|(offset, _)| start + 3 + offset)
.or(Some(redirect.target.len()))
})
.ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(
!redirect.target[..authority_end].contains('$'),
"location redirect captures are not allowed in the URL authority"
);
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
.expect("static capture regex")
.replace_all(&redirect.target, "capture");
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
let parsed =
url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
"location redirect target must use http/https and include a host"
);
ensure!(
parsed.username().is_empty() && parsed.password().is_none(),
"location redirect target cannot contain credentials"
);
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
None | Some("") => None,
Some(pattern) => {
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
ensure!(
pattern.len() <= 1024 && !pattern.chars().any(char::is_control),
"invalid location redirect rewrite regex"
);
let mut builder = regex::RegexBuilder::new(pattern);
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
Some(builder.build().context("invalid location redirect rewrite regex")?)
builder
.size_limit(2 * 1024 * 1024)
.dfa_size_limit(2 * 1024 * 1024);
Some(
builder
.build()
.context("invalid location redirect rewrite regex")?,
)
}
};
Ok(Some(crate::config::CompiledLocationRedirect {
+16 -9
View File
@@ -1,7 +1,4 @@
impl Snapshot {
/// Resolve a vhost-local internal named location. Internal locations are
/// deliberately excluded from ordinary URI matching and can only be reached
/// by an explicit internal dispatch such as an error fallback.
pub fn select_internal_on(
&self,
host: &str,
@@ -25,21 +22,31 @@ impl Snapshot {
Self::find_host_pattern(host, |pattern| {
let owns_host = self.fallbacks.get(pattern).is_some_and(|routes| {
routes.iter().any(|route| {
route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
route
.frontend
.as_ref()
.is_some_and(|frontend| frontend.id == vhost_id)
&& route
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
})
});
if !owns_host {
return None;
}
// Some(None) stops at the owning hostname even when the named
// location is absent. Do not try a less-specific wildcard.
Some(self.routes.get(pattern).and_then(|routes| {
routes.iter().find(|route| {
route.internal
&& route.name == target
&& route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
&& route
.frontend
.as_ref()
.is_some_and(|frontend| frontend.id == vhost_id)
&& route
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
})
}))
})
-26
View File
@@ -36,8 +36,6 @@ pub struct Config {
pub geoip_updates: crate::geoip::UpdateSettings,
#[serde(default)]
pub listeners: crate::settings::Listeners,
/// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet
/// persist an explicit `binding.http2_settings` object.
#[serde(default)]
pub http2: crate::settings::Http2Connections,
#[serde(default)]
@@ -112,20 +110,14 @@ pub struct Backend {
pub address_allowlist: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_ca_pem: Option<String>,
/// Optional DNS name used for TLS SNI and certificate hostname verification.
/// TCP/DNS connection targeting remains pinned to `url`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls_server_name: Option<String>,
/// Verify the HTTPS backend certificate chain and server identity.
/// Disabled by default for reverse-proxy compatibility; enable explicitly when required.
#[serde(default)]
pub tls_verify: bool,
pub id: String,
pub url: String,
#[serde(default)]
pub allow_private: bool,
/// Explicitly allow a backend that resolves to a local RustProxy listener.
/// This can expose the control plane or create a proxy loop and is off by default.
#[serde(default)]
pub accept_local_listener_risk: bool,
#[serde(default)]
@@ -136,12 +128,8 @@ pub struct Backend {
pub enum UpstreamProtocol {
#[default]
Http1,
/// Legacy value `http2`: HTTPS uses ALPN H2/H1, HTTP uses H2C prior knowledge.
/// Retain its 0.32.2 meaning when loading unchanged saved configurations.
Http2,
/// HTTPS negotiates H2/H1; plaintext uses H1 without H2C probing.
Auto,
/// New explicit mode `h2`: require H2 for HTTPS and H2C for cleartext.
H2,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -159,7 +147,6 @@ pub struct Update {
pub struct ValidationPolicy {
pub tls_addr: Option<SocketAddr>,
pub cert_dir: Option<PathBuf>,
/// Resolved runtime data directory. Exposed only through authenticated admin status.
pub data_dir: Option<PathBuf>,
pub proxy_addr: SocketAddr,
pub admin_addr: SocketAddr,
@@ -170,21 +157,14 @@ pub struct CompiledBackend {
pub client: crate::upstream::ProxyClient,
pub websocket_client: crate::upstream::ProxyClient,
pub id: String,
/// Configured network origin used by raw TCP probes and policy checks.
pub origin: String,
/// HTTP request origin. For HTTPS this may carry a separate verified TLS name.
pub request_origin: String,
/// Parsed once at configuration publish; reused for every upstream request.
pub request_origin_uri: http::Uri,
pub host_header: HeaderValue,
pub preserve_host: bool,
pub protocol: UpstreamProtocol,
/// Whether backend TLS certificate chain/name verification is enabled.
/// Retained in the compiled snapshot for failure diagnostics only.
pub tls_verify: bool,
pub accept_local_listener_risk: bool,
/// True only when this backend URL names the configured administration socket
/// directly. Used by the guarded control-plane reverse-proxy path.
pub targets_admin_listener: bool,
}
#[derive(Debug, Clone)]
@@ -208,7 +188,6 @@ pub struct CompiledRoute {
pub frontend: Option<Arc<CompiledFrontend>>,
pub pool: Option<Arc<crate::balance::Pool>>,
pub cache: Option<bool>,
/// Vhost-local stable location ID (`main` for the main source).
pub name: String,
pub id: String,
pub internal: bool,
@@ -237,10 +216,8 @@ pub struct CompiledFrontend {
pub websocket_rfc8441: bool,
pub websocket_rfc9220: bool,
pub force_ssl: bool,
/// Optional quick www/non-www canonical redirect (from, to).
pub www_redirect: Option<(String, String)>,
pub forward_client_ip: bool,
/// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected.
pub https_redirect_port: Option<u16>,
pub http2_enabled: bool,
pub http3_enabled: bool,
@@ -264,7 +241,6 @@ pub struct CompiledFrontend {
pub acl_profile_id: Option<String>,
pub profile_acl: Option<CompiledAcl>,
pub profile_country_access: crate::rate_limit::CountryAccess,
/// All Vhost profile scopes, checked before the inline ACL.
pub profile_policies: Vec<crate::acl::ProfileAccess>,
pub acl: Option<CompiledAcl>,
pub headers: CompiledHeaders,
@@ -295,7 +271,6 @@ pub struct Snapshot {
pub tls: Option<Arc<rustls::ServerConfig>>,
pub envelope: Envelope,
routes: HashMap<String, Vec<Arc<CompiledRoute>>>,
/// Main vhost backend fallback, kept separate from user Custom locations.
fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>>,
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
@@ -305,7 +280,6 @@ pub struct Snapshot {
}
impl std::fmt::Debug for Snapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// QUIC crypto has no Debug implementation; never format private keys.
f.debug_struct("Snapshot")
.field("revision", &self.envelope.revision)
.field("listeners", &self.listeners)
+99
View File
@@ -0,0 +1,99 @@
mod route_policy_tests {
use crate::{
acl::{routing_path, Acl, Action, Rule as AclRule},
config::{compile, demo_config, ValidationPolicy},
headers::{apply as apply_headers, HeaderRules, Operation, Rule as HeaderRule},
vhost::{LocationResponse, PathRoute},
};
use http::HeaderMap;
fn policy() -> ValidationPolicy {
ValidationPolicy {
tls_addr: None,
cert_dir: None,
data_dir: None,
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
}
}
fn local_route() -> PathRoute {
PathRoute {
id: "local-status".into(),
path_prefix: "/status".into(),
response: Some(LocationResponse {
status: 200,
content_type: "text/plain; charset=utf-8".into(),
body: "ok".into(),
}),
..PathRoute::default()
}
}
#[test]
fn repeated_slashes_route_normally_but_reach_upstream_unchanged() {
let raw_target = "/api//v1/items";
let routing_target = routing_path(raw_target).unwrap();
assert_eq!(routing_target, "/api/v1/items");
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].routes = vec![PathRoute {
id: "api".into(),
path_prefix: "/api".into(),
..PathRoute::default()
}];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", &routing_target).unwrap();
assert_eq!(selected.id, "demo/api");
assert_eq!(raw_target, "/api//v1/items");
}
#[test]
fn local_location_response_uses_response_rules_without_opening_backend() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let mut route = local_route();
route.headers = HeaderRules {
request: vec![],
response: vec![HeaderRule {
op: Operation::Set,
name: "x-local-response".into(),
value: Some("yes".into()),
}],
};
config.vhosts[0].routes = vec![route];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", "/status").unwrap();
let response = selected.response.as_ref().expect("route must remain local");
assert_eq!(response.status, 200);
assert_eq!(response.body, "ok");
let mut headers = HeaderMap::new();
apply_headers(&mut headers, &selected.headers.response);
assert_eq!(headers.get("x-local-response").unwrap(), "yes");
assert!(selected.response.is_some());
}
#[test]
fn local_location_response_still_respects_route_acl() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let mut route = local_route();
route.acl = Some(Acl {
default: Action::Deny,
rules: vec![AclRule {
network: "10.0.0.0/8".into(),
action: Action::Allow,
}],
});
config.vhosts[0].routes = vec![route];
let snapshot = compile(config, 1, &policy()).unwrap();
let selected = snapshot.select("localhost", "/status").unwrap();
let acl = selected.acl.as_ref().expect("route ACL must be compiled");
assert!(acl.allows("10.1.2.3".parse().unwrap()));
assert!(!acl.allows("192.0.2.1".parse().unwrap()));
assert!(selected.response.is_some());
}
}
-6
View File
@@ -77,7 +77,6 @@ fn compile_routing(
|| host_bindings.iter().all(|l| l.tls),
"prefixed affinity cookies require TLS on every resolved frontend"
);
// Disabled vhosts also reserve their name, preventing fallback exposure.
for name in &routing_hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
@@ -91,8 +90,6 @@ fn compile_routing(
}
claimed.extend(host_bindings.iter().copied());
}
// Backend definitions are irrelevant to a Web Server main source unless
// an enabled Custom Location explicitly uses Reverse Proxy.
let needs_proxy_pool = host.source.is_reverse_proxy()
|| host.routes.iter().any(|route| {
route.enabled
@@ -119,8 +116,6 @@ fn compile_routing(
None
} else {
let mut pool_settings = host.load_balancing.clone();
// Blank health-check Host means the first exact DNS name of the vhost.
// This is important when the backend URL is an IP shared by several vhosts.
if pool_settings.health_check.host.is_none()
&& (pool_settings.health_check.mode == crate::balance::HealthMode::Http
|| host.backends.iter().any(|member| {
@@ -369,7 +364,6 @@ fn compile_routing(
if host.enabled && route.enabled {
let (source, route_pool, backend) =
if route.response.is_some() || route.redirect.is_some() {
// Local return/redirect is handled before source dispatch and needs no origin.
(CompiledSource::ReverseProxy, None, None)
} else {
match &route.source {
+21 -36
View File
@@ -1,13 +1,8 @@
impl Snapshot {
// Exact-first lookups with stack-buffered wildcard keys for normal hosts.
fn find_host_pattern<T>(
host: &str,
mut lookup: impl FnMut(&str) -> Option<T>,
) -> Option<T> {
fn find_host_pattern<T>(host: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> {
if let Some(value) = lookup(host) {
return Some(value);
}
// Skip the final dot: only one label follows it.
let last_dot = host.rfind('.')?;
let mut wildcard = [0u8; 256];
wildcard[..2].copy_from_slice(b"*.");
@@ -25,7 +20,6 @@ impl Snapshot {
return Some(value);
}
} else if let Some(value) = lookup(&format!("*.{suffix}")) {
// Preserve behavior for unusual overlong internal host inputs.
return Some(value);
}
}
@@ -40,7 +34,6 @@ impl Snapshot {
})
.unwrap_or(true)
}
/// RFC 8441 is connection-scoped; requests still enforce vhost opt-in.
pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool {
self.frontends.values().flatten().any(|frontend| {
frontend.websockets
@@ -51,7 +44,6 @@ impl Snapshot {
})
})
}
/// RFC 9220 is connection-scoped; requests still enforce vhost opt-in.
pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool {
self.frontends.values().flatten().any(|frontend| {
frontend.websockets
@@ -62,8 +54,6 @@ impl Snapshot {
})
})
}
/// Production routing always uses the TCP connection's local endpoint.
/// Exact hostnames win over the longest matching wildcard hostname.
pub fn select_on(
&self,
host: &str,
@@ -73,7 +63,6 @@ impl Snapshot {
) -> Option<&CompiledRoute> {
self.select_on_arc(host, path, local, tls).map(Arc::as_ref)
}
/// Shared compiled-route handle for middleware and forward().
pub fn select_on_arc(
&self,
host: &str,
@@ -87,7 +76,6 @@ impl Snapshot {
.iter()
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
})?;
// The claimed vhost owns its fallback ahead of broader wildcards.
self.routes
.get(pattern)
.and_then(|routes| {
@@ -112,7 +100,6 @@ impl Snapshot {
})
})
}
/// Resolve protocol-error attribution with the routing precedence and bindings.
pub fn resolve_logging_vhost(
&self,
local: SocketAddr,
@@ -136,11 +123,7 @@ impl Snapshot {
});
}
}
for (candidate, matched_by) in [
(authority, "authority"),
(host, "host"),
(sni, "sni"),
] {
for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
let Some(candidate) = candidate else {
continue;
};
@@ -156,7 +139,6 @@ impl Snapshot {
}
None
}
/// Select redirects using accepted socket, TLS state and normalized path.
pub fn redirect_on(
&self,
host: &str,
@@ -189,7 +171,6 @@ impl Snapshot {
})
})
}
/// Select a host-owned local 404/static response using the accepted socket.
pub fn not_found_host_on(
&self,
host: &str,
@@ -209,10 +190,12 @@ impl Snapshot {
})
})
}
/// Legacy inspection helper only; network handlers must call select_on.
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
Self::find_host_pattern(host, |pattern| {
let fallback = self.fallbacks.get(pattern).and_then(|routes| routes.first())?;
let fallback = self
.fallbacks
.get(pattern)
.and_then(|routes| routes.first())?;
self.routes
.get(pattern)
.and_then(|routes| select_route(routes, path, |_| true))
@@ -269,17 +252,25 @@ where
prefix
}
impl CompiledRoute {
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> {
pub fn redirect_location(
&self,
path: &str,
uri: &http::Uri,
) -> Result<Option<http::HeaderValue>> {
let Some(spec) = &self.redirect else {
return Ok(None);
};
let target = if let Some(regex) = &spec.rewrite_regex {
ensure!(regex.is_match(path), "location redirect rewrite regex did not match");
ensure!(
regex.is_match(path),
"location redirect rewrite regex did not match"
);
regex.replace(path, spec.target.as_str()).into_owned()
} else {
spec.target.clone()
};
let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?;
let mut target =
url::Url::parse(&target).context("invalid compiled location redirect target")?;
if spec.preserve_query {
if let Some(query) = uri.query() {
let merged = match target.query() {
@@ -289,10 +280,11 @@ impl CompiledRoute {
target.set_query(Some(&merged));
}
}
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?))
Ok(Some(
http::HeaderValue::from_str(target.as_str())
.context("invalid redirect Location header")?,
))
}
/// Return a normalized replacement path for the upstream hop. `None` means
/// preserve the client's exact origin-form path bytes.
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
use crate::vhost::RewriteMode;
let rewritten = match self.rewrite.mode {
@@ -346,7 +338,6 @@ pub(crate) fn valid_id(id: &str) -> bool {
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Vhost names are ASCII DNS names (IDNs must be provided as punycode) or IPv4.
pub fn normalize_host(value: &str) -> Result<String> {
let host = value
.strip_suffix('.')
@@ -370,8 +361,6 @@ pub fn normalize_host(value: &str) -> Result<String> {
);
Ok(host)
}
/// Configured frontend patterns may use a single leading `*.` wildcard.
/// Runtime Host values remain exact and are normalized by `normalize_host`.
pub fn normalize_host_pattern(value: &str) -> Result<String> {
let value = value.trim();
if let Some(base) = value.strip_prefix("*.") {
@@ -395,13 +384,10 @@ pub fn normalize_host_pattern(value: &str) -> Result<String> {
pub fn request_host(value: &str) -> Result<String> {
ensure!(!value.contains('@'), "invalid Host");
let authority: Authority = value.parse().context("invalid Host authority")?;
// Authority::port() returns None for both an absent and an invalid port.
// Inspect the explicit suffix so an out-of-range port cannot bypass validation.
let raw_host = authority.host();
let suffix = &authority.as_str()[raw_host.len()..];
if !suffix.is_empty() {
let port = suffix.strip_prefix(':').context("invalid Host port")?;
// HTTP permits an empty port suffix, which selects the default port.
if !port.is_empty() {
let number: u16 = port.parse().context("invalid Host port")?;
ensure!(number > 0, "invalid Host port");
@@ -461,7 +447,6 @@ pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> {
!(s[0] == 0x2001 && s[1] == 0x0db8),
"documentation address is not a backend"
);
// Only global unicast, ULA and loopback. Reject IPv4 translation/tunnel special ranges.
let ula = s[0] & 0xfe00 == 0xfc00;
let global =
s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0);
+1138 -1063
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
mod transport_tests {
use crate::config::{compile, demo_config, ValidationPolicy};
fn policy() -> ValidationPolicy {
ValidationPolicy {
tls_addr: None,
cert_dir: None,
data_dir: None,
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
}
}
#[test]
fn local_listener_backend_requires_explicit_risk_acceptance() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let backend = &mut config.vhosts[0].backends[0];
backend.url = "http://127.0.0.1:9090".into();
backend.allow_private = true;
backend.accept_local_listener_risk = false;
let error = compile(config.clone(), 1, &policy())
.expect_err("local administration listener must be rejected by default");
let detail = format!("{error:#}");
assert!(
detail.contains("accept_local_listener_risk=true"),
"{detail}"
);
config.vhosts[0].backends[0].accept_local_listener_risk = true;
compile(config, 2, &policy())
.expect("explicit risk acceptance should permit the local listener target");
}
#[test]
fn rfc8441_requires_an_h2_capable_listener_and_h2c_is_explicit() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].websockets = true;
config.vhosts[0].websocket_rfc8441 = true;
config.vhosts[0].http2_enabled = true;
let error = compile(config.clone(), 10, &policy())
.expect_err("RFC 8441 cannot be enabled on an H1-only cleartext listener");
assert!(error.to_string().contains("HTTP/2-capable listener"));
config.listeners.endpoints[0].binding.h2c = true;
let snapshot = compile(config, 11, &policy())
.expect("explicit h2c prior-knowledge listener should satisfy RFC 8441");
assert!(snapshot.websocket_rfc8441_on("127.0.0.1:8080".parse().unwrap(), false));
}
}
-4
View File
@@ -1,5 +1,3 @@
//! SQLite stores desired configuration, administrator state and bounded audit history.
//! No database call is made from the public HTTP forwarding path.
use crate::config::{Config, Envelope};
mod certificate_api;
mod dashboard_presets;
@@ -61,7 +59,6 @@ pub(crate) async fn audit_tx(
action: &str,
detail: &str,
) -> Result<()> {
// Callers only supply allowlisted actions and non-secret, bounded metadata.
sqlx::query("INSERT INTO admin_audit(time, actor, action, detail) VALUES(?, ?, ?, ?)")
.bind(crate::auth::now())
.bind(actor)
@@ -212,7 +209,6 @@ impl Store {
pub async fn replace(&self, expected: u64, config: &Config) -> Result<bool> {
self.replace_as(expected, config, "local").await
}
/// Compare-and-swap and audit are one transaction. Publication is handled by App.
pub async fn replace_as(&self, expected: u64, config: &Config, actor: &str) -> Result<bool> {
self.replace_as_action(expected, config, actor, "config.apply", None)
.await
+80 -25
View File
@@ -15,37 +15,68 @@ pub struct ApiTokenMetadata {
pub last_used_at: Option<i64>,
}
#[derive(Debug)]
pub struct ApiTokenGrant { pub id: String, pub permission: String }
pub struct ApiTokenGrant {
pub id: String,
pub permission: String,
}
impl Store {
pub async fn api_tokens(&self) -> Result<Vec<ApiTokenMetadata>> {
let rows = sqlx::query("SELECT id,permission,comment,allowed_cidrs,created_at,last_used_at FROM administration_api_tokens ORDER BY created_at DESC,id")
.fetch_all(&self.pool).await?;
rows.into_iter().map(|row| {
let cidrs: String = row.try_get("allowed_cidrs")?;
Ok(ApiTokenMetadata { id: row.try_get("id")?, permission: row.try_get("permission")?,
comment: row.try_get("comment")?, allowed_cidrs: serde_json::from_str(&cidrs)?,
created_at: row.try_get("created_at")?, last_used_at: row.try_get("last_used_at")? })
}).collect()
rows.into_iter()
.map(|row| {
let cidrs: String = row.try_get("allowed_cidrs")?;
Ok(ApiTokenMetadata {
id: row.try_get("id")?,
permission: row.try_get("permission")?,
comment: row.try_get("comment")?,
allowed_cidrs: serde_json::from_str(&cidrs)?,
created_at: row.try_get("created_at")?,
last_used_at: row.try_get("last_used_at")?,
})
})
.collect()
}
pub async fn create_api_token(&self, id: &str, hash: &str, permission: &str,
comment: &str, cidrs: &[String], actor: &str) -> Result<bool> {
pub async fn create_api_token(
&self,
id: &str,
hash: &str,
permission: &str,
comment: &str,
cidrs: &[String],
actor: &str,
) -> Result<bool> {
let mut tx = self.pool.begin().await?;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM administration_api_tokens").fetch_one(&mut *tx).await?;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM administration_api_tokens")
.fetch_one(&mut *tx)
.await?;
ensure!(count < API_TOKEN_CAPACITY, "API token limit reached");
let result = sqlx::query("INSERT OR IGNORE INTO administration_api_tokens(id,token_hash,permission,comment,allowed_cidrs,created_at) VALUES(?,?,?,?,?,?)")
.bind(id).bind(hash).bind(permission).bind(comment).bind(serde_json::to_string(cidrs)?)
.bind(crate::auth::now()).execute(&mut *tx).await?;
if result.rows_affected() != 1 { return Ok(false); }
audit_tx(&mut tx, actor, "api_token.create", &format!("id={id} permission={permission}")).await?;
if result.rows_affected() != 1 {
return Ok(false);
}
audit_tx(
&mut tx,
actor,
"api_token.create",
&format!("id={id} permission={permission}"),
)
.await?;
tx.commit().await?;
Ok(true)
}
pub async fn revoke_api_token(&self, id: &str, actor: &str) -> Result<bool> {
let mut tx = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM administration_api_tokens WHERE id = ?")
.bind(id).execute(&mut *tx).await?;
if result.rows_affected() == 0 { return Ok(false); }
.bind(id)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Ok(false);
}
audit_tx(&mut tx, actor, "api_token.revoke", &format!("id={id}")).await?;
tx.commit().await?;
Ok(true)
@@ -54,39 +85,63 @@ impl Store {
let Some(row) = sqlx::query("SELECT id,permission,allowed_cidrs FROM administration_api_tokens WHERE token_hash = ?")
.bind(hash).fetch_optional(&self.pool).await? else { return Ok(None); };
let cidrs: String = row.try_get("allowed_cidrs")?;
let allowed: Vec<String> = serde_json::from_str(&cidrs).context("invalid stored CIDR list")?;
if !allowed.iter().filter_map(|s| s.parse::<ipnet::IpNet>().ok()).any(|net| net.contains(&ip)) {
let allowed: Vec<String> =
serde_json::from_str(&cidrs).context("invalid stored CIDR list")?;
if !allowed
.iter()
.filter_map(|s| s.parse::<ipnet::IpNet>().ok())
.any(|net| net.contains(&ip))
{
return Ok(None);
}
let id: String = row.try_get("id")?;
let permission: String = row.try_get("permission")?;
sqlx::query("UPDATE administration_api_tokens SET last_used_at = ? WHERE id = ?")
.bind(crate::auth::now()).bind(&id).execute(&self.pool).await?;
.bind(crate::auth::now())
.bind(&id)
.execute(&self.pool)
.await?;
Ok(Some(ApiTokenGrant { id, permission }))
}
pub async fn prometheus_token_hash(&self) -> Result<Option<String>> {
Ok(sqlx::query_scalar("SELECT token_hash FROM prometheus_credential WHERE id = 1")
.fetch_optional(&self.pool).await?)
Ok(
sqlx::query_scalar("SELECT token_hash FROM prometheus_credential WHERE id = 1")
.fetch_optional(&self.pool)
.await?,
)
}
pub async fn prometheus_token_status(&self) -> Result<Option<i64>> {
Ok(sqlx::query_scalar("SELECT created_at FROM prometheus_credential WHERE id = 1 AND token_hash != 'revoked'")
.fetch_optional(&self.pool).await?)
Ok(sqlx::query_scalar(
"SELECT created_at FROM prometheus_credential WHERE id = 1 AND token_hash != 'revoked'",
)
.fetch_optional(&self.pool)
.await?)
}
pub async fn rotate_prometheus_token(&self, hash: &str, actor: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("INSERT INTO prometheus_credential(id,token_hash,created_at) VALUES(1,?,?) ON CONFLICT(id) DO UPDATE SET token_hash=excluded.token_hash,created_at=excluded.created_at")
.bind(hash).bind(crate::auth::now()).execute(&mut *tx).await?;
audit_tx(&mut tx, actor, "prometheus_token.rotate", "exporter token rotated").await?;
audit_tx(
&mut tx,
actor,
"prometheus_token.rotate",
"exporter token rotated",
)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn revoke_prometheus_token(&self, actor: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
// Keep a disabled marker so an old environment token does not silently
// become valid again after the operator clicks Revoke in the panel.
sqlx::query("INSERT INTO prometheus_credential(id,token_hash,created_at) VALUES(1,'revoked',?) ON CONFLICT(id) DO UPDATE SET token_hash='revoked',created_at=excluded.created_at")
.bind(crate::auth::now()).execute(&mut *tx).await?;
audit_tx(&mut tx, actor, "prometheus_token.revoke", "exporter token revoked").await?;
audit_tx(
&mut tx,
actor,
"prometheus_token.revoke",
"exporter token revoked",
)
.await?;
tx.commit().await?;
Ok(())
}
+27 -14
View File
@@ -5,7 +5,10 @@ use super::{
use anyhow::{ensure, Context, Result};
use sqlx::Row;
fn normalized_certificate_api_ids(comment: &str, certificate_ids: &[String]) -> Result<Vec<String>> {
fn normalized_certificate_api_ids(
comment: &str,
certificate_ids: &[String],
) -> Result<Vec<String>> {
ensure!(
comment.len() <= 256 && !comment.chars().any(char::is_control),
"invalid certificate API comment"
@@ -18,7 +21,9 @@ fn normalized_certificate_api_ids(comment: &str, certificate_ids: &[String]) ->
normalized.sort();
normalized.dedup();
ensure!(
normalized.iter().all(|value| value != "*" && crate::config::valid_id(value)),
normalized
.iter()
.all(|value| value != "*" && crate::config::valid_id(value)),
"invalid certificate API certificate id"
);
ensure!(
@@ -36,7 +41,8 @@ fn credential_from_row(row: sqlx::sqlite::SqliteRow) -> Result<CertificateApiCre
created_at: row.try_get("created_at")?,
last_used_at: row.try_get("last_used_at")?,
allow_deploy: row.try_get::<i64, _>("allow_deploy")? != 0,
certificate_ids: serde_json::from_str(&raw_ids).context("invalid certificate API grant ids")?,
certificate_ids: serde_json::from_str(&raw_ids)
.context("invalid certificate API grant ids")?,
})
}
@@ -135,7 +141,6 @@ impl Store {
})
}
// Metadata access never selects token_hash, including for a single credential.
pub async fn certificate_api_credential(
&self,
key_id: &str,
@@ -154,7 +159,6 @@ impl Store {
row.map(credential_from_row).transpose()
}
// Do not touch token_hash, created_at, or last_used_at while editing settings.
pub async fn edit_certificate_api_credential(
&self,
key_id: &str,
@@ -170,11 +174,12 @@ impl Store {
let normalized_ids = normalized_certificate_api_ids(comment, certificate_ids)?;
let ids_json = serde_json::to_string(&normalized_ids)?;
let mut tx = self.pool.begin().await?;
let changed = sqlx::query("UPDATE certificate_api_credentials SET comment = ? WHERE key_id = ?")
.bind(comment)
.bind(key_id)
.execute(&mut *tx)
.await?;
let changed =
sqlx::query("UPDATE certificate_api_credentials SET comment = ? WHERE key_id = ?")
.bind(comment)
.bind(key_id)
.execute(&mut *tx)
.await?;
if changed.rows_affected() == 0 {
return Ok(None);
}
@@ -193,7 +198,11 @@ impl Store {
"certificate_api.edit",
&format!(
"key_id={key_id}; scope={}; certificate_ids={}",
if allow_deploy { "deploy+reload" } else { "reload" },
if allow_deploy {
"deploy+reload"
} else {
"reload"
},
normalized_ids.join(",")
),
)
@@ -202,7 +211,6 @@ impl Store {
self.certificate_api_credential(key_id).await
}
// Hash-only rotation preserves comment/grants and invalidates the old token at commit.
pub async fn regenerate_certificate_api_token(
&self,
key_id: &str,
@@ -231,8 +239,13 @@ impl Store {
if changed.rows_affected() == 0 {
return Ok(None);
}
audit_tx(&mut tx, actor, "certificate_api.regenerate", &format!("key_id={key_id}"))
.await?;
audit_tx(
&mut tx,
actor,
"certificate_api.regenerate",
&format!("key_id={key_id}"),
)
.await?;
tx.commit().await?;
self.certificate_api_credential(key_id).await
}
Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff. Show more