From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [REBASED v2 backup 2/9] add acme client
Date: Mon, 3 May 2021 11:39:52 +0200 [thread overview]
Message-ID: <20210503093959.14855-3-w.bumiller@proxmox.com> (raw)
In-Reply-To: <20210503093959.14855-1-w.bumiller@proxmox.com>
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 | 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
next prev parent reply other threads:[~2021-05-03 9:40 UTC|newest]
Thread overview: 12+ messages / expand[flat|nested] mbox.gz Atom feed top
2021-05-03 9:39 [pbs-devel] [REBASED v2 backup 0/9] rebased and reordered acme implementation Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 1/9] add acme config Wolfgang Bumiller
2021-05-03 9:39 ` Wolfgang Bumiller [this message]
2021-05-04 6:10 ` [pbs-devel] [REBASED v2 backup 2/9] add acme client Dietmar Maurer
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 3/9] add node config Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 4/9] add config/acme api path Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 5/9] add node/{node}/certificates api call Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 6/9] add node/{node}/config api path Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 7/9] add acme commands to proxmox-backup-manager Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 8/9] ui: add certificate & acme view Wolfgang Bumiller
2021-05-03 9:39 ` [pbs-devel] [REBASED v2 backup 9/9] daily-update: check acme certificates Wolfgang Bumiller
2021-05-04 7:57 ` [pbs-devel] applied: [REBASED v2 backup 0/9] rebased and reordered acme implementation Dietmar Maurer
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=20210503093959.14855-3-w.bumiller@proxmox.com \
--to=w.bumiller@proxmox.com \
--cc=pbs-devel@lists.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.