From: "Fabian Grünbichler" <f.gruenbichler@proxmox.com>
To: Proxmox Backup Server development discussion
<pbs-devel@lists.proxmox.com>
Subject: Re: [pbs-devel] [PATCH proxmox-backup v5 5/5] acme: certificate ordering through proxmox-acme-api
Date: Tue, 13 Jan 2026 14:45:06 +0100 [thread overview]
Message-ID: <1768310071.o1dolezxdy.astroid@yuna.none> (raw)
In-Reply-To: <20260108112629.189670-10-s.rufinatscha@proxmox.com>
On January 8, 2026 12:26 pm, Samuel Rufinatscha wrote:
> PBS currently uses its own ACME client and API logic, while PDM uses the
> factored out proxmox-acme and proxmox-acme-api crates. This duplication
> risks differences in behaviour and requires ACME maintenance in two
> places. This patch is part of a series to move PBS over to the shared
> ACME stack.
>
> Changes:
> - Replace the custom ACME order/authorization loop in node certificates
> with a call to proxmox_acme_api::order_certificate.
> - Build domain + config data as proxmox-acme-api types
> - Remove obsolete local ACME ordering and plugin glue code.
>
> Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
> ---
> src/acme/mod.rs | 2 -
> src/acme/plugin.rs | 335 ----------------------------------
> src/api2/node/certificates.rs | 229 ++++-------------------
> src/api2/types/acme.rs | 73 --------
> src/api2/types/mod.rs | 3 -
> src/config/acme/mod.rs | 8 +-
> src/config/acme/plugin.rs | 92 +---------
> src/config/node.rs | 20 +-
> src/lib.rs | 2 -
> 9 files changed, 38 insertions(+), 726 deletions(-)
> delete mode 100644 src/acme/mod.rs
> delete mode 100644 src/acme/plugin.rs
> delete mode 100644 src/api2/types/acme.rs
>
[..]
> diff --git a/src/api2/node/certificates.rs b/src/api2/node/certificates.rs
> index 47ff8de5..73401c41 100644
> --- a/src/api2/node/certificates.rs
> +++ b/src/api2/node/certificates.rs
> @@ -1,14 +1,11 @@
> -use std::sync::Arc;
> -use std::time::Duration;
> -
> use anyhow::{bail, format_err, Error};
> use openssl::pkey::PKey;
> use openssl::x509::X509;
> use serde::{Deserialize, Serialize};
> -use tracing::{info, warn};
> +use tracing::info;
>
> use pbs_api_types::{NODE_SCHEMA, PRIV_SYS_MODIFY};
> -use proxmox_acme::async_client::AcmeClient;
> +use proxmox_acme_api::AcmeDomain;
> use proxmox_rest_server::WorkerTask;
> use proxmox_router::list_subdirs_api_method;
> use proxmox_router::SubdirMap;
> @@ -18,8 +15,6 @@ use proxmox_schema::api;
> use pbs_buildcfg::configdir;
> use pbs_tools::cert;
>
> -use crate::api2::types::AcmeDomain;
> -use crate::config::node::NodeConfig;
> use crate::server::send_certificate_renewal_mail;
>
> pub const ROUTER: Router = Router::new()
> @@ -268,193 +263,6 @@ pub async fn delete_custom_certificate() -> Result<(), Error> {
> Ok(())
> }
>
> -struct OrderedCertificate {
> - certificate: hyper::body::Bytes,
> - private_key_pem: Vec<u8>,
> -}
> -
> -async fn order_certificate(
> - worker: Arc<WorkerTask>,
> - node_config: &NodeConfig,
> -) -> Result<Option<OrderedCertificate>, Error> {
> - use proxmox_acme::authorization::Status;
> - use proxmox_acme::order::Identifier;
> -
> - let domains = node_config.acme_domains().try_fold(
> - Vec::<AcmeDomain>::new(),
> - |mut acc, domain| -> Result<_, Error> {
> - let mut domain = domain?;
> - domain.domain.make_ascii_lowercase();
> - if let Some(alias) = &mut domain.alias {
> - alias.make_ascii_lowercase();
> - }
> - acc.push(domain);
> - Ok(acc)
> - },
> - )?;
> -
> - 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() {
> - info!("No domains configured to be ordered from an ACME server.");
> - return Ok(None);
> - }
> -
> - let (plugins, _) = crate::config::acme::plugin::config()?;
> -
> - let mut acme = node_config.acme_client().await?;
> -
> - info!("Placing ACME order");
> - let order = acme
> - .new_order(domains.iter().map(|d| d.domain.to_ascii_lowercase()))
> - .await?;
> - info!("Order URL: {}", order.location);
> -
> - let identifiers: Vec<String> = order
> - .data
> - .identifiers
> - .iter()
> - .map(|identifier| match identifier {
> - Identifier::Dns(domain) => domain.clone(),
> - })
> - .collect();
> -
> - for auth_url in &order.data.authorizations {
> - info!("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 {
> - info!("{domain} is already validated!");
> - continue;
> - }
> -
> - info!("The validation for {domain} is pending");
> - 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::get_acme_plugin(&plugins, plugin_id)?
> - .ok_or_else(|| format_err!("plugin '{plugin_id}' for domain '{domain}' not found!"))?;
> -
> - info!("Setting up validation plugin");
> - let validation_url = plugin_cfg
> - .setup(&mut acme, &auth, domain_config, Arc::clone(&worker))
> - .await?;
> -
> - let result = request_validation(&mut acme, auth_url, validation_url).await;
> -
> - if let Err(err) = plugin_cfg
> - .teardown(&mut acme, &auth, domain_config, Arc::clone(&worker))
> - .await
> - {
> - warn!("Failed to teardown plugin '{plugin_id}' for domain '{domain}' - {err}");
> - }
> -
> - result?;
> - }
> -
> - info!("All domains validated");
> - info!("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 => {
> - info!("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 => {
> - info!("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 => {
> - info!("still processing, trying again in 30 seconds");
> - tokio::time::sleep(Duration::from_secs(30)).await;
> - }
> - Status::Valid => {
> - info!("valid");
> - break;
> - }
> - other => bail!("order status: {:?}", other),
> - }
> - }
> -
> - info!("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,
> - private_key_pem: csr.private_key_pem,
> - }))
> -}
> -
> -async fn request_validation(
> - acme: &mut AcmeClient,
> - auth_url: &str,
> - validation_url: &str,
> -) -> Result<(), Error> {
> - info!("Triggering validation");
> - acme.request_challenge_validation(validation_url).await?;
> -
> - info!("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 => {
> - info!("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
> - ),
> - }
> - }
> -}
> -
> #[api(
> input: {
> properties: {
> @@ -524,9 +332,30 @@ fn spawn_certificate_worker(
>
> let auth_id = rpcenv.get_auth_id().unwrap();
>
> + let acme_config = if let Some(cfg) = node_config.acme_config().transpose()? {
> + cfg
> + } else {
> + proxmox_acme_api::parse_acme_config_string("account=default")?
> + };
wouldn't it make sense to inline this into acme_config() ? the same
fallback is already there for acme_client()
> +
> + let domains = node_config.acme_domains().try_fold(
> + Vec::<AcmeDomain>::new(),
> + |mut acc, domain| -> Result<_, Error> {
> + let mut domain = domain?;
> + domain.domain.make_ascii_lowercase();
> + if let Some(alias) = &mut domain.alias {
> + alias.make_ascii_lowercase();
> + }
> + acc.push(domain);
> + Ok(acc)
> + },
> + )?;
> +
> WorkerTask::spawn(name, None, auth_id, true, move |worker| async move {
> let work = || async {
> - if let Some(cert) = order_certificate(worker, &node_config).await? {
> + if let Some(cert) =
> + proxmox_acme_api::order_certificate(worker, &acme_config, &domains).await?
> + {
> crate::config::set_proxy_certificate(&cert.certificate, &cert.private_key_pem)?;
> crate::server::reload_proxy_certificate().await?;
> }
> @@ -562,16 +391,20 @@ pub fn revoke_acme_cert(rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error
>
> let auth_id = rpcenv.get_auth_id().unwrap();
>
> + let acme_config = if let Some(cfg) = node_config.acme_config().transpose()? {
> + cfg
> + } else {
> + proxmox_acme_api::parse_acme_config_string("account=default")?
> + };
here as well
> +
> WorkerTask::spawn(
> "acme-revoke-cert",
> None,
> auth_id,
> true,
> move |_worker| async move {
> - info!("Loading ACME account");
> - let mut acme = node_config.acme_client().await?;
> info!("Revoking old certificate");
> - acme.revoke_certificate(cert_pem.as_bytes(), None).await?;
> + proxmox_acme_api::revoke_certificate(&acme_config, &cert_pem.as_bytes()).await?;
> info!("Deleting certificate and regenerating a self-signed one");
> delete_custom_certificate().await?;
> Ok(())
[..]
> diff --git a/src/config/acme/plugin.rs b/src/config/acme/plugin.rs
> index 8ce852ec..4b4a216e 100644
> --- a/src/config/acme/plugin.rs
> +++ b/src/config/acme/plugin.rs
> @@ -1,104 +1,16 @@
> use std::sync::LazyLock;
>
> use anyhow::Error;
> -use serde::{Deserialize, Serialize};
> use serde_json::Value;
>
> -use pbs_api_types::PROXMOX_SAFE_ID_FORMAT;
> -use proxmox_schema::{api, ApiType, Schema, StringSchema, Updater};
> +use proxmox_acme_api::{DnsPlugin, StandalonePlugin, PLUGIN_ID_SCHEMA};
> +use proxmox_schema::{ApiType, Schema};
> use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
>
> use pbs_config::{open_backup_lockfile, BackupLockGuard};
>
> -pub const PLUGIN_ID_SCHEMA: Schema = StringSchema::new("ACME Challenge Plugin ID.")
> - .format(&PROXMOX_SAFE_ID_FORMAT)
> - .min_length(1)
> - .max_length(32)
> - .schema();
> -
> pub static CONFIG: LazyLock<SectionConfig> = LazyLock::new(init);
>
> -#[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<u32>,
> -
> - /// Flag to disable the config.
> - #[serde(skip_serializing_if = "Option::is_none", default)]
> - pub disable: Option<bool>,
> -}
> -
> -#[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,
> -}
> -
> -impl DnsPlugin {
> - pub fn decode_data(&self, output: &mut Vec<u8>) -> Result<(), Error> {
> - Ok(proxmox_base64::url::decode_to_vec(&self.data, output)?)
> - }
> -}
> -
> fn init() -> SectionConfig {
> let mut config = SectionConfig::new(&PLUGIN_ID_SCHEMA);
>
> diff --git a/src/config/node.rs b/src/config/node.rs
> index e4b66a20..6865b815 100644
> --- a/src/config/node.rs
> +++ b/src/config/node.rs
> @@ -9,14 +9,14 @@ use pbs_api_types::{
> OPENSSL_CIPHERS_TLS_1_3_SCHEMA,
> };
> use proxmox_acme::async_client::AcmeClient;
> -use proxmox_acme_api::AcmeAccountName;
> +use proxmox_acme_api::{AcmeAccountName, AcmeConfig, AcmeDomain, ACME_DOMAIN_PROPERTY_SCHEMA};
> use proxmox_http::ProxyConfig;
> use proxmox_schema::{api, ApiStringFormat, ApiType, Updater};
>
> use pbs_buildcfg::configdir;
> use pbs_config::{open_backup_lockfile, BackupLockGuard};
>
> -use crate::api2::types::{AcmeDomain, ACME_DOMAIN_PROPERTY_SCHEMA, HTTP_PROXY_SCHEMA};
> +use crate::api2::types::HTTP_PROXY_SCHEMA;
>
> const CONF_FILE: &str = configdir!("/node.cfg");
> const LOCK_FILE: &str = configdir!("/.node.lck");
> @@ -43,20 +43,6 @@ pub fn save_config(config: &NodeConfig) -> Result<(), Error> {
> pbs_config::replace_backup_config(CONF_FILE, &raw)
> }
>
> -#[api(
> - properties: {
> - account: { type: AcmeAccountName },
> - }
> -)]
> -#[derive(Deserialize, Serialize)]
> -/// The ACME configuration.
> -///
> -/// Currently only contains the name of the account use.
> -pub struct AcmeConfig {
> - /// Account to use to acquire ACME certificates.
> - account: AcmeAccountName,
> -}
> -
> /// All available languages in Proxmox. Taken from proxmox-i18n repository.
> /// pt_BR, zh_CN, and zh_TW use the same case in the translation files.
> // TODO: auto-generate from available translations
> @@ -242,7 +228,7 @@ impl NodeConfig {
>
> pub async fn acme_client(&self) -> Result<AcmeClient, Error> {
> let account = if let Some(cfg) = self.acme_config().transpose()? {
> - cfg.account
> + AcmeAccountName::from_string(cfg.account)?
> } else {
> AcmeAccountName::from_string("default".to_string())? // should really not happen
> };
> diff --git a/src/lib.rs b/src/lib.rs
> index 8633378c..828f5842 100644
> --- a/src/lib.rs
> +++ b/src/lib.rs
> @@ -27,8 +27,6 @@ pub(crate) mod auth;
>
> pub mod tape;
>
> -pub mod acme;
> -
> pub mod client_helpers;
>
> pub mod traffic_control_cache;
> --
> 2.47.3
>
>
>
> _______________________________________________
> pbs-devel mailing list
> pbs-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel
>
>
>
_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel
next prev parent reply other threads:[~2026-01-13 13:45 UTC|newest]
Thread overview: 29+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-01-08 11:26 [pbs-devel] [PATCH proxmox{, -backup} v5 0/9] fix #6939: acme: support servers returning 204 for nonce requests Samuel Rufinatscha
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox v5 1/4] acme: reduce visibility of Request type Samuel Rufinatscha
2026-01-13 13:46 ` Fabian Grünbichler
2026-01-14 15:07 ` Samuel Rufinatscha
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox v5 2/4] acme: introduce http_status module Samuel Rufinatscha
2026-01-13 13:45 ` Fabian Grünbichler
2026-01-14 10:29 ` Samuel Rufinatscha
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox v5 3/4] fix #6939: acme: support servers returning 204 for nonce requests Samuel Rufinatscha
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox v5 4/4] acme-api: add helper to load client for an account Samuel Rufinatscha
2026-01-13 13:45 ` Fabian Grünbichler
2026-01-13 16:57 ` Samuel Rufinatscha
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox-backup v5 1/5] acme: clean up ACME-related imports Samuel Rufinatscha
2026-01-13 13:45 ` [pbs-devel] applied: " Fabian Grünbichler
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox-backup v5 2/5] acme: include proxmox-acme-api dependency Samuel Rufinatscha
2026-01-13 13:45 ` Fabian Grünbichler
2026-01-13 16:41 ` Samuel Rufinatscha
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox-backup v5 3/5] acme: drop local AcmeClient Samuel Rufinatscha
2026-01-13 13:45 ` Fabian Grünbichler
2026-01-14 8:56 ` Samuel Rufinatscha
2026-01-14 9:58 ` Fabian Grünbichler
2026-01-14 10:52 ` Samuel Rufinatscha
2026-01-14 16:41 ` Samuel Rufinatscha
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox-backup v5 4/5] acme: change API impls to use proxmox-acme-api handlers Samuel Rufinatscha
2026-01-13 13:45 ` Fabian Grünbichler
2026-01-13 16:53 ` Samuel Rufinatscha
2026-01-08 11:26 ` [pbs-devel] [PATCH proxmox-backup v5 5/5] acme: certificate ordering through proxmox-acme-api Samuel Rufinatscha
2026-01-13 13:45 ` Fabian Grünbichler [this message]
2026-01-13 16:51 ` Samuel Rufinatscha
2026-01-13 13:48 ` [pbs-devel] [PATCH proxmox{, -backup} v5 0/9] fix #6939: acme: support servers returning 204 for nonce requests Fabian Grünbichler
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=1768310071.o1dolezxdy.astroid@yuna.none \
--to=f.gruenbichler@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox