public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [PATCH proxmox-backup v6 1/2] acme: remove local AcmeClient and use proxmox-acme-api handlers
Date: Fri, 16 Jan 2026 12:28:58 +0100	[thread overview]
Message-ID: <20260116112859.194016-5-s.rufinatscha@proxmox.com> (raw)
In-Reply-To: <20260116112859.194016-1-s.rufinatscha@proxmox.com>

PBS currently uses its own ACME client and API logic, while PDM uses the
factored out proxmox-acme and proxmox-acme-api crates. This requires
maintenance in two places. This patch moves PBS over to the shared
ACME stack.

Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
 Cargo.toml                             |   3 +
 src/acme/client.rs                     | 691 -------------------------
 src/acme/mod.rs                        |   4 -
 src/acme/plugin.rs                     |   2 +-
 src/api2/config/acme.rs                | 399 ++------------
 src/api2/node/certificates.rs          | 221 +-------
 src/api2/types/acme.rs                 |  61 +--
 src/bin/proxmox-backup-api.rs          |   2 +
 src/bin/proxmox-backup-manager.rs      |   3 +-
 src/bin/proxmox-backup-proxy.rs        |   1 +
 src/bin/proxmox_backup_manager/acme.rs |  37 +-
 src/config/acme/mod.rs                 | 167 ------
 src/config/acme/plugin.rs              |  88 +---
 src/config/node.rs                     |  43 +-
 14 files changed, 98 insertions(+), 1624 deletions(-)
 delete mode 100644 src/acme/client.rs

diff --git a/Cargo.toml b/Cargo.toml
index 49548ecc..5c94bfaa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -101,6 +101,7 @@ pbs-api-types = "1.0.8"
 # other proxmox crates
 pathpatterns = "1"
 proxmox-acme = "1"
+proxmox-acme-api = { version = "1", features = [ "impl" ] }
 pxar = "1"
 
 # PBS workspace
@@ -251,6 +252,7 @@ pbs-api-types.workspace = true
 
 # in their respective repo
 proxmox-acme.workspace = true
+proxmox-acme-api.workspace = true
 pxar.workspace = true
 
 # proxmox-backup workspace/internal crates
@@ -269,6 +271,7 @@ proxmox-rrd-api-types.workspace = true
 [patch.crates-io]
 #pbs-api-types = { path = "../proxmox/pbs-api-types" }
 #proxmox-acme = { path = "../proxmox/proxmox-acme" }
+#proxmox-acme-api = { path = "../proxmox/proxmox-acme-api" }
 #proxmox-api-macro = { path = "../proxmox/proxmox-api-macro" }
 #proxmox-apt = { path = "../proxmox/proxmox-apt" }
 #proxmox-apt-api-types = { path = "../proxmox/proxmox-apt-api-types" }
diff --git a/src/acme/client.rs b/src/acme/client.rs
deleted file mode 100644
index 9fb6ad55..00000000
--- a/src/acme/client.rs
+++ /dev/null
@@ -1,691 +0,0 @@
-//! HTTP Client for the ACME protocol.
-
-use std::fs::OpenOptions;
-use std::io;
-use std::os::unix::fs::OpenOptionsExt;
-
-use anyhow::{bail, format_err};
-use bytes::Bytes;
-use http_body_util::BodyExt;
-use hyper::Request;
-use nix::sys::stat::Mode;
-use proxmox_http::Body;
-use serde::{Deserialize, Serialize};
-
-use proxmox_acme::account::AccountCreator;
-use proxmox_acme::order::{Order, OrderData};
-use proxmox_acme::types::AccountData as AcmeAccountData;
-use proxmox_acme::Request as AcmeRequest;
-use proxmox_acme::{Account, Authorization, Challenge, Directory, Error, ErrorResponse};
-use proxmox_http::client::Client;
-use proxmox_sys::fs::{replace_file, CreateOptions};
-
-use crate::api2::types::AcmeAccountName;
-use crate::config::acme::account_path;
-use crate::tools::pbs_simple_http;
-
-/// 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.
-    location: String,
-
-    /// The account data.
-    account: AcmeAccountData,
-
-    /// The private key as PEM formatted string.
-    key: String,
-
-    /// ToS URL the user agreed to.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    tos: Option<String>,
-
-    #[serde(skip_serializing_if = "is_false", default)]
-    debug: bool,
-
-    /// The directory's URL.
-    directory_url: String,
-}
-
-#[inline]
-fn is_false(b: &bool) -> bool {
-    !*b
-}
-
-pub struct AcmeClient {
-    directory_url: String,
-    debug: bool,
-    account_path: Option<String>,
-    tos: Option<String>,
-    account: Option<Account>,
-    directory: Option<Directory>,
-    nonce: Option<String>,
-    http_client: Client,
-}
-
-impl AcmeClient {
-    /// Create a new ACME client for a given ACME directory URL.
-    pub fn new(directory_url: String) -> Self {
-        Self {
-            directory_url,
-            debug: false,
-            account_path: None,
-            tos: None,
-            account: None,
-            directory: None,
-            nonce: None,
-            http_client: pbs_simple_http(None),
-        }
-    }
-
-    /// Load an existing ACME account by name.
-    pub async fn load(account_name: &AcmeAccountName) -> Result<Self, anyhow::Error> {
-        let account_path = account_path(account_name.as_ref());
-        let data = match tokio::fs::read(&account_path).await {
-            Ok(data) => data,
-            Err(err) if err.kind() == io::ErrorKind::NotFound => {
-                bail!("acme account '{}' does not exist", account_name)
-            }
-            Err(err) => bail!(
-                "failed to load acme account from '{}' - {}",
-                account_path,
-                err
-            ),
-        };
-        let data: AccountData = serde_json::from_slice(&data).map_err(|err| {
-            format_err!(
-                "failed to parse acme account from '{}' - {}",
-                account_path,
-                err
-            )
-        })?;
-
-        let account = Account::from_parts(data.location, data.key, data.account);
-
-        let mut me = Self::new(data.directory_url);
-        me.debug = data.debug;
-        me.account_path = Some(account_path);
-        me.tos = data.tos;
-        me.account = Some(account);
-
-        Ok(me)
-    }
-
-    pub async fn new_account<'a>(
-        &'a mut self,
-        account_name: &AcmeAccountName,
-        tos_agreed: bool,
-        contact: Vec<String>,
-        rsa_bits: Option<u32>,
-        eab_creds: Option<(String, String)>,
-    ) -> Result<&'a Account, anyhow::Error> {
-        self.tos = if tos_agreed {
-            self.terms_of_service_url().await?.map(str::to_owned)
-        } else {
-            None
-        };
-
-        let mut account = Account::creator()
-            .set_contacts(contact)
-            .agree_to_tos(tos_agreed);
-
-        if let Some((eab_kid, eab_hmac_key)) = eab_creds {
-            account = account.set_eab_credentials(eab_kid, eab_hmac_key)?;
-        }
-
-        let account = if let Some(bits) = rsa_bits {
-            account.generate_rsa_key(bits)?
-        } else {
-            account.generate_ec_key()?
-        };
-
-        let _ = self.register_account(account).await?;
-
-        crate::config::acme::make_acme_account_dir()?;
-        let account_path = account_path(account_name.as_ref());
-        let file = OpenOptions::new()
-            .write(true)
-            .create_new(true)
-            .mode(0o600)
-            .open(&account_path)
-            .map_err(|err| format_err!("failed to open {:?} for writing: {}", account_path, err))?;
-        self.write_to(file).map_err(|err| {
-            format_err!(
-                "failed to write acme account to {:?}: {}",
-                account_path,
-                err
-            )
-        })?;
-        self.account_path = Some(account_path);
-
-        // unwrap: Setting `self.account` is literally this function's job, we just can't keep
-        // the borrow from from `self.register_account()` active due to clashes.
-        Ok(self.account.as_ref().unwrap())
-    }
-
-    fn save(&self) -> Result<(), anyhow::Error> {
-        let mut data = Vec::<u8>::new();
-        self.write_to(&mut data)?;
-        let account_path = self.account_path.as_ref().ok_or_else(|| {
-            format_err!("no account path set, cannot save updated account information")
-        })?;
-        crate::config::acme::make_acme_account_dir()?;
-        replace_file(
-            account_path,
-            &data,
-            CreateOptions::new()
-                .perm(Mode::from_bits_truncate(0o600))
-                .owner(nix::unistd::ROOT)
-                .group(nix::unistd::Gid::from_raw(0)),
-            true,
-        )
-    }
-
-    /// Shortcut to `account().ok_or_else(...).key_authorization()`.
-    pub fn key_authorization(&self, token: &str) -> Result<String, anyhow::Error> {
-        Ok(Self::need_account(&self.account)?.key_authorization(token)?)
-    }
-
-    /// Shortcut to `account().ok_or_else(...).dns_01_txt_value()`.
-    /// the key authorization value.
-    pub fn dns_01_txt_value(&self, token: &str) -> Result<String, anyhow::Error> {
-        Ok(Self::need_account(&self.account)?.dns_01_txt_value(token)?)
-    }
-
-    async fn register_account(
-        &mut self,
-        account: AccountCreator,
-    ) -> Result<&Account, anyhow::Error> {
-        let mut retry = retry();
-        let mut response = loop {
-            retry.tick()?;
-
-            let (directory, nonce) = Self::get_dir_nonce(
-                &mut self.http_client,
-                &self.directory_url,
-                &mut self.directory,
-                &mut self.nonce,
-            )
-            .await?;
-            let request = account.request(directory, nonce)?;
-            match self.run_request(request).await {
-                Ok(response) => break response,
-                Err(err) if err.is_bad_nonce() => continue,
-                Err(err) => return Err(err.into()),
-            }
-        };
-
-        let account = account.response(response.location_required()?, &response.body)?;
-
-        self.account = Some(account);
-        Ok(self.account.as_ref().unwrap())
-    }
-
-    pub async fn update_account<T: Serialize>(
-        &mut self,
-        data: &T,
-    ) -> Result<&Account, anyhow::Error> {
-        let account = Self::need_account(&self.account)?;
-
-        let mut retry = retry();
-        let response = loop {
-            retry.tick()?;
-
-            let (_directory, nonce) = Self::get_dir_nonce(
-                &mut self.http_client,
-                &self.directory_url,
-                &mut self.directory,
-                &mut self.nonce,
-            )
-            .await?;
-
-            let request = account.post_request(&account.location, nonce, data)?;
-            match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
-                Ok(response) => break response,
-                Err(err) if err.is_bad_nonce() => continue,
-                Err(err) => return Err(err.into()),
-            }
-        };
-
-        // unwrap: we've been keeping an immutable reference to it from the top of the method
-        let _ = account;
-        self.account.as_mut().unwrap().data = response.json()?;
-        self.save()?;
-        Ok(self.account.as_ref().unwrap())
-    }
-
-    pub async fn new_order<I>(&mut self, domains: I) -> Result<Order, anyhow::Error>
-    where
-        I: IntoIterator<Item = String>,
-    {
-        let account = Self::need_account(&self.account)?;
-
-        let order = domains
-            .into_iter()
-            .fold(OrderData::new(), |order, domain| order.domain(domain));
-
-        let mut retry = retry();
-        loop {
-            retry.tick()?;
-
-            let (directory, nonce) = Self::get_dir_nonce(
-                &mut self.http_client,
-                &self.directory_url,
-                &mut self.directory,
-                &mut self.nonce,
-            )
-            .await?;
-
-            let mut new_order = account.new_order(&order, directory, nonce)?;
-            let mut response = match Self::execute(
-                &mut self.http_client,
-                new_order.request.take().unwrap(),
-                &mut self.nonce,
-            )
-            .await
-            {
-                Ok(response) => response,
-                Err(err) if err.is_bad_nonce() => continue,
-                Err(err) => return Err(err.into()),
-            };
-
-            return Ok(
-                new_order.response(response.location_required()?, response.bytes().as_ref())?
-            );
-        }
-    }
-
-    /// Low level "POST-as-GET" request.
-    async fn post_as_get(&mut self, url: &str) -> Result<AcmeResponse, anyhow::Error> {
-        let account = Self::need_account(&self.account)?;
-
-        let mut retry = retry();
-        loop {
-            retry.tick()?;
-
-            let (_directory, nonce) = Self::get_dir_nonce(
-                &mut self.http_client,
-                &self.directory_url,
-                &mut self.directory,
-                &mut self.nonce,
-            )
-            .await?;
-
-            let request = account.get_request(url, nonce)?;
-            match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
-                Ok(response) => return Ok(response),
-                Err(err) if err.is_bad_nonce() => continue,
-                Err(err) => return Err(err.into()),
-            }
-        }
-    }
-
-    /// Low level POST request.
-    async fn post<T: Serialize>(
-        &mut self,
-        url: &str,
-        data: &T,
-    ) -> Result<AcmeResponse, anyhow::Error> {
-        let account = Self::need_account(&self.account)?;
-
-        let mut retry = retry();
-        loop {
-            retry.tick()?;
-
-            let (_directory, nonce) = Self::get_dir_nonce(
-                &mut self.http_client,
-                &self.directory_url,
-                &mut self.directory,
-                &mut self.nonce,
-            )
-            .await?;
-
-            let request = account.post_request(url, nonce, data)?;
-            match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
-                Ok(response) => return Ok(response),
-                Err(err) if err.is_bad_nonce() => continue,
-                Err(err) => return Err(err.into()),
-            }
-        }
-    }
-
-    /// Request challenge validation. Afterwards, the challenge should be polled.
-    pub async fn request_challenge_validation(
-        &mut self,
-        url: &str,
-    ) -> Result<Challenge, anyhow::Error> {
-        Ok(self
-            .post(url, &serde_json::Value::Object(Default::default()))
-            .await?
-            .json()?)
-    }
-
-    /// Assuming the provided URL is an 'Authorization' URL, get and deserialize it.
-    pub async fn get_authorization(&mut self, url: &str) -> Result<Authorization, anyhow::Error> {
-        Ok(self.post_as_get(url).await?.json()?)
-    }
-
-    /// Assuming the provided URL is an 'Order' URL, get and deserialize it.
-    pub async fn get_order(&mut self, url: &str) -> Result<OrderData, anyhow::Error> {
-        Ok(self.post_as_get(url).await?.json()?)
-    }
-
-    /// Finalize an Order via its `finalize` URL property and the DER encoded CSR.
-    pub async fn finalize(&mut self, url: &str, csr: &[u8]) -> Result<(), anyhow::Error> {
-        let csr = proxmox_base64::url::encode_no_pad(csr);
-        let data = serde_json::json!({ "csr": csr });
-        self.post(url, &data).await?;
-        Ok(())
-    }
-
-    /// Download a certificate via its 'certificate' URL property.
-    ///
-    /// The certificate will be a PEM certificate chain.
-    pub async fn get_certificate(&mut self, url: &str) -> Result<Bytes, anyhow::Error> {
-        Ok(self.post_as_get(url).await?.body)
-    }
-
-    /// Revoke an existing certificate (PEM or DER formatted).
-    pub async fn revoke_certificate(
-        &mut self,
-        certificate: &[u8],
-        reason: Option<u32>,
-    ) -> Result<(), anyhow::Error> {
-        // TODO: This can also work without an account.
-        let account = Self::need_account(&self.account)?;
-
-        let revocation = account.revoke_certificate(certificate, reason)?;
-
-        let mut retry = retry();
-        loop {
-            retry.tick()?;
-
-            let (directory, nonce) = Self::get_dir_nonce(
-                &mut self.http_client,
-                &self.directory_url,
-                &mut self.directory,
-                &mut self.nonce,
-            )
-            .await?;
-
-            let request = revocation.request(directory, nonce)?;
-            match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
-                Ok(_response) => return Ok(()),
-                Err(err) if err.is_bad_nonce() => continue,
-                Err(err) => return Err(err.into()),
-            }
-        }
-    }
-
-    fn need_account(account: &Option<Account>) -> Result<&Account, anyhow::Error> {
-        account
-            .as_ref()
-            .ok_or_else(|| format_err!("cannot use client without an account"))
-    }
-
-    pub(crate) fn account(&self) -> Result<&Account, anyhow::Error> {
-        Self::need_account(&self.account)
-    }
-
-    pub fn tos(&self) -> Option<&str> {
-        self.tos.as_deref()
-    }
-
-    pub fn directory_url(&self) -> &str {
-        &self.directory_url
-    }
-
-    fn to_account_data(&self) -> Result<AccountData, anyhow::Error> {
-        let account = self.account()?;
-
-        Ok(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()
-            },
-            tos: self.tos.clone(),
-            debug: self.debug,
-            directory_url: self.directory_url.clone(),
-        })
-    }
-
-    fn write_to<T: io::Write>(&self, out: T) -> Result<(), anyhow::Error> {
-        let data = self.to_account_data()?;
-
-        Ok(serde_json::to_writer_pretty(out, &data)?)
-    }
-}
-
-struct AcmeResponse {
-    body: Bytes,
-    location: Option<String>,
-    got_nonce: bool,
-}
-
-impl AcmeResponse {
-    /// Convenience helper to assert that a location header was part of the response.
-    fn location_required(&mut self) -> Result<String, anyhow::Error> {
-        self.location
-            .take()
-            .ok_or_else(|| format_err!("missing Location header"))
-    }
-
-    /// Convenience shortcut to perform json deserialization of the returned body.
-    fn json<T: for<'a> Deserialize<'a>>(&self) -> Result<T, Error> {
-        Ok(serde_json::from_slice(&self.body)?)
-    }
-
-    /// Convenience shortcut to get the body as bytes.
-    fn bytes(&self) -> &[u8] {
-        &self.body
-    }
-}
-
-impl AcmeClient {
-    /// Non-self-borrowing run_request version for borrow workarounds.
-    async fn execute(
-        http_client: &mut Client,
-        request: AcmeRequest,
-        nonce: &mut Option<String>,
-    ) -> Result<AcmeResponse, Error> {
-        let req_builder = Request::builder().method(request.method).uri(&request.url);
-
-        let http_request = if !request.content_type.is_empty() {
-            req_builder
-                .header("Content-Type", request.content_type)
-                .header("Content-Length", request.body.len())
-                .body(request.body.into())
-        } else {
-            req_builder.body(Body::empty())
-        }
-        .map_err(|err| Error::Custom(format!("failed to create http request: {err}")))?;
-
-        let response = http_client
-            .request(http_request)
-            .await
-            .map_err(|err| Error::Custom(err.to_string()))?;
-        let (parts, body) = response.into_parts();
-
-        let status = parts.status.as_u16();
-        let body = body
-            .collect()
-            .await
-            .map_err(|err| Error::Custom(format!("failed to retrieve response body: {err}")))?
-            .to_bytes();
-
-        let got_nonce = if let Some(new_nonce) = parts.headers.get(proxmox_acme::REPLAY_NONCE) {
-            let new_nonce = new_nonce.to_str().map_err(|err| {
-                Error::Client(format!(
-                    "received invalid replay-nonce header from ACME server: {err}"
-                ))
-            })?;
-            *nonce = Some(new_nonce.to_owned());
-            true
-        } else {
-            false
-        };
-
-        if parts.status.is_success() {
-            if status != request.expected {
-                return Err(Error::InvalidApi(format!(
-                    "ACME server responded with unexpected status code: {:?}",
-                    parts.status
-                )));
-            }
-
-            let location = parts
-                .headers
-                .get("Location")
-                .map(|header| {
-                    header.to_str().map(str::to_owned).map_err(|err| {
-                        Error::Client(format!(
-                            "received invalid location header from ACME server: {err}"
-                        ))
-                    })
-                })
-                .transpose()?;
-
-            return Ok(AcmeResponse {
-                body,
-                location,
-                got_nonce,
-            });
-        }
-
-        let error: ErrorResponse = serde_json::from_slice(&body).map_err(|err| {
-            Error::Client(format!(
-                "error status with improper error ACME response: {err}"
-            ))
-        })?;
-
-        if error.ty == proxmox_acme::error::BAD_NONCE {
-            if !got_nonce {
-                return Err(Error::InvalidApi(
-                    "badNonce without a new Replay-Nonce header".to_string(),
-                ));
-            }
-            return Err(Error::BadNonce);
-        }
-
-        Err(Error::Api(error))
-    }
-
-    /// Low-level API to run an n API request. This automatically updates the current nonce!
-    async fn run_request(&mut self, request: AcmeRequest) -> Result<AcmeResponse, Error> {
-        Self::execute(&mut self.http_client, request, &mut self.nonce).await
-    }
-
-    pub async fn directory(&mut self) -> Result<&Directory, Error> {
-        Ok(Self::get_directory(
-            &mut self.http_client,
-            &self.directory_url,
-            &mut self.directory,
-            &mut self.nonce,
-        )
-        .await?
-        .0)
-    }
-
-    async fn get_directory<'a, 'b>(
-        http_client: &mut Client,
-        directory_url: &str,
-        directory: &'a mut Option<Directory>,
-        nonce: &'b mut Option<String>,
-    ) -> Result<(&'a Directory, Option<&'b str>), Error> {
-        if let Some(d) = directory {
-            return Ok((d, nonce.as_deref()));
-        }
-
-        let response = Self::execute(
-            http_client,
-            AcmeRequest {
-                url: directory_url.to_string(),
-                method: "GET",
-                content_type: "",
-                body: String::new(),
-                expected: 200,
-            },
-            nonce,
-        )
-        .await?;
-
-        *directory = Some(Directory::from_parts(
-            directory_url.to_string(),
-            response.json()?,
-        ));
-
-        Ok((directory.as_mut().unwrap(), nonce.as_deref()))
-    }
-
-    /// Like `get_directory`, but if the directory provides no nonce, also performs a `HEAD`
-    /// request on the new nonce URL.
-    async fn get_dir_nonce<'a, 'b>(
-        http_client: &mut Client,
-        directory_url: &str,
-        directory: &'a mut Option<Directory>,
-        nonce: &'b mut Option<String>,
-    ) -> Result<(&'a Directory, &'b str), Error> {
-        // this let construct is a lifetime workaround:
-        let _ = Self::get_directory(http_client, directory_url, directory, nonce).await?;
-        let dir = directory.as_ref().unwrap(); // the above fails if it couldn't fill this option
-        if nonce.is_none() {
-            // this is also a lifetime issue...
-            let _ = Self::get_nonce(http_client, nonce, dir.new_nonce_url()).await?;
-        };
-        Ok((dir, nonce.as_deref().unwrap()))
-    }
-
-    pub async fn terms_of_service_url(&mut self) -> Result<Option<&str>, Error> {
-        Ok(self.directory().await?.terms_of_service_url())
-    }
-
-    async fn get_nonce<'a>(
-        http_client: &mut Client,
-        nonce: &'a mut Option<String>,
-        new_nonce_url: &str,
-    ) -> Result<&'a str, Error> {
-        let response = Self::execute(
-            http_client,
-            AcmeRequest {
-                url: new_nonce_url.to_owned(),
-                method: "HEAD",
-                content_type: "",
-                body: String::new(),
-                expected: 200,
-            },
-            nonce,
-        )
-        .await?;
-
-        if !response.got_nonce {
-            return Err(Error::InvalidApi(
-                "no new nonce received from new nonce URL".to_string(),
-            ));
-        }
-
-        nonce
-            .as_deref()
-            .ok_or_else(|| Error::Client("failed to update nonce".to_string()))
-    }
-}
-
-/// bad nonce retry count helper
-struct Retry(usize);
-
-const fn retry() -> Retry {
-    Retry(0)
-}
-
-impl Retry {
-    fn tick(&mut self) -> Result<(), Error> {
-        if self.0 >= 3 {
-            Err(Error::Client("kept getting a badNonce error!".to_string()))
-        } else {
-            self.0 += 1;
-            Ok(())
-        }
-    }
-}
diff --git a/src/acme/mod.rs b/src/acme/mod.rs
index bf61811c..700d90d7 100644
--- a/src/acme/mod.rs
+++ b/src/acme/mod.rs
@@ -1,5 +1 @@
-mod client;
-pub use client::AcmeClient;
-
 pub(crate) mod plugin;
-pub(crate) use plugin::get_acme_plugin;
diff --git a/src/acme/plugin.rs b/src/acme/plugin.rs
index 993d729b..6804243c 100644
--- a/src/acme/plugin.rs
+++ b/src/acme/plugin.rs
@@ -18,10 +18,10 @@ use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader};
 use tokio::net::TcpListener;
 use tokio::process::Command;
 
+use proxmox_acme::async_client::AcmeClient;
 use proxmox_acme::{Authorization, Challenge};
 use proxmox_rest_server::WorkerTask;
 
-use crate::acme::AcmeClient;
 use crate::api2::types::AcmeDomain;
 use crate::config::acme::plugin::{DnsPlugin, PluginData};
 
diff --git a/src/api2/config/acme.rs b/src/api2/config/acme.rs
index 18671639..fb1a8a6f 100644
--- a/src/api2/config/acme.rs
+++ b/src/api2/config/acme.rs
@@ -1,29 +1,19 @@
-use std::fs;
-use std::ops::ControlFlow;
+use anyhow::Error;
 use std::path::Path;
-use std::sync::{Arc, LazyLock, Mutex};
-use std::time::SystemTime;
-
-use anyhow::{bail, format_err, Error};
-use hex::FromHex;
-use serde::{Deserialize, Serialize};
-use serde_json::{json, Value};
-use tracing::{info, warn};
+use tracing::info;
 
 use pbs_api_types::{Authid, PRIV_SYS_MODIFY};
