Files
RustProxy/src/acme/model.rs
T
2026-10-04 11:15:58 +02:00

404 lines
16 KiB
Rust

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<String>,
pub environment: String,
pub message: String,
pub log_directory: String,
pub certificate: Option<Certificate>,
}
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<Zeroizing<Vec<u8>>, 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::<std::net::IpAddr>().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<Semaphore>,
job: Mutex<Option<Job>>,
renewals: Mutex<std::collections::BTreeMap<String, RenewalState>>,
}
impl Manager {
pub fn new() -> Arc<Self> {
Arc::new(Self {
slot: Arc::new(Semaphore::new(1)),
job: Mutex::new(None),
renewals: Mutex::new(Default::default()),
})
}
pub fn view(&self) -> Option<Job> {
self.job.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub async fn start(
state: Arc<App>,
mut request: Value,
actor: String,
) -> Result<Job, ApiError> {
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::<std::net::IpAddr>().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::<u128>()),
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<String>,
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<u32>);
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<String>) -> Result<Value, String> {
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)
}