419 lines
15 KiB
Rust
419 lines
15 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::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<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>,
|
|
runtime_slot: Arc<Semaphore>,
|
|
runtime: Mutex<RuntimeStatus>,
|
|
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)),
|
|
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<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;
|
|
}
|
|
}
|
|
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,
|
|
}
|