From: Dietmar Maurer <dietmar@proxmox.com>
To: Proxmox Backup Server development discussion
<pbs-devel@lists.proxmox.com>,
Wolfgang Bumiller <w.bumiller@proxmox.com>
Subject: Re: [pbs-devel] [REBASED backup 02/14] add acme config and client
Date: Fri, 30 Apr 2021 08:16:39 +0200 [thread overview]
Message-ID: <d1481807-04ab-36c7-b9fc-3a070c191a20@proxmox.com> (raw)
In-Reply-To: <20210429131322.24319-3-w.bumiller@proxmox.com>
Is it possible to split this patch? I would like to separate the
config/* part.
IMHO this should not depend on acme/
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 <w.bumiller@proxmox.com>
> ---
> 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<String>,
> +
> + #[serde(skip_serializing_if = "is_false", default)]
> + debug: bool,
> +
> + /// The directory's URL.
> + directory_url: String,
> +}
> +
> +#[inline]
> +fn is_false(b: &bool) -> bool {
> + !*b
> +}
> +
> +pub struct AcmeClient {
> + directory_url: String,
> + debug: bool,
> + account_path: Option<String>,
> + tos: Option<String>,
> + account: Option<Account>,
> + directory: Option<Directory>,
> + nonce: Option<String>,
> + http_client: Option<SimpleHttp>,
> +}
> +
> +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, anyhow::Error> {
> + 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<Self, anyhow::Error> {
> + 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<String>,
> + rsa_bits: Option<u32>,
> + ) -> 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::<u8>::new();
> + self.write_to(&mut data)?;
> + let account_path = self.account_path.as_ref().ok_or_else(|| {
> + format_err!("no account path set, cannot save 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<String, anyhow::Error> {
> + Ok(Self::need_account(&self.account)?.key_authorization(token)?)
> + }
> +
> + /// Shortcut to `account().ok_or_else(...).dns_01_txt_value()`.
> + /// the key authorization value.
> + pub fn dns_01_txt_value(&self, token: &str) -> Result<String, anyhow::Error> {
> + Ok(Self::need_account(&self.account)?.dns_01_txt_value(token)?)
> + }
> +
> + async fn register_account(
> + &mut self,
> + account: AccountCreator,
> + ) -> Result<&Account, anyhow::Error> {
> + let mut retry = retry();
> + let mut response = loop {
> + retry.tick()?;
> +
> + let (directory, nonce) = Self::get_dir_nonce(
> + &mut self.http_client,
> + &self.directory_url,
> + &mut self.directory,
> + &mut self.nonce,
> + )
> + .await?;
> + let request = account.request(directory, nonce)?;
> + match self.run_request(request).await {
> + Ok(response) => break response,
> + Err(err) if err.is_bad_nonce() => continue,
> + Err(err) => return Err(err.into()),
> + }
> + };
> +
> + let account = account.response(response.location_required()?, &response.body)?;
> +
> + self.account = Some(account);
> + Ok(self.account.as_ref().unwrap())
> + }
> +
> + pub async fn update_account<T: Serialize>(
> + &mut self,
> + data: &T,
> + ) -> Result<&Account, anyhow::Error> {
> + let account = Self::need_account(&self.account)?;
> +
> + let mut retry = retry();
> + let response = loop {
> + retry.tick()?;
> +
> + let (_directory, nonce) = Self::get_dir_nonce(
> + &mut self.http_client,
> + &self.directory_url,
> + &mut self.directory,
> + &mut self.nonce,
> + )
> + .await?;
> +
> + let request = account.post_request(&account.location, &nonce, data)?;
> + match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
> + Ok(response) => break response,
> + Err(err) if err.is_bad_nonce() => continue,
> + Err(err) => return Err(err.into()),
> + }
> + };
> +
> + // unwrap: we've been keeping an immutable reference to it from the top of the method
> + let _ = account;
> + self.account.as_mut().unwrap().data = response.json()?;
> + self.save()?;
> + Ok(self.account.as_ref().unwrap())
> + }
> +
> + pub async fn new_order<I>(&mut self, domains: I) -> Result<Order, anyhow::Error>
> + where
> + I: IntoIterator<Item = String>,
> + {
> + let account = Self::need_account(&self.account)?;
> +
> + let order = domains
> + .into_iter()
> + .fold(OrderData::new(), |order, domain| order.domain(domain));
> +
> + let mut retry = retry();
> + loop {
> + retry.tick()?;
> +
> + let (directory, nonce) = Self::get_dir_nonce(
> + &mut self.http_client,
> + &self.directory_url,
> + &mut self.directory,
> + &mut self.nonce,
> + )
> + .await?;
> +
> + let mut new_order = account.new_order(&order, directory, nonce)?;
> + let mut response = match Self::execute(
> + &mut self.http_client,
> + new_order.request.take().unwrap(),
> + &mut self.nonce,
> + )
> + .await
> + {
> + Ok(response) => response,
> + Err(err) if err.is_bad_nonce() => continue,
> + Err(err) => return Err(err.into()),
> + };
> +
> + return Ok(
> + new_order.response(response.location_required()?, response.bytes().as_ref())?
> + );
> + }
> + }
> +
> + /// Low level "POST-as-GET" request.
> + async fn post_as_get(&mut self, url: &str) -> Result<AcmeResponse, anyhow::Error> {
> + let account = Self::need_account(&self.account)?;
> +
> + let mut retry = retry();
> + loop {
> + retry.tick()?;
> +
> + let (_directory, nonce) = Self::get_dir_nonce(
> + &mut self.http_client,
> + &self.directory_url,
> + &mut self.directory,
> + &mut self.nonce,
> + )
> + .await?;
> +
> + let request = account.get_request(url, nonce)?;
> + match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
> + Ok(response) => return Ok(response),
> + Err(err) if err.is_bad_nonce() => continue,
> + Err(err) => return Err(err.into()),
> + }
> + }
> + }
> +
> + /// Low level POST request.
> + async fn post<T: Serialize>(
> + &mut self,
> + url: &str,
> + data: &T,
> + ) -> Result<AcmeResponse, anyhow::Error> {
> + let account = Self::need_account(&self.account)?;
> +
> + let mut retry = retry();
> + loop {
> + retry.tick()?;
> +
> + let (_directory, nonce) = Self::get_dir_nonce(
> + &mut self.http_client,
> + &self.directory_url,
> + &mut self.directory,
> + &mut self.nonce,
> + )
> + .await?;
> +
> + let request = account.post_request(url, nonce, data)?;
> + match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
> + Ok(response) => return Ok(response),
> + Err(err) if err.is_bad_nonce() => continue,
> + Err(err) => return Err(err.into()),
> + }
> + }
> + }
> +
> + /// Request challenge validation. Afterwards, the challenge should be polled.
> + pub async fn request_challenge_validation(
> + &mut self,
> + url: &str,
> + ) -> Result<Challenge, anyhow::Error> {
> + Ok(self
> + .post(url, &serde_json::Value::Object(Default::default()))
> + .await?
> + .json()?)
> + }
> +
> + /// Assuming the provided URL is an 'Authorization' URL, get and deserialize it.
> + pub async fn get_authorization(&mut self, url: &str) -> Result<Authorization, anyhow::Error> {
> + Ok(self.post_as_get(url).await?.json()?)
> + }
> +
> + /// Assuming the provided URL is an 'Order' URL, get and deserialize it.
> + pub async fn get_order(&mut self, url: &str) -> Result<OrderData, anyhow::Error> {
> + Ok(self.post_as_get(url).await?.json()?)
> + }
> +
> + /// Finalize an Order via its `finalize` URL property and the DER encoded CSR.
> + pub async fn finalize(&mut self, url: &str, csr: &[u8]) -> Result<(), anyhow::Error> {
> + let csr = 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<Bytes, anyhow::Error> {
> + Ok(self.post_as_get(url).await?.body)
> + }
> +
> + /// Revoke an existing certificate (PEM or DER formatted).
> + pub async fn revoke_certificate(
> + &mut self,
> + certificate: &[u8],
> + reason: Option<u32>,
> + ) -> Result<(), anyhow::Error> {
> + // TODO: This can also work without an account.
> + let account = Self::need_account(&self.account)?;
> +
> + let revocation = account.revoke_certificate(certificate, reason)?;
> +
> + let mut retry = retry();
> + loop {
> + retry.tick()?;
> +
> + let (directory, nonce) = Self::get_dir_nonce(
> + &mut self.http_client,
> + &self.directory_url,
> + &mut self.directory,
> + &mut self.nonce,
> + )
> + .await?;
> +
> + let request = revocation.request(&directory, nonce)?;
> + match Self::execute(&mut self.http_client, request, &mut self.nonce).await {
> + Ok(_response) => return Ok(()),
> + Err(err) if err.is_bad_nonce() => continue,
> + Err(err) => return Err(err.into()),
> + }
> + }
> + }
> +
> + fn need_account(account: &Option<Account>) -> Result<&Account, anyhow::Error> {
> + account
> + .as_ref()
> + .ok_or_else(|| format_err!("cannot use client without an account"))
> + }
> +
> + pub(crate) fn account(&self) -> Result<&Account, anyhow::Error> {
> + Self::need_account(&self.account)
> + }
> +
> + pub fn tos(&self) -> Option<&str> {
> + self.tos.as_deref()
> + }
> +
> + pub fn directory_url(&self) -> &str {
> + &self.directory_url
> + }
> +
> + fn to_account_data(&self) -> Result<AccountData, anyhow::Error> {
> + let account = self.account()?;
> +
> + Ok(AccountData {
> + location: account.location.clone(),
> + key: account.private_key.clone(),
> + account: AcmeAccountData {
> + only_return_existing: false, // don't actually write this out in case it's set
> + ..account.data.clone()
> + },
> + tos: self.tos.clone(),
> + debug: self.debug,
> + directory_url: self.directory_url.clone(),
> + })
> + }
> +
> + fn write_to<T: io::Write>(&self, out: T) -> Result<(), anyhow::Error> {
> + let data = self.to_account_data()?;
> +
> + Ok(serde_json::to_writer_pretty(out, &data)?)
> + }
> +}
> +
> +struct AcmeResponse {
> + body: Bytes,
> + location: Option<String>,
> + got_nonce: bool,
> +}
> +
> +impl AcmeResponse {
> + /// Convenience helper to assert that a location header was part of the response.
> + fn location_required(&mut self) -> Result<String, anyhow::Error> {
> + self.location
> + .take()
> + .ok_or_else(|| format_err!("missing Location header"))
> + }
> +
> + /// Convenience shortcut to perform json deserialization of the returned body.
> + fn json<T: for<'a> Deserialize<'a>>(&self) -> Result<T, Error> {
> + Ok(serde_json::from_slice(&self.body)?)
> + }
> +
> + /// Convenience shortcut to get the body as bytes.
> + fn bytes(&self) -> &[u8] {
> + &self.body
> + }
> +}
> +
> +impl AcmeClient {
> + /// Non-self-borrowing run_request version for borrow workarounds.
> + async fn execute(
> + http_client: &mut Option<SimpleHttp>,
> + request: AcmeRequest,
> + nonce: &mut Option<String>,
> + ) -> Result<AcmeResponse, Error> {
> + let req_builder = Request::builder().method(request.method).uri(&request.url);
> +
> + let http_request = if !request.content_type.is_empty() {
> + req_builder
> + .header("Content-Type", request.content_type)
> + .header("Content-Length", request.body.len())
> + .body(request.body.into())
> + } else {
> + req_builder.body(Body::empty())
> + }
> + .map_err(|err| Error::Custom(format!("failed to create http request: {}", err)))?;
> +
> + let response = http_client
> + .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<AcmeResponse, Error> {
> + 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<SimpleHttp>,
> + directory_url: &str,
> + directory: &'a mut Option<Directory>,
> + nonce: &'b mut Option<String>,
> + ) -> Result<(&'a Directory, Option<&'b str>), Error> {
> + if let Some(d) = directory {
> + return Ok((d, nonce.as_deref()));
> + }
> +
> + let response = Self::execute(
> + http_client,
> + AcmeRequest {
> + url: directory_url.to_string(),
> + method: "GET",
> + content_type: "",
> + body: String::new(),
> + expected: 200,
> + },
> + nonce,
> + )
> + .await?;
> +
> + *directory = Some(Directory::from_parts(
> + directory_url.to_string(),
> + response.json()?,
> + ));
> +
> + Ok((directory.as_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<SimpleHttp>,
> + directory_url: &str,
> + directory: &'a mut Option<Directory>,
> + nonce: &'b mut Option<String>,
> + ) -> Result<(&'a Directory, &'b str), Error> {
> + // this let construct is a lifetime workaround:
> + let _ = Self::get_directory(http_client, directory_url, directory, nonce).await?;
> + let dir = directory.as_ref().unwrap(); // the above fails if it couldn't fill this option
> + if nonce.is_none() {
> + // this is also a lifetime issue...
> + let _ = Self::get_nonce(http_client, nonce, dir.new_nonce_url()).await?;
> + };
> + Ok((dir, nonce.as_deref().unwrap()))
> + }
> +
> + pub async fn terms_of_service_url(&mut self) -> Result<Option<&str>, Error> {
> + Ok(self.directory().await?.terms_of_service_url())
> + }
> +
> + async fn get_nonce<'a>(
> + http_client: &mut Option<SimpleHttp>,
> + nonce: &'a mut Option<String>,
> + new_nonce_url: &str,
> + ) -> Result<&'a str, Error> {
> + let response = Self::execute(
> + http_client,
> + AcmeRequest {
> + url: new_nonce_url.to_owned(),
> + method: "HEAD",
> + content_type: "",
> + body: String::new(),
> + expected: 200,
> + },
> + nonce,
> + )
> + .await?;
> +
> + if !response.got_nonce {
> + return Err(Error::InvalidApi(
> + "no new nonce received from new nonce URL".to_string(),
> + ));
> + }
> +
> + nonce
> + .as_deref()
> + .ok_or_else(|| Error::Client("failed to update nonce".to_string()))
> + }
> +}
> +
> +/// bad nonce retry count helper
> +struct Retry(usize);
> +
> +const fn retry() -> Retry {
> + Retry(0)
> +}
> +
> +impl Retry {
> + fn tick(&mut self) -> Result<(), Error> {
> + if self.0 >= 3 {
> + 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<String>,
> +
> + /// 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<String>,
> +}
> +
> +#[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<str> 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<F>(mut func: F) -> Result<(), Error>
> +where
> + F: FnMut(AccountName) -> ControlFlow<Result<(), Error>>,
> +{
> + 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<F>(mut func: F) -> Result<(), Error>
> +where
> + F: FnMut(&str) -> ControlFlow<Result<(), Error>>,
> +{
> + 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<String, String>) -> Vec<String> {
> + let mut out = Vec::new();
> + let _ = foreach_acme_account(|name| {
> + out.push(name.into_string());
> + ControlFlow::CONTINUE
> + });
> + out
> +}
> +
> +pub fn complete_acme_plugin(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
> + match plugin::config() {
> + Ok((config, _digest)) => config
> + .iter()
> + .map(|(id, (_type, _cfg))| id.clone())
> + .collect(),
> + Err(_) => Vec::new(),
> + }
> +}
> +
> +pub fn complete_acme_plugin_type(_arg: &str, _param: &HashMap<String, String>) -> Vec<String> {
> + vec!["dns".to_string(), "http".to_string()]
> +}
> 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<u32>,
> +
> + /// Flag to disable the config.
> + #[serde(skip_serializing_if = "Option::is_none", default)]
> + disable: Option<bool>,
> +}
> +
> +#[api(
> + properties: {
> + core: { type: DnsPluginCore },
> + },
> +)]
> +/// DNS ACME Challenge Plugin.
> +#[derive(Deserialize, Serialize)]
> +#[serde(rename_all = "kebab-case")]
> +pub struct DnsPlugin {
> + #[serde(flatten)]
> + pub(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<u8>) -> 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<std::fs::File, Error> {
> + proxmox::tools::fs::open_file_locked(ACME_PLUGIN_CFG_LOCKFILE, LOCK_TIMEOUT, false)
> +}
> +
> +pub fn write_lock() -> Result<std::fs::File, Error> {
> + 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<Option<Box<dyn AcmePlugin + Send + Sync + 'static>>, 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<Item = (&String, &(String, Value))> + 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<Box<dyn Future<Output = Result<&'c str, Error>> + 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<Box<dyn Future<Output = Result<(), Error>> + 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<Box<dyn Future<Output = Result<&'c str, Error>> + 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<Box<dyn Future<Output = Result<(), Error>> + 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;
next prev parent reply other threads:[~2021-04-30 6:17 UTC|newest]
Thread overview: 20+ messages / expand[flat|nested] mbox.gz Atom feed top
2021-04-29 13:13 [pbs-devel] [REBASED backup 00/14] rebased and reordered remaining acme patches Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 01/14] add dns alias schema Wolfgang Bumiller
2021-04-30 6:11 ` [pbs-devel] applied: " Dietmar Maurer
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 02/14] add acme config and client Wolfgang Bumiller
2021-04-30 6:16 ` Dietmar Maurer [this message]
2021-04-30 7:25 ` Wolfgang Bumiller
2021-04-30 6:20 ` Dietmar Maurer
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 03/14] add node config Wolfgang Bumiller
2021-04-30 6:26 ` Dietmar Maurer
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 04/14] add config/acme api path Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 05/14] add node/{node}/certificates api call Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 06/14] add node/{node}/config api path Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 07/14] add acme commands to proxmox-backup-manager Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 08/14] implement standalone acme validation Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 09/14] ui: add certificate & acme view Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 10/14] daily-update: check acme certificates Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 11/14] acme: create directories as needed Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 12/14] acme: pipe plugin output to task log Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 13/14] api: acme: make account name optional in register call Wolfgang Bumiller
2021-04-29 13:13 ` [pbs-devel] [REBASED backup 14/14] validate node config before writing Wolfgang Bumiller
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=d1481807-04ab-36c7-b9fc-3a070c191a20@proxmox.com \
--to=dietmar@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
--cc=w.bumiller@proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal