async fn execute( state: &App, directory: PathBuf, raw: Zeroizing, ) -> Result { let value = worker(state, directory.clone(), raw).await?; let data: Output = serde_json::from_value(value).map_err(|_| "acme.invalid_worker_response".to_owned())?; tokio::task::spawn_blocking(move || { let result = crate::tls::validate_acme( &directory, data.certificate_id, data.cert_file.clone(), data.key_file.clone(), &data.domains, ) .map(|mut cert| { cert.issuer = Some(crate::tls::Issuer { provider: data.provider, environment: data.environment, auto_renew: true, }); cert }); if result.is_err() { for name in [data.cert_file, data.key_file] { if name.starts_with("acme-") && crate::tls::safe_name(&name).is_ok() { let _ = std::fs::remove_file(directory.join(name)); } } } result.map_err(|_| "acme.certificate_validation_failed".to_owned()) }) .await .map_err(|_| "acme.validation_worker_failed".to_owned())? } /// A reserved validation path: only an active HTTP-01 job and its exact DNS /// names can read bounded tokens. No origin, cache, auth or custom pages here. pub async fn challenge( axum::extract::State(state): axum::extract::State>, request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { use axum::{body::Body, response::IntoResponse}; use tokio::io::AsyncReadExt; const PREFIX: &str = "/.well-known/acme-challenge/"; let Some(token) = request.uri().path().strip_prefix(PREFIX) else { return next.run(request).await; }; let denied = || { ( StatusCode::NOT_FOUND, [("cache-control", "no-store")], "ACME challenge not found\n", ) .into_response() }; if token.is_empty() || token.len() > 256 || !token .bytes() .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-')) || request.uri().query().is_some() || request .extensions() .get::() .is_some_and(|t| t.tls) { return denied(); } let host = request .headers() .get(http::header::HOST) .and_then(|h| h.to_str().ok()) .and_then(|h| crate::config::request_host(h).ok()); let permitted = state.acme.view().is_some_and(|job| { job.status == "running" && job.provider == "http01" && host.is_some_and(|h| job.domains.contains(&h)) }); if !permitted { return denied(); } if request.method() != http::Method::GET && request.method() != http::Method::HEAD { return ( StatusCode::METHOD_NOT_ALLOWED, [("allow", "GET, HEAD"), ("cache-control", "no-store")], ) .into_response(); } let Some(directory) = &state.policy.cert_dir else { return denied(); }; let path = directory .join("acme-http01/.well-known/acme-challenge") .join(token); let mut options = tokio::fs::OpenOptions::new(); options.read(true); #[cfg(unix)] { options.custom_flags(libc::O_NOFOLLOW); } let Ok(file) = options.open(path).await else { return denied(); }; let Ok(metadata) = file.metadata().await else { return denied(); }; if !metadata.is_file() || metadata.len() > 4096 { return denied(); } let mut data = Vec::new(); if file.take(4097).read_to_end(&mut data).await.is_err() || data.len() > 4096 || data.is_empty() || !data .iter() .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'-' | b'.')) { return denied(); } let length = data.len().to_string(); let mut response = ( [ ("content-type", "application/octet-stream"), ("cache-control", "no-store"), ("x-content-type-options", "nosniff"), ], data, ) .into_response(); if let Ok(value) = length.parse() { response.headers_mut().insert("content-length", value); } if request.method() == http::Method::HEAD { *response.body_mut() = Body::empty(); } response } #[cfg(test)] include!("providers/tests.rs"); /// Public diagnostics contain no account/credential material. Desired renewal /// permission lives with the certificate; observations never overwrite it. #[derive(Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct RenewalState { pub last_checked: i64, pub next_check: i64, pub last_renewed: Option, pub not_after: Option, pub failures: u32, pub status: String, pub message: String, } fn private_json(path: &std::path::Path, limit: u64) -> anyhow::Result { use std::io::Read; let mut options = std::fs::OpenOptions::new(); options.read(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW); } let file = options.open(path)?; let meta = file.metadata()?; anyhow::ensure!( meta.is_file() && meta.len() <= limit, "invalid private metadata" ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; anyhow::ensure!( meta.permissions().mode() & 0o077 == 0, "private metadata permissions" ); } let mut bytes = Vec::new(); file.take(limit + 1).read_to_end(&mut bytes)?; anyhow::ensure!(bytes.len() as u64 <= limit, "private metadata too large"); Ok(serde_json::from_slice(&bytes)?) } fn profile_domains(directory: &std::path::Path, cert: &Certificate) -> anyhow::Result> { let issuer = cert .issuer .as_ref() .ok_or_else(|| anyhow::anyhow!("no issuer"))?; anyhow::ensure!( crate::config::valid_id(&cert.id) && matches!( issuer.provider.as_str(), "http01" | "rfc2136" | "cloudflare" | "ovh" ) && matches!(issuer.environment.as_str(), "production" | "staging"), "invalid profile identity" ); let suffix = if issuer.provider == "rfc2136" { String::new() } else { format!("-{}", issuer.provider) }; let profile = private_json( &directory .join("acme") .join(format!("{}-{}{}", issuer.environment, cert.id, suffix)) .join("profile.json"), 16384, )?; anyhow::ensure!( profile.get("certificate_id").and_then(Value::as_str) == Some(cert.id.as_str()) && profile.get("provider").and_then(Value::as_str) == Some(issuer.provider.as_str()) && profile.get("environment").and_then(Value::as_str) == Some(issuer.environment.as_str()), "profile identity mismatch" ); let names: Vec = serde_json::from_value(profile["domains"].clone())?; anyhow::ensure!( !names.is_empty() && names.len() <= 16, "invalid profile names" ); for name in &names { crate::config::normalize_host(name.strip_prefix("*.").unwrap_or(name))?; } Ok(names) }