-use proxmox_acme::types::AccountData as AcmeAccountData;
-use proxmox_acme::Account;
+use proxmox_acme_api::{
+    AccountEntry, AccountInfo, AcmeAccountName, AcmeChallengeSchema, ChallengeSchemaWrapper,
+    DeletablePluginProperty, DnsPluginCore, DnsPluginCoreUpdater, KnownAcmeDirectory, PluginConfig,
+    DEFAULT_ACME_DIRECTORY_ENTRY, PLUGIN_ID_SCHEMA,
+};
+use proxmox_config_digest::ConfigDigest;
 use proxmox_rest_server::WorkerTask;
 use proxmox_router::{
     http_bail, list_subdirs_api_method, Permission, Router, RpcEnvironment, SubdirMap,
 };
-use proxmox_schema::{api, param_bail};
-
-use crate::acme::AcmeClient;
-use crate::api2::types::{AcmeAccountName, AcmeChallengeSchema, KnownAcmeDirectory};
-use crate::config::acme::plugin::{
-    self, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA,
-};
+use proxmox_schema::api;
 
 pub(crate) const ROUTER: Router = Router::new()
     .get(&list_subdirs_api_method!(SUBDIRS))
@@ -65,19 +55,6 @@ const PLUGIN_ITEM_ROUTER: Router = Router::new()
     .put(&API_METHOD_UPDATE_PLUGIN)
     .delete(&API_METHOD_DELETE_PLUGIN);
 
-#[api(
-    properties: {
-        name: { type: AcmeAccountName },
-    },
-)]
-/// An ACME Account entry.
-///
-/// Currently only contains a 'name' property.
-#[derive(Serialize)]
-pub struct AccountEntry {
-    name: AcmeAccountName,
-}
-
 #[api(
     access: {
         permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
@@ -91,40 +68,7 @@ pub struct AccountEntry {
 )]
 /// List ACME accounts.
 pub fn list_accounts() -> Result<Vec<AccountEntry>, Error> {
-    let mut entries = Vec::new();
-    crate::config::acme::foreach_acme_account(|name| {
-        entries.push(AccountEntry { name });
-        ControlFlow::Continue(())
-    })?;
-    Ok(entries)
-}
-
-#[api(
-    properties: {
-        account: { type: Object, properties: {}, additional_properties: true },
-        tos: {
-            type: String,
-            optional: true,
-        },
-    },
-)]
-/// ACME Account information.
-///
-/// This is what we return via the API.
-#[derive(Serialize)]
-pub struct AccountInfo {
-    /// Raw account data.
-    account: AcmeAccountData,
-
-    /// The ACME directory URL the account was created at.
-    directory: String,
-
-    /// The account's own URL within the ACME directory.
-    location: String,
-
-    /// The ToS URL, if the user agreed to one.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    tos: Option<String>,
+    proxmox_acme_api::list_accounts()
 }
 
 #[api(
@@ -141,23 +85,7 @@ pub struct AccountInfo {
 )]
 /// Return existing ACME account information.
 pub async fn get_account(name: AcmeAccountName) -> Result<AccountInfo, Error> {
-    let client = AcmeClient::load(&name).await?;
-    let account = client.account()?;
-    Ok(AccountInfo {
-        location: account.location.clone(),
-        tos: client.tos().map(str::to_owned),
-        directory: client.directory_url().to_owned(),
-        account: AcmeAccountData {
-            only_return_existing: false, // don't actually write this out in case it's set
-            ..account.data.clone()
-        },
-    })
-}
-
-fn account_contact_from_string(s: &str) -> Vec<String> {
-    s.split(&[' ', ';', ',', '\0'][..])
-        .map(|s| format!("mailto:{s}"))
-        .collect()
+    proxmox_acme_api::get_account(name).await
 }
 
 #[api(
@@ -222,15 +150,11 @@ fn register_account(
         );
     }
 
