[pbs-devel] [REBASED backup 02/14] add acme config and client

Dietmar Maurer dietmar at proxmox.com
Fri Apr 30 08:16:39 CEST 2021


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 at 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;





More information about the pbs-devel mailing list