From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 73A5C1FF146 for ; Fri, 16 Jan 2026 12:29:42 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id D47E417790; Fri, 16 Jan 2026 12:29:47 +0100 (CET) From: Samuel Rufinatscha To: pbs-devel@lists.proxmox.com Date: Fri, 16 Jan 2026 12:28:58 +0100 Message-ID: <20260116112859.194016-5-s.rufinatscha@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260116112859.194016-1-s.rufinatscha@proxmox.com> References: <20260116112859.194016-1-s.rufinatscha@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1768562898399 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.251 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record TVD_PH_BODY_ACCOUNTS_PRE 0.001 The body matches phrases such as "accounts suspended", "account credited", "account verification" Subject: [pbs-devel] [PATCH proxmox-backup v6 1/2] acme: remove local AcmeClient and use proxmox-acme-api handlers X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Backup Server development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pbs-devel-bounces@lists.proxmox.com Sender: "pbs-devel" 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 --- 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, - - #[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, - tos: Option, - account: Option, - directory: Option, - nonce: Option, - 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 { - 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, - rsa_bits: Option, - 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::::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 { - 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 { - 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( - &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(&mut self, domains: I) -> Result - where - I: IntoIterator, - { - 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 { - 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( - &mut self, - url: &str, - data: &T, - ) -> Result { - 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 { - 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 { - 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 { - 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 { - 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, - ) -> 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) -> 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 { - 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(&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, - got_nonce: bool, -} - -impl AcmeResponse { - /// Convenience helper to assert that a location header was part of the response. - fn location_required(&mut self) -> Result { - self.location - .take() - .ok_or_else(|| format_err!("missing Location header")) - } - - /// Convenience shortcut to perform json deserialization of the returned body. - fn json Deserialize<'a>>(&self) -> Result { - 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, - ) -> Result { - 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 { - 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, - nonce: &'b mut Option, - ) -> 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, - nonce: &'b mut Option, - ) -> 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, Error> { - Ok(self.directory().await?.terms_of_service_url()) - } - - async fn get_nonce<'a>( - http_client: &mut Client, - nonce: &'a mut Option, - 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, 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, + 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 { - 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 { - 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, - 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) -> Result, 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) -> Result, 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>, -} - -impl Serialize for ChallengeSchemaWrapper { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.inner.serialize(serializer) - } -} - -struct CachedSchema { - schema: Arc>, - cached_mtime: SystemTime, -} - -fn get_cached_challenge_schemas() -> Result { - static CACHE: LazyLock>> = 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 { )] /// Get named known ACME directory endpoints. fn get_challenge_schema() -> Result { - 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, - - /// Plugin configuration data. - #[serde(skip_serializing_if = "Option::is_none", default)] - data: Option, - - /// Extra delay in seconds to wait before requesting validation. - /// - /// Allows to cope with long TTL of DNS records. - #[serde(skip_serializing_if = "Option::is_none", default)] - validation_delay: Option, - - /// Flag to disable the config. - #[serde(skip_serializing_if = "Option::is_none", default)] - disable: Option, -} - -// 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, 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 )] /// List ACME challenge plugins. pub fn get_plugin(id: String, rpcenv: &mut dyn RpcEnvironment) -> Result { - 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 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, - delete: Option>, - digest: Option, + delete: Option>, + digest: Option, ) -> 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, -} - -async fn order_certificate( - worker: Arc, - node_config: &NodeConfig, -) -> Result, Error> { - use proxmox_acme::authorization::Status; - use proxmox_acme::order::Identifier; - - let domains = node_config.acme_domains().try_fold( - Vec::::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 = 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::::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, } - -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::() { 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(mut func: F) -> Result<(), Error> -where - F: FnMut(AcmeAccountName) -> ControlFlow>, -{ - 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, Error> { - let raw = file_read_string(ACME_DNS_SCHEMA_FN)?; - let schemas: serde_json::Map = serde_json::from_str(&raw)?; - - Ok(schemas - .iter() - .map(|(id, schema)| AcmeChallengeSchema { - id: id.to_owned(), - name: schema - .get("name") - .and_then(Value::as_str) - .unwrap_or(id) - .to_owned(), - ty: "dns", - schema: schema.to_owned(), - }) - .collect()) -} - -pub fn complete_acme_account(_arg: &str, _param: &HashMap) -> Vec { - 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) -> Vec { - 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) -> Vec { - 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, -) -> Vec { - 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 = 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 { - 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> { - 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 { - 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 { + self.acme + .as_deref() + .map(|config| { + crate::tools::config::from_property_string::( + 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