This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
async fn execute(
state: &App,
directory: PathBuf,
raw: Zeroizing<String>,
) -> Result<Certificate, String> {
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<Arc<App>>,
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::<crate::server::Transport>()
.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<i64>,
pub not_after: Option<i64>,
pub failures: u32,
pub status: String,
pub message: String,
}
fn private_json(path: &std::path::Path, limit: u64) -> anyhow::Result<Value> {
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<Vec<String>> {
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<String> = 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)
}