From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 0ED0E7857F for ; Fri, 30 Apr 2021 08:21:28 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EAA84245B4 for ; Fri, 30 Apr 2021 08:20:57 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id C58BE245A4 for ; Fri, 30 Apr 2021 08:20:53 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 946E4417C6 for ; Fri, 30 Apr 2021 08:20:53 +0200 (CEST) To: Proxmox Backup Server development discussion , Wolfgang Bumiller References: <20210429131322.24319-1-w.bumiller@proxmox.com> <20210429131322.24319-3-w.bumiller@proxmox.com> From: Dietmar Maurer Message-ID: <4ddb3562-0b31-4eb0-ffad-50950a7845d4@proxmox.com> Date: Fri, 30 Apr 2021 08:20:49 +0200 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Thunderbird/78.10.0 MIME-Version: 1.0 In-Reply-To: <20210429131322.24319-3-w.bumiller@proxmox.com> Content-Type: text/plain; charset=utf-8; format=flowed Content-Transfer-Encoding: 7bit Content-Language: en-US X-SPAM-LEVEL: Spam detection results: 0 AWL 0.280 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment NICE_REPLY_A -0.001 Looks like a legit reply (A) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [plugin.rs, letsencrypt.org, config.rs, lib.rs, client.rs, mod.rs] Subject: Re: [pbs-devel] [REBASED backup 02/14] add acme config and client 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: , X-List-Received-Date: Fri, 30 Apr 2021 06:21:28 -0000 And please can you include my suggested changes? (remove AccountName and read_lock) On 4/29/21 3:13 PM, Wolfgang Bumiller wrote: > This is the highlevel part using proxmox-acme-rs to create > requests and our hyper code to issue them to the acme > server. > > Signed-off-by: Wolfgang Bumiller > --- > src/acme/client.rs | 671 ++++++++++++++++++++++++++++++++++++++ > src/acme/mod.rs | 2 + > src/config.rs | 1 + > src/config/acme/mod.rs | 234 +++++++++++++ > src/config/acme/plugin.rs | 380 +++++++++++++++++++++ > src/lib.rs | 2 + > 6 files changed, 1290 insertions(+) > create mode 100644 src/acme/client.rs > create mode 100644 src/acme/mod.rs > create mode 100644 src/config/acme/mod.rs > create mode 100644 src/config/acme/plugin.rs > > diff --git a/src/acme/client.rs b/src/acme/client.rs > new file mode 100644 > index 00000000..16a158d5 > --- /dev/null > +++ b/src/acme/client.rs > @@ -0,0 +1,671 @@ > +//! HTTP Client for the ACME protocol. > + > +use std::fs::OpenOptions; > +use std::io; > +use std::os::unix::fs::OpenOptionsExt; > + > +use anyhow::format_err; > +use bytes::Bytes; > +use hyper::{Body, Request}; > +use nix::sys::stat::Mode; > +use serde::{Deserialize, Serialize}; > + > +use proxmox::tools::fs::{replace_file, CreateOptions}; > +use proxmox_acme_rs::account::AccountCreator; > +use proxmox_acme_rs::account::AccountData as AcmeAccountData; > +use proxmox_acme_rs::order::{Order, OrderData}; > +use proxmox_acme_rs::Request as AcmeRequest; > +use proxmox_acme_rs::{Account, Authorization, Challenge, Directory, Error, ErrorResponse}; > + > +use crate::config::acme::{account_path, AccountName}; > +use crate::tools::http::SimpleHttp; > + > +/// 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: Option, > +} > + > +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: None, > + } > + } > + > + /// Load an existing ACME account by name. > + pub async fn load(account_name: &AccountName) -> Result { > + Self::load_path(account_path(account_name.as_ref())).await > + } > + > + /// Load an existing ACME account by path. > + async fn load_path(account_path: String) -> Result { > + let data = tokio::fs::read(&account_path).await?; > + let data: AccountData = serde_json::from_slice(&data)?; > + > + let account = Account::from_parts(data.location, data.key, data.account); > + > + Ok(Self { > + directory_url: data.directory_url, > + debug: data.debug, > + account_path: Some(account_path), > + tos: data.tos, > + account: Some(account), > + directory: None, > + nonce: None, > + http_client: None, > + }) > + } > + > + pub async fn new_account<'a>( > + &'a mut self, > + account_name: &AccountName, > + tos_agreed: bool, > + contact: Vec, > + rsa_bits: Option, > + ) -> Result<&'a Account, anyhow::Error> { > + self.tos = if tos_agreed { > + self.terms_of_service_url().await?.map(str::to_owned) > + } else { > + None > + }; > + > + let account = Account::creator() > + .set_contacts(contact) > + .agree_to_tos(tos_agreed); > + > + let account = if let Some(bits) = rsa_bits { > + account.generate_rsa_key(bits)? > + } else { > + account.generate_ec_key()? > + }; > + > + let _ = self.register_account(account).await?; > + > + let account_path = account_path(account_name.as_ref()); > + let file = OpenOptions::new() > + .write(true) > + .create(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 upated account information") > + })?; > + replace_file( > + account_path, > + &data, > + CreateOptions::new() > + .perm(Mode::from_bits_truncate(0o600)) > + .owner(nix::unistd::ROOT) > + .group(nix::unistd::Gid::from_raw(0)), > + ) > + } > + > + /// 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 = base64::encode_config(csr, base64::URL_SAFE_NO_PAD); > + 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 Option, > + 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 > + .get_or_insert_with(|| SimpleHttp::new(None)) > + .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 = hyper::body::to_bytes(body) > + .await > + .map_err(|err| Error::Custom(format!("failed to retrieve response body: {}", err)))?; > + > + let got_nonce = if let Some(new_nonce) = parts.headers.get(proxmox_acme_rs::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_rs::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 > + } > + > + 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 Option, > + 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_ref().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 Option, > + 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 Option, > + 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 { > + Error::Client(format!("kept getting a badNonce error!")); > + } > + self.0 += 1; > + Ok(()) > + } > +} > diff --git a/src/acme/mod.rs b/src/acme/mod.rs > new file mode 100644 > index 00000000..5923f8da > --- /dev/null > +++ b/src/acme/mod.rs > @@ -0,0 +1,2 @@ > +pub mod client; > +pub use client::AcmeClient; > diff --git a/src/config.rs b/src/config.rs > index 37df2fd2..83ea0461 100644 > --- a/src/config.rs > +++ b/src/config.rs > @@ -16,6 +16,7 @@ use proxmox::try_block; > use crate::buildcfg; > > pub mod acl; > +pub mod acme; > pub mod cached_user_info; > pub mod datastore; > pub mod network; > diff --git a/src/config/acme/mod.rs b/src/config/acme/mod.rs > new file mode 100644 > index 00000000..c3c26c3b > --- /dev/null > +++ b/src/config/acme/mod.rs > @@ -0,0 +1,234 @@ > +use std::collections::HashMap; > +use std::fmt; > +use std::path::Path; > + > +use anyhow::{bail, format_err, Error}; > +use serde::{Deserialize, Serialize}; > + > +use proxmox::api::api; > +use proxmox::sys::error::SysError; > + > +use crate::api2::types::{ > + DNS_ALIAS_FORMAT, DNS_NAME_FORMAT, PROXMOX_SAFE_ID_FORMAT, PROXMOX_SAFE_ID_REGEX, > +}; > +use crate::tools::ControlFlow; > + > +pub(crate) const ACME_ACCOUNT_DIR: &str = configdir!("/acme/accounts"); > + > +pub mod plugin; > + > +#[api( > + properties: { > + "domain": { format: &DNS_NAME_FORMAT }, > + "alias": { > + optional: true, > + format: &DNS_ALIAS_FORMAT, > + }, > + "plugin": { > + optional: true, > + format: &PROXMOX_SAFE_ID_FORMAT, > + }, > + }, > + default_key: "domain", > +)] > +#[derive(Deserialize, Serialize)] > +/// A domain entry for an ACME certificate. > +pub struct AcmeDomain { > + /// The domain to certify for. > + pub domain: String, > + > + /// The domain to use for challenges instead of the default acme challenge domain. > + /// > + /// This is useful if you use CNAME entries to redirect `_acme-challenge.*` domains to a > + /// different DNS server. > + #[serde(skip_serializing_if = "Option::is_none")] > + pub alias: Option, > + > + /// The plugin to use to validate this domain. > + /// > + /// Empty means standalone HTTP validation is used. > + #[serde(skip_serializing_if = "Option::is_none")] > + pub plugin: Option, > +} > + > +#[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, > +} > + > +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) > +} > + > +#[api(format: &PROXMOX_SAFE_ID_FORMAT)] > +/// ACME account name. > +#[derive(Clone, Eq, PartialEq, Hash, Deserialize, Serialize)] > +#[serde(transparent)] > +pub struct AccountName(String); > + > +impl AccountName { > + pub fn into_string(self) -> String { > + self.0 > + } > +} > + > +impl std::ops::Deref for AccountName { > + type Target = str; > + > + #[inline] > + fn deref(&self) -> &str { > + &self.0 > + } > +} > + > +impl std::ops::DerefMut for AccountName { > + #[inline] > + fn deref_mut(&mut self) -> &mut str { > + &mut self.0 > + } > +} > + > +impl AsRef for AccountName { > + #[inline] > + fn as_ref(&self) -> &str { > + self.0.as_ref() > + } > +} > + > +impl fmt::Debug for AccountName { > + #[inline] > + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { > + fmt::Debug::fmt(&self.0, f) > + } > +} > + > +impl fmt::Display for AccountName { > + #[inline] > + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { > + fmt::Display::fmt(&self.0, f) > + } > +} > + > +pub fn foreach_acme_account(mut func: F) -> Result<(), Error> > +where > + F: FnMut(AccountName) -> ControlFlow>, > +{ > + match crate::tools::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 = AccountName(file_name.to_owned()); > + > + if let ControlFlow::Break(result) = func(account_name) { > + return result; > + } > + } > + Ok(()) > + } > + Err(err) if err.not_found() => Ok(()), > + Err(err) => Err(err.into()), > + } > +} > + > +/// Run a function for each DNS plugin ID. > +pub fn foreach_dns_plugin(mut func: F) -> Result<(), Error> > +where > + F: FnMut(&str) -> ControlFlow>, > +{ > + match crate::tools::fs::read_subdir(-1, "/usr/share/proxmox-acme/dnsapi") { > + Ok(files) => { > + for file in files.filter_map(Result::ok) { > + if let Some(id) = file > + .file_name() > + .to_str() > + .ok() > + .and_then(|name| name.strip_prefix("dns_")) > + .and_then(|name| name.strip_suffix(".sh")) > + { > + if let ControlFlow::Break(result) = func(id) { > + 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 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()] > +} > diff --git a/src/config/acme/plugin.rs b/src/config/acme/plugin.rs > new file mode 100644 > index 00000000..acfa44c5 > --- /dev/null > +++ b/src/config/acme/plugin.rs > @@ -0,0 +1,380 @@ > +use std::future::Future; > +use std::pin::Pin; > +use std::process::Stdio; > + > +use anyhow::{bail, format_err, Error}; > +use lazy_static::lazy_static; > +use serde::{Deserialize, Serialize}; > +use serde_json::Value; > +use tokio::io::AsyncWriteExt; > +use tokio::process::Command; > + > +use proxmox::api::{ > + api, > + schema::*, > + section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin}, > +}; > + > +use proxmox::tools::{fs::replace_file, fs::CreateOptions}; > + > +use proxmox_acme_rs::{Authorization, Challenge}; > + > +use crate::acme::AcmeClient; > +use crate::api2::types::PROXMOX_SAFE_ID_FORMAT; > +use crate::config::acme::AcmeDomain; > + > +const ACME_PATH: &str = "/usr/share/proxmox-acme/proxmox-acme"; > + > +pub const PLUGIN_ID_SCHEMA: Schema = StringSchema::new("ACME Challenge Plugin ID.") > + .format(&PROXMOX_SAFE_ID_FORMAT) > + .schema(); > + > +lazy_static! { > + pub static ref CONFIG: SectionConfig = 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(), > + } > + } > +} > + > +/// In PVE/PMG we store the plugin's "data" member as base64url encoded string. The UI sends > +/// regular base64 encoded data. We need to "fix" this up. > + > +#[api( > + properties: { > + id: { schema: PLUGIN_ID_SCHEMA }, > + disable: { > + optional: true, > + default: false, > + }, > + "validation-delay": { > + default: 30, > + optional: true, > + minimum: 0, > + maximum: 2 * 24 * 60 * 60, > + }, > + }, > +)] > +/// DNS ACME Challenge Plugin core data. > +#[derive(Deserialize, Serialize, Updater)] > +#[serde(rename_all = "kebab-case")] > +pub struct DnsPluginCore { > + /// Plugin ID. > + pub(crate) id: String, > + > + /// DNS API Plugin Id. > + api: String, > + > + /// Extra delay in seconds to wait before requesting validation. > + /// > + /// Allows to cope with long TTL of DNS records. > + #[serde(skip_serializing_if = "Option::is_none", default)] > + validation_delay: Option, > + > + /// Flag to disable the config. > + #[serde(skip_serializing_if = "Option::is_none", default)] > + disable: Option, > +} > + > +#[api( > + properties: { > + core: { type: DnsPluginCore }, > + }, > +)] > +/// DNS ACME Challenge Plugin. > +#[derive(Deserialize, Serialize)] > +#[serde(rename_all = "kebab-case")] > +pub struct DnsPlugin { > + #[serde(flatten)] > + pub(crate) core: DnsPluginCore, > + > + // FIXME: The `Updater` should allow: > + // * having different descriptions for this and the Updater version > + // * having different `#[serde]` attributes for the Updater > + // * or, well, leaving fields out completely in teh Updater but this means we may need to > + // separate Updater and Builder deriving. > + // We handle this property separately in the API calls. > + /// DNS plugin data (base64url encoded without padding). > + #[serde(with = "proxmox::tools::serde::string_as_base64url_nopad")] > + pub(crate) data: String, > +} > + > +impl DnsPlugin { > + pub fn decode_data(&self, output: &mut Vec) -> Result<(), Error> { > + Ok(base64::decode_config_buf(&self.data, base64::URL_SAFE_NO_PAD, output)?) > + } > +} > + > +//impl DnsPluginUpdater { > +// // The UI passes regular base64 data, we need base64url data. In PVE/PMG this happens magically > +// // since perl parses both on decode... > +// pub fn api_fixup(&mut self) -> Result<(), Error> { > +// if let Some(data) = self.data.as_mut() { > +// let new = base64::encode_config(&base64::decode(&data)?, base64::URL_SAFE_NO_PAD); > +// *data = new; > +// } > +// Ok(()) > +// } > +//} > + > +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 > +} > + > +pub const ACME_PLUGIN_CFG_FILENAME: &str = "/etc/proxmox-backup/acme/plugins.cfg"; > +pub const ACME_PLUGIN_CFG_LOCKFILE: &str = "/etc/proxmox-backup/acme/.plugins.lck"; > +const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); > + > +pub fn read_lock() -> Result { > + proxmox::tools::fs::open_file_locked(ACME_PLUGIN_CFG_LOCKFILE, LOCK_TIMEOUT, false) > +} > + > +pub fn write_lock() -> Result { > + proxmox::tools::fs::open_file_locked(ACME_PLUGIN_CFG_LOCKFILE, LOCK_TIMEOUT, true) > +} > + > +pub fn config() -> Result<(PluginData, [u8; 32]), Error> { > + let content = proxmox::tools::fs::file_read_optional_string(ACME_PLUGIN_CFG_FILENAME)? > + .unwrap_or_else(|| "".to_string()); > + > + let digest = openssl::sha::sha256(content.as_bytes()); > + let mut data = CONFIG.parse(ACME_PLUGIN_CFG_FILENAME, &content)?; > + > + if data.sections.get("standalone").is_none() { > + let standalone = StandalonePlugin::default(); > + data.set_data("standalone", "standalone", &standalone) > + .unwrap(); > + } > + > + Ok((PluginData { data }, digest)) > +} > + > +pub fn save_config(config: &PluginData) -> Result<(), Error> { > + let raw = CONFIG.write(ACME_PLUGIN_CFG_FILENAME, &config.data)?; > + > + let backup_user = crate::backup::backup_user()?; > + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); > + // set the correct owner/group/permissions while saving file > + // owner(rw) = root, group(r)= backup > + let options = CreateOptions::new() > + .perm(mode) > + .owner(nix::unistd::ROOT) > + .group(backup_user.gid); > + > + replace_file(ACME_PLUGIN_CFG_FILENAME, raw.as_bytes(), options)?; > + > + Ok(()) > +} > + > +pub struct PluginData { > + data: SectionConfigData, > +} > + > +impl PluginData { > + #[inline] > + pub fn remove(&mut self, name: &str) -> Option<(String, Value)> { > + self.data.sections.remove(name) > + } > + > + #[inline] > + pub fn contains_key(&mut self, name: &str) -> bool { > + self.data.sections.contains_key(name) > + } > + > + #[inline] > + pub fn get(&self, name: &str) -> Option<&(String, Value)> { > + self.data.sections.get(name) > + } > + > + #[inline] > + pub fn get_mut(&mut self, name: &str) -> Option<&mut (String, Value)> { > + self.data.sections.get_mut(name) > + } > + > + // FIXME: Verify the plugin type *exists* and check its config schema... > + pub fn insert(&mut self, id: String, ty: String, plugin: Value) { > + self.data.sections.insert(id, (ty, plugin)); > + } > + > + pub fn get_plugin( > + &self, > + name: &str, > + ) -> Result>, Error> { > + let (ty, data) = match self.get(name) { > + Some(plugin) => plugin, > + None => return Ok(None), > + }; > + > + Ok(Some(match ty.as_str() { > + "dns" => { > + let plugin: DnsPlugin = serde_json::from_value(data.clone())?; > + Box::new(plugin) > + } > + // "standalone" => todo!("standalone plugin"), > + other => bail!("missing implementation for plugin type '{}'", other), > + })) > + } > + > + pub fn iter(&self) -> impl Iterator + Send { > + self.data.sections.iter() > + } > +} > + > +pub trait AcmePlugin { > + /// Setup everything required to trigger the validation and return the corresponding validation > + /// URL. > + fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( > + &'a self, > + client: &'b mut AcmeClient, > + authorization: &'c Authorization, > + domain: &'d AcmeDomain, > + ) -> Pin> + Send + 'fut>>; > + > + fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( > + &'a self, > + client: &'b mut AcmeClient, > + authorization: &'c Authorization, > + domain: &'d AcmeDomain, > + ) -> Pin> + Send + 'fut>>; > +} > + > +impl DnsPlugin { > + fn extract_challenge(authorization: &Authorization) -> Result<&Challenge, Error> { > + authorization > + .challenges > + .iter() > + .find(|ch| ch.ty == "dns-01") > + .ok_or_else(|| format_err!("no supported challenge type (dns-01) found")) > + } > + > + async fn action<'a>( > + &self, > + client: &mut AcmeClient, > + authorization: &'a Authorization, > + domain: &AcmeDomain, > + action: &str, > + ) -> Result<&'a str, Error> { > + let challenge = Self::extract_challenge(authorization)?; > + let mut stdin_data = client > + .dns_01_txt_value( > + challenge > + .token() > + .ok_or_else(|| format_err!("missing token in challenge"))?, > + )? > + .into_bytes(); > + stdin_data.push(b'\n'); > + stdin_data.extend(self.data.as_bytes()); > + if stdin_data.last() != Some(&b'\n') { > + stdin_data.push(b'\n'); > + } > + > + let mut command = Command::new("/usr/bin/setpriv"); > + > + #[rustfmt::skip] > + command.args(&[ > + "--reuid", "nobody", > + "--regid", "nogroup", > + "--clear-groups", > + "--reset-env", > + "--", > + "/bin/bash", > + ACME_PATH, > + action, > + &self.core.api, > + domain.alias.as_deref().unwrap_or(&domain.domain), > + ]); > + > + let mut child = command.stdin(Stdio::piped()).spawn()?; > + > + let mut stdin = child.stdin.take().expect("Stdio::piped()"); > + match async move { > + stdin.write_all(&stdin_data).await?; > + stdin.flush().await?; > + Ok::<_, std::io::Error>(()) > + }.await { > + Ok(()) => (), > + Err(err) => { > + if let Err(err) = child.kill().await { > + eprintln!("failed to kill '{} {}' command: {}", ACME_PATH, action, err); > + } > + bail!("'{}' failed: {}", ACME_PATH, err); > + } > + } > + > + let status = child.wait().await?; > + if !status.success() { > + bail!( > + "'{} {}' exited with error ({})", > + ACME_PATH, > + action, > + status.code().unwrap_or(-1) > + ); > + } > + > + Ok(&challenge.url) > + } > +} > + > +impl AcmePlugin for DnsPlugin { > + fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( > + &'a self, > + client: &'b mut AcmeClient, > + authorization: &'c Authorization, > + domain: &'d AcmeDomain, > + ) -> Pin> + Send + 'fut>> { > + Box::pin(self.action(client, authorization, domain, "setup")) > + } > + > + fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>( > + &'a self, > + client: &'b mut AcmeClient, > + authorization: &'c Authorization, > + domain: &'d AcmeDomain, > + ) -> Pin> + Send + 'fut>> { > + Box::pin(async move { > + self.action(client, authorization, domain, "teardown") > + .await > + .map(drop) > + }) > + } > +} > diff --git a/src/lib.rs b/src/lib.rs > index 200cf496..1b1de527 100644 > --- a/src/lib.rs > +++ b/src/lib.rs > @@ -32,3 +32,5 @@ pub mod auth; > pub mod rrd; > > pub mod tape; > + > +pub mod acme;