all lists on 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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal