[pbs-devel] [REBASED v2 backup 2/9] add acme client

Wolfgang Bumiller w.bumiller at proxmox.com
Mon May 3 11:39:52 CEST 2021


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 | 673 +++++++++++++++++++++++++++++++++++++++++++++
 src/acme/mod.rs    |   5 +
 src/acme/plugin.rs | 299 ++++++++++++++++++++
 src/lib.rs         |   2 +
 4 files changed, 979 insertions(+)
 create mode 100644 src/acme/client.rs
 create mode 100644 src/acme/mod.rs
 create mode 100644 src/acme/plugin.rs

diff --git a/src/acme/client.rs b/src/acme/client.rs
new file mode 100644
index 00000000..7f88bbf9
--- /dev/null
+++ b/src/acme/client.rs
@@ -0,0 +1,673 @@
+//! 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?;
+
+        crate::config::acme::make_acme_account_dir()?;
+        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")
+        })?;
+        crate::config::acme::make_acme_account_dir()?;
+        replace_file(
+            account_path,
+            &data,
+            CreateOptions::new()
+                .perm(Mode::from_bits_truncate(0o600))
+                .owner(nix::unistd::ROOT)
+                .group(nix::unistd::Gid::from_raw(0)),
+        )
+    }
+
+    /// 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..bf61811c
--- /dev/null
+++ b/src/acme/mod.rs
@@ -0,0 +1,5 @@
+mod client;
+pub use client::AcmeClient;
+
+pub(crate) mod plugin;
+pub(crate) use plugin::get_acme_plugin;
diff --git a/src/acme/plugin.rs b/src/acme/plugin.rs
new file mode 100644
index 00000000..860e7750
--- /dev/null
+++ b/src/acme/plugin.rs
@@ -0,0 +1,299 @@
+use std::future::Future;
+use std::pin::Pin;
+use std::process::Stdio;
+use std::sync::Arc;
+
+use anyhow::{bail, format_err, Error};
+use hyper::{Body, Request, Response};
+use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader};
+use tokio::process::Command;
+
+use proxmox_acme_rs::{Authorization, Challenge};
+
+use crate::acme::AcmeClient;
+use crate::config::acme::AcmeDomain;
+use crate::server::WorkerTask;
+
+use crate::config::acme::plugin::{DnsPlugin, PluginData};
+
+const PROXMOX_ACME_SH_PATH: &str = "/usr/share/proxmox-acme/proxmox-acme";
+
+pub(crate) fn get_acme_plugin(
+    plugin_data: &PluginData,
+    name: &str,
+) -> Result<Option<Box<dyn AcmePlugin + Send + Sync + 'static>>, Error> {
+    let (ty, data) = match plugin_data.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" => {
+            // this one has no config
+            Box::new(StandaloneServer::default())
+        }
+        other => bail!("missing implementation for plugin type '{}'", other),
+    }))
+}
+
+pub(crate) 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 mut self,
+        client: &'b mut AcmeClient,
+        authorization: &'c Authorization,
+        domain: &'d AcmeDomain,
+        task: Arc<WorkerTask>,
+    ) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>>;
+
+    fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
+        &'a mut self,
+        client: &'b mut AcmeClient,
+        authorization: &'c Authorization,
+        domain: &'d AcmeDomain,
+        task: Arc<WorkerTask>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>>;
+}
+
+fn extract_challenge<'a>(
+    authorization: &'a Authorization,
+    ty: &str,
+) -> Result<&'a Challenge, Error> {
+    authorization
+        .challenges
+        .iter()
+        .find(|ch| ch.ty == ty)
+        .ok_or_else(|| format_err!("no supported challenge type (dns-01) found"))
+}
+
+async fn pipe_to_tasklog<T: AsyncRead + Unpin>(
+    pipe: T,
+    task: Arc<WorkerTask>,
+) -> Result<(), std::io::Error> {
+    let mut pipe = BufReader::new(pipe);
+    let mut line = String::new();
+    loop {
+        line.clear();
+        match pipe.read_line(&mut line).await {
+            Ok(0) => return Ok(()),
+            Ok(_) => task.log(line.as_str()),
+            Err(err) => return Err(err),
+        }
+    }
+}
+
+impl DnsPlugin {
+    async fn action<'a>(
+        &self,
+        client: &mut AcmeClient,
+        authorization: &'a Authorization,
+        domain: &AcmeDomain,
+        task: Arc<WorkerTask>,
+        action: &str,
+    ) -> Result<&'a str, Error> {
+        let challenge = extract_challenge(authorization, "dns-01")?;
+        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",
+                PROXMOX_ACME_SH_PATH,
+                action,
+                &self.core.api,
+                domain.alias.as_deref().unwrap_or(&domain.domain),
+        ]);
+
+        // We could use 1 socketpair, but tokio wraps them all in `File` internally causing `close`
+        // to be called separately on all of them without exception, so we need 3 pipes :-(
+
+        let mut child = command
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped())
+            .spawn()?;
+
+        let mut stdin = child.stdin.take().expect("Stdio::piped()");
+        let stdout = child.stdout.take().expect("Stdio::piped() failed?");
+        let stdout = pipe_to_tasklog(stdout, Arc::clone(&task));
+        let stderr = child.stderr.take().expect("Stdio::piped() failed?");
+        let stderr = pipe_to_tasklog(stderr, Arc::clone(&task));
+        let stdin = async move {
+            stdin.write_all(&stdin_data).await?;
+            stdin.flush().await?;
+            Ok::<_, std::io::Error>(())
+        };
+        match futures::try_join!(stdin, stdout, stderr) {
+            Ok(((), (), ())) => (),
+            Err(err) => {
+                if let Err(err) = child.kill().await {
+                    task.log(format!(
+                        "failed to kill '{} {}' command: {}",
+                        PROXMOX_ACME_SH_PATH, action, err
+                    ));
+                }
+                bail!("'{}' failed: {}", PROXMOX_ACME_SH_PATH, err);
+            }
+        }
+
+        let status = child.wait().await?;
+        if !status.success() {
+            bail!(
+                "'{} {}' exited with error ({})",
+                PROXMOX_ACME_SH_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 mut self,
+        client: &'b mut AcmeClient,
+        authorization: &'c Authorization,
+        domain: &'d AcmeDomain,
+        task: Arc<WorkerTask>,
+    ) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>> {
+        Box::pin(self.action(client, authorization, domain, task, "setup"))
+    }
+
+    fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
+        &'a mut self,
+        client: &'b mut AcmeClient,
+        authorization: &'c Authorization,
+        domain: &'d AcmeDomain,
+        task: Arc<WorkerTask>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>> {
+        Box::pin(async move {
+            self.action(client, authorization, domain, task, "teardown")
+                .await
+                .map(drop)
+        })
+    }
+}
+
+#[derive(Default)]
+struct StandaloneServer {
+    abort_handle: Option<futures::future::AbortHandle>,
+}
+
+// In case the "order_certificates" future gets dropped between setup & teardown, let's also cancel
+// the HTTP listener on Drop:
+impl Drop for StandaloneServer {
+    fn drop(&mut self) {
+        self.stop();
+    }
+}
+
+impl StandaloneServer {
+    fn stop(&mut self) {
+        if let Some(abort) = self.abort_handle.take() {
+            abort.abort();
+        }
+    }
+}
+
+async fn standalone_respond(
+    req: Request<Body>,
+    path: Arc<String>,
+    key_auth: Arc<String>,
+) -> Result<Response<Body>, hyper::Error> {
+    if req.method() == hyper::Method::GET && req.uri().path() == path.as_str() {
+        Ok(Response::builder()
+            .status(http::StatusCode::OK)
+            .body(key_auth.as_bytes().to_vec().into())
+            .unwrap())
+    } else {
+        Ok(Response::builder()
+            .status(http::StatusCode::NOT_FOUND)
+            .body("Not found.".into())
+            .unwrap())
+    }
+}
+
+impl AcmePlugin for StandaloneServer {
+    fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
+        &'a mut self,
+        client: &'b mut AcmeClient,
+        authorization: &'c Authorization,
+        _domain: &'d AcmeDomain,
+        _task: Arc<WorkerTask>,
+    ) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>> {
+        use hyper::server::conn::AddrIncoming;
+        use hyper::service::{make_service_fn, service_fn};
+
+        Box::pin(async move {
+            self.stop();
+
+            let challenge = extract_challenge(authorization, "http-01")?;
+            let token = challenge
+                .token()
+                .ok_or_else(|| format_err!("missing token in challenge"))?;
+            let key_auth = Arc::new(client.key_authorization(&token)?);
+            let path = Arc::new(format!("/.well-known/acme-challenge/{}", token));
+
+            let service = make_service_fn(move |_| {
+                let path = Arc::clone(&path);
+                let key_auth = Arc::clone(&key_auth);
+                async move {
+                    Ok::<_, hyper::Error>(service_fn(move |request| {
+                        standalone_respond(request, Arc::clone(&path), Arc::clone(&key_auth))
+                    }))
+                }
+            });
+
+            // `[::]:80` first, then `*:80`
+            let incoming = AddrIncoming::bind(&(([0u16; 8], 80).into()))
+                .or_else(|_| AddrIncoming::bind(&(([0u8; 4], 80).into())))?;
+
+            let server = hyper::Server::builder(incoming).serve(service);
+
+            let (future, abort) = futures::future::abortable(server);
+            self.abort_handle = Some(abort);
+            tokio::spawn(future);
+
+            Ok(challenge.url.as_str())
+        })
+    }
+
+    fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
+        &'a mut self,
+        _client: &'b mut AcmeClient,
+        _authorization: &'c Authorization,
+        _domain: &'d AcmeDomain,
+        _task: Arc<WorkerTask>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>> {
+        Box::pin(async move {
+            if let Some(abort) = self.abort_handle.take() {
+                abort.abort();
+            }
+            Ok(())
+        })
+    }
+}
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;
-- 
2.20.1






More information about the pbs-devel mailing list