diff --git a/Cargo.toml b/Cargo.toml index bb9ef5e8..f2012510 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "proxmox-acme", + "proxmox-acme-api", "proxmox-api-macro", "proxmox-apt", "proxmox-async", @@ -100,6 +101,7 @@ webauthn-rs = "0.3" zstd = { version = "0.12", features = [ "bindgen" ] } # workspace dependencies +proxmox-acme = { version = "0.5.2", path = "proxmox-acme", default-features = false } proxmox-api-macro = { version = "1.0.8", path = "proxmox-api-macro" } proxmox-async = { version = "0.4.1", path = "proxmox-async" } proxmox-compression = { version = "0.2.0", path = "proxmox-compression" } diff --git a/proxmox-acme-api/Cargo.toml b/proxmox-acme-api/Cargo.toml new file mode 100644 index 00000000..93514bc1 --- /dev/null +++ b/proxmox-acme-api/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "proxmox-acme-api" +version = "0.0.1" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +exclude.workspace = true +description = "ACME API implementation" + +[dependencies] +anyhow.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +base64 = { workspace = true, optional = true } +hex = { workspace = true, optional = true } + + +tokio = { workspace = true, optional = true, features = ["fs"] } +hyper = { workspace = true, optional = true } +futures = { workspace = true, optional = true } +http = { workspace = true, optional = true } +log = { workspace = true, optional = true } +nix = { workspace = true, optional = true } +openssl = { workspace = true, optional = true } +lazy_static = { workspace = true, optional = true } +proxmox-serde = { workspace = true, optional = true } +proxmox-section-config = { workspace = true, optional = true } +proxmox-rest-server = { workspace = true, optional = true } +proxmox-router = { workspace = true, optional = true } +proxmox-sys = { workspace = true, optional = true } +proxmox-schema = { workspace = true, features = ["api-macro", "api-types"] } +proxmox-acme = { workspace = true, optional = true, features = ["api-types"] } + +[features] +default = ["api-types"] +api-types = ["dep:proxmox-acme", "dep:proxmox-serde"] +impl = [ + "api-types", + "dep:proxmox-acme", + "proxmox-acme?/impl", + "proxmox-acme?/async-client", + "dep:proxmox-section-config", + "dep:openssl", + "dep:lazy_static", + "dep:log", + "dep:nix", + "dep:tokio", + "dep:futures", + "dep:http", + "dep:hyper", + "dep:proxmox-sys", + "dep:proxmox-rest-server", + "dep:proxmox-router", + "dep:base64", + "dep:hex", +] diff --git a/proxmox-acme-api/src/account_api_impl.rs b/proxmox-acme-api/src/account_api_impl.rs new file mode 100644 index 00000000..e5cfe087 --- /dev/null +++ b/proxmox-acme-api/src/account_api_impl.rs @@ -0,0 +1,134 @@ +//! ACME account configuration API implementation + +use std::ops::ControlFlow; + +use anyhow::Error; +use serde_json::json; + +use proxmox_acme::async_client::AcmeClient; +use proxmox_acme::types::AccountData as AcmeAccountData; + +use proxmox_rest_server::WorkerTask; +use proxmox_sys::task_warn; + +use crate::types::{AccountEntry, AccountInfo, AcmeAccountName}; +use crate::config::{AcmeApiConfig, DEFAULT_ACME_DIRECTORY_ENTRY}; +use crate::account_config::AccountData; + +fn account_contact_from_string(s: &str) -> Vec { + s.split(&[' ', ';', ',', '\0'][..]) + .map(|s| format!("mailto:{}", s)) + .collect() +} + +impl AcmeApiConfig { + pub fn list_accounts(&self) -> Result, Error> { + let mut entries = Vec::new(); + self.foreach_acme_account(|name| { + entries.push(AccountEntry { name }); + ControlFlow::Continue(()) + })?; + Ok(entries) + } + + pub async fn get_account( + &self, + account_name: AcmeAccountName, + ) -> Result { + let account_data = self.load_account_config(&account_name).await?; + Ok(AccountInfo { + location: account_data.location.clone(), + tos: account_data.tos.clone(), + directory: account_data.directory_url.clone(), + account: AcmeAccountData { + only_return_existing: false, // don't actually write this out in case it's set + ..account_data.account.clone() + }, + }) + } + + pub async fn get_tos(&self, directory: Option) -> Result, Error> { + let directory = directory.unwrap_or_else(|| DEFAULT_ACME_DIRECTORY_ENTRY.url.to_string()); + Ok(AcmeClient::new(directory) + .terms_of_service_url() + .await? + .map(str::to_owned)) + } + + pub async fn register_account( + &self, + name: &AcmeAccountName, + contact: String, + tos_url: Option, + directory_url: String, + eab_creds: Option<(String, String)>, + ) -> Result { + let mut client = AcmeClient::new(directory_url.clone()); + + let contact = account_contact_from_string(&contact); + let account = client + .new_account(tos_url.is_some(), contact, None, eab_creds) + .await?; + + let account = AccountData::from_account_dir_tos(account, directory_url, tos_url); + + self.create_account_config(&name, &account)?; + + Ok(account.location) + } + + pub async fn deactivate_account( + &self, + worker: &WorkerTask, + name: &AcmeAccountName, + force: bool, + ) -> Result<(), Error> { + let mut account_data = self.load_account_config(name).await?; + let mut client = account_data.client(); + + match client + .update_account(&json!({"status": "deactivated"})) + .await + { + Ok(account) => { + account_data.account = account.data.clone(); + self.save_account_config(&name, &account_data)?; + } + Err(err) if !force => return Err(err), + Err(err) => { + task_warn!( + worker, + "error deactivating account {}, proceedeing anyway - {}", + name, + err, + ); + } + } + + self.mark_account_deactivated(&name)?; + + Ok(()) + } + + pub async fn update_account( + &self, + name: &AcmeAccountName, + contact: Option, + ) -> Result<(), Error> { + let mut account_data = self.load_account_config(name).await?; + let mut client = account_data.client(); + + let data = match contact { + Some(contact) => json!({ + "contact": account_contact_from_string(&contact), + }), + None => json!({}), + }; + + let account = client.update_account(&data).await?; + account_data.account = account.data.clone(); + self.save_account_config(&name, &account_data)?; + + Ok(()) + } +} diff --git a/proxmox-acme-api/src/account_config.rs b/proxmox-acme-api/src/account_config.rs new file mode 100644 index 00000000..9e5ef7fe --- /dev/null +++ b/proxmox-acme-api/src/account_config.rs @@ -0,0 +1,230 @@ +//! ACME account configuration helpers (load/save config) + +use std::ops::ControlFlow; +use std::fs::OpenOptions; +use std::path::Path; +use std::os::unix::fs::OpenOptionsExt; + +use anyhow::{bail, format_err, Error}; +use serde::{Deserialize, Serialize}; + +use proxmox_sys::error::SysError; +use proxmox_sys::fs::{replace_file, CreateOptions}; + +use proxmox_schema::api_types::SAFE_ID_REGEX; + +use proxmox_acme::async_client::AcmeClient; +use proxmox_acme::types::AccountData as AcmeAccountData; +use proxmox_acme::Account; + +use crate::config::AcmeApiConfig; +use crate::types::AcmeAccountName; + +#[inline] +fn is_false(b: &bool) -> bool { + !*b +} + +// Our on-disk format inherited from PVE's proxmox-acme code. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountData { + /// The account's location URL. + pub location: String, + + /// The account data. + pub account: AcmeAccountData, + + /// The private key as PEM formatted string. + pub key: String, + + /// ToS URL the user agreed to. + #[serde(skip_serializing_if = "Option::is_none")] + pub tos: Option, + + #[serde(skip_serializing_if = "is_false", default)] + pub debug: bool, + + /// The directory's URL. + pub directory_url: String, +} + +impl AccountData { + pub fn from_account_dir_tos( + account: &Account, + directory_url: String, + tos: Option, + ) -> Self { + AccountData { + location: account.location.clone(), + key: account.private_key.clone(), + account: AcmeAccountData { + only_return_existing: false, // don't actually write this out in case it's set + ..account.data.clone() + }, + debug: false, + tos, + directory_url, + } + } + + pub fn client(&self) -> AcmeClient { + let mut client = AcmeClient::new(self.directory_url.clone()); + client.set_account(Account { + location: self.location.clone(), + private_key: self.key.clone(), + data: self.account.clone(), + }); + client + } +} + +impl AcmeApiConfig { + fn acme_account_dir(&self) -> String { + format!("{}/{}", self.config_dir, "accounts") + } + + /// Returns the path to the account configuration file (`$config_dir/accounts/$name`). + pub fn account_cfg_filename(&self, name: &str) -> String { + format!("{}/{}", self.acme_account_dir(), name) + } + + fn make_acme_account_dir(&self) -> nix::Result<()> { + self.make_acme_dir()?; + Self::create_acme_subdir(&self.acme_account_dir()) + } + + pub(crate) fn foreach_acme_account(&self, mut func: F) -> Result<(), Error> + where + F: FnMut(AcmeAccountName) -> ControlFlow>, + { + match proxmox_sys::fs::scan_subdir(-1, self.acme_account_dir().as_str(), &SAFE_ID_REGEX) { + Ok(files) => { + for file in files { + let file = file?; + let file_name = unsafe { file.file_name_utf8_unchecked() }; + + if file_name.starts_with('_') { + continue; + } + + let account_name = match AcmeAccountName::from_string(file_name.to_owned()) { + Ok(account_name) => account_name, + Err(_) => continue, + }; + + if let ControlFlow::Break(result) = func(account_name) { + return result; + } + } + Ok(()) + } + Err(err) if err.not_found() => Ok(()), + Err(err) => Err(err.into()), + } + } + + // Mark account as deactivated + pub(crate) fn mark_account_deactivated(&self, account_name: &str) -> Result<(), Error> { + let from = self.account_cfg_filename(account_name); + for i in 0..100 { + let to = self.account_cfg_filename(&format!("_deactivated_{}_{}", account_name, i)); + if !Path::new(&to).exists() { + return std::fs::rename(&from, &to).map_err(|err| { + format_err!( + "failed to move account path {:?} to {:?} - {}", + from, + to, + err + ) + }); + } + } + bail!( + "No free slot to rename deactivated account {:?}, please cleanup {:?}", + from, + self.acme_account_dir() + ); + } + + // Load an existing ACME account by name. + pub(crate) async fn load_account_config(&self, account_name: &str) -> Result { + let account_cfg_filename = self.account_cfg_filename(account_name); + let data = match tokio::fs::read(&account_cfg_filename).await { + Ok(data) => data, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + bail!("acme account '{}' does not exist", account_name) + } + Err(err) => bail!( + "failed to load acme account from '{}' - {}", + account_cfg_filename, + err + ), + }; + let data: AccountData = serde_json::from_slice(&data).map_err(|err| { + format_err!( + "failed to parse acme account from '{}' - {}", + account_cfg_filename, + err + ) + })?; + + Ok(data) + } + + // Save an new ACME account (fails if the file already exists). + pub(crate) fn create_account_config( + &self, + account_name: &AcmeAccountName, + account: &AccountData, + ) -> Result<(), Error> { + self.make_acme_account_dir()?; + + let account_cfg_filename = self.account_cfg_filename(account_name.as_ref()); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&account_cfg_filename) + .map_err(|err| format_err!("failed to open {:?} for writing: {}", account_cfg_filename, err))?; + + serde_json::to_writer_pretty(file, account).map_err(|err| { + format_err!( + "failed to write acme account to {:?}: {}", + account_cfg_filename, + err + ) + })?; + + Ok(()) + } + + // Save ACME account data (overtwrite existing data). + pub(crate) fn save_account_config( + &self, + account_name: &AcmeAccountName, + account: &AccountData, + ) -> Result<(), Error> { + let account_cfg_filename = self.account_cfg_filename(account_name.as_ref()); + + let mut data = Vec::::new(); + serde_json::to_writer_pretty(&mut data, account).map_err(|err| { + format_err!( + "failed to serialize acme account to {:?}: {}", + account_cfg_filename, + err + ) + })?; + + self.make_acme_account_dir()?; + replace_file( + account_cfg_filename, + &data, + CreateOptions::new() + .perm(nix::sys::stat::Mode::from_bits_truncate(0o600)) + .owner(nix::unistd::ROOT) + .group(nix::unistd::Gid::from_raw(0)), + true, + ) + } +} diff --git a/proxmox-acme-api/src/acme_plugin.rs b/proxmox-acme-api/src/acme_plugin.rs new file mode 100644 index 00000000..fe6dee3e --- /dev/null +++ b/proxmox-acme-api/src/acme_plugin.rs @@ -0,0 +1,315 @@ +//! Plugin type definitions. + +use std::future::Future; +use std::pin::Pin; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{bail, format_err, Error}; +use hyper::{Body, Request, Response}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +use proxmox_acme::async_client::AcmeClient; +use proxmox_acme::{Authorization, Challenge}; +use proxmox_rest_server::WorkerTask; + +use crate::types::{AcmeDomain, DnsPlugin}; +use crate::plugin_config::PluginData; + +const PROXMOX_ACME_SH_PATH: &str = "/usr/share/proxmox-acme/proxmox-acme"; + +pub(crate) fn get_acme_plugin( + plugin_data: &PluginData, + name: &str, +) -> Result>, Error> { + let (ty, data) = match plugin_data.get(name) { + Some(plugin) => plugin, + None => return Ok(None), + }; + + Ok(Some(match ty.as_str() { + "dns" => { + let plugin: DnsPlugin = serde::Deserialize::deserialize(data)?; + Box::new(plugin) + } + "standalone" => { + // this one has no config + Box::::default() + } + other => bail!("missing implementation for plugin type '{}'", other), + })) +} + +pub(crate) trait AcmePlugin { + /// Setup everything required to trigger the validation and return the corresponding validation + /// URL. + fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( + &'a mut self, + client: &'b mut AcmeClient, + authorization: &'c Authorization, + domain: &'d AcmeDomain, + task: Arc, + ) -> Pin> + Send + 'fut>>; + + fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( + &'a mut self, + client: &'b mut AcmeClient, + authorization: &'c Authorization, + domain: &'d AcmeDomain, + task: Arc, + ) -> Pin> + Send + 'fut>>; +} + +fn extract_challenge<'a>( + authorization: &'a Authorization, + ty: &str, +) -> Result<&'a Challenge, Error> { + authorization + .challenges + .iter() + .find(|ch| ch.ty == ty) + .ok_or_else(|| format_err!("no supported challenge type ({}) found", ty)) +} + +async fn pipe_to_tasklog( + pipe: T, + task: Arc, +) -> Result<(), std::io::Error> { + let mut pipe = BufReader::new(pipe); + let mut line = String::new(); + loop { + line.clear(); + match pipe.read_line(&mut line).await { + Ok(0) => return Ok(()), + Ok(_) => task.log_message(line.as_str()), + Err(err) => return Err(err), + } + } +} + +impl DnsPlugin { + async fn action<'a>( + &self, + client: &mut AcmeClient, + authorization: &'a Authorization, + domain: &AcmeDomain, + task: Arc, + action: &str, + ) -> Result<&'a str, Error> { + let challenge = extract_challenge(authorization, "dns-01")?; + let mut stdin_data = client + .dns_01_txt_value( + challenge + .token() + .ok_or_else(|| format_err!("missing token in challenge"))?, + )? + .into_bytes(); + stdin_data.push(b'\n'); + stdin_data.extend(self.data.as_bytes()); + if stdin_data.last() != Some(&b'\n') { + stdin_data.push(b'\n'); + } + + let mut command = Command::new("/usr/bin/setpriv"); + + #[rustfmt::skip] + command.args([ + "--reuid", "nobody", + "--regid", "nogroup", + "--clear-groups", + "--reset-env", + "--", + "/bin/bash", + PROXMOX_ACME_SH_PATH, + action, + &self.core.api, + domain.alias.as_deref().unwrap_or(&domain.domain), + ]); + + // We could use 1 socketpair, but tokio wraps them all in `File` internally causing `close` + // to be called separately on all of them without exception, so we need 3 pipes :-( + + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let mut stdin = child.stdin.take().expect("Stdio::piped()"); + let stdout = child.stdout.take().expect("Stdio::piped() failed?"); + let stdout = pipe_to_tasklog(stdout, Arc::clone(&task)); + let stderr = child.stderr.take().expect("Stdio::piped() failed?"); + let stderr = pipe_to_tasklog(stderr, Arc::clone(&task)); + let stdin = async move { + stdin.write_all(&stdin_data).await?; + stdin.flush().await?; + Ok::<_, std::io::Error>(()) + }; + match futures::try_join!(stdin, stdout, stderr) { + Ok(((), (), ())) => (), + Err(err) => { + if let Err(err) = child.kill().await { + task.log_message(format!( + "failed to kill '{} {}' command: {}", + PROXMOX_ACME_SH_PATH, action, err + )); + } + bail!("'{}' failed: {}", PROXMOX_ACME_SH_PATH, err); + } + } + + let status = child.wait().await?; + if !status.success() { + bail!( + "'{} {}' exited with error ({})", + PROXMOX_ACME_SH_PATH, + action, + status.code().unwrap_or(-1) + ); + } + + Ok(&challenge.url) + } +} + +impl AcmePlugin for DnsPlugin { + fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( + &'a mut self, + client: &'b mut AcmeClient, + authorization: &'c Authorization, + domain: &'d AcmeDomain, + task: Arc, + ) -> Pin> + Send + 'fut>> { + Box::pin(async move { + let result = self + .action(client, authorization, domain, task.clone(), "setup") + .await; + + let validation_delay = self.core.validation_delay.unwrap_or(30) as u64; + if validation_delay > 0 { + task.log_message(format!( + "Sleeping {} seconds to wait for TXT record propagation", + validation_delay + )); + tokio::time::sleep(Duration::from_secs(validation_delay)).await; + } + result + }) + } + + fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( + &'a mut self, + client: &'b mut AcmeClient, + authorization: &'c Authorization, + domain: &'d AcmeDomain, + task: Arc, + ) -> Pin> + Send + 'fut>> { + Box::pin(async move { + self.action(client, authorization, domain, task, "teardown") + .await + .map(drop) + }) + } +} + +#[derive(Default)] +struct StandaloneServer { + abort_handle: Option, +} + +// In case the "order_certificates" future gets dropped between setup & teardown, let's also cancel +// the HTTP listener on Drop: +impl Drop for StandaloneServer { + fn drop(&mut self) { + self.stop(); + } +} + +impl StandaloneServer { + fn stop(&mut self) { + if let Some(abort) = self.abort_handle.take() { + abort.abort(); + } + } +} + +async fn standalone_respond( + req: Request, + path: Arc, + key_auth: Arc, +) -> Result, hyper::Error> { + if req.method() == hyper::Method::GET && req.uri().path() == path.as_str() { + Ok(Response::builder() + .status(http::StatusCode::OK) + .body(key_auth.as_bytes().to_vec().into()) + .unwrap()) + } else { + Ok(Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body("Not found.".into()) + .unwrap()) + } +} + +impl AcmePlugin for StandaloneServer { + fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( + &'a mut self, + client: &'b mut AcmeClient, + authorization: &'c Authorization, + _domain: &'d AcmeDomain, + _task: Arc, + ) -> Pin> + Send + 'fut>> { + use hyper::server::conn::AddrIncoming; + use hyper::service::{make_service_fn, service_fn}; + + Box::pin(async move { + self.stop(); + + let challenge = extract_challenge(authorization, "http-01")?; + let token = challenge + .token() + .ok_or_else(|| format_err!("missing token in challenge"))?; + let key_auth = Arc::new(client.key_authorization(token)?); + let path = Arc::new(format!("/.well-known/acme-challenge/{}", token)); + + let service = make_service_fn(move |_| { + let path = Arc::clone(&path); + let key_auth = Arc::clone(&key_auth); + async move { + Ok::<_, hyper::Error>(service_fn(move |request| { + standalone_respond(request, Arc::clone(&path), Arc::clone(&key_auth)) + })) + } + }); + + // `[::]:80` first, then `*:80` + let incoming = AddrIncoming::bind(&(([0u16; 8], 80).into())) + .or_else(|_| AddrIncoming::bind(&(([0u8; 4], 80).into())))?; + + let server = hyper::Server::builder(incoming).serve(service); + + let (future, abort) = futures::future::abortable(server); + self.abort_handle = Some(abort); + tokio::spawn(future); + + Ok(challenge.url.as_str()) + }) + } + + fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( + &'a mut self, + _client: &'b mut AcmeClient, + _authorization: &'c Authorization, + _domain: &'d AcmeDomain, + _task: Arc, + ) -> Pin> + Send + 'fut>> { + Box::pin(async move { + if let Some(abort) = self.abort_handle.take() { + abort.abort(); + } + Ok(()) + }) + } +} diff --git a/proxmox-acme-api/src/certificate_helpers.rs b/proxmox-acme-api/src/certificate_helpers.rs new file mode 100644 index 00000000..1bf36afc --- /dev/null +++ b/proxmox-acme-api/src/certificate_helpers.rs @@ -0,0 +1,206 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{bail, format_err, Error}; + +use proxmox_acme::async_client::AcmeClient; +use proxmox_rest_server::WorkerTask; +use proxmox_sys::{task_log, task_warn}; + +use crate::config::AcmeApiConfig; +use crate::types::{AcmeConfig, AcmeDomain}; + +pub struct OrderedCertificate { + pub certificate: Vec, + pub private_key_pem: Vec, +} + +impl AcmeApiConfig { + pub async fn order_certificate( + &self, + worker: Arc, + acme_config: AcmeConfig, + domains: Vec, + ) -> Result, Error> { + use proxmox_acme::authorization::Status; + use proxmox_acme::order::Identifier; + + let get_domain_config = |domain: &str| { + domains + .iter() + .find(|d| d.domain == domain) + .ok_or_else(|| format_err!("no config for domain '{}'", domain)) + }; + + if domains.is_empty() { + task_log!( + worker, + "No domains configured to be ordered from an ACME server." + ); + return Ok(None); + } + + let mut acme = self.load_account_config(&acme_config.account).await?.client(); + + let (plugins, _) = self.plugin_config()?; + + task_log!(worker, "Placing ACME order"); + + let order = acme + .new_order(domains.iter().map(|d| d.domain.to_ascii_lowercase())) + .await?; + + task_log!(worker, "Order URL: {}", order.location); + + let identifiers: Vec = order + .data + .identifiers + .iter() + .map(|identifier| match identifier { + Identifier::Dns(domain) => domain.clone(), + }) + .collect(); + + for auth_url in &order.data.authorizations { + task_log!(worker, "Getting authorization details from '{}'", auth_url); + let mut auth = acme.get_authorization(auth_url).await?; + + let domain = match &mut auth.identifier { + Identifier::Dns(domain) => domain.to_ascii_lowercase(), + }; + + if auth.status == Status::Valid { + task_log!(worker, "{} is already validated!", domain); + continue; + } + + task_log!(worker, "The validation for {} is pending", domain); + let domain_config: &AcmeDomain = get_domain_config(&domain)?; + let plugin_id = domain_config.plugin.as_deref().unwrap_or("standalone"); + let mut plugin_cfg = crate::acme_plugin::get_acme_plugin(&plugins, plugin_id)? + .ok_or_else(|| { + format_err!("plugin '{}' for domain '{}' not found!", plugin_id, domain) + })?; + + task_log!(worker, "Setting up validation plugin"); + let validation_url = plugin_cfg + .setup(&mut acme, &auth, domain_config, Arc::clone(&worker)) + .await?; + + let result = Self::request_validation(&worker, &mut acme, auth_url, validation_url).await; + + if let Err(err) = plugin_cfg + .teardown(&mut acme, &auth, domain_config, Arc::clone(&worker)) + .await + { + task_warn!( + worker, + "Failed to teardown plugin '{}' for domain '{}' - {}", + plugin_id, + domain, + err + ); + } + + result?; + } + + task_log!(worker, "All domains validated"); + task_log!(worker, "Creating CSR"); + + let csr = proxmox_acme::util::Csr::generate(&identifiers, &Default::default())?; + let mut finalize_error_cnt = 0u8; + let order_url = &order.location; + let mut order; + loop { + use proxmox_acme::order::Status; + + order = acme.get_order(order_url).await?; + + match order.status { + Status::Pending => { + task_log!(worker, "still pending, trying to finalize anyway"); + let finalize = order + .finalize + .as_deref() + .ok_or_else(|| format_err!("missing 'finalize' URL in order"))?; + if let Err(err) = acme.finalize(finalize, &csr.data).await { + if finalize_error_cnt >= 5 { + return Err(err); + } + + finalize_error_cnt += 1; + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + Status::Ready => { + task_log!(worker, "order is ready, finalizing"); + let finalize = order + .finalize + .as_deref() + .ok_or_else(|| format_err!("missing 'finalize' URL in order"))?; + acme.finalize(finalize, &csr.data).await?; + tokio::time::sleep(Duration::from_secs(5)).await; + } + Status::Processing => { + task_log!(worker, "still processing, trying again in 30 seconds"); + tokio::time::sleep(Duration::from_secs(30)).await; + } + Status::Valid => { + task_log!(worker, "valid"); + break; + } + other => bail!("order status: {:?}", other), + } + } + + task_log!(worker, "Downloading certificate"); + let certificate = acme + .get_certificate( + order + .certificate + .as_deref() + .ok_or_else(|| format_err!("missing certificate url in finalized order"))?, + ) + .await?; + + Ok(Some(OrderedCertificate { + certificate: certificate.to_vec(), + private_key_pem: csr.private_key_pem, + })) + } + + async fn request_validation( + worker: &WorkerTask, + acme: &mut AcmeClient, + auth_url: &str, + validation_url: &str, + ) -> Result<(), Error> { + task_log!(worker, "Triggering validation"); + acme.request_challenge_validation(validation_url).await?; + + task_log!(worker, "Sleeping for 5 seconds"); + tokio::time::sleep(Duration::from_secs(5)).await; + + loop { + use proxmox_acme::authorization::Status; + + let auth = acme.get_authorization(auth_url).await?; + match auth.status { + Status::Pending => { + task_log!( + worker, + "Status is still 'pending', trying again in 10 seconds" + ); + tokio::time::sleep(Duration::from_secs(10)).await; + } + Status::Valid => return Ok(()), + other => bail!( + "validating challenge '{}' failed - status: {:?}", + validation_url, + other + ), + } + } + } +} diff --git a/proxmox-acme-api/src/challenge_schemas.rs b/proxmox-acme-api/src/challenge_schemas.rs new file mode 100644 index 00000000..6e1217c8 --- /dev/null +++ b/proxmox-acme-api/src/challenge_schemas.rs @@ -0,0 +1,74 @@ +//! Read DNS Challenge schemas. +//! +//! Those schemas are provided by debian package "libproxmox-acme-plugins". + +use std::sync::Arc; +use std::sync::Mutex; +use std::time::SystemTime; + +use anyhow::Error; +use lazy_static::lazy_static; +use serde::Serialize; +use serde_json::Value; + +use proxmox_sys::fs::file_read_string; + +use crate::types::AcmeChallengeSchema; + +const ACME_DNS_SCHEMA_FN: &str = "/usr/share/proxmox-acme/dns-challenge-schema.json"; + +/// Wrapper for efficient Arc use when returning the ACME challenge-plugin schema for serializing. +pub struct ChallengeSchemaWrapper { + inner: Arc>, +} + +impl Serialize for ChallengeSchemaWrapper { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.inner.serialize(serializer) + } +} + +fn load_dns_challenge_schema() -> Result, Error> { + let raw = file_read_string(ACME_DNS_SCHEMA_FN)?; + let schemas: serde_json::Map = serde_json::from_str(&raw)?; + + Ok(schemas + .iter() + .map(|(id, schema)| AcmeChallengeSchema { + id: id.to_owned(), + name: schema + .get("name") + .and_then(Value::as_str) + .unwrap_or(id) + .to_owned(), + ty: "dns".into(), + schema: schema.to_owned(), + }) + .collect()) +} + +pub fn get_cached_challenge_schemas() -> Result { + lazy_static! { + static ref CACHE: Mutex>, SystemTime)>> = + Mutex::new(None); + } + + // the actual loading code + let mut last = CACHE.lock().unwrap(); + + let actual_mtime = std::fs::metadata(ACME_DNS_SCHEMA_FN)?.modified()?; + + let schema = match &*last { + Some((schema, cached_mtime)) if *cached_mtime >= actual_mtime => schema.clone(), + _ => { + let new_schema = Arc::new(load_dns_challenge_schema()?); + *last = Some((Arc::clone(&new_schema), actual_mtime)); + new_schema + } + }; + + Ok(ChallengeSchemaWrapper { inner: schema }) +} diff --git a/proxmox-acme-api/src/config.rs b/proxmox-acme-api/src/config.rs new file mode 100644 index 00000000..97129a08 --- /dev/null +++ b/proxmox-acme-api/src/config.rs @@ -0,0 +1,68 @@ +//! ACME API Configuration. + +use std::borrow::Cow; + +use proxmox_sys::error::SysError; +use proxmox_sys::fs::CreateOptions; + +use crate::types::KnownAcmeDirectory; + +/// ACME API Configuration. +/// +/// This struct provides access to the server side configuration, like the +/// configuration directory. All ACME API functions are implemented as member +/// fuction, so they all have access to this configuration. +/// + +pub struct AcmeApiConfig { + /// Path to the ACME configuration directory. + pub config_dir: &'static str, + /// Configuration file owner. + pub file_owner: fn() -> nix::unistd::User, +} + +/// List of known ACME directorties. +pub const KNOWN_ACME_DIRECTORIES: &[KnownAcmeDirectory] = &[ + KnownAcmeDirectory { + name: Cow::Borrowed("Let's Encrypt V2"), + url: Cow::Borrowed("https://acme-v02.api.letsencrypt.org/directory"), + }, + KnownAcmeDirectory { + name: Cow::Borrowed("Let's Encrypt V2 Staging"), + url: Cow::Borrowed("https://acme-staging-v02.api.letsencrypt.org/directory"), + }, +]; + +/// Default ACME directorties. +pub const DEFAULT_ACME_DIRECTORY_ENTRY: &KnownAcmeDirectory = &KNOWN_ACME_DIRECTORIES[0]; + +// local helpers to read/write acme configuration +impl AcmeApiConfig { + pub(crate) fn acme_config_dir(&self) -> &'static str { + self.config_dir + } + + pub(crate) fn plugin_cfg_filename(&self) -> String { + format!("{}/plugins.cfg", self.acme_config_dir()) + } + pub(crate) fn plugin_cfg_lockfile(&self) -> String { + format!("{}/.plugins.lck", self.acme_config_dir()) + } + + pub(crate) fn create_acme_subdir(dir: &str) -> nix::Result<()> { + let root_only = CreateOptions::new() + .owner(nix::unistd::ROOT) + .group(nix::unistd::Gid::from_raw(0)) + .perm(nix::sys::stat::Mode::from_bits_truncate(0o700)); + + match proxmox_sys::fs::create_dir(dir, root_only) { + Ok(()) => Ok(()), + Err(err) if err.already_exists() => Ok(()), + Err(err) => Err(err), + } + } + + pub(crate) fn make_acme_dir(&self) -> nix::Result<()> { + Self::create_acme_subdir(&self.acme_config_dir()) + } +} diff --git a/proxmox-acme-api/src/lib.rs b/proxmox-acme-api/src/lib.rs new file mode 100644 index 00000000..ea39cfa3 --- /dev/null +++ b/proxmox-acme-api/src/lib.rs @@ -0,0 +1,28 @@ +//! ACME API crate (API types and API implementation) + +#[cfg(feature = "api-types")] +pub mod types; + +#[cfg(feature = "impl")] +pub mod challenge_schemas; + +#[cfg(feature = "impl")] +pub mod config; + +#[cfg(feature = "impl")] +pub(crate) mod account_config; + +#[cfg(feature = "impl")] +pub(crate) mod plugin_config; + +#[cfg(feature = "impl")] +pub(crate) mod account_api_impl; + +#[cfg(feature = "impl")] +pub(crate) mod plugin_api_impl; + +#[cfg(feature = "impl")] +pub(crate) mod acme_plugin; + +#[cfg(feature = "impl")] +pub(crate) mod certificate_helpers; diff --git a/proxmox-acme-api/src/plugin_api_impl.rs b/proxmox-acme-api/src/plugin_api_impl.rs new file mode 100644 index 00000000..7d90ffb4 --- /dev/null +++ b/proxmox-acme-api/src/plugin_api_impl.rs @@ -0,0 +1,188 @@ +//! ACME plugin configuration API implementation + +use anyhow::{bail, format_err, Error}; +use hex::FromHex; + +use serde::Deserialize; +use serde_json::Value; + +use proxmox_schema::param_bail; + +use crate::config::AcmeApiConfig; +use crate::types::{DeletablePluginProperty, PluginConfig, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater}; + +use proxmox_router::{http_bail, RpcEnvironment}; + +impl AcmeApiConfig { + pub fn list_plugins( + &self, + rpcenv: &mut dyn RpcEnvironment, + ) -> Result, Error> { + let (plugins, digest) = self.plugin_config()?; + + rpcenv["digest"] = hex::encode(digest).into(); + Ok(plugins + .iter() + .map(|(id, (ty, data))| modify_cfg_for_api(id, ty, data)) + .collect()) + } + + pub fn get_plugin( + &self, + id: String, + rpcenv: &mut dyn RpcEnvironment, + ) -> Result { + let (plugins, digest) = self.plugin_config()?; + rpcenv["digest"] = hex::encode(digest).into(); + + match plugins.get(&id) { + Some((ty, data)) => Ok(modify_cfg_for_api(&id, ty, data)), + None => http_bail!(NOT_FOUND, "no such plugin"), + } + } + + pub fn add_plugin( + &self, + r#type: String, + core: DnsPluginCore, + data: String, + ) -> Result<(), Error> { + // Currently we only support DNS plugins and the standalone plugin is "fixed": + if r#type != "dns" { + param_bail!("type", "invalid ACME plugin type: {:?}", r#type); + } + + let data = String::from_utf8(base64::decode(data)?) + .map_err(|_| format_err!("data must be valid UTF-8"))?; + + let id = core.id.clone(); + + let _lock = self.lock_plugin_config()?; + + let (mut plugins, _digest) = self.plugin_config()?; + if plugins.contains_key(&id) { + param_bail!("id", "ACME plugin ID {:?} already exists", id); + } + + let plugin = serde_json::to_value(DnsPlugin { core, data })?; + + plugins.insert(id, r#type, plugin); + + self.save_plugin_config(&plugins)?; + + Ok(()) + } + + pub fn update_plugin( + &self, + id: String, + update: DnsPluginCoreUpdater, + data: Option, + delete: Option>, + digest: Option, + ) -> Result<(), Error> { + let data = data + .as_deref() + .map(base64::decode) + .transpose()? + .map(String::from_utf8) + .transpose() + .map_err(|_| format_err!("data must be valid UTF-8"))?; + + let _lock = self.lock_plugin_config()?; + + let (mut plugins, expected_digest) = self.plugin_config()?; + + if let Some(digest) = digest { + let digest = <[u8; 32]>::from_hex(digest)?; + if digest != expected_digest { + bail!("detected modified configuration - file changed by other user? Try again."); + } + } + + match plugins.get_mut(&id) { + Some((ty, ref mut entry)) => { + if ty != "dns" { + bail!("cannot update plugin of type {:?}", ty); + } + + let mut plugin = DnsPlugin::deserialize(&*entry)?; + + if let Some(delete) = delete { + for delete_prop in delete { + match delete_prop { + DeletablePluginProperty::ValidationDelay => { + plugin.core.validation_delay = None; + } + DeletablePluginProperty::Disable => { + plugin.core.disable = None; + } + } + } + } + if let Some(data) = data { + plugin.data = data; + } + if let Some(api) = update.api { + plugin.core.api = api; + } + if update.validation_delay.is_some() { + plugin.core.validation_delay = update.validation_delay; + } + if update.disable.is_some() { + plugin.core.disable = update.disable; + } + + *entry = serde_json::to_value(plugin)?; + } + None => http_bail!(NOT_FOUND, "no such plugin"), + } + + self.save_plugin_config(&plugins)?; + + Ok(()) + } + + pub fn delete_plugin(&self, id: String) -> Result<(), Error> { + let _lock = self.lock_plugin_config()?; + + let (mut plugins, _digest) = self.plugin_config()?; + if plugins.remove(&id).is_none() { + http_bail!(NOT_FOUND, "no such plugin"); + } + self.save_plugin_config(&plugins)?; + + Ok(()) + } +} + +// See PMG/PVE's $modify_cfg_for_api sub +fn modify_cfg_for_api(id: &str, ty: &str, data: &Value) -> PluginConfig { + let mut entry = data.clone(); + + let obj = entry.as_object_mut().unwrap(); + obj.remove("id"); + obj.insert("plugin".to_string(), Value::String(id.to_owned())); + obj.insert("type".to_string(), Value::String(ty.to_owned())); + + // FIXME: This needs to go once the `Updater` is fixed. + // None of these should be able to fail unless the user changed the files by hand, in which + // case we leave the unmodified string in the Value for now. This will be handled with an error + // later. + if let Some(Value::String(ref mut data)) = obj.get_mut("data") { + if let Ok(new) = base64::decode_config(&data, base64::URL_SAFE_NO_PAD) { + if let Ok(utf8) = String::from_utf8(new) { + *data = utf8; + } + } + } + + // PVE/PMG do this explicitly for ACME plugins... + // obj.insert("digest".to_string(), Value::String(digest.clone())); + + serde_json::from_value(entry).unwrap_or_else(|_| PluginConfig { + plugin: "*Error*".to_string(), + ty: "*Error*".to_string(), + ..Default::default() + }) +} diff --git a/proxmox-acme-api/src/plugin_config.rs b/proxmox-acme-api/src/plugin_config.rs new file mode 100644 index 00000000..8b202feb --- /dev/null +++ b/proxmox-acme-api/src/plugin_config.rs @@ -0,0 +1,142 @@ +//! ACME plugin configuration helpers (SectionConfig implementation) + +use anyhow::Error; +use lazy_static::lazy_static; +use serde_json::Value; + +use proxmox_schema::{ApiType, Schema}; +use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}; + +use crate::config::AcmeApiConfig; +use crate::types::{PLUGIN_ID_SCHEMA, DnsPlugin, StandalonePlugin}; + +lazy_static! { + static ref CONFIG: SectionConfig = init(); +} + +impl DnsPlugin { + pub fn decode_data(&self, output: &mut Vec) -> Result<(), Error> { + Ok(base64::decode_config_buf( + &self.data, + base64::URL_SAFE_NO_PAD, + output, + )?) + } +} + +fn init() -> SectionConfig { + let mut config = SectionConfig::new(&PLUGIN_ID_SCHEMA); + + let standalone_schema = match &StandalonePlugin::API_SCHEMA { + Schema::Object(schema) => schema, + _ => unreachable!(), + }; + let standalone_plugin = SectionConfigPlugin::new( + "standalone".to_string(), + Some("id".to_string()), + standalone_schema, + ); + config.register_plugin(standalone_plugin); + + let dns_challenge_schema = match DnsPlugin::API_SCHEMA { + Schema::AllOf(ref schema) => schema, + _ => unreachable!(), + }; + let dns_challenge_plugin = SectionConfigPlugin::new( + "dns".to_string(), + Some("id".to_string()), + dns_challenge_schema, + ); + config.register_plugin(dns_challenge_plugin); + + config +} + +// LockGuard for the plugin configuration +pub(crate) struct AcmePluginConfigLockGuard { + _file: Option, +} + +impl AcmeApiConfig { + pub(crate) fn lock_plugin_config(&self) -> Result { + self.make_acme_dir()?; + let file_owner = (self.file_owner)(); + + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0660); + let options = proxmox_sys::fs::CreateOptions::new() + .perm(mode) + .owner(file_owner.uid) + .group(file_owner.gid); + + let timeout = std::time::Duration::new(10, 0); + + let plugin_cfg_lockfile = self.plugin_cfg_lockfile(); + let file = proxmox_sys::fs::open_file_locked(&plugin_cfg_lockfile, timeout, true, options)?; + Ok(AcmePluginConfigLockGuard { _file: Some(file) }) + } + + pub(crate) fn plugin_config(&self) -> Result<(PluginData, [u8; 32]), Error> { + let plugin_cfg_filename = self.plugin_cfg_filename(); + + let content = + proxmox_sys::fs::file_read_optional_string(&plugin_cfg_filename)?.unwrap_or_default(); + + let digest = openssl::sha::sha256(content.as_bytes()); + let mut data = CONFIG.parse(&plugin_cfg_filename, &content)?; + + if data.sections.get("standalone").is_none() { + let standalone = StandalonePlugin::default(); + data.set_data("standalone", "standalone", &standalone) + .unwrap(); + } + + Ok((PluginData { data }, digest)) + } + + pub(crate) fn save_plugin_config(&self, config: &PluginData) -> Result<(), Error> { + self.make_acme_dir()?; + let plugin_cfg_filename = self.plugin_cfg_filename(); + let raw = CONFIG.write(&plugin_cfg_filename, &config.data)?; + let file_owner = (self.file_owner)(); + + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); + // set the correct owner/group/permissions while saving file + let options = proxmox_sys::fs::CreateOptions::new() + .perm(mode) + .owner(file_owner.uid) + .group(file_owner.gid); + + proxmox_sys::fs::replace_file(plugin_cfg_filename, raw.as_bytes(), options, true) + } +} + +pub(crate) struct PluginData { + data: SectionConfigData, +} + +// And some convenience helpers. +impl PluginData { + pub fn remove(&mut self, name: &str) -> Option<(String, Value)> { + self.data.sections.remove(name) + } + + pub fn contains_key(&mut self, name: &str) -> bool { + self.data.sections.contains_key(name) + } + + pub fn get(&self, name: &str) -> Option<&(String, Value)> { + self.data.sections.get(name) + } + + pub fn get_mut(&mut self, name: &str) -> Option<&mut (String, Value)> { + self.data.sections.get_mut(name) + } + + pub fn insert(&mut self, id: String, ty: String, plugin: Value) { + self.data.sections.insert(id, (ty, plugin)); + } + + pub fn iter(&self) -> impl Iterator + Send { + self.data.sections.iter() + } +} diff --git a/proxmox-acme-api/src/types.rs b/proxmox-acme-api/src/types.rs new file mode 100644 index 00000000..57ad7103 --- /dev/null +++ b/proxmox-acme-api/src/types.rs @@ -0,0 +1,355 @@ +//! ACME API type definitions. + +use std::borrow::Cow; + +use anyhow::Error; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use proxmox_schema::{api, ApiStringFormat, ApiType, Schema, StringSchema, Updater}; +use proxmox_schema::api_types::{DNS_ALIAS_FORMAT, DNS_NAME_FORMAT, SAFE_ID_FORMAT}; + +use proxmox_acme::types::AccountData as AcmeAccountData; + +#[api( + properties: { + san: { + type: Array, + items: { + description: "A SubjectAlternateName entry.", + type: String, + }, + }, + }, +)] +/// Certificate information. +#[derive(PartialEq, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct CertificateInfo { + /// Certificate file name. + pub filename: String, + + /// Certificate subject name. + pub subject: String, + + /// List of certificate's SubjectAlternativeName entries. + pub san: Vec, + + /// Certificate issuer name. + pub issuer: String, + + /// Certificate's notBefore timestamp (UNIX epoch). + #[serde(skip_serializing_if = "Option::is_none")] + pub notbefore: Option, + + /// Certificate's notAfter timestamp (UNIX epoch). + #[serde(skip_serializing_if = "Option::is_none")] + pub notafter: Option, + + /// Certificate in PEM format. + #[serde(skip_serializing_if = "Option::is_none")] + pub pem: Option, + + /// Certificate's public key algorithm. + pub public_key_type: String, + + /// Certificate's public key size if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key_bits: Option, + + /// The SSL Fingerprint. + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, +} + +proxmox_schema::api_string_type! { + #[api(format: &SAFE_ID_FORMAT)] + /// ACME account name. + #[derive(Clone, Eq, PartialEq, Hash, Deserialize, Serialize)] + #[serde(transparent)] + pub struct AcmeAccountName(String); +} + +#[api( + properties: { + name: { type: String }, + url: { type: String }, + }, +)] +/// An ACME directory endpoint with a name and URL. +#[derive(Clone, Deserialize, Serialize, PartialEq)] +pub struct KnownAcmeDirectory { + /// The ACME directory's name. + pub name: Cow<'static, str>, + /// The ACME directory's endpoint URL. + pub url: Cow<'static, str>, +} + +#[api( + properties: { + schema: { + type: Object, + additional_properties: true, + properties: {}, + }, + type: { + type: String, + }, + }, +)] +#[derive(Clone, Deserialize, Serialize, PartialEq)] +/// Schema for an ACME challenge plugin. +pub struct AcmeChallengeSchema { + /// Plugin ID. + pub id: String, + + /// Human readable name, falls back to id. + pub name: String, + + /// Plugin Type. + #[serde(rename = "type")] + pub ty: String, + + /// The plugin's parameter schema. + pub schema: Value, +} + +#[api( + properties: { + "domain": { format: &DNS_NAME_FORMAT }, + "alias": { + optional: true, + format: &DNS_ALIAS_FORMAT, + }, + "plugin": { + optional: true, + format: &SAFE_ID_FORMAT, + }, + }, + default_key: "domain", +)] +#[derive(Clone, PartialEq, Deserialize, Serialize)] +/// A domain entry for an ACME certificate. +pub struct AcmeDomain { + /// The domain to certify for. + pub domain: String, + + /// The domain to use for challenges instead of the default acme challenge domain. + /// + /// This is useful if you use CNAME entries to redirect `_acme-challenge.*` domains to a + /// different DNS server. + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + + /// The plugin to use to validate this domain. + /// + /// Empty means standalone HTTP validation is used. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin: Option, +} + +/// ACME domain configuration string [Schema]. +pub const ACME_DOMAIN_PROPERTY_SCHEMA: Schema = + StringSchema::new("ACME domain configuration string") + .format(&ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA)) + .schema(); + +/// Parse [AcmeDomain] from property string. +pub fn parse_acme_domain_string(value_str: &str) -> Result { + let value = AcmeDomain::API_SCHEMA.parse_property_string(value_str)?; + let value: AcmeDomain = serde_json::from_value(value)?; + Ok(value) +} + +/// Format [AcmeDomain] as property string. +pub fn create_acme_domain_string(config: &AcmeDomain) -> String { + proxmox_schema::property_string::print::(config).unwrap() +} + +#[api()] +#[derive(Clone, PartialEq, Deserialize, Serialize)] +/// ACME Account information. +/// +/// This is what we return via the API. +pub struct AccountInfo { + /// Raw account data. + pub account: AcmeAccountData, + + /// The ACME directory URL the account was created at. + pub directory: String, + + /// The account's own URL within the ACME directory. + pub location: String, + + /// The ToS URL, if the user agreed to one. + #[serde(skip_serializing_if = "Option::is_none")] + pub tos: Option, +} + +/// An ACME Account entry. +/// +/// Currently only contains a 'name' property. +#[api()] +#[derive(Clone, PartialEq, Deserialize, Serialize)] +pub struct AcmeAccountEntry { + pub name: AcmeAccountName, +} + +#[api()] +#[derive(Clone, PartialEq, Deserialize, Serialize)] +/// The ACME configuration. +/// +/// Currently only contains the name of the account use. +pub struct AcmeConfig { + /// Account to use to acquire ACME certificates. + pub account: String, +} + +/// Parse [AcmeConfig] from property string. +pub fn parse_acme_config_string(value_str: &str) -> Result { + let value = AcmeConfig::API_SCHEMA.parse_property_string(value_str)?; + let value: AcmeConfig = serde_json::from_value(value)?; + Ok(value) +} + +/// Format [AcmeConfig] as property string. +pub fn create_acme_config_string(config: &AcmeConfig) -> String { + proxmox_schema::property_string::print::(config).unwrap() +} + +/// [Schema] for ACME Challenge Plugin ID. +pub const PLUGIN_ID_SCHEMA: Schema = StringSchema::new("ACME Challenge Plugin ID.") + .format(&SAFE_ID_FORMAT) + .min_length(1) + .max_length(32) + .schema(); + +#[api] +#[derive(Clone, Default, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +/// ACME plugin config. The API's format is inherited from PVE/PMG: +pub struct PluginConfig { + /// Plugin ID. + pub plugin: String, + + /// Plugin type. + #[serde(rename = "type")] + pub ty: String, + + /// DNS Api name. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub api: Option, + + /// Plugin configuration data. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub data: Option, + + /// Extra delay in seconds to wait before requesting validation. + /// + /// Allows to cope with long TTL of DNS records. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub validation_delay: Option, + + /// Flag to disable the config. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub disable: Option, +} + +#[api( + properties: { + id: { schema: PLUGIN_ID_SCHEMA }, + }, +)] +#[derive(Deserialize, Serialize)] +/// Standalone ACME Plugin for the http-1 challenge. +pub struct StandalonePlugin { + /// Plugin ID. + id: String, +} + +impl Default for StandalonePlugin { + fn default() -> Self { + Self { + id: "standalone".to_string(), + } + } +} + +#[api( + properties: { + id: { schema: PLUGIN_ID_SCHEMA }, + disable: { + optional: true, + default: false, + }, + "validation-delay": { + default: 30, + optional: true, + minimum: 0, + maximum: 2 * 24 * 60 * 60, + }, + }, +)] +/// DNS ACME Challenge Plugin core data. +#[derive(Deserialize, Serialize, Updater)] +#[serde(rename_all = "kebab-case")] +pub struct DnsPluginCore { + /// Plugin ID. + #[updater(skip)] + pub id: String, + + /// DNS API Plugin Id. + pub api: String, + + /// Extra delay in seconds to wait before requesting validation. + /// + /// Allows to cope with long TTL of DNS records. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub validation_delay: Option, + + /// Flag to disable the config. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub disable: Option, +} + +#[api( + properties: { + core: { type: DnsPluginCore }, + }, +)] +/// DNS ACME Challenge Plugin. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct DnsPlugin { + #[serde(flatten)] + pub core: DnsPluginCore, + + // We handle this property separately in the API calls. + /// DNS plugin data (base64url encoded without padding). + #[serde(with = "proxmox_serde::string_as_base64url_nopad")] + pub data: String, +} + +#[api()] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Deletable plugin property names. +pub enum DeletablePluginProperty { + /// Delete the disable property + Disable, + /// Delete the validation-delay property + ValidationDelay, +} + +#[api( + properties: { + name: { type: AcmeAccountName }, + }, +)] +/// An ACME Account entry. +/// +/// Currently only contains a 'name' property. +#[derive(Clone, PartialEq, Deserialize, Serialize)] +pub struct AccountEntry { + pub name: AcmeAccountName, +}