v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user