-    if Path::new(&crate::config::acme::account_path(&name)).exists() {
+    if Path::new(&proxmox_acme_api::account_config_filename(&name)).exists() {
         http_bail!(BAD_REQUEST, "account {} already exists", name);
     }
 
-    let directory = directory.unwrap_or_else(|| {
-        crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
-            .url
-            .to_owned()
-    });
+    let directory = directory.unwrap_or_else(|| DEFAULT_ACME_DIRECTORY_ENTRY.url.to_string());
 
     WorkerTask::spawn(
         "acme-register",
@@ -238,41 +162,24 @@ fn register_account(
         auth_id.to_string(),
         true,
         move |_worker| async move {
-            let mut client = AcmeClient::new(directory);
-
             info!("Registering ACME account '{}'...", &name);
 
-            let account = do_register_account(
-                &mut client,
+            let location = proxmox_acme_api::register_account(
                 &name,
-                tos_url.is_some(),
                 contact,
-                None,
+                tos_url,
+                Some(directory),
                 eab_kid.zip(eab_hmac_key),
             )
             .await?;
 
-            info!("Registration successful, account URL: {}", account.location);
+            info!("Registration successful, account URL: {}", location);
 
             Ok(())
         },
     )
 }
 
-pub async fn do_register_account<'a>(
-    client: &'a mut AcmeClient,
-    name: &AcmeAccountName,
-    agree_to_tos: bool,
-    contact: String,
-    rsa_bits: Option<u32>,
-    eab_creds: Option<(String, String)>,
-) -> Result<&'a Account, Error> {
-    let contact = account_contact_from_string(&contact);
-    client
-        .new_account(name, agree_to_tos, contact, rsa_bits, eab_creds)
-        .await
-}
-
 #[api(
     input: {
         properties: {
@@ -303,14 +210,7 @@ pub fn update_account(
         auth_id.to_string(),
         true,
         move |_worker| async move {
-            let data = match contact {
-                Some(data) => json!({
-                    "contact": account_contact_from_string(&data),
-                }),
-                None => json!({}),
-            };
-
-            AcmeClient::load(&name).await?.update_account(&data).await?;
+            proxmox_acme_api::update_account(&name, contact).await?;
 
             Ok(())
         },
@@ -348,18 +248,8 @@ pub fn deactivate_account(
         auth_id.to_string(),
         true,
         move |_worker| async move {
-            match AcmeClient::load(&name)
-                .await?
-                .update_account(&json!({"status": "deactivated"}))
-                .await
-            {
-                Ok(_account) => (),
-                Err(err) if !force => return Err(err),
-                Err(err) => {
-                    warn!("error deactivating account {name}, proceeding anyway - {err}");
-                }
-            }
-            crate::config::acme::mark_account_deactivated(&name)?;
+            proxmox_acme_api::deactivate_account(&name, force).await?;
+
             Ok(())
         },
     )
@@ -386,15 +276,7 @@ pub fn deactivate_account(
 )]
 /// Get the Terms of Service URL for an ACME directory.
 async fn get_tos(directory: Option<String>) -> Result<Option<String>, Error> {
-    let directory = directory.unwrap_or_else(|| {
-        crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
-            .url
-            .to_owned()
-    });
-    Ok(AcmeClient::new(directory)
-        .terms_of_service_url()
-        .await?
-        .map(str::to_owned))
+    proxmox_acme_api::get_tos(directory).await
 }
 
 #[api(
@@ -409,52 +291,7 @@ async fn get_tos(directory: Option<String>) -> Result<Option<String>, Error> {
 )]
 /// Get named known ACME directory endpoints.
 fn get_directories() -> Result<&'static [KnownAcmeDirectory], Error> {
-    Ok(crate::config::acme::KNOWN_ACME_DIRECTORIES)
-}
-
-/// Wrapper for efficient Arc use when returning the ACME challenge-plugin schema for serializing
-struct ChallengeSchemaWrapper {
-    inner: Arc<Vec<AcmeChallengeSchema>>,
-}
-
-impl Serialize for ChallengeSchemaWrapper {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        self.inner.serialize(serializer)
-    }
-}
-
-struct CachedSchema {
-    schema: Arc<Vec<AcmeChallengeSchema>>,
-    cached_mtime: SystemTime,
-}
-
-fn get_cached_challenge_schemas() -> Result<ChallengeSchemaWrapper, Error> {
-    static CACHE: LazyLock<Mutex<Option<CachedSchema>>> = LazyLock::new(|| Mutex::new(None));
-
-    // the actual loading code
-    let mut last = CACHE.lock().unwrap();
-
-    let actual_mtime = fs::metadata(crate::config::acme::ACME_DNS_SCHEMA_FN)?.modified()?;
-
-    let schema = match &*last {
-        Some(CachedSchema {
-            schema,
-            cached_mtime,
-        }) if *cached_mtime >= actual_mtime => schema.clone(),
-        _ => {
-            let new_schema = Arc::new(crate::config::acme::load_dns_challenge_schema()?);
-            *last = Some(CachedSchema {
-                schema: Arc::clone(&new_schema),
-                cached_mtime: actual_mtime,
-            });
-            new_schema
-        }
-    };
-
-    Ok(ChallengeSchemaWrapper { inner: schema })
+    Ok(proxmox_acme_api::KNOWN_ACME_DIRECTORIES)
 }
 
 #[api(
@@ -469,69 +306,7 @@ fn get_cached_challenge_schemas() -> Result<ChallengeSchemaWrapper, Error> {
 )]
 /// Get named known ACME directory endpoints.
 fn get_challenge_schema() -> Result<ChallengeSchemaWrapper, Error> {
-    get_cached_challenge_schemas()
-}
-
-#[api]
-#[derive(Default, Deserialize, Serialize)]
-#[serde(rename_all = "kebab-case")]
-/// The API's format is inherited from PVE/PMG:
-pub struct PluginConfig {
-    /// Plugin ID.
-    plugin: String,
-
-    /// Plugin type.
-    #[serde(rename = "type")]
-    ty: String,
-
-    /// DNS Api name.
-    #[serde(skip_serializing_if = "Option::is_none", default)]
-    api: Option<String>,
-
-    /// Plugin configuration data.
-    #[serde(skip_serializing_if = "Option::is_none", default)]
-    data: Option<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)]
-    validation_delay: Option<u32>,
-
-    /// Flag to disable the config.
-    #[serde(skip_serializing_if = "Option::is_none", default)]
-    disable: Option<bool>,
-}
-
-// 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) = proxmox_base64::url::decode_no_pad(&data) {
-            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()
-    })
+    proxmox_acme_api::get_cached_challenge_schemas()
 }
 
 #[api(
@@ -547,12 +322,7 @@ fn modify_cfg_for_api(id: &str, ty: &str, data: &Value) -> PluginConfig {
 )]
 /// List ACME challenge plugins.
 pub fn list_plugins(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PluginConfig>, Error> {
-    let (plugins, digest) = plugin::config()?;
-    rpcenv["digest"] = hex::encode(digest).into();
-    Ok(plugins
-        .iter()
-        .map(|(id, (ty, data))| modify_cfg_for_api(id, ty, data))
-        .collect())
+    proxmox_acme_api::list_plugins(rpcenv)
 }
 
 #[api(
@@ -569,13 +339,7 @@ pub fn list_plugins(rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PluginConfig>
 )]
 /// List ACME challenge plugins.
 pub fn get_plugin(id: String, rpcenv: &mut dyn RpcEnvironment) -> Result<PluginConfig, Error> {
-    let (plugins, digest) = 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"),
-    }
+    proxmox_acme_api::get_plugin(id, rpcenv)
 }
 
 // Currently we only have "the" standalone plugin and DNS plugins so we can just flatten a
@@ -607,30 +371,7 @@ pub fn get_plugin(id: String, rpcenv: &mut dyn RpcEnvironment) -> Result<PluginC
 )]
 /// Add ACME plugin configuration.
 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);
-    }
-
-    let data = String::from_utf8(proxmox_base64::decode(data)?)
-        .map_err(|_| format_err!("data must be valid UTF-8"))?;
-
-    let id = core.id.clone();
-
-    let _lock = plugin::lock()?;
-
-    let (mut plugins, _digest) = 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);
-
-    plugin::save_config(&plugins)?;
-
-    Ok(())
+    proxmox_acme_api::add_plugin(r#type, core, data)
 }
 
 #[api(
