Files
RustProxy/src/acme/model.rs
T

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,
}