v0.25.5
This commit is contained in:
1 parent
1dea7f55c8
commit
c819f0a335
151 files changed
+7610
-4264
No files matched your search
+8
-1
@@ -50,7 +50,14 @@ mod tests {
|
||||
routing_path("/static/js//events.js").unwrap(),
|
||||
"/static/js/events.js"
|
||||
);
|
||||
for path in ["/%2e%2e/secret", "/a/%2E%2E/b", "/a%2fb", "/a%5cb", "/bad%", "/bad%2"] {
|
||||
for path in [
|
||||
"/%2e%2e/secret",
|
||||
"/a/%2E%2E/b",
|
||||
"/a%2fb",
|
||||
"/a%5cb",
|
||||
"/bad%",
|
||||
"/bad%2",
|
||||
] {
|
||||
assert!(routing_path(path).is_err(), "{path}");
|
||||
}
|
||||
assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users");
|
||||
|
||||
+12
-3
@@ -72,8 +72,17 @@ impl Manager {
|
||||
id: format!("{:032x}", rand::random::<u128>()),
|
||||
certificate_id: cert.id.clone(),
|
||||
status: "running",
|
||||
kind: if dry_run { "renewal-dry-run" } else { "renewal" },
|
||||
log_directory: certbot_log_directory(&directory, &issuer.provider, &issuer.environment, &cert.id),
|
||||
kind: if dry_run {
|
||||
"renewal-dry-run"
|
||||
} else {
|
||||
"renewal"
|
||||
},
|
||||
log_directory: certbot_log_directory(
|
||||
&directory,
|
||||
&issuer.provider,
|
||||
&issuer.environment,
|
||||
&cert.id,
|
||||
),
|
||||
provider: issuer.provider.clone(),
|
||||
domains,
|
||||
environment: issuer.environment.clone(),
|
||||
@@ -109,7 +118,7 @@ impl Manager {
|
||||
.await
|
||||
.map_err(|_| ApiError(StatusCode::CONFLICT, "acme.secret_missing".into()))?;
|
||||
request["credential_ini_b64"] = Value::String(
|
||||
base64::engine::general_purpose::STANDARD.encode(credential.as_slice())
|
||||
base64::engine::general_purpose::STANDARD.encode(credential.as_slice()),
|
||||
);
|
||||
}
|
||||
let raw = Zeroizing::new(request.to_string());
|
||||
|
||||
+124
-29
@@ -29,8 +29,17 @@ pub struct Job {
|
||||
pub certificate: Option<Certificate>,
|
||||
}
|
||||
|
||||
pub(crate) fn certbot_log_directory(directory: &std::path::Path, provider: &str, environment: &str, certificate_id: &str) -> String {
|
||||
let suffix = if provider == "rfc2136" { String::new() } else { format!("-{provider}") };
|
||||
pub(crate) fn certbot_log_directory(
|
||||
directory: &std::path::Path,
|
||||
provider: &str,
|
||||
environment: &str,
|
||||
certificate_id: &str,
|
||||
) -> String {
|
||||
let suffix = if provider == "rfc2136" {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{provider}")
|
||||
};
|
||||
directory
|
||||
.join("acme")
|
||||
.join(format!("{environment}-{certificate_id}{suffix}"))
|
||||
@@ -38,26 +47,57 @@ pub(crate) fn certbot_log_directory(directory: &std::path::Path, provider: &str,
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
fn provider_credential_bytes(request: &Value, provider: &str) -> Result<Zeroizing<Vec<u8>>, ApiError> {
|
||||
fn provider_credential_bytes(
|
||||
request: &Value,
|
||||
provider: &str,
|
||||
) -> Result<Zeroizing<Vec<u8>>, ApiError> {
|
||||
fn credential<'a>(request: &'a Value, name: &str) -> Result<&'a str, ApiError> {
|
||||
let invalid = || ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into());
|
||||
let value = request.get(name).and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
let invalid = || {
|
||||
ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_api_credential".into(),
|
||||
)
|
||||
};
|
||||
let value = request
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(invalid)?;
|
||||
let valid = (8..=4096).contains(&value.len())
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'_' | b'.' | b'/' | b'+' | b'-' | b'=')
|
||||
});
|
||||
if valid { Ok(value) } else { Err(invalid()) }
|
||||
if valid {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(invalid())
|
||||
}
|
||||
}
|
||||
let bytes = match provider {
|
||||
"cloudflare" => format!("dns_cloudflare_api_token = {}\n", credential(request, "api_token")?).into_bytes(),
|
||||
"cloudflare" => format!(
|
||||
"dns_cloudflare_api_token = {}\n",
|
||||
credential(request, "api_token")?
|
||||
)
|
||||
.into_bytes(),
|
||||
"ovh" => {
|
||||
if request.get("endpoint").is_some_and(|value| !value.is_string()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into()));
|
||||
if request
|
||||
.get("endpoint")
|
||||
.is_some_and(|value| !value.is_string())
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_ovh_endpoint".into(),
|
||||
));
|
||||
}
|
||||
let endpoint = request.get("endpoint").and_then(Value::as_str).unwrap_or("ovh-eu");
|
||||
let endpoint = request
|
||||
.get("endpoint")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("ovh-eu");
|
||||
if !matches!(endpoint, "ovh-eu" | "ovh-ca") {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_ovh_endpoint".into(),
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"dns_ovh_endpoint = {}\ndns_ovh_application_key = {}\ndns_ovh_application_secret = {}\ndns_ovh_consumer_key = {}\n",
|
||||
@@ -73,15 +113,32 @@ fn provider_credential_bytes(request: &Value, provider: &str) -> Result<Zeroizin
|
||||
.get("dns_server")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
|
||||
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_dns_server".into()))?;
|
||||
if request.get("dns_port").is_some_and(|value| value.as_u64().is_none()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into()));
|
||||
.ok_or_else(|| {
|
||||
ApiError(StatusCode::BAD_REQUEST, "acme.invalid_dns_server".into())
|
||||
})?;
|
||||
if request
|
||||
.get("dns_port")
|
||||
.is_some_and(|value| value.as_u64().is_none())
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_limits".into(),
|
||||
));
|
||||
}
|
||||
let dns_port = request.get("dns_port").and_then(Value::as_u64).unwrap_or(53);
|
||||
let dns_port = request
|
||||
.get("dns_port")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(53);
|
||||
if !(1..=65535).contains(&dns_port) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_limits".into(),
|
||||
));
|
||||
}
|
||||
let raw_name = request.get("key_name").and_then(Value::as_str).unwrap_or("");
|
||||
let raw_name = request
|
||||
.get("key_name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let key_name = raw_name.to_ascii_lowercase();
|
||||
let key_name = key_name.trim_end_matches('.');
|
||||
let valid_name = !key_name.is_empty()
|
||||
@@ -95,43 +152,81 @@ fn provider_credential_bytes(request: &Value, provider: &str) -> Result<Zeroizin
|
||||
})
|
||||
});
|
||||
if !valid_name {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_domain".into(),
|
||||
));
|
||||
}
|
||||
if request.get("key_algorithm").is_some_and(|value| !value.is_string()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into()));
|
||||
if request
|
||||
.get("key_algorithm")
|
||||
.is_some_and(|value| !value.is_string())
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_key_algorithm".into(),
|
||||
));
|
||||
}
|
||||
let key_algorithm = request
|
||||
.get("key_algorithm")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("HMAC-SHA256");
|
||||
if !matches!(key_algorithm, "HMAC-SHA256" | "HMAC-SHA384" | "HMAC-SHA512") {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_key_algorithm".into(),
|
||||
));
|
||||
}
|
||||
let key_secret = request.get("key_secret").and_then(Value::as_str).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into()))?;
|
||||
let key_secret = request
|
||||
.get("key_secret")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_api_credential".into(),
|
||||
)
|
||||
})?;
|
||||
let decoded = Zeroizing::new(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(key_secret)
|
||||
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()))?,
|
||||
.map_err(|_| {
|
||||
ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into())
|
||||
})?,
|
||||
);
|
||||
if key_secret.len() > 1024 || !(16..=512).contains(&decoded.len()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_key_secret".into(),
|
||||
));
|
||||
}
|
||||
if request.get("sign_query").is_some_and(|value| !value.is_boolean()) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_sign_query".into()));
|
||||
if request
|
||||
.get("sign_query")
|
||||
.is_some_and(|value| !value.is_boolean())
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_sign_query".into(),
|
||||
));
|
||||
}
|
||||
let sign_query = request.get("sign_query").and_then(Value::as_bool).unwrap_or(false);
|
||||
let sign_query = request
|
||||
.get("sign_query")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
format!(
|
||||
"dns_rfc2136_server = {dns_server}\ndns_rfc2136_port = {dns_port}\ndns_rfc2136_name = {key_name}.\ndns_rfc2136_secret = {key_secret}\ndns_rfc2136_algorithm = {key_algorithm}\ndns_rfc2136_sign_query = {sign_query}\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
"http01" => Vec::new(),
|
||||
_ => return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_provider".into())),
|
||||
_ => {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"acme.invalid_provider".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(Zeroizing::new(bytes))
|
||||
}
|
||||
|
||||
|
||||
pub struct Manager {
|
||||
slot: Arc<Semaphore>,
|
||||
job: Mutex<Option<Job>>,
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
fn valid_certificate_api_token(value: &str) -> bool {
|
||||
(32..=128).contains(&value.len())
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')
|
||||
})
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
|
||||
}
|
||||
|
||||
fn valid_certificate_api_key_id(value: &str) -> bool {
|
||||
|
||||
+31
-20
@@ -169,8 +169,7 @@ async fn certificate_deploy(
|
||||
}
|
||||
|
||||
let administration_restart_required = config.administration.tls
|
||||
&& config.administration.certificate_id.as_deref()
|
||||
== Some(certificate_id.as_str());
|
||||
&& config.administration.certificate_id.as_deref() == Some(certificate_id.as_str());
|
||||
let result = state
|
||||
.update_as(
|
||||
Update {
|
||||
@@ -196,8 +195,12 @@ async fn certificate_deploy(
|
||||
})
|
||||
.await
|
||||
{
|
||||
Err(join_error) => tracing::warn!(%join_error, "external certificate rollback task failed"),
|
||||
Ok(Err(rollback_error)) => tracing::warn!(%rollback_error, "external certificate rollback failed"),
|
||||
Err(join_error) => {
|
||||
tracing::warn!(%join_error, "external certificate rollback task failed")
|
||||
}
|
||||
Ok(Err(rollback_error)) => {
|
||||
tracing::warn!(%rollback_error, "external certificate rollback failed")
|
||||
}
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
}
|
||||
@@ -227,8 +230,12 @@ async fn certificate_deploy(
|
||||
})
|
||||
.await
|
||||
{
|
||||
Err(join_error) => tracing::warn!(%join_error, "previous external certificate cleanup task failed"),
|
||||
Ok(Err(cleanup_error)) => tracing::warn!(%cleanup_error, "previous external certificate cleanup failed"),
|
||||
Err(join_error) => {
|
||||
tracing::warn!(%join_error, "previous external certificate cleanup task failed")
|
||||
}
|
||||
Ok(Err(cleanup_error)) => {
|
||||
tracing::warn!(%cleanup_error, "previous external certificate cleanup failed")
|
||||
}
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
}
|
||||
@@ -271,12 +278,13 @@ async fn certificate_reload(
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let revision = state.reload_certificates().await?;
|
||||
let snapshot = state.config.load_full();
|
||||
let admin_certificate_restart_required = snapshot
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.tls
|
||||
&& snapshot.envelope.config.administration.certificate_id.is_some();
|
||||
let admin_certificate_restart_required = snapshot.envelope.config.administration.tls
|
||||
&& snapshot
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.certificate_id
|
||||
.is_some();
|
||||
state
|
||||
.store
|
||||
.audit(
|
||||
@@ -295,23 +303,26 @@ async fn certificate_reload(
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
async fn certificate_reload_one(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(certificate_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !crate::config::valid_id(&certificate_id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate not found".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"certificate not found".into(),
|
||||
));
|
||||
}
|
||||
let revision = state.reload_certificate(&certificate_id).await?;
|
||||
let snapshot = state.config.load_full();
|
||||
let admin_certificate_restart_required = snapshot
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.tls
|
||||
&& snapshot.envelope.config.administration.certificate_id.as_deref()
|
||||
let admin_certificate_restart_required = snapshot.envelope.config.administration.tls
|
||||
&& snapshot
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.certificate_id
|
||||
.as_deref()
|
||||
== Some(certificate_id.as_str());
|
||||
state
|
||||
.store
|
||||
|
||||
+448
-67
@@ -12,73 +12,454 @@ use std::sync::OnceLock;
|
||||
// document a direct compiler input, so a clean or partially removed target directory
|
||||
// cannot leave the crate pointing at a missing OUT_DIR artifact.
|
||||
static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
("README.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))),
|
||||
("ROADMAP.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ROADMAP.md"))),
|
||||
("CHANGELOG.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md"))),
|
||||
("docs/ACME_PROVIDERS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_PROVIDERS.md"))),
|
||||
("docs/ACME_RFC2136.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_RFC2136.md"))),
|
||||
("docs/API.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API.md"))),
|
||||
("docs/API_ACCESS.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API_ACCESS.md"))),
|
||||
("docs/ARCHITECTURE.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ARCHITECTURE.md"))),
|
||||
("docs/CACHE.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE.md"))),
|
||||
("docs/CACHE_DIAGNOSTICS.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE_DIAGNOSTICS.md"))),
|
||||
("docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CHANGELOG_TECHNICAL_ARCHIVE.md"))),
|
||||
("docs/CONTAINER.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CONTAINER.md"))),
|
||||
("docs/CUSTOM_LOCATIONS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CUSTOM_LOCATIONS.md"))),
|
||||
("docs/DOCKER_DISCOVERY.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/DOCKER_DISCOVERY.md"))),
|
||||
("docs/ERROR_LOGGING.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ERROR_LOGGING.md"))),
|
||||
("docs/HTTP3.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/HTTP3.md"))),
|
||||
("docs/INSTALL.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INSTALL.md"))),
|
||||
("docs/INTEGRATIONS_040.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INTEGRATIONS_040.md"))),
|
||||
("docs/LIVE_LOGS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/LIVE_LOGS.md"))),
|
||||
("docs/MANAGEMENT.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MANAGEMENT.md"))),
|
||||
("docs/MIGRATION.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MIGRATION.md"))),
|
||||
("docs/MOCK_AUTH.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_AUTH.md"))),
|
||||
("docs/MOCK_SERVER.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_SERVER.md"))),
|
||||
("docs/NOT_FOUND_HOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/NOT_FOUND_HOSTS.md"))),
|
||||
("docs/NPM_MIGRATION.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/NPM_MIGRATION.md"))),
|
||||
("docs/PHP_FPM.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM.md"))),
|
||||
("docs/PROMETHEUS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PROMETHEUS.md"))),
|
||||
("docs/README.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/README.md"))),
|
||||
("docs/REDIRECTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REDIRECTS.md"))),
|
||||
("docs/REFERENCES.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REFERENCES.md"))),
|
||||
("docs/RENEWAL.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/RENEWAL.md"))),
|
||||
("docs/ROADMAP_HISTORY.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ROADMAP_HISTORY.md"))),
|
||||
("docs/SECRET_STORE_BACKUP.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECRET_STORE_BACKUP.md"))),
|
||||
("docs/SECURITY.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY.md"))),
|
||||
("docs/SECURITY_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY_AUDIT.md"))),
|
||||
("docs/SETTINGS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SETTINGS.md"))),
|
||||
("docs/STREAMS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md"))),
|
||||
("docs/TEST_REPORT.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TEST_REPORT.md"))),
|
||||
("docs/TODO.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TODO.md"))),
|
||||
("docs/TRANSFORM.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSFORM.md"))),
|
||||
("docs/TRANSPORT.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSPORT.md"))),
|
||||
("docs/V0.21.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.21.0_AUDIT.md"))),
|
||||
("docs/V0.22.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.0_AUDIT.md"))),
|
||||
("docs/V0.22.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.1_AUDIT.md"))),
|
||||
("docs/V0.22.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.2_AUDIT.md"))),
|
||||
("docs/V0.22.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.3_AUDIT.md"))),
|
||||
("docs/V0.23.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.0_AUDIT.md"))),
|
||||
("docs/V0.23.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.1_AUDIT.md"))),
|
||||
("docs/V0.23.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.2_AUDIT.md"))),
|
||||
("docs/V0.23.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.3_AUDIT.md"))),
|
||||
("docs/V0.24.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.0_AUDIT.md"))),
|
||||
("docs/V0.24.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.1_AUDIT.md"))),
|
||||
("docs/V0.24.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.3_AUDIT.md"))),
|
||||
("docs/V0.24.4_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.4_AUDIT.md"))),
|
||||
("docs/V0.24.5_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.5_AUDIT.md"))),
|
||||
("docs/V0.24.6_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.6_AUDIT.md"))),
|
||||
("docs/V0.24.7_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.7_AUDIT.md"))),
|
||||
("docs/V0.24.8_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.8_AUDIT.md"))),
|
||||
("docs/V0.25.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_AUDIT.md"))),
|
||||
("docs/V0.25.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.1_AUDIT.md"))),
|
||||
("docs/V0.25.4_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.4_AUDIT.md"))),
|
||||
("docs/V0.25.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.3_AUDIT.md"))),
|
||||
("docs/V0.25.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.2_AUDIT.md"))),
|
||||
("docs/V0.25.0_SECURITY_PREPROD_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"))),
|
||||
("docs/VHOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md"))),
|
||||
("docs/VHOST_ACCESS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md"))),
|
||||
("docs/WEB_SERVER.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/WEB_SERVER.md"))),
|
||||
(
|
||||
"README.md",
|
||||
"project",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")),
|
||||
),
|
||||
(
|
||||
"ROADMAP.md",
|
||||
"project",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ROADMAP.md")),
|
||||
),
|
||||
(
|
||||
"CHANGELOG.md",
|
||||
"project",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md")),
|
||||
),
|
||||
(
|
||||
"docs/ACME_PROVIDERS.md",
|
||||
"guides",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/ACME_PROVIDERS.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/ACME_RFC2136.md",
|
||||
"historical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_RFC2136.md")),
|
||||
),
|
||||
(
|
||||
"docs/API.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API.md")),
|
||||
),
|
||||
(
|
||||
"docs/API_ACCESS.md",
|
||||
"technical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API_ACCESS.md")),
|
||||
),
|
||||
(
|
||||
"docs/ARCHITECTURE.md",
|
||||
"technical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ARCHITECTURE.md")),
|
||||
),
|
||||
(
|
||||
"docs/CACHE.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE.md")),
|
||||
),
|
||||
(
|
||||
"docs/CACHE_DIAGNOSTICS.md",
|
||||
"technical",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/CACHE_DIAGNOSTICS.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/CHANGELOG_TECHNICAL_ARCHIVE.md",
|
||||
"historical",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/CHANGELOG_TECHNICAL_ARCHIVE.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/CONTAINER.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CONTAINER.md")),
|
||||
),
|
||||
(
|
||||
"docs/CUSTOM_LOCATIONS.md",
|
||||
"guides",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/CUSTOM_LOCATIONS.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/DOCKER_DISCOVERY.md",
|
||||
"guides",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/DOCKER_DISCOVERY.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/ERROR_LOGGING.md",
|
||||
"technical",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/ERROR_LOGGING.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/HTTP3.md",
|
||||
"technical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/HTTP3.md")),
|
||||
),
|
||||
(
|
||||
"docs/INSTALL.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INSTALL.md")),
|
||||
),
|
||||
(
|
||||
"docs/INTEGRATIONS_040.md",
|
||||
"historical",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/INTEGRATIONS_040.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/LIVE_LOGS.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/LIVE_LOGS.md")),
|
||||
),
|
||||
(
|
||||
"docs/MANAGEMENT.md",
|
||||
"historical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MANAGEMENT.md")),
|
||||
),
|
||||
(
|
||||
"docs/MIGRATION.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MIGRATION.md")),
|
||||
),
|
||||
(
|
||||
"docs/MOCK_AUTH.md",
|
||||
"technical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_AUTH.md")),
|
||||
),
|
||||
(
|
||||
"docs/MOCK_SERVER.md",
|
||||
"technical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_SERVER.md")),
|
||||
),
|
||||
(
|
||||
"docs/NOT_FOUND_HOSTS.md",
|
||||
"guides",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/NOT_FOUND_HOSTS.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/NPM_MIGRATION.md",
|
||||
"guides",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/NPM_MIGRATION.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/PHP_FPM.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM.md")),
|
||||
),
|
||||
(
|
||||
"docs/PROMETHEUS.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PROMETHEUS.md")),
|
||||
),
|
||||
(
|
||||
"docs/README.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/README.md")),
|
||||
),
|
||||
(
|
||||
"docs/REDIRECTS.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REDIRECTS.md")),
|
||||
),
|
||||
(
|
||||
"docs/REFERENCES.md",
|
||||
"technical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REFERENCES.md")),
|
||||
),
|
||||
(
|
||||
"docs/RENEWAL.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/RENEWAL.md")),
|
||||
),
|
||||
(
|
||||
"docs/ROADMAP_HISTORY.md",
|
||||
"historical",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/ROADMAP_HISTORY.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/SECRET_STORE_BACKUP.md",
|
||||
"guides",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/SECRET_STORE_BACKUP.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/SECURITY.md",
|
||||
"technical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY.md")),
|
||||
),
|
||||
(
|
||||
"docs/SECURITY_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/SECURITY_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/SETTINGS.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SETTINGS.md")),
|
||||
),
|
||||
(
|
||||
"docs/STREAMS.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
|
||||
),
|
||||
(
|
||||
"docs/TEST_REPORT.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TEST_REPORT.md")),
|
||||
),
|
||||
(
|
||||
"docs/TODO.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TODO.md")),
|
||||
),
|
||||
(
|
||||
"docs/TRANSFORM.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSFORM.md")),
|
||||
),
|
||||
(
|
||||
"docs/TRANSPORT.md",
|
||||
"technical",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSPORT.md")),
|
||||
),
|
||||
(
|
||||
"docs/V0.21.0_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.21.0_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.22.0_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.22.0_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.22.1_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.22.1_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.22.2_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.22.2_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.22.3_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.22.3_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.23.0_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.23.0_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.23.1_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.23.1_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.23.2_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.23.2_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.23.3_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.23.3_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.24.0_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.24.0_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.24.1_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.24.1_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.24.3_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.24.3_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.24.4_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.24.4_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.24.5_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.24.5_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.24.6_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.24.6_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.24.7_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.24.7_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.24.8_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.24.8_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.25.0_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.25.0_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.25.1_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.25.1_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.25.5_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.25.5_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.25.4_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.25.4_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.25.3_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.25.3_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.25.2_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.25.2_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.25.0_SECURITY_PREPROD_AUDIT.md",
|
||||
"audits",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/VHOSTS.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md")),
|
||||
),
|
||||
(
|
||||
"docs/VHOST_ACCESS.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md")),
|
||||
),
|
||||
(
|
||||
"docs/WEB_SERVER.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/WEB_SERVER.md")),
|
||||
),
|
||||
];
|
||||
|
||||
pub(super) async fn index() -> Html<&'static str> {
|
||||
|
||||
+19
-10
@@ -188,14 +188,12 @@ async fn logs(
|
||||
})
|
||||
.take(limit)
|
||||
.collect();
|
||||
Ok(Json(
|
||||
json!({
|
||||
"entries": entries,
|
||||
"capacity": crate::logging::HISTORY_CAPACITY,
|
||||
"window": crate::logging::LIVE_WINDOW,
|
||||
"dropped": state.logs.dropped.load(Ordering::Relaxed)
|
||||
}),
|
||||
))
|
||||
Ok(Json(json!({
|
||||
"entries": entries,
|
||||
"capacity": crate::logging::HISTORY_CAPACITY,
|
||||
"window": crate::logging::LIVE_WINDOW,
|
||||
"dropped": state.logs.dropped.load(Ordering::Relaxed)
|
||||
})))
|
||||
}
|
||||
|
||||
async fn live_logs_stream(
|
||||
@@ -208,7 +206,12 @@ async fn live_logs_stream(
|
||||
.viewer_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "events.too_many_viewers".into()))?;
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"events.too_many_viewers".into(),
|
||||
)
|
||||
})?;
|
||||
let receiver = state.logs.subscribe();
|
||||
let next_auth_check = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
let stream = futures_util::stream::unfold(
|
||||
@@ -344,7 +347,13 @@ async fn traffic_status(
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let snapshot = state.config.load_full();
|
||||
if query.vhost.as_ref().is_some_and(|id| {
|
||||
id != "*" && !snapshot.envelope.config.vhosts.iter().any(|vhost| &vhost.id == id)
|
||||
id != "*"
|
||||
&& !snapshot
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.any(|vhost| &vhost.id == id)
|
||||
}) {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
|
||||
+29
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.25.4",
|
||||
"version": "0.25.5",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
@@ -489,7 +489,7 @@
|
||||
"cloudflare_client_ip": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Use a verified Cloudflare connecting-IP header only when the immediate peer is in the configured trusted Cloudflare ranges."
|
||||
"description": "Use verified Cloudflare client-IP and country headers only when the immediate peer is in the built-in Cloudflare edge ranges; country falls back to local GeoIP when unavailable or invalid."
|
||||
},
|
||||
"timing_headers": {
|
||||
"type": "boolean",
|
||||
@@ -2305,6 +2305,11 @@
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"include_traffic": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Export bounded Traffic analytics aggregates. Raw client IPs, request paths and full User-Agent strings are intentionally excluded."
|
||||
},
|
||||
"include_backends": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
@@ -4584,6 +4589,13 @@
|
||||
"$ref": "#/components/schemas/TrafficTopEntry"
|
||||
}
|
||||
},
|
||||
"user_agent_families": {
|
||||
"type": "array",
|
||||
"description": "Fixed-cardinality User-Agent families used by Prometheus; raw User-Agent top values remain available only in the authenticated Traffic API/UI.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TrafficTopEntry"
|
||||
}
|
||||
},
|
||||
"client_ips": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -4596,6 +4608,20 @@
|
||||
"$ref": "#/components/schemas/TrafficTopEntry"
|
||||
}
|
||||
},
|
||||
"method_families": {
|
||||
"type": "array",
|
||||
"description": "Fixed-cardinality common HTTP method families with all extension methods folded into OTHER.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TrafficTopEntry"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"description": "Fixed serving-source families: reverse_proxy, web_server, local_response or unresolved.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TrafficTopEntry"
|
||||
}
|
||||
},
|
||||
"statuses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -9350,4 +9376,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-17
@@ -66,7 +66,6 @@ async fn cache_purge(
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
async fn cache_invalidate(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
@@ -123,11 +122,18 @@ async fn cache_ban_delete(
|
||||
Path(id): Path<u64>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
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(),
|
||||
));
|
||||
}
|
||||
state
|
||||
.store
|
||||
.audit(&session.username, "cache.ban.remove", &format!("ban_id={id}"))
|
||||
.audit(
|
||||
&session.username,
|
||||
"cache.ban.remove",
|
||||
&format!("ban_id={id}"),
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
|
||||
@@ -164,7 +170,10 @@ async fn kill_tunnel(
|
||||
));
|
||||
}
|
||||
let Some(tunnel) = state.tunnel_registry.kill(id) else {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "unknown active tunnel".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown active tunnel".into(),
|
||||
));
|
||||
};
|
||||
state
|
||||
.store
|
||||
@@ -207,10 +216,12 @@ async fn cache_cold_cleanup_job(
|
||||
State(state): State<Arc<App>>,
|
||||
Path(id): Path<u64>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let job = state
|
||||
.cache
|
||||
.cold_cleanup_job(id)
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "unknown cold-cache cleanup job".into()))?;
|
||||
let job = state.cache.cold_cleanup_job(id).ok_or_else(|| {
|
||||
ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown cold-cache cleanup job".into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(json!({"job": job})))
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
@@ -268,7 +279,6 @@ async fn certificate_upload(
|
||||
Ok(Json(json!({"certificate":certificate, "staged":true})))
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateApiRotate {
|
||||
@@ -339,12 +349,7 @@ async fn certificate_api_rotate(
|
||||
let token_hash = auth::secret_hash(&token);
|
||||
let credential = state
|
||||
.store
|
||||
.upsert_certificate_api_credential(
|
||||
&key_id,
|
||||
&token_hash,
|
||||
&comment,
|
||||
&session.username,
|
||||
)
|
||||
.upsert_certificate_api_credential(&key_id, &token_hash, &comment, &session.username)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.to_string().contains("credential limit reached") {
|
||||
@@ -371,7 +376,10 @@ async fn certificate_api_revoke(
|
||||
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 revoked = state
|
||||
.store
|
||||
@@ -379,7 +387,10 @@ async fn certificate_api_revoke(
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
if !revoked {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"certificate_api.not_found".into(),
|
||||
));
|
||||
}
|
||||
Ok(Json(json!({"revoked": true, "key_id": key_id})))
|
||||
}
|
||||
|
||||
+11
-7
@@ -36,7 +36,8 @@ fn admin_app_javascript() -> &'static str {
|
||||
include_str!("web/app/config-history.js"),
|
||||
include_str!("web/app/runtime.js"),
|
||||
include_str!("web/app/settings.js"),
|
||||
].join("\n")
|
||||
]
|
||||
.join("\n")
|
||||
});
|
||||
ASSET.as_str()
|
||||
}
|
||||
@@ -49,7 +50,8 @@ fn admin_vhosts_javascript() -> &'static str {
|
||||
include_str!("web/vhosts/editor.js"),
|
||||
include_str!("web/vhosts/render.js"),
|
||||
"})();",
|
||||
].join("\n")
|
||||
]
|
||||
.join("\n")
|
||||
});
|
||||
ASSET.as_str()
|
||||
}
|
||||
@@ -449,9 +451,11 @@ fn certificate_api_operation(method: &Method, path: &str) -> bool {
|
||||
if method != Method::POST {
|
||||
return false;
|
||||
}
|
||||
matches!(path, "/api/certificates/deploy" | "/api/certificates/reload")
|
||||
|| path
|
||||
.strip_prefix("/api/certificates/")
|
||||
.and_then(|tail| tail.strip_suffix("/reload"))
|
||||
.is_some_and(crate::config::valid_id)
|
||||
matches!(
|
||||
path,
|
||||
"/api/certificates/deploy" | "/api/certificates/reload"
|
||||
) || path
|
||||
.strip_prefix("/api/certificates/")
|
||||
.and_then(|tail| tail.strip_suffix("/reload"))
|
||||
.is_some_and(crate::config::valid_id)
|
||||
}
|
||||
@@ -45,7 +45,10 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
|
||||
Ok(None) => None,
|
||||
Ok(Some((key_id, token))) => {
|
||||
if peer.is_some_and(|peer| !peer.0.ip().is_loopback()) && scheme != "https" {
|
||||
return secure(reject(StatusCode::FORBIDDEN, "certificate_api.https_required"));
|
||||
return secure(reject(
|
||||
StatusCode::FORBIDDEN,
|
||||
"certificate_api.https_required",
|
||||
));
|
||||
}
|
||||
let token_hash = auth::secret_hash(&token);
|
||||
match state
|
||||
|
||||
+14
-4
@@ -158,8 +158,8 @@ fn status_payload(state: &App) -> Value {
|
||||
payload["transform_resources"] = json!(&transform_resources);
|
||||
payload["geoip"] = json!(state.geoip.view());
|
||||
let system = state.diagnostics.view();
|
||||
let cache_ram_bytes = (cache_view.hot_allocated_bytes as u64)
|
||||
.saturating_add(cache_view.transient_bytes);
|
||||
let cache_ram_bytes =
|
||||
(cache_view.hot_allocated_bytes as u64).saturating_add(cache_view.transient_bytes);
|
||||
let non_cache_rss_bytes = system
|
||||
.process_rss_bytes
|
||||
.map(|rss| rss.saturating_sub(cache_ram_bytes));
|
||||
@@ -359,7 +359,12 @@ async fn config_history(
|
||||
.configuration_revision(revision)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "configuration revision not found".into()))?;
|
||||
.ok_or_else(|| {
|
||||
ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"configuration revision not found".into(),
|
||||
)
|
||||
})?;
|
||||
return Ok(Json(json!({"revision": detail})));
|
||||
}
|
||||
let limit = query.limit.unwrap_or(512).clamp(1, 512);
|
||||
@@ -393,7 +398,12 @@ async fn restore_config_history(
|
||||
.configuration_revision(input.revision)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "configuration revision not found".into()))?;
|
||||
.ok_or_else(|| {
|
||||
ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"configuration revision not found".into(),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.restore_as(
|
||||
Update {
|
||||
|
||||
@@ -390,6 +390,8 @@ function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefre
|
||||
function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); }
|
||||
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
|
||||
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
|
||||
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
|
||||
$("about-close").addEventListener("click", () => $("about-dialog").close());
|
||||
$("language").addEventListener("change", () => { language = $("language").value; try { localStorage.setItem("rp-language", language); } catch { } translate(); });
|
||||
$("ui-theme").addEventListener("change", async () => {
|
||||
const select = $("ui-theme"), previousTheme = adminPreferences?.theme || document.documentElement.dataset.theme || "dark", nextTheme = normalizeAdminTheme(select.value);
|
||||
@@ -398,8 +400,17 @@ $("ui-theme").addEventListener("change", async () => {
|
||||
catch (error) { applyAdminTheme(previousTheme); if (error.status !== 401) toast(error.message, true); }
|
||||
finally { select.disabled = false; }
|
||||
});
|
||||
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => setPanel(button.dataset.panel)));
|
||||
window.addEventListener("popstate", () => setPanel(panelFromLocation(), false));
|
||||
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
|
||||
function setMobileMenu(open) {
|
||||
if (!mobileMenuToggle || !dashboardSidebar) return;
|
||||
dashboardSidebar.classList.toggle("mobile-nav-open", open);
|
||||
mobileMenuToggle.setAttribute("aria-expanded", String(open));
|
||||
}
|
||||
mobileMenuToggle?.addEventListener("click", event => { event.stopPropagation(); setMobileMenu(!dashboardSidebar.classList.contains("mobile-nav-open")); });
|
||||
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => { setPanel(button.dataset.panel); setMobileMenu(false); }));
|
||||
document.addEventListener("click", event => { if (dashboardSidebar?.classList.contains("mobile-nav-open") && !dashboardSidebar.contains(event.target)) setMobileMenu(false); });
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
|
||||
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); });
|
||||
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
|
||||
$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } });
|
||||
$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } });
|
||||
|
||||
@@ -6,7 +6,7 @@ function renderSettings() {
|
||||
if (!settingsDirty.has("response-form")) {
|
||||
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
||||
$("expose-request-id").checked = !!diagnostics.request_id;
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.4";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.5";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ main {
|
||||
background: #101923;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.docs-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -312,7 +313,7 @@ main {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
|
||||
}
|
||||
|
||||
.markdown-reader :not(pre) > code {
|
||||
.markdown-reader :not(pre)>code {
|
||||
padding: 2px 4px;
|
||||
background: #edf1f4
|
||||
}
|
||||
@@ -342,4 +343,4 @@ main {
|
||||
.markdown-reader {
|
||||
padding: 18px
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -1,5 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -10,10 +11,12 @@
|
||||
<script defer src="/docs/swagger-ui-bundle.js"></script>
|
||||
<script defer src="/docs/init.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="docs-bar"><a class="docs-name" href="/">RustProxy</a>
|
||||
<h1 data-i18n="docsTitle">Documentation</h1>
|
||||
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout" data-i18n="docsLogout">Sign out</button>
|
||||
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout"
|
||||
data-i18n="docsLogout">Sign out</button>
|
||||
</header>
|
||||
<main>
|
||||
<section class="docs-session">
|
||||
@@ -22,14 +25,16 @@
|
||||
</section>
|
||||
<p id="docs-error" role="alert"></p>
|
||||
<nav class="docs-tabs" aria-label="Documentation sections">
|
||||
<button type="button" id="docs-tab-reader" class="active" data-docs-tab="reader" data-i18n="docsReader">Reader</button>
|
||||
<button type="button" id="docs-tab-reader" class="active" data-docs-tab="reader"
|
||||
data-i18n="docsReader">Reader</button>
|
||||
<button type="button" id="docs-tab-api" data-docs-tab="api" data-i18n="docsApi">API</button>
|
||||
</nav>
|
||||
<section id="docs-reader-pane" class="docs-pane">
|
||||
<div class="reader-layout">
|
||||
<aside class="reader-sidebar">
|
||||
<label for="docs-reader-search" data-i18n="docsReaderSearch">Search documentation</label>
|
||||
<input id="docs-reader-search" type="search" autocomplete="off" data-i18n-placeholder="docsReaderSearchPlaceholder" placeholder="Search title or path">
|
||||
<input id="docs-reader-search" type="search" autocomplete="off"
|
||||
data-i18n-placeholder="docsReaderSearchPlaceholder" placeholder="Search title or path">
|
||||
<div id="docs-reader-list" class="reader-list"></div>
|
||||
</aside>
|
||||
<article id="docs-reader-content" class="markdown-reader" aria-live="polite"></article>
|
||||
@@ -37,12 +42,15 @@
|
||||
</section>
|
||||
<section id="docs-api-pane" class="docs-pane" hidden>
|
||||
<section class="docs-api-controls">
|
||||
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write operations</span></label>
|
||||
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed here.</p>
|
||||
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write
|
||||
operations</span></label>
|
||||
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed
|
||||
here.</p>
|
||||
</section>
|
||||
<div id="swagger-ui"></div>
|
||||
<p><a href="/docs/licenses.txt" data-i18n="docsLicenses">Swagger UI licenses</a></p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
+577
-248
File diff suppressed because it is too large.
Load diff
@@ -126,7 +126,7 @@ curl --fail --show-error --request POST \
|
||||
catch (error) { $("certificate-api-status").textContent = error.message; }
|
||||
finally { certificateApiLoading = false; }
|
||||
}
|
||||
const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true };
|
||||
const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_traffic: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true };
|
||||
function example() {
|
||||
const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), env = $("prom-token-env").value.trim();
|
||||
$("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${env ? "\n authorization:\n credentials_file: /etc/prometheus/rustproxy.token" : ""}`;
|
||||
@@ -136,7 +136,7 @@ curl --fail --show-error --request POST \
|
||||
if (!settingsDirty.has("prometheus-form")) {
|
||||
$("prom-enabled").checked = p.enabled; $("prom-listen").value = p.listen; $("prom-path").value = p.path;
|
||||
$("prom-cidrs").value = p.allowed_cidrs.join("\n"); $("prom-token-env").value = p.bearer_token_env || ""; $("prom-trusted-no-token").checked = !!p.allow_trusted_network_without_token;
|
||||
for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["backends", "include_backends"], ["docker-vhosts", "include_docker_vhosts"], ["domains", "include_cache_domains"], ["streams", "include_streams"], ["logging", "include_logging"]]) $(`prom-${id}`).checked = p[key];
|
||||
for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["traffic", "include_traffic"], ["backends", "include_backends"], ["docker-vhosts", "include_docker_vhosts"], ["domains", "include_cache_domains"], ["streams", "include_streams"], ["logging", "include_logging"]]) $(`prom-${id}`).checked = p[key];
|
||||
}
|
||||
const status = latestStatus?.prometheus;
|
||||
$("prom-status").textContent = status?.restart_required ? t("restartPending") : status?.active?.enabled ? `${t("prometheusActive")}: ${status.active.listen}${p.path}` : t("prometheusDisabled"); example();
|
||||
@@ -170,7 +170,7 @@ curl --fail --show-error --request POST \
|
||||
bindSettingsForm("prometheus-form", next => {
|
||||
next.prometheus = {
|
||||
enabled: $("prom-enabled").checked, listen: $("prom-listen").value.trim(), path: $("prom-path").value.trim(), allowed_cidrs: $("prom-cidrs").value.split(/[\n,]/).map(v => v.trim()).filter(Boolean), bearer_token_env: $("prom-token-env").value.trim() || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked,
|
||||
include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_backends: $("prom-backends").checked, include_docker_vhosts: $("prom-docker-vhosts").checked, include_cache_domains: $("prom-domains").checked, include_streams: $("prom-streams").checked, include_logging: $("prom-logging").checked
|
||||
include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_traffic: $("prom-traffic").checked, include_backends: $("prom-backends").checked, include_docker_vhosts: $("prom-docker-vhosts").checked, include_cache_domains: $("prom-domains").checked, include_streams: $("prom-streams").checked, include_logging: $("prom-logging").checked
|
||||
};
|
||||
}, "prometheus");
|
||||
bindSettingsForm("administration-form", next => {
|
||||
|
||||
+230
-28
@@ -3781,7 +3781,7 @@ body[data-density="dense"] .stat strong {
|
||||
max-width: none
|
||||
}
|
||||
|
||||
.settings-page>.edit-form + .edit-form {
|
||||
.settings-page>.edit-form+.edit-form {
|
||||
margin-top: var(--ui-gap)
|
||||
}
|
||||
|
||||
@@ -3920,7 +3920,8 @@ body[data-density="dense"] .stat strong {
|
||||
dialog.vhost-dialog,
|
||||
dialog.logs-dialog,
|
||||
dialog.audit-dialog,
|
||||
dialog.certificate-dialog {
|
||||
dialog.certificate-dialog,
|
||||
dialog.config-history-dialog {
|
||||
border-radius: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
@@ -4692,11 +4693,16 @@ dialog .window-heading {
|
||||
}
|
||||
|
||||
@media(max-width:1100px) {
|
||||
.traffic-wide-chart { grid-column: span 1 }
|
||||
.traffic-wide-chart {
|
||||
grid-column: span 1
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:650px) {
|
||||
.traffic-bar-row { grid-template-columns: minmax(88px, 1.2fr) minmax(90px, 2fr) minmax(60px, auto); gap: 7px }
|
||||
.traffic-bar-row {
|
||||
grid-template-columns: minmax(88px, 1.2fr) minmax(90px, 2fr) minmax(60px, auto);
|
||||
gap: 7px
|
||||
}
|
||||
}
|
||||
|
||||
.logging-grid label {
|
||||
@@ -4720,6 +4726,7 @@ dialog .window-heading {
|
||||
}
|
||||
|
||||
@media(max-width:720px) {
|
||||
|
||||
.traffic-chart-grid,
|
||||
.logging-grid {
|
||||
grid-template-columns: 1fr
|
||||
@@ -6778,7 +6785,6 @@ body[data-density="compact"] .page-footer {
|
||||
}
|
||||
}
|
||||
|
||||
/* Certificates workspace (0.20.0): use the same tab language as Settings. */
|
||||
#panel-tls .certificate-workspace-heading {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px
|
||||
@@ -7031,7 +7037,6 @@ body[data-density="compact"] .page-footer {
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline operation feedback mirrors the login feedback pattern and remains local to the action. */
|
||||
.operation-message:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -7330,7 +7335,6 @@ body[data-density="compact"] .page-footer {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 0.24.3 dashboard refinements */
|
||||
.payload-split {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
@@ -7515,7 +7519,7 @@ body[data-density="compact"] .page-footer {
|
||||
color: var(--notice-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 0.24.8 cache occupancy charts and compact memory-accounting help */
|
||||
|
||||
.mini-heading-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -7607,7 +7611,6 @@ body[data-density="compact"] .page-footer {
|
||||
color: var(--transform-chart);
|
||||
}
|
||||
|
||||
/* Web Service editor mirrors the vhost tabbed configuration layout. */
|
||||
.web-service-pages {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -7628,21 +7631,10 @@ body[data-density="compact"] .page-footer {
|
||||
}
|
||||
|
||||
.config-history-dialog {
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
padding: 0;
|
||||
width: min(1180px, calc(100vw - 24px));
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100dvh - 28px);
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 18px 70px var(--shadow-strong);
|
||||
}
|
||||
|
||||
.config-history-dialog::backdrop {
|
||||
background: var(--shadow-strong);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.config-history-layout {
|
||||
@@ -7667,7 +7659,6 @@ body[data-density="compact"] .page-footer {
|
||||
|
||||
.config-history-month {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 9px;
|
||||
background: var(--panel);
|
||||
}
|
||||
@@ -7695,7 +7686,6 @@ body[data-density="compact"] .page-footer {
|
||||
min-height: 34px;
|
||||
padding: 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: 10px var(--mono);
|
||||
@@ -7769,7 +7759,6 @@ button.config-history-day.is-selected {
|
||||
|
||||
.config-history-change {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
padding: 8px;
|
||||
background: var(--panel);
|
||||
}
|
||||
@@ -7797,9 +7786,222 @@ button.config-history-day.is-selected {
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.config-history-layout { grid-template-columns: 1fr; overflow: auto; }
|
||||
.config-history-browser, .config-history-detail { overflow: visible; }
|
||||
.config-history-change-values { grid-template-columns: 1fr; }
|
||||
.config-history-detail-head { flex-direction: column; }
|
||||
.config-history-detail-actions { align-items: flex-start; text-align: left; }
|
||||
.config-history-layout {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.config-history-browser,
|
||||
.config-history-detail {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.config-history-change-values {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-history-detail-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.config-history-detail-actions {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media(max-width:850px) {
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 60;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.sidebar .brand {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: 600 11px var(--mono);
|
||||
}
|
||||
|
||||
.mobile-menu-toggle:hover,
|
||||
.mobile-menu-toggle[aria-expanded="true"] {
|
||||
color: var(--text);
|
||||
border-color: var(--border-hover);
|
||||
background: var(--nav-active-bg);
|
||||
}
|
||||
|
||||
.mobile-menu-icon {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 15px;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
box-shadow: 0 -5px currentColor, 0 5px currentColor;
|
||||
}
|
||||
|
||||
.sidebar .main-navigation {
|
||||
grid-column: 1 / -1;
|
||||
display: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar.mobile-nav-open .main-navigation {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.sidebar .main-navigation .nav {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar .main-navigation .nav.active {
|
||||
border-color: var(--line);
|
||||
border-left-color: var(--green);
|
||||
background: var(--nav-active-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:600px) {
|
||||
.sidebar .main-navigation .nav span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.about-dialog {
|
||||
width: min(560px, calc(100vw - 24px));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about-dialog .window-heading button {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.about-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.about-product {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.about-product>div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.about-product strong {
|
||||
font: 600 16px var(--mono);
|
||||
}
|
||||
|
||||
.about-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.about-meta>div {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.about-meta dt {
|
||||
margin-bottom: 5px;
|
||||
color: var(--muted);
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: .8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.about-meta dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.about-card>p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.about-repository {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.about-repository span:last-child {
|
||||
color: var(--muted);
|
||||
font: 10px var(--mono);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media(max-width:620px) {
|
||||
.topbar {
|
||||
height: auto;
|
||||
min-height: 46px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.topbar .breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.about-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-repository {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,12 @@
|
||||
liveChartLimit = LIVE_CHART_LIMITS.has(parsed) ? parsed : 60;
|
||||
for (const key of Object.keys(liveChartSamples)) liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit);
|
||||
if ($("transform-live-samples")) $("transform-live-samples").value = String(liveChartLimit);
|
||||
if (persist) { try { localStorage.setItem(LIVE_CHART_STORAGE_KEY, String(liveChartLimit)); } catch {} }
|
||||
if (persist) { try { localStorage.setItem(LIVE_CHART_STORAGE_KEY, String(liveChartLimit)); } catch { } }
|
||||
redrawLiveChart();
|
||||
}
|
||||
function initializeLiveChartLimit() {
|
||||
let saved = 60;
|
||||
try { saved = Number(localStorage.getItem(LIVE_CHART_STORAGE_KEY) || 60); } catch {}
|
||||
try { saved = Number(localStorage.getItem(LIVE_CHART_STORAGE_KEY) || 60); } catch { }
|
||||
setLiveChartLimit(saved, false);
|
||||
}
|
||||
function renderLiveChart(counters) {
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ use argon2::{
|
||||
Algorithm, Argon2, Params, Version,
|
||||
};
|
||||
pub use files::{
|
||||
load_or_create_setup_token, load_setup_token, lock_directory, prepare_database, try_lock_directory,
|
||||
prepare_directory,
|
||||
load_or_create_setup_token, load_setup_token, lock_directory, prepare_database,
|
||||
prepare_directory, try_lock_directory,
|
||||
};
|
||||
use http::{header::COOKIE, HeaderMap, StatusCode};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
|
||||
+6
-5
@@ -68,10 +68,7 @@ impl Store {
|
||||
self.create_session_with_lifetime(generation, "browser", previous, session_seconds, 60)
|
||||
.await
|
||||
}
|
||||
pub async fn create_live_log_session(
|
||||
&self,
|
||||
generation: i64,
|
||||
) -> Result<Option<SessionToken>> {
|
||||
pub async fn create_live_log_session(&self, generation: i64) -> Result<Option<SessionToken>> {
|
||||
self.create_session_with_lifetime(generation, "cli", None, SESSION_SECONDS, 8 * 60 * 60)
|
||||
.await
|
||||
}
|
||||
@@ -87,7 +84,11 @@ impl Store {
|
||||
let secret = random_secret();
|
||||
let hash = secret_hash(&secret);
|
||||
let csrf = random_secret();
|
||||
let lifetime = if kind == "cli" { cli_lifetime } else { browser_lifetime };
|
||||
let lifetime = if kind == "cli" {
|
||||
cli_lifetime
|
||||
} else {
|
||||
browser_lifetime
|
||||
};
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let valid = sqlx::query(
|
||||
"UPDATE administrator SET generation = generation WHERE id = 1 AND generation = ?",
|
||||
|
||||
+35
-9
@@ -144,7 +144,10 @@ fn harden_tree(root: &Path, path: &Path) -> Result<()> {
|
||||
if metadata.file_type().is_symlink() {
|
||||
let resolved = fs::canonicalize(path).context("restored symlink target is missing")?;
|
||||
let canonical_root = fs::canonicalize(root)?;
|
||||
ensure!(resolved.starts_with(&canonical_root), "restored symlink escapes the data directory");
|
||||
ensure!(
|
||||
resolved.starts_with(&canonical_root),
|
||||
"restored symlink escapes the data directory"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
@@ -164,7 +167,10 @@ fn harden_tree(root: &Path, path: &Path) -> Result<()> {
|
||||
fn private_dir(path: &Path) -> Result<()> {
|
||||
fs::create_dir_all(path)?;
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "backup directory must not be a symlink");
|
||||
ensure!(
|
||||
meta.is_dir() && !meta.file_type().is_symlink(),
|
||||
"backup directory must not be a symlink"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -337,13 +343,19 @@ fn encrypt_archive(input: &Path, output: &Path, crypto: &CryptoContext) -> Resul
|
||||
|
||||
fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let metadata = fs::symlink_metadata(input)?;
|
||||
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup must be a regular file");
|
||||
ensure!(
|
||||
metadata.is_file() && !metadata.file_type().is_symlink(),
|
||||
"backup must be a regular file"
|
||||
);
|
||||
let mut source = File::open(input)?;
|
||||
let mut magic = [0u8; 8];
|
||||
source.read_exact(&mut magic)?;
|
||||
ensure!(&magic == BACKUP_MAGIC, "not a RustProxy backup");
|
||||
let header_len = read_u32(&mut source)? as usize;
|
||||
ensure!(header_len > 0 && header_len <= MAX_HEADER_BYTES, "invalid backup header length");
|
||||
ensure!(
|
||||
header_len > 0 && header_len <= MAX_HEADER_BYTES,
|
||||
"invalid backup header length"
|
||||
);
|
||||
let mut header_bytes = vec![0u8; header_len];
|
||||
source.read_exact(&mut header_bytes)?;
|
||||
let crypto = parse_crypto_header(header_bytes, password)?;
|
||||
@@ -372,7 +384,10 @@ fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroiz
|
||||
counter = counter.checked_add(1).context("backup is too large")?;
|
||||
}
|
||||
let mut extra = [0u8; 1];
|
||||
ensure!(source.read(&mut extra)? == 0, "trailing data after encrypted backup");
|
||||
ensure!(
|
||||
source.read(&mut extra)? == 0,
|
||||
"trailing data after encrypted backup"
|
||||
);
|
||||
target.sync_all()?;
|
||||
Ok(crypto.key)
|
||||
}
|
||||
@@ -398,7 +413,10 @@ fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
fn open_master_key(blob: &[u8], backup_key: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
|
||||
ensure!(blob.len() == 4 + 12 + 32 + AES_256_GCM.tag_len(), "invalid wrapped secret store key");
|
||||
ensure!(
|
||||
blob.len() == 4 + 12 + 32 + AES_256_GCM.tag_len(),
|
||||
"invalid wrapped secret store key"
|
||||
);
|
||||
ensure!(&blob[..4] == KEY_MAGIC, "invalid wrapped secret store key");
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
nonce_bytes.copy_from_slice(&blob[4..16]);
|
||||
@@ -416,7 +434,10 @@ fn open_master_key(blob: &[u8], backup_key: &[u8]) -> Result<Zeroizing<Vec<u8>>>
|
||||
|
||||
fn hash_file(path: &Path) -> Result<(u64, String)> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup input must be a regular file");
|
||||
ensure!(
|
||||
metadata.is_file() && !metadata.file_type().is_symlink(),
|
||||
"backup input must be a regular file"
|
||||
);
|
||||
let mut file = File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut size = 0u64;
|
||||
@@ -426,7 +447,9 @@ fn hash_file(path: &Path) -> Result<(u64, String)> {
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
size = size.checked_add(read as u64).context("backup input too large")?;
|
||||
size = size
|
||||
.checked_add(read as u64)
|
||||
.context("backup input too large")?;
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok((size, format!("{:x}", hasher.finalize())))
|
||||
@@ -437,5 +460,8 @@ fn hash_bytes(bytes: &[u8]) -> String {
|
||||
}
|
||||
|
||||
fn excluded_directory(name: &str) -> bool {
|
||||
matches!(name, "cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01")
|
||||
matches!(
|
||||
name,
|
||||
"cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01"
|
||||
)
|
||||
}
|
||||
+64
-21
@@ -29,7 +29,10 @@ fn backup_input(path: &Path) -> Result<File> {
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
ensure!(file.metadata()?.is_file(), "backup input must be a regular file");
|
||||
ensure!(
|
||||
file.metadata()?.is_file(),
|
||||
"backup input must be a regular file"
|
||||
);
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
@@ -65,7 +68,9 @@ fn copy_snapshot_file(source: &Path, target: &Path) -> Result<(u64, String)> {
|
||||
}
|
||||
output.write_all(&buffer[..read])?;
|
||||
hasher.update(&buffer[..read]);
|
||||
size = size.checked_add(read as u64).context("backup input too large")?;
|
||||
size = size
|
||||
.checked_add(read as u64)
|
||||
.context("backup input too large")?;
|
||||
}
|
||||
output.sync_all()?;
|
||||
let after = input.metadata()?;
|
||||
@@ -82,7 +87,10 @@ fn symlink_target_bytes(target: &Path) -> Result<Vec<u8>> {
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let bytes = target.as_os_str().as_bytes().to_vec();
|
||||
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
|
||||
ensure!(
|
||||
!bytes.is_empty() && bytes.len() <= 4096,
|
||||
"invalid symlink target"
|
||||
);
|
||||
return Ok(bytes);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
@@ -92,13 +100,19 @@ fn symlink_target_bytes(target: &Path) -> Result<Vec<u8>> {
|
||||
.context("symlink target must be valid UTF-8")?
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
|
||||
ensure!(
|
||||
!bytes.is_empty() && bytes.len() <= 4096,
|
||||
"invalid symlink target"
|
||||
);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn symlink_target_path(bytes: &[u8]) -> Result<PathBuf> {
|
||||
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
|
||||
ensure!(
|
||||
!bytes.is_empty() && bytes.len() <= 4096,
|
||||
"invalid symlink target"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
@@ -106,22 +120,33 @@ fn symlink_target_path(bytes: &[u8]) -> Result<PathBuf> {
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Ok(PathBuf::from(std::str::from_utf8(bytes).context("symlink target is not UTF-8")?))
|
||||
Ok(PathBuf::from(
|
||||
std::str::from_utf8(bytes).context("symlink target is not UTF-8")?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_symlink_target(link_relative: &Path, target: &Path) -> Result<()> {
|
||||
ensure!(!target.is_absolute(), "backup symlink target must be relative");
|
||||
let mut depth = link_relative.parent().map_or(0usize, |parent| parent.components().count());
|
||||
ensure!(
|
||||
!target.is_absolute(),
|
||||
"backup symlink target must be relative"
|
||||
);
|
||||
let mut depth = link_relative
|
||||
.parent()
|
||||
.map_or(0usize, |parent| parent.components().count());
|
||||
for component in target.components() {
|
||||
match component {
|
||||
Component::Normal(_) => depth = depth.checked_add(1).context("symlink target too deep")?,
|
||||
Component::Normal(_) => {
|
||||
depth = depth.checked_add(1).context("symlink target too deep")?
|
||||
}
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
ensure!(depth > 0, "backup symlink escapes the data directory");
|
||||
depth -= 1;
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => bail!("backup symlink target must be relative"),
|
||||
Component::RootDir | Component::Prefix(_) => {
|
||||
bail!("backup symlink target must be relative")
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -152,7 +177,10 @@ fn collect_data_files(
|
||||
let path = item.path();
|
||||
let name = item.file_name();
|
||||
let name_text = name.to_string_lossy();
|
||||
if excluded_file(&name_text) || excluded_directory(&name_text) || path == root.join("secrets") {
|
||||
if excluded_file(&name_text)
|
||||
|| excluded_directory(&name_text)
|
||||
|| path == root.join("secrets")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let meta = fs::symlink_metadata(&path)?;
|
||||
@@ -215,30 +243,45 @@ fn validate_snapshot_symlinks(snapshot_root: &Path, symlinks: &[PathBuf]) -> Res
|
||||
for link in symlinks {
|
||||
let resolved = fs::canonicalize(link)
|
||||
.with_context(|| format!("backup symlink target is missing: {}", link.display()))?;
|
||||
ensure!(resolved.starts_with(&root), "backup symlink escapes the data directory: {}", link.display());
|
||||
ensure!(
|
||||
resolved.starts_with(&root),
|
||||
"backup symlink escapes the data directory: {}",
|
||||
link.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_snapshot_config(envelope: &crate::config::Envelope, snapshot_root: &Path) -> Result<()> {
|
||||
fn validate_snapshot_config(
|
||||
envelope: &crate::config::Envelope,
|
||||
snapshot_root: &Path,
|
||||
) -> Result<()> {
|
||||
let cert_dir = snapshot_root.join("certs");
|
||||
for certificate in &envelope.config.tls.certificates {
|
||||
for name in [&certificate.cert_file, &certificate.key_file] {
|
||||
crate::tls::safe_name(name)?;
|
||||
let path = cert_dir.join(name);
|
||||
let meta = fs::symlink_metadata(&path)
|
||||
.with_context(|| format!("configured certificate file changed during backup: {name}"))?;
|
||||
ensure!(meta.is_file() && !meta.file_type().is_symlink(), "configured certificate file is not a regular file: {name}");
|
||||
let meta = fs::symlink_metadata(&path).with_context(|| {
|
||||
format!("configured certificate file changed during backup: {name}")
|
||||
})?;
|
||||
ensure!(
|
||||
meta.is_file() && !meta.file_type().is_symlink(),
|
||||
"configured certificate file is not a regular file: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
if envelope.config.administration.tls && envelope.config.administration.certificate_id.is_none() {
|
||||
if envelope.config.administration.tls && envelope.config.administration.certificate_id.is_none()
|
||||
{
|
||||
for name in ["default.pem", "default.key"] {
|
||||
let path = cert_dir.join(name);
|
||||
let meta = fs::symlink_metadata(&path)
|
||||
.with_context(|| format!("default administration certificate changed during backup: {name}"))?;
|
||||
ensure!(meta.is_file() && !meta.file_type().is_symlink(), "default administration certificate is not a regular file: {name}");
|
||||
let meta = fs::symlink_metadata(&path).with_context(|| {
|
||||
format!("default administration certificate changed during backup: {name}")
|
||||
})?;
|
||||
ensure!(
|
||||
meta.is_file() && !meta.file_type().is_symlink(),
|
||||
"default administration certificate is not a regular file: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+5
-4
@@ -68,7 +68,8 @@ mod tests {
|
||||
restore(&archive, &restored, "correct horse battery staple")
|
||||
.await
|
||||
.unwrap();
|
||||
let restored_link = restored.join("certs/acme/production-linuxiarz/config/live/rustproxy/fullchain.pem");
|
||||
let restored_link =
|
||||
restored.join("certs/acme/production-linuxiarz/config/live/rustproxy/fullchain.pem");
|
||||
assert_eq!(
|
||||
fs::read_link(&restored_link).unwrap(),
|
||||
PathBuf::from("../../archive/rustproxy/fullchain1.pem")
|
||||
@@ -88,8 +89,8 @@ mod tests {
|
||||
Path::new("../../../etc/passwd")
|
||||
)
|
||||
.is_err());
|
||||
assert!(validate_symlink_target(Path::new("certs/link.pem"), Path::new("/etc/passwd"))
|
||||
.is_err());
|
||||
assert!(
|
||||
validate_symlink_target(Path::new("certs/link.pem"), Path::new("/etc/passwd")).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
+132
-36
@@ -2,13 +2,19 @@ fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry
|
||||
let mut file = private_file_options().create_new(true).open(path)?;
|
||||
file.write_all(ARCHIVE_MAGIC)?;
|
||||
let manifest_bytes = serde_json::to_vec(manifest)?;
|
||||
ensure!(manifest_bytes.len() <= MAX_MANIFEST_BYTES, "backup manifest is too large");
|
||||
ensure!(
|
||||
manifest_bytes.len() <= MAX_MANIFEST_BYTES,
|
||||
"backup manifest is too large"
|
||||
);
|
||||
file.write_all(&(manifest_bytes.len() as u32).to_be_bytes())?;
|
||||
file.write_all(&manifest_bytes)?;
|
||||
let mut buffer = [0u8; 1024 * 1024];
|
||||
for entry in entries {
|
||||
let path_bytes = entry.manifest.path.as_bytes();
|
||||
ensure!(!path_bytes.is_empty() && path_bytes.len() <= u16::MAX as usize, "backup path is too long");
|
||||
ensure!(
|
||||
!path_bytes.is_empty() && path_bytes.len() <= u16::MAX as usize,
|
||||
"backup path is too long"
|
||||
);
|
||||
file.write_all(&(path_bytes.len() as u16).to_be_bytes())?;
|
||||
file.write_all(path_bytes)?;
|
||||
file.write_all(&entry.manifest.size.to_be_bytes())?;
|
||||
@@ -23,7 +29,10 @@ fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry
|
||||
file.write_all(&buffer[..read])?;
|
||||
remaining -= read as u64;
|
||||
}
|
||||
ensure!(input.read(&mut buffer[..1])? == 0, "backup snapshot changed while being archived");
|
||||
ensure!(
|
||||
input.read(&mut buffer[..1])? == 0,
|
||||
"backup snapshot changed while being archived"
|
||||
);
|
||||
}
|
||||
EntrySource::Bytes(bytes) => file.write_all(bytes)?,
|
||||
}
|
||||
@@ -49,11 +58,17 @@ fn read_u64<R: Read>(reader: &mut R) -> Result<u64> {
|
||||
}
|
||||
|
||||
fn safe_relative(path: &str) -> Result<PathBuf> {
|
||||
ensure!(!path.is_empty() && path.len() <= 4096 && !path.contains('\\') && !path.contains(':'), "unsafe backup path");
|
||||
ensure!(
|
||||
!path.is_empty() && path.len() <= 4096 && !path.contains('\\') && !path.contains(':'),
|
||||
"unsafe backup path"
|
||||
);
|
||||
let value = Path::new(path);
|
||||
ensure!(!value.is_absolute(), "unsafe absolute backup path");
|
||||
for component in value.components() {
|
||||
ensure!(matches!(component, Component::Normal(_)), "unsafe backup path component");
|
||||
ensure!(
|
||||
matches!(component, Component::Normal(_)),
|
||||
"unsafe backup path component"
|
||||
);
|
||||
}
|
||||
Ok(value.to_path_buf())
|
||||
}
|
||||
@@ -61,35 +76,61 @@ fn safe_relative(path: &str) -> Result<PathBuf> {
|
||||
fn validate_manifest_entries(manifest: &Manifest) -> Result<()> {
|
||||
let mut kinds = std::collections::HashMap::new();
|
||||
for entry in &manifest.entries {
|
||||
ensure!(kinds.insert(entry.path.clone(), entry.kind).is_none(), "duplicate backup entry");
|
||||
ensure!(
|
||||
kinds.insert(entry.path.clone(), entry.kind).is_none(),
|
||||
"duplicate backup entry"
|
||||
);
|
||||
if entry.path == "keys/secret-store-key.enc" {
|
||||
ensure!(entry.kind == EntryKind::File, "invalid Secret Store key entry type");
|
||||
ensure!(
|
||||
entry.kind == EntryKind::File,
|
||||
"invalid Secret Store key entry type"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let relative = entry.path.strip_prefix("data/").context("unsupported backup entry")?;
|
||||
let relative = entry
|
||||
.path
|
||||
.strip_prefix("data/")
|
||||
.context("unsupported backup entry")?;
|
||||
safe_relative(relative)?;
|
||||
if manifest.archive_version == 1 {
|
||||
ensure!(entry.kind == EntryKind::File, "legacy backup contains unsupported entry type");
|
||||
ensure!(
|
||||
entry.kind == EntryKind::File,
|
||||
"legacy backup contains unsupported entry type"
|
||||
);
|
||||
}
|
||||
match entry.kind {
|
||||
EntryKind::Directory => ensure!(entry.size == 0 && entry.sha256 == hash_bytes(&[]), "invalid directory backup entry"),
|
||||
EntryKind::Symlink => ensure!(entry.size > 0 && entry.size <= 4096, "invalid symlink backup entry"),
|
||||
EntryKind::Directory => ensure!(
|
||||
entry.size == 0 && entry.sha256 == hash_bytes(&[]),
|
||||
"invalid directory backup entry"
|
||||
),
|
||||
EntryKind::Symlink => ensure!(
|
||||
entry.size > 0 && entry.size <= 4096,
|
||||
"invalid symlink backup entry"
|
||||
),
|
||||
EntryKind::File => {}
|
||||
}
|
||||
}
|
||||
for (path, kind) in &kinds {
|
||||
let Some(relative) = path.strip_prefix("data/") else { continue };
|
||||
let Some(relative) = path.strip_prefix("data/") else {
|
||||
continue;
|
||||
};
|
||||
let relative = Path::new(relative);
|
||||
let mut parent = relative.parent();
|
||||
while let Some(value) = parent.filter(|value| !value.as_os_str().is_empty()) {
|
||||
let ancestor = format!("data/{}", value.to_string_lossy().replace('\\', "/"));
|
||||
if let Some(ancestor_kind) = kinds.get(&ancestor) {
|
||||
ensure!(*ancestor_kind == EntryKind::Directory, "backup entry has a non-directory ancestor");
|
||||
ensure!(
|
||||
*ancestor_kind == EntryKind::Directory,
|
||||
"backup entry has a non-directory ancestor"
|
||||
);
|
||||
}
|
||||
parent = value.parent();
|
||||
}
|
||||
if *kind == EntryKind::Symlink {
|
||||
ensure!(manifest.archive_version >= 2, "legacy backup cannot contain symlinks");
|
||||
ensure!(
|
||||
manifest.archive_version >= 2,
|
||||
"legacy backup cannot contain symlinks"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -99,27 +140,49 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
|
||||
let mut file = File::open(path)?;
|
||||
let mut magic = [0u8; 8];
|
||||
file.read_exact(&mut magic)?;
|
||||
ensure!(&magic == ARCHIVE_MAGIC, "invalid decrypted RustProxy backup payload");
|
||||
ensure!(
|
||||
&magic == ARCHIVE_MAGIC,
|
||||
"invalid decrypted RustProxy backup payload"
|
||||
);
|
||||
let manifest_len = read_u32(&mut file)? as usize;
|
||||
ensure!(manifest_len > 0 && manifest_len <= MAX_MANIFEST_BYTES, "invalid backup manifest length");
|
||||
ensure!(
|
||||
manifest_len > 0 && manifest_len <= MAX_MANIFEST_BYTES,
|
||||
"invalid backup manifest length"
|
||||
);
|
||||
let mut manifest_bytes = vec![0u8; manifest_len];
|
||||
file.read_exact(&mut manifest_bytes)?;
|
||||
let manifest: Manifest = serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?;
|
||||
ensure!((1..=ARCHIVE_VERSION).contains(&manifest.archive_version), "unsupported backup archive version");
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?;
|
||||
ensure!(
|
||||
(1..=ARCHIVE_VERSION).contains(&manifest.archive_version),
|
||||
"unsupported backup archive version"
|
||||
);
|
||||
validate_backup_version(&manifest.rustproxy_version)?;
|
||||
ensure!(manifest.schema_version <= crate::db::SCHEMA_VERSION, "backup database schema is newer than this RustProxy binary");
|
||||
ensure!(manifest.entries.len() <= 100_000, "backup contains too many files");
|
||||
ensure!(
|
||||
manifest.schema_version <= crate::db::SCHEMA_VERSION,
|
||||
"backup database schema is newer than this RustProxy binary"
|
||||
);
|
||||
ensure!(
|
||||
manifest.entries.len() <= 100_000,
|
||||
"backup contains too many files"
|
||||
);
|
||||
validate_manifest_entries(&manifest)?;
|
||||
let mut wrapped_key = None;
|
||||
let mut pending_symlinks = Vec::new();
|
||||
let mut buffer = vec![0u8; 1024 * 1024];
|
||||
for expected in &manifest.entries {
|
||||
let path_len = read_u16(&mut file)? as usize;
|
||||
ensure!(path_len > 0 && path_len <= 4096, "invalid backup path length");
|
||||
ensure!(
|
||||
path_len > 0 && path_len <= 4096,
|
||||
"invalid backup path length"
|
||||
);
|
||||
let mut path_bytes = vec![0u8; path_len];
|
||||
file.read_exact(&mut path_bytes)?;
|
||||
let archive_path = String::from_utf8(path_bytes).context("backup path is not UTF-8")?;
|
||||
ensure!(archive_path == expected.path, "backup manifest entry order/path mismatch");
|
||||
ensure!(
|
||||
archive_path == expected.path,
|
||||
"backup manifest entry order/path mismatch"
|
||||
);
|
||||
let size = read_u64(&mut file)?;
|
||||
ensure!(size == expected.size, "backup manifest size mismatch");
|
||||
let mut hasher = Sha256::new();
|
||||
@@ -130,7 +193,9 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
|
||||
hasher.update(&bytes);
|
||||
wrapped_key = Some(bytes);
|
||||
} else {
|
||||
let relative_text = archive_path.strip_prefix("data/").context("unsupported backup entry")?;
|
||||
let relative_text = archive_path
|
||||
.strip_prefix("data/")
|
||||
.context("unsupported backup entry")?;
|
||||
let relative = safe_relative(relative_text)?;
|
||||
let target = stage.join(&relative);
|
||||
match expected.kind {
|
||||
@@ -158,10 +223,16 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure!(format!("{:x}", hasher.finalize()) == expected.sha256, "backup entry checksum mismatch");
|
||||
ensure!(
|
||||
format!("{:x}", hasher.finalize()) == expected.sha256,
|
||||
"backup entry checksum mismatch"
|
||||
);
|
||||
}
|
||||
let mut extra = [0u8; 1];
|
||||
ensure!(file.read(&mut extra)? == 0, "trailing data in backup payload");
|
||||
ensure!(
|
||||
file.read(&mut extra)? == 0,
|
||||
"trailing data in backup payload"
|
||||
);
|
||||
for (relative, bytes) in pending_symlinks {
|
||||
let target = symlink_target_path(&bytes)?;
|
||||
validate_symlink_target(&relative, &target)?;
|
||||
@@ -172,7 +243,10 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
|
||||
create_symlink(&target, &link)?;
|
||||
}
|
||||
let key = wrapped_key.context("backup is missing Secret Store key material")?;
|
||||
ensure!(stage.join("config.sqlite3").is_file(), "backup is missing database");
|
||||
ensure!(
|
||||
stage.join("config.sqlite3").is_file(),
|
||||
"backup is missing database"
|
||||
);
|
||||
Ok((manifest, key))
|
||||
}
|
||||
|
||||
@@ -180,7 +254,11 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
|
||||
crate::auth::prepare_directory(data_dir)?;
|
||||
let offline_lock = crate::auth::try_lock_directory(data_dir)?;
|
||||
let database = data_dir.join("config.sqlite3");
|
||||
ensure!(database.is_file(), "database does not exist at {}", database.display());
|
||||
ensure!(
|
||||
database.is_file(),
|
||||
"database does not exist at {}",
|
||||
database.display()
|
||||
);
|
||||
crate::auth::prepare_database(&database)?;
|
||||
let store = crate::db::Store::open(&database, &crate::config::Config::default()).await?;
|
||||
crate::secret_store::initialize(data_dir)?;
|
||||
@@ -190,8 +268,12 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
|
||||
}
|
||||
let parent = parent_or_dot(output);
|
||||
let data_root = fs::canonicalize(data_dir).context("cannot resolve data directory")?;
|
||||
let output_parent = fs::canonicalize(parent).context("cannot resolve backup output directory")?;
|
||||
ensure!(!output_parent.starts_with(&data_root), "backup output must be outside the RustProxy data directory");
|
||||
let output_parent =
|
||||
fs::canonicalize(parent).context("cannot resolve backup output directory")?;
|
||||
ensure!(
|
||||
!output_parent.starts_with(&data_root),
|
||||
"backup output must be outside the RustProxy data directory"
|
||||
);
|
||||
let temp = create_temp_root(parent, "rustproxy-backup")?;
|
||||
let snapshot = temp.0.join("config.sqlite3");
|
||||
let snapshot_root = temp.0.join("data-snapshot");
|
||||
@@ -204,9 +286,15 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
|
||||
let envelope = store.backup_to(&snapshot).await?;
|
||||
let mut entries = Vec::new();
|
||||
let mut symlinks = Vec::new();
|
||||
let result = collect_data_files(data_dir, data_dir, &snapshot_root, &mut entries, &mut symlinks)
|
||||
.and_then(|_| validate_snapshot_symlinks(&snapshot_root, &symlinks))
|
||||
.and_then(|_| validate_snapshot_config(&envelope, &snapshot_root));
|
||||
let result = collect_data_files(
|
||||
data_dir,
|
||||
data_dir,
|
||||
&snapshot_root,
|
||||
&mut entries,
|
||||
&mut symlinks,
|
||||
)
|
||||
.and_then(|_| validate_snapshot_symlinks(&snapshot_root, &symlinks))
|
||||
.and_then(|_| validate_snapshot_config(&envelope, &snapshot_root));
|
||||
match result {
|
||||
Ok(()) => {
|
||||
captured = Some((envelope, entries));
|
||||
@@ -216,7 +304,8 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
|
||||
}
|
||||
}
|
||||
let (envelope, mut entries) = captured.ok_or_else(|| {
|
||||
last_error.unwrap_or_else(|| anyhow::anyhow!("could not capture a stable live data snapshot"))
|
||||
last_error
|
||||
.unwrap_or_else(|| anyhow::anyhow!("could not capture a stable live data snapshot"))
|
||||
})?;
|
||||
let crypto = new_crypto(password)?;
|
||||
let master = crate::secret_store::read_master_key_for_backup(data_dir)?;
|
||||
@@ -265,7 +354,10 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
|
||||
|
||||
pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<BackupSummary> {
|
||||
let archive_meta = fs::symlink_metadata(archive).context("backup archive does not exist")?;
|
||||
ensure!(archive_meta.is_file() && !archive_meta.file_type().is_symlink(), "backup archive must be a regular file");
|
||||
ensure!(
|
||||
archive_meta.is_file() && !archive_meta.file_type().is_symlink(),
|
||||
"backup archive must be a regular file"
|
||||
);
|
||||
let parent = parent_or_dot(data_dir);
|
||||
ensure!(parent.is_dir(), "restore parent directory does not exist");
|
||||
let old_lock = if data_dir.exists() {
|
||||
@@ -287,7 +379,10 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
|
||||
let stage_db = stage.join("config.sqlite3");
|
||||
crate::auth::prepare_database(&stage_db)?;
|
||||
let store = crate::db::Store::open(&stage_db, &crate::config::Config::default()).await?;
|
||||
let envelope = store.load().await.context("restored configuration is unreadable")?;
|
||||
let envelope = store
|
||||
.load()
|
||||
.await
|
||||
.context("restored configuration is unreadable")?;
|
||||
crate::secret_store::validate_all(&store, &stage)
|
||||
.await
|
||||
.context("restored Secret Store cannot be decrypted")?;
|
||||
@@ -310,7 +405,8 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
|
||||
}
|
||||
drop(old_lock);
|
||||
if previous.exists() {
|
||||
fs::remove_dir_all(&previous).context("restore succeeded but old data directory could not be removed")?;
|
||||
fs::remove_dir_all(&previous)
|
||||
.context("restore succeeded but old data directory could not be removed")?;
|
||||
}
|
||||
drop(stage_lock);
|
||||
Ok(BackupSummary {
|
||||
|
||||
+42
-15
@@ -24,10 +24,26 @@ impl BackendState {
|
||||
latency_us: 0,
|
||||
ewma_us: 0,
|
||||
transitions: 0,
|
||||
unhealthy_source: if mode == HealthMode::Off { "none" } else { "active" },
|
||||
unhealthy_reason: if mode == HealthMode::Off { "none" } else { "checking" },
|
||||
active_unhealthy_source: if mode == HealthMode::Off { "none" } else { "active" },
|
||||
active_unhealthy_reason: if mode == HealthMode::Off { "none" } else { "checking" },
|
||||
unhealthy_source: if mode == HealthMode::Off {
|
||||
"none"
|
||||
} else {
|
||||
"active"
|
||||
},
|
||||
unhealthy_reason: if mode == HealthMode::Off {
|
||||
"none"
|
||||
} else {
|
||||
"checking"
|
||||
},
|
||||
active_unhealthy_source: if mode == HealthMode::Off {
|
||||
"none"
|
||||
} else {
|
||||
"active"
|
||||
},
|
||||
active_unhealthy_reason: if mode == HealthMode::Off {
|
||||
"none"
|
||||
} else {
|
||||
"checking"
|
||||
},
|
||||
passive_unhealthy_source: "none",
|
||||
passive_unhealthy_reason: "none",
|
||||
}),
|
||||
@@ -169,9 +185,9 @@ impl BackendState {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_source_is_still_unhealthy =
|
||||
(h.unhealthy_source.starts_with("active") && !h.active_healthy)
|
||||
|| (h.unhealthy_source.starts_with("passive") && !h.passive_healthy);
|
||||
let current_source_is_still_unhealthy = (h.unhealthy_source.starts_with("active")
|
||||
&& !h.active_healthy)
|
||||
|| (h.unhealthy_source.starts_with("passive") && !h.passive_healthy);
|
||||
if current_source_is_still_unhealthy {
|
||||
return;
|
||||
}
|
||||
@@ -202,7 +218,8 @@ impl BackendState {
|
||||
h.active_healthy = true;
|
||||
}
|
||||
} else {
|
||||
self.active_health_failures_total.fetch_add(1, Ordering::Relaxed);
|
||||
self.active_health_failures_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
h.successes = 0;
|
||||
h.failures = h.failures.saturating_add(1);
|
||||
if h.failures >= settings.fall {
|
||||
@@ -260,11 +277,21 @@ impl BackendState {
|
||||
|
||||
fn record_passive_observation(&self, reason: &'static str) {
|
||||
match reason {
|
||||
"http_5xx" => { self.http_5xx_total.fetch_add(1, Ordering::Relaxed); }
|
||||
"timeout" => { self.timeouts_total.fetch_add(1, Ordering::Relaxed); }
|
||||
"connect_error" => { self.connect_errors_total.fetch_add(1, Ordering::Relaxed); }
|
||||
"io_error" => { self.io_errors_total.fetch_add(1, Ordering::Relaxed); }
|
||||
"slow_response" => { self.slow_responses_total.fetch_add(1, Ordering::Relaxed); }
|
||||
"http_5xx" => {
|
||||
self.http_5xx_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
"timeout" => {
|
||||
self.timeouts_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
"connect_error" => {
|
||||
self.connect_errors_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
"io_error" => {
|
||||
self.io_errors_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
"slow_response" => {
|
||||
self.slow_responses_total.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -301,7 +328,8 @@ impl BackendState {
|
||||
if !passive.counts(reason) {
|
||||
return;
|
||||
}
|
||||
self.passive_health_failures_total.fetch_add(1, Ordering::Relaxed);
|
||||
self.passive_health_failures_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
h.passive_successes = 0;
|
||||
h.passive_failures = h.passive_failures.saturating_add(1);
|
||||
if h.passive_failures >= passive.fall(active) {
|
||||
@@ -325,7 +353,6 @@ impl BackendState {
|
||||
}
|
||||
self.finish_transition(&mut h, previous);
|
||||
}
|
||||
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Member {
|
||||
|
||||
@@ -26,7 +26,9 @@ impl Lease {
|
||||
|| !self.passive_health.count_slow_responses)
|
||||
{
|
||||
if is_slow {
|
||||
self.member.state.record_passive_observation("slow_response");
|
||||
self.member
|
||||
.state
|
||||
.record_passive_observation("slow_response");
|
||||
}
|
||||
"http_5xx"
|
||||
} else if is_slow {
|
||||
@@ -62,7 +64,10 @@ impl Lease {
|
||||
self.member.state.record_failure(class, detail);
|
||||
let passive_reason = match class {
|
||||
"connect" | "dns" | "tls" => "connect_error",
|
||||
"timeout" | "body_timeout" | "response_header_timeout" | "read_timeout"
|
||||
"timeout"
|
||||
| "body_timeout"
|
||||
| "response_header_timeout"
|
||||
| "read_timeout"
|
||||
| "send_timeout" => "timeout",
|
||||
_ => "io_error",
|
||||
};
|
||||
@@ -147,33 +152,102 @@ impl Pool {
|
||||
let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}");
|
||||
}
|
||||
for (metric, value) in [
|
||||
("active_health_failures_total", state.active_health_failures_total.load(Ordering::Relaxed)),
|
||||
("passive_health_failures_total", state.passive_health_failures_total.load(Ordering::Relaxed)),
|
||||
("http_5xx_total", state.http_5xx_total.load(Ordering::Relaxed)),
|
||||
("passive_http_5xx_total", state.http_5xx_total.load(Ordering::Relaxed)),
|
||||
("timeouts_total", state.timeouts_total.load(Ordering::Relaxed)),
|
||||
("passive_timeouts_total", state.timeouts_total.load(Ordering::Relaxed)),
|
||||
("connect_errors_total", state.connect_errors_total.load(Ordering::Relaxed)),
|
||||
("passive_connect_errors_total", state.connect_errors_total.load(Ordering::Relaxed)),
|
||||
("io_errors_total", state.io_errors_total.load(Ordering::Relaxed)),
|
||||
("passive_io_errors_total", state.io_errors_total.load(Ordering::Relaxed)),
|
||||
("slow_responses_total", state.slow_responses_total.load(Ordering::Relaxed)),
|
||||
("passive_slow_total", state.slow_responses_total.load(Ordering::Relaxed)),
|
||||
("retry_attempts_total", state.retry_attempts_total.load(Ordering::Relaxed)),
|
||||
("retry_successes_total", state.retry_successes_total.load(Ordering::Relaxed)),
|
||||
("capacity_rejections_total", state.capacity_rejections_total.load(Ordering::Relaxed)),
|
||||
("queue_timeouts_total", state.queue_timeouts_total.load(Ordering::Relaxed)),
|
||||
("dns_errors_total", state.dns_errors_total.load(Ordering::Relaxed)),
|
||||
("tls_errors_total", state.tls_errors_total.load(Ordering::Relaxed)),
|
||||
("response_header_timeouts_total", state.response_header_timeouts_total.load(Ordering::Relaxed)),
|
||||
("read_timeouts_total", state.read_timeouts_total.load(Ordering::Relaxed)),
|
||||
("send_timeouts_total", state.send_timeouts_total.load(Ordering::Relaxed)),
|
||||
(
|
||||
"active_health_failures_total",
|
||||
state.active_health_failures_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"passive_health_failures_total",
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"http_5xx_total",
|
||||
state.http_5xx_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"passive_http_5xx_total",
|
||||
state.http_5xx_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"timeouts_total",
|
||||
state.timeouts_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"passive_timeouts_total",
|
||||
state.timeouts_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"connect_errors_total",
|
||||
state.connect_errors_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"passive_connect_errors_total",
|
||||
state.connect_errors_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"io_errors_total",
|
||||
state.io_errors_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"passive_io_errors_total",
|
||||
state.io_errors_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"slow_responses_total",
|
||||
state.slow_responses_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"passive_slow_total",
|
||||
state.slow_responses_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"retry_attempts_total",
|
||||
state.retry_attempts_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"retry_successes_total",
|
||||
state.retry_successes_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"capacity_rejections_total",
|
||||
state.capacity_rejections_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"queue_timeouts_total",
|
||||
state.queue_timeouts_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"dns_errors_total",
|
||||
state.dns_errors_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"tls_errors_total",
|
||||
state.tls_errors_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"response_header_timeouts_total",
|
||||
state.response_header_timeouts_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"read_timeouts_total",
|
||||
state.read_timeouts_total.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"send_timeouts_total",
|
||||
state.send_timeouts_total.load(Ordering::Relaxed),
|
||||
),
|
||||
] {
|
||||
let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}");
|
||||
}
|
||||
for (metric, value) in [
|
||||
("last_ejected_timestamp_milliseconds", state.last_ejected_ms.load(Ordering::Relaxed)),
|
||||
("last_recovered_timestamp_milliseconds", state.last_recovered_ms.load(Ordering::Relaxed)),
|
||||
(
|
||||
"last_ejected_timestamp_milliseconds",
|
||||
state.last_ejected_ms.load(Ordering::Relaxed),
|
||||
),
|
||||
(
|
||||
"last_recovered_timestamp_milliseconds",
|
||||
state.last_recovered_ms.load(Ordering::Relaxed),
|
||||
),
|
||||
] {
|
||||
let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}");
|
||||
}
|
||||
|
||||
+12
-3
@@ -241,10 +241,16 @@ impl Settings {
|
||||
let _: HeaderValue = host.parse()?;
|
||||
}
|
||||
if let Some(fall) = self.passive_health.fall {
|
||||
ensure!((1..=20).contains(&fall), "passive health fall must be 1..20");
|
||||
ensure!(
|
||||
(1..=20).contains(&fall),
|
||||
"passive health fall must be 1..20"
|
||||
);
|
||||
}
|
||||
if let Some(rise) = self.passive_health.rise {
|
||||
ensure!((1..=20).contains(&rise), "passive health rise must be 1..20");
|
||||
ensure!(
|
||||
(1..=20).contains(&rise),
|
||||
"passive health rise must be 1..20"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
(1..=3600).contains(&k.idle_timeout_seconds)
|
||||
@@ -252,7 +258,10 @@ impl Settings {
|
||||
&& (1..=64).contains(&k.max_idle_per_host),
|
||||
"invalid keepalive limits"
|
||||
);
|
||||
ensure!(self.retry.max_retries <= 3, "retry max_retries must be 0..3");
|
||||
ensure!(
|
||||
self.retry.max_retries <= 3,
|
||||
"retry max_retries must be 0..3"
|
||||
);
|
||||
ensure!(
|
||||
self.retry.retry_statuses.len() <= 8
|
||||
&& self
|
||||
|
||||
@@ -53,16 +53,14 @@ impl Pool {
|
||||
};
|
||||
let same_origin = serde_json::to_vec(&member.definition.backend()).ok()
|
||||
== serde_json::to_vec(&old.definition.backend()).ok();
|
||||
let same_health = serde_json::to_vec(&(
|
||||
&self.settings.health_check,
|
||||
&self.settings.passive_health,
|
||||
))
|
||||
.ok()
|
||||
== serde_json::to_vec(&(
|
||||
&previous.settings.health_check,
|
||||
&previous.settings.passive_health,
|
||||
))
|
||||
.ok();
|
||||
let same_health =
|
||||
serde_json::to_vec(&(&self.settings.health_check, &self.settings.passive_health))
|
||||
.ok()
|
||||
== serde_json::to_vec(&(
|
||||
&previous.settings.health_check,
|
||||
&previous.settings.passive_health,
|
||||
))
|
||||
.ok();
|
||||
if same_origin && same_health && (old.definition.enabled || !member.definition.enabled)
|
||||
{
|
||||
// This pool has not been published or cloned into routes yet.
|
||||
|
||||
@@ -31,15 +31,24 @@ impl Pool {
|
||||
"max_concurrent_requests".into(),
|
||||
json!(m.definition.max_concurrent_requests),
|
||||
);
|
||||
item.insert("queue_timeout_ms".into(), json!(m.definition.queue_timeout_ms));
|
||||
item.insert(
|
||||
"queue_timeout_ms".into(),
|
||||
json!(m.definition.queue_timeout_ms),
|
||||
);
|
||||
item.insert("status".into(), json!(status));
|
||||
item.insert("healthy".into(), json!(h.healthy));
|
||||
item.insert("active".into(), json!(m.state.active.load(Ordering::Relaxed)));
|
||||
item.insert(
|
||||
"active".into(),
|
||||
json!(m.state.active.load(Ordering::Relaxed)),
|
||||
);
|
||||
item.insert(
|
||||
"requests".into(),
|
||||
json!(m.state.requests.load(Ordering::Relaxed)),
|
||||
);
|
||||
item.insert("errors".into(), json!(m.state.errors.load(Ordering::Relaxed)));
|
||||
item.insert(
|
||||
"errors".into(),
|
||||
json!(m.state.errors.load(Ordering::Relaxed)),
|
||||
);
|
||||
item.insert(
|
||||
"bytes_in".into(),
|
||||
json!(m.state.bytes_in.load(Ordering::Relaxed)),
|
||||
@@ -48,7 +57,10 @@ impl Pool {
|
||||
"bytes_out".into(),
|
||||
json!(m.state.bytes_out.load(Ordering::Relaxed)),
|
||||
);
|
||||
item.insert("traffic_bytes_per_second".into(), json!(m.state.traffic_rate()));
|
||||
item.insert(
|
||||
"traffic_bytes_per_second".into(),
|
||||
json!(m.state.traffic_rate()),
|
||||
);
|
||||
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));
|
||||
@@ -107,7 +119,10 @@ impl Pool {
|
||||
);
|
||||
item.insert(
|
||||
"response_header_timeouts_total".into(),
|
||||
json!(m.state.response_header_timeouts_total.load(Ordering::Relaxed)),
|
||||
json!(m
|
||||
.state
|
||||
.response_header_timeouts_total
|
||||
.load(Ordering::Relaxed)),
|
||||
);
|
||||
item.insert(
|
||||
"read_timeouts_total".into(),
|
||||
@@ -134,7 +149,10 @@ impl Pool {
|
||||
);
|
||||
item.insert(
|
||||
"passive_health_failures_total".into(),
|
||||
json!(m.state.passive_health_failures_total.load(Ordering::Relaxed)),
|
||||
json!(m
|
||||
.state
|
||||
.passive_health_failures_total
|
||||
.load(Ordering::Relaxed)),
|
||||
);
|
||||
item.insert(
|
||||
"last_status".into(),
|
||||
|
||||
+379
-369
@@ -1,14 +1,14 @@
|
||||
use super::*;
|
||||
fn settings(mode: HealthMode) -> HealthCheck {
|
||||
HealthCheck {
|
||||
mode,
|
||||
fall: 2,
|
||||
rise: 2,
|
||||
..Default::default()
|
||||
}
|
||||
use super::*;
|
||||
fn settings(mode: HealthMode) -> HealthCheck {
|
||||
HealthCheck {
|
||||
mode,
|
||||
fall: 2,
|
||||
rise: 2,
|
||||
..Default::default()
|
||||
}
|
||||
fn pool(algorithm: Algorithm, sticky: StickyMode) -> Arc<Pool> {
|
||||
let cfg = serde_json::from_value(serde_json::json!({
|
||||
}
|
||||
fn pool(algorithm: Algorithm, sticky: StickyMode) -> Arc<Pool> {
|
||||
let cfg = serde_json::from_value(serde_json::json!({
|
||||
"listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]},
|
||||
"vhosts":[{
|
||||
"id":"app","frontend":{"hosts":["app.localhost"],"listener_ids":["http"]},
|
||||
@@ -16,378 +16,388 @@
|
||||
{"id":"b","url":"http://127.0.0.1:9001","allow_private":true,"weight":1}],
|
||||
"load_balancing":{"algorithm":algorithm,"sticky":{"mode":sticky},"slow_start_seconds":0,"health_check":{"mode":"off"}}
|
||||
}]})).unwrap();
|
||||
let policy = crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone()
|
||||
}
|
||||
fn capacity_pool(limit: u32, queue_timeout_ms: u64) -> Arc<Pool> {
|
||||
let cfg = serde_json::from_value(serde_json::json!({
|
||||
"listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]},
|
||||
"vhosts":[{
|
||||
"id":"limited","frontend":{"hosts":["limited.localhost"],"listener_ids":["http"]},
|
||||
"backends":[{
|
||||
"id":"only","url":"http://127.0.0.1:9000","allow_private":true,
|
||||
"max_concurrent_requests":limit,"queue_timeout_ms":queue_timeout_ms
|
||||
}],
|
||||
"load_balancing":{"health_check":{"mode":"off"}}
|
||||
}]
|
||||
})).unwrap();
|
||||
let policy = crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone()
|
||||
}
|
||||
fn select(pool: &Pool) -> Selection {
|
||||
pool.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), false)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn default_health_check_uses_tcp() {
|
||||
assert_eq!(HealthMode::default(), HealthMode::Tcp);
|
||||
assert_eq!(HealthCheck::default().mode, HealthMode::Tcp);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_settings_without_passive_health_default_to_off() {
|
||||
let parsed: Settings = serde_json::from_value(serde_json::json!({
|
||||
"health_check": { "mode": "off" }
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!parsed.passive_health.enabled);
|
||||
assert!(parsed.passive_health.count_connect_errors);
|
||||
assert!(parsed.passive_health.count_timeouts);
|
||||
assert!(parsed.passive_health.count_io_errors);
|
||||
assert!(parsed.passive_health.count_http_5xx);
|
||||
assert!(!parsed.passive_health.count_slow_responses);
|
||||
assert_eq!(parsed.retry.max_retries, 1);
|
||||
assert!(parsed.retry.retry_connect_errors);
|
||||
assert!(parsed.retry.retry_timeouts);
|
||||
assert_eq!(parsed.retry.retry_statuses, vec![502, 503, 504]);
|
||||
}
|
||||
let policy = crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone()
|
||||
}
|
||||
fn capacity_pool(limit: u32, queue_timeout_ms: u64) -> Arc<Pool> {
|
||||
let cfg = serde_json::from_value(serde_json::json!({
|
||||
"listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]},
|
||||
"vhosts":[{
|
||||
"id":"limited","frontend":{"hosts":["limited.localhost"],"listener_ids":["http"]},
|
||||
"backends":[{
|
||||
"id":"only","url":"http://127.0.0.1:9000","allow_private":true,
|
||||
"max_concurrent_requests":limit,"queue_timeout_ms":queue_timeout_ms
|
||||
}],
|
||||
"load_balancing":{"health_check":{"mode":"off"}}
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
let policy = crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone()
|
||||
}
|
||||
fn select(pool: &Pool) -> Selection {
|
||||
pool.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), false)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn default_health_check_uses_tcp() {
|
||||
assert_eq!(HealthMode::default(), HealthMode::Tcp);
|
||||
assert_eq!(HealthCheck::default().mode, HealthMode::Tcp);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_settings_without_passive_health_default_to_off() {
|
||||
let parsed: Settings = serde_json::from_value(serde_json::json!({
|
||||
"health_check": { "mode": "off" }
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!parsed.passive_health.enabled);
|
||||
assert!(parsed.passive_health.count_connect_errors);
|
||||
assert!(parsed.passive_health.count_timeouts);
|
||||
assert!(parsed.passive_health.count_io_errors);
|
||||
assert!(parsed.passive_health.count_http_5xx);
|
||||
assert!(!parsed.passive_health.count_slow_responses);
|
||||
assert_eq!(parsed.retry.max_retries, 1);
|
||||
assert!(parsed.retry.retry_connect_errors);
|
||||
assert!(parsed.retry.retry_timeouts);
|
||||
assert_eq!(parsed.retry.retry_statuses, vec![502, 503, 504]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_policy_rejects_unbounded_or_non_5xx_configuration() {
|
||||
let mut settings = Settings::default();
|
||||
settings.retry.max_retries = 4;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.retry.max_retries = 1;
|
||||
settings.retry.retry_statuses = vec![429];
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn retry_policy_rejects_unbounded_or_non_5xx_configuration() {
|
||||
let mut settings = Settings::default();
|
||||
settings.retry.max_retries = 4;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.retry.max_retries = 1;
|
||||
settings.retry.retry_statuses = vec![429];
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traffic_rate_drops_to_zero_after_an_idle_sample_window() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
state.bytes_out.store(2048, std::sync::atomic::Ordering::Relaxed);
|
||||
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
|
||||
state.sample_traffic();
|
||||
assert!(state.traffic_rate() > 0.0);
|
||||
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
|
||||
state.sample_traffic();
|
||||
assert_eq!(state.traffic_rate(), 0.0);
|
||||
#[test]
|
||||
fn traffic_rate_drops_to_zero_after_an_idle_sample_window() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
state
|
||||
.bytes_out
|
||||
.store(2048, std::sync::atomic::Ordering::Relaxed);
|
||||
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
|
||||
state.sample_traffic();
|
||||
assert!(state.traffic_rate() > 0.0);
|
||||
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
|
||||
state.sample_traffic();
|
||||
assert_eq!(state.traffic_rate(), 0.0);
|
||||
}
|
||||
#[test]
|
||||
fn active_failures_are_independent_from_passive_user_traffic() {
|
||||
let state = BackendState::new(HealthMode::Http);
|
||||
let settings = settings(HealthMode::Http);
|
||||
let passive = PassiveHealth::default();
|
||||
let fast = Duration::from_millis(5);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
state.observe_active(&settings, "status", fast);
|
||||
state.observe_passive(&settings, &passive, "ok", fast);
|
||||
state.observe_active(&settings, "status", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&settings, &passive, "http_5xx", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_health_is_opt_in_and_uses_separate_thresholds() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let fast = Duration::from_millis(5);
|
||||
for _ in 0..10 {
|
||||
state.observe_passive(&active, &PassiveHealth::default(), "http_5xx", fast);
|
||||
}
|
||||
#[test]
|
||||
fn active_failures_are_independent_from_passive_user_traffic() {
|
||||
let state = BackendState::new(HealthMode::Http);
|
||||
let settings = settings(HealthMode::Http);
|
||||
let passive = PassiveHealth::default();
|
||||
let fast = Duration::from_millis(5);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
state.observe_active(&settings, "status", fast);
|
||||
state.observe_passive(&settings, &passive, "ok", fast);
|
||||
state.observe_active(&settings, "status", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&settings, &passive, "http_5xx", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 10);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
fall: Some(2),
|
||||
rise: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
assert!(state.healthy());
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
#[test]
|
||||
fn passive_http_5xx_can_be_explicitly_ignored_when_passive_health_is_on() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_http_5xx: false,
|
||||
fall: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..100 {
|
||||
state.observe_passive(&active, &passive, "http_5xx", Duration::from_millis(5));
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 100);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_connect_errors_use_the_explicit_fall_threshold() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_connect_errors: true,
|
||||
fall: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..2 {
|
||||
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(5));
|
||||
assert!(state.healthy());
|
||||
}
|
||||
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(5));
|
||||
assert!(!state.healthy());
|
||||
assert_eq!(state.connect_errors_total.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_health_is_opt_in_and_uses_separate_thresholds() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let fast = Duration::from_millis(5);
|
||||
for _ in 0..10 {
|
||||
state.observe_passive(&active, &PassiveHealth::default(), "http_5xx", fast);
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 10);
|
||||
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0);
|
||||
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
fall: Some(2),
|
||||
rise: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
#[test]
|
||||
fn active_health_can_stay_healthy_while_passive_health_is_off() {
|
||||
let state = BackendState::new(HealthMode::Tcp);
|
||||
let active = settings(HealthMode::Tcp);
|
||||
let passive = PassiveHealth::default();
|
||||
let fast = Duration::from_millis(5);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
for _ in 0..20 {
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
assert!(state.healthy());
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
#[test]
|
||||
fn passive_http_5xx_can_be_explicitly_ignored_when_passive_health_is_on() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_http_5xx: false,
|
||||
fall: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..100 {
|
||||
state.observe_passive(
|
||||
&active,
|
||||
&passive,
|
||||
"http_5xx",
|
||||
Duration::from_millis(5),
|
||||
);
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 100);
|
||||
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 20);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_connect_errors_use_the_explicit_fall_threshold() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_connect_errors: true,
|
||||
fall: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..2 {
|
||||
state.observe_passive(
|
||||
&active,
|
||||
&passive,
|
||||
"connect_error",
|
||||
Duration::from_millis(5),
|
||||
);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
state.observe_passive(
|
||||
&active,
|
||||
&passive,
|
||||
#[test]
|
||||
fn unhealthy_source_hands_off_when_the_original_health_domain_recovers() {
|
||||
let state = BackendState::new(HealthMode::Tcp);
|
||||
let active = HealthCheck {
|
||||
fall: 1,
|
||||
rise: 1,
|
||||
..settings(HealthMode::Tcp)
|
||||
};
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_http_5xx: true,
|
||||
fall: Some(1),
|
||||
rise: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let fast = Duration::from_millis(5);
|
||||
|
||||
state.observe_active(&active, "ok", fast);
|
||||
state.observe_active(&active, "connect_error", fast);
|
||||
{
|
||||
let health = lock(&state.health);
|
||||
assert_eq!(health.unhealthy_source, "active_tcp");
|
||||
}
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
{
|
||||
let health = lock(&state.health);
|
||||
assert!(!health.healthy);
|
||||
assert_eq!(health.unhealthy_source, "passive_http_5xx");
|
||||
assert_eq!(health.unhealthy_reason, "http_5xx");
|
||||
}
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_checks_never_quarantine_and_probes_are_single_flight() {
|
||||
let off = BackendState::new(HealthMode::Off);
|
||||
let settings = settings(HealthMode::Off);
|
||||
for _ in 0..5 {
|
||||
off.observe_passive(
|
||||
&settings,
|
||||
&PassiveHealth::default(),
|
||||
"connect_error",
|
||||
Duration::from_millis(5),
|
||||
Duration::ZERO,
|
||||
);
|
||||
assert!(!state.healthy());
|
||||
assert_eq!(state.connect_errors_total.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_health_can_stay_healthy_while_passive_health_is_off() {
|
||||
let state = BackendState::new(HealthMode::Tcp);
|
||||
let active = settings(HealthMode::Tcp);
|
||||
let passive = PassiveHealth::default();
|
||||
let fast = Duration::from_millis(5);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
for _ in 0..20 {
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 20);
|
||||
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhealthy_source_hands_off_when_the_original_health_domain_recovers() {
|
||||
let state = BackendState::new(HealthMode::Tcp);
|
||||
let active = HealthCheck {
|
||||
fall: 1,
|
||||
rise: 1,
|
||||
..settings(HealthMode::Tcp)
|
||||
};
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_http_5xx: true,
|
||||
fall: Some(1),
|
||||
rise: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let fast = Duration::from_millis(5);
|
||||
|
||||
state.observe_active(&active, "ok", fast);
|
||||
state.observe_active(&active, "connect_error", fast);
|
||||
{
|
||||
let health = lock(&state.health);
|
||||
assert_eq!(health.unhealthy_source, "active_tcp");
|
||||
}
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
{
|
||||
let health = lock(&state.health);
|
||||
assert!(!health.healthy);
|
||||
assert_eq!(health.unhealthy_source, "passive_http_5xx");
|
||||
assert_eq!(health.unhealthy_reason, "http_5xx");
|
||||
}
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_checks_never_quarantine_and_probes_are_single_flight() {
|
||||
let off = BackendState::new(HealthMode::Off);
|
||||
let settings = settings(HealthMode::Off);
|
||||
for _ in 0..5 {
|
||||
off.observe_passive(&settings, &PassiveHealth::default(), "connect_error", Duration::ZERO);
|
||||
}
|
||||
assert!(off.healthy());
|
||||
assert!(!off.claim_probe(&settings));
|
||||
let on = BackendState::new(HealthMode::Http);
|
||||
let settings = super::tests::settings(HealthMode::Http);
|
||||
assert!(on.claim_probe(&settings));
|
||||
assert!(!on.claim_probe(&settings));
|
||||
on.observe_active(&settings, "ok", Duration::ZERO);
|
||||
assert!(!on.claim_probe(&settings));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn weighted_round_robin_distributes_three_to_one_without_body_collection() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Off);
|
||||
let mut counts = [0, 0];
|
||||
for _ in 0..40 {
|
||||
let selection = select(&pool);
|
||||
counts[usize::from(selection.lease.member.definition.id == "b")] += 1;
|
||||
}
|
||||
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.
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn least_connections_counts_a_lease_until_its_last_owner_drops() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let mut held = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let selection = select(&pool);
|
||||
assert_eq!(selection.lease.member.definition.id, "a");
|
||||
held.push(selection.lease);
|
||||
}
|
||||
let other = select(&pool);
|
||||
assert_eq!(other.lease.member.definition.id, "b");
|
||||
let clone = held[0].clone();
|
||||
held.clear();
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 1);
|
||||
drop(clone);
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failover_selection_never_reuses_an_attempted_backend() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let first = select(&pool);
|
||||
let first_id = first.lease.member.definition.id.clone();
|
||||
drop(first);
|
||||
let retry = pool
|
||||
.select_retry(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
&HeaderMap::new(),
|
||||
false,
|
||||
std::slice::from_ref(&first_id),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(retry.lease.member.definition.id, first_id);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn backend_concurrency_limit_queues_then_rejects_and_recovers() {
|
||||
let pool = capacity_pool(1, 10);
|
||||
let first = select(&pool);
|
||||
let headers = HeaderMap::new();
|
||||
let saturated = pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await;
|
||||
assert!(matches!(saturated, Err(SelectionFailure::Capacity)));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["capacity_rejections_total"], 1);
|
||||
assert_eq!(member["queue_timeouts_total"], 1);
|
||||
assert_eq!(member["active"], 1);
|
||||
drop(first);
|
||||
assert!(pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_capacity_queue_timeout_rejects_without_counting_a_queue_timeout() {
|
||||
let pool = capacity_pool(1, 0);
|
||||
let first = select(&pool);
|
||||
let headers = HeaderMap::new();
|
||||
let saturated = pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await;
|
||||
assert!(matches!(saturated, Err(SelectionFailure::Capacity)));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["capacity_rejections_total"], 1);
|
||||
assert_eq!(member["queue_timeouts_total"], 0);
|
||||
drop(first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_backend_failures_are_exposed_without_losing_aggregate_counters() {
|
||||
let pool = capacity_pool(0, 0);
|
||||
assert!(off.healthy());
|
||||
assert!(!off.claim_probe(&settings));
|
||||
let on = BackendState::new(HealthMode::Http);
|
||||
let settings = super::tests::settings(HealthMode::Http);
|
||||
assert!(on.claim_probe(&settings));
|
||||
assert!(!on.claim_probe(&settings));
|
||||
on.observe_active(&settings, "ok", Duration::ZERO);
|
||||
assert!(!on.claim_probe(&settings));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn weighted_round_robin_distributes_three_to_one_without_body_collection() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Off);
|
||||
let mut counts = [0, 0];
|
||||
for _ in 0..40 {
|
||||
let selection = select(&pool);
|
||||
selection
|
||||
.lease
|
||||
.error_detail("dns", "lookup backend.example failed", Duration::from_millis(7));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["dns_errors_total"], 1);
|
||||
assert_eq!(member["connect_errors_total"], 1);
|
||||
assert_eq!(member["last_failure_class"], "dns");
|
||||
assert_eq!(member["last_failure_detail"], "lookup backend.example failed");
|
||||
assert!(member["last_failure_at_ms"].as_u64().unwrap_or_default() > 0);
|
||||
counts[usize::from(selection.lease.member.definition.id == "b")] += 1;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn traffic_and_adaptive_selection_use_observed_cost_and_weight() {
|
||||
for algorithm in [Algorithm::LeastTraffic, Algorithm::Adaptive] {
|
||||
let pool = pool(algorithm, StickyMode::Off);
|
||||
lock(&pool.members[0].state.traffic).rate = 50_000_000.0;
|
||||
lock(&pool.members[0].state.health).ewma_us = 1_000_000;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn ip_hash_is_stable_and_cannot_be_spoofed_with_forwarded_headers() {
|
||||
let pool = pool(Algorithm::IpHash, StickyMode::Off);
|
||||
let original = select(&pool).lease.member.definition.id.clone();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "10.0.0.99".parse().unwrap());
|
||||
for ip in ["127.0.0.1", "::ffff:127.0.0.1"] {
|
||||
for _ in 0..8 {
|
||||
assert_eq!(
|
||||
pool.select(ip.parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
.lease
|
||||
.member
|
||||
.definition
|
||||
.id,
|
||||
original
|
||||
);
|
||||
}
|
||||
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.
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn least_connections_counts_a_lease_until_its_last_owner_drops() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let mut held = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let selection = select(&pool);
|
||||
assert_eq!(selection.lease.member.definition.id, "a");
|
||||
held.push(selection.lease);
|
||||
}
|
||||
let other = select(&pool);
|
||||
assert_eq!(other.lease.member.definition.id, "b");
|
||||
let clone = held[0].clone();
|
||||
held.clear();
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 1);
|
||||
drop(clone);
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failover_selection_never_reuses_an_attempted_backend() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let first = select(&pool);
|
||||
let first_id = first.lease.member.definition.id.clone();
|
||||
drop(first);
|
||||
let retry = pool
|
||||
.select_retry(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
&HeaderMap::new(),
|
||||
false,
|
||||
std::slice::from_ref(&first_id),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(retry.lease.member.definition.id, first_id);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn backend_concurrency_limit_queues_then_rejects_and_recovers() {
|
||||
let pool = capacity_pool(1, 10);
|
||||
let first = select(&pool);
|
||||
let headers = HeaderMap::new();
|
||||
let saturated = pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await;
|
||||
assert!(matches!(saturated, Err(SelectionFailure::Capacity)));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["capacity_rejections_total"], 1);
|
||||
assert_eq!(member["queue_timeouts_total"], 1);
|
||||
assert_eq!(member["active"], 1);
|
||||
drop(first);
|
||||
assert!(pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_capacity_queue_timeout_rejects_without_counting_a_queue_timeout() {
|
||||
let pool = capacity_pool(1, 0);
|
||||
let first = select(&pool);
|
||||
let headers = HeaderMap::new();
|
||||
let saturated = pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await;
|
||||
assert!(matches!(saturated, Err(SelectionFailure::Capacity)));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["capacity_rejections_total"], 1);
|
||||
assert_eq!(member["queue_timeouts_total"], 0);
|
||||
drop(first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_backend_failures_are_exposed_without_losing_aggregate_counters() {
|
||||
let pool = capacity_pool(0, 0);
|
||||
let selection = select(&pool);
|
||||
selection.lease.error_detail(
|
||||
"dns",
|
||||
"lookup backend.example failed",
|
||||
Duration::from_millis(7),
|
||||
);
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["dns_errors_total"], 1);
|
||||
assert_eq!(member["connect_errors_total"], 1);
|
||||
assert_eq!(member["last_failure_class"], "dns");
|
||||
assert_eq!(
|
||||
member["last_failure_detail"],
|
||||
"lookup backend.example failed"
|
||||
);
|
||||
assert!(member["last_failure_at_ms"].as_u64().unwrap_or_default() > 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn traffic_and_adaptive_selection_use_observed_cost_and_weight() {
|
||||
for algorithm in [Algorithm::LeastTraffic, Algorithm::Adaptive] {
|
||||
let pool = pool(algorithm, StickyMode::Off);
|
||||
lock(&pool.members[0].state.traffic).rate = 50_000_000.0;
|
||||
lock(&pool.members[0].state.health).ewma_us = 1_000_000;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn ip_hash_is_stable_and_cannot_be_spoofed_with_forwarded_headers() {
|
||||
let pool = pool(Algorithm::IpHash, StickyMode::Off);
|
||||
let original = select(&pool).lease.member.definition.id.clone();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "10.0.0.99".parse().unwrap());
|
||||
for ip in ["127.0.0.1", "::ffff:127.0.0.1"] {
|
||||
for _ in 0..8 {
|
||||
assert_eq!(
|
||||
pool.select(ip.parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
.lease
|
||||
.member
|
||||
.definition
|
||||
.id,
|
||||
original
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
-165
@@ -1,170 +1,173 @@
|
||||
#[tokio::test]
|
||||
async fn opaque_cookie_is_removed_from_origin_requests_and_protected_on_responses() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
let first = select(&pool);
|
||||
let cookie = first.cookie.unwrap().to_str().unwrap().to_owned();
|
||||
assert!(
|
||||
cookie.contains("HttpOnly")
|
||||
&& cookie.contains("SameSite=Lax")
|
||||
&& !cookie.contains("Secure")
|
||||
);
|
||||
let cookie = cookie.split(';').next().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("{cookie}; app=ok").parse().unwrap());
|
||||
let next = pool
|
||||
.select("10.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
next.lease.member.definition.id,
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_none());
|
||||
pool.strip_affinity(&mut headers);
|
||||
assert_eq!(headers["cookie"], "app=ok");
|
||||
headers.append(
|
||||
"set-cookie",
|
||||
format!("{}=origin-spoof", pool.cookie_name)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.append("set-cookie", "app=ok".parse().unwrap());
|
||||
pool.strip_affinity_response(&mut headers);
|
||||
assert_eq!(headers.get_all("set-cookie").iter().count(), 1);
|
||||
assert_eq!(headers["set-cookie"], "app=ok");
|
||||
let secure = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), true)
|
||||
.unwrap();
|
||||
assert!(secure
|
||||
.cookie
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("; Secure"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failed_sticky_member_is_replaced_and_duplicate_cookies_are_ignored() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
let first = select(&pool);
|
||||
let cookie = first
|
||||
.cookie
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("{cookie}; {cookie}").parse().unwrap());
|
||||
assert!(pool.affinity(&headers).is_none());
|
||||
headers.insert("cookie", cookie.parse().unwrap());
|
||||
lock(&first.lease.member.state.health).healthy = false;
|
||||
let next = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
next.lease.member.definition.id,
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_some());
|
||||
lock(&next.lease.member.state.health).healthy = false;
|
||||
assert!(pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn slow_start_ramps_weight_and_error_is_counted_once_per_exchange() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let selected = select(&pool);
|
||||
let state = &selected.lease.member.state;
|
||||
assert!(state.weight(100, 100) < 100.0);
|
||||
assert_eq!(state.weight(100, 0), 100.0);
|
||||
selected.lease.started();
|
||||
selected.lease.bytes(50, true);
|
||||
selected.lease.bytes(100, false);
|
||||
selected.lease.error("timeout", Duration::from_secs(1));
|
||||
selected.lease.error("body", Duration::from_secs(2));
|
||||
selected.lease.retry_attempted();
|
||||
selected.lease.retry_succeeded();
|
||||
assert_eq!(state.requests.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.errors.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.timeouts_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.io_errors_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.bytes_in.load(Ordering::Relaxed), 50);
|
||||
assert_eq!(state.bytes_out.load(Ordering::Relaxed), 100);
|
||||
assert_eq!(state.retry_attempts_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.retry_successes_total.load(Ordering::Relaxed), 1);
|
||||
assert!(pool
|
||||
.prometheus()
|
||||
.contains("rust_proxy_backend_requests_total{vhost=\"app\",backend=\"a\"} 1"));
|
||||
assert!(pool
|
||||
.prometheus()
|
||||
.contains("rust_proxy_backend_retry_attempts_total{vhost=\"app\",backend=\"a\"} 1"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn slow_5xx_counts_both_observability_classes_when_passive_is_off() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let selected = select(&pool);
|
||||
selected.lease.response(500, Duration::from_secs(20), true);
|
||||
let state = &selected.lease.member.state;
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.slow_responses_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn opaque_cookie_is_removed_from_origin_requests_and_protected_on_responses() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
let first = select(&pool);
|
||||
let cookie = first.cookie.unwrap().to_str().unwrap().to_owned();
|
||||
assert!(
|
||||
cookie.contains("HttpOnly")
|
||||
&& cookie.contains("SameSite=Lax")
|
||||
&& !cookie.contains("Secure")
|
||||
);
|
||||
let cookie = cookie.split(';').next().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("{cookie}; app=ok").parse().unwrap());
|
||||
let next = pool
|
||||
.select("10.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
next.lease.member.definition.id,
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_none());
|
||||
pool.strip_affinity(&mut headers);
|
||||
assert_eq!(headers["cookie"], "app=ok");
|
||||
headers.append(
|
||||
"set-cookie",
|
||||
format!("{}=origin-spoof", pool.cookie_name)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.append("set-cookie", "app=ok".parse().unwrap());
|
||||
pool.strip_affinity_response(&mut headers);
|
||||
assert_eq!(headers.get_all("set-cookie").iter().count(), 1);
|
||||
assert_eq!(headers["set-cookie"], "app=ok");
|
||||
let secure = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), true)
|
||||
.unwrap();
|
||||
assert!(secure
|
||||
.cookie
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("; Secure"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failed_sticky_member_is_replaced_and_duplicate_cookies_are_ignored() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
let first = select(&pool);
|
||||
let cookie = first
|
||||
.cookie
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("{cookie}; {cookie}").parse().unwrap());
|
||||
assert!(pool.affinity(&headers).is_none());
|
||||
headers.insert("cookie", cookie.parse().unwrap());
|
||||
lock(&first.lease.member.state.health).healthy = false;
|
||||
let next = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
next.lease.member.definition.id,
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_some());
|
||||
lock(&next.lease.member.state.health).healthy = false;
|
||||
assert!(pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn slow_start_ramps_weight_and_error_is_counted_once_per_exchange() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let selected = select(&pool);
|
||||
let state = &selected.lease.member.state;
|
||||
assert!(state.weight(100, 100) < 100.0);
|
||||
assert_eq!(state.weight(100, 0), 100.0);
|
||||
selected.lease.started();
|
||||
selected.lease.bytes(50, true);
|
||||
selected.lease.bytes(100, false);
|
||||
selected.lease.error("timeout", Duration::from_secs(1));
|
||||
selected.lease.error("body", Duration::from_secs(2));
|
||||
selected.lease.retry_attempted();
|
||||
selected.lease.retry_succeeded();
|
||||
assert_eq!(state.requests.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.errors.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.timeouts_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.io_errors_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.bytes_in.load(Ordering::Relaxed), 50);
|
||||
assert_eq!(state.bytes_out.load(Ordering::Relaxed), 100);
|
||||
assert_eq!(state.retry_attempts_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.retry_successes_total.load(Ordering::Relaxed), 1);
|
||||
assert!(pool
|
||||
.prometheus()
|
||||
.contains("rust_proxy_backend_requests_total{vhost=\"app\",backend=\"a\"} 1"));
|
||||
assert!(pool
|
||||
.prometheus()
|
||||
.contains("rust_proxy_backend_retry_attempts_total{vhost=\"app\",backend=\"a\"} 1"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn slow_5xx_counts_both_observability_classes_when_passive_is_off() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let selected = select(&pool);
|
||||
selected.lease.response(500, Duration::from_secs(20), true);
|
||||
let state = &selected.lease.member.state;
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.slow_responses_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_upload_duration_does_not_trigger_passive_slow_failure() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let upload = select(&pool);
|
||||
upload.lease.response(200, Duration::from_secs(20), false);
|
||||
assert_eq!(upload.lease.member.state.errors.load(Ordering::Relaxed), 0);
|
||||
let read = select(&pool);
|
||||
read.lease.response(200, Duration::from_secs(20), true);
|
||||
assert_eq!(read.lease.member.state.errors.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn backup_is_only_new_assignment_when_no_primary_is_eligible() {
|
||||
let mut pool = pool(Algorithm::RoundRobin, StickyMode::Off);
|
||||
Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[1])
|
||||
.unwrap()
|
||||
.definition
|
||||
.backup = true;
|
||||
for _ in 0..8 {
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
lock(&pool.members[0].state.health).healthy = true;
|
||||
#[tokio::test]
|
||||
async fn client_upload_duration_does_not_trigger_passive_slow_failure() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let upload = select(&pool);
|
||||
upload.lease.response(200, Duration::from_secs(20), false);
|
||||
assert_eq!(upload.lease.member.state.errors.load(Ordering::Relaxed), 0);
|
||||
let read = select(&pool);
|
||||
read.lease.response(200, Duration::from_secs(20), true);
|
||||
assert_eq!(read.lease.member.state.errors.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn backup_is_only_new_assignment_when_no_primary_is_eligible() {
|
||||
let mut pool = pool(Algorithm::RoundRobin, StickyMode::Off);
|
||||
Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[1])
|
||||
.unwrap()
|
||||
.definition
|
||||
.backup = true;
|
||||
for _ in 0..8 {
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn drain_rejects_new_sessions_but_preserves_valid_existing_affinity() {
|
||||
let mut pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[0])
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
lock(&pool.members[0].state.health).healthy = true;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn drain_rejects_new_sessions_but_preserves_valid_existing_affinity() {
|
||||
let mut pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[0])
|
||||
.unwrap()
|
||||
.definition
|
||||
.drain = true;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("{}={}", pool.cookie_name, pool.members[0].token)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let existing = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(existing.lease.member.definition.id, "a");
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
assert_eq!(
|
||||
pool.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
.lease
|
||||
.member
|
||||
.definition
|
||||
.drain = true;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("{}={}", pool.cookie_name, pool.members[0].token)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let existing = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(existing.lease.member.definition.id, "a");
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
assert_eq!(
|
||||
pool.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
.lease
|
||||
.member
|
||||
.definition
|
||||
.id,
|
||||
"b"
|
||||
);
|
||||
}
|
||||
.id,
|
||||
"b"
|
||||
);
|
||||
}
|
||||
Vendored
+12
-13
@@ -57,7 +57,10 @@ impl Cache {
|
||||
let batch: Vec<_> = {
|
||||
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
|
||||
cold.lru
|
||||
.range((std::ops::Bound::Excluded(after_sequence), std::ops::Bound::Unbounded))
|
||||
.range((
|
||||
std::ops::Bound::Excluded(after_sequence),
|
||||
std::ops::Bound::Unbounded,
|
||||
))
|
||||
.take(SNAPSHOT_BATCH)
|
||||
.filter_map(|(sequence, key)| {
|
||||
cold.entries.get(key).map(|entry| {
|
||||
@@ -137,11 +140,9 @@ impl Cache {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !cold
|
||||
.entries
|
||||
.get(&candidate.key)
|
||||
.is_some_and(|entry| entry.sequence == candidate.sequence && entry.pinned_reads == 0)
|
||||
{
|
||||
if !cold.entries.get(&candidate.key).is_some_and(|entry| {
|
||||
entry.sequence == candidate.sequence && entry.pinned_reads == 0
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = cold.remove(&candidate.key) {
|
||||
@@ -217,7 +218,10 @@ impl Cache {
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?;
|
||||
let id = self.cleanup_sequence.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
let id = self
|
||||
.cleanup_sequence
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.saturating_add(1);
|
||||
let job = ColdCleanupJob {
|
||||
id,
|
||||
status: "queued",
|
||||
@@ -251,12 +255,7 @@ impl Cache {
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
let result = cache
|
||||
.remove_cold_candidates(
|
||||
Some(&request),
|
||||
None,
|
||||
None,
|
||||
settings.cold_gc_max_delete,
|
||||
)
|
||||
.remove_cold_candidates(Some(&request), None, None, settings.cold_gc_max_delete)
|
||||
.await;
|
||||
let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(job) = jobs.get_mut(&id) {
|
||||
|
||||
Vendored
+31
-13
@@ -115,8 +115,19 @@ impl Cache {
|
||||
.get(&entry.scope)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
|
||||
if !self.enqueue_demotion(key, entry, revision, generation, scope_generation, key_generation) {
|
||||
let key_generation = store
|
||||
.key_generations
|
||||
.get(&entry.base_key)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if !self.enqueue_demotion(
|
||||
key,
|
||||
entry,
|
||||
revision,
|
||||
generation,
|
||||
scope_generation,
|
||||
key_generation,
|
||||
) {
|
||||
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -156,18 +167,25 @@ impl Cache {
|
||||
.get(&entry.scope)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
|
||||
let key_generation = store
|
||||
.key_generations
|
||||
.get(&entry.base_key)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let bytes = entry.cost;
|
||||
let memory_lease = self.try_reserve_transient(bytes, &settings).unwrap_or_else(|| {
|
||||
let bytes = bytes as u64;
|
||||
self.transient_bytes_active.fetch_add(bytes, Ordering::Relaxed);
|
||||
TransientLease {
|
||||
_inner: Arc::new(TransientLeaseInner {
|
||||
counter: self.transient_bytes_active.clone(),
|
||||
bytes,
|
||||
}),
|
||||
}
|
||||
});
|
||||
let memory_lease =
|
||||
self.try_reserve_transient(bytes, &settings)
|
||||
.unwrap_or_else(|| {
|
||||
let bytes = bytes as u64;
|
||||
self.transient_bytes_active
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
TransientLease {
|
||||
_inner: Arc::new(TransientLeaseInner {
|
||||
counter: self.transient_bytes_active.clone(),
|
||||
bytes,
|
||||
}),
|
||||
}
|
||||
});
|
||||
ColdDraft {
|
||||
key,
|
||||
revision,
|
||||
|
||||
Vendored
+19
-4
@@ -33,7 +33,10 @@ impl Cache {
|
||||
for item in read_dir.flatten() {
|
||||
let path = item.path();
|
||||
if path.is_file() {
|
||||
let extension = path.extension().and_then(|v| v.to_str()).unwrap_or_default();
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or_default();
|
||||
if matches!(extension, "json" | "body" | "tmp") {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
@@ -142,12 +145,24 @@ impl Cache {
|
||||
revision,
|
||||
scope: file.scope,
|
||||
scheme: file.scheme,
|
||||
authority: if file.authority.is_empty() { file.domain.clone() } else { file.authority },
|
||||
authority: if file.authority.is_empty() {
|
||||
file.domain.clone()
|
||||
} else {
|
||||
file.authority
|
||||
},
|
||||
domain: file.domain,
|
||||
path: file.path.clone(),
|
||||
target: if file.target.is_empty() { file.path } else { file.target },
|
||||
target: if file.target.is_empty() {
|
||||
file.path
|
||||
} else {
|
||||
file.target
|
||||
},
|
||||
tags: file.tags,
|
||||
created_at: if file.created_at_ms == 0 { stored_at } else { system_time_ms(file.created_at_ms) },
|
||||
created_at: if file.created_at_ms == 0 {
|
||||
stored_at
|
||||
} else {
|
||||
system_time_ms(file.created_at_ms)
|
||||
},
|
||||
headers,
|
||||
body_path,
|
||||
meta_path: path,
|
||||
|
||||
Vendored
+22
-4
@@ -101,7 +101,9 @@ impl Cache {
|
||||
let key = key_hex(&draft.key);
|
||||
let body_name = format!("{key}.body");
|
||||
let (body_path, meta_path, body_tmp, meta_tmp) = cold_paths(&draft.root, &draft.key);
|
||||
let Some(shard) = body_path.parent() else { return; };
|
||||
let Some(shard) = body_path.parent() else {
|
||||
return;
|
||||
};
|
||||
if tokio::fs::create_dir_all(shard).await.is_err() {
|
||||
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
@@ -125,7 +127,13 @@ impl Cache {
|
||||
path: draft.entry.path.clone(),
|
||||
target: draft.entry.target.clone(),
|
||||
tags: draft.entry.tags.clone(),
|
||||
created_at_ms: draft.entry.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(u64::MAX as u128) as u64,
|
||||
created_at_ms: draft
|
||||
.entry
|
||||
.created_at
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64,
|
||||
headers,
|
||||
body_file: body_name,
|
||||
body_bytes: draft.entry.body.len(),
|
||||
@@ -138,8 +146,18 @@ impl Cache {
|
||||
stale_if_error: draft.entry.stale_if_error,
|
||||
must_revalidate: draft.entry.must_revalidate,
|
||||
proxy_revalidate: draft.entry.proxy_revalidate,
|
||||
origin_etag: draft.entry.origin_etag.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned),
|
||||
origin_last_modified: draft.entry.origin_last_modified.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned),
|
||||
origin_etag: draft
|
||||
.entry
|
||||
.origin_etag
|
||||
.as_ref()
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned),
|
||||
origin_last_modified: draft
|
||||
.entry
|
||||
.origin_last_modified
|
||||
.as_ref()
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned),
|
||||
last_access_ms: now_ms(),
|
||||
hits: draft.entry.hits,
|
||||
transformed: draft.entry.transformed,
|
||||
|
||||
Vendored
+76
-44
@@ -92,57 +92,89 @@ fn canonical_url(scheme: &str, authority: &str, target: &str) -> String {
|
||||
|
||||
fn normalized_matcher(request: &InvalidationRequest) -> Result<SelectorMatcher> {
|
||||
ensure!(!request.value.is_empty(), "invalidation value is required");
|
||||
ensure!(request.value.len() <= 8192, "invalidation value is too long");
|
||||
ensure!(
|
||||
request.value.len() <= 8192,
|
||||
"invalidation value is too long"
|
||||
);
|
||||
if let Some(vhost) = &request.vhost {
|
||||
ensure!(!vhost.is_empty() && vhost.len() <= 64, "invalid vhost selector");
|
||||
ensure!(
|
||||
!vhost.is_empty() && vhost.len() <= 64,
|
||||
"invalid vhost selector"
|
||||
);
|
||||
}
|
||||
if let Some(host) = &request.host {
|
||||
ensure!(!host.is_empty() && host.len() <= 253, "invalid host selector");
|
||||
ensure!(
|
||||
!host.is_empty() && host.len() <= 253,
|
||||
"invalid host selector"
|
||||
);
|
||||
}
|
||||
let mut value = request.value.trim().to_owned();
|
||||
let mut scheme = None;
|
||||
let mut host = request.host.as_ref().map(|value| value.to_ascii_lowercase());
|
||||
let regex = match request.selector {
|
||||
InvalidationSelector::Exact | InvalidationSelector::Prefix => {
|
||||
if value.starts_with("http://") || value.starts_with("https://") {
|
||||
let url = url::Url::parse(&value).context("invalid purge URL")?;
|
||||
ensure!(url.username().is_empty() && url.password().is_none(), "purge URL must not contain credentials");
|
||||
ensure!(url.fragment().is_none(), "purge URL must not contain a fragment");
|
||||
scheme = Some(url.scheme().to_ascii_lowercase());
|
||||
let authority = match url.port() {
|
||||
Some(port) => format!("{}:{port}", url.host_str().context("purge URL host missing")?),
|
||||
None => url.host_str().context("purge URL host missing")?.to_owned(),
|
||||
};
|
||||
host = Some(authority.to_ascii_lowercase());
|
||||
value = url.path().to_owned();
|
||||
if let Some(query) = url.query() {
|
||||
value.push('?');
|
||||
value.push_str(query);
|
||||
let mut host = request
|
||||
.host
|
||||
.as_ref()
|
||||
.map(|value| value.to_ascii_lowercase());
|
||||
let regex =
|
||||
match request.selector {
|
||||
InvalidationSelector::Exact | InvalidationSelector::Prefix => {
|
||||
if value.starts_with("http://") || value.starts_with("https://") {
|
||||
let url = url::Url::parse(&value).context("invalid purge URL")?;
|
||||
ensure!(
|
||||
url.username().is_empty() && url.password().is_none(),
|
||||
"purge URL must not contain credentials"
|
||||
);
|
||||
ensure!(
|
||||
url.fragment().is_none(),
|
||||
"purge URL must not contain a fragment"
|
||||
);
|
||||
scheme = Some(url.scheme().to_ascii_lowercase());
|
||||
let authority = match url.port() {
|
||||
Some(port) => format!(
|
||||
"{}:{port}",
|
||||
url.host_str().context("purge URL host missing")?
|
||||
),
|
||||
None => url.host_str().context("purge URL host missing")?.to_owned(),
|
||||
};
|
||||
host = Some(authority.to_ascii_lowercase());
|
||||
value = url.path().to_owned();
|
||||
if let Some(query) = url.query() {
|
||||
value.push('?');
|
||||
value.push_str(query);
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
value.starts_with('/'),
|
||||
"exact/prefix invalidation must use an absolute URL or /path target"
|
||||
);
|
||||
None
|
||||
}
|
||||
ensure!(value.starts_with('/'), "exact/prefix invalidation must use an absolute URL or /path target");
|
||||
None
|
||||
}
|
||||
InvalidationSelector::Tag => {
|
||||
ensure!(value.len() <= 128, "cache tag is too long");
|
||||
ensure!(value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')), "invalid cache tag");
|
||||
None
|
||||
}
|
||||
InvalidationSelector::Regex => {
|
||||
ensure!(value.len() <= 512, "regex is too long");
|
||||
Some(
|
||||
regex::RegexBuilder::new(&value)
|
||||
.size_limit(1 << 20)
|
||||
.build()
|
||||
.context("invalid or too expensive regex")?,
|
||||
)
|
||||
}
|
||||
InvalidationSelector::CacheKey => {
|
||||
ensure!(parse_key_hex(&value).is_some(), "cache_key must be 64 hexadecimal characters");
|
||||
value.make_ascii_lowercase();
|
||||
None
|
||||
}
|
||||
};
|
||||
InvalidationSelector::Tag => {
|
||||
ensure!(value.len() <= 128, "cache tag is too long");
|
||||
ensure!(
|
||||
value.bytes().all(|b| b.is_ascii_alphanumeric()
|
||||
|| matches!(b, b'_' | b'-' | b'.' | b':' | b'/')),
|
||||
"invalid cache tag"
|
||||
);
|
||||
None
|
||||
}
|
||||
InvalidationSelector::Regex => {
|
||||
ensure!(value.len() <= 512, "regex is too long");
|
||||
Some(
|
||||
regex::RegexBuilder::new(&value)
|
||||
.size_limit(1 << 20)
|
||||
.build()
|
||||
.context("invalid or too expensive regex")?,
|
||||
)
|
||||
}
|
||||
InvalidationSelector::CacheKey => {
|
||||
ensure!(
|
||||
parse_key_hex(&value).is_some(),
|
||||
"cache_key must be 64 hexadecimal characters"
|
||||
);
|
||||
value.make_ascii_lowercase();
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(SelectorMatcher {
|
||||
selector: request.selector,
|
||||
value,
|
||||
@@ -198,7 +230,7 @@ fn matcher_matches(
|
||||
InvalidationSelector::Tag => {
|
||||
host_matches(matcher, scheme, authority, domain)
|
||||
&& tags.iter().any(|tag| tag == &matcher.value)
|
||||
},
|
||||
}
|
||||
InvalidationSelector::Regex => {
|
||||
if !host_matches(matcher, scheme, authority, domain) {
|
||||
return false;
|
||||
|
||||
Vendored
+13
-6
@@ -56,24 +56,31 @@ impl Cache {
|
||||
let started = Instant::now();
|
||||
let matcher = normalized_matcher(&request)?;
|
||||
if requires_wide_confirmation(&matcher) {
|
||||
ensure!(request.confirm_wide, "wide invalidation requires confirm_wide=true");
|
||||
ensure!(
|
||||
request.confirm_wide,
|
||||
"wide invalidation requires confirm_wide=true"
|
||||
);
|
||||
}
|
||||
match request.operation {
|
||||
InvalidationOperation::Ban => {
|
||||
let ttl = request.ttl_seconds.unwrap_or(DEFAULT_BAN_TTL_SECONDS);
|
||||
ensure!((1..=MAX_BAN_TTL_SECONDS).contains(&ttl), "BAN ttl_seconds must be 1..604800");
|
||||
ensure!(
|
||||
(1..=MAX_BAN_TTL_SECONDS).contains(&ttl),
|
||||
"BAN ttl_seconds must be 1..604800"
|
||||
);
|
||||
let now = SystemTime::now();
|
||||
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
bans.retain(|rule| !rule.expired(now));
|
||||
ensure!(bans.len() < MAX_BAN_RULES, "active BAN rule limit reached");
|
||||
let id = self.ban_sequence.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
|
||||
let id = self
|
||||
.ban_sequence
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.wrapping_add(1);
|
||||
bans.push(BanRule {
|
||||
id,
|
||||
matcher: matcher.clone(),
|
||||
created_at: now,
|
||||
expires_at: now
|
||||
.checked_add(Duration::from_secs(ttl))
|
||||
.unwrap_or(now),
|
||||
expires_at: now.checked_add(Duration::from_secs(ttl)).unwrap_or(now),
|
||||
});
|
||||
self.counters.bans_created.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(InvalidationResult {
|
||||
|
||||
Vendored
+1
-6
@@ -13,12 +13,7 @@ impl Cache {
|
||||
.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;
|
||||
}
|
||||
|
||||
Vendored
+18
-9
@@ -18,7 +18,10 @@ impl Cache {
|
||||
}
|
||||
if entry.cost > settings.max_bytes
|
||||
|| (!entry.requires_revalidation
|
||||
&& entry.initial_age.saturating_add(entry.received.elapsed()).as_secs()
|
||||
&& entry
|
||||
.initial_age
|
||||
.saturating_add(entry.received.elapsed())
|
||||
.as_secs()
|
||||
>= retention_deadline(
|
||||
entry.ttl,
|
||||
entry.stale_while_revalidate,
|
||||
@@ -37,12 +40,7 @@ impl Cache {
|
||||
.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 false;
|
||||
}
|
||||
@@ -80,10 +78,21 @@ impl Cache {
|
||||
.get(&old.scope)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let key_generation = store.key_generations.get(&old.base_key).copied().unwrap_or(0);
|
||||
let key_generation = store
|
||||
.key_generations
|
||||
.get(&old.base_key)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if !(settings.cold_enabled
|
||||
&& settings.cold_on_lru
|
||||
&& self.enqueue_demotion(key, old, revision, generation, scope_generation, key_generation))
|
||||
&& self.enqueue_demotion(
|
||||
key,
|
||||
old,
|
||||
revision,
|
||||
generation,
|
||||
scope_generation,
|
||||
key_generation,
|
||||
))
|
||||
{
|
||||
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
Vendored
+4
-1
@@ -196,7 +196,10 @@ impl Cache {
|
||||
skip_compression: parts.extensions.get::<crate::compression::Skip>().is_some(),
|
||||
origin_etag,
|
||||
origin_last_modified,
|
||||
transformed: parts.extensions.get::<crate::transform::Applied>().is_some(),
|
||||
transformed: parts
|
||||
.extensions
|
||||
.get::<crate::transform::Applied>()
|
||||
.is_some(),
|
||||
_permit: permit,
|
||||
_fill_leader: fill_leader,
|
||||
};
|
||||
|
||||
Vendored
+9
-2
@@ -56,7 +56,11 @@ pub struct ColdCleanupRequest {
|
||||
}
|
||||
impl Default for ColdCleanupRequest {
|
||||
fn default() -> Self {
|
||||
Self { unused_for_seconds: None, oldest: Some(100), hits_zero: false }
|
||||
Self {
|
||||
unused_for_seconds: None,
|
||||
oldest: Some(100),
|
||||
hits_zero: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ColdCleanupRequest {
|
||||
@@ -66,7 +70,10 @@ impl ColdCleanupRequest {
|
||||
"at least one cold-cache cleanup criterion is required"
|
||||
);
|
||||
if let Some(value) = self.unused_for_seconds {
|
||||
ensure!((1..=31_536_000).contains(&value), "unused_for_seconds must be 1..31536000");
|
||||
ensure!(
|
||||
(1..=31_536_000).contains(&value),
|
||||
"unused_for_seconds must be 1..31536000"
|
||||
);
|
||||
}
|
||||
if let Some(value) = self.oldest {
|
||||
ensure!((1..=10_000).contains(&value), "oldest must be 1..10000");
|
||||
|
||||
Vendored
+4
-2
@@ -109,7 +109,10 @@ impl Settings {
|
||||
(500..=599).contains(status),
|
||||
"cache.stale_if_error_statuses must contain HTTP 5xx status codes"
|
||||
);
|
||||
ensure!(statuses.insert(*status), "duplicate stale-if-error HTTP status");
|
||||
ensure!(
|
||||
statuses.insert(*status),
|
||||
"duplicate stale-if-error HTTP status"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
(1..=604_800).contains(&self.proactive_refresh_before_expiry_seconds),
|
||||
@@ -332,7 +335,6 @@ pub struct ScopeView {
|
||||
pub cold_bytes: u64,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default)]
|
||||
struct Counters {
|
||||
hits: AtomicU64,
|
||||
|
||||
Vendored
+5
-3
@@ -248,9 +248,11 @@ impl Revalidation {
|
||||
}
|
||||
}
|
||||
if let Some(Some(value)) = control.get("min-fresh") {
|
||||
if value.parse::<u64>().ok().is_some_and(|required| {
|
||||
self.ttl.saturating_sub(self.age) < required
|
||||
}) {
|
||||
if value
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.is_some_and(|required| self.ttl.saturating_sub(self.age) < required)
|
||||
{
|
||||
return Some("request_min_fresh");
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
-8
@@ -17,14 +17,16 @@ fn default_cache_scheme() -> String {
|
||||
fn cache_tags(headers: &HeaderMap) -> Vec<String> {
|
||||
let mut tags = Vec::new();
|
||||
for value in headers.get_all("cache-tag") {
|
||||
let Ok(value) = value.to_str() else { continue; };
|
||||
let Ok(value) = value.to_str() else {
|
||||
continue;
|
||||
};
|
||||
for raw in value.split(|c: char| c == ',' || c.is_ascii_whitespace()) {
|
||||
let tag = raw.trim();
|
||||
if tag.is_empty()
|
||||
|| tag.len() > 128
|
||||
|| !tag
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/'))
|
||||
|| !tag.bytes().all(|b| {
|
||||
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -180,10 +182,7 @@ fn cache_state(
|
||||
requires_revalidation: bool,
|
||||
request_headers: &HeaderMap,
|
||||
) -> CacheState {
|
||||
if age < ttl
|
||||
&& !requires_revalidation
|
||||
&& request_freshness_allows(age, ttl, request_headers)
|
||||
{
|
||||
if age < ttl && !requires_revalidation && request_freshness_allows(age, ttl, request_headers) {
|
||||
return CacheState::Fresh;
|
||||
}
|
||||
match stale_mode(
|
||||
|
||||
+381
-383
@@ -1,412 +1,410 @@
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
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()
|
||||
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");
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
#[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();
|
||||
}
|
||||
|
||||
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 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"));
|
||||
|
||||
#[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(
|
||||
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 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,
|
||||
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 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 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 {
|
||||
"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(),
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
transformed.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(
|
||||
transformed,
|
||||
transformed_ticket.clone(),
|
||||
transformed_plan,
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.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();
|
||||
cache
|
||||
.wrap(
|
||||
transformed,
|
||||
transformed_ticket.clone(),
|
||||
transformed_plan,
|
||||
&settings,
|
||||
)
|
||||
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 cold = restarted.cold.lock().unwrap();
|
||||
let transformed = cold
|
||||
.entries
|
||||
.values()
|
||||
.find(|entry| entry.path == "/transformed.json")
|
||||
.expect("transformed cold entry");
|
||||
assert_eq!(transformed.stale_while_revalidate, 30);
|
||||
assert_eq!(transformed.stale_if_error, 90);
|
||||
assert_eq!(transformed.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
|
||||
assert_eq!(
|
||||
transformed.origin_last_modified.as_ref().unwrap(),
|
||||
"Mon, 26 Jan 2026 23:11:40 GMT"
|
||||
);
|
||||
assert_eq!(transformed.headers["etag"], "\"rustproxy-representation\"");
|
||||
assert!(transformed.transformed);
|
||||
}
|
||||
|
||||
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();
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
Bytes::from_static(br#"{"a":1}"#)
|
||||
);
|
||||
|
||||
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,
|
||||
)
|
||||
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();
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
Bytes::from_static(b"body { color: red; }")
|
||||
);
|
||||
restart_shutdown.cancel();
|
||||
}
|
||||
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 2);
|
||||
wait_for_cold(&cache, 2).await;
|
||||
shutdown.cancel();
|
||||
drop(cache);
|
||||
#[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();
|
||||
}
|
||||
|
||||
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 cold = restarted.cold.lock().unwrap();
|
||||
let transformed = cold
|
||||
.entries
|
||||
.values()
|
||||
.find(|entry| entry.path == "/transformed.json")
|
||||
.expect("transformed cold entry");
|
||||
assert_eq!(transformed.stale_while_revalidate, 30);
|
||||
assert_eq!(transformed.stale_if_error, 90);
|
||||
assert_eq!(transformed.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
|
||||
assert_eq!(
|
||||
transformed.origin_last_modified.as_ref().unwrap(),
|
||||
"Mon, 26 Jan 2026 23:11:40 GMT"
|
||||
);
|
||||
assert_eq!(transformed.headers["etag"], "\"rustproxy-representation\"");
|
||||
assert!(transformed.transformed);
|
||||
#[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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
cache.demote_idle();
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
|
||||
#[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();
|
||||
}
|
||||
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_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);
|
||||
}
|
||||
#[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();
|
||||
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();
|
||||
}
|
||||
cache.demote_idle();
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().entries, 0);
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
shutdown.cancel();
|
||||
}
|
||||
+311
-274
@@ -1,293 +1,330 @@
|
||||
#[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);
|
||||
#[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();
|
||||
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(),
|
||||
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!(restarted.view().cold_entries, 2);
|
||||
restart_shutdown.cancel();
|
||||
assert_eq!(name.len(), 2);
|
||||
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||
shards.push(item.path());
|
||||
}
|
||||
|
||||
#[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| {
|
||||
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();
|
||||
}
|
||||
}));
|
||||
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 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;
|
||||
#[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;
|
||||
}
|
||||
panic!("cold cleanup did not finish");
|
||||
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 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;
|
||||
#[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));
|
||||
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();
|
||||
}
|
||||
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 {
|
||||
key,
|
||||
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(),
|
||||
headers: HeaderMap::new(),
|
||||
body_path: PathBuf::from("old.body"),
|
||||
meta_path: PathBuf::from("old.json"),
|
||||
body_bytes: 1,
|
||||
body_sha256: "00".repeat(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,
|
||||
origin_etag: None,
|
||||
origin_last_modified: None,
|
||||
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,
|
||||
};
|
||||
#[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 {
|
||||
key,
|
||||
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(),
|
||||
headers: HeaderMap::new(),
|
||||
body_path: PathBuf::from("old.body"),
|
||||
meta_path: PathBuf::from("old.json"),
|
||||
body_bytes: 1,
|
||||
body_sha256: "00".repeat(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,
|
||||
origin_etag: None,
|
||||
origin_last_modified: None,
|
||||
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 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);
|
||||
}
|
||||
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());
|
||||
}
|
||||
#[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());
|
||||
}
|
||||
Vendored
+3
-16
@@ -86,12 +86,7 @@ impl Cache {
|
||||
.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;
|
||||
}
|
||||
@@ -258,10 +253,7 @@ impl Cache {
|
||||
}
|
||||
let hits = cached.hits.saturating_add(1);
|
||||
let mut headers = cached.headers.clone();
|
||||
headers.insert(
|
||||
"age",
|
||||
HeaderValue::from_str(&cached.age.to_string()).ok()?,
|
||||
);
|
||||
headers.insert("age", HeaderValue::from_str(&cached.age.to_string()).ok()?);
|
||||
headers.append(
|
||||
"warning",
|
||||
HeaderValue::from_static("110 RustProxy \"Response is stale\""),
|
||||
@@ -331,12 +323,7 @@ impl Cache {
|
||||
.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
|
||||
}
|
||||
fn discard_storage_key(&self, key: &Key) {
|
||||
self.store
|
||||
|
||||
+50
-51
@@ -140,7 +140,6 @@ pub fn compile_reusing(
|
||||
compile_with_active(config, revision, policy, defaults, previous, &[])
|
||||
}
|
||||
|
||||
|
||||
type LocalHostBuild = (
|
||||
HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
@@ -148,64 +147,64 @@ type LocalHostBuild = (
|
||||
);
|
||||
|
||||
fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
let mut redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>> = HashMap::new();
|
||||
let mut not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>> =
|
||||
HashMap::new();
|
||||
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() {
|
||||
for b in &bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(bindings.iter().copied());
|
||||
}
|
||||
if redirect.enabled {
|
||||
let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?);
|
||||
let mut redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>> = HashMap::new();
|
||||
let mut not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>> =
|
||||
HashMap::new();
|
||||
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 {
|
||||
redirects
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(compiled.clone());
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
for b in &bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(bindings.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
for b in &bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
if redirect.enabled {
|
||||
let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?);
|
||||
for name in &redirect.frontend.hosts {
|
||||
redirects
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(compiled.clone());
|
||||
}
|
||||
}
|
||||
claimed.extend(bindings.iter().copied());
|
||||
}
|
||||
if host.enabled {
|
||||
let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?);
|
||||
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 {
|
||||
not_found_hosts
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(compiled.clone());
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
for b in &bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(bindings.iter().copied());
|
||||
}
|
||||
if host.enabled {
|
||||
let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?);
|
||||
for name in &host.frontend.hosts {
|
||||
not_found_hosts
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(compiled.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((redirects, not_found_hosts, claimed_hosts))
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ pub fn compile_with_active(
|
||||
config.compression.validate()?;
|
||||
config.server_header.validate()?;
|
||||
let forwarding = config.forwarding.compile()?;
|
||||
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();
|
||||
for service in &mut config.web_services {
|
||||
service
|
||||
|
||||
+356
-348
@@ -9,381 +9,389 @@ fn compile_routing(
|
||||
effective_tls: &crate::tls::Settings,
|
||||
tls: &Option<Arc<rustls::ServerConfig>>,
|
||||
) -> Result<RoutingBuild> {
|
||||
let mut routes: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
|
||||
let mut fallbacks: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
|
||||
let mut frontends: HashMap<String, Vec<Arc<CompiledFrontend>>> = HashMap::new();
|
||||
let (redirects, not_found_hosts, mut claimed_hosts) = compile_local_hosts(config)?;
|
||||
let mut pools = Vec::new();
|
||||
let mut vhost_metrics = std::collections::BTreeMap::new();
|
||||
for host in &mut config.vhosts {
|
||||
let host_bindings = host.frontend.resolved(&config.listeners)?;
|
||||
let tls_ports: HashSet<u16> = host_bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.tls)
|
||||
.map(|binding| binding.address.port())
|
||||
.collect();
|
||||
if host.websocket_rfc8441 {
|
||||
ensure!(
|
||||
host_bindings.iter().any(|binding| {
|
||||
(binding.tls && binding.http2) || (!binding.tls && binding.h2c)
|
||||
}),
|
||||
"vhost {} RFC 8441 requires at least one selected HTTP/2-capable listener",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
if host.websocket_rfc9220 {
|
||||
ensure!(
|
||||
host_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.tls && binding.http3.is_some()),
|
||||
"vhost {} RFC 9220 requires at least one selected HTTP/3 listener",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
let https_redirect_port = if host.force_ssl {
|
||||
ensure!(
|
||||
host_bindings.iter().any(|binding| !binding.tls),
|
||||
"vhost {} Force SSL requires at least one selected plain HTTP listener",
|
||||
host.id
|
||||
);
|
||||
ensure!(
|
||||
!tls_ports.is_empty(),
|
||||
"vhost {} Force SSL requires at least one selected TLS listener",
|
||||
host.id
|
||||
);
|
||||
if tls_ports.contains(&443) {
|
||||
Some(443)
|
||||
} else {
|
||||
let mut routes: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
|
||||
let mut fallbacks: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
|
||||
let mut frontends: HashMap<String, Vec<Arc<CompiledFrontend>>> = HashMap::new();
|
||||
let (redirects, not_found_hosts, mut claimed_hosts) = compile_local_hosts(config)?;
|
||||
let mut pools = Vec::new();
|
||||
let mut vhost_metrics = std::collections::BTreeMap::new();
|
||||
for host in &mut config.vhosts {
|
||||
let host_bindings = host.frontend.resolved(&config.listeners)?;
|
||||
let tls_ports: HashSet<u16> = host_bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.tls)
|
||||
.map(|binding| binding.address.port())
|
||||
.collect();
|
||||
if host.websocket_rfc8441 {
|
||||
ensure!(
|
||||
host_bindings.iter().any(|binding| {
|
||||
(binding.tls && binding.http2) || (!binding.tls && binding.h2c)
|
||||
}),
|
||||
"vhost {} RFC 8441 requires at least one selected HTTP/2-capable listener",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
if host.websocket_rfc9220 {
|
||||
ensure!(
|
||||
host_bindings
|
||||
.iter()
|
||||
.any(|binding| binding.tls && binding.http3.is_some()),
|
||||
"vhost {} RFC 9220 requires at least one selected HTTP/3 listener",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
let https_redirect_port = if host.force_ssl {
|
||||
ensure!(
|
||||
host_bindings.iter().any(|binding| !binding.tls),
|
||||
"vhost {} Force SSL requires at least one selected plain HTTP listener",
|
||||
host.id
|
||||
);
|
||||
ensure!(
|
||||
!tls_ports.is_empty(),
|
||||
"vhost {} Force SSL requires at least one selected TLS listener",
|
||||
host.id
|
||||
);
|
||||
if tls_ports.contains(&443) {
|
||||
Some(443)
|
||||
} else {
|
||||
ensure!(
|
||||
tls_ports.len() == 1,
|
||||
"vhost {} Force SSL cannot choose an HTTPS target: select one TLS port or include port 443",
|
||||
host.id
|
||||
);
|
||||
tls_ports.iter().copied().next()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cookie = host
|
||||
.load_balancing
|
||||
.sticky
|
||||
.cookie_name
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
ensure!(
|
||||
!(cookie.starts_with("__Host-") || cookie.starts_with("__Secure-"))
|
||||
|| 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 &host.frontend.hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
for b in &host_bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
tls_ports.iter().copied().next()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cookie = host
|
||||
.load_balancing
|
||||
.sticky
|
||||
.cookie_name
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
ensure!(
|
||||
!(cookie.starts_with("__Host-") || cookie.starts_with("__Secure-"))
|
||||
|| 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 &host.frontend.hosts {
|
||||
let claimed = claimed_hosts.entry(name.clone()).or_default();
|
||||
for a in claimed.iter() {
|
||||
for b in &host_bindings {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(a.address, b.address),
|
||||
"overlapping frontend bindings for hostname {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
claimed.extend(host_bindings.iter().copied());
|
||||
}
|
||||
claimed.extend(host_bindings.iter().copied());
|
||||
}
|
||||
let mut compiled_members = Vec::new();
|
||||
for definition in &host.backends {
|
||||
let mut backend = compile_backend_with_timeout(
|
||||
&definition.backend(),
|
||||
&desired_policy,
|
||||
&guarded_bindings,
|
||||
&host.load_balancing.keepalive,
|
||||
host.proxy_timeouts
|
||||
.connect_timeout()
|
||||
.unwrap_or(std::time::Duration::from_secs(10)),
|
||||
)
|
||||
.with_context(|| format!("vhost {} backend {}", host.id, definition.id))?;
|
||||
backend.id = format!("{}/{}", host.id, definition.id);
|
||||
compiled_members.push(Arc::new(backend));
|
||||
}
|
||||
let pool = if compiled_members.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut pool = crate::balance::Pool::new(
|
||||
host.id.clone(),
|
||||
host.load_balancing.clone(),
|
||||
&host.backends,
|
||||
compiled_members,
|
||||
)?;
|
||||
if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) {
|
||||
pool.reuse_observations(old);
|
||||
let mut compiled_members = Vec::new();
|
||||
for definition in &host.backends {
|
||||
let mut backend = compile_backend_with_timeout(
|
||||
&definition.backend(),
|
||||
&desired_policy,
|
||||
&guarded_bindings,
|
||||
&host.load_balancing.keepalive,
|
||||
host.proxy_timeouts
|
||||
.connect_timeout()
|
||||
.unwrap_or(std::time::Duration::from_secs(10)),
|
||||
)
|
||||
.with_context(|| format!("vhost {} backend {}", host.id, definition.id))?;
|
||||
backend.id = format!("{}/{}", host.id, definition.id);
|
||||
compiled_members.push(Arc::new(backend));
|
||||
}
|
||||
Some(Arc::new(pool))
|
||||
};
|
||||
let metrics = previous
|
||||
.and_then(|s| s.vhost_metrics.get(&host.id))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::new(crate::metrics::VhostMetrics::default()));
|
||||
vhost_metrics.insert(host.id.clone(), metrics.clone());
|
||||
let tls_policy = if let Some(policy) = &host.tls_policy {
|
||||
policy.validate_narrower_than(&effective_tls.policy)?;
|
||||
policy.clone()
|
||||
} else {
|
||||
effective_tls.policy.clone()
|
||||
};
|
||||
if host.http3_enabled
|
||||
&& host_bindings.iter().any(|binding| binding.http3.is_some())
|
||||
&& tls_policy != effective_tls.policy
|
||||
{
|
||||
anyhow::bail!(
|
||||
let pool = if compiled_members.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut pool = crate::balance::Pool::new(
|
||||
host.id.clone(),
|
||||
host.load_balancing.clone(),
|
||||
&host.backends,
|
||||
compiled_members,
|
||||
)?;
|
||||
if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) {
|
||||
pool.reuse_observations(old);
|
||||
}
|
||||
Some(Arc::new(pool))
|
||||
};
|
||||
let metrics = previous
|
||||
.and_then(|s| s.vhost_metrics.get(&host.id))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::new(crate::metrics::VhostMetrics::default()));
|
||||
vhost_metrics.insert(host.id.clone(), metrics.clone());
|
||||
let tls_policy = if let Some(policy) = &host.tls_policy {
|
||||
policy.validate_narrower_than(&effective_tls.policy)?;
|
||||
policy.clone()
|
||||
} else {
|
||||
effective_tls.policy.clone()
|
||||
};
|
||||
if host.http3_enabled
|
||||
&& host_bindings.iter().any(|binding| binding.http3.is_some())
|
||||
&& tls_policy != effective_tls.policy
|
||||
{
|
||||
anyhow::bail!(
|
||||
"vhost {} uses HTTP/3, so its TLS policy must match the global TLS policy; disable HTTP/3 to use a per-vhost TLS override",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
let frontend_tls = tls
|
||||
.as_ref()
|
||||
.map(|base| crate::tls::variant(base, &tls_policy, true))
|
||||
.transpose()?;
|
||||
let frontend_tls_http1 = tls
|
||||
.as_ref()
|
||||
.map(|base| crate::tls::variant(base, &tls_policy, false))
|
||||
.transpose()?;
|
||||
let (profile_acl, profile_country_access) = match host.acl_profile_id.as_deref() {
|
||||
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
|
||||
})?,
|
||||
None => (None, crate::rate_limit::CountryAccess::default()),
|
||||
};
|
||||
let frontend = Arc::new(CompiledFrontend {
|
||||
metrics,
|
||||
websockets: host.websockets,
|
||||
websocket_legacy_support: host.websocket_legacy_support,
|
||||
websocket_rfc8441: host.websocket_rfc8441,
|
||||
websocket_rfc9220: host.websocket_rfc9220,
|
||||
force_ssl: host.force_ssl,
|
||||
forward_client_ip: host.forward_client_ip,
|
||||
https_redirect_port,
|
||||
http2_enabled: host.http2_enabled,
|
||||
http3_enabled: host.http3_enabled,
|
||||
tls_policy,
|
||||
tls: frontend_tls,
|
||||
tls_http1: frontend_tls_http1,
|
||||
cloudflare_client_ip: host.cloudflare_client_ip,
|
||||
timing_headers: host.timing_headers,
|
||||
backend_header: host.backend_header,
|
||||
backend_timing_header: host.backend_timing_header,
|
||||
rate_limit: host.rate_limit.clone(),
|
||||
method_policy: host.method_policy.clone(),
|
||||
basic_auth: host.basic_auth.compile()?,
|
||||
server_header: host
|
||||
.server_header
|
||||
.clone()
|
||||
.unwrap_or_else(|| config.server_header.clone()),
|
||||
diagnostic_headers: host
|
||||
.diagnostic_headers
|
||||
.clone()
|
||||
.unwrap_or_else(|| config.diagnostic_headers.clone()),
|
||||
tinyauth: host.tinyauth.compile(&desired_policy, &guarded_bindings)?,
|
||||
id: host.id.clone(),
|
||||
bindings: host_bindings,
|
||||
acl_profile_id: host.acl_profile_id.clone(),
|
||||
profile_acl,
|
||||
profile_country_access,
|
||||
acl: host.acl.as_ref().map(Acl::compile).transpose()?,
|
||||
headers: host.headers.compile()?,
|
||||
security: host.security_headers.compile()?,
|
||||
cache: host.cache.resolve(&config.cache)?,
|
||||
transform: host.transform.resolve(&config.transform)?,
|
||||
pages: host.pages.clone(),
|
||||
proxy_timeouts: host.proxy_timeouts.clone(),
|
||||
});
|
||||
if host.enabled {
|
||||
let (fallback_source, fallback_pool, fallback_backend) = match &host.source {
|
||||
crate::vhost::Source::ReverseProxy => {
|
||||
let pool = pool.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("vhost {} Reverse Proxy source has no backend pool", host.id)
|
||||
})?;
|
||||
(
|
||||
CompiledSource::ReverseProxy,
|
||||
Some(pool.clone()),
|
||||
Some(pool.members[0].backend.clone()),
|
||||
)
|
||||
}
|
||||
crate::vhost::Source::WebServer { web_service_id } => (
|
||||
CompiledSource::WebServer(web_services[web_service_id].clone()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
let fallback = CompiledRoute {
|
||||
backend_id: None,
|
||||
bindings: frontend.bindings.clone(),
|
||||
frontend: Some(frontend.clone()),
|
||||
pool: fallback_pool,
|
||||
cache: None,
|
||||
id: format!("{}/main", host.id),
|
||||
path_prefix: "/".into(),
|
||||
match_type: crate::vhost::PathMatch::Prefix,
|
||||
path_regex: None,
|
||||
rewrite: crate::vhost::PathRewrite::default(),
|
||||
response: None,
|
||||
source: fallback_source,
|
||||
backend: fallback_backend,
|
||||
acl_profile_id: None,
|
||||
profile_acl: None,
|
||||
profile_country_access: crate::rate_limit::CountryAccess::default(),
|
||||
acl: None,
|
||||
country_access: crate::rate_limit::CountryAccess::default(),
|
||||
method_policy: crate::vhost::MethodPolicy::default(),
|
||||
headers: crate::headers::CompiledHeaders::default(),
|
||||
};
|
||||
for name in &host.frontend.hosts {
|
||||
fallbacks
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(fallback.clone());
|
||||
}
|
||||
}
|
||||
let mut path_ids = HashSet::new();
|
||||
let mut path_bindings = HashSet::new();
|
||||
for route in &mut host.routes {
|
||||
ensure!(
|
||||
valid_id(&route.id) && path_ids.insert(route.id.clone()),
|
||||
"invalid/duplicate vhost path id"
|
||||
);
|
||||
let path_regex = match route.match_type {
|
||||
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => {
|
||||
ensure!(
|
||||
!route.path_prefix.is_empty()
|
||||
&& route.path_prefix.len() <= 1024
|
||||
&& !route.path_prefix.chars().any(char::is_control),
|
||||
"invalid vhost location regex"
|
||||
);
|
||||
let mut builder = regex::RegexBuilder::new(&route.path_prefix);
|
||||
builder
|
||||
.case_insensitive(
|
||||
route.match_type == crate::vhost::PathMatch::RegexInsensitive,
|
||||
)
|
||||
.size_limit(2 * 1024 * 1024)
|
||||
.dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(builder.build().context("invalid vhost location regex")?)
|
||||
}
|
||||
crate::vhost::PathMatch::Exact => {
|
||||
ensure!(
|
||||
!route.path_prefix.contains('?')
|
||||
&& !route.path_prefix.contains('#')
|
||||
&& !route.path_prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||
"invalid exact location path"
|
||||
);
|
||||
route.path_prefix = crate::acl::routing_path(&route.path_prefix)
|
||||
.context("invalid exact location path")?;
|
||||
None
|
||||
}
|
||||
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => {
|
||||
normalize_prefix(&mut route.path_prefix)?;
|
||||
None
|
||||
}
|
||||
};
|
||||
ensure!(
|
||||
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
||||
"duplicate vhost location matcher"
|
||||
);
|
||||
validate_route_rewrite(route, path_regex.as_ref())?;
|
||||
validate_location_response(route.response.as_ref())?;
|
||||
let (route_profile_acl, route_profile_country_access) = match route.acl_profile_id.as_deref() {
|
||||
let frontend_tls = tls
|
||||
.as_ref()
|
||||
.map(|base| crate::tls::variant(base, &tls_policy, true))
|
||||
.transpose()?;
|
||||
let frontend_tls_http1 = tls
|
||||
.as_ref()
|
||||
.map(|base| crate::tls::variant(base, &tls_policy, false))
|
||||
.transpose()?;
|
||||
let (profile_acl, profile_country_access) = match host.acl_profile_id.as_deref() {
|
||||
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"vhost {} location {} references unknown ACL profile {}",
|
||||
host.id,
|
||||
route.id,
|
||||
id
|
||||
)
|
||||
anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id)
|
||||
})?,
|
||||
None => (None, crate::rate_limit::CountryAccess::default()),
|
||||
};
|
||||
let acl = route.acl.as_ref().map(Acl::compile).transpose()?;
|
||||
let headers = route.headers.compile()?;
|
||||
if route.source.is_reverse_proxy() {
|
||||
if let Some(id) = &route.backend_id {
|
||||
ensure!(
|
||||
host.backends.iter().any(|b| &b.id == id),
|
||||
"vhost path references unknown backend {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
if host.enabled && route.enabled {
|
||||
let (source, route_pool, backend) = if route.response.is_some() {
|
||||
// Local return is handled before source dispatch and needs no origin.
|
||||
(CompiledSource::ReverseProxy, None, None)
|
||||
} else {
|
||||
match &route.source {
|
||||
crate::vhost::Source::ReverseProxy => {
|
||||
let pool = pool.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"vhost {} location {} Reverse Proxy source has no backend pool",
|
||||
host.id,
|
||||
route.id
|
||||
)
|
||||
})?;
|
||||
let backend = if let Some(id) = route.backend_id.as_deref() {
|
||||
pool.members
|
||||
.iter()
|
||||
.find(|member| member.definition.id == id)
|
||||
.map(|member| member.backend.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))?
|
||||
} else {
|
||||
pool.members[0].backend.clone()
|
||||
};
|
||||
(CompiledSource::ReverseProxy, Some(pool.clone()), Some(backend))
|
||||
}
|
||||
crate::vhost::Source::WebServer { web_service_id } => (
|
||||
CompiledSource::WebServer(web_services[web_service_id].clone()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
let frontend = Arc::new(CompiledFrontend {
|
||||
metrics,
|
||||
websockets: host.websockets,
|
||||
websocket_legacy_support: host.websocket_legacy_support,
|
||||
websocket_rfc8441: host.websocket_rfc8441,
|
||||
websocket_rfc9220: host.websocket_rfc9220,
|
||||
force_ssl: host.force_ssl,
|
||||
forward_client_ip: host.forward_client_ip,
|
||||
https_redirect_port,
|
||||
http2_enabled: host.http2_enabled,
|
||||
http3_enabled: host.http3_enabled,
|
||||
tls_policy,
|
||||
tls: frontend_tls,
|
||||
tls_http1: frontend_tls_http1,
|
||||
cloudflare_client_ip: host.cloudflare_client_ip,
|
||||
timing_headers: host.timing_headers,
|
||||
backend_header: host.backend_header,
|
||||
backend_timing_header: host.backend_timing_header,
|
||||
rate_limit: host.rate_limit.clone(),
|
||||
method_policy: host.method_policy.clone(),
|
||||
basic_auth: host.basic_auth.compile()?,
|
||||
server_header: host
|
||||
.server_header
|
||||
.clone()
|
||||
.unwrap_or_else(|| config.server_header.clone()),
|
||||
diagnostic_headers: host
|
||||
.diagnostic_headers
|
||||
.clone()
|
||||
.unwrap_or_else(|| config.diagnostic_headers.clone()),
|
||||
tinyauth: host.tinyauth.compile(&desired_policy, &guarded_bindings)?,
|
||||
id: host.id.clone(),
|
||||
bindings: host_bindings,
|
||||
acl_profile_id: host.acl_profile_id.clone(),
|
||||
profile_acl,
|
||||
profile_country_access,
|
||||
acl: host.acl.as_ref().map(Acl::compile).transpose()?,
|
||||
headers: host.headers.compile()?,
|
||||
security: host.security_headers.compile()?,
|
||||
cache: host.cache.resolve(&config.cache)?,
|
||||
transform: host.transform.resolve(&config.transform)?,
|
||||
pages: host.pages.clone(),
|
||||
proxy_timeouts: host.proxy_timeouts.clone(),
|
||||
});
|
||||
if host.enabled {
|
||||
let (fallback_source, fallback_pool, fallback_backend) = match &host.source {
|
||||
crate::vhost::Source::ReverseProxy => {
|
||||
let pool = pool.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"vhost {} Reverse Proxy source has no backend pool",
|
||||
host.id
|
||||
)
|
||||
})?;
|
||||
(
|
||||
CompiledSource::ReverseProxy,
|
||||
Some(pool.clone()),
|
||||
Some(pool.members[0].backend.clone()),
|
||||
)
|
||||
}
|
||||
crate::vhost::Source::WebServer { web_service_id } => (
|
||||
CompiledSource::WebServer(web_services[web_service_id].clone()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
let compiled = CompiledRoute {
|
||||
backend_id: route.backend_id.clone(),
|
||||
let fallback = CompiledRoute {
|
||||
backend_id: None,
|
||||
bindings: frontend.bindings.clone(),
|
||||
frontend: Some(frontend.clone()),
|
||||
pool: route_pool,
|
||||
cache: route.cache,
|
||||
id: format!("{}/{}", host.id, route.id),
|
||||
path_prefix: route.path_prefix.clone(),
|
||||
match_type: route.match_type,
|
||||
path_regex,
|
||||
rewrite: route.rewrite.clone(),
|
||||
response: route.response.clone(),
|
||||
source,
|
||||
backend,
|
||||
acl_profile_id: route.acl_profile_id.clone(),
|
||||
profile_acl: route_profile_acl,
|
||||
profile_country_access: route_profile_country_access,
|
||||
acl,
|
||||
country_access: route.country_access.clone(),
|
||||
method_policy: route.method_policy.clone(),
|
||||
headers,
|
||||
pool: fallback_pool,
|
||||
cache: None,
|
||||
id: format!("{}/main", host.id),
|
||||
path_prefix: "/".into(),
|
||||
match_type: crate::vhost::PathMatch::Prefix,
|
||||
path_regex: None,
|
||||
rewrite: crate::vhost::PathRewrite::default(),
|
||||
response: None,
|
||||
source: fallback_source,
|
||||
backend: fallback_backend,
|
||||
acl_profile_id: None,
|
||||
profile_acl: None,
|
||||
profile_country_access: crate::rate_limit::CountryAccess::default(),
|
||||
acl: None,
|
||||
country_access: crate::rate_limit::CountryAccess::default(),
|
||||
method_policy: crate::vhost::MethodPolicy::default(),
|
||||
headers: crate::headers::CompiledHeaders::default(),
|
||||
};
|
||||
for name in &host.frontend.hosts {
|
||||
routes
|
||||
fallbacks
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(compiled.clone());
|
||||
.push(fallback.clone());
|
||||
}
|
||||
}
|
||||
let mut path_ids = HashSet::new();
|
||||
let mut path_bindings = HashSet::new();
|
||||
for route in &mut host.routes {
|
||||
ensure!(
|
||||
valid_id(&route.id) && path_ids.insert(route.id.clone()),
|
||||
"invalid/duplicate vhost path id"
|
||||
);
|
||||
let path_regex = match route.match_type {
|
||||
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => {
|
||||
ensure!(
|
||||
!route.path_prefix.is_empty()
|
||||
&& route.path_prefix.len() <= 1024
|
||||
&& !route.path_prefix.chars().any(char::is_control),
|
||||
"invalid vhost location regex"
|
||||
);
|
||||
let mut builder = regex::RegexBuilder::new(&route.path_prefix);
|
||||
builder
|
||||
.case_insensitive(
|
||||
route.match_type == crate::vhost::PathMatch::RegexInsensitive,
|
||||
)
|
||||
.size_limit(2 * 1024 * 1024)
|
||||
.dfa_size_limit(2 * 1024 * 1024);
|
||||
Some(builder.build().context("invalid vhost location regex")?)
|
||||
}
|
||||
crate::vhost::PathMatch::Exact => {
|
||||
ensure!(
|
||||
!route.path_prefix.contains('?')
|
||||
&& !route.path_prefix.contains('#')
|
||||
&& !route.path_prefix.bytes().any(|b| b <= 32 || b == 127),
|
||||
"invalid exact location path"
|
||||
);
|
||||
route.path_prefix = crate::acl::routing_path(&route.path_prefix)
|
||||
.context("invalid exact location path")?;
|
||||
None
|
||||
}
|
||||
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => {
|
||||
normalize_prefix(&mut route.path_prefix)?;
|
||||
None
|
||||
}
|
||||
};
|
||||
ensure!(
|
||||
path_bindings.insert((route.match_type, route.path_prefix.clone())),
|
||||
"duplicate vhost location matcher"
|
||||
);
|
||||
validate_route_rewrite(route, path_regex.as_ref())?;
|
||||
validate_location_response(route.response.as_ref())?;
|
||||
let (route_profile_acl, route_profile_country_access) =
|
||||
match route.acl_profile_id.as_deref() {
|
||||
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"vhost {} location {} references unknown ACL profile {}",
|
||||
host.id,
|
||||
route.id,
|
||||
id
|
||||
)
|
||||
})?,
|
||||
None => (None, crate::rate_limit::CountryAccess::default()),
|
||||
};
|
||||
let acl = route.acl.as_ref().map(Acl::compile).transpose()?;
|
||||
let headers = route.headers.compile()?;
|
||||
if route.source.is_reverse_proxy() {
|
||||
if let Some(id) = &route.backend_id {
|
||||
ensure!(
|
||||
host.backends.iter().any(|b| &b.id == id),
|
||||
"vhost path references unknown backend {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
if host.enabled && route.enabled {
|
||||
let (source, route_pool, backend) = if route.response.is_some() {
|
||||
// Local return is handled before source dispatch and needs no origin.
|
||||
(CompiledSource::ReverseProxy, None, None)
|
||||
} else {
|
||||
match &route.source {
|
||||
crate::vhost::Source::ReverseProxy => {
|
||||
let pool = pool.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"vhost {} location {} Reverse Proxy source has no backend pool",
|
||||
host.id,
|
||||
route.id
|
||||
)
|
||||
})?;
|
||||
let backend = if let Some(id) = route.backend_id.as_deref() {
|
||||
pool.members
|
||||
.iter()
|
||||
.find(|member| member.definition.id == id)
|
||||
.map(|member| member.backend.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))?
|
||||
} else {
|
||||
pool.members[0].backend.clone()
|
||||
};
|
||||
(
|
||||
CompiledSource::ReverseProxy,
|
||||
Some(pool.clone()),
|
||||
Some(backend),
|
||||
)
|
||||
}
|
||||
crate::vhost::Source::WebServer { web_service_id } => (
|
||||
CompiledSource::WebServer(web_services[web_service_id].clone()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
}
|
||||
};
|
||||
let compiled = CompiledRoute {
|
||||
backend_id: route.backend_id.clone(),
|
||||
bindings: frontend.bindings.clone(),
|
||||
frontend: Some(frontend.clone()),
|
||||
pool: route_pool,
|
||||
cache: route.cache,
|
||||
id: format!("{}/{}", host.id, route.id),
|
||||
path_prefix: route.path_prefix.clone(),
|
||||
match_type: route.match_type,
|
||||
path_regex,
|
||||
rewrite: route.rewrite.clone(),
|
||||
response: route.response.clone(),
|
||||
source,
|
||||
backend,
|
||||
acl_profile_id: route.acl_profile_id.clone(),
|
||||
profile_acl: route_profile_acl,
|
||||
profile_country_access: route_profile_country_access,
|
||||
acl,
|
||||
country_access: route.country_access.clone(),
|
||||
method_policy: route.method_policy.clone(),
|
||||
headers,
|
||||
};
|
||||
for name in &host.frontend.hosts {
|
||||
routes
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(compiled.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if host.enabled {
|
||||
for name in &host.frontend.hosts {
|
||||
frontends
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(frontend.clone());
|
||||
}
|
||||
if let Some(pool) = pool {
|
||||
pools.push(pool);
|
||||
}
|
||||
}
|
||||
}
|
||||
if host.enabled {
|
||||
for name in &host.frontend.hosts {
|
||||
frontends
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(frontend.clone());
|
||||
}
|
||||
if let Some(pool) = pool {
|
||||
pools.push(pool);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(RoutingBuild {
|
||||
routes,
|
||||
fallbacks,
|
||||
|
||||
@@ -26,8 +26,7 @@ impl Snapshot {
|
||||
&& frontend.websocket_rfc8441
|
||||
&& frontend.http2_enabled
|
||||
&& frontend.bindings.iter().any(|binding| {
|
||||
binding.matches(local, tls)
|
||||
&& if tls { binding.http2 } else { binding.h2c }
|
||||
binding.matches(local, tls) && if tls { binding.http2 } else { binding.h2c }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+410
-402
@@ -1,423 +1,431 @@
|
||||
use super::*;
|
||||
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(),
|
||||
}
|
||||
use super::*;
|
||||
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 missing_http2_settings_use_release_defaults() {
|
||||
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
assert_eq!(config.http2, crate::settings::Http2Connections::default());
|
||||
config.http2.validate().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn missing_http2_settings_use_release_defaults() {
|
||||
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
assert_eq!(config.http2, crate::settings::Http2Connections::default());
|
||||
config.http2.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_unicode_prefix_survives_serialization_and_recompile() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into();
|
||||
let first = compile(config, 0, &policy()).unwrap();
|
||||
let saved = serde_json::to_string(&first.envelope.config).unwrap();
|
||||
let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap();
|
||||
assert!(second.select("localhost", "/caf\u{e9}/hello").is_some());
|
||||
}
|
||||
#[test]
|
||||
fn web_server_main_source_needs_no_backend_pool() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "site".into(),
|
||||
};
|
||||
config.vhosts[0].backends.clear();
|
||||
config.vhosts[0].routes.clear();
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
assert!(matches!(&route.source, CompiledSource::WebServer(_)));
|
||||
assert!(route.pool.is_none());
|
||||
assert!(route.backend.is_none());
|
||||
assert!(snapshot.pools.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_server_and_reverse_proxy_can_share_one_vhost() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
let host = &mut config.vhosts[0];
|
||||
host.source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "site".into(),
|
||||
};
|
||||
host.routes = vec![crate::vhost::PathRoute {
|
||||
id: "api".into(),
|
||||
path_prefix: "/api".into(),
|
||||
source: crate::vhost::Source::ReverseProxy,
|
||||
..Default::default()
|
||||
}];
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let root = snapshot.select("localhost", "/").unwrap();
|
||||
let api = snapshot.select("localhost", "/api/test").unwrap();
|
||||
assert!(matches!(&root.source, CompiledSource::WebServer(_)));
|
||||
assert!(matches!(&api.source, CompiledSource::ReverseProxy));
|
||||
assert!(api.pool.is_some());
|
||||
assert!(api.backend.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_web_service_can_be_reused_by_multiple_vhosts() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"shared").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "shared-site".into(),
|
||||
name: "Shared site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
let first = &mut config.vhosts[0];
|
||||
first.source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "shared-site".into(),
|
||||
};
|
||||
first.backends.clear();
|
||||
first.routes.clear();
|
||||
|
||||
let mut second = first.clone();
|
||||
second.id = "shared-second".into();
|
||||
second.frontend.hosts = vec!["second.localhost".into()];
|
||||
config.vhosts.push(second);
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let first_route = snapshot.select("localhost", "/").unwrap();
|
||||
let second_route = snapshot.select("second.localhost", "/").unwrap();
|
||||
assert!(
|
||||
matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
|
||||
);
|
||||
assert!(
|
||||
matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_web_service_reference_is_rejected() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "missing".into(),
|
||||
};
|
||||
let error = compile(config, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("unknown Web Service"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_source_fields_remain_reverse_proxy_for_old_configs() {
|
||||
let source: crate::vhost::Source =
|
||||
serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap();
|
||||
assert_eq!(source, crate::vhost::Source::ReverseProxy);
|
||||
let route: crate::vhost::PathRoute =
|
||||
serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
|
||||
assert_eq!(route.source, crate::vhost::Source::ReverseProxy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_normalization() {
|
||||
assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com");
|
||||
for invalid in [
|
||||
"",
|
||||
"a..b",
|
||||
"*.test",
|
||||
"a/b",
|
||||
"user@host",
|
||||
"-bad.test",
|
||||
"a:99999",
|
||||
] {
|
||||
assert!(request_host(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() {
|
||||
assert_eq!(
|
||||
normalize_host_pattern("*.Example.TEST.").unwrap(),
|
||||
"*.example.test"
|
||||
);
|
||||
for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] {
|
||||
assert!(normalize_host_pattern(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_unicode_prefix_survives_serialization_and_recompile() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into();
|
||||
let first = compile(config, 0, &policy()).unwrap();
|
||||
let saved = serde_json::to_string(&first.envelope.config).unwrap();
|
||||
let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap();
|
||||
assert!(second.select("localhost", "/caf\u{e9}/hello").is_some());
|
||||
}
|
||||
#[test]
|
||||
fn web_server_main_source_needs_no_backend_pool() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "site".into(),
|
||||
};
|
||||
config.vhosts[0].backends.clear();
|
||||
config.vhosts[0].routes.clear();
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
assert!(matches!(&route.source, CompiledSource::WebServer(_)));
|
||||
assert!(route.pool.is_none());
|
||||
assert!(route.backend.is_none());
|
||||
assert!(snapshot.pools.is_empty());
|
||||
}
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].id = "wildcard".into();
|
||||
c.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
|
||||
let mut exact = c.vhosts[0].clone();
|
||||
exact.id = "exact".into();
|
||||
exact.frontend.hosts = vec!["api.example.test".into()];
|
||||
c.vhosts.push(exact);
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.select_on("api.example.test", "/", local, false)
|
||||
.unwrap()
|
||||
.id,
|
||||
"exact/main"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.select_on("www.example.test", "/", local, false)
|
||||
.unwrap()
|
||||
.id,
|
||||
"wildcard/main"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.acl_profiles = serde_json::from_value(serde_json::json!([{
|
||||
"id":"office", "name":"Office",
|
||||
"acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]},
|
||||
"country_access":{"mode":"block","countries":["RU"]}
|
||||
}]))
|
||||
.unwrap();
|
||||
c.vhosts[0].acl_profile_id = Some("office".into());
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let frontend = snapshot.frontend("localhost", local, false).unwrap();
|
||||
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
|
||||
assert!(frontend
|
||||
.profile_acl
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.allows("10.2.3.4".parse().unwrap()));
|
||||
assert!(!frontend
|
||||
.profile_acl
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.allows("192.0.2.1".parse().unwrap()));
|
||||
assert!(!frontend.profile_country_access.allows(Some("RU")));
|
||||
|
||||
#[test]
|
||||
fn web_server_and_reverse_proxy_can_share_one_vhost() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
let host = &mut config.vhosts[0];
|
||||
host.source = crate::vhost::Source::WebServer { web_service_id: "site".into() };
|
||||
host.routes = vec![crate::vhost::PathRoute {
|
||||
id: "api".into(),
|
||||
path_prefix: "/api".into(),
|
||||
source: crate::vhost::Source::ReverseProxy,
|
||||
..Default::default()
|
||||
}];
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let root = snapshot.select("localhost", "/").unwrap();
|
||||
let api = snapshot.select("localhost", "/api/test").unwrap();
|
||||
assert!(matches!(&root.source, CompiledSource::WebServer(_)));
|
||||
assert!(matches!(&api.source, CompiledSource::ReverseProxy));
|
||||
assert!(api.pool.is_some());
|
||||
assert!(api.backend.is_some());
|
||||
}
|
||||
c.vhosts[0].acl_profile_id = Some("missing".into());
|
||||
assert!(compile(c, 2, &policy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_web_service_can_be_reused_by_multiple_vhosts() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"shared").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "shared-site".into(),
|
||||
name: "Shared site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
let first = &mut config.vhosts[0];
|
||||
first.source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "shared-site".into(),
|
||||
};
|
||||
first.backends.clear();
|
||||
first.routes.clear();
|
||||
#[test]
|
||||
fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].websockets = true;
|
||||
c.vhosts[0].websocket_rfc8441 = true;
|
||||
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("HTTP/2-capable listener"), "{error}");
|
||||
|
||||
let mut second = first.clone();
|
||||
second.id = "shared-second".into();
|
||||
second.frontend.hosts = vec!["second.localhost".into()];
|
||||
config.vhosts.push(second);
|
||||
c.listeners.endpoints[0].binding.h2c = true;
|
||||
let snapshot = compile(c.clone(), 2, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
assert!(snapshot.websocket_rfc8441_on(local, false));
|
||||
assert!(!snapshot.websocket_rfc8441_on(local, true));
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let first_route = snapshot.select("localhost", "/").unwrap();
|
||||
let second_route = snapshot.select("second.localhost", "/").unwrap();
|
||||
assert!(matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site"));
|
||||
assert!(matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site"));
|
||||
}
|
||||
c.vhosts[0].websocket_rfc8441 = false;
|
||||
let snapshot = compile(c, 3, &policy()).unwrap();
|
||||
assert!(!snapshot.websocket_rfc8441_on(local, false));
|
||||
}
|
||||
#[test]
|
||||
fn force_ssl_requires_plain_http_and_chooses_https_target() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].force_ssl = true;
|
||||
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(
|
||||
error.contains("at least one selected TLS listener"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn unknown_web_service_reference_is_rejected() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "missing".into(),
|
||||
};
|
||||
let error = compile(config, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("unknown Web Service"), "{error}");
|
||||
}
|
||||
c.listeners.endpoints.push(crate::settings::NamedListener {
|
||||
id: "https-a".into(),
|
||||
name: "HTTPS A".into(),
|
||||
scope: crate::settings::ListenerScope::Internal,
|
||||
enabled: true,
|
||||
binding: crate::vhost::Listener {
|
||||
address: "127.0.0.1:8443".parse().unwrap(),
|
||||
tls: true,
|
||||
http2: true,
|
||||
http2_settings: Some(crate::settings::Http2Connections::default()),
|
||||
h2c: false,
|
||||
http3: None,
|
||||
},
|
||||
additional_addresses: vec![],
|
||||
});
|
||||
c.listeners.endpoints.push(crate::settings::NamedListener {
|
||||
id: "https-b".into(),
|
||||
name: "HTTPS B".into(),
|
||||
scope: crate::settings::ListenerScope::Internal,
|
||||
enabled: true,
|
||||
binding: crate::vhost::Listener {
|
||||
address: "127.0.0.1:9443".parse().unwrap(),
|
||||
tls: true,
|
||||
http2: true,
|
||||
http2_settings: Some(crate::settings::Http2Connections::default()),
|
||||
h2c: false,
|
||||
http3: None,
|
||||
},
|
||||
additional_addresses: vec![],
|
||||
});
|
||||
c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()];
|
||||
let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("cannot choose an HTTPS target"), "{error}");
|
||||
|
||||
#[test]
|
||||
fn missing_source_fields_remain_reverse_proxy_for_old_configs() {
|
||||
let source: crate::vhost::Source = serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap();
|
||||
assert_eq!(source, crate::vhost::Source::ReverseProxy);
|
||||
let route: crate::vhost::PathRoute = serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
|
||||
assert_eq!(route.source, crate::vhost::Source::ReverseProxy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_normalization() {
|
||||
assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com");
|
||||
for invalid in [
|
||||
"",
|
||||
"a..b",
|
||||
"*.test",
|
||||
"a/b",
|
||||
"user@host",
|
||||
"-bad.test",
|
||||
"a:99999",
|
||||
] {
|
||||
assert!(request_host(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() {
|
||||
assert_eq!(
|
||||
normalize_host_pattern("*.Example.TEST.").unwrap(),
|
||||
"*.example.test"
|
||||
);
|
||||
for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] {
|
||||
assert!(normalize_host_pattern(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].id = "wildcard".into();
|
||||
c.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
|
||||
let mut exact = c.vhosts[0].clone();
|
||||
exact.id = "exact".into();
|
||||
exact.frontend.hosts = vec!["api.example.test".into()];
|
||||
c.vhosts.push(exact);
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.select_on("api.example.test", "/", local, false)
|
||||
.unwrap()
|
||||
.id,
|
||||
"exact/main"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.select_on("www.example.test", "/", local, false)
|
||||
.unwrap()
|
||||
.id,
|
||||
"wildcard/main"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.acl_profiles = serde_json::from_value(serde_json::json!([{
|
||||
"id":"office", "name":"Office",
|
||||
"acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]},
|
||||
"country_access":{"mode":"block","countries":["RU"]}
|
||||
}]))
|
||||
c.listeners
|
||||
.endpoints
|
||||
.iter_mut()
|
||||
.find(|item| item.id == "https-b")
|
||||
.unwrap()
|
||||
.binding
|
||||
.address = "127.0.0.1:443".parse().unwrap();
|
||||
let snapshot = compile(c.clone(), 3, &policy()).unwrap();
|
||||
let frontend = snapshot
|
||||
.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false)
|
||||
.unwrap();
|
||||
c.vhosts[0].acl_profile_id = Some("office".into());
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let frontend = snapshot.frontend("localhost", local, false).unwrap();
|
||||
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
|
||||
assert!(frontend
|
||||
.profile_acl
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.allows("10.2.3.4".parse().unwrap()));
|
||||
assert!(!frontend
|
||||
.profile_acl
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.allows("192.0.2.1".parse().unwrap()));
|
||||
assert!(!frontend.profile_country_access.allows(Some("RU")));
|
||||
assert_eq!(frontend.https_redirect_port, Some(443));
|
||||
|
||||
c.vhosts[0].acl_profile_id = Some("missing".into());
|
||||
assert!(compile(c, 2, &policy()).is_err());
|
||||
}
|
||||
c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()];
|
||||
let error = compile(c, 4, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("plain HTTP listener"), "{error}");
|
||||
}
|
||||
#[test]
|
||||
fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into());
|
||||
let s = compile(c, 1, &policy()).unwrap();
|
||||
let backend = &s.pools[0].members[0].backend;
|
||||
assert_eq!(backend.origin, "https://10.87.20.2:443");
|
||||
assert_eq!(
|
||||
backend.request_origin,
|
||||
"https://backend.unitra.internal:443"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tls_server_name_is_https_dns_only() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into());
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(!c.vhosts[0].backends[0].tls_verify);
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
|
||||
#[test]
|
||||
fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].websockets = true;
|
||||
c.vhosts[0].websocket_rfc8441 = true;
|
||||
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("HTTP/2-capable listener"), "{error}");
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
|
||||
c.listeners.endpoints[0].binding.h2c = true;
|
||||
let snapshot = compile(c.clone(), 2, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
assert!(snapshot.websocket_rfc8441_on(local, false));
|
||||
assert!(!snapshot.websocket_rfc8441_on(local, true));
|
||||
|
||||
c.vhosts[0].websocket_rfc8441 = false;
|
||||
let snapshot = compile(c, 3, &policy()).unwrap();
|
||||
assert!(!snapshot.websocket_rfc8441_on(local, false));
|
||||
}
|
||||
#[test]
|
||||
fn force_ssl_requires_plain_http_and_chooses_https_target() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].force_ssl = true;
|
||||
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(
|
||||
error.contains("at least one selected TLS listener"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
c.listeners.endpoints.push(crate::settings::NamedListener {
|
||||
id: "https-a".into(),
|
||||
name: "HTTPS A".into(),
|
||||
scope: crate::settings::ListenerScope::Internal,
|
||||
enabled: true,
|
||||
binding: crate::vhost::Listener {
|
||||
address: "127.0.0.1:8443".parse().unwrap(),
|
||||
tls: true,
|
||||
http2: true,
|
||||
http2_settings: Some(crate::settings::Http2Connections::default()),
|
||||
h2c: false,
|
||||
http3: None,
|
||||
},
|
||||
additional_addresses: vec![],
|
||||
});
|
||||
c.listeners.endpoints.push(crate::settings::NamedListener {
|
||||
id: "https-b".into(),
|
||||
name: "HTTPS B".into(),
|
||||
scope: crate::settings::ListenerScope::Internal,
|
||||
enabled: true,
|
||||
binding: crate::vhost::Listener {
|
||||
address: "127.0.0.1:9443".parse().unwrap(),
|
||||
tls: true,
|
||||
http2: true,
|
||||
http2_settings: Some(crate::settings::Http2Connections::default()),
|
||||
h2c: false,
|
||||
http3: None,
|
||||
},
|
||||
additional_addresses: vec![],
|
||||
});
|
||||
c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()];
|
||||
let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("cannot choose an HTTPS target"), "{error}");
|
||||
|
||||
c.listeners
|
||||
.endpoints
|
||||
.iter_mut()
|
||||
.find(|item| item.id == "https-b")
|
||||
.unwrap()
|
||||
.binding
|
||||
.address = "127.0.0.1:443".parse().unwrap();
|
||||
let snapshot = compile(c.clone(), 3, &policy()).unwrap();
|
||||
let frontend = snapshot
|
||||
.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false)
|
||||
.unwrap();
|
||||
assert_eq!(frontend.https_redirect_port, Some(443));
|
||||
|
||||
c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()];
|
||||
let error = compile(c, 4, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("plain HTTP listener"), "{error}");
|
||||
}
|
||||
#[test]
|
||||
fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into());
|
||||
let s = compile(c, 1, &policy()).unwrap();
|
||||
let backend = &s.pools[0].members[0].backend;
|
||||
assert_eq!(backend.origin, "https://10.87.20.2:443");
|
||||
assert_eq!(
|
||||
backend.request_origin,
|
||||
"https://backend.unitra.internal:443"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tls_server_name_is_https_dns_only() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into());
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(!c.vhosts[0].backends[0].tls_verify);
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
|
||||
c.vhosts[0].backends[0].tls_verify = true;
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.redirects = serde_json::from_value(serde_json::json!([{
|
||||
c.vhosts[0].backends[0].tls_verify = true;
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.redirects = serde_json::from_value(serde_json::json!([{
|
||||
"id":"legacy","name":"Legacy","frontend":{"hosts":["old.localhost"],"listener_ids":["http"]},
|
||||
"rules":[{"id":"root","target":"https://new.localhost/","status_code":308}]
|
||||
}])).unwrap();
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let (_host, rule) = snapshot
|
||||
.redirect_on("old.localhost", "/a", local, false)
|
||||
.unwrap();
|
||||
assert_eq!(rule.id, "root");
|
||||
assert!(snapshot
|
||||
.select_on("old.localhost", "/a", local, false)
|
||||
.is_none());
|
||||
}
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let (_host, rule) = snapshot
|
||||
.redirect_on("old.localhost", "/a", local, false)
|
||||
.unwrap();
|
||||
assert_eq!(rule.id, "root");
|
||||
assert!(snapshot
|
||||
.select_on("old.localhost", "/a", local, false)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_locations_override_but_main_vhost_remains_fallback() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
// The demo document still contains the pre-0.16 synthetic root route.
|
||||
// Validation must normalize it away and keep the main backend internally.
|
||||
let mut custom = c.vhosts[0].routes[0].clone();
|
||||
custom.id = "test".into();
|
||||
custom.path_prefix = "/test".into();
|
||||
custom.response = Some(crate::vhost::LocationResponse {
|
||||
status: 200,
|
||||
content_type: "text/plain".into(),
|
||||
body: "ok".into(),
|
||||
});
|
||||
c.vhosts[0].routes.push(custom);
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
#[test]
|
||||
fn custom_locations_override_but_main_vhost_remains_fallback() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
// The demo document still contains the pre-0.16 synthetic root route.
|
||||
// Validation must normalize it away and keep the main backend internally.
|
||||
let mut custom = c.vhosts[0].routes[0].clone();
|
||||
custom.id = "test".into();
|
||||
custom.path_prefix = "/test".into();
|
||||
custom.response = Some(crate::vhost::LocationResponse {
|
||||
status: 200,
|
||||
content_type: "text/plain".into(),
|
||||
body: "ok".into(),
|
||||
});
|
||||
c.vhosts[0].routes.push(custom);
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/test").unwrap().id,
|
||||
"demo/test"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/test/a").unwrap().id,
|
||||
"demo/test"
|
||||
);
|
||||
assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main");
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/abc").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/static/a.js").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
assert!(snapshot.envelope.config.vhosts[0]
|
||||
.routes
|
||||
.iter()
|
||||
.all(|route| !route.is_legacy_main_fallback()));
|
||||
}
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/test").unwrap().id,
|
||||
"demo/test"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/test/a").unwrap().id,
|
||||
"demo/test"
|
||||
);
|
||||
assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main");
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/abc").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/static/a.js").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
assert!(snapshot.envelope.config.vhosts[0]
|
||||
.routes
|
||||
.iter()
|
||||
.all(|route| !route.is_legacy_main_fallback()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliberate_root_custom_location_can_override_main_fallback() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let root = &mut c.vhosts[0].routes[0];
|
||||
root.response = Some(crate::vhost::LocationResponse {
|
||||
status: 204,
|
||||
content_type: "text/plain".into(),
|
||||
body: String::new(),
|
||||
});
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/anything").unwrap().id,
|
||||
"demo/root"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn deliberate_root_custom_location_can_override_main_fallback() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let root = &mut c.vhosts[0].routes[0];
|
||||
root.response = Some(crate::vhost::LocationResponse {
|
||||
status: 204,
|
||||
content_type: "text/plain".into(),
|
||||
body: String::new(),
|
||||
});
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/anything").unwrap().id,
|
||||
"demo/root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.administration.listen = Some("0.0.0.0:9090".parse().unwrap());
|
||||
c.administration.tls = true;
|
||||
c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].accept_local_listener_risk = true;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert!(snapshot.pools[0].members[0].backend.targets_admin_listener);
|
||||
}
|
||||
#[test]
|
||||
fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.administration.listen = Some("0.0.0.0:9090".parse().unwrap());
|
||||
c.administration.tls = true;
|
||||
c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].accept_local_listener_risk = true;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert!(snapshot.pools[0].members[0].backend.targets_admin_listener);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_diagnostic_flags_compile_to_frontend() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backend_header = true;
|
||||
c.vhosts[0].backend_timing_header = true;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let frontend = snapshot.frontend("localhost", local, false).unwrap();
|
||||
assert!(frontend.backend_header);
|
||||
assert!(frontend.backend_timing_header);
|
||||
}
|
||||
#[test]
|
||||
fn backend_diagnostic_flags_compile_to_frontend() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backend_header = true;
|
||||
c.vhosts[0].backend_timing_header = true;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let frontend = snapshot.frontend("localhost", local, false).unwrap();
|
||||
assert!(frontend.backend_header);
|
||||
assert!(frontend.backend_timing_header);
|
||||
}
|
||||
+195
-195
@@ -1,207 +1,207 @@
|
||||
#[test]
|
||||
fn path_boundaries() {
|
||||
assert!(prefix_matches("/api", "/api"));
|
||||
assert!(prefix_matches("/api", "/api/users"));
|
||||
assert!(!prefix_matches("/api", "/apix"));
|
||||
assert!(prefix_matches("/", "/anything"));
|
||||
#[test]
|
||||
fn path_boundaries() {
|
||||
assert!(prefix_matches("/api", "/api"));
|
||||
assert!(prefix_matches("/api", "/api/users"));
|
||||
assert!(!prefix_matches("/api", "/apix"));
|
||||
assert!(prefix_matches("/", "/anything"));
|
||||
}
|
||||
#[test]
|
||||
fn private_requires_opt_in_and_metadata_always_denied() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].backends[0].allow_private = false;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
for target in [
|
||||
"http://169.254.169.254",
|
||||
"http://100.100.100.200",
|
||||
"http://0.0.0.0",
|
||||
"http://127.0.0.1:9090",
|
||||
"http://[::ffff:169.254.169.254]",
|
||||
"http://[fe80::1]",
|
||||
"http://user:pass@127.0.0.1:9000",
|
||||
] {
|
||||
c.vhosts[0].backends[0].url = target.into();
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}");
|
||||
}
|
||||
#[test]
|
||||
fn private_requires_opt_in_and_metadata_always_denied() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].backends[0].allow_private = false;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
for target in [
|
||||
"http://169.254.169.254",
|
||||
"http://100.100.100.200",
|
||||
"http://0.0.0.0",
|
||||
"http://127.0.0.1:9090",
|
||||
"http://[::ffff:169.254.169.254]",
|
||||
"http://[fe80::1]",
|
||||
"http://user:pass@127.0.0.1:9000",
|
||||
] {
|
||||
c.vhosts[0].backends[0].url = target.into();
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}");
|
||||
}
|
||||
c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into();
|
||||
c.vhosts[0].backends[0].accept_local_listener_risk = true;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].backends[0].allow_private = false;
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn longest_prefix_and_aliases() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut specific = c.vhosts[0].routes[0].clone();
|
||||
specific.id = "api".into();
|
||||
specific.path_prefix = "/api".into();
|
||||
c.vhosts[0].routes.push(specific);
|
||||
let s = compile(c, 7, &policy()).unwrap();
|
||||
assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api");
|
||||
assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main");
|
||||
assert!(s.select("unknown", "/").is_none());
|
||||
assert_eq!(s.envelope.revision, 7);
|
||||
}
|
||||
#[test]
|
||||
fn nginx_style_location_precedence_exact_priority_regex_prefix() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].routes[0].id = "root".into();
|
||||
c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into();
|
||||
c.vhosts[0].backends[0].accept_local_listener_risk = true;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].backends[0].allow_private = false;
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn longest_prefix_and_aliases() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut specific = c.vhosts[0].routes[0].clone();
|
||||
specific.id = "api".into();
|
||||
specific.path_prefix = "/api".into();
|
||||
c.vhosts[0].routes.push(specific);
|
||||
let s = compile(c, 7, &policy()).unwrap();
|
||||
assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api");
|
||||
assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main");
|
||||
assert!(s.select("unknown", "/").is_none());
|
||||
assert_eq!(s.envelope.revision, 7);
|
||||
}
|
||||
#[test]
|
||||
fn nginx_style_location_precedence_exact_priority_regex_prefix() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].routes[0].id = "root".into();
|
||||
|
||||
let mut prefix = c.vhosts[0].routes[0].clone();
|
||||
prefix.id = "api-prefix".into();
|
||||
prefix.path_prefix = "/api".into();
|
||||
let mut prefix = c.vhosts[0].routes[0].clone();
|
||||
prefix.id = "api-prefix".into();
|
||||
prefix.path_prefix = "/api".into();
|
||||
|
||||
let mut regex = c.vhosts[0].routes[0].clone();
|
||||
regex.id = "api-regex".into();
|
||||
regex.match_type = crate::vhost::PathMatch::Regex;
|
||||
regex.path_prefix = r"^/api/v[0-9]+/".into();
|
||||
let mut regex = c.vhosts[0].routes[0].clone();
|
||||
regex.id = "api-regex".into();
|
||||
regex.match_type = crate::vhost::PathMatch::Regex;
|
||||
regex.path_prefix = r"^/api/v[0-9]+/".into();
|
||||
|
||||
let mut exact = c.vhosts[0].routes[0].clone();
|
||||
exact.id = "api-exact".into();
|
||||
exact.match_type = crate::vhost::PathMatch::Exact;
|
||||
exact.path_prefix = "/api/v1/fixed".into();
|
||||
let mut exact = c.vhosts[0].routes[0].clone();
|
||||
exact.id = "api-exact".into();
|
||||
exact.match_type = crate::vhost::PathMatch::Exact;
|
||||
exact.path_prefix = "/api/v1/fixed".into();
|
||||
|
||||
let mut trailing_exact = c.vhosts[0].routes[0].clone();
|
||||
trailing_exact.id = "trailing-exact".into();
|
||||
trailing_exact.match_type = crate::vhost::PathMatch::Exact;
|
||||
trailing_exact.path_prefix = "/trailing/".into();
|
||||
let mut trailing_exact = c.vhosts[0].routes[0].clone();
|
||||
trailing_exact.id = "trailing-exact".into();
|
||||
trailing_exact.match_type = crate::vhost::PathMatch::Exact;
|
||||
trailing_exact.path_prefix = "/trailing/".into();
|
||||
|
||||
c.vhosts[0]
|
||||
.routes
|
||||
.extend([prefix.clone(), regex, exact, trailing_exact]);
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v1/fixed").unwrap().id,
|
||||
"demo/api-exact"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v2/users").unwrap().id,
|
||||
"demo/api-regex"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/plain").unwrap().id,
|
||||
"demo/api-prefix"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/trailing/").unwrap().id,
|
||||
"demo/trailing-exact"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/trailing").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
c.vhosts[0]
|
||||
.routes
|
||||
.extend([prefix.clone(), regex, exact, trailing_exact]);
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v1/fixed").unwrap().id,
|
||||
"demo/api-exact"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v2/users").unwrap().id,
|
||||
"demo/api-regex"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/plain").unwrap().id,
|
||||
"demo/api-prefix"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/trailing/").unwrap().id,
|
||||
"demo/trailing-exact"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/trailing").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
|
||||
c.vhosts[0].routes.retain(|route| route.id != "api-prefix");
|
||||
prefix.match_type = crate::vhost::PathMatch::PrefixPriority;
|
||||
prefix.id = "api-priority".into();
|
||||
c.vhosts[0].routes.push(prefix);
|
||||
let snapshot = compile(c, 2, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v2/users").unwrap().id,
|
||||
"demo/api-priority"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn location_rewrites_are_normalized_and_regex_captures_expand() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let route = &mut c.vhosts[0].routes[0];
|
||||
route.match_type = crate::vhost::PathMatch::Regex;
|
||||
route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into();
|
||||
route.rewrite = crate::vhost::PathRewrite {
|
||||
mode: crate::vhost::RewriteMode::Regex,
|
||||
value: "/service/${1}${2}".into(),
|
||||
};
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", "/api/v42/items").unwrap();
|
||||
assert_eq!(
|
||||
selected
|
||||
.rewritten_path("/api/v42/items")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("/service/42/items")
|
||||
);
|
||||
assert_eq!(
|
||||
selected
|
||||
.rewritten_path("/api/v42/Ubuntu 14.04 LTS")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("/service/42/Ubuntu 14.04 LTS")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn custom_location_response_is_validated() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse {
|
||||
status: 200,
|
||||
content_type: "application/json".into(),
|
||||
body: r#"{"ok":true}"#.into(),
|
||||
});
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200;
|
||||
c.vhosts[0].routes[0]
|
||||
.response
|
||||
.as_mut()
|
||||
c.vhosts[0].routes.retain(|route| route.id != "api-prefix");
|
||||
prefix.match_type = crate::vhost::PathMatch::PrefixPriority;
|
||||
prefix.id = "api-priority".into();
|
||||
c.vhosts[0].routes.push(prefix);
|
||||
let snapshot = compile(c, 2, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v2/users").unwrap().id,
|
||||
"demo/api-priority"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn location_rewrites_are_normalized_and_regex_captures_expand() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let route = &mut c.vhosts[0].routes[0];
|
||||
route.match_type = crate::vhost::PathMatch::Regex;
|
||||
route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into();
|
||||
route.rewrite = crate::vhost::PathRewrite {
|
||||
mode: crate::vhost::RewriteMode::Regex,
|
||||
value: "/service/${1}${2}".into(),
|
||||
};
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", "/api/v42/items").unwrap();
|
||||
assert_eq!(
|
||||
selected
|
||||
.rewritten_path("/api/v42/items")
|
||||
.unwrap()
|
||||
.content_type = "text/plain\r\nx-bad: 1".into();
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn local_response_hosts_reserve_frontends_and_do_not_require_backends() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.not_found_hosts = serde_json::from_value(serde_json::json!([{
|
||||
"id":"missing",
|
||||
"name":"Missing site",
|
||||
"frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]},
|
||||
"page":{"kind":"html","status":404,"html":"<h1>Not found</h1>"}
|
||||
}]))
|
||||
.as_deref(),
|
||||
Some("/service/42/items")
|
||||
);
|
||||
assert_eq!(
|
||||
selected
|
||||
.rewritten_path("/api/v42/Ubuntu 14.04 LTS")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("/service/42/Ubuntu 14.04 LTS")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn custom_location_response_is_validated() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse {
|
||||
status: 200,
|
||||
content_type: "application/json".into(),
|
||||
body: r#"{"ok":true}"#.into(),
|
||||
});
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200;
|
||||
c.vhosts[0].routes[0]
|
||||
.response
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.content_type = "text/plain\r\nx-bad: 1".into();
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn local_response_hosts_reserve_frontends_and_do_not_require_backends() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.not_found_hosts = serde_json::from_value(serde_json::json!([{
|
||||
"id":"missing",
|
||||
"name":"Missing site",
|
||||
"frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]},
|
||||
"page":{"kind":"html","status":404,"html":"<h1>Not found</h1>"}
|
||||
}]))
|
||||
.unwrap();
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let host = snapshot
|
||||
.not_found_host_on("missing.localhost", local, false)
|
||||
.unwrap();
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let host = snapshot
|
||||
.not_found_host_on("missing.localhost", local, false)
|
||||
.unwrap();
|
||||
assert_eq!(host.id, "missing");
|
||||
assert_eq!(host.page.status, Some(404));
|
||||
assert_eq!(host.id, "missing");
|
||||
assert_eq!(host.page.status, Some(404));
|
||||
|
||||
c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()];
|
||||
assert!(compile(c, 2, &policy()).is_err());
|
||||
}
|
||||
c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()];
|
||||
assert!(compile(c, 2, &policy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_are_rejected() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut duplicate = c.vhosts[0].routes[0].clone();
|
||||
duplicate.id = "other".into();
|
||||
c.vhosts[0].routes.push(duplicate);
|
||||
assert!(compile(c, 0, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn tls12_only_vhost_override_is_valid_without_http3_listener() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy {
|
||||
tls12: true,
|
||||
tls13: false,
|
||||
cipher_suites: vec![],
|
||||
});
|
||||
assert!(compile(c, 1, &policy()).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn conflicts_are_rejected() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut duplicate = c.vhosts[0].routes[0].clone();
|
||||
duplicate.id = "other".into();
|
||||
c.vhosts[0].routes.push(duplicate);
|
||||
assert!(compile(c, 0, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn tls12_only_vhost_override_is_valid_without_http3_listener() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy {
|
||||
tls12: true,
|
||||
tls13: false,
|
||||
cipher_suites: vec![],
|
||||
});
|
||||
assert!(compile(c, 1, &policy()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_remote_administration_requires_tls() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.administration.listen = Some("192.0.2.10:9090".parse().unwrap());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.administration.tls = true;
|
||||
assert!(compile(c, 1, &policy()).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() {
|
||||
let mut p = policy();
|
||||
p.admin_addr = "0.0.0.0:9090".parse().unwrap();
|
||||
let c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(c.administration.listen.is_none());
|
||||
assert!(compile(c, 1, &p).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn persisted_remote_administration_requires_tls() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.administration.listen = Some("192.0.2.10:9090".parse().unwrap());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.administration.tls = true;
|
||||
assert!(compile(c, 1, &policy()).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() {
|
||||
let mut p = policy();
|
||||
p.admin_addr = "0.0.0.0:9090".parse().unwrap();
|
||||
let c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(c.administration.listen.is_none());
|
||||
assert!(compile(c, 1, &p).is_ok());
|
||||
}
|
||||
+6
-5
@@ -53,7 +53,8 @@ impl Store {
|
||||
)
|
||||
.bind(revision_i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await? else {
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let document: String = row.try_get("document")?;
|
||||
@@ -75,9 +76,10 @@ impl Store {
|
||||
);
|
||||
(
|
||||
Some(previous.try_get::<i64, _>("revision")?.try_into()?),
|
||||
Some(serde_json::from_str(&previous_document).context(
|
||||
"invalid previous configuration history document",
|
||||
)?),
|
||||
Some(
|
||||
serde_json::from_str(&previous_document)
|
||||
.context("invalid previous configuration history document")?,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
@@ -92,5 +94,4 @@ impl Store {
|
||||
previous_config,
|
||||
}))
|
||||
}
|
||||
|
||||
}
|
||||
+13
-8
@@ -105,12 +105,7 @@ mod tests {
|
||||
let token = "b".repeat(64);
|
||||
let hash = crate::auth::secret_hash(&token);
|
||||
let saved = store
|
||||
.upsert_certificate_api_credential(
|
||||
"certbot-main",
|
||||
&hash,
|
||||
"production certbot",
|
||||
"owner",
|
||||
)
|
||||
.upsert_certificate_api_credential("certbot-main", &hash, "production certbot", "owner")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.key_id, "certbot-main");
|
||||
@@ -171,8 +166,18 @@ mod tests {
|
||||
let remaining = store.certificate_api_credentials().await.unwrap();
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].key_id, "deploy-hook");
|
||||
assert_eq!(store.revoke_all_certificate_api_credentials("owner").await.unwrap(), 1);
|
||||
assert!(store.certificate_api_credentials().await.unwrap().is_empty());
|
||||
assert_eq!(
|
||||
store
|
||||
.revoke_all_certificate_api_credentials("owner")
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(store
|
||||
.certificate_api_credentials()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
|
||||
+27
-5
@@ -88,14 +88,17 @@ static CLOUDFLARE_NETWORKS: LazyLock<Vec<IpNet>> = LazyLock::new(|| {
|
||||
.collect()
|
||||
});
|
||||
|
||||
fn cloudflare_peer(peer: SocketAddr) -> bool {
|
||||
let peer = canonical(peer.ip());
|
||||
CLOUDFLARE_NETWORKS
|
||||
.iter()
|
||||
.any(|network| network.contains(&peer))
|
||||
}
|
||||
|
||||
/// Return CF-Connecting-IP only when the TCP peer itself is in Cloudflare's
|
||||
/// published edge ranges. The header is otherwise untrusted user input.
|
||||
pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option<IpAddr> {
|
||||
let peer = canonical(peer.ip());
|
||||
if !CLOUDFLARE_NETWORKS
|
||||
.iter()
|
||||
.any(|network| network.contains(&peer))
|
||||
{
|
||||
if !cloudflare_peer(peer) {
|
||||
return None;
|
||||
}
|
||||
let mut values = headers.get_all("cf-connecting-ip").iter();
|
||||
@@ -111,6 +114,25 @@ pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option<IpA
|
||||
.ok()
|
||||
.map(canonical)
|
||||
}
|
||||
|
||||
/// Return a trusted ISO country code supplied by Cloudflare. `CF-IPCountry` is
|
||||
/// accepted only from a Cloudflare edge and only as one two-letter code.
|
||||
pub fn cloudflare_country(headers: &HeaderMap, peer: SocketAddr) -> Option<String> {
|
||||
if !cloudflare_peer(peer) {
|
||||
return None;
|
||||
}
|
||||
let mut values = headers.get_all("cf-ipcountry").iter();
|
||||
let value = values.next()?;
|
||||
if values.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let value = value.to_str().ok()?.trim();
|
||||
if value.len() != 2 || !value.bytes().all(|byte| byte.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
let country = value.to_ascii_uppercase();
|
||||
(country != "XX").then_some(country)
|
||||
}
|
||||
fn parse_node(value: &str) -> Option<IpAddr> {
|
||||
let value = value.trim();
|
||||
let value = if value.starts_with('"') {
|
||||
|
||||
@@ -167,6 +167,29 @@ mod tests {
|
||||
assert!(cloudflare_client_ip(&headers, "192.0.2.2:443".parse().unwrap()).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn cloudflare_country_requires_trusted_peer_and_iso_code() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cf-ipcountry", "pl".parse().unwrap());
|
||||
assert_eq!(
|
||||
cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()),
|
||||
Some("PL".into())
|
||||
);
|
||||
assert!(cloudflare_country(&headers, "192.0.2.2:443".parse().unwrap()).is_none());
|
||||
headers.insert("cf-ipcountry", "XX".parse().unwrap());
|
||||
assert!(cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()).is_none());
|
||||
headers.insert("cf-ipcountry", "T1".parse().unwrap());
|
||||
assert!(cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()).is_none());
|
||||
headers.insert("cf-ipcountry", "POL".parse().unwrap());
|
||||
assert!(cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn cloudflare_country_rejects_duplicate_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append("cf-ipcountry", "PL".parse().unwrap());
|
||||
headers.append("cf-ipcountry", "DE".parse().unwrap());
|
||||
assert!(cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn authenticated_client_override_prepends_chain_once() {
|
||||
let mut c = context(Settings::default(), HeaderMap::new(), "104.16.1.2:443");
|
||||
c.override_client_ip("203.0.113.8".parse().unwrap())
|
||||
|
||||
+98
-24
@@ -1,7 +1,12 @@
|
||||
impl IoWrite for RedactingWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { self.buffer.extend_from_slice(bytes); Ok(bytes.len()) }
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.buffer.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
if self.buffer.is_empty() { return Ok(()); }
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let text = String::from_utf8_lossy(&self.buffer);
|
||||
let redacted = redact_text(&text);
|
||||
let mut stderr = io::stderr().lock();
|
||||
@@ -11,10 +16,18 @@ impl IoWrite for RedactingWriter {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for RedactingWriter { fn drop(&mut self) { let _ = self.flush(); } }
|
||||
impl Drop for RedactingWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.flush();
|
||||
}
|
||||
}
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingStderr {
|
||||
type Writer = RedactingWriter;
|
||||
fn make_writer(&'a self) -> Self::Writer { RedactingWriter { buffer: Vec::with_capacity(512) } }
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
RedactingWriter {
|
||||
buffer: Vec::with_capacity(512),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_regex(pattern: &'static str) -> &'static regex::Regex {
|
||||
@@ -55,17 +68,31 @@ fn is_secret_field(name: &str) -> bool {
|
||||
}
|
||||
|
||||
pub fn redact_text(input: &str) -> String {
|
||||
let mut value = redact_regex("pem").replace_all(input, "[REDACTED PRIVATE KEY]").into_owned();
|
||||
value = redact_regex("auth").replace_all(&value, "$1[REDACTED]").into_owned();
|
||||
value = redact_regex("query").replace_all(&value, "$1[REDACTED]").into_owned();
|
||||
value = redact_regex("json").replace_all(&value, "$1[REDACTED]$2").into_owned();
|
||||
redact_regex("kv").replace_all(&value, "$1[REDACTED]").into_owned()
|
||||
let mut value = redact_regex("pem")
|
||||
.replace_all(input, "[REDACTED PRIVATE KEY]")
|
||||
.into_owned();
|
||||
value = redact_regex("auth")
|
||||
.replace_all(&value, "$1[REDACTED]")
|
||||
.into_owned();
|
||||
value = redact_regex("query")
|
||||
.replace_all(&value, "$1[REDACTED]")
|
||||
.into_owned();
|
||||
value = redact_regex("json")
|
||||
.replace_all(&value, "$1[REDACTED]$2")
|
||||
.into_owned();
|
||||
redact_regex("kv")
|
||||
.replace_all(&value, "$1[REDACTED]")
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn redact_value(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(text) => *text = redact_text(text),
|
||||
Value::Array(items) => for item in items { redact_value(item); },
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
redact_value(item);
|
||||
}
|
||||
}
|
||||
Value::Object(items) => {
|
||||
for (key, item) in items {
|
||||
if is_secret_field(key) {
|
||||
@@ -80,7 +107,10 @@ fn redact_value(value: &mut Value) {
|
||||
}
|
||||
|
||||
fn rfc3339_now() -> String {
|
||||
let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64;
|
||||
let ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
let seconds = ms / 1000;
|
||||
let millis = ms % 1000;
|
||||
let days = seconds.div_euclid(86_400);
|
||||
@@ -94,7 +124,9 @@ fn rfc3339_now() -> String {
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = mp + if mp < 10 { 3 } else { -9 };
|
||||
if month <= 2 { year += 1; }
|
||||
if month <= 2 {
|
||||
year += 1;
|
||||
}
|
||||
let hour = day_seconds / 3600;
|
||||
let minute = (day_seconds % 3600) / 60;
|
||||
let second = day_seconds % 60;
|
||||
@@ -119,7 +151,9 @@ async fn open_error_file(path: PathBuf) -> Result<File> {
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(file)
|
||||
}).await.map_err(|_| anyhow::anyhow!("error log open worker failed"))??;
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("error log open worker failed"))??;
|
||||
Ok(File::from_std(file))
|
||||
}
|
||||
|
||||
@@ -223,7 +257,9 @@ async fn error_log_loop(
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(opened) = file.as_mut() else { continue; };
|
||||
let Some(opened) = file.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let line = format_error_event(&event, config.format);
|
||||
if let Err(error) = opened.write_all(&line).await {
|
||||
errors.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -360,7 +396,14 @@ impl Logs {
|
||||
pub fn recent_limit(&self, limit: usize) -> Vec<LogEntry> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|entries| entries.iter().rev().take(limit.min(HISTORY_CAPACITY)).cloned().collect())
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.rev()
|
||||
.take(limit.min(HISTORY_CAPACITY))
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn stats(&self) -> SinkStats {
|
||||
@@ -406,15 +449,46 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
|
||||
"path" => json!(&entry.path),
|
||||
"http_version" => json!(entry.http_version),
|
||||
"transport" => json!(entry.transport),
|
||||
"websocket_bootstrap" => entry.websocket_bootstrap.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
|
||||
"websocket_phase" => entry.websocket_phase.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
|
||||
"websocket_error" => entry.websocket_error.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
|
||||
"h3_reset_code" => entry.h3_reset_code.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
|
||||
"upstream_status" => entry.upstream_status.map(|v| json!(v)).unwrap_or(Value::Null),
|
||||
"upstream_error" => entry.upstream_error.clone().map(Value::String).unwrap_or(Value::Null),
|
||||
"client_ip" => entry.client_ip.clone().map(Value::String).unwrap_or(Value::Null),
|
||||
"user_agent" => entry.user_agent.clone().map(Value::String).unwrap_or(Value::Null),
|
||||
"country" => entry.country.clone().map(Value::String).unwrap_or(Value::Null),
|
||||
"websocket_bootstrap" => entry
|
||||
.websocket_bootstrap
|
||||
.map(|v| Value::String(v.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"websocket_phase" => entry
|
||||
.websocket_phase
|
||||
.map(|v| Value::String(v.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"websocket_error" => entry
|
||||
.websocket_error
|
||||
.map(|v| Value::String(v.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"h3_reset_code" => entry
|
||||
.h3_reset_code
|
||||
.map(|v| Value::String(v.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"upstream_status" => entry
|
||||
.upstream_status
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(Value::Null),
|
||||
"upstream_error" => entry
|
||||
.upstream_error
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"client_ip" => entry
|
||||
.client_ip
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"user_agent" => entry
|
||||
.user_agent
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"country" => entry
|
||||
.country
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"status" => json!(entry.status),
|
||||
"duration_ms" => json!(entry.duration_ms),
|
||||
"request_bytes" => json!(entry.request_bytes),
|
||||
|
||||
+61
-18
@@ -261,7 +261,6 @@ fn source_value_name(name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ErrorLogFormat {
|
||||
#[serde(rename = "JSON")]
|
||||
@@ -270,7 +269,9 @@ pub enum ErrorLogFormat {
|
||||
Raw,
|
||||
}
|
||||
impl Default for ErrorLogFormat {
|
||||
fn default() -> Self { Self::Json }
|
||||
fn default() -> Self {
|
||||
Self::Json
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -282,14 +283,20 @@ pub struct ErrorLogSettings {
|
||||
}
|
||||
impl Default for ErrorLogSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, path: "/var/log/rustproxy/error.log".into(), format: ErrorLogFormat::Json }
|
||||
Self {
|
||||
enabled: false,
|
||||
path: "/var/log/rustproxy/error.log".into(),
|
||||
format: ErrorLogFormat::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ErrorLogSettings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.enabled {
|
||||
ensure!(
|
||||
!self.path.trim().is_empty() && self.path.len() <= 1024 && !self.path.chars().any(char::is_control),
|
||||
!self.path.trim().is_empty()
|
||||
&& self.path.len() <= 1024
|
||||
&& !self.path.chars().any(char::is_control),
|
||||
"error_log.path must be 1..1024 bytes without controls"
|
||||
);
|
||||
}
|
||||
@@ -339,7 +346,14 @@ impl ErrorLogController {
|
||||
written.clone(),
|
||||
errors.clone(),
|
||||
));
|
||||
Arc::new(Self { settings, settings_changed, sender, dropped, written, errors })
|
||||
Arc::new(Self {
|
||||
settings,
|
||||
settings_changed,
|
||||
sender,
|
||||
dropped,
|
||||
written,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
pub fn configure(&self, settings: ErrorLogSettings) {
|
||||
self.settings.store(Arc::new(settings));
|
||||
@@ -363,10 +377,14 @@ impl ErrorLogController {
|
||||
static ERROR_LOG: OnceLock<Arc<ErrorLogController>> = OnceLock::new();
|
||||
|
||||
pub fn install_error_log_controller(controller: Arc<ErrorLogController>) -> Result<()> {
|
||||
ERROR_LOG.set(controller).map_err(|_| anyhow::anyhow!("error logger already initialized"))
|
||||
ERROR_LOG
|
||||
.set(controller)
|
||||
.map_err(|_| anyhow::anyhow!("error logger already initialized"))
|
||||
}
|
||||
pub fn configure_error_log(settings: ErrorLogSettings) {
|
||||
if let Some(controller) = ERROR_LOG.get() { controller.configure(settings); }
|
||||
if let Some(controller) = ERROR_LOG.get() {
|
||||
controller.configure(settings);
|
||||
}
|
||||
}
|
||||
pub fn error_log_stats() -> Option<ErrorLogStats> {
|
||||
ERROR_LOG.get().map(|controller| controller.stats())
|
||||
@@ -377,7 +395,9 @@ pub struct ErrorLogLayer {
|
||||
controller: Arc<ErrorLogController>,
|
||||
}
|
||||
impl ErrorLogLayer {
|
||||
pub fn new(controller: Arc<ErrorLogController>) -> Self { Self { controller } }
|
||||
pub fn new(controller: Arc<ErrorLogController>) -> Self {
|
||||
Self { controller }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -388,21 +408,42 @@ struct EventVisitor {
|
||||
impl tracing::field::Visit for EventVisitor {
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
let value = format!("{value:?}");
|
||||
if field.name() == "message" { self.message = Some(value); }
|
||||
else { self.fields.insert(field.name().to_owned(), value); }
|
||||
if field.name() == "message" {
|
||||
self.message = Some(value);
|
||||
} else {
|
||||
self.fields.insert(field.name().to_owned(), value);
|
||||
}
|
||||
}
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
if field.name() == "message" { self.message = Some(value.to_owned()); }
|
||||
else { self.fields.insert(field.name().to_owned(), value.to_owned()); }
|
||||
if field.name() == "message" {
|
||||
self.message = Some(value.to_owned());
|
||||
} else {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), value.to_owned());
|
||||
}
|
||||
}
|
||||
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), value.to_string());
|
||||
}
|
||||
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), value.to_string());
|
||||
}
|
||||
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), value.to_string());
|
||||
}
|
||||
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
|
||||
self.fields
|
||||
.insert(field.name().to_owned(), value.to_string());
|
||||
}
|
||||
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
|
||||
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
|
||||
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { self.fields.insert(field.name().to_owned(), value.to_string()); }
|
||||
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
|
||||
}
|
||||
impl<S: Subscriber> Layer<S> for ErrorLogLayer {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {
|
||||
if *event.metadata().level() != Level::ERROR { return; }
|
||||
if *event.metadata().level() != Level::ERROR {
|
||||
return;
|
||||
}
|
||||
let mut visitor = EventVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
let mut fields = visitor.fields;
|
||||
@@ -424,4 +465,6 @@ impl<S: Subscriber> Layer<S> for ErrorLogLayer {
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct RedactingStderr;
|
||||
pub struct RedactingWriter { buffer: Vec<u8> }
|
||||
pub struct RedactingWriter {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
+36
-8
@@ -46,12 +46,24 @@ struct FileState {
|
||||
const PER_VHOST_OPEN_FILES: usize = 64;
|
||||
|
||||
fn vhost_log_name(vhost: Option<&str>) -> String {
|
||||
let raw = vhost.filter(|value| !value.is_empty()).unwrap_or("_unmatched");
|
||||
let raw = vhost
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("_unmatched");
|
||||
let mut safe = String::with_capacity(raw.len().min(96));
|
||||
for ch in raw.chars().take(96) {
|
||||
safe.push(if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { ch } else { '_' });
|
||||
safe.push(
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
},
|
||||
);
|
||||
}
|
||||
if safe.is_empty() {
|
||||
"_unmatched".into()
|
||||
} else {
|
||||
safe
|
||||
}
|
||||
if safe.is_empty() { "_unmatched".into() } else { safe }
|
||||
}
|
||||
struct LogstashState {
|
||||
address: String,
|
||||
@@ -93,15 +105,23 @@ async fn sink_loop(
|
||||
if tokio::fs::create_dir_all(&directory).await.is_err() {
|
||||
failed = true;
|
||||
} else {
|
||||
let path = directory.join(format!("{}.jsonl", vhost_log_name(entry.vhost.as_deref())));
|
||||
let path =
|
||||
directory.join(format!("{}.jsonl", vhost_log_name(entry.vhost.as_deref())));
|
||||
if !vhost_files.contains_key(&path) {
|
||||
if vhost_files.len() >= PER_VHOST_OPEN_FILES {
|
||||
if let Some(path) = vhost_files.keys().next().cloned() {
|
||||
vhost_files.remove(&path);
|
||||
}
|
||||
}
|
||||
match OpenOptions::new().create(true).append(true).open(&path).await {
|
||||
Ok(opened) => { vhost_files.insert(path.clone(), opened); }
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await
|
||||
{
|
||||
Ok(opened) => {
|
||||
vhost_files.insert(path.clone(), opened);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, path=%path.display(), "per-vhost access log file unavailable");
|
||||
failed = true;
|
||||
@@ -122,8 +142,16 @@ async fn sink_loop(
|
||||
vhost_directory = None;
|
||||
let path = PathBuf::from(&config.file.path);
|
||||
if file.as_ref().is_none_or(|state| state.path != path) {
|
||||
file = match OpenOptions::new().create(true).append(true).open(&path).await {
|
||||
Ok(opened) => Some(FileState { path: path.clone(), file: opened }),
|
||||
file = match OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await
|
||||
{
|
||||
Ok(opened) => Some(FileState {
|
||||
path: path.clone(),
|
||||
file: opened,
|
||||
}),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, path=%path.display(), "access log file unavailable");
|
||||
None
|
||||
|
||||
@@ -155,6 +155,9 @@ mod tests {
|
||||
}
|
||||
assert_eq!(logs.recent_limit(LIVE_WINDOW).len(), LIVE_WINDOW);
|
||||
assert_eq!(logs.recent().len(), LIVE_WINDOW + 80);
|
||||
assert_eq!(logs.recent_limit(HISTORY_CAPACITY + 1).len(), LIVE_WINDOW + 80);
|
||||
assert_eq!(
|
||||
logs.recent_limit(HISTORY_CAPACITY + 1).len(),
|
||||
LIVE_WINDOW + 80
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -107,9 +107,13 @@ async fn run(
|
||||
let initialize = directory.clone();
|
||||
tokio::task::spawn_blocking(move || rust_proxy::tls::initialize_default(&initialize)).await??;
|
||||
rust_proxy::secret_store::initialize(&data_dir)?;
|
||||
let migrated = rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
|
||||
let migrated =
|
||||
rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
|
||||
if migrated > 0 {
|
||||
tracing::info!(migrated, "legacy ACME credentials migrated to encrypted Secret Store");
|
||||
tracing::info!(
|
||||
migrated,
|
||||
"legacy ACME credentials migrated to encrypted Secret Store"
|
||||
);
|
||||
}
|
||||
let cert_dir = Some(directory.clone());
|
||||
let policy = ValidationPolicy {
|
||||
|
||||
+54
-25
@@ -8,6 +8,12 @@ use hyper_util::{
|
||||
client::legacy::{connect::HttpConnector, Client},
|
||||
rt::TokioExecutor,
|
||||
};
|
||||
use rust_proxy::{
|
||||
auth,
|
||||
config::{self, Config, Envelope, ValidationPolicy},
|
||||
db::Store,
|
||||
runtime::App,
|
||||
};
|
||||
use rustls::{
|
||||
client::{
|
||||
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
||||
@@ -16,12 +22,6 @@ use rustls::{
|
||||
pki_types::{CertificateDer, ServerName, UnixTime},
|
||||
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
|
||||
};
|
||||
use rust_proxy::{
|
||||
auth,
|
||||
config::{self, Config, Envelope, ValidationPolicy},
|
||||
db::Store,
|
||||
runtime::App,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{self, BufRead, IsTerminal, Read, Write};
|
||||
use std::{
|
||||
@@ -34,14 +34,14 @@ use tokio::{
|
||||
net::{TcpListener, UdpSocket},
|
||||
task::JoinSet,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.4"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.5"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
@@ -142,9 +142,7 @@ enum BackupCommand {
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Restore an encrypted full backup atomically. Stop the RustProxy service first.
|
||||
Restore {
|
||||
archive: PathBuf,
|
||||
},
|
||||
Restore { archive: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -241,11 +239,21 @@ fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
fn installed_data_dir_candidates() -> &'static [&'static str] {
|
||||
#[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))]
|
||||
#[cfg(any(
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "dragonfly"
|
||||
))]
|
||||
{
|
||||
return &["/var/db/rustproxy", "/var/lib/rustproxy"];
|
||||
}
|
||||
#[cfg(not(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")))]
|
||||
#[cfg(not(any(
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "dragonfly"
|
||||
)))]
|
||||
{
|
||||
&["/var/lib/rustproxy", "/var/db/rustproxy"]
|
||||
}
|
||||
@@ -302,11 +310,16 @@ async fn application() -> Result<()> {
|
||||
let cli = Cli::from_arg_matches(&matches)?;
|
||||
let data_dir_explicit = explicit(&matches, "data_dir");
|
||||
if cli.livelogs {
|
||||
ensure!(cli.command.is_none(), "--livelogs cannot be combined with a subcommand");
|
||||
ensure!(
|
||||
cli.command.is_none(),
|
||||
"--livelogs cannot be combined with a subcommand"
|
||||
);
|
||||
let data_dir = resolve_installed_data_dir(cli.data_dir, data_dir_explicit);
|
||||
return live_logs_command(data_dir, cli.admin_listen).await;
|
||||
}
|
||||
let command = cli.command.context("a subcommand is required (or use --livelogs)")?;
|
||||
let command = cli
|
||||
.command
|
||||
.context("a subcommand is required (or use --livelogs)")?;
|
||||
match command {
|
||||
Command::Token => {
|
||||
admin_command(
|
||||
@@ -316,7 +329,11 @@ async fn application() -> Result<()> {
|
||||
.await
|
||||
}
|
||||
Command::Admin { action } => {
|
||||
admin_command(resolve_installed_data_dir(cli.data_dir, data_dir_explicit), action).await
|
||||
admin_command(
|
||||
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
|
||||
action,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Command::Tls { action } => {
|
||||
tls_command(
|
||||
@@ -368,15 +385,19 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
|
||||
});
|
||||
let mut generated = false;
|
||||
let password = if io::stdin().is_terminal() {
|
||||
let value = Zeroizing::new(
|
||||
rpassword::prompt_password("Backup password (leave empty to generate): ")?
|
||||
);
|
||||
let value = Zeroizing::new(rpassword::prompt_password(
|
||||
"Backup password (leave empty to generate): ",
|
||||
)?);
|
||||
if value.is_empty() {
|
||||
generated = true;
|
||||
Zeroizing::new(rust_proxy::backup::generated_password())
|
||||
} else {
|
||||
let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
|
||||
ensure!(value.as_str() == confirmation.as_str(), "backup passwords do not match");
|
||||
let confirmation =
|
||||
Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
|
||||
ensure!(
|
||||
value.as_str() == confirmation.as_str(),
|
||||
"backup passwords do not match"
|
||||
);
|
||||
value
|
||||
}
|
||||
} else {
|
||||
@@ -386,10 +407,15 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
|
||||
let summary = rust_proxy::backup::create(&data_dir, &output, password.as_str()).await?;
|
||||
println!(
|
||||
"Encrypted backup created: {} ({} files, configuration revision {})",
|
||||
summary.path.display(), summary.files, summary.configuration_revision
|
||||
summary.path.display(),
|
||||
summary.files,
|
||||
summary.configuration_revision
|
||||
);
|
||||
if generated {
|
||||
println!("Generated backup password (shown once): {}", password.as_str());
|
||||
println!(
|
||||
"Generated backup password (shown once): {}",
|
||||
password.as_str()
|
||||
);
|
||||
println!("Store this password securely; restore is impossible without it.");
|
||||
}
|
||||
Ok(())
|
||||
@@ -401,10 +427,13 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
|
||||
);
|
||||
let password = Zeroizing::new(rpassword::prompt_password("Backup password: ")?);
|
||||
ensure!(!password.is_empty(), "backup password cannot be empty");
|
||||
let summary = rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
|
||||
let summary =
|
||||
rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
|
||||
println!(
|
||||
"Backup restored to {} ({} files, configuration revision {})",
|
||||
data_dir.display(), summary.files, summary.configuration_revision
|
||||
data_dir.display(),
|
||||
summary.files,
|
||||
summary.configuration_revision
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+99
-24
@@ -169,7 +169,10 @@ struct LiveLogFilters {
|
||||
}
|
||||
|
||||
fn log_string<'a>(entry: &'a serde_json::Value, key: &str) -> &'a str {
|
||||
entry.get(key).and_then(|value| value.as_str()).unwrap_or("-")
|
||||
entry
|
||||
.get(key)
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("-")
|
||||
}
|
||||
|
||||
fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
@@ -178,7 +181,10 @@ fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'
|
||||
|
||||
fn live_status_matches(filter: &str, status: u64) -> bool {
|
||||
if let Some(prefix) = filter.strip_suffix("xx") {
|
||||
return prefix.parse::<u64>().ok().is_some_and(|class| status / 100 == class);
|
||||
return prefix
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.is_some_and(|class| status / 100 == class);
|
||||
}
|
||||
filter.parse::<u64>().ok() == Some(status)
|
||||
}
|
||||
@@ -205,7 +211,10 @@ fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0);
|
||||
let status = entry
|
||||
.get("status")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
if filters
|
||||
.status
|
||||
.as_ref()
|
||||
@@ -234,7 +243,10 @@ fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool
|
||||
}
|
||||
|
||||
fn live_time(entry: &serde_json::Value) -> String {
|
||||
let ms = entry.get("time_ms").and_then(|value| value.as_u64()).unwrap_or(0);
|
||||
let ms = entry
|
||||
.get("time_ms")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
let seconds = (ms / 1000) % 86_400;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}Z",
|
||||
@@ -246,8 +258,14 @@ fn live_time(entry: &serde_json::Value) -> String {
|
||||
}
|
||||
|
||||
fn print_live_entry(entry: &serde_json::Value, detail: bool) {
|
||||
let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0);
|
||||
let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0);
|
||||
let id = entry
|
||||
.get("id")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
let status = entry
|
||||
.get("status")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
let duration = entry
|
||||
.get("duration_ms")
|
||||
.and_then(|value| value.as_u64())
|
||||
@@ -304,7 +322,9 @@ fn live_filters_summary(filters: &LiveLogFilters) -> String {
|
||||
let mut values = Vec::new();
|
||||
macro_rules! add {
|
||||
($name:literal, $value:expr) => {
|
||||
if let Some(value) = $value.as_ref() { values.push(format!("{}={}", $name, value)); }
|
||||
if let Some(value) = $value.as_ref() {
|
||||
values.push(format!("{}={}", $name, value));
|
||||
}
|
||||
};
|
||||
}
|
||||
add!("host", filters.host);
|
||||
@@ -317,11 +337,23 @@ fn live_filters_summary(filters: &LiveLogFilters) -> String {
|
||||
add!("cache", filters.cache);
|
||||
add!("status", filters.status);
|
||||
add!("path", filters.path_prefix);
|
||||
if filters.errors_only { values.push("errors=on".into()); }
|
||||
if let Some(ms) = filters.slow_ms { values.push(format!("slow>={ms}ms")); }
|
||||
if filters.detail { values.push("detail=on".into()); }
|
||||
if filters.paused { values.push("paused".into()); }
|
||||
if values.is_empty() { "none".into() } else { values.join(" ") }
|
||||
if filters.errors_only {
|
||||
values.push("errors=on".into());
|
||||
}
|
||||
if let Some(ms) = filters.slow_ms {
|
||||
values.push(format!("slow>={ms}ms"));
|
||||
}
|
||||
if filters.detail {
|
||||
values.push("detail=on".into());
|
||||
}
|
||||
if filters.paused {
|
||||
values.push("paused".into());
|
||||
}
|
||||
if values.is_empty() {
|
||||
"none".into()
|
||||
} else {
|
||||
values.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn live_help() {
|
||||
@@ -356,20 +388,44 @@ fn handle_live_command(
|
||||
match command.as_str() {
|
||||
"help" | "?" => live_help(),
|
||||
"filters" => println!("filters: {}", live_filters_summary(filters)),
|
||||
"reset" => { *filters = LiveLogFilters::default(); println!("filters cleared"); }
|
||||
"pause" => { filters.paused = true; println!("paused"); }
|
||||
"resume" => { filters.paused = false; println!("resumed"); }
|
||||
"clear" => { print!("\x1b[2J\x1b[H"); io::stdout().flush()?; }
|
||||
"reset" => {
|
||||
*filters = LiveLogFilters::default();
|
||||
println!("filters cleared");
|
||||
}
|
||||
"pause" => {
|
||||
filters.paused = true;
|
||||
println!("paused");
|
||||
}
|
||||
"resume" => {
|
||||
filters.paused = false;
|
||||
println!("resumed");
|
||||
}
|
||||
"clear" => {
|
||||
print!("\x1b[2J\x1b[H");
|
||||
io::stdout().flush()?;
|
||||
}
|
||||
"quit" | "exit" | "q" => return Ok(false),
|
||||
"detail" => filters.detail = matches!(value, "on" | "1" | "true"),
|
||||
"errors" => filters.errors_only = matches!(value, "on" | "1" | "true"),
|
||||
"slow" => {
|
||||
filters.slow_ms = if matches!(value, "" | "off" | "*") { None } else { Some(value.parse().context("slow expects milliseconds or off")?) };
|
||||
filters.slow_ms = if matches!(value, "" | "off" | "*") {
|
||||
None
|
||||
} else {
|
||||
Some(value.parse().context("slow expects milliseconds or off")?)
|
||||
};
|
||||
}
|
||||
"status" => {
|
||||
if matches!(value, "" | "*" | "off") { filters.status = None; }
|
||||
else {
|
||||
ensure!(matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx") || value.parse::<u16>().ok().is_some_and(|v| (100..=599).contains(&v)), "status expects 100..599 or 1xx..5xx");
|
||||
if matches!(value, "" | "*" | "off") {
|
||||
filters.status = None;
|
||||
} else {
|
||||
ensure!(
|
||||
matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx")
|
||||
|| value
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.is_some_and(|v| (100..=599).contains(&v)),
|
||||
"status expects 100..599 or 1xx..5xx"
|
||||
);
|
||||
filters.status = Some(value.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
@@ -378,9 +434,19 @@ fn handle_live_command(
|
||||
"backend" => set_live_filter(&mut filters.backend, value),
|
||||
"route" => set_live_filter(&mut filters.route, value),
|
||||
"client" => set_live_filter(&mut filters.client, value),
|
||||
"method" => { set_live_filter(&mut filters.method, value); if let Some(v) = filters.method.as_mut() { *v = v.to_ascii_uppercase(); } },
|
||||
"method" => {
|
||||
set_live_filter(&mut filters.method, value);
|
||||
if let Some(v) = filters.method.as_mut() {
|
||||
*v = v.to_ascii_uppercase();
|
||||
}
|
||||
}
|
||||
"transport" => set_live_filter(&mut filters.transport, value),
|
||||
"cache" => { set_live_filter(&mut filters.cache, value); if let Some(v) = filters.cache.as_mut() { *v = v.to_ascii_uppercase(); } },
|
||||
"cache" => {
|
||||
set_live_filter(&mut filters.cache, value);
|
||||
if let Some(v) = filters.cache.as_mut() {
|
||||
*v = v.to_ascii_uppercase();
|
||||
}
|
||||
}
|
||||
"path" => set_live_filter(&mut filters.path_prefix, value),
|
||||
"show" => {
|
||||
let id: u64 = value.parse().context("show expects a request id")?;
|
||||
@@ -391,7 +457,10 @@ fn handle_live_command(
|
||||
}
|
||||
_ => bail!("unknown live-log command '{command}' (type help)"),
|
||||
}
|
||||
if !matches!(command.as_str(), "help" | "?" | "filters" | "show" | "clear") {
|
||||
if !matches!(
|
||||
command.as_str(),
|
||||
"help" | "?" | "filters" | "show" | "clear"
|
||||
) {
|
||||
println!("filters: {}", live_filters_summary(filters));
|
||||
}
|
||||
Ok(true)
|
||||
@@ -406,7 +475,13 @@ fn take_sse_event(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
let lf = find(b"\n\n").map(|index| (index, 2));
|
||||
let crlf = find(b"\r\n\r\n").map(|index| (index, 4));
|
||||
let (index, delimiter) = match (lf, crlf) {
|
||||
(Some(a), Some(b)) => if a.0 <= b.0 { a } else { b },
|
||||
(Some(a), Some(b)) => {
|
||||
if a.0 <= b.0 {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
(Some(a), None) => a,
|
||||
(None, Some(b)) => b,
|
||||
(None, None) => return None,
|
||||
|
||||
+10
-2
@@ -113,7 +113,11 @@ async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Resu
|
||||
if matches!(&action, TlsCommand::Reload) {
|
||||
auth::prepare_directory(&dir)?;
|
||||
let path = dir.join("config.sqlite3");
|
||||
ensure!(path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display());
|
||||
ensure!(
|
||||
path.is_file(),
|
||||
"database does not exist at {}; start rust-proxy serve first",
|
||||
path.display()
|
||||
);
|
||||
auth::prepare_database(&path)?;
|
||||
let store = Store::open(&path, &Config::default()).await?;
|
||||
let admin = LocalAdminClient::connect(&dir, addr).await?;
|
||||
@@ -150,7 +154,11 @@ async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Resu
|
||||
|
||||
auth::prepare_directory(&dir)?;
|
||||
let database = dir.join("config.sqlite3");
|
||||
ensure!(database.is_file(), "database does not exist at {}; start rust-proxy serve first", database.display());
|
||||
ensure!(
|
||||
database.is_file(),
|
||||
"database does not exist at {}; start rust-proxy serve first",
|
||||
database.display()
|
||||
);
|
||||
auth::prepare_database(&database)?;
|
||||
let store = Store::open(&database, &Config::default()).await?;
|
||||
let admin = LocalAdminClient::connect(&dir, addr).await?;
|
||||
|
||||
@@ -55,7 +55,10 @@ mod cli_regressions {
|
||||
#[test]
|
||||
fn sse_parser_accepts_lf_and_crlf_frames() {
|
||||
let mut lf = b"event: log\ndata: {\"id\":1}\n\nrest".to_vec();
|
||||
assert_eq!(take_sse_event(&mut lf).unwrap(), b"event: log\ndata: {\"id\":1}".to_vec());
|
||||
assert_eq!(
|
||||
take_sse_event(&mut lf).unwrap(),
|
||||
b"event: log\ndata: {\"id\":1}".to_vec()
|
||||
);
|
||||
assert_eq!(lf, b"rest".to_vec());
|
||||
let mut crlf = b"event: gap\r\ndata: {\"dropped\":2}\r\n\r\n".to_vec();
|
||||
let event = take_sse_event(&mut crlf).unwrap();
|
||||
|
||||
+20
-7
@@ -72,9 +72,12 @@ fn local_admin_public_roots() -> RootCertStore {
|
||||
fn local_admin_certificate_pins(data_dir: &Path) -> Result<Vec<[u8; 32]>> {
|
||||
let directory = data_dir.join("certs");
|
||||
let mut pins = Vec::new();
|
||||
for entry in std::fs::read_dir(&directory)
|
||||
.with_context(|| format!("cannot read local certificate directory {}", directory.display()))?
|
||||
{
|
||||
for entry in std::fs::read_dir(&directory).with_context(|| {
|
||||
format!(
|
||||
"cannot read local certificate directory {}",
|
||||
directory.display()
|
||||
)
|
||||
})? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("pem") {
|
||||
@@ -105,7 +108,10 @@ fn local_admin_certificate_pins(data_dir: &Path) -> Result<Vec<[u8; 32]>> {
|
||||
Ok(pins)
|
||||
}
|
||||
|
||||
fn local_admin_scheme_from_probe(status: StatusCode, location: Option<&str>) -> Result<&'static str> {
|
||||
fn local_admin_scheme_from_probe(
|
||||
status: StatusCode,
|
||||
location: Option<&str>,
|
||||
) -> Result<&'static str> {
|
||||
if status == StatusCode::PERMANENT_REDIRECT {
|
||||
ensure!(
|
||||
location.is_some_and(|value| value.starts_with("https://")),
|
||||
@@ -198,10 +204,17 @@ impl LocalAdminClient {
|
||||
}
|
||||
|
||||
async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
ensure!(addr.ip().is_loopback(), "--livelogs requires the local loopback administration listener");
|
||||
ensure!(
|
||||
addr.ip().is_loopback(),
|
||||
"--livelogs requires the local loopback administration listener"
|
||||
);
|
||||
auth::prepare_directory(&dir)?;
|
||||
let path = dir.join("config.sqlite3");
|
||||
ensure!(path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display());
|
||||
ensure!(
|
||||
path.is_file(),
|
||||
"database does not exist at {}; start rust-proxy serve first",
|
||||
path.display()
|
||||
);
|
||||
auth::prepare_database(&path)?;
|
||||
let store = Store::open(&path, &Config::default()).await?;
|
||||
let admin = LocalAdminClient::connect(&dir, addr).await?;
|
||||
@@ -226,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
|
||||
println!("RustProxy live logs 0.25.4 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.25.5 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
if interactive { print!("> "); io::stdout().flush()?; }
|
||||
let mut filters = LiveLogFilters::default();
|
||||
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
|
||||
|
||||
@@ -92,5 +92,3 @@ impl VhostMetrics {
|
||||
}
|
||||
#[cfg(test)]
|
||||
include!("vhost/tests.rs");
|
||||
|
||||
|
||||
+13
-2
@@ -8,9 +8,20 @@ mod tests {
|
||||
enabled: true,
|
||||
http2_enabled: true,
|
||||
http3_enabled: true,
|
||||
frontend: crate::vhost::Frontend { hosts: vec!["missing.example".into()], listener_ids: vec!["http".into()] },
|
||||
frontend: crate::vhost::Frontend {
|
||||
hosts: vec!["missing.example".into()],
|
||||
listener_ids: vec!["http".into()],
|
||||
},
|
||||
certificate_id: None,
|
||||
page: crate::pages::Page { kind, status: Some(status), html: if matches!(kind, crate::pages::Kind::Html) { "<h1>missing</h1>".into() } else { String::new() } },
|
||||
page: crate::pages::Page {
|
||||
kind,
|
||||
status: Some(status),
|
||||
html: if matches!(kind, crate::pages::Kind::Html) {
|
||||
"<h1>missing</h1>".into()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Configured labels only: never export request URLs, queries, users or secrets.
|
||||
include!("prometheus/model.rs");
|
||||
include!("prometheus/core.rs");
|
||||
include!("prometheus/traffic.rs");
|
||||
include!("prometheus/vhosts_backends.rs");
|
||||
include!("prometheus/docker_misc.rs");
|
||||
|
||||
|
||||
+382
-319
@@ -1,370 +1,433 @@
|
||||
fn render_core(app: &App, snapshot: &crate::config::Snapshot, out: &mut String, f: &mut Families) {
|
||||
let config = &snapshot.envelope.config;
|
||||
let settings = &config.prometheus;
|
||||
f.add(
|
||||
"rust_proxy_admin_requests_total",
|
||||
"counter",
|
||||
"Admin HTTP requests, including denied requests and static assets.",
|
||||
"",
|
||||
app.metrics.admin_requests.load(Ordering::Relaxed),
|
||||
);
|
||||
for (index, counter) in app.metrics.admin_status.iter().enumerate() {
|
||||
f.add(
|
||||
"rust_proxy_admin_responses_total",
|
||||
"rust_proxy_admin_requests_total",
|
||||
"counter",
|
||||
"Admin response headers by status class.",
|
||||
&format!("class=\"{}xx\"", index + 1),
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
out.push_str("# HELP rust_proxy_admin_header_duration_seconds Time to control-plane response headers; not SSE lifetime.\n# TYPE rust_proxy_admin_header_duration_seconds summary\n");
|
||||
let _ = writeln!(out, "rust_proxy_admin_header_duration_seconds_count {}\nrust_proxy_admin_header_duration_seconds_sum {:.6}",
|
||||
app.metrics.admin_response_headers.load(Ordering::Relaxed), app.metrics.admin_header_duration_us.load(Ordering::Relaxed) as f64/1e6);
|
||||
f.add(
|
||||
"rust_proxy_build_info",
|
||||
"gauge",
|
||||
"Build version.",
|
||||
&labels(&[("version", env!("CARGO_PKG_VERSION"))]),
|
||||
1,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_uptime_seconds",
|
||||
"gauge",
|
||||
"Process monotonic uptime.",
|
||||
"",
|
||||
app.started.elapsed().as_secs_f64(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhosts_configured",
|
||||
"gauge",
|
||||
"Configured vhosts including disabled.",
|
||||
"",
|
||||
config.vhosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_redirect_hosts_configured",
|
||||
"gauge",
|
||||
"Configured redirect hosts including disabled.",
|
||||
"",
|
||||
config.redirects.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_not_found_hosts_configured",
|
||||
"gauge",
|
||||
"Configured local-response/404 hosts including disabled.",
|
||||
"",
|
||||
config.not_found_hosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_stream_hosts_configured",
|
||||
"gauge",
|
||||
"Configured Layer-4 stream hosts including disabled.",
|
||||
"",
|
||||
config.streams.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_acl_profiles_configured",
|
||||
"gauge",
|
||||
"Configured reusable ACL profiles.",
|
||||
"",
|
||||
config.acl_profiles.len(),
|
||||
);
|
||||
if settings.include_system {
|
||||
let geo = app.geoip.view();
|
||||
f.add(
|
||||
"rust_proxy_geoip_loaded",
|
||||
"gauge",
|
||||
"Whether the bounded country GeoIP database is loaded.",
|
||||
"Admin HTTP requests, including denied requests and static assets.",
|
||||
"",
|
||||
u8::from(geo.loaded),
|
||||
app.metrics.admin_requests.load(Ordering::Relaxed),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_geoip_ipv4_ranges",
|
||||
"gauge",
|
||||
"Loaded IPv4 country ranges.",
|
||||
"",
|
||||
geo.ipv4_ranges,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_geoip_ipv6_ranges",
|
||||
"gauge",
|
||||
"Loaded IPv6 country ranges.",
|
||||
"",
|
||||
geo.ipv6_ranges,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_certificates_configured",
|
||||
"gauge",
|
||||
"Certificate inventory size, not validity.",
|
||||
"",
|
||||
config.tls.certificates.len(),
|
||||
);
|
||||
let renewals = app.acme.renewal_view();
|
||||
for certificate in &config.tls.certificates {
|
||||
let l = labels(&[("certificate", &certificate.id)]);
|
||||
let issuer = certificate.issuer.as_ref();
|
||||
f.add(
|
||||
"rust_proxy_certificate_auto_renew_enabled",
|
||||
"gauge",
|
||||
"Desired automatic renewal switch for a configured certificate.",
|
||||
&l,
|
||||
u8::from(issuer.is_some_and(|issuer| issuer.auto_renew)),
|
||||
);
|
||||
if let Some(issuer) = issuer {
|
||||
for (index, counter) in app.metrics.admin_status.iter().enumerate() {
|
||||
f.add(
|
||||
"rust_proxy_certificate_issuer_info",
|
||||
"gauge",
|
||||
"Bounded configured certificate issuer metadata.",
|
||||
&labels(&[
|
||||
("certificate", &certificate.id),
|
||||
("provider", &issuer.provider),
|
||||
("environment", &issuer.environment),
|
||||
]),
|
||||
1,
|
||||
"rust_proxy_admin_responses_total",
|
||||
"counter",
|
||||
"Admin response headers by status class.",
|
||||
&format!("class=\"{}xx\"", index + 1),
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
if let Some(renewal) = renewals.get(&certificate.id) {
|
||||
for (name, value) in [
|
||||
("last_checked_timestamp_seconds", Some(renewal.last_checked)),
|
||||
("next_check_timestamp_seconds", Some(renewal.next_check)),
|
||||
("last_renewed_timestamp_seconds", renewal.last_renewed),
|
||||
("not_after_timestamp_seconds", renewal.not_after),
|
||||
] {
|
||||
if let Some(value) = value.filter(|value| *value > 0) {
|
||||
f.add(
|
||||
&format!("rust_proxy_certificate_renewal_{name}"),
|
||||
"gauge",
|
||||
"Certificate renewal observation timestamp.",
|
||||
&l,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push_str("# HELP rust_proxy_admin_header_duration_seconds Time to control-plane response headers; not SSE lifetime.\n# TYPE rust_proxy_admin_header_duration_seconds summary\n");
|
||||
let _ = writeln!(out, "rust_proxy_admin_header_duration_seconds_count {}\nrust_proxy_admin_header_duration_seconds_sum {:.6}",
|
||||
app.metrics.admin_response_headers.load(Ordering::Relaxed), app.metrics.admin_header_duration_us.load(Ordering::Relaxed) as f64/1e6);
|
||||
f.add(
|
||||
"rust_proxy_build_info",
|
||||
"gauge",
|
||||
"Build version.",
|
||||
&labels(&[("version", env!("CARGO_PKG_VERSION"))]),
|
||||
1,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_uptime_seconds",
|
||||
"gauge",
|
||||
"Process monotonic uptime.",
|
||||
"",
|
||||
app.started.elapsed().as_secs_f64(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhosts_configured",
|
||||
"gauge",
|
||||
"Configured vhosts including disabled.",
|
||||
"",
|
||||
config.vhosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_redirect_hosts_configured",
|
||||
"gauge",
|
||||
"Configured redirect hosts including disabled.",
|
||||
"",
|
||||
config.redirects.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_not_found_hosts_configured",
|
||||
"gauge",
|
||||
"Configured local-response/404 hosts including disabled.",
|
||||
"",
|
||||
config.not_found_hosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_stream_hosts_configured",
|
||||
"gauge",
|
||||
"Configured Layer-4 stream hosts including disabled.",
|
||||
"",
|
||||
config.streams.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_acl_profiles_configured",
|
||||
"gauge",
|
||||
"Configured reusable ACL profiles.",
|
||||
"",
|
||||
config.acl_profiles.len(),
|
||||
);
|
||||
if settings.include_system {
|
||||
let geo = app.geoip.view();
|
||||
f.add(
|
||||
"rust_proxy_certificate_renewal_failures",
|
||||
"rust_proxy_geoip_loaded",
|
||||
"gauge",
|
||||
"Consecutive observed automatic renewal failures.",
|
||||
&l,
|
||||
renewal.failures,
|
||||
"Whether the bounded country GeoIP database is loaded.",
|
||||
"",
|
||||
u8::from(geo.loaded),
|
||||
);
|
||||
if !renewal.status.is_empty() {
|
||||
f.add(
|
||||
"rust_proxy_geoip_ipv4_ranges",
|
||||
"gauge",
|
||||
"Loaded IPv4 country ranges.",
|
||||
"",
|
||||
geo.ipv4_ranges,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_geoip_ipv6_ranges",
|
||||
"gauge",
|
||||
"Loaded IPv6 country ranges.",
|
||||
"",
|
||||
geo.ipv6_ranges,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_certificates_configured",
|
||||
"gauge",
|
||||
"Certificate inventory size, not validity.",
|
||||
"",
|
||||
config.tls.certificates.len(),
|
||||
);
|
||||
let renewals = app.acme.renewal_view();
|
||||
for certificate in &config.tls.certificates {
|
||||
let l = labels(&[("certificate", &certificate.id)]);
|
||||
let issuer = certificate.issuer.as_ref();
|
||||
f.add(
|
||||
"rust_proxy_certificate_auto_renew_enabled",
|
||||
"gauge",
|
||||
"Desired automatic renewal switch for a configured certificate.",
|
||||
&l,
|
||||
u8::from(issuer.is_some_and(|issuer| issuer.auto_renew)),
|
||||
);
|
||||
if let Some(issuer) = issuer {
|
||||
f.add(
|
||||
"rust_proxy_certificate_renewal_status_info",
|
||||
"rust_proxy_certificate_issuer_info",
|
||||
"gauge",
|
||||
"Latest bounded certificate renewal status.",
|
||||
"Bounded configured certificate issuer metadata.",
|
||||
&labels(&[
|
||||
("certificate", &certificate.id),
|
||||
("status", &renewal.status),
|
||||
("provider", &issuer.provider),
|
||||
("environment", &issuer.environment),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
if let Some(renewal) = renewals.get(&certificate.id) {
|
||||
for (name, value) in [
|
||||
("last_checked_timestamp_seconds", Some(renewal.last_checked)),
|
||||
("next_check_timestamp_seconds", Some(renewal.next_check)),
|
||||
("last_renewed_timestamp_seconds", renewal.last_renewed),
|
||||
("not_after_timestamp_seconds", renewal.not_after),
|
||||
] {
|
||||
if let Some(value) = value.filter(|value| *value > 0) {
|
||||
f.add(
|
||||
&format!("rust_proxy_certificate_renewal_{name}"),
|
||||
"gauge",
|
||||
"Certificate renewal observation timestamp.",
|
||||
&l,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_certificate_renewal_failures",
|
||||
"gauge",
|
||||
"Consecutive observed automatic renewal failures.",
|
||||
&l,
|
||||
renewal.failures,
|
||||
);
|
||||
if !renewal.status.is_empty() {
|
||||
f.add(
|
||||
"rust_proxy_certificate_renewal_status_info",
|
||||
"gauge",
|
||||
"Latest bounded certificate renewal status.",
|
||||
&labels(&[
|
||||
("certificate", &certificate.id),
|
||||
("status", &renewal.status),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_listeners_active",
|
||||
"gauge",
|
||||
"Bound frontend endpoints.",
|
||||
"",
|
||||
app.active_listeners.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_restart_required",
|
||||
"gauge",
|
||||
"Listener/exporter intent differs from startup state.",
|
||||
"",
|
||||
u8::from(
|
||||
snapshot.listeners != app.active_listeners
|
||||
|| settings.restart_differs(&app.active_prometheus),
|
||||
),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_request_capacity",
|
||||
"gauge",
|
||||
"Configured maximum concurrent admitted exchanges.",
|
||||
"",
|
||||
app.max_inflight,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_request_permits_available",
|
||||
"gauge",
|
||||
"Currently available exchange permits.",
|
||||
"",
|
||||
app.inflight.available_permits(),
|
||||
);
|
||||
if let Some(job) = app.acme.view() {
|
||||
f.add(
|
||||
"rust_proxy_acme_job_state",
|
||||
"rust_proxy_listeners_active",
|
||||
"gauge",
|
||||
"Latest ACME job state; no domains or secrets.",
|
||||
&labels(&[("provider", &job.provider), ("state", job.status)]),
|
||||
1,
|
||||
"Bound frontend endpoints.",
|
||||
"",
|
||||
app.active_listeners.len(),
|
||||
);
|
||||
}
|
||||
for (index, listener) in app.active_listeners.iter().enumerate() {
|
||||
let index = index.to_string();
|
||||
let address = listener.address.to_string();
|
||||
let l = labels(&[("listener", &index), ("address", &address)]);
|
||||
for (protocol, enabled) in [
|
||||
("http1", true),
|
||||
("http2", listener.tls && listener.http2),
|
||||
("http3", listener.http3.is_some()),
|
||||
("tls", listener.tls),
|
||||
] {
|
||||
f.add(
|
||||
"rust_proxy_restart_required",
|
||||
"gauge",
|
||||
"Listener/exporter intent differs from startup state.",
|
||||
"",
|
||||
u8::from(
|
||||
snapshot.listeners != app.active_listeners
|
||||
|| settings.restart_differs(&app.active_prometheus),
|
||||
),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_request_capacity",
|
||||
"gauge",
|
||||
"Configured maximum concurrent admitted exchanges.",
|
||||
"",
|
||||
app.max_inflight,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_request_permits_available",
|
||||
"gauge",
|
||||
"Currently available exchange permits.",
|
||||
"",
|
||||
app.inflight.available_permits(),
|
||||
);
|
||||
if let Some(job) = app.acme.view() {
|
||||
f.add(
|
||||
"rust_proxy_listener_protocol_enabled",
|
||||
"rust_proxy_acme_job_state",
|
||||
"gauge",
|
||||
"Protocols active per listener.",
|
||||
&format!("{l},protocol=\"{protocol}\""),
|
||||
u8::from(enabled),
|
||||
"Latest ACME job state; no domains or secrets.",
|
||||
&labels(&[("provider", &job.provider), ("state", job.status)]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (index, listener) in app.active_listeners.iter().enumerate() {
|
||||
let index = index.to_string();
|
||||
let address = listener.address.to_string();
|
||||
let l = labels(&[("listener", &index), ("address", &address)]);
|
||||
for (protocol, enabled) in [
|
||||
("http1", true),
|
||||
("http2", listener.tls && listener.http2),
|
||||
("http3", listener.http3.is_some()),
|
||||
("tls", listener.tls),
|
||||
] {
|
||||
f.add(
|
||||
"rust_proxy_listener_protocol_enabled",
|
||||
"gauge",
|
||||
"Protocols active per listener.",
|
||||
&format!("{l},protocol=\"{protocol}\""),
|
||||
u8::from(enabled),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_vhosts(snapshot: &crate::config::Snapshot, f: &mut Families) {
|
||||
let config = &snapshot.envelope.config;
|
||||
let settings = &config.prometheus;
|
||||
if settings.include_vhosts {
|
||||
for redirect in &config.redirects {
|
||||
let l = labels(&[("redirect", &redirect.id)]);
|
||||
if settings.include_vhosts {
|
||||
f.add(
|
||||
"rust_proxy_redirect_host_enabled",
|
||||
"rust_proxy_web_services_configured",
|
||||
"gauge",
|
||||
"Desired redirect-host enable flag.",
|
||||
&l,
|
||||
u8::from(redirect.enabled),
|
||||
"Configured built-in Web Server services.",
|
||||
"",
|
||||
config.web_services.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_redirect_host_domains",
|
||||
"gauge",
|
||||
"Configured DNS names for a redirect host.",
|
||||
&l,
|
||||
redirect.frontend.hosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_redirect_host_rules",
|
||||
"gauge",
|
||||
"Configured redirect rules.",
|
||||
&l,
|
||||
redirect.rules.len(),
|
||||
);
|
||||
}
|
||||
for host in &config.not_found_hosts {
|
||||
let l = labels(&[("host", &host.id)]);
|
||||
f.add(
|
||||
"rust_proxy_not_found_host_enabled",
|
||||
"gauge",
|
||||
"Desired local-response/404-host enable flag.",
|
||||
&l,
|
||||
u8::from(host.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_not_found_host_domains",
|
||||
"gauge",
|
||||
"Configured DNS names for a local-response/404 host.",
|
||||
&l,
|
||||
host.frontend.hosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_not_found_host_status",
|
||||
"gauge",
|
||||
"Configured local-response HTTP status.",
|
||||
&l,
|
||||
host.page.status.unwrap_or(404),
|
||||
);
|
||||
}
|
||||
for host in &config.vhosts {
|
||||
let l = labels(&[("vhost", &host.id)]);
|
||||
f.add(
|
||||
"rust_proxy_vhost_enabled",
|
||||
"gauge",
|
||||
"Desired vhost enable flag.",
|
||||
&l,
|
||||
u8::from(host.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhost_domains",
|
||||
"gauge",
|
||||
"Configured DNS names for a vhost.",
|
||||
&l,
|
||||
host.frontend.hosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhost_paths",
|
||||
"gauge",
|
||||
"Configured path rules for a vhost.",
|
||||
&l,
|
||||
host.routes.len(),
|
||||
);
|
||||
if let Ok(cache) = host.cache.resolve(&config.cache) {
|
||||
for service in &config.web_services {
|
||||
let php = if service.php.enabled { "true" } else { "false" };
|
||||
let directory_listing = if service.directory_listing {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
f.add(
|
||||
"rust_proxy_vhost_cache_enabled",
|
||||
"rust_proxy_web_service_info",
|
||||
"gauge",
|
||||
"Desired effective cache switch; request privacy rules still apply.",
|
||||
&l,
|
||||
u8::from(cache.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhost_cache_max_object_bytes",
|
||||
"gauge",
|
||||
"Effective maximum cache object size.",
|
||||
&l,
|
||||
cache.max_object_bytes,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhost_cache_max_ttl_seconds",
|
||||
"gauge",
|
||||
"Effective maximum cache TTL.",
|
||||
&l,
|
||||
cache.max_ttl_seconds,
|
||||
);
|
||||
}
|
||||
for domain in &host.frontend.hosts {
|
||||
f.add(
|
||||
"rust_proxy_vhost_domain_info",
|
||||
"gauge",
|
||||
"Configured name ownership.",
|
||||
&labels(&[("vhost", &host.id), ("domain", domain)]),
|
||||
"Bounded built-in Web Server service configuration metadata.",
|
||||
&labels(&[
|
||||
("service", &service.id),
|
||||
("php", php),
|
||||
("directory_listing", directory_listing),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
let m = &snapshot.vhost_metrics[&host.id];
|
||||
for (name, counter) in [
|
||||
("requests_total", &m.requests),
|
||||
("completed_total", &m.completed),
|
||||
("errors_total", &m.errors),
|
||||
("rejected_total", &m.rejected),
|
||||
("request_bytes_total", &m.bytes_in),
|
||||
("response_bytes_total", &m.bytes_out),
|
||||
] {
|
||||
for redirect in &config.redirects {
|
||||
let l = labels(&[("redirect", &redirect.id)]);
|
||||
f.add(
|
||||
&format!("rust_proxy_vhost_{name}"),
|
||||
"counter",
|
||||
"Vhost exchange counter; reset on process restart or vhost removal.",
|
||||
"rust_proxy_redirect_host_enabled",
|
||||
"gauge",
|
||||
"Desired redirect-host enable flag.",
|
||||
&l,
|
||||
counter.load(Ordering::Relaxed),
|
||||
u8::from(redirect.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_redirect_host_domains",
|
||||
"gauge",
|
||||
"Configured DNS names for a redirect host.",
|
||||
&l,
|
||||
redirect.frontend.hosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_redirect_host_rules",
|
||||
"gauge",
|
||||
"Configured redirect rules.",
|
||||
&l,
|
||||
redirect.rules.len(),
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_vhost_active",
|
||||
"gauge",
|
||||
"Admitted unfinished exchanges.",
|
||||
&l,
|
||||
m.active.load(Ordering::Relaxed),
|
||||
);
|
||||
for (index, counter) in m.status.iter().enumerate() {
|
||||
for host in &config.not_found_hosts {
|
||||
let l = labels(&[("host", &host.id)]);
|
||||
f.add(
|
||||
"rust_proxy_vhost_responses_total",
|
||||
"counter",
|
||||
"Completed exchanges by status class.",
|
||||
&format!("{l},class=\"{}xx\"", index + 1),
|
||||
counter.load(Ordering::Relaxed),
|
||||
"rust_proxy_not_found_host_enabled",
|
||||
"gauge",
|
||||
"Desired local-response/404-host enable flag.",
|
||||
&l,
|
||||
u8::from(host.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_not_found_host_domains",
|
||||
"gauge",
|
||||
"Configured DNS names for a local-response/404 host.",
|
||||
&l,
|
||||
host.frontend.hosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_not_found_host_status",
|
||||
"gauge",
|
||||
"Configured local-response HTTP status.",
|
||||
&l,
|
||||
host.page.status.unwrap_or(404),
|
||||
);
|
||||
}
|
||||
for host in &config.vhosts {
|
||||
let l = labels(&[("vhost", &host.id)]);
|
||||
let (source, web_service) = match &host.source {
|
||||
crate::vhost::Source::ReverseProxy => ("reverse_proxy", ""),
|
||||
crate::vhost::Source::WebServer { web_service_id } => {
|
||||
("web_server", web_service_id.as_str())
|
||||
}
|
||||
};
|
||||
f.add(
|
||||
"rust_proxy_vhost_source_info",
|
||||
"gauge",
|
||||
"Configured main serving source for a vhost.",
|
||||
&format!(
|
||||
"{l},source=\"{source}\",web_service=\"{}\"",
|
||||
escaped(web_service)
|
||||
),
|
||||
1,
|
||||
);
|
||||
for route in host.routes.iter().filter(|route| route.enabled) {
|
||||
let (route_source, route_service) = if route.response.is_some() {
|
||||
("local_response", "")
|
||||
} else {
|
||||
match &route.source {
|
||||
crate::vhost::Source::ReverseProxy => ("reverse_proxy", ""),
|
||||
crate::vhost::Source::WebServer { web_service_id } => {
|
||||
("web_server", web_service_id.as_str())
|
||||
}
|
||||
}
|
||||
};
|
||||
f.add(
|
||||
"rust_proxy_vhost_route_source_info",
|
||||
"gauge",
|
||||
"Configured serving source for an enabled vhost route.",
|
||||
&format!(
|
||||
"{l},route=\"{}\",source=\"{route_source}\",web_service=\"{}\"",
|
||||
escaped(&route.id),
|
||||
escaped(route_service)
|
||||
),
|
||||
1,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_vhost_enabled",
|
||||
"gauge",
|
||||
"Desired vhost enable flag.",
|
||||
&l,
|
||||
u8::from(host.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhost_domains",
|
||||
"gauge",
|
||||
"Configured DNS names for a vhost.",
|
||||
&l,
|
||||
host.frontend.hosts.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhost_paths",
|
||||
"gauge",
|
||||
"Configured path rules for a vhost.",
|
||||
&l,
|
||||
host.routes.len(),
|
||||
);
|
||||
if let Ok(cache) = host.cache.resolve(&config.cache) {
|
||||
f.add(
|
||||
"rust_proxy_vhost_cache_enabled",
|
||||
"gauge",
|
||||
"Desired effective cache switch; request privacy rules still apply.",
|
||||
&l,
|
||||
u8::from(cache.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhost_cache_max_object_bytes",
|
||||
"gauge",
|
||||
"Effective maximum cache object size.",
|
||||
&l,
|
||||
cache.max_object_bytes,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_vhost_cache_max_ttl_seconds",
|
||||
"gauge",
|
||||
"Effective maximum cache TTL.",
|
||||
&l,
|
||||
cache.max_ttl_seconds,
|
||||
);
|
||||
}
|
||||
for domain in &host.frontend.hosts {
|
||||
f.add(
|
||||
"rust_proxy_vhost_domain_info",
|
||||
"gauge",
|
||||
"Configured name ownership.",
|
||||
&labels(&[("vhost", &host.id), ("domain", domain)]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
let m = &snapshot.vhost_metrics[&host.id];
|
||||
for (name, counter) in [
|
||||
("requests_total", &m.requests),
|
||||
("completed_total", &m.completed),
|
||||
("errors_total", &m.errors),
|
||||
("rejected_total", &m.rejected),
|
||||
("request_bytes_total", &m.bytes_in),
|
||||
("response_bytes_total", &m.bytes_out),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_vhost_{name}"),
|
||||
"counter",
|
||||
"Vhost exchange counter; reset on process restart or vhost removal.",
|
||||
&l,
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_vhost_active",
|
||||
"gauge",
|
||||
"Admitted unfinished exchanges.",
|
||||
&l,
|
||||
m.active.load(Ordering::Relaxed),
|
||||
);
|
||||
for (index, counter) in m.status.iter().enumerate() {
|
||||
f.add(
|
||||
"rust_proxy_vhost_responses_total",
|
||||
"counter",
|
||||
"Completed exchanges by status class.",
|
||||
&format!("{l},class=\"{}xx\"", index + 1),
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+192
-189
@@ -1,226 +1,228 @@
|
||||
fn render_misc(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families) {
|
||||
let config = &snapshot.envelope.config;
|
||||
let settings = &config.prometheus;
|
||||
if settings.include_streams {
|
||||
for host in &config.streams {
|
||||
let protocol = match host.protocol {
|
||||
crate::stream::Protocol::Tcp => "tcp",
|
||||
crate::stream::Protocol::Udp => "udp",
|
||||
};
|
||||
if settings.include_streams {
|
||||
for host in &config.streams {
|
||||
let protocol = match host.protocol {
|
||||
crate::stream::Protocol::Tcp => "tcp",
|
||||
crate::stream::Protocol::Udp => "udp",
|
||||
};
|
||||
f.add(
|
||||
"rust_proxy_stream_enabled",
|
||||
"gauge",
|
||||
"Desired Layer-4 stream enable flag.",
|
||||
&labels(&[("stream", &host.id), ("protocol", protocol)]),
|
||||
u8::from(host.enabled),
|
||||
);
|
||||
}
|
||||
for host in &app.active_streams {
|
||||
let Some(metrics) = app.stream_metrics.get(&host.id) else {
|
||||
continue;
|
||||
};
|
||||
let protocol = match host.protocol {
|
||||
crate::stream::Protocol::Tcp => "tcp",
|
||||
crate::stream::Protocol::Udp => "udp",
|
||||
};
|
||||
let l = labels(&[("stream", &host.id), ("protocol", protocol)]);
|
||||
let view = metrics.view();
|
||||
for (name, kind, value) in [
|
||||
("sessions_total", "counter", view.sessions_total),
|
||||
(
|
||||
"sessions_completed_total",
|
||||
"counter",
|
||||
view.sessions_completed,
|
||||
),
|
||||
("active", "gauge", view.active),
|
||||
("rejected_total", "counter", view.rejected),
|
||||
("errors_total", "counter", view.errors),
|
||||
("bytes_in_total", "counter", view.bytes_in),
|
||||
("bytes_out_total", "counter", view.bytes_out),
|
||||
("packets_in_total", "counter", view.packets_in),
|
||||
("packets_out_total", "counter", view.packets_out),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_stream_{name}"),
|
||||
kind,
|
||||
"Layer-4 stream observation; reset on process restart.",
|
||||
&l,
|
||||
value,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_stream_max_connections",
|
||||
"gauge",
|
||||
"Configured stream connection/session capacity.",
|
||||
&l,
|
||||
host.max_connections,
|
||||
);
|
||||
}
|
||||
}
|
||||
if settings.include_logging {
|
||||
let stats = app.logs.stats();
|
||||
f.add(
|
||||
"rust_proxy_stream_enabled",
|
||||
"rust_proxy_log_sink_queue_capacity",
|
||||
"gauge",
|
||||
"Desired Layer-4 stream enable flag.",
|
||||
&labels(&[("stream", &host.id), ("protocol", protocol)]),
|
||||
u8::from(host.enabled),
|
||||
"Structured log sink queue capacity.",
|
||||
"",
|
||||
stats.queue_capacity,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_log_sink_queue_dropped_total",
|
||||
"counter",
|
||||
"Structured log entries dropped because the sink queue was full.",
|
||||
"",
|
||||
stats.queue_dropped,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_log_sink_sent_total",
|
||||
"counter",
|
||||
"Structured log entries delivered to configured sinks.",
|
||||
"",
|
||||
stats.sent,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_log_sink_errors_total",
|
||||
"counter",
|
||||
"Structured log sink delivery errors.",
|
||||
"",
|
||||
stats.errors,
|
||||
);
|
||||
}
|
||||
for host in &app.active_streams {
|
||||
let Some(metrics) = app.stream_metrics.get(&host.id) else {
|
||||
continue;
|
||||
};
|
||||
let protocol = match host.protocol {
|
||||
crate::stream::Protocol::Tcp => "tcp",
|
||||
crate::stream::Protocol::Udp => "udp",
|
||||
};
|
||||
let l = labels(&[("stream", &host.id), ("protocol", protocol)]);
|
||||
let view = metrics.view();
|
||||
for (name, kind, value) in [
|
||||
("sessions_total", "counter", view.sessions_total),
|
||||
(
|
||||
"sessions_completed_total",
|
||||
"counter",
|
||||
view.sessions_completed,
|
||||
),
|
||||
("active", "gauge", view.active),
|
||||
("rejected_total", "counter", view.rejected),
|
||||
("errors_total", "counter", view.errors),
|
||||
("bytes_in_total", "counter", view.bytes_in),
|
||||
("bytes_out_total", "counter", view.bytes_out),
|
||||
("packets_in_total", "counter", view.packets_in),
|
||||
("packets_out_total", "counter", view.packets_out),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_stream_{name}"),
|
||||
kind,
|
||||
"Layer-4 stream observation; reset on process restart.",
|
||||
&l,
|
||||
value,
|
||||
);
|
||||
}
|
||||
if settings.include_cache_domains {
|
||||
// Bounded resident inspector. Counter families are gauges deliberately:
|
||||
// these per-domain values reset on config publication, unlike globals.
|
||||
let dashboard = app.cache.dashboard(None, 0, 1);
|
||||
f.add(
|
||||
"rust_proxy_stream_max_connections",
|
||||
"rust_proxy_cache_statistics_revision",
|
||||
"gauge",
|
||||
"Configured stream connection/session capacity.",
|
||||
&l,
|
||||
host.max_connections,
|
||||
"Revision that owns domain statistics.",
|
||||
"",
|
||||
dashboard.statistics_revision,
|
||||
);
|
||||
}
|
||||
}
|
||||
if settings.include_logging {
|
||||
let stats = app.logs.stats();
|
||||
f.add(
|
||||
"rust_proxy_log_sink_queue_capacity",
|
||||
"gauge",
|
||||
"Structured log sink queue capacity.",
|
||||
"",
|
||||
stats.queue_capacity,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_log_sink_queue_dropped_total",
|
||||
"counter",
|
||||
"Structured log entries dropped because the sink queue was full.",
|
||||
"",
|
||||
stats.queue_dropped,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_log_sink_sent_total",
|
||||
"counter",
|
||||
"Structured log entries delivered to configured sinks.",
|
||||
"",
|
||||
stats.sent,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_log_sink_errors_total",
|
||||
"counter",
|
||||
"Structured log sink delivery errors.",
|
||||
"",
|
||||
stats.errors,
|
||||
);
|
||||
}
|
||||
if settings.include_cache_domains {
|
||||
// Bounded resident inspector. Counter families are gauges deliberately:
|
||||
// these per-domain values reset on config publication, unlike globals.
|
||||
let dashboard = app.cache.dashboard(None, 0, 1);
|
||||
f.add(
|
||||
"rust_proxy_cache_statistics_revision",
|
||||
"gauge",
|
||||
"Revision that owns domain statistics.",
|
||||
"",
|
||||
dashboard.statistics_revision,
|
||||
);
|
||||
for domain in dashboard.domains {
|
||||
// Only configured names, never arbitrary Host input or unknown scope.
|
||||
if !config
|
||||
.vhosts
|
||||
.iter()
|
||||
.any(|h| h.id == domain.vhost && h.frontend.hosts.contains(&domain.domain))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let l = labels(&[("vhost", &domain.vhost), ("domain", &domain.domain)]);
|
||||
for (name, n) in [
|
||||
("objects", domain.entries as u64),
|
||||
("hot_objects", domain.hot_entries as u64),
|
||||
("resident_bytes", domain.resident_bytes as u64),
|
||||
("cold_objects", domain.cold_entries as u64),
|
||||
("cold_bytes", domain.cold_bytes),
|
||||
("hits", domain.counters.hits),
|
||||
("misses", domain.counters.misses),
|
||||
("bypasses", domain.counters.bypasses),
|
||||
("stored", domain.counters.stored),
|
||||
("saved_body_bytes", domain.counters.saved_body_bytes),
|
||||
] {
|
||||
for domain in dashboard.domains {
|
||||
// Only configured names, never arbitrary Host input or unknown scope.
|
||||
if !config
|
||||
.vhosts
|
||||
.iter()
|
||||
.any(|h| h.id == domain.vhost && h.frontend.hosts.contains(&domain.domain))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let l = labels(&[("vhost", &domain.vhost), ("domain", &domain.domain)]);
|
||||
for (name, n) in [
|
||||
("objects", domain.entries as u64),
|
||||
("hot_objects", domain.hot_entries as u64),
|
||||
("resident_bytes", domain.resident_bytes as u64),
|
||||
("cold_objects", domain.cold_entries as u64),
|
||||
("cold_bytes", domain.cold_bytes),
|
||||
("hits", domain.counters.hits),
|
||||
("misses", domain.counters.misses),
|
||||
("bypasses", domain.counters.bypasses),
|
||||
("stored", domain.counters.stored),
|
||||
("saved_body_bytes", domain.counters.saved_body_bytes),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_cache_domain_{name}"),
|
||||
"gauge",
|
||||
"Domain cache observation since current configuration revision.",
|
||||
&l,
|
||||
n,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
&format!("rust_proxy_cache_domain_{name}"),
|
||||
"rust_proxy_cache_domain_hit_ratio",
|
||||
"gauge",
|
||||
"Domain cache observation since current configuration revision.",
|
||||
"Domain HIT ratio 0..1.",
|
||||
&l,
|
||||
n,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_cache_domain_hit_ratio",
|
||||
"gauge",
|
||||
"Domain HIT ratio 0..1.",
|
||||
&l,
|
||||
domain.hit_ratio,
|
||||
);
|
||||
for (reason, count) in domain.counters.reasons {
|
||||
f.add(
|
||||
"rust_proxy_cache_domain_decisions",
|
||||
"gauge",
|
||||
"Decision reasons since config publication.",
|
||||
&format!("{l},reason=\"{}\"", escaped(&reason)),
|
||||
count,
|
||||
domain.hit_ratio,
|
||||
);
|
||||
for (reason, count) in domain.counters.reasons {
|
||||
f.add(
|
||||
"rust_proxy_cache_domain_decisions",
|
||||
"gauge",
|
||||
"Decision reasons since config publication.",
|
||||
&format!("{l},reason=\"{}\"", escaped(&reason)),
|
||||
count,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn render_histograms(snapshot: &crate::config::Snapshot, docker_targets: &DockerTargets<'_>, out: &mut String) {
|
||||
fn render_histograms(
|
||||
snapshot: &crate::config::Snapshot,
|
||||
docker_targets: &DockerTargets<'_>,
|
||||
out: &mut String,
|
||||
) {
|
||||
let config = &snapshot.envelope.config;
|
||||
let settings = &config.prometheus;
|
||||
if settings.include_vhosts {
|
||||
out.push_str("# HELP rust_proxy_vhost_exchange_duration_seconds Full exchange lifetime including streaming and cancellation.\n# TYPE rust_proxy_vhost_exchange_duration_seconds histogram\n");
|
||||
for (id, m) in &snapshot.vhost_metrics {
|
||||
if docker_targets.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
let l = labels(&[("vhost", id)]);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"rust_proxy_vhost_exchange_duration_seconds_sum{{{l}}} {:.6}",
|
||||
m.duration_us.load(Ordering::Relaxed) as f64 / 1e6
|
||||
);
|
||||
// Read the cumulative last bucket once for +Inf and count.
|
||||
let count =
|
||||
m.buckets[crate::metrics::DURATION_BUCKETS_US.len()].load(Ordering::Relaxed);
|
||||
let mut previous = 0;
|
||||
for (i, threshold) in crate::metrics::DURATION_BUCKETS_US.iter().enumerate() {
|
||||
let value = m.buckets[i]
|
||||
.load(Ordering::Relaxed)
|
||||
.max(previous)
|
||||
.min(count);
|
||||
previous = value;
|
||||
if settings.include_vhosts {
|
||||
out.push_str("# HELP rust_proxy_vhost_exchange_duration_seconds Full exchange lifetime including streaming and cancellation.\n# TYPE rust_proxy_vhost_exchange_duration_seconds histogram\n");
|
||||
for (id, m) in &snapshot.vhost_metrics {
|
||||
if docker_targets.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
let l = labels(&[("vhost", id)]);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"rust_proxy_vhost_exchange_duration_seconds_bucket{{{l},le=\"{}\"}} {value}",
|
||||
*threshold as f64 / 1e6
|
||||
"rust_proxy_vhost_exchange_duration_seconds_sum{{{l}}} {:.6}",
|
||||
m.duration_us.load(Ordering::Relaxed) as f64 / 1e6
|
||||
);
|
||||
// Read the cumulative last bucket once for +Inf and count.
|
||||
let count =
|
||||
m.buckets[crate::metrics::DURATION_BUCKETS_US.len()].load(Ordering::Relaxed);
|
||||
let mut previous = 0;
|
||||
for (i, threshold) in crate::metrics::DURATION_BUCKETS_US.iter().enumerate() {
|
||||
let value = m.buckets[i]
|
||||
.load(Ordering::Relaxed)
|
||||
.max(previous)
|
||||
.min(count);
|
||||
previous = value;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"rust_proxy_vhost_exchange_duration_seconds_bucket{{{l},le=\"{}\"}} {value}",
|
||||
*threshold as f64 / 1e6
|
||||
);
|
||||
}
|
||||
let _ = writeln!(out, "rust_proxy_vhost_exchange_duration_seconds_bucket{{{l},le=\"+Inf\"}} {count}\nrust_proxy_vhost_exchange_duration_seconds_count{{{l}}} {count}");
|
||||
}
|
||||
let _ = writeln!(out, "rust_proxy_vhost_exchange_duration_seconds_bucket{{{l},le=\"+Inf\"}} {count}\nrust_proxy_vhost_exchange_duration_seconds_count{{{l}}} {count}");
|
||||
}
|
||||
}
|
||||
if settings.include_docker_vhosts {
|
||||
out.push_str("# HELP rust_proxy_docker_vhost_exchange_duration_seconds Full Docker-vhost exchange lifetime including streaming and cancellation.\n# TYPE rust_proxy_docker_vhost_exchange_duration_seconds histogram\n");
|
||||
for (id, m) in &snapshot.vhost_metrics {
|
||||
let Some((agent, assignment)) = docker_targets.get(id) else {
|
||||
continue;
|
||||
};
|
||||
let l = labels(&[
|
||||
("agent", &agent.id),
|
||||
("vhost", id),
|
||||
("service", &assignment.service_key),
|
||||
]);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"rust_proxy_docker_vhost_exchange_duration_seconds_sum{{{l}}} {:.6}",
|
||||
m.duration_us.load(Ordering::Relaxed) as f64 / 1e6
|
||||
);
|
||||
let count =
|
||||
m.buckets[crate::metrics::DURATION_BUCKETS_US.len()].load(Ordering::Relaxed);
|
||||
let mut previous = 0;
|
||||
for (i, threshold) in crate::metrics::DURATION_BUCKETS_US.iter().enumerate() {
|
||||
let value = m.buckets[i]
|
||||
.load(Ordering::Relaxed)
|
||||
.max(previous)
|
||||
.min(count);
|
||||
previous = value;
|
||||
if settings.include_docker_vhosts {
|
||||
out.push_str("# HELP rust_proxy_docker_vhost_exchange_duration_seconds Full Docker-vhost exchange lifetime including streaming and cancellation.\n# TYPE rust_proxy_docker_vhost_exchange_duration_seconds histogram\n");
|
||||
for (id, m) in &snapshot.vhost_metrics {
|
||||
let Some((agent, assignment)) = docker_targets.get(id) else {
|
||||
continue;
|
||||
};
|
||||
let l = labels(&[
|
||||
("agent", &agent.id),
|
||||
("vhost", id),
|
||||
("service", &assignment.service_key),
|
||||
]);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"rust_proxy_docker_vhost_exchange_duration_seconds_sum{{{l}}} {:.6}",
|
||||
m.duration_us.load(Ordering::Relaxed) as f64 / 1e6
|
||||
);
|
||||
let count =
|
||||
m.buckets[crate::metrics::DURATION_BUCKETS_US.len()].load(Ordering::Relaxed);
|
||||
let mut previous = 0;
|
||||
for (i, threshold) in crate::metrics::DURATION_BUCKETS_US.iter().enumerate() {
|
||||
let value = m.buckets[i]
|
||||
.load(Ordering::Relaxed)
|
||||
.max(previous)
|
||||
.min(count);
|
||||
previous = value;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"rust_proxy_docker_vhost_exchange_duration_seconds_bucket{{{l},le=\"{}\"}} {value}",
|
||||
*threshold as f64 / 1e6
|
||||
);
|
||||
}
|
||||
let _ = writeln!(out, "rust_proxy_docker_vhost_exchange_duration_seconds_bucket{{{l},le=\"+Inf\"}} {count}\nrust_proxy_docker_vhost_exchange_duration_seconds_count{{{l}}} {count}");
|
||||
}
|
||||
let _ = writeln!(out, "rust_proxy_docker_vhost_exchange_duration_seconds_bucket{{{l},le=\"+Inf\"}} {count}\nrust_proxy_docker_vhost_exchange_duration_seconds_count{{{l}}} {count}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pub fn render(app: &App) -> String {
|
||||
let snapshot = app.config.load_full();
|
||||
let config = &snapshot.envelope.config;
|
||||
@@ -236,6 +238,7 @@ pub fn render(app: &App) -> String {
|
||||
let mut families = Families::default();
|
||||
render_core(app, &snapshot, &mut out, &mut families);
|
||||
render_vhosts(&snapshot, &mut families);
|
||||
render_traffic(app, &snapshot, &mut families);
|
||||
render_backends(&snapshot, &docker_targets, &mut families);
|
||||
render_docker(&snapshot, &docker_targets, &mut families);
|
||||
render_misc(app, &snapshot, &mut families);
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct Settings {
|
||||
pub allow_trusted_network_without_token: bool,
|
||||
pub include_system: bool,
|
||||
pub include_vhosts: bool,
|
||||
pub include_traffic: bool,
|
||||
pub include_backends: bool,
|
||||
pub include_docker_vhosts: bool,
|
||||
pub include_cache_domains: bool,
|
||||
@@ -46,6 +47,7 @@ impl Default for Settings {
|
||||
allow_trusted_network_without_token: false,
|
||||
include_system: true,
|
||||
include_vhosts: true,
|
||||
include_traffic: true,
|
||||
include_backends: true,
|
||||
include_docker_vhosts: true,
|
||||
include_cache_domains: true,
|
||||
@@ -252,7 +254,8 @@ impl Families {
|
||||
}
|
||||
}
|
||||
|
||||
type DockerTargets<'a> = BTreeMap<String, (&'a crate::docker::Agent, &'a crate::docker::Assignment)>;
|
||||
type DockerTargets<'a> =
|
||||
BTreeMap<String, (&'a crate::docker::Agent, &'a crate::docker::Assignment)>;
|
||||
|
||||
fn docker_targets(config: &crate::config::Config) -> DockerTargets<'_> {
|
||||
let mut targets = BTreeMap::new();
|
||||
|
||||
@@ -4,6 +4,7 @@ mod tests {
|
||||
fn secure_defaults_and_paths() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(!settings.enabled);
|
||||
assert!(settings.include_traffic);
|
||||
settings.validate().unwrap();
|
||||
settings.enabled = true;
|
||||
settings.listen = "0.0.0.0:9191".parse().unwrap();
|
||||
@@ -27,6 +28,21 @@ mod tests {
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn traffic_labels_are_bounded() {
|
||||
assert!(safe_country("PL"));
|
||||
assert!(!safe_country("pl"));
|
||||
assert!(!safe_country("POL"));
|
||||
assert!(safe_traffic_state(
|
||||
"web_server",
|
||||
&["web_server", "reverse_proxy"]
|
||||
));
|
||||
assert!(!safe_traffic_state(
|
||||
"attacker-value",
|
||||
&["web_server", "reverse_proxy"]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_and_grouping() {
|
||||
assert_eq!(escaped("a\"b\\c\nd"), "a\\\"b\\\\c\\nd");
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
fn safe_country(value: &str) -> bool {
|
||||
value.len() == 2 && value.bytes().all(|byte| byte.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
fn safe_traffic_state(value: &str, allowed: &[&str]) -> bool {
|
||||
allowed.contains(&value)
|
||||
}
|
||||
|
||||
fn render_traffic(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families) {
|
||||
let config = &snapshot.envelope.config;
|
||||
if !config.prometheus.include_traffic {
|
||||
return;
|
||||
}
|
||||
|
||||
let overall = app.traffic.view(None);
|
||||
f.add(
|
||||
"rust_proxy_traffic_user_agent_capture_enabled",
|
||||
"gauge",
|
||||
"Whether Traffic/logging User-Agent capture is enabled.",
|
||||
"",
|
||||
u8::from(config.logging.capture_user_agent),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_traffic_country_capture_enabled",
|
||||
"gauge",
|
||||
"Whether Traffic/logging country capture is enabled.",
|
||||
"",
|
||||
u8::from(config.logging.capture_country),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_traffic_queue_capacity",
|
||||
"gauge",
|
||||
"Bounded asynchronous Traffic analytics queue capacity.",
|
||||
"",
|
||||
overall.queue_capacity,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_traffic_queue_dropped_total",
|
||||
"counter",
|
||||
"Traffic analytics samples dropped because the bounded queue was full.",
|
||||
"",
|
||||
overall.queue_dropped,
|
||||
);
|
||||
|
||||
let summaries = app.traffic.summaries();
|
||||
for host in &config.vhosts {
|
||||
let Some(data) = summaries.get(&host.id) else {
|
||||
continue;
|
||||
};
|
||||
let base = labels(&[("vhost", &host.id)]);
|
||||
for (name, help, value) in [
|
||||
(
|
||||
"rust_proxy_traffic_requests_total",
|
||||
"Completed requests retained by bounded Traffic analytics.",
|
||||
data.requests,
|
||||
),
|
||||
(
|
||||
"rust_proxy_traffic_errors_total",
|
||||
"Completed 5xx requests retained by bounded Traffic analytics.",
|
||||
data.errors,
|
||||
),
|
||||
(
|
||||
"rust_proxy_traffic_request_bytes_total",
|
||||
"Request body bytes retained by bounded Traffic analytics.",
|
||||
data.request_bytes,
|
||||
),
|
||||
(
|
||||
"rust_proxy_traffic_response_bytes_total",
|
||||
"Response body bytes retained by bounded Traffic analytics.",
|
||||
data.response_bytes,
|
||||
),
|
||||
(
|
||||
"rust_proxy_traffic_duration_milliseconds_total",
|
||||
"Accumulated request duration retained by bounded Traffic analytics.",
|
||||
data.duration_ms,
|
||||
),
|
||||
] {
|
||||
f.add(name, "counter", help, &base, value);
|
||||
}
|
||||
|
||||
for entry in &data.countries {
|
||||
if safe_country(&entry.value) {
|
||||
f.add(
|
||||
"rust_proxy_traffic_country_requests",
|
||||
"gauge",
|
||||
"Approximate bounded top-country request counts since Traffic analytics reset.",
|
||||
&format!("{base},country=\"{}\"", escaped(&entry.value)),
|
||||
entry.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
for entry in &data.user_agent_families {
|
||||
if safe_traffic_state(
|
||||
&entry.value,
|
||||
&[
|
||||
"bot", "curl", "wget", "python", "go", "java", "postman", "edge", "opera",
|
||||
"chrome", "firefox", "safari", "other",
|
||||
],
|
||||
) {
|
||||
f.add(
|
||||
"rust_proxy_traffic_user_agent_family_requests_total",
|
||||
"counter",
|
||||
"Requests grouped into a fixed User-Agent family set; raw User-Agent values are never Prometheus labels.",
|
||||
&format!("{base},family=\"{}\"", entry.value),
|
||||
entry.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
for entry in &data.method_families {
|
||||
if safe_traffic_state(
|
||||
&entry.value,
|
||||
&[
|
||||
"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "TRACE",
|
||||
"OTHER",
|
||||
],
|
||||
) {
|
||||
f.add(
|
||||
"rust_proxy_traffic_method_requests_total",
|
||||
"counter",
|
||||
"Requests grouped into a fixed HTTP method set.",
|
||||
&format!("{base},method=\"{}\"", entry.value),
|
||||
entry.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
for entry in &data.sources {
|
||||
if safe_traffic_state(
|
||||
&entry.value,
|
||||
&[
|
||||
"reverse_proxy",
|
||||
"web_server",
|
||||
"local_response",
|
||||
"unresolved",
|
||||
],
|
||||
) {
|
||||
f.add(
|
||||
"rust_proxy_traffic_source_requests_total",
|
||||
"counter",
|
||||
"Requests grouped by bounded serving source, including the built-in Web Server.",
|
||||
&format!("{base},source=\"{}\"", entry.value),
|
||||
entry.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
for entry in &data.statuses {
|
||||
if safe_traffic_state(&entry.value, &["1xx", "2xx", "3xx", "4xx", "5xx"]) {
|
||||
f.add(
|
||||
"rust_proxy_traffic_status_class_requests_total",
|
||||
"counter",
|
||||
"Requests grouped by HTTP response status class.",
|
||||
&format!("{base},class=\"{}\"", entry.value),
|
||||
entry.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
for entry in &data.cache {
|
||||
if safe_traffic_state(&entry.value, &["HIT", "MISS", "BYPASS", "-"]) {
|
||||
f.add(
|
||||
"rust_proxy_traffic_cache_requests_total",
|
||||
"counter",
|
||||
"Requests grouped by bounded cache outcome.",
|
||||
&format!("{base},cache=\"{}\"", escaped(&entry.value)),
|
||||
entry.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
for entry in &data.routes {
|
||||
let configured = entry.value == "(main)"
|
||||
|| host
|
||||
.routes
|
||||
.iter()
|
||||
.any(|route| route.enabled && route.id == entry.value);
|
||||
if configured {
|
||||
f.add(
|
||||
"rust_proxy_traffic_route_requests",
|
||||
"gauge",
|
||||
"Approximate bounded top-route request counts for currently configured route identifiers.",
|
||||
&format!("{base},route=\"{}\"", escaped(&entry.value)),
|
||||
entry.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+356
-286
@@ -1,253 +1,15 @@
|
||||
fn render_backends(snapshot: &crate::config::Snapshot, docker_targets: &DockerTargets<'_>, f: &mut Families) {
|
||||
fn render_backends(
|
||||
snapshot: &crate::config::Snapshot,
|
||||
docker_targets: &DockerTargets<'_>,
|
||||
f: &mut Families,
|
||||
) {
|
||||
let config = &snapshot.envelope.config;
|
||||
let settings = &config.prometheus;
|
||||
if settings.include_backends {
|
||||
for pool in &snapshot.pools {
|
||||
if docker_targets.contains_key(&pool.id) {
|
||||
continue;
|
||||
}
|
||||
let view = pool.view();
|
||||
if let (Some(algorithm), Some(health_mode), Some(sticky_mode)) = (
|
||||
view["algorithm"].as_str(),
|
||||
view["health_mode"].as_str(),
|
||||
view["sticky_mode"].as_str(),
|
||||
) {
|
||||
f.add(
|
||||
"rust_proxy_backend_pool_info",
|
||||
"gauge",
|
||||
"Bounded backend-pool selection and health policy.",
|
||||
&labels(&[
|
||||
("vhost", &pool.id),
|
||||
("algorithm", algorithm),
|
||||
("health_mode", health_mode),
|
||||
("sticky_mode", sticky_mode),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_backend_pool_ready",
|
||||
"gauge",
|
||||
"At least one healthy eligible pool member.",
|
||||
&labels(&[("vhost", &pool.id)]),
|
||||
u8::from(pool.ready()),
|
||||
);
|
||||
if let Some(members) = view["members"].as_array() {
|
||||
for member in members {
|
||||
let l = labels(&[
|
||||
("vhost", &pool.id),
|
||||
("backend", member["id"].as_str().unwrap_or("unknown")),
|
||||
]);
|
||||
let status = member["status"].as_str().unwrap_or("unknown");
|
||||
f.add(
|
||||
"rust_proxy_backend_status_info",
|
||||
"gauge",
|
||||
"Latest bounded backend selection/health state.",
|
||||
&format!("{l},status=\"{}\"", escaped(status)),
|
||||
1,
|
||||
);
|
||||
for (name, flag) in [
|
||||
(
|
||||
"up",
|
||||
status != "disabled" && member["healthy"].as_bool().unwrap_or(false),
|
||||
),
|
||||
("draining", status == "draining"),
|
||||
("backup", member["backup"].as_bool().unwrap_or(false)),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_backend_{name}"),
|
||||
"gauge",
|
||||
"Backend health or selection state.",
|
||||
&l,
|
||||
u8::from(flag),
|
||||
);
|
||||
}
|
||||
for (field, metric, kind) in [
|
||||
("active", "active_exchanges", "gauge"),
|
||||
("requests", "requests_total", "counter"),
|
||||
("errors", "errors_total", "counter"),
|
||||
("bytes_in", "bytes_in_total", "counter"),
|
||||
("bytes_out", "bytes_out_total", "counter"),
|
||||
("weight", "weight", "gauge"),
|
||||
("failures", "consecutive_failures", "gauge"),
|
||||
("passive_failures", "passive_failures", "gauge"),
|
||||
("successes", "consecutive_successes", "gauge"),
|
||||
("passive_successes", "passive_successes", "gauge"),
|
||||
("transitions", "health_transitions_total", "counter"),
|
||||
("active_health_failures_total", "active_health_failures_total", "counter"),
|
||||
("passive_health_failures_total", "passive_health_failures_total", "counter"),
|
||||
("http_5xx_total", "http_5xx_total", "counter"),
|
||||
("http_5xx_total", "passive_http_5xx_total", "counter"),
|
||||
("timeouts_total", "timeouts_total", "counter"),
|
||||
("timeouts_total", "passive_timeouts_total", "counter"),
|
||||
("connect_errors_total", "connect_errors_total", "counter"),
|
||||
("connect_errors_total", "passive_connect_errors_total", "counter"),
|
||||
("io_errors_total", "io_errors_total", "counter"),
|
||||
("io_errors_total", "passive_io_errors_total", "counter"),
|
||||
("slow_responses_total", "slow_responses_total", "counter"),
|
||||
("slow_responses_total", "passive_slow_total", "counter"),
|
||||
("capacity_rejections_total", "capacity_rejections_total", "counter"),
|
||||
("queue_timeouts_total", "queue_timeouts_total", "counter"),
|
||||
("dns_errors_total", "dns_errors_total", "counter"),
|
||||
("tls_errors_total", "tls_errors_total", "counter"),
|
||||
("response_header_timeouts_total", "response_header_timeouts_total", "counter"),
|
||||
("read_timeouts_total", "read_timeouts_total", "counter"),
|
||||
("send_timeouts_total", "send_timeouts_total", "counter"),
|
||||
("max_concurrent_requests", "max_concurrent_requests", "gauge"),
|
||||
("queue_timeout_ms", "queue_timeout_milliseconds", "gauge"),
|
||||
("last_status", "last_http_status", "gauge"),
|
||||
] {
|
||||
if let Some(n) = member[field].as_u64() {
|
||||
f.add(
|
||||
&format!("rust_proxy_backend_{metric}"),
|
||||
kind,
|
||||
"Backend observation.",
|
||||
&l,
|
||||
n,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (field, metric, divisor) in [
|
||||
("latency_ms", "latency_seconds", 1000.0),
|
||||
("ewma_latency_ms", "ewma_latency_seconds", 1000.0),
|
||||
("last_probe_ms", "last_probe_timestamp_seconds", 1000.0),
|
||||
("last_failure_at_ms", "last_failure_timestamp_seconds", 1000.0),
|
||||
("last_ejected_ms", "last_ejected_timestamp_seconds", 1000.0),
|
||||
("last_recovered_ms", "last_recovered_timestamp_seconds", 1000.0),
|
||||
("traffic_bytes_per_second", "traffic_bytes_per_second", 1.0),
|
||||
] {
|
||||
if let Some(n) = member[field].as_f64() {
|
||||
f.add(
|
||||
&format!("rust_proxy_backend_{metric}"),
|
||||
"gauge",
|
||||
"Backend moving observation.",
|
||||
&l,
|
||||
n / divisor,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(result) = member["last_result"]
|
||||
.as_str()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
f.add(
|
||||
"rust_proxy_backend_last_probe_result_info",
|
||||
"gauge",
|
||||
"Latest bounded backend health-probe result.",
|
||||
&format!("{l},result=\"{}\"", escaped(result)),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn render_docker(snapshot: &crate::config::Snapshot, docker_targets: &DockerTargets<'_>, f: &mut Families) {
|
||||
let config = &snapshot.envelope.config;
|
||||
let settings = &config.prometheus;
|
||||
if settings.include_docker_vhosts {
|
||||
f.add(
|
||||
"rust_proxy_docker_agents_configured",
|
||||
"gauge",
|
||||
"Configured Docker discovery agents including disabled agents.",
|
||||
"",
|
||||
config.docker_agents.agents.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_docker_vhosts_configured",
|
||||
"gauge",
|
||||
"Configured Docker vhost assignments including disabled assignments.",
|
||||
"",
|
||||
docker_targets.len(),
|
||||
);
|
||||
for agent in &config.docker_agents.agents {
|
||||
let agent_labels = labels(&[("agent", &agent.id)]);
|
||||
f.add(
|
||||
"rust_proxy_docker_agent_enabled",
|
||||
"gauge",
|
||||
"Desired Docker agent enable flag.",
|
||||
&agent_labels,
|
||||
u8::from(agent.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_docker_agent_assignments",
|
||||
"gauge",
|
||||
"Configured Docker vhost assignments for an agent.",
|
||||
&agent_labels,
|
||||
agent.assignments.len(),
|
||||
);
|
||||
for assignment in &agent.assignments {
|
||||
let vhost = crate::docker::generated_id(&agent.id, &assignment.service_key);
|
||||
let base_labels = labels(&[
|
||||
("agent", &agent.id),
|
||||
("vhost", &vhost),
|
||||
("service", &assignment.service_key),
|
||||
]);
|
||||
let domains = crate::docker::assignment_hosts(assignment, &agent.base_domain)
|
||||
.unwrap_or_default();
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_enabled",
|
||||
"gauge",
|
||||
"Desired Docker vhost assignment enable flag.",
|
||||
&base_labels,
|
||||
u8::from(agent.enabled && assignment.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_domains",
|
||||
"gauge",
|
||||
"Configured DNS names for a Docker vhost assignment.",
|
||||
&base_labels,
|
||||
domains.len(),
|
||||
);
|
||||
for domain in domains {
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_domain_info",
|
||||
"gauge",
|
||||
"Configured Docker vhost name ownership.",
|
||||
&format!("{base_labels},domain=\"{}\"", escaped(&domain)),
|
||||
1,
|
||||
);
|
||||
}
|
||||
if let Some(m) = snapshot.vhost_metrics.get(&vhost) {
|
||||
for (name, counter) in [
|
||||
("requests_total", &m.requests),
|
||||
("completed_total", &m.completed),
|
||||
("errors_total", &m.errors),
|
||||
("rejected_total", &m.rejected),
|
||||
("request_bytes_total", &m.bytes_in),
|
||||
("response_bytes_total", &m.bytes_out),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_docker_vhost_{name}"),
|
||||
"counter",
|
||||
"Docker vhost exchange counter; reset on process restart or assignment removal.",
|
||||
&base_labels,
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_active",
|
||||
"gauge",
|
||||
"Admitted unfinished Docker-vhost exchanges.",
|
||||
&base_labels,
|
||||
m.active.load(Ordering::Relaxed),
|
||||
);
|
||||
for (index, counter) in m.status.iter().enumerate() {
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_responses_total",
|
||||
"counter",
|
||||
"Completed Docker-vhost exchanges by status class.",
|
||||
&format!("{base_labels},class=\"{}xx\"", index + 1),
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(pool) = snapshot.pools.iter().find(|pool| pool.id == vhost) else {
|
||||
if settings.include_backends {
|
||||
for pool in &snapshot.pools {
|
||||
if docker_targets.contains_key(&pool.id) {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
let view = pool.view();
|
||||
if let (Some(algorithm), Some(health_mode), Some(sticky_mode)) = (
|
||||
view["algorithm"].as_str(),
|
||||
@@ -255,52 +17,52 @@ if settings.include_docker_vhosts {
|
||||
view["sticky_mode"].as_str(),
|
||||
) {
|
||||
f.add(
|
||||
"rust_proxy_docker_backend_pool_info",
|
||||
"rust_proxy_backend_pool_info",
|
||||
"gauge",
|
||||
"Bounded Docker backend-pool selection and health policy.",
|
||||
&format!(
|
||||
"{base_labels},algorithm=\"{}\",health_mode=\"{}\",sticky_mode=\"{}\"",
|
||||
escaped(algorithm),
|
||||
escaped(health_mode),
|
||||
escaped(sticky_mode)
|
||||
),
|
||||
"Bounded backend-pool selection and health policy.",
|
||||
&labels(&[
|
||||
("vhost", &pool.id),
|
||||
("algorithm", algorithm),
|
||||
("health_mode", health_mode),
|
||||
("sticky_mode", sticky_mode),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_docker_backend_pool_ready",
|
||||
"rust_proxy_backend_pool_ready",
|
||||
"gauge",
|
||||
"At least one healthy eligible Docker backend member.",
|
||||
&base_labels,
|
||||
"At least one healthy eligible pool member.",
|
||||
&labels(&[("vhost", &pool.id)]),
|
||||
u8::from(pool.ready()),
|
||||
);
|
||||
if let Some(members) = view["members"].as_array() {
|
||||
for member in members {
|
||||
let backend = member["id"].as_str().unwrap_or("unknown");
|
||||
let member_labels =
|
||||
format!("{base_labels},backend=\"{}\"", escaped(backend));
|
||||
let l = labels(&[
|
||||
("vhost", &pool.id),
|
||||
("backend", member["id"].as_str().unwrap_or("unknown")),
|
||||
]);
|
||||
let status = member["status"].as_str().unwrap_or("unknown");
|
||||
f.add(
|
||||
"rust_proxy_docker_backend_status_info",
|
||||
"rust_proxy_backend_status_info",
|
||||
"gauge",
|
||||
"Latest bounded Docker backend selection/health state.",
|
||||
&format!("{member_labels},status=\"{}\"", escaped(status)),
|
||||
"Latest bounded backend selection/health state.",
|
||||
&format!("{l},status=\"{}\"", escaped(status)),
|
||||
1,
|
||||
);
|
||||
for (name, flag) in [
|
||||
(
|
||||
"up",
|
||||
status != "disabled"
|
||||
&& member["healthy"].as_bool().unwrap_or(false),
|
||||
status != "disabled" && member["healthy"].as_bool().unwrap_or(false),
|
||||
),
|
||||
("draining", status == "draining"),
|
||||
("backup", member["backup"].as_bool().unwrap_or(false)),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_docker_backend_{name}"),
|
||||
&format!("rust_proxy_backend_{name}"),
|
||||
"gauge",
|
||||
"Docker backend health or selection state.",
|
||||
&member_labels,
|
||||
"Backend health or selection state.",
|
||||
&l,
|
||||
u8::from(flag),
|
||||
);
|
||||
}
|
||||
@@ -316,35 +78,59 @@ if settings.include_docker_vhosts {
|
||||
("successes", "consecutive_successes", "gauge"),
|
||||
("passive_successes", "passive_successes", "gauge"),
|
||||
("transitions", "health_transitions_total", "counter"),
|
||||
("active_health_failures_total", "active_health_failures_total", "counter"),
|
||||
("passive_health_failures_total", "passive_health_failures_total", "counter"),
|
||||
(
|
||||
"active_health_failures_total",
|
||||
"active_health_failures_total",
|
||||
"counter",
|
||||
),
|
||||
(
|
||||
"passive_health_failures_total",
|
||||
"passive_health_failures_total",
|
||||
"counter",
|
||||
),
|
||||
("http_5xx_total", "http_5xx_total", "counter"),
|
||||
("http_5xx_total", "passive_http_5xx_total", "counter"),
|
||||
("timeouts_total", "timeouts_total", "counter"),
|
||||
("timeouts_total", "passive_timeouts_total", "counter"),
|
||||
("connect_errors_total", "connect_errors_total", "counter"),
|
||||
("connect_errors_total", "passive_connect_errors_total", "counter"),
|
||||
(
|
||||
"connect_errors_total",
|
||||
"passive_connect_errors_total",
|
||||
"counter",
|
||||
),
|
||||
("io_errors_total", "io_errors_total", "counter"),
|
||||
("io_errors_total", "passive_io_errors_total", "counter"),
|
||||
("slow_responses_total", "slow_responses_total", "counter"),
|
||||
("slow_responses_total", "passive_slow_total", "counter"),
|
||||
("capacity_rejections_total", "capacity_rejections_total", "counter"),
|
||||
(
|
||||
"capacity_rejections_total",
|
||||
"capacity_rejections_total",
|
||||
"counter",
|
||||
),
|
||||
("queue_timeouts_total", "queue_timeouts_total", "counter"),
|
||||
("dns_errors_total", "dns_errors_total", "counter"),
|
||||
("tls_errors_total", "tls_errors_total", "counter"),
|
||||
("response_header_timeouts_total", "response_header_timeouts_total", "counter"),
|
||||
(
|
||||
"response_header_timeouts_total",
|
||||
"response_header_timeouts_total",
|
||||
"counter",
|
||||
),
|
||||
("read_timeouts_total", "read_timeouts_total", "counter"),
|
||||
("send_timeouts_total", "send_timeouts_total", "counter"),
|
||||
("max_concurrent_requests", "max_concurrent_requests", "gauge"),
|
||||
(
|
||||
"max_concurrent_requests",
|
||||
"max_concurrent_requests",
|
||||
"gauge",
|
||||
),
|
||||
("queue_timeout_ms", "queue_timeout_milliseconds", "gauge"),
|
||||
("last_status", "last_http_status", "gauge"),
|
||||
] {
|
||||
if let Some(n) = member[field].as_u64() {
|
||||
f.add(
|
||||
&format!("rust_proxy_docker_backend_{metric}"),
|
||||
&format!("rust_proxy_backend_{metric}"),
|
||||
kind,
|
||||
"Docker backend observation.",
|
||||
&member_labels,
|
||||
"Backend observation.",
|
||||
&l,
|
||||
n,
|
||||
);
|
||||
}
|
||||
@@ -353,17 +139,25 @@ if settings.include_docker_vhosts {
|
||||
("latency_ms", "latency_seconds", 1000.0),
|
||||
("ewma_latency_ms", "ewma_latency_seconds", 1000.0),
|
||||
("last_probe_ms", "last_probe_timestamp_seconds", 1000.0),
|
||||
("last_failure_at_ms", "last_failure_timestamp_seconds", 1000.0),
|
||||
(
|
||||
"last_failure_at_ms",
|
||||
"last_failure_timestamp_seconds",
|
||||
1000.0,
|
||||
),
|
||||
("last_ejected_ms", "last_ejected_timestamp_seconds", 1000.0),
|
||||
("last_recovered_ms", "last_recovered_timestamp_seconds", 1000.0),
|
||||
(
|
||||
"last_recovered_ms",
|
||||
"last_recovered_timestamp_seconds",
|
||||
1000.0,
|
||||
),
|
||||
("traffic_bytes_per_second", "traffic_bytes_per_second", 1.0),
|
||||
] {
|
||||
if let Some(n) = member[field].as_f64() {
|
||||
f.add(
|
||||
&format!("rust_proxy_docker_backend_{metric}"),
|
||||
&format!("rust_proxy_backend_{metric}"),
|
||||
"gauge",
|
||||
"Docker backend moving observation.",
|
||||
&member_labels,
|
||||
"Backend moving observation.",
|
||||
&l,
|
||||
n / divisor,
|
||||
);
|
||||
}
|
||||
@@ -373,10 +167,10 @@ if settings.include_docker_vhosts {
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
f.add(
|
||||
"rust_proxy_docker_backend_last_probe_result_info",
|
||||
"rust_proxy_backend_last_probe_result_info",
|
||||
"gauge",
|
||||
"Latest bounded Docker backend health-probe result.",
|
||||
&format!("{member_labels},result=\"{}\"", escaped(result)),
|
||||
"Latest bounded backend health-probe result.",
|
||||
&format!("{l},result=\"{}\"", escaped(result)),
|
||||
1,
|
||||
);
|
||||
}
|
||||
@@ -386,4 +180,280 @@ if settings.include_docker_vhosts {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_docker(
|
||||
snapshot: &crate::config::Snapshot,
|
||||
docker_targets: &DockerTargets<'_>,
|
||||
f: &mut Families,
|
||||
) {
|
||||
let config = &snapshot.envelope.config;
|
||||
let settings = &config.prometheus;
|
||||
if settings.include_docker_vhosts {
|
||||
f.add(
|
||||
"rust_proxy_docker_agents_configured",
|
||||
"gauge",
|
||||
"Configured Docker discovery agents including disabled agents.",
|
||||
"",
|
||||
config.docker_agents.agents.len(),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_docker_vhosts_configured",
|
||||
"gauge",
|
||||
"Configured Docker vhost assignments including disabled assignments.",
|
||||
"",
|
||||
docker_targets.len(),
|
||||
);
|
||||
for agent in &config.docker_agents.agents {
|
||||
let agent_labels = labels(&[("agent", &agent.id)]);
|
||||
f.add(
|
||||
"rust_proxy_docker_agent_enabled",
|
||||
"gauge",
|
||||
"Desired Docker agent enable flag.",
|
||||
&agent_labels,
|
||||
u8::from(agent.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_docker_agent_assignments",
|
||||
"gauge",
|
||||
"Configured Docker vhost assignments for an agent.",
|
||||
&agent_labels,
|
||||
agent.assignments.len(),
|
||||
);
|
||||
for assignment in &agent.assignments {
|
||||
let vhost = crate::docker::generated_id(&agent.id, &assignment.service_key);
|
||||
let base_labels = labels(&[
|
||||
("agent", &agent.id),
|
||||
("vhost", &vhost),
|
||||
("service", &assignment.service_key),
|
||||
]);
|
||||
let domains = crate::docker::assignment_hosts(assignment, &agent.base_domain)
|
||||
.unwrap_or_default();
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_enabled",
|
||||
"gauge",
|
||||
"Desired Docker vhost assignment enable flag.",
|
||||
&base_labels,
|
||||
u8::from(agent.enabled && assignment.enabled),
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_domains",
|
||||
"gauge",
|
||||
"Configured DNS names for a Docker vhost assignment.",
|
||||
&base_labels,
|
||||
domains.len(),
|
||||
);
|
||||
for domain in domains {
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_domain_info",
|
||||
"gauge",
|
||||
"Configured Docker vhost name ownership.",
|
||||
&format!("{base_labels},domain=\"{}\"", escaped(&domain)),
|
||||
1,
|
||||
);
|
||||
}
|
||||
if let Some(m) = snapshot.vhost_metrics.get(&vhost) {
|
||||
for (name, counter) in [
|
||||
("requests_total", &m.requests),
|
||||
("completed_total", &m.completed),
|
||||
("errors_total", &m.errors),
|
||||
("rejected_total", &m.rejected),
|
||||
("request_bytes_total", &m.bytes_in),
|
||||
("response_bytes_total", &m.bytes_out),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_docker_vhost_{name}"),
|
||||
"counter",
|
||||
"Docker vhost exchange counter; reset on process restart or assignment removal.",
|
||||
&base_labels,
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_active",
|
||||
"gauge",
|
||||
"Admitted unfinished Docker-vhost exchanges.",
|
||||
&base_labels,
|
||||
m.active.load(Ordering::Relaxed),
|
||||
);
|
||||
for (index, counter) in m.status.iter().enumerate() {
|
||||
f.add(
|
||||
"rust_proxy_docker_vhost_responses_total",
|
||||
"counter",
|
||||
"Completed Docker-vhost exchanges by status class.",
|
||||
&format!("{base_labels},class=\"{}xx\"", index + 1),
|
||||
counter.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(pool) = snapshot.pools.iter().find(|pool| pool.id == vhost) else {
|
||||
continue;
|
||||
};
|
||||
let view = pool.view();
|
||||
if let (Some(algorithm), Some(health_mode), Some(sticky_mode)) = (
|
||||
view["algorithm"].as_str(),
|
||||
view["health_mode"].as_str(),
|
||||
view["sticky_mode"].as_str(),
|
||||
) {
|
||||
f.add(
|
||||
"rust_proxy_docker_backend_pool_info",
|
||||
"gauge",
|
||||
"Bounded Docker backend-pool selection and health policy.",
|
||||
&format!(
|
||||
"{base_labels},algorithm=\"{}\",health_mode=\"{}\",sticky_mode=\"{}\"",
|
||||
escaped(algorithm),
|
||||
escaped(health_mode),
|
||||
escaped(sticky_mode)
|
||||
),
|
||||
1,
|
||||
);
|
||||
}
|
||||
f.add(
|
||||
"rust_proxy_docker_backend_pool_ready",
|
||||
"gauge",
|
||||
"At least one healthy eligible Docker backend member.",
|
||||
&base_labels,
|
||||
u8::from(pool.ready()),
|
||||
);
|
||||
if let Some(members) = view["members"].as_array() {
|
||||
for member in members {
|
||||
let backend = member["id"].as_str().unwrap_or("unknown");
|
||||
let member_labels =
|
||||
format!("{base_labels},backend=\"{}\"", escaped(backend));
|
||||
let status = member["status"].as_str().unwrap_or("unknown");
|
||||
f.add(
|
||||
"rust_proxy_docker_backend_status_info",
|
||||
"gauge",
|
||||
"Latest bounded Docker backend selection/health state.",
|
||||
&format!("{member_labels},status=\"{}\"", escaped(status)),
|
||||
1,
|
||||
);
|
||||
for (name, flag) in [
|
||||
(
|
||||
"up",
|
||||
status != "disabled"
|
||||
&& member["healthy"].as_bool().unwrap_or(false),
|
||||
),
|
||||
("draining", status == "draining"),
|
||||
("backup", member["backup"].as_bool().unwrap_or(false)),
|
||||
] {
|
||||
f.add(
|
||||
&format!("rust_proxy_docker_backend_{name}"),
|
||||
"gauge",
|
||||
"Docker backend health or selection state.",
|
||||
&member_labels,
|
||||
u8::from(flag),
|
||||
);
|
||||
}
|
||||
for (field, metric, kind) in [
|
||||
("active", "active_exchanges", "gauge"),
|
||||
("requests", "requests_total", "counter"),
|
||||
("errors", "errors_total", "counter"),
|
||||
("bytes_in", "bytes_in_total", "counter"),
|
||||
("bytes_out", "bytes_out_total", "counter"),
|
||||
("weight", "weight", "gauge"),
|
||||
("failures", "consecutive_failures", "gauge"),
|
||||
("passive_failures", "passive_failures", "gauge"),
|
||||
("successes", "consecutive_successes", "gauge"),
|
||||
("passive_successes", "passive_successes", "gauge"),
|
||||
("transitions", "health_transitions_total", "counter"),
|
||||
(
|
||||
"active_health_failures_total",
|
||||
"active_health_failures_total",
|
||||
"counter",
|
||||
),
|
||||
(
|
||||
"passive_health_failures_total",
|
||||
"passive_health_failures_total",
|
||||
"counter",
|
||||
),
|
||||
("http_5xx_total", "http_5xx_total", "counter"),
|
||||
("http_5xx_total", "passive_http_5xx_total", "counter"),
|
||||
("timeouts_total", "timeouts_total", "counter"),
|
||||
("timeouts_total", "passive_timeouts_total", "counter"),
|
||||
("connect_errors_total", "connect_errors_total", "counter"),
|
||||
(
|
||||
"connect_errors_total",
|
||||
"passive_connect_errors_total",
|
||||
"counter",
|
||||
),
|
||||
("io_errors_total", "io_errors_total", "counter"),
|
||||
("io_errors_total", "passive_io_errors_total", "counter"),
|
||||
("slow_responses_total", "slow_responses_total", "counter"),
|
||||
("slow_responses_total", "passive_slow_total", "counter"),
|
||||
(
|
||||
"capacity_rejections_total",
|
||||
"capacity_rejections_total",
|
||||
"counter",
|
||||
),
|
||||
("queue_timeouts_total", "queue_timeouts_total", "counter"),
|
||||
("dns_errors_total", "dns_errors_total", "counter"),
|
||||
("tls_errors_total", "tls_errors_total", "counter"),
|
||||
(
|
||||
"response_header_timeouts_total",
|
||||
"response_header_timeouts_total",
|
||||
"counter",
|
||||
),
|
||||
("read_timeouts_total", "read_timeouts_total", "counter"),
|
||||
("send_timeouts_total", "send_timeouts_total", "counter"),
|
||||
(
|
||||
"max_concurrent_requests",
|
||||
"max_concurrent_requests",
|
||||
"gauge",
|
||||
),
|
||||
("queue_timeout_ms", "queue_timeout_milliseconds", "gauge"),
|
||||
("last_status", "last_http_status", "gauge"),
|
||||
] {
|
||||
if let Some(n) = member[field].as_u64() {
|
||||
f.add(
|
||||
&format!("rust_proxy_docker_backend_{metric}"),
|
||||
kind,
|
||||
"Docker backend observation.",
|
||||
&member_labels,
|
||||
n,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (field, metric, divisor) in [
|
||||
("latency_ms", "latency_seconds", 1000.0),
|
||||
("ewma_latency_ms", "ewma_latency_seconds", 1000.0),
|
||||
("last_probe_ms", "last_probe_timestamp_seconds", 1000.0),
|
||||
(
|
||||
"last_failure_at_ms",
|
||||
"last_failure_timestamp_seconds",
|
||||
1000.0,
|
||||
),
|
||||
("last_ejected_ms", "last_ejected_timestamp_seconds", 1000.0),
|
||||
(
|
||||
"last_recovered_ms",
|
||||
"last_recovered_timestamp_seconds",
|
||||
1000.0,
|
||||
),
|
||||
("traffic_bytes_per_second", "traffic_bytes_per_second", 1.0),
|
||||
] {
|
||||
if let Some(n) = member[field].as_f64() {
|
||||
f.add(
|
||||
&format!("rust_proxy_docker_backend_{metric}"),
|
||||
"gauge",
|
||||
"Docker backend moving observation.",
|
||||
&member_labels,
|
||||
n / divisor,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(result) = member["last_result"]
|
||||
.as_str()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
f.add(
|
||||
"rust_proxy_docker_backend_last_probe_result_info",
|
||||
"gauge",
|
||||
"Latest bounded Docker backend health-probe result.",
|
||||
&format!("{member_labels},result=\"{}\"", escaped(result)),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-10
@@ -25,6 +25,7 @@ pub struct Exchange {
|
||||
pub vhost_metrics: Option<Arc<crate::metrics::VhostMetrics>>,
|
||||
pub vhost: Option<String>,
|
||||
pub route: Mutex<Option<String>>,
|
||||
pub source: Mutex<&'static str>,
|
||||
pub cache_effective_enabled: Mutex<Option<bool>>,
|
||||
pub transform_effective_enabled: Mutex<Option<bool>>,
|
||||
pub backend: Mutex<Option<Arc<crate::balance::Lease>>>,
|
||||
@@ -60,13 +61,22 @@ pub struct Exchange {
|
||||
impl Exchange {
|
||||
pub fn websocket_phase(&self, phase: &'static str) {
|
||||
if self.websocket_bootstrap.is_some() {
|
||||
*self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()) = Some(phase);
|
||||
*self
|
||||
.websocket_phase
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(phase);
|
||||
}
|
||||
}
|
||||
pub fn websocket_error(&self, phase: &'static str, error: &'static str) {
|
||||
if self.websocket_bootstrap.is_some() {
|
||||
*self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()) = Some(phase);
|
||||
*self.websocket_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(error);
|
||||
*self
|
||||
.websocket_phase
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(phase);
|
||||
*self
|
||||
.websocket_error
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(error);
|
||||
}
|
||||
}
|
||||
pub fn h3_reset(&self, code: &'static str) {
|
||||
@@ -130,12 +140,30 @@ impl Drop for Exchange {
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.map(|l| l.member.backend.id.clone());
|
||||
let websocket_phase = *self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let websocket_phase = *self
|
||||
.websocket_phase
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let route = self.route.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let cache_effective_enabled = *self.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let transform_effective_enabled = *self.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let client_ip = self.client_ip.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let country = self.country.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let source = *self.source.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cache_effective_enabled = *self
|
||||
.cache_effective_enabled
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let transform_effective_enabled = *self
|
||||
.transform_effective_enabled
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let client_ip = self
|
||||
.client_ip
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
let country = self
|
||||
.country
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
let request_bytes = self.request_bytes.load(Ordering::Relaxed);
|
||||
let response_bytes = self.response_bytes.load(Ordering::Relaxed);
|
||||
let duration_ms = duration.as_millis() as u64;
|
||||
@@ -168,6 +196,7 @@ impl Drop for Exchange {
|
||||
request_bytes,
|
||||
response_bytes,
|
||||
cache,
|
||||
source,
|
||||
user_agent: self.user_agent.clone(),
|
||||
country: country.clone(),
|
||||
});
|
||||
@@ -189,13 +218,20 @@ impl Drop for Exchange {
|
||||
transport: self.transport,
|
||||
websocket_bootstrap: self.websocket_bootstrap,
|
||||
websocket_phase,
|
||||
websocket_error: *self.websocket_error.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
websocket_error: *self
|
||||
.websocket_error
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()),
|
||||
h3_reset_code: *self.h3_reset_code.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
upstream_status: match self.upstream_status.load(Ordering::Relaxed) {
|
||||
0 => None,
|
||||
value => Some(value),
|
||||
},
|
||||
upstream_error: self.upstream_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||
upstream_error: self
|
||||
.upstream_error
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
client_ip,
|
||||
user_agent: self.user_agent.clone(),
|
||||
country,
|
||||
|
||||
@@ -11,6 +11,7 @@ mod tests {
|
||||
vhost_metrics: None,
|
||||
vhost: None,
|
||||
route: Mutex::new(None),
|
||||
source: Mutex::new("unresolved"),
|
||||
cache_effective_enabled: Mutex::new(None),
|
||||
transform_effective_enabled: Mutex::new(None),
|
||||
backend: Mutex::new(None),
|
||||
|
||||
@@ -42,10 +42,8 @@ fn schedule_proactive_refresh(
|
||||
delay = 5;
|
||||
continue;
|
||||
};
|
||||
let Some(revalidation_guard) = context
|
||||
.state
|
||||
.cache
|
||||
.begin_background_revalidation(&cached)
|
||||
let Some(revalidation_guard) =
|
||||
context.state.cache.begin_background_revalidation(&cached)
|
||||
else {
|
||||
drop(inflight_guard);
|
||||
delay = 1;
|
||||
@@ -120,10 +118,7 @@ fn error(status: StatusCode, message: &'static str) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
fn method_policy_denial(
|
||||
policy: &crate::vhost::MethodPolicy,
|
||||
method: &Method,
|
||||
) -> Option<Response> {
|
||||
fn method_policy_denial(policy: &crate::vhost::MethodPolicy, method: &Method) -> Option<Response> {
|
||||
if policy.allows(method) {
|
||||
return None;
|
||||
}
|
||||
@@ -142,7 +137,11 @@ fn local_location_response(spec: &crate::vhost::LocationResponse, method: &Metho
|
||||
let body_allowed = !matches!(status.as_u16(), 100..=199 | 204 | 304) && *method != Method::HEAD;
|
||||
let mut response = Response::builder()
|
||||
.status(status)
|
||||
.body(if body_allowed { Body::from(spec.body.clone()) } else { Body::empty() })
|
||||
.body(if body_allowed {
|
||||
Body::from(spec.body.clone())
|
||||
} else {
|
||||
Body::empty()
|
||||
})
|
||||
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()).into_response());
|
||||
if let Ok(value) = HeaderValue::from_str(&spec.content_type) {
|
||||
response.headers_mut().insert("content-type", value);
|
||||
@@ -191,8 +190,7 @@ fn admin_session_passthrough(
|
||||
tls: bool,
|
||||
backend: &crate::config::CompiledBackend,
|
||||
) -> bool {
|
||||
tls
|
||||
&& backend.accept_local_listener_risk
|
||||
tls && backend.accept_local_listener_risk
|
||||
&& backend.targets_admin_listener
|
||||
&& snapshot
|
||||
.envelope
|
||||
|
||||
@@ -25,6 +25,17 @@ if !snapshot.host_allowed(&host, context.client_ip)
|
||||
"RustProxy: denied by access policy\n",
|
||||
);
|
||||
}
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
|
||||
*exchange.source.lock().unwrap_or_else(|e| e.into_inner()) = if route.response.is_some() {
|
||||
"local_response"
|
||||
} else {
|
||||
match &route.source {
|
||||
crate::config::CompiledSource::WebServer(_) => "web_server",
|
||||
crate::config::CompiledSource::ReverseProxy => "reverse_proxy",
|
||||
}
|
||||
};
|
||||
}
|
||||
// Local location responses intentionally still pass host/profile/location ACLs
|
||||
// and the outer response-header/security pipeline, but never select/open an upstream.
|
||||
if let Some(spec) = &route.response {
|
||||
|
||||
+61
-17
@@ -95,8 +95,8 @@ async fn forward(
|
||||
);
|
||||
};
|
||||
let frontend = snapshot.frontend(&host, local.0, tls);
|
||||
let websocket_legacy_support = handshake.is_some()
|
||||
&& frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
|
||||
let websocket_legacy_support =
|
||||
handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
|
||||
let redirect = snapshot.redirect_on(&host, &path, local.0, tls);
|
||||
let local_host = snapshot.not_found_host_on(&host, local.0, tls);
|
||||
// TLS ALPN normally prevents HTTP/2 from reaching a frontend that disabled it.
|
||||
@@ -147,7 +147,9 @@ async fn forward(
|
||||
return response;
|
||||
}
|
||||
if !tls {
|
||||
if let Some(response) = frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri())) {
|
||||
if let Some(response) =
|
||||
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +169,7 @@ async fn forward(
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut cloudflare_country = None;
|
||||
if frontend.is_some_and(|frontend| frontend.cloudflare_client_ip) {
|
||||
if let Some(client) = crate::forwarding::cloudflare_client_ip(request.headers(), peer.0) {
|
||||
if context.override_client_ip(client).is_err() {
|
||||
@@ -175,10 +178,12 @@ async fn forward(
|
||||
"RustProxy: invalid Cloudflare client address\n",
|
||||
);
|
||||
}
|
||||
cloudflare_country = crate::forwarding::cloudflare_country(request.headers(), peer.0);
|
||||
}
|
||||
}
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) = Some(context.client_ip.to_string());
|
||||
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(context.client_ip.to_string());
|
||||
}
|
||||
// GeoIP stays off the normal request path. Resolve lazily for any vhost or
|
||||
// location policy that actually needs a country code, or for log capture.
|
||||
@@ -190,7 +195,9 @@ async fn forward(
|
||||
|| frontend.profile_country_access.needs_geoip()
|
||||
|| frontend.rate_limit.needs_geoip()
|
||||
{
|
||||
country = state.geoip.country(context.client_ip);
|
||||
country = cloudflare_country
|
||||
.clone()
|
||||
.or_else(|| state.geoip.country(context.client_ip));
|
||||
country_resolved = true;
|
||||
}
|
||||
if capture_country {
|
||||
@@ -245,12 +252,16 @@ async fn forward(
|
||||
"RustProxy: redirect target could not be rendered\n",
|
||||
);
|
||||
};
|
||||
response.extensions_mut().insert(RedirectResponse { http3_enabled: _redirect.http3_enabled });
|
||||
response.extensions_mut().insert(RedirectResponse {
|
||||
http3_enabled: _redirect.http3_enabled,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
if let Some(item) = local_host {
|
||||
let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND);
|
||||
response.extensions_mut().insert(LocalHostResponse { http3_enabled: item.http3_enabled });
|
||||
response.extensions_mut().insert(LocalHostResponse {
|
||||
http3_enabled: item.http3_enabled,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
let selected = snapshot.select_on(&host, &path, local.0, tls);
|
||||
@@ -275,7 +286,9 @@ async fn forward(
|
||||
if (route.profile_country_access.needs_geoip() || route.country_access.needs_geoip())
|
||||
&& !country_resolved
|
||||
{
|
||||
country = state.geoip.country(context.client_ip);
|
||||
country = cloudflare_country
|
||||
.clone()
|
||||
.or_else(|| state.geoip.country(context.client_ip));
|
||||
if capture_country {
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone();
|
||||
@@ -360,20 +373,51 @@ async fn forward(
|
||||
});
|
||||
};
|
||||
let (
|
||||
backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings,
|
||||
transform_settings, scope, ignore_static_cookie, only_cached, public_suffix,
|
||||
origin_suffix, uri, exchange, lease, stop, downstream, mut h2_downstream,
|
||||
range_selection, if_range, range_requested, effective_reason, revalidation_reason,
|
||||
cache_ticket, client_conditionals, cached_revalidation, freshness_revalidation_reason,
|
||||
pending_revalidation, mut miss_recorded,
|
||||
backend,
|
||||
backend_lease,
|
||||
affinity_cookie,
|
||||
admin_control_plane,
|
||||
cache_settings,
|
||||
transform_settings,
|
||||
scope,
|
||||
ignore_static_cookie,
|
||||
only_cached,
|
||||
public_suffix,
|
||||
origin_suffix,
|
||||
uri,
|
||||
exchange,
|
||||
lease,
|
||||
stop,
|
||||
downstream,
|
||||
mut h2_downstream,
|
||||
range_selection,
|
||||
if_range,
|
||||
range_requested,
|
||||
effective_reason,
|
||||
revalidation_reason,
|
||||
cache_ticket,
|
||||
client_conditionals,
|
||||
cached_revalidation,
|
||||
freshness_revalidation_reason,
|
||||
pending_revalidation,
|
||||
mut miss_recorded,
|
||||
) = include!("forward/route_cache.rs");
|
||||
|
||||
let (serve_stale, mut fill_leader) = include!("forward/cache_lookup.rs");
|
||||
|
||||
let (
|
||||
websocket_upstream_h2, revalidation_sent, mut h2_direct_session, request_body_empty,
|
||||
proactive_base_headers, upstream_started, active_backend,
|
||||
active_lease, active_affinity_cookie, active_uri, retries, upstream_result,
|
||||
websocket_upstream_h2,
|
||||
revalidation_sent,
|
||||
mut h2_direct_session,
|
||||
request_body_empty,
|
||||
proactive_base_headers,
|
||||
upstream_started,
|
||||
active_backend,
|
||||
active_lease,
|
||||
active_affinity_cookie,
|
||||
active_uri,
|
||||
retries,
|
||||
upstream_result,
|
||||
final_attempt_elapsed,
|
||||
) = include!("forward/upstream.rs");
|
||||
|
||||
|
||||
@@ -54,7 +54,9 @@ pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec<String> {
|
||||
.flat_map(|h| h.split(';'))
|
||||
{
|
||||
let part = part.trim();
|
||||
let Some((name, _)) = part.split_once('=') else { continue; };
|
||||
let Some((name, _)) = part.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if name == crate::auth::COOKIE_NAME {
|
||||
admin.push(part.to_owned());
|
||||
} else {
|
||||
|
||||
@@ -57,9 +57,8 @@ mod authority_tests {
|
||||
#[tokio::test]
|
||||
async fn h3_delete_unknown_nonempty_body_is_replayed_not_buffered_away() {
|
||||
use bytes::Bytes;
|
||||
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(
|
||||
Bytes::from_static(b"payload"),
|
||||
)]);
|
||||
let stream =
|
||||
futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"payload"))]);
|
||||
let mut request = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.version(Version::HTTP_3)
|
||||
@@ -171,7 +170,10 @@ mod authority_tests {
|
||||
for name in ["etag", "x-cache", "x-cache-reason", "cache-status"] {
|
||||
assert!(!response.headers().contains_key(name), "{name}");
|
||||
}
|
||||
assert!(response.extensions().get::<LocalGeneratedResponse>().is_some());
|
||||
assert!(response
|
||||
.extensions()
|
||||
.get::<LocalGeneratedResponse>()
|
||||
.is_some());
|
||||
}
|
||||
#[test]
|
||||
fn http2_uses_authority_and_rejects_conflicting_host() {
|
||||
@@ -188,11 +190,9 @@ mod authority_tests {
|
||||
}
|
||||
#[test]
|
||||
fn legacy_websocket_origin_matches_upstream_scheme_and_host() {
|
||||
let origin = legacy_websocket_origin(
|
||||
"https://ilo.internal:443",
|
||||
&"10.0.0.5:443".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let origin =
|
||||
legacy_websocket_origin("https://ilo.internal:443", &"10.0.0.5:443".parse().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(origin, "https://10.0.0.5:443");
|
||||
assert!(legacy_websocket_origin("not-an-origin", &"10.0.0.5".parse().unwrap()).is_none());
|
||||
}
|
||||
@@ -261,18 +261,39 @@ mod authority_tests {
|
||||
#[test]
|
||||
fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() {
|
||||
let mut response = Response::new(Body::empty());
|
||||
response.headers_mut().insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
|
||||
response.headers_mut().insert("x-rust-proxy-backend-time", HeaderValue::from_static("legacy-spoof"));
|
||||
response.headers_mut().insert("x-rustproxy-backend-time", HeaderValue::from_static("999ms"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
|
||||
response.headers_mut().insert(
|
||||
"x-rust-proxy-backend-time",
|
||||
HeaderValue::from_static("legacy-spoof"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"x-rustproxy-backend-time",
|
||||
HeaderValue::from_static("999ms"),
|
||||
);
|
||||
apply_backend_diagnostic_values(&mut response, false, false);
|
||||
assert!(response.headers().get("x-rust-proxy-backend").is_none());
|
||||
assert!(response.headers().get("x-rust-proxy-backend-time").is_none());
|
||||
assert!(response
|
||||
.headers()
|
||||
.get("x-rust-proxy-backend-time")
|
||||
.is_none());
|
||||
assert!(response.headers().get("x-rustproxy-backend-time").is_none());
|
||||
|
||||
response.extensions_mut().insert(OriginBackend("origin-a".into()));
|
||||
response.extensions_mut().insert(OriginTiming(Duration::from_millis(12)));
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(OriginBackend("origin-a".into()));
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(OriginTiming(Duration::from_millis(12)));
|
||||
apply_backend_diagnostic_values(&mut response, true, true);
|
||||
assert_eq!(response.headers().get("x-rust-proxy-backend").unwrap(), "origin-a");
|
||||
assert_eq!(response.headers().get("x-rustproxy-backend-time").unwrap(), "12.00ms");
|
||||
assert_eq!(
|
||||
response.headers().get("x-rust-proxy-backend").unwrap(),
|
||||
"origin-a"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get("x-rustproxy-backend-time").unwrap(),
|
||||
"12.00ms"
|
||||
);
|
||||
}
|
||||
}
|
||||
+20
-16
@@ -27,9 +27,13 @@ struct LocalGeneratedResponse;
|
||||
#[derive(Clone)]
|
||||
struct StaticSourceResponse;
|
||||
#[derive(Clone, Copy)]
|
||||
struct RedirectResponse { http3_enabled: bool }
|
||||
struct RedirectResponse {
|
||||
http3_enabled: bool,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct LocalHostResponse { http3_enabled: bool }
|
||||
struct LocalHostResponse {
|
||||
http3_enabled: bool,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct OriginTiming(Duration);
|
||||
#[derive(Clone)]
|
||||
@@ -82,7 +86,9 @@ fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
parts.join(" -> ")
|
||||
}
|
||||
|
||||
fn classify_stale_error(error: &(dyn std::error::Error + 'static)) -> crate::cache::StaleErrorClass {
|
||||
fn classify_stale_error(
|
||||
error: &(dyn std::error::Error + 'static),
|
||||
) -> crate::cache::StaleErrorClass {
|
||||
let mut current = Some(error);
|
||||
let mut text = String::new();
|
||||
for _ in 0..12 {
|
||||
@@ -117,7 +123,10 @@ fn classify_stale_error(error: &(dyn std::error::Error + 'static)) -> crate::cac
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_error_class(error: &(dyn std::error::Error + 'static), connect_failure: bool) -> &'static str {
|
||||
fn upstream_error_class(
|
||||
error: &(dyn std::error::Error + 'static),
|
||||
connect_failure: bool,
|
||||
) -> &'static str {
|
||||
match classify_stale_error(error) {
|
||||
crate::cache::StaleErrorClass::Dns => "dns",
|
||||
crate::cache::StaleErrorClass::Tls => "tls",
|
||||
@@ -219,7 +228,6 @@ where
|
||||
detail
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CacheRefreshContext {
|
||||
state: Arc<App>,
|
||||
@@ -273,21 +281,17 @@ async fn refresh_cache_once(
|
||||
*refresh.version_mut() = Version::HTTP_11;
|
||||
*refresh.headers_mut() = refresh_headers;
|
||||
let started = Instant::now();
|
||||
let Ok(Ok(origin)) = tokio::time::timeout(
|
||||
context.timeout,
|
||||
context.backend.client.request(refresh),
|
||||
)
|
||||
.await
|
||||
let Ok(Ok(origin)) =
|
||||
tokio::time::timeout(context.timeout, context.backend.client.request(refresh)).await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let origin_validation_headers = origin.headers().clone();
|
||||
let Some(route) = context.snapshot.select_on(
|
||||
&context.host,
|
||||
&context.path,
|
||||
context.local,
|
||||
context.tls,
|
||||
) else {
|
||||
let Some(route) =
|
||||
context
|
||||
.snapshot
|
||||
.select_on(&context.host, &context.path, context.local, context.tls)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if origin.status() == StatusCode::NOT_MODIFIED {
|
||||
|
||||
@@ -105,13 +105,17 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
.extensions()
|
||||
.get::<PeerAddr>()
|
||||
.map(|peer| peer.0.ip().to_string());
|
||||
let user_agent = state.logs.capture_user_agent().then(|| {
|
||||
request
|
||||
.headers()
|
||||
.get(USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.chars().take(512).collect::<String>())
|
||||
}).flatten();
|
||||
let user_agent = state
|
||||
.logs
|
||||
.capture_user_agent()
|
||||
.then(|| {
|
||||
request
|
||||
.headers()
|
||||
.get(USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.chars().take(512).collect::<String>())
|
||||
})
|
||||
.flatten();
|
||||
let (http_version, transport) = match request.version() {
|
||||
Version::HTTP_10 => ("1.0", "h1"),
|
||||
Version::HTTP_11 => ("1.1", "h1"),
|
||||
@@ -128,6 +132,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
vhost_metrics: frontend_context.as_ref().map(|f| f.metrics.clone()),
|
||||
vhost: frontend_context.as_ref().map(|f| f.id.clone()),
|
||||
route: Mutex::new(None),
|
||||
source: Mutex::new("unresolved"),
|
||||
cache_effective_enabled: Mutex::new(None),
|
||||
transform_effective_enabled: Mutex::new(None),
|
||||
backend: Mutex::new(None),
|
||||
@@ -180,7 +185,10 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
))
|
||||
});
|
||||
let mut response = next.run(request).await;
|
||||
let minimal_welcome = response.extensions().get::<crate::pages::MinimalWelcome>().is_some();
|
||||
let minimal_welcome = response
|
||||
.extensions()
|
||||
.get::<crate::pages::MinimalWelcome>()
|
||||
.is_some();
|
||||
let code = response.status().as_u16();
|
||||
let custom_error = frontend_context
|
||||
.as_ref()
|
||||
@@ -201,17 +209,32 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
// minimal. It is never cacheable and carries no optional RustProxy
|
||||
// diagnostics/server/security/compression/cache-diagnostic headers.
|
||||
for name in [
|
||||
"server", "server-timing", "alt-svc", "x-rustproxy-request-id",
|
||||
"x-rustproxy-revision", "x-rust-proxy-backend",
|
||||
"x-rust-proxy-backend-time", "x-rustproxy-backend-time", "x-cache", "x-cache-hits",
|
||||
"x-cache-status", "x-cache-resident-age", "content-encoding", "vary",
|
||||
"strict-transport-security", "content-security-policy",
|
||||
"permissions-policy", "referrer-policy", "x-frame-options",
|
||||
"server",
|
||||
"server-timing",
|
||||
"alt-svc",
|
||||
"x-rustproxy-request-id",
|
||||
"x-rustproxy-revision",
|
||||
"x-rust-proxy-backend",
|
||||
"x-rust-proxy-backend-time",
|
||||
"x-rustproxy-backend-time",
|
||||
"x-cache",
|
||||
"x-cache-hits",
|
||||
"x-cache-status",
|
||||
"x-cache-resident-age",
|
||||
"content-encoding",
|
||||
"vary",
|
||||
"strict-transport-security",
|
||||
"content-security-policy",
|
||||
"permissions-policy",
|
||||
"referrer-policy",
|
||||
"x-frame-options",
|
||||
"x-content-type-options",
|
||||
] {
|
||||
response.headers_mut().remove(name);
|
||||
}
|
||||
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
} else {
|
||||
if response
|
||||
.extensions()
|
||||
@@ -241,10 +264,18 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
&request_id,
|
||||
&state.active_listeners,
|
||||
);
|
||||
if response.extensions().get::<RedirectResponse>().is_some_and(|redirect| !redirect.http3_enabled) {
|
||||
if response
|
||||
.extensions()
|
||||
.get::<RedirectResponse>()
|
||||
.is_some_and(|redirect| !redirect.http3_enabled)
|
||||
{
|
||||
response.headers_mut().remove("alt-svc");
|
||||
}
|
||||
if response.extensions().get::<LocalHostResponse>().is_some_and(|item| !item.http3_enabled) {
|
||||
if response
|
||||
.extensions()
|
||||
.get::<LocalHostResponse>()
|
||||
.is_some_and(|item| !item.http3_enabled)
|
||||
{
|
||||
response.headers_mut().remove("alt-svc");
|
||||
}
|
||||
response = crate::compression::encode(
|
||||
@@ -267,9 +298,18 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
.is_none()
|
||||
&& response.extensions().get::<RedirectResponse>().is_none()
|
||||
&& response.extensions().get::<LocalHostResponse>().is_none()
|
||||
&& response.extensions().get::<crate::pages::PageServed>().is_none()
|
||||
&& response.extensions().get::<LocalGeneratedResponse>().is_none()
|
||||
&& response.extensions().get::<StaticSourceResponse>().is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<crate::pages::PageServed>()
|
||||
.is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<LocalGeneratedResponse>()
|
||||
.is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<StaticSourceResponse>()
|
||||
.is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<crate::tinyauth::AuthenticationFailure>()
|
||||
@@ -300,8 +340,14 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
cache.annotate_transformed(response.headers_mut(), transformed);
|
||||
}
|
||||
}
|
||||
if response.extensions().get::<crate::pages::PageServed>().is_some()
|
||||
|| response.extensions().get::<LocalGeneratedResponse>().is_some()
|
||||
if response
|
||||
.extensions()
|
||||
.get::<crate::pages::PageServed>()
|
||||
.is_some()
|
||||
|| response
|
||||
.extensions()
|
||||
.get::<LocalGeneratedResponse>()
|
||||
.is_some()
|
||||
{
|
||||
// Static/placeholder/default pages are generated locally and must never
|
||||
// look cacheable or carry RustProxy cache diagnostics/validators.
|
||||
@@ -358,7 +404,10 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
}
|
||||
/// Build a same-host HTTPS redirect while preserving the request path and query.
|
||||
fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option<HeaderValue> {
|
||||
let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/");
|
||||
let path = uri
|
||||
.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or("/");
|
||||
let location = if port == 443 {
|
||||
format!("https://{host}{path}")
|
||||
} else {
|
||||
@@ -377,7 +426,9 @@ fn force_ssl_redirect(
|
||||
}
|
||||
let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?;
|
||||
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
|
||||
response.headers_mut().insert(http::header::LOCATION, location);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(http::header::LOCATION, location);
|
||||
// Do not pin a redirect in a browser after an operator later disables Force SSL.
|
||||
response
|
||||
.headers_mut()
|
||||
|
||||
@@ -14,8 +14,8 @@ use http_body_util::BodyExt;
|
||||
use hyper::upgrade::{OnUpgrade, Upgraded};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use sha1::{Digest, Sha1};
|
||||
use serde::Serialize;
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
future::Future,
|
||||
@@ -283,8 +283,7 @@ pub fn validate_response(
|
||||
"invalid upstream upgrade"
|
||||
);
|
||||
ensure!(
|
||||
!headers.contains_key("transfer-encoding")
|
||||
&& !headers.contains_key("content-length"),
|
||||
!headers.contains_key("transfer-encoding") && !headers.contains_key("content-length"),
|
||||
"101 response must not include body framing"
|
||||
);
|
||||
for value in headers.get_all("connection").iter() {
|
||||
|
||||
@@ -20,9 +20,11 @@ mod tests {
|
||||
.header("sec-websocket-version", "13")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert("websocket".parse::<h3::ext::Protocol>().unwrap_or_else(|_| panic!("static websocket H3 protocol token must parse")));
|
||||
request.extensions_mut().insert(
|
||||
"websocket"
|
||||
.parse::<h3::ext::Protocol>()
|
||||
.unwrap_or_else(|_| panic!("static websocket H3 protocol token must parse")),
|
||||
);
|
||||
request
|
||||
}
|
||||
fn h2_request() -> Request {
|
||||
@@ -80,22 +82,41 @@ mod tests {
|
||||
#[test]
|
||||
fn rfc8441_h2_response_rejects_h1_framing_fields() {
|
||||
let handshake = validate_h2(&h2_request()).unwrap();
|
||||
for (name, value) in [("connection", "close"), ("upgrade", "websocket"), ("content-length", "0")] {
|
||||
for (name, value) in [
|
||||
("connection", "close"),
|
||||
("upgrade", "websocket"),
|
||||
("content-length", "0"),
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
value.parse().unwrap(),
|
||||
);
|
||||
assert!(validate_h2_response(&headers, &handshake).is_err(), "accepted forbidden response {name}");
|
||||
assert!(
|
||||
validate_h2_response(&headers, &handshake).is_err(),
|
||||
"accepted forbidden response {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunnel_transport_metrics_classify_frontend_protocol() {
|
||||
assert!(matches!(TunnelTransport::from_protocol("h1-upgrade"), TunnelTransport::H1));
|
||||
assert!(matches!(TunnelTransport::from_protocol("h2-rfc8441-h1-upstream"), TunnelTransport::H2));
|
||||
assert!(matches!(TunnelTransport::from_protocol("h2-rfc8441-h2-upstream"), TunnelTransport::H2));
|
||||
assert!(matches!(TunnelTransport::from_protocol("h3-rfc9220"), TunnelTransport::H3));
|
||||
assert!(matches!(
|
||||
TunnelTransport::from_protocol("h1-upgrade"),
|
||||
TunnelTransport::H1
|
||||
));
|
||||
assert!(matches!(
|
||||
TunnelTransport::from_protocol("h2-rfc8441-h1-upstream"),
|
||||
TunnelTransport::H2
|
||||
));
|
||||
assert!(matches!(
|
||||
TunnelTransport::from_protocol("h2-rfc8441-h2-upstream"),
|
||||
TunnelTransport::H2
|
||||
));
|
||||
assert!(matches!(
|
||||
TunnelTransport::from_protocol("h3-rfc9220"),
|
||||
TunnelTransport::H3
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -139,9 +160,11 @@ mod tests {
|
||||
assert!(validate_h3(&request).is_err(), "accepted forbidden {name}");
|
||||
}
|
||||
let mut request = h3_request();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert("connect-udp".parse::<h3::ext::Protocol>().unwrap_or_else(|_| panic!("static connect-udp H3 protocol token must parse")));
|
||||
request.extensions_mut().insert(
|
||||
"connect-udp"
|
||||
.parse::<h3::ext::Protocol>()
|
||||
.unwrap_or_else(|_| panic!("static connect-udp H3 protocol token must parse")),
|
||||
);
|
||||
assert!(validate_h3(&request).is_err());
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -406,7 +406,11 @@ pub fn spawn_h2_idle_watch(state: Arc<App>, session: &Arc<TunnelSession>) {
|
||||
return;
|
||||
};
|
||||
if session.inactive_for() >= idle {
|
||||
session._active.exchange.failed.store(true, Ordering::Relaxed);
|
||||
session
|
||||
._active
|
||||
.exchange
|
||||
.failed
|
||||
.store(true, Ordering::Relaxed);
|
||||
session
|
||||
._active
|
||||
.exchange
|
||||
|
||||
@@ -9,7 +9,9 @@ pub fn bridge_h2_to_h1(
|
||||
lease: Option<Arc<ConnectionLease>>,
|
||||
stop: Option<ServerShutdown>,
|
||||
) -> Body {
|
||||
let stop = stop.map(|value| value.0).unwrap_or_else(|| state.shutdown.clone());
|
||||
let stop = stop
|
||||
.map(|value| value.0)
|
||||
.unwrap_or_else(|| state.shutdown.clone());
|
||||
let session = state.tunnel_registry.register(
|
||||
exchange.clone(),
|
||||
upstream_label,
|
||||
@@ -70,7 +72,10 @@ pub fn bridge_h2_to_h1(
|
||||
Ok(read) => {
|
||||
buffer.truncate(read);
|
||||
record_session_bytes(&exchange, &session, read as u64, false);
|
||||
Some((Ok::<Bytes, io::Error>(Bytes::from(buffer)), (upstream_read, session, exchange)))
|
||||
Some((
|
||||
Ok::<Bytes, io::Error>(Bytes::from(buffer)),
|
||||
(upstream_read, session, exchange),
|
||||
))
|
||||
}
|
||||
Err(error) => Some((Err(error), (upstream_read, session, exchange))),
|
||||
}
|
||||
@@ -212,21 +217,32 @@ pub async fn run_h3(
|
||||
let stop = stop.map(|v| v.0).unwrap_or_else(|| state.shutdown.clone());
|
||||
let started = Instant::now();
|
||||
let activity = Arc::new(AtomicU64::new(0));
|
||||
let session = state.tunnel_registry.register(
|
||||
exchange.clone(),
|
||||
upstream_label,
|
||||
"h3-rfc9220",
|
||||
stop,
|
||||
lease,
|
||||
);
|
||||
let session =
|
||||
state
|
||||
.tunnel_registry
|
||||
.register(exchange.clone(), upstream_label, "h3-rfc9220", stop, lease);
|
||||
let stop = session.cancellation();
|
||||
let upstream = TokioIo::new(upstream);
|
||||
let (mut upstream_read, mut upstream_write) = tokio::io::split(upstream);
|
||||
let result = {
|
||||
let copying = async {
|
||||
tokio::try_join!(
|
||||
h3_client_to_backend(receive, &mut upstream_write, &exchange, &activity, started, &session),
|
||||
backend_to_h3(&mut upstream_read, send, &exchange, &activity, started, &session)
|
||||
h3_client_to_backend(
|
||||
receive,
|
||||
&mut upstream_write,
|
||||
&exchange,
|
||||
&activity,
|
||||
started,
|
||||
&session
|
||||
),
|
||||
backend_to_h3(
|
||||
&mut upstream_read,
|
||||
send,
|
||||
&exchange,
|
||||
&activity,
|
||||
started,
|
||||
&session
|
||||
)
|
||||
)?;
|
||||
Ok::<(), io::Error>(())
|
||||
};
|
||||
|
||||
@@ -11,9 +11,15 @@ impl App {
|
||||
/// resolver is rebuilt as one validated snapshot, so the requested pair is
|
||||
/// re-read atomically with the other configured pairs while desired
|
||||
/// configuration and its revision remain unchanged.
|
||||
pub async fn reload_certificate(self: &Arc<Self>, certificate_id: &str) -> Result<u64, ApiError> {
|
||||
pub async fn reload_certificate(
|
||||
self: &Arc<Self>,
|
||||
certificate_id: &str,
|
||||
) -> Result<u64, ApiError> {
|
||||
if !crate::config::valid_id(certificate_id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate not found".into()));
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"certificate not found".into(),
|
||||
));
|
||||
}
|
||||
self.reload_certificate_snapshot(Some(certificate_id)).await
|
||||
}
|
||||
@@ -72,11 +78,14 @@ impl App {
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?;
|
||||
self.config.store(Arc::new(prepared));
|
||||
if let Some(certificate_id) = certificate_id {
|
||||
tracing::info!(revision, certificate_id, "configured certificate reloaded from disk");
|
||||
tracing::info!(
|
||||
revision,
|
||||
certificate_id,
|
||||
"configured certificate reloaded from disk"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(revision, "configured certificates reloaded from disk");
|
||||
}
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,7 +29,8 @@ impl App {
|
||||
actor: String,
|
||||
source_revision: u64,
|
||||
) -> Result<config::Envelope, ApiError> {
|
||||
self.update_as_action(update, actor, Some(source_revision)).await
|
||||
self.update_as_action(update, actor, Some(source_revision))
|
||||
.await
|
||||
}
|
||||
async fn update_as_action(
|
||||
self: &Arc<Self>,
|
||||
@@ -155,5 +156,4 @@ impl App {
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,7 +39,10 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(value.as_slice(), secret);
|
||||
drop(value);
|
||||
assert_eq!(read_key(&master_key_path(dir.path())).unwrap().len(), KEY_BYTES);
|
||||
assert_eq!(
|
||||
read_key(&master_key_path(dir.path())).unwrap().len(),
|
||||
KEY_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -51,11 +54,27 @@ mod tests {
|
||||
let store = crate::db::Store::open(&database, &crate::config::Config::default())
|
||||
.await
|
||||
.unwrap();
|
||||
put(&store, dir.path(), "acme/site/cloudflare/credential", "cloudflare", b"secret-value", serde_json::json!({})).await.unwrap();
|
||||
put(
|
||||
&store,
|
||||
dir.path(),
|
||||
"acme/site/cloudflare/credential",
|
||||
"cloudflare",
|
||||
b"secret-value",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE secret_store SET ciphertext = zeroblob(length(ciphertext))")
|
||||
.execute(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(get(&store, dir.path(), "acme/site/cloudflare/credential", Some("cloudflare")).await.is_err());
|
||||
assert!(get(
|
||||
&store,
|
||||
dir.path(),
|
||||
"acme/site/cloudflare/credential",
|
||||
Some("cloudflare")
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
+52
-15
@@ -3,6 +3,7 @@ use anyhow::{Context, Result};
|
||||
use axum::{body::Body, Router};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use bytes::{Buf, Bytes, BytesMut};
|
||||
use http_body::Body as HttpBody;
|
||||
use hyper::{
|
||||
body::Incoming,
|
||||
server::conn::{http1, http2},
|
||||
@@ -10,7 +11,6 @@ use hyper::{
|
||||
Request, Response,
|
||||
};
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
|
||||
use http_body::Body as HttpBody;
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
io,
|
||||
@@ -32,7 +32,6 @@ use tokio::{
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tower::ServiceExt;
|
||||
|
||||
|
||||
const HTTP2_PRIOR_KNOWLEDGE_PREFACE: &[u8; 24] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
|
||||
const H2_FRAME_HEADER_LEN: usize = 9;
|
||||
|
||||
@@ -45,7 +44,10 @@ fn header_has_token(headers: &http::HeaderMap, name: &str, token: &str) -> bool
|
||||
.any(|value| value.trim().eq_ignore_ascii_case(token))
|
||||
}
|
||||
|
||||
fn single_header<'a>(headers: &'a http::HeaderMap, name: &str) -> std::result::Result<&'a str, &'static str> {
|
||||
fn single_header<'a>(
|
||||
headers: &'a http::HeaderMap,
|
||||
name: &str,
|
||||
) -> std::result::Result<&'a str, &'static str> {
|
||||
let mut values = headers.get_all(name).iter();
|
||||
let value = values.next().ok_or("missing required h2c header")?;
|
||||
if values.next().is_some() {
|
||||
@@ -97,9 +99,15 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
|
||||
if request.version() != http::Version::HTTP_11 {
|
||||
return Err("h2c upgrade requires HTTP/1.1");
|
||||
}
|
||||
if !header_has_token(request.headers(), http::header::CONNECTION.as_str(), "upgrade")
|
||||
|| !header_has_token(request.headers(), http::header::CONNECTION.as_str(), "http2-settings")
|
||||
{
|
||||
if !header_has_token(
|
||||
request.headers(),
|
||||
http::header::CONNECTION.as_str(),
|
||||
"upgrade",
|
||||
) || !header_has_token(
|
||||
request.headers(),
|
||||
http::header::CONNECTION.as_str(),
|
||||
"http2-settings",
|
||||
) {
|
||||
return Err("Connection must nominate Upgrade and HTTP2-Settings");
|
||||
}
|
||||
if !single_header(request.headers(), http::header::UPGRADE.as_str())?
|
||||
@@ -114,10 +122,19 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
|
||||
if settings.len() > 36 || settings.len() % 6 != 0 {
|
||||
return Err("invalid HTTP2-Settings payload");
|
||||
}
|
||||
if request.headers().contains_key(http::header::TRANSFER_ENCODING) {
|
||||
if request
|
||||
.headers()
|
||||
.contains_key(http::header::TRANSFER_ENCODING)
|
||||
{
|
||||
return Err("h2c upgrade request body is not supported");
|
||||
}
|
||||
if request.headers().get_all(http::header::CONTENT_LENGTH).iter().count() > 1 {
|
||||
if request
|
||||
.headers()
|
||||
.get_all(http::header::CONTENT_LENGTH)
|
||||
.iter()
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err("duplicate Content-Length on h2c upgrade");
|
||||
}
|
||||
if request
|
||||
@@ -132,7 +149,12 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
|
||||
.uri()
|
||||
.authority()
|
||||
.map(|value| value.as_str())
|
||||
.or_else(|| request.headers().get(http::header::HOST).and_then(|value| value.to_str().ok()))
|
||||
.or_else(|| {
|
||||
request
|
||||
.headers()
|
||||
.get(http::header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
})
|
||||
.ok_or("h2c upgrade requires Host/:authority")?;
|
||||
let path = request
|
||||
.uri()
|
||||
@@ -167,7 +189,9 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
|
||||
if name == "te" && !value.as_bytes().eq_ignore_ascii_case(b"trailers") {
|
||||
continue;
|
||||
}
|
||||
let value = value.to_str().map_err(|_| "non-text header in h2c bootstrap")?;
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| "non-text header in h2c bootstrap")?;
|
||||
hpack_literal(&mut block, name, value);
|
||||
}
|
||||
if block.len() > 32_768 {
|
||||
@@ -215,15 +239,21 @@ impl<I> H2cUpgradeIo<I> {
|
||||
}
|
||||
|
||||
fn try_finish_bootstrap(&mut self) -> io::Result<()> {
|
||||
if self.bootstrap_done || self.bootstrap.len() < HTTP2_PRIOR_KNOWLEDGE_PREFACE.len() + H2_FRAME_HEADER_LEN {
|
||||
if self.bootstrap_done
|
||||
|| self.bootstrap.len() < HTTP2_PRIOR_KNOWLEDGE_PREFACE.len() + H2_FRAME_HEADER_LEN
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if &self.bootstrap[..HTTP2_PRIOR_KNOWLEDGE_PREFACE.len()] != HTTP2_PRIOR_KNOWLEDGE_PREFACE {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid HTTP/2 client preface after h2c upgrade"));
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid HTTP/2 client preface after h2c upgrade",
|
||||
));
|
||||
}
|
||||
let offset = HTTP2_PRIOR_KNOWLEDGE_PREFACE.len();
|
||||
let header = &self.bootstrap[offset..offset + H2_FRAME_HEADER_LEN];
|
||||
let length = ((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize;
|
||||
let length =
|
||||
((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize;
|
||||
if length > 16_384
|
||||
|| header[3] != 0x4
|
||||
|| header[4] != 0
|
||||
@@ -232,7 +262,10 @@ impl<I> H2cUpgradeIo<I> {
|
||||
|| header[7] != 0
|
||||
|| header[8] != 0
|
||||
{
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "h2c client preface must begin with a non-ACK SETTINGS frame"));
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"h2c client preface must begin with a non-ACK SETTINGS frame",
|
||||
));
|
||||
}
|
||||
let end = offset + H2_FRAME_HEADER_LEN + length;
|
||||
if self.bootstrap.len() < end {
|
||||
@@ -296,7 +329,11 @@ impl<I: AsyncRead + Unpin> AsyncRead for H2cUpgradeIo<I> {
|
||||
}
|
||||
|
||||
impl<I: AsyncWrite + Unpin> AsyncWrite for H2cUpgradeIo<I> {
|
||||
fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut TaskContext<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.inner).poll_write(cx, buf)
|
||||
}
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<io::Result<()>> {
|
||||
|
||||
+8
-2
@@ -93,8 +93,14 @@ async fn http2_connection<I>(
|
||||
};
|
||||
match tokio::time::timeout(drain, &mut conn).await {
|
||||
Ok(Ok(())) => tracing::debug!(?cause, "HTTP/2 graceful shutdown drained"),
|
||||
Ok(Err(error)) => tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error"),
|
||||
Err(_) => tracing::debug!(?cause, drain_seconds = drain.as_secs_f64(), "HTTP/2 drain timeout reached; closing transport"),
|
||||
Ok(Err(error)) => {
|
||||
tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error")
|
||||
}
|
||||
Err(_) => tracing::debug!(
|
||||
?cause,
|
||||
drain_seconds = drain.as_secs_f64(),
|
||||
"HTTP/2 drain timeout reached; closing transport"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
-18
@@ -55,13 +55,18 @@ mod h2c_tests {
|
||||
) -> io::Result<(u8, u8, u32, Vec<u8>)> {
|
||||
let mut header = [0u8; 9];
|
||||
stream.read_exact(&mut header).await?;
|
||||
let length = ((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize;
|
||||
let length =
|
||||
((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize;
|
||||
if length > 64 * 1024 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "unexpectedly large HTTP/2 test frame"));
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"unexpectedly large HTTP/2 test frame",
|
||||
));
|
||||
}
|
||||
let mut payload = vec![0u8; length];
|
||||
stream.read_exact(&mut payload).await?;
|
||||
let stream_id = u32::from_be_bytes([header[5], header[6], header[7], header[8]]) & 0x7fff_ffff;
|
||||
let stream_id =
|
||||
u32::from_be_bytes([header[5], header[6], header[7], header[8]]) & 0x7fff_ffff;
|
||||
Ok((header[3], header[4], stream_id, payload))
|
||||
}
|
||||
|
||||
@@ -72,7 +77,11 @@ mod h2c_tests {
|
||||
async fn start_direct_h2(
|
||||
router: Router,
|
||||
settings: Http2Connections,
|
||||
) -> (tokio::net::TcpStream, CancellationToken, tokio::task::JoinHandle<()>) {
|
||||
) -> (
|
||||
tokio::net::TcpStream,
|
||||
CancellationToken,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let stop = CancellationToken::new();
|
||||
@@ -100,7 +109,10 @@ mod h2c_tests {
|
||||
.await;
|
||||
});
|
||||
let mut client = tokio::net::TcpStream::connect(address).await.unwrap();
|
||||
client.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE).await.unwrap();
|
||||
client
|
||||
.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE)
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0])
|
||||
.await
|
||||
@@ -144,7 +156,10 @@ mod h2c_tests {
|
||||
assert!(response.starts_with("HTTP/1.1 101"), "{response}");
|
||||
assert!(response.to_ascii_lowercase().contains("upgrade: h2c"));
|
||||
|
||||
client.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE).await.unwrap();
|
||||
client
|
||||
.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE)
|
||||
.await
|
||||
.unwrap();
|
||||
// Client connection preface must begin with a non-ACK SETTINGS frame.
|
||||
client
|
||||
.write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0])
|
||||
@@ -154,12 +169,10 @@ mod h2c_tests {
|
||||
let mut saw_stream_one_headers = false;
|
||||
let mut body = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let (kind, flags, stream_id, payload) = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
read_h2_frame(&mut client),
|
||||
)
|
||||
.await
|
||||
.expect("HTTP/2 response frame timeout");
|
||||
let (kind, flags, stream_id, payload) =
|
||||
tokio::time::timeout(Duration::from_secs(2), read_h2_frame(&mut client))
|
||||
.await
|
||||
.expect("HTTP/2 response frame timeout");
|
||||
if kind == 0x1 && stream_id == 1 {
|
||||
saw_stream_one_headers = true;
|
||||
}
|
||||
@@ -251,7 +264,10 @@ mod h2c_tests {
|
||||
.header("http2-settings", "")
|
||||
.body(())
|
||||
.unwrap();
|
||||
client.write_all(&h2_headers_frame(&upgrade).unwrap()).await.unwrap();
|
||||
client
|
||||
.write_all(&h2_headers_frame(&upgrade).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let saw_goaway_and_close = tokio::time::timeout(Duration::from_secs(4), async {
|
||||
let mut saw_goaway = false;
|
||||
@@ -259,7 +275,12 @@ mod h2c_tests {
|
||||
match try_read_h2_frame(&mut client).await {
|
||||
Ok((0x7, _, 0, _)) => saw_goaway = true,
|
||||
Ok(_) => {}
|
||||
Err(error) if matches!(error.kind(), io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset) => {
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset
|
||||
) =>
|
||||
{
|
||||
return saw_goaway;
|
||||
}
|
||||
Err(_) => return saw_goaway,
|
||||
@@ -268,7 +289,10 @@ mod h2c_tests {
|
||||
})
|
||||
.await
|
||||
.expect("lifetime/drain shutdown exceeded its bound");
|
||||
assert!(saw_goaway_and_close, "lifetime shutdown must send GOAWAY before drain timeout closes TCP");
|
||||
assert!(
|
||||
saw_goaway_and_close,
|
||||
"lifetime shutdown must send GOAWAY before drain timeout closes TCP"
|
||||
);
|
||||
server.await.unwrap();
|
||||
drop(blocker);
|
||||
}
|
||||
@@ -303,7 +327,10 @@ mod h2c_tests {
|
||||
.header("http2-settings", "")
|
||||
.body(())
|
||||
.unwrap();
|
||||
client.write_all(&h2_headers_frame(&request).unwrap()).await.unwrap();
|
||||
client
|
||||
.write_all(&h2_headers_frame(&request).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let release = blocker.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(1_250)).await;
|
||||
@@ -318,7 +345,9 @@ mod h2c_tests {
|
||||
Ok((0x7, _, 0, _)) => saw_goaway = true,
|
||||
Ok((kind, flags, 1, _)) if (kind == 0x0 || kind == 0x1) && flags & 0x1 != 0 => {
|
||||
saw_end_stream = true;
|
||||
if saw_goaway { return (true, true); }
|
||||
if saw_goaway {
|
||||
return (true, true);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => return (saw_goaway, saw_end_stream),
|
||||
@@ -328,7 +357,10 @@ mod h2c_tests {
|
||||
.await
|
||||
.expect("active stream did not drain within the configured bound");
|
||||
assert!(saw_goaway, "lifetime shutdown must send GOAWAY");
|
||||
assert!(saw_end_stream, "an accepted stream must be allowed to finish during drain");
|
||||
assert!(
|
||||
saw_end_stream,
|
||||
"an accepted stream must be allowed to finish during drain"
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -302,11 +302,15 @@ async fn connection<I>(
|
||||
match on_upgrade.await {
|
||||
Ok(upgraded) => {
|
||||
if let Some(state) = &upgrade_state_for_task {
|
||||
state.metrics.http2_connections.fetch_add(1, Ordering::Relaxed);
|
||||
state
|
||||
.metrics
|
||||
.http2_connections
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let extended_connect = upgrade_state_for_task.as_ref().is_some_and(|state| {
|
||||
state.config.load().websocket_rfc8441_on(local, false)
|
||||
});
|
||||
let extended_connect =
|
||||
upgrade_state_for_task.as_ref().is_some_and(|state| {
|
||||
state.config.load().websocket_rfc8441_on(local, false)
|
||||
});
|
||||
let io = H2cUpgradeIo::new(TokioIo::new(upgraded), injected_headers);
|
||||
http2_connection(
|
||||
io,
|
||||
|
||||
@@ -14,7 +14,10 @@ pub(crate) fn augment(view: &mut View) {
|
||||
if let Ok(stat) = fs::read_to_string("/proc/stat") {
|
||||
view.logical_cpus = Some(
|
||||
stat.lines()
|
||||
.filter(|line| line.starts_with("cpu") && line.as_bytes().get(3).is_some_and(u8::is_ascii_digit))
|
||||
.filter(|line| {
|
||||
line.starts_with("cpu")
|
||||
&& line.as_bytes().get(3).is_some_and(u8::is_ascii_digit)
|
||||
})
|
||||
.count(),
|
||||
)
|
||||
.filter(|count| *count > 0);
|
||||
@@ -33,7 +36,8 @@ pub(crate) fn augment(view: &mut View) {
|
||||
view.process_rss_bytes = key_value(&status, "VmRSS").map(|n| n.saturating_mul(1024));
|
||||
view.process_rss_anon_bytes = key_value(&status, "RssAnon").map(|n| n.saturating_mul(1024));
|
||||
view.process_rss_file_bytes = key_value(&status, "RssFile").map(|n| n.saturating_mul(1024));
|
||||
view.process_rss_shmem_bytes = key_value(&status, "RssShmem").map(|n| n.saturating_mul(1024));
|
||||
view.process_rss_shmem_bytes =
|
||||
key_value(&status, "RssShmem").map(|n| n.saturating_mul(1024));
|
||||
view.process_data_bytes = key_value(&status, "VmData").map(|n| n.saturating_mul(1024));
|
||||
view.process_swap_bytes = key_value(&status, "VmSwap").map(|n| n.saturating_mul(1024));
|
||||
view.process_threads = key_value(&status, "Threads");
|
||||
|
||||
+21
-12
@@ -191,8 +191,9 @@ fn is_loopback_interface(name: &str) -> bool {
|
||||
if name == "lo" {
|
||||
return true;
|
||||
}
|
||||
name.strip_prefix("lo")
|
||||
.is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
name.strip_prefix("lo").is_some_and(|suffix| {
|
||||
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_network_interfaces<I>(interfaces: I) -> Vec<NetworkInterfaceView>
|
||||
@@ -290,9 +291,12 @@ fn merge_network_interfaces(
|
||||
// Both libraries expose cumulative byte counters. Merge by interface name
|
||||
// and keep the larger counter so a partial/zero reading from one backend
|
||||
// cannot hide a usable reading from the other.
|
||||
normalize_network_interfaces(primary.into_iter().chain(secondary).map(|item| {
|
||||
(item.name, item.rx_bytes, item.tx_bytes)
|
||||
}))
|
||||
normalize_network_interfaces(
|
||||
primary
|
||||
.into_iter()
|
||||
.chain(secondary)
|
||||
.map(|item| (item.name, item.rx_bytes, item.tx_bytes)),
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_network(view: &mut View) {
|
||||
@@ -301,10 +305,8 @@ fn collect_network(view: &mut View) {
|
||||
// sysinfo has no native network backend. Combining both inventories also
|
||||
// avoids dropping an interface when either backend is only partially
|
||||
// available. No shell commands or per-platform network parser are used.
|
||||
let interfaces = merge_network_interfaces(
|
||||
collect_network_sysinfo(),
|
||||
collect_network_systemstat(),
|
||||
);
|
||||
let interfaces =
|
||||
merge_network_interfaces(collect_network_sysinfo(), collect_network_systemstat());
|
||||
if interfaces.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -340,7 +342,9 @@ fn collect_process(view: &mut View, counters: &mut Counters) {
|
||||
view.process_virtual_memory_bytes = metrics.virtual_memory_bytes;
|
||||
view.process_virtual_memory_max_bytes = metrics.virtual_memory_max_bytes;
|
||||
view.process_start_time_seconds = metrics.start_time_seconds;
|
||||
view.open_fds = metrics.open_fds.and_then(|value| usize::try_from(value).ok());
|
||||
view.open_fds = metrics
|
||||
.open_fds
|
||||
.and_then(|value| usize::try_from(value).ok());
|
||||
view.max_fds = metrics.max_fds;
|
||||
view.process_threads = metrics.threads;
|
||||
}
|
||||
@@ -393,12 +397,17 @@ pub(crate) fn sample(previous: Option<(View, Counters)>) -> (View, Counters) {
|
||||
|
||||
view.sampled_at_ms = now_ms();
|
||||
if let Some((previous_view, previous_counters)) = previous {
|
||||
let seconds = view.sampled_at_ms.saturating_sub(previous_view.sampled_at_ms) as f64 / 1000.0;
|
||||
let seconds = view
|
||||
.sampled_at_ms
|
||||
.saturating_sub(previous_view.sampled_at_ms) as f64
|
||||
/ 1000.0;
|
||||
if seconds > 0.0 {
|
||||
view.process_cpu_percent = counters
|
||||
.process_cpu_seconds
|
||||
.zip(previous_counters.process_cpu_seconds)
|
||||
.and_then(|(current, old)| (current >= old).then_some((current - old) * 100.0 / seconds));
|
||||
.and_then(|(current, old)| {
|
||||
(current >= old).then_some((current - old) * 100.0 / seconds)
|
||||
});
|
||||
}
|
||||
calculate_rates(&mut view, &previous_view);
|
||||
}
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user