@@ -646,26 +387,7 @@ pub fn add_plugin(r#type: String, core: DnsPluginCore, data: String) -> Result<(
 )]
 /// Delete an ACME plugin configuration.
 pub fn delete_plugin(id: String) -> Result<(), Error> {
-    let _lock = plugin::lock()?;
-
-    let (mut plugins, _digest) = plugin::config()?;
-    if plugins.remove(&id).is_none() {
-        http_bail!(NOT_FOUND, "no such plugin");
-    }
-    plugin::save_config(&plugins)?;
-
-    Ok(())
-}
-
-#[api()]
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "kebab-case")]
-/// Deletable property name
-pub enum DeletableProperty {
-    /// Delete the disable property
-    Disable,
-    /// Delete the validation-delay property
-    ValidationDelay,
+    proxmox_acme_api::delete_plugin(id)
 }
 
 #[api(
@@ -687,12 +409,12 @@ pub enum DeletableProperty {
                 type: Array,
                 optional: true,
                 items: {
-                    type: DeletableProperty,
+                    type: DeletablePluginProperty,
                 }
             },
             digest: {
-                description: "Digest to protect against concurrent updates",
                 optional: true,
+                type: ConfigDigest,
             },
         },
     },
@@ -706,65 +428,8 @@ pub fn update_plugin(
     id: String,
     update: DnsPluginCoreUpdater,
     data: Option<String>,
-    delete: Option<Vec<DeletableProperty>>,
-    digest: Option<String>,
+    delete: Option<Vec<DeletablePluginProperty>>,
+    digest: Option<ConfigDigest>,
 ) -> Result<(), Error> {
-    let data = data
-        .as_deref()
-        .map(proxmox_base64::decode)
-        .transpose()?
-        .map(String::from_utf8)
-        .transpose()
-        .map_err(|_| format_err!("data must be valid UTF-8"))?;
-
-    let _lock = plugin::lock()?;
-
-    let (mut plugins, expected_digest) = plugin::config()?;
-
-    if let Some(digest) = digest {
-        let digest = <[u8; 32]>::from_hex(digest)?;
-        crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
-    }
-
-    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 {
-                        DeletableProperty::ValidationDelay => {
-                            plugin.core.validation_delay = None;
-                        }
-                        DeletableProperty::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"),
-    }
-
-    plugin::save_config(&plugins)?;
-
-    Ok(())
+    proxmox_acme_api::update_plugin(id, update, data, delete, digest)
 }
diff --git a/src/api2/node/certificates.rs b/src/api2/node/certificates.rs
index 6b1d87d2..7fb3a478 100644
--- a/src/api2/node/certificates.rs
+++ b/src/api2/node/certificates.rs
@@ -1,13 +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_api::AcmeDomain;
 use proxmox_rest_server::WorkerTask;
 use proxmox_router::list_subdirs_api_method;
 use proxmox_router::SubdirMap;
@@ -17,9 +15,6 @@ use proxmox_schema::api;
 use pbs_buildcfg::configdir;
 use pbs_tools::cert;
 
-use crate::acme::AcmeClient;
-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,26 @@ fn spawn_certificate_worker(
 
     let auth_id = rpcenv.get_auth_id().unwrap();
 
+    let acme_config = node_config.acme_config()?;
+
+    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 +387,16 @@ pub fn revoke_acme_cert(rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error
 
     let auth_id = rpcenv.get_auth_id().unwrap();
 
+    let acme_config = node_config.acme_config()?;
+
     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/api2/types/acme.rs b/src/api2/types/acme.rs
index 8661f9e8..b83b9882 100644
--- a/src/api2/types/acme.rs
+++ b/src/api2/types/acme.rs
@@ -1,8 +1,7 @@
 use serde::{Deserialize, Serialize};
-use serde_json::Value;
 
 use pbs_api_types::{DNS_ALIAS_FORMAT, DNS_NAME_FORMAT, PROXMOX_SAFE_ID_FORMAT};
-use proxmox_schema::{api, ApiStringFormat, ApiType, Schema, StringSchema};
+use proxmox_schema::api;
 
 #[api(
     properties: {
@@ -37,61 +36,3 @@ pub struct AcmeDomain {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub plugin: Option<String>,
 }
-
-pub const ACME_DOMAIN_PROPERTY_SCHEMA: Schema =
-    StringSchema::new("ACME domain configuration string")
-        .format(&ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA))
-        .schema();
-
-#[api(
-    properties: {
-        name: { type: String },
-        url: { type: String },
-    },
-)]
-/// An ACME directory endpoint with a name and URL.
-#[derive(Serialize)]
-pub struct KnownAcmeDirectory {
-    /// The ACME directory's name.
-    pub name: &'static str,
-
-    /// The ACME directory's endpoint URL.
-    pub url: &'static str,
-}
-
-proxmox_schema::api_string_type! {
-    #[api(format: &PROXMOX_SAFE_ID_FORMAT)]
-    /// ACME account name.
-    #[derive(Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
-    #[serde(transparent)]
-    pub struct AcmeAccountName(String);
-}
-
-#[api(
-    properties: {
-        schema: {
-            type: Object,
-            additional_properties: true,
-            properties: {},
-        },
-        type: {
-            type: String,
-        },
-    },
-)]
-#[derive(Serialize)]
-/// 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: &'static str,
-
-    /// The plugin's parameter schema.
-    pub schema: Value,
-}
diff --git a/src/bin/proxmox-backup-api.rs b/src/bin/proxmox-backup-api.rs
index 417e9e97..d0091dca 100644
--- a/src/bin/proxmox-backup-api.rs
+++ b/src/bin/proxmox-backup-api.rs
@@ -14,6 +14,7 @@ use proxmox_rest_server::{ApiConfig, RestServer};
 use proxmox_router::RpcEnvironmentType;
 use proxmox_sys::fs::CreateOptions;
 
+use pbs_buildcfg::configdir;
 use proxmox_backup::auth_helpers::*;
 use proxmox_backup::config;
 use proxmox_backup::server::auth::check_pbs_auth;
