use crate::{ runtime::{ApiError, App}, tls::Certificate, }; use base64::Engine as _; use http::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::{Arc, Mutex}; use tokio::sync::Semaphore; use zeroize::{Zeroize, Zeroizing}; #[derive(Debug, Clone, Serialize)] pub struct Job { pub id: String, pub certificate_id: String, pub status: &'static str, pub kind: &'static str, pub provider: String, pub domains: Vec, pub environment: String, pub message: String, pub log_directory: String, pub certificate: Option, } 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}")) .join("logs") .to_string_lossy() .into_owned() } fn provider_credential_bytes( request: &Value, provider: &str, ) -> Result>, 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 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()) } } let bytes = match provider { "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(), )); } 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(), )); } format!( "dns_ovh_endpoint = {}\ndns_ovh_application_key = {}\ndns_ovh_application_secret = {}\ndns_ovh_consumer_key = {}\n", endpoint, credential(request, "application_key")?, credential(request, "application_secret")?, credential(request, "consumer_key")? ) .into_bytes() } "rfc2136" => { let dns_server = request .get("dns_server") .and_then(Value::as_str) .and_then(|value| value.parse::().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(), )); } 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(), )); } 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() && key_name.len() <= 253 && key_name.split('.').all(|label| { !label.is_empty() && label.len() <= 63 && label.bytes().enumerate().all(|(index, byte)| { (byte.is_ascii_lowercase() || byte.is_ascii_digit()) || (byte == b'-' && index > 0 && index + 1 < label.len()) }) }); if !valid_name { 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(), )); } 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(), )); } 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()) })?, ); if key_secret.len() > 1024 || !(16..=512).contains(&decoded.len()) { 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(), )); } 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(), )) } }; Ok(Zeroizing::new(bytes)) } pub struct Manager { slot: Arc, runtime_slot: Arc, runtime: Mutex, job: Mutex>, renewals: Mutex>, } impl Manager { pub fn new() -> Arc { Arc::new(Self { slot: Arc::new(Semaphore::new(1)), runtime_slot: Arc::new(Semaphore::new(1)), runtime: Mutex::new(RuntimeStatus::default()), job: Mutex::new(None), renewals: Mutex::new(Default::default()), }) } pub fn view(&self) -> Option { self.job.lock().unwrap_or_else(|e| e.into_inner()).clone() } pub async fn start( state: Arc, mut request: Value, actor: String, ) -> Result { let directory = state.policy.cert_dir.clone().ok_or_else(|| { ApiError(StatusCode::CONFLICT, "acme.no_certificate_directory".into()) })?; let id = request .get("certificate_id") .and_then(Value::as_str) .filter(|v| crate::config::valid_id(v)) .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_id".into()))? .to_owned(); let environment = request .get("environment") .and_then(Value::as_str) .unwrap_or("staging") .to_owned(); if !matches!(environment.as_str(), "staging" | "production") || request.get("agree_tos") != Some(&Value::Bool(true)) || (environment == "production" && request.get("confirm_production") != Some(&Value::Bool(true))) { return Err(ApiError( StatusCode::BAD_REQUEST, "acme.consent_required".into(), )); } let provider = request .get("provider") .and_then(Value::as_str) .unwrap_or("rfc2136") .to_owned(); if !matches!( provider.as_str(), "http01" | "cloudflare" | "ovh" | "rfc2136" ) { return Err(ApiError( StatusCode::BAD_REQUEST, "acme.invalid_provider".into(), )); } let names = request .get("domains") .and_then(Value::as_array) .filter(|names| !names.is_empty() && names.len() <= 16) .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domains".into()))?; let mut domains = Vec::new(); for name in names { let raw = name .as_str() .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into()))?; if provider == "http01" && raw.starts_with("*.") { return Err(ApiError( StatusCode::BAD_REQUEST, "acme.http01_no_wildcard".into(), )); } let normalized = crate::config::normalize_host(raw.strip_prefix("*.").unwrap_or(raw)) .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into()))?; if !normalized.contains('.') || normalized.parse::().is_ok() { return Err(ApiError( StatusCode::BAD_REQUEST, "acme.dns_names_only".into(), )); } domains.push(normalized); } if provider == "http01" && !state.active_listeners.iter().any(|l| !l.tls) { return Err(ApiError( StatusCode::CONFLICT, "acme.http01_listener_required".into(), )); } let slot = state .acme .slot .clone() .try_acquire_owned() .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "acme.job_busy".into()))?; if provider != "http01" { let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| { ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into()) })?; let credential = provider_credential_bytes(&request, &provider)?; crate::secret_store::put( &state.store, data_dir, &crate::secret_store::acme_secret_id(&id, &provider), &provider, credential.as_ref(), serde_json::json!({"certificate_id": id.clone(), "purpose": "acme_dns"}), ) .await .map_err(crate::auth::internal)?; } let raw = Zeroizing::new(serde_json::to_string(&request).map_err(crate::auth::internal)?); for key in [ "key_secret", "api_token", "application_key", "application_secret", "consumer_key", ] { if let Some(Value::String(secret)) = request.get_mut(key) { secret.zeroize(); } } if raw.len() > 16384 { return Err(ApiError( StatusCode::PAYLOAD_TOO_LARGE, "acme.request_too_large".into(), )); } let log_directory = certbot_log_directory(&directory, &provider, &environment, &id); let job = Job { id: format!("{:032x}", rand::random::()), certificate_id: id, status: "running", kind: "issue", provider, domains, environment, message: "acme.working".into(), log_directory, certificate: None, }; state .store .audit(&actor, "acme.issue", &job.id) .await .map_err(crate::auth::internal)?; *state.acme.job.lock().unwrap_or_else(|e| e.into_inner()) = Some(job.clone()); let task_job = job.clone(); let tasks = state.tunnels.clone(); tasks.spawn(async move { let _slot = slot; let result = execute(&state, directory, raw).await; let mut finished = task_job; match result { Ok(cert) => { finished.status = "ready"; finished.message = "acme.ready_to_import".into(); finished.certificate = Some(cert); } Err(message) => { finished.status = "failed"; finished.message = message; } } let _ = state .store .audit( &actor, "acme.result", &format!("{} {}", finished.id, finished.status), ) .await; *state.acme.job.lock().unwrap_or_else(|e| e.into_inner()) = Some(finished); }); Ok(job) } } #[derive(Deserialize)] struct Output { provider: String, environment: String, certificate_id: String, domains: Vec, cert_file: String, key_file: String, }