use crate::{ runtime::{ApiError, App}, tls::Certificate, }; use base64::Engine as _; use http::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ path::PathBuf, process::Stdio, sync::{Arc, Mutex}, time::Duration, }; use tokio::{io::AsyncWriteExt, process::Command, 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, job: Mutex>, renewals: Mutex>, } impl Manager { pub fn new() -> Arc { Arc::new(Self { slot: Arc::new(Semaphore::new(1)), 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; } } // Never write provider stderr, request JSON or TSIG material to audit. 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, } /// Kill the entire private process group if execution is cancelled/abandoned. /// Certbot inherits the group, so it cannot outlive a stopped RustProxy worker. struct ProcessGroup(Option); impl Drop for ProcessGroup { fn drop(&mut self) { #[cfg(unix)] if let Some(id) = self.0 { // SAFETY: this is the process group created solely for this child. unsafe { libc::kill(-(id as i32), libc::SIGKILL); } } } } async fn worker(state: &App, directory: PathBuf, raw: Zeroizing) -> Result { let mut command = Command::new("python3"); command .arg("-I") .arg("-c") .arg(include_str!("../../scripts/acme_provider.py")) .arg("--cert-dir") .arg(&directory) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); #[cfg(unix)] { use std::os::unix::process::CommandExt; command.as_std_mut().process_group(0); } let mut child = command .spawn() .map_err(|_| "acme.python_not_available".to_owned())?; let mut group = ProcessGroup(child.id()); let stdin = child .stdin .take() .ok_or_else(|| "acme.worker_io".to_owned())?; let work = async move { let mut stdin = stdin; stdin .write_all(raw.as_bytes()) .await .map_err(|_| "acme.worker_io".to_owned())?; stdin .shutdown() .await .map_err(|_| "acme.worker_io".to_owned())?; drop(stdin); drop(raw); child .wait_with_output() .await .map_err(|_| "acme.worker_io".to_owned()) }; let output = tokio::select! { _ = state.shutdown.cancelled() => return Err("acme.cancelled".into()), result = tokio::time::timeout(Duration::from_secs(2160), work) => result.map_err(|_| "acme.provider_timeout".to_owned())??, }; group.0 = None; // the process has been reaped, never kill a reused PID if output.stdout.len() > 16384 { return Err("acme.invalid_worker_response".into()); } let value: Value = serde_json::from_slice(&output.stdout) .map_err(|_| "acme.invalid_worker_response".to_owned())?; if !output.status.success() { let reason = value.get("error").and_then(Value::as_str).filter(|s| { s.starts_with("acme.") && s.len() < 96 && s.bytes() .all(|c| c.is_ascii_lowercase() || c == b'_' || c == b'.') }); return Err(reason.unwrap_or("acme.worker_failed").to_owned()); } Ok(value) }