From 7c899090e4963443e586a57d5b983c6ce00e38d4 Mon Sep 17 00:00:00 2001 From: Dietmar Maurer Date: Fri, 17 May 2024 11:52:57 +0200 Subject: [PATCH] acme-api: use product-config instead of custom acme api configuration Signed-off-by: Dietmar Maurer --- proxmox-acme-api/Cargo.toml | 3 + proxmox-acme-api/src/account_api_impl.rs | 216 ++++++------ proxmox-acme-api/src/account_config.rs | 288 ++++++++-------- proxmox-acme-api/src/certificate_helpers.rs | 352 ++++++++++---------- proxmox-acme-api/src/config.rs | 63 ++-- proxmox-acme-api/src/lib.rs | 26 +- proxmox-acme-api/src/plugin_api_impl.rs | 235 +++++++------ proxmox-acme-api/src/plugin_config.rs | 74 ++-- 8 files changed, 608 insertions(+), 649 deletions(-) diff --git a/proxmox-acme-api/Cargo.toml b/proxmox-acme-api/Cargo.toml index 93514bc1..d76c47f5 100644 --- a/proxmox-acme-api/Cargo.toml +++ b/proxmox-acme-api/Cargo.toml @@ -32,12 +32,15 @@ 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"] } +proxmox-product-config = { workspace = true, optional = true } [features] default = ["api-types"] api-types = ["dep:proxmox-acme", "dep:proxmox-serde"] impl = [ "api-types", + "dep:proxmox-product-config", + "proxmox-product-config?/impl", "dep:proxmox-acme", "proxmox-acme?/impl", "proxmox-acme?/async-client", diff --git a/proxmox-acme-api/src/account_api_impl.rs b/proxmox-acme-api/src/account_api_impl.rs index e5cfe087..27d30ba9 100644 --- a/proxmox-acme-api/src/account_api_impl.rs +++ b/proxmox-acme-api/src/account_api_impl.rs @@ -11,9 +11,9 @@ 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; +use crate::config::DEFAULT_ACME_DIRECTORY_ENTRY; +use crate::types::{AccountEntry, AccountInfo, AcmeAccountName}; fn account_contact_from_string(s: &str) -> Vec { s.split(&[' ', ';', ',', '\0'][..]) @@ -21,114 +21,106 @@ fn account_contact_from_string(s: &str) -> Vec { .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(()) - } +pub fn list_accounts() -> Result, Error> { + let mut entries = Vec::new(); + super::account_config::foreach_acme_account(|name| { + entries.push(AccountEntry { name }); + ControlFlow::Continue(()) + })?; + Ok(entries) +} + +pub async fn get_account(account_name: AcmeAccountName) -> Result { + let account_data = super::account_config::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(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( + 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); + + super::account_config::create_account_config(&name, &account)?; + + Ok(account.location) +} + +pub async fn deactivate_account( + worker: &WorkerTask, + name: &AcmeAccountName, + force: bool, +) -> Result<(), Error> { + let mut account_data = super::account_config::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(); + super::account_config::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, + ); + } + } + + super::account_config::mark_account_deactivated(&name)?; + + Ok(()) +} + +pub async fn update_account( + name: &AcmeAccountName, + contact: Option, +) -> Result<(), Error> { + let mut account_data = super::account_config::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(); + super::account_config::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 index 9e5ef7fe..7c7bd8a3 100644 --- a/proxmox-acme-api/src/account_config.rs +++ b/proxmox-acme-api/src/account_config.rs @@ -1,9 +1,9 @@ //! ACME account configuration helpers (load/save config) -use std::ops::ControlFlow; use std::fs::OpenOptions; -use std::path::Path; +use std::ops::ControlFlow; use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; use anyhow::{bail, format_err, Error}; use serde::{Deserialize, Serialize}; @@ -17,7 +17,6 @@ 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] @@ -79,152 +78,155 @@ impl AccountData { } } -impl AcmeApiConfig { - fn acme_account_dir(&self) -> String { - format!("{}/{}", self.config_dir, "accounts") - } +fn acme_account_dir() -> PathBuf { + super::config::acme_config_dir().join("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) - } +/// Returns the path to the account configuration file (`$config_dir/accounts/$name`). +pub fn account_cfg_filename(name: &str) -> PathBuf { + acme_account_dir().join(name) +} - fn make_acme_account_dir(&self) -> nix::Result<()> { - self.make_acme_dir()?; - Self::create_acme_subdir(&self.acme_account_dir()) - } +fn make_acme_account_dir() -> nix::Result<()> { + super::config::make_acme_dir()?; + super::config::create_secret_subdir(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() }; +pub(crate) fn foreach_acme_account(mut func: F) -> Result<(), Error> +where + F: FnMut(AcmeAccountName) -> ControlFlow>, +{ + match proxmox_sys::fs::scan_subdir(-1, acme_account_dir().as_path(), &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; - } + 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()), + Ok(()) } - } - - // 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, - ) + Err(err) if err.not_found() => Ok(()), + Err(err) => Err(err.into()), } } + +// Mark account as deactivated +pub(crate) fn mark_account_deactivated(account_name: &str) -> Result<(), Error> { + let from = account_cfg_filename(account_name); + for i in 0..100 { + let to = 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, + acme_account_dir() + ); +} + +// Load an existing ACME account by name. +pub(crate) async fn load_account_config(account_name: &str) -> Result { + let account_cfg_filename = 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( + account_name: &AcmeAccountName, + account: &AccountData, +) -> Result<(), Error> { + make_acme_account_dir()?; + + let account_cfg_filename = 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( + account_name: &AcmeAccountName, + account: &AccountData, +) -> Result<(), Error> { + let account_cfg_filename = 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 + ) + })?; + + 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/certificate_helpers.rs b/proxmox-acme-api/src/certificate_helpers.rs index 1bf36afc..c83f2d57 100644 --- a/proxmox-acme-api/src/certificate_helpers.rs +++ b/proxmox-acme-api/src/certificate_helpers.rs @@ -7,7 +7,6 @@ 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 { @@ -15,192 +14,191 @@ pub struct OrderedCertificate { 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; +pub async fn order_certificate( + 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 + let get_domain_config = |domain: &str| { + domains .iter() - .map(|identifier| match identifier { - Identifier::Dns(domain) => domain.clone(), - }) - .collect(); + .find(|d| d.domain == domain) + .ok_or_else(|| format_err!("no config for domain '{}'", domain)) + }; - 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, - })) + if domains.is_empty() { + task_log!( + worker, + "No domains configured to be ordered from an ACME server." + ); + return Ok(None); } - 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?; + let mut acme = super::account_config::load_account_config(&acme_config.account) + .await? + .client(); - task_log!(worker, "Sleeping for 5 seconds"); - tokio::time::sleep(Duration::from_secs(5)).await; + let (plugins, _) = super::plugin_config::plugin_config()?; - loop { - use proxmox_acme::authorization::Status; + task_log!(worker, "Placing ACME order"); - 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; + 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 = 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; } - Status::Valid => return Ok(()), - other => bail!( - "validating challenge '{}' failed - status: {:?}", - validation_url, - other - ), + 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/config.rs b/proxmox-acme-api/src/config.rs index 97129a08..02b2e68a 100644 --- a/proxmox-acme-api/src/config.rs +++ b/proxmox-acme-api/src/config.rs @@ -1,26 +1,15 @@ //! ACME API Configuration. use std::borrow::Cow; +use std::path::{Path, PathBuf}; use proxmox_sys::error::SysError; use proxmox_sys::fs::CreateOptions; +use proxmox_product_config::product_config; + 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 { @@ -36,33 +25,31 @@ pub const KNOWN_ACME_DIRECTORIES: &[KnownAcmeDirectory] = &[ /// 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 acme_config_dir() -> PathBuf { + product_config().absolute_path("acme") +} - 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 plugin_cfg_filename() -> PathBuf { + acme_config_dir().join("plugins.cfg") +} - 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)); +pub(crate) fn plugin_cfg_lockfile() -> PathBuf { + acme_config_dir().join("plugins.lck") +} - 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 create_secret_subdir>(dir: P) -> 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)); - pub(crate) fn make_acme_dir(&self) -> nix::Result<()> { - Self::create_acme_subdir(&self.acme_config_dir()) + 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() -> nix::Result<()> { + create_secret_subdir(acme_config_dir()) +} diff --git a/proxmox-acme-api/src/lib.rs b/proxmox-acme-api/src/lib.rs index ea39cfa3..2bb2f6bd 100644 --- a/proxmox-acme-api/src/lib.rs +++ b/proxmox-acme-api/src/lib.rs @@ -4,25 +4,37 @@ pub mod types; #[cfg(feature = "impl")] -pub mod challenge_schemas; +mod config; #[cfg(feature = "impl")] -pub mod config; +mod challenge_schemas; +#[cfg(feature = "impl")] +pub use challenge_schemas::get_cached_challenge_schemas; #[cfg(feature = "impl")] -pub(crate) mod account_config; +mod account_config; #[cfg(feature = "impl")] -pub(crate) mod plugin_config; +mod plugin_config; #[cfg(feature = "impl")] -pub(crate) mod account_api_impl; +mod account_api_impl; +#[cfg(feature = "impl")] +pub use account_api_impl::{ + deactivate_account, get_account, get_tos, list_accounts, register_account, update_account, +}; #[cfg(feature = "impl")] -pub(crate) mod plugin_api_impl; +mod plugin_api_impl; +#[cfg(feature = "impl")] +pub use plugin_api_impl::{add_plugin, delete_plugin, get_plugin, list_plugins, update_plugin}; + #[cfg(feature = "impl")] pub(crate) mod acme_plugin; + #[cfg(feature = "impl")] -pub(crate) mod certificate_helpers; +mod certificate_helpers; +#[cfg(feature = "impl")] +pub use certificate_helpers::order_certificate; diff --git a/proxmox-acme-api/src/plugin_api_impl.rs b/proxmox-acme-api/src/plugin_api_impl.rs index 7d90ffb4..6205612e 100644 --- a/proxmox-acme-api/src/plugin_api_impl.rs +++ b/proxmox-acme-api/src/plugin_api_impl.rs @@ -8,152 +8,141 @@ use serde_json::Value; use proxmox_schema::param_bail; -use crate::config::AcmeApiConfig; -use crate::types::{DeletablePluginProperty, PluginConfig, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater}; +use crate::types::{ + DeletablePluginProperty, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PluginConfig, +}; 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()?; +pub fn list_plugins(rpcenv: &mut dyn RpcEnvironment) -> Result, Error> { + let (plugins, digest) = super::plugin_config::plugin_config()?; - rpcenv["digest"] = hex::encode(digest).into(); - Ok(plugins - .iter() - .map(|(id, (ty, data))| modify_cfg_for_api(id, ty, data)) - .collect()) + 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( + id: String, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + let (plugins, digest) = super::plugin_config::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(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); } - pub fn get_plugin( - &self, - id: String, - rpcenv: &mut dyn RpcEnvironment, - ) -> Result { - let (plugins, digest) = self.plugin_config()?; - rpcenv["digest"] = hex::encode(digest).into(); + let data = String::from_utf8(base64::decode(data)?) + .map_err(|_| format_err!("data must be valid UTF-8"))?; - match plugins.get(&id) { - Some((ty, data)) => Ok(modify_cfg_for_api(&id, ty, data)), - None => http_bail!(NOT_FOUND, "no such plugin"), + let id = core.id.clone(); + + let _lock = super::plugin_config::lock_plugin_config()?; + + let (mut plugins, _digest) = super::plugin_config::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); + + super::plugin_config::save_plugin_config(&plugins)?; + + Ok(()) +} + +pub fn update_plugin( + 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 = super::plugin_config::lock_plugin_config()?; + + let (mut plugins, expected_digest) = super::plugin_config::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."); } } - 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); } - } - 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)?; - 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(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"), + 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)?; } - - self.save_plugin_config(&plugins)?; - - Ok(()) + None => http_bail!(NOT_FOUND, "no such plugin"), } - pub fn delete_plugin(&self, id: String) -> Result<(), Error> { - let _lock = self.lock_plugin_config()?; + super::plugin_config::save_plugin_config(&plugins)?; - 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(()) +} - Ok(()) +pub fn delete_plugin(id: String) -> Result<(), Error> { + let _lock = super::plugin_config::lock_plugin_config()?; + + let (mut plugins, _digest) = super::plugin_config::plugin_config()?; + if plugins.remove(&id).is_none() { + http_bail!(NOT_FOUND, "no such plugin"); } + super::plugin_config::save_plugin_config(&plugins)?; + + Ok(()) } // See PMG/PVE's $modify_cfg_for_api sub diff --git a/proxmox-acme-api/src/plugin_config.rs b/proxmox-acme-api/src/plugin_config.rs index 8b202feb..346752cd 100644 --- a/proxmox-acme-api/src/plugin_config.rs +++ b/proxmox-acme-api/src/plugin_config.rs @@ -6,9 +6,9 @@ use serde_json::Value; use proxmox_schema::{ApiType, Schema}; use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}; +use proxmox_product_config::{ApiLockGuard, open_api_lockfile, replace_config}; -use crate::config::AcmeApiConfig; -use crate::types::{PLUGIN_ID_SCHEMA, DnsPlugin, StandalonePlugin}; +use crate::types::{DnsPlugin, StandalonePlugin, PLUGIN_ID_SCHEMA}; lazy_static! { static ref CONFIG: SectionConfig = init(); @@ -52,62 +52,38 @@ fn init() -> SectionConfig { config } -// LockGuard for the plugin configuration -pub(crate) struct AcmePluginConfigLockGuard { - _file: Option, +pub(crate) fn lock_plugin_config() -> Result { + super::config::make_acme_dir()?; + + let plugin_cfg_lockfile = super::config::plugin_cfg_lockfile(); + + open_api_lockfile(plugin_cfg_lockfile, None, true) } -impl AcmeApiConfig { - pub(crate) fn lock_plugin_config(&self) -> Result { - self.make_acme_dir()?; - let file_owner = (self.file_owner)(); +pub(crate) fn plugin_config() -> Result<(PluginData, [u8; 32]), Error> { + let plugin_cfg_filename = super::config::plugin_cfg_filename(); - 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 content = + proxmox_sys::fs::file_read_optional_string(&plugin_cfg_filename)?.unwrap_or_default(); - let timeout = std::time::Duration::new(10, 0); + let digest = openssl::sha::sha256(content.as_bytes()); + let mut data = CONFIG.parse(&plugin_cfg_filename, &content)?; - 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) }) + if data.sections.get("standalone").is_none() { + let standalone = StandalonePlugin::default(); + data.set_data("standalone", "standalone", &standalone) + .unwrap(); } - pub(crate) fn plugin_config(&self) -> Result<(PluginData, [u8; 32]), Error> { - let plugin_cfg_filename = self.plugin_cfg_filename(); + Ok((PluginData { data }, digest)) +} - let content = - proxmox_sys::fs::file_read_optional_string(&plugin_cfg_filename)?.unwrap_or_default(); +pub(crate) fn save_plugin_config(config: &PluginData) -> Result<(), Error> { + super::config::make_acme_dir()?; + let plugin_cfg_filename = super::config::plugin_cfg_filename(); + let raw = CONFIG.write(&plugin_cfg_filename, &config.data)?; - 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) - } + replace_config(plugin_cfg_filename, raw.as_bytes()) } pub(crate) struct PluginData {