404 lines
16 KiB
Rust
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)
|
|
}
|