@@ -78,6 +79,7 @@ async fn run() -> Result<(), Error> {
     let mut command_sock = proxmox_daemon::command_socket::CommandSocket::new(backup_user.gid);
 
     proxmox_product_config::init(backup_user.clone(), pbs_config::priv_user()?);
+    proxmox_acme_api::init(configdir!("/acme"), true)?;
 
     let dir_opts = CreateOptions::new()
         .owner(backup_user.uid)
diff --git a/src/bin/proxmox-backup-manager.rs b/src/bin/proxmox-backup-manager.rs
index f8365070..f041ba0b 100644
--- a/src/bin/proxmox-backup-manager.rs
+++ b/src/bin/proxmox-backup-manager.rs
@@ -19,12 +19,12 @@ use proxmox_router::{cli::*, RpcEnvironment};
 use proxmox_schema::api;
 use proxmox_sys::fs::CreateOptions;
 
+use pbs_buildcfg::configdir;
 use pbs_client::{display_task_log, view_task_result};
 use pbs_config::sync;
 use pbs_tools::json::required_string_param;
 use proxmox_backup::api2;
 use proxmox_backup::client_helpers::connect_to_localhost;
-use proxmox_backup::config;
 
 mod proxmox_backup_manager;
 use proxmox_backup_manager::*;
@@ -667,6 +667,7 @@ async fn run() -> Result<(), Error> {
         .init()?;
     proxmox_backup::server::notifications::init()?;
     proxmox_product_config::init(pbs_config::backup_user()?, pbs_config::priv_user()?);
+    proxmox_acme_api::init(configdir!("/acme"), false)?;
 
     let cmd_def = CliCommandMap::new()
         .insert("acl", acl_commands())
diff --git a/src/bin/proxmox-backup-proxy.rs b/src/bin/proxmox-backup-proxy.rs
index 870208fe..eea44a7d 100644
--- a/src/bin/proxmox-backup-proxy.rs
+++ b/src/bin/proxmox-backup-proxy.rs
@@ -188,6 +188,7 @@ async fn run() -> Result<(), Error> {
     proxmox_backup::server::notifications::init()?;
     metric_collection::init()?;
     proxmox_product_config::init(pbs_config::backup_user()?, pbs_config::priv_user()?);
+    proxmox_acme_api::init(configdir!("/acme"), false)?;
 
     let mut indexpath = PathBuf::from(pbs_buildcfg::JS_DIR);
     indexpath.push("index.hbs");
diff --git a/src/bin/proxmox_backup_manager/acme.rs b/src/bin/proxmox_backup_manager/acme.rs
index 0f0eafea..57431225 100644
--- a/src/bin/proxmox_backup_manager/acme.rs
+++ b/src/bin/proxmox_backup_manager/acme.rs
@@ -3,15 +3,13 @@ use std::io::Write;
 use anyhow::{bail, Error};
 use serde_json::Value;
 
+use proxmox_acme::async_client::AcmeClient;
+use proxmox_acme_api::{AcmeAccountName, DnsPluginCore, KNOWN_ACME_DIRECTORIES};
 use proxmox_router::{cli::*, ApiHandler, RpcEnvironment};
 use proxmox_schema::api;
 use proxmox_sys::fs::file_get_contents;
 
-use proxmox_backup::acme::AcmeClient;
 use proxmox_backup::api2;
-use proxmox_backup::api2::types::AcmeAccountName;
-use proxmox_backup::config::acme::plugin::DnsPluginCore;
-use proxmox_backup::config::acme::KNOWN_ACME_DIRECTORIES;
 
 pub fn acme_mgmt_cli() -> CommandLineInterface {
     let cmd_def = CliCommandMap::new()
@@ -122,7 +120,7 @@ async fn register_account(
 
                 match input.trim().parse::<usize>() {
                     Ok(n) if n < KNOWN_ACME_DIRECTORIES.len() => {
-                        break (KNOWN_ACME_DIRECTORIES[n].url.to_owned(), false);
+                        break (KNOWN_ACME_DIRECTORIES[n].url.to_string(), false);
                     }
                     Ok(n) if n == KNOWN_ACME_DIRECTORIES.len() => {
                         input.clear();
@@ -188,17 +186,20 @@ async fn register_account(
 
     println!("Attempting to register account with {directory_url:?}...");
 
-    let account = api2::config::acme::do_register_account(
-        &mut client,
+    let tos_agreed = tos_agreed
+        .then(|| directory.terms_of_service_url().map(str::to_owned))
+        .flatten();
+
+    let location = proxmox_acme_api::register_account(
         &name,
-        tos_agreed,
         contact,
-        None,
+        tos_agreed,
+        Some(directory_url),
         eab_creds,
     )
     .await?;
 
-    println!("Registration successful, account URL: {}", account.location);
+    println!("Registration successful, account URL: {}", location);
 
     Ok(())
 }
@@ -266,19 +267,19 @@ pub fn account_cli() -> CommandLineInterface {
             "deactivate",
             CliCommand::new(&API_METHOD_DEACTIVATE_ACCOUNT)
                 .arg_param(&["name"])
-                .completion_cb("name", crate::config::acme::complete_acme_account),
+                .completion_cb("name", proxmox_acme_api::complete_acme_account),
         )
         .insert(
             "info",
             CliCommand::new(&API_METHOD_GET_ACCOUNT)
                 .arg_param(&["name"])
-                .completion_cb("name", crate::config::acme::complete_acme_account),
+                .completion_cb("name", proxmox_acme_api::complete_acme_account),
         )
         .insert(
             "update",
             CliCommand::new(&API_METHOD_UPDATE_ACCOUNT)
                 .arg_param(&["name"])
-                .completion_cb("name", crate::config::acme::complete_acme_account),
+                .completion_cb("name", proxmox_acme_api::complete_acme_account),
         );
 
     cmd_def.into()
@@ -373,26 +374,26 @@ pub fn plugin_cli() -> CommandLineInterface {
             "config", // name comes from pve/pmg
             CliCommand::new(&API_METHOD_GET_PLUGIN)
                 .arg_param(&["id"])
-                .completion_cb("id", crate::config::acme::complete_acme_plugin),
+                .completion_cb("id", proxmox_acme_api::complete_acme_plugin),
         )
         .insert(
             "add",
             CliCommand::new(&API_METHOD_ADD_PLUGIN)
                 .arg_param(&["type", "id"])
-                .completion_cb("api", crate::config::acme::complete_acme_api_challenge_type)
-                .completion_cb("type", crate::config::acme::complete_acme_plugin_type),
+                .completion_cb("api", proxmox_acme_api::complete_acme_api_challenge_type)
+                .completion_cb("type", proxmox_acme_api::complete_acme_plugin_type),
         )
         .insert(
             "remove",
             CliCommand::new(&acme::API_METHOD_DELETE_PLUGIN)
                 .arg_param(&["id"])
-                .completion_cb("id", crate::config::acme::complete_acme_plugin),
+                .completion_cb("id", proxmox_acme_api::complete_acme_plugin),
         )
         .insert(
             "set",
             CliCommand::new(&acme::API_METHOD_UPDATE_PLUGIN)
                 .arg_param(&["id"])
-                .completion_cb("id", crate::config::acme::complete_acme_plugin),
+                .completion_cb("id", proxmox_acme_api::complete_acme_plugin),
         );
 
     cmd_def.into()
diff --git a/src/config/acme/mod.rs b/src/config/acme/mod.rs
index ac89ae5e..962cb1bb 100644
--- a/src/config/acme/mod.rs
+++ b/src/config/acme/mod.rs
@@ -1,168 +1 @@
-use std::collections::HashMap;
-use std::ops::ControlFlow;
-use std::path::Path;
-
-use anyhow::{bail, format_err, Error};
-use serde_json::Value;
-
-use pbs_api_types::PROXMOX_SAFE_ID_REGEX;
-use proxmox_sys::error::SysError;
-use proxmox_sys::fs::{file_read_string, CreateOptions};
-
-use crate::api2::types::{AcmeAccountName, AcmeChallengeSchema, KnownAcmeDirectory};
-
-pub(crate) const ACME_DIR: &str = pbs_buildcfg::configdir!("/acme");
-pub(crate) const ACME_ACCOUNT_DIR: &str = pbs_buildcfg::configdir!("/acme/accounts");
-
-pub(crate) const ACME_DNS_SCHEMA_FN: &str = "/usr/share/proxmox-acme/dns-challenge-schema.json";
-
 pub mod plugin;
-
-// `const fn`ify this once it is supported in `proxmox`
-fn root_only() -> CreateOptions {
-    CreateOptions::new()
-        .owner(nix::unistd::ROOT)
-        .group(nix::unistd::Gid::from_raw(0))
-        .perm(nix::sys::stat::Mode::from_bits_truncate(0o700))
-}
-
-fn create_acme_subdir(dir: &str) -> Result<(), Error> {
-    proxmox_sys::fs::ensure_dir_exists(dir, &root_only(), false)
-}
-
-pub(crate) fn make_acme_dir() -> Result<(), Error> {
-    create_acme_subdir(ACME_DIR)
-}
-
-pub(crate) fn make_acme_account_dir() -> Result<(), Error> {
-    make_acme_dir()?;
-    create_acme_subdir(ACME_ACCOUNT_DIR)
-}
-
-pub const KNOWN_ACME_DIRECTORIES: &[KnownAcmeDirectory] = &[
-    KnownAcmeDirectory {
-        name: "Let's Encrypt V2",
-        url: "https://acme-v02.api.letsencrypt.org/directory",
-    },
-    KnownAcmeDirectory {
-        name: "Let's Encrypt V2 Staging",
-        url: "https://acme-staging-v02.api.letsencrypt.org/directory",
-    },
-];
-
-pub const DEFAULT_ACME_DIRECTORY_ENTRY: &KnownAcmeDirectory = &KNOWN_ACME_DIRECTORIES[0];
-
-pub fn account_path(name: &str) -> String {
-    format!("{ACME_ACCOUNT_DIR}/{name}")
-}
-
-pub fn foreach_acme_account<F>(mut func: F) -> Result<(), Error>
-where
-    F: FnMut(AcmeAccountName) -> ControlFlow<Result<(), Error>>,
-{
-    match proxmox_sys::fs::scan_subdir(-1, ACME_ACCOUNT_DIR, &PROXMOX_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()),
-    }
-}
-
-pub fn mark_account_deactivated(name: &str) -> Result<(), Error> {
-    let from = account_path(name);
-    for i in 0..100 {
-        let to = account_path(&format!("_deactivated_{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
-    );
-}
-
-pub fn load_dns_challenge_schema() -> Result<Vec<AcmeChallengeSchema>, Error> {
-    let raw = file_read_string(ACME_DNS_SCHEMA_FN)?;
-    let schemas: serde_json::Map<String, Value> = 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",
-            schema: schema.to_owned(),
-        })
-        .collect())
-}
-
-pub fn complete_acme_account(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
-    let mut out = Vec::new();
-    let _ = foreach_acme_account(|name| {
-        out.push(name.into_string());
-        ControlFlow::Continue(())
-    });
-    out
-}
-
-pub fn complete_acme_plugin(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
-    match plugin::config() {
-        Ok((config, _digest)) => config
-            .iter()
-            .map(|(id, (_type, _cfg))| id.clone())
-            .collect(),
-        Err(_) => Vec::new(),
-    }
-}
-
-pub fn complete_acme_plugin_type(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
-    vec![
-        "dns".to_string(),
-        //"http".to_string(), // makes currently not really sense to create or the like
-    ]
-}
-
-pub fn complete_acme_api_challenge_type(
-    _arg: &str,
-    param: &HashMap<String, String>,
-) -> Vec<String> {
-    if param.get("type") == Some(&"dns".to_string()) {
-        match load_dns_challenge_schema() {
-            Ok(schema) => schema.into_iter().map(|s| s.id).collect(),
-            Err(_) => Vec::new(),
-        }
-    } else {
-        Vec::new()
-    }
-}
diff --git a/src/config/acme/plugin.rs b/src/config/acme/plugin.rs
index 8ce852ec..e5a41f99 100644
--- a/src/config/acme/plugin.rs
+++ b/src/config/acme/plugin.rs
@@ -1,14 +1,10 @@
-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_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
-
-use pbs_config::{open_backup_lockfile, BackupLockGuard};
+use proxmox_schema::{api, Schema, StringSchema, Updater};
+use proxmox_section_config::SectionConfigData;
 
 pub const PLUGIN_ID_SCHEMA: Schema = StringSchema::new("ACME Challenge Plugin ID.")
     .format(&PROXMOX_SAFE_ID_FORMAT)
@@ -16,28 +12,6 @@ pub const PLUGIN_ID_SCHEMA: Schema = StringSchema::new("ACME Challenge Plugin ID
     .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 },
@@ -99,64 +73,6 @@ impl DnsPlugin {
     }
 }
 
-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
-}
-
-const ACME_PLUGIN_CFG_FILENAME: &str = pbs_buildcfg::configdir!("/acme/plugins.cfg");
-const ACME_PLUGIN_CFG_LOCKFILE: &str = pbs_buildcfg::configdir!("/acme/.plugins.lck");
-
-pub fn lock() -> Result<BackupLockGuard, Error> {
-    super::make_acme_dir()?;
-    open_backup_lockfile(ACME_PLUGIN_CFG_LOCKFILE, None, true)
-}
-
-pub fn config() -> Result<(PluginData, [u8; 32]), Error> {
-    let content =
-        proxmox_sys::fs::file_read_optional_string(ACME_PLUGIN_CFG_FILENAME)?.unwrap_or_default();
-
-    let digest = openssl::sha::sha256(content.as_bytes());
-    let mut data = CONFIG.parse(ACME_PLUGIN_CFG_FILENAME, &content)?;
-
-    if !data.sections.contains_key("standalone") {
-        let standalone = StandalonePlugin::default();
-        data.set_data("standalone", "standalone", &standalone)
-            .unwrap();
-    }
-
-    Ok((PluginData { data }, digest))
-}
-
-pub fn save_config(config: &PluginData) -> Result<(), Error> {
-    super::make_acme_dir()?;
-    let raw = CONFIG.write(ACME_PLUGIN_CFG_FILENAME, &config.data)?;
-    pbs_config::replace_backup_config(ACME_PLUGIN_CFG_FILENAME, raw.as_bytes())
-}
-
 pub struct PluginData {
     data: SectionConfigData,
 }
diff --git a/src/config/node.rs b/src/config/node.rs
index 253b2e36..81eecb24 100644
--- a/src/config/node.rs
+++ b/src/config/node.rs
@@ -8,16 +8,14 @@ use pbs_api_types::{
     EMAIL_SCHEMA, MULTI_LINE_COMMENT_SCHEMA, OPENSSL_CIPHERS_TLS_1_2_SCHEMA,
     OPENSSL_CIPHERS_TLS_1_3_SCHEMA,
 };
+use proxmox_acme_api::{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::acme::AcmeClient;
-use crate::api2::types::{
-    AcmeAccountName, 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");
@@ -44,20 +42,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
@@ -235,19 +219,16 @@ pub struct NodeConfig {
 }
 
 impl NodeConfig {
-    pub fn acme_config(&self) -> Option<Result<AcmeConfig, Error>> {
-        self.acme.as_deref().map(|config| -> Result<_, Error> {
-            crate::tools::config::from_property_string(config, &AcmeConfig::API_SCHEMA)
-        })
-    }
-
-    pub async fn acme_client(&self) -> Result<AcmeClient, Error> {
-        let account = if let Some(cfg) = self.acme_config().transpose()? {
-            cfg.account
-        } else {
-            AcmeAccountName::from_string("default".to_string())? // should really not happen
-        };
-        AcmeClient::load(&account).await
+    pub fn acme_config(&self) -> Result<AcmeConfig, Error> {
+        self.acme
+            .as_deref()
+            .map(|config| {
+                crate::tools::config::from_property_string::<AcmeConfig>(
+                    config,
+                    &AcmeConfig::API_SCHEMA,
+                )
+            })
+            .unwrap_or_else(|| proxmox_acme_api::parse_acme_config_string("account=default"))
     }
 
     pub fn acme_domains(&'_ self) -> AcmeDomainIter<'_> {
-- 
2.47.3



_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel


  parent reply	other threads:[~2026-01-16 11:29 UTC|newest]

Thread overview: 6+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-01-16 11:28 [pbs-devel] [PATCH proxmox{, -backup} v6 0/5] fix #6939: acme: support servers returning 204 for nonce requests Samuel Rufinatscha
2026-01-16 11:28 ` [pbs-devel] [PATCH proxmox v6 1/3] acme-api: add ACME completion helpers Samuel Rufinatscha
2026-01-16 11:28 ` [pbs-devel] [PATCH proxmox v6 2/3] acme: introduce http_status module Samuel Rufinatscha
2026-01-16 11:28 ` [pbs-devel] [PATCH proxmox v6 3/3] fix #6939: acme: support servers returning 204 for nonce requests Samuel Rufinatscha
2026-01-16 11:28 ` Samuel Rufinatscha [this message]
2026-01-16 11:28 ` [pbs-devel] [PATCH proxmox-backup v6 2/2] acme: remove unused src/acme and plugin code Samuel Rufinatscha

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=20260116112859.194016-5-s.rufinatscha@proxmox.com \
    --to=s.rufinatscha@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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal