229 lines
7.3 KiB
Rust
229 lines
7.3 KiB
Rust
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)
|
|
}
|