From c07c46cd82daca987aef51b4fd2c5b2c4af6dade Mon Sep 17 00:00:00 2001 From: Dietmar Maurer Date: Wed, 20 Mar 2024 13:03:53 +0100 Subject: [PATCH] acme: add async-client feature The client code is copied from propxmox-backup, without the load/safe account functionality. Signed-off-by: Dietmar Maurer --- proxmox-acme/Cargo.toml | 5 + proxmox-acme/src/async_client.rs | 587 +++++++++++++++++++++++++++++++ proxmox-acme/src/lib.rs | 3 + 3 files changed, 595 insertions(+) create mode 100644 proxmox-acme/src/async_client.rs diff --git a/proxmox-acme/Cargo.toml b/proxmox-acme/Cargo.toml index 2dee5d3d..5c6dc2bb 100644 --- a/proxmox-acme/Cargo.toml +++ b/proxmox-acme/Cargo.toml @@ -22,6 +22,10 @@ openssl = { workspace = true, optional = true } native-tls = { workspace = true, optional = true } proxmox-schema = { workspace = true, optional = true, features = [ "api-macro" ] } +proxmox-http = { workspace = true, optional = true, features = [ "client" ] } +anyhow = { workspace = true, optional = true } +bytes = { workspace = true, optional = true } +hyper = { workspace = true, optional = true } [dependencies.ureq] optional = true @@ -34,6 +38,7 @@ default = [ "impl" ] api-types = [ "dep:proxmox-schema" ] impl = [ "api-types", "dep:openssl" ] client = [ "impl", "dep:ureq", "dep:native-tls"] +async-client = [ "impl", "dep:hyper", "dep:proxmox-http", "dep:anyhow", "dep:bytes" ] [dev-dependencies] anyhow.workspace = true diff --git a/proxmox-acme/src/async_client.rs b/proxmox-acme/src/async_client.rs new file mode 100644 index 00000000..51365c7e --- /dev/null +++ b/proxmox-acme/src/async_client.rs @@ -0,0 +1,587 @@ +//! Async HTTP Client implementation for the ACME protocol. + +use anyhow::format_err; +use bytes::Bytes; +use hyper::{Body, Request}; +use serde::{Deserialize, Serialize}; + +use proxmox_http::client::Client; + +use crate::account::AccountCreator; +use crate::order::{Order, OrderData}; +use crate::Request as AcmeRequest; +use crate::{Account, Authorization, Challenge, Directory, Error, ErrorResponse}; + +/// A non-blocking Acme client using tokio/hyper. +pub struct AcmeClient { + directory_url: String, + account: Option, + directory: Option, + nonce: Option, + http_client: Client, +} + +impl AcmeClient { + /// Create a new ACME client for a given ACME directory URL. + pub fn new(directory_url: String) -> Self { + const USER_AGENT_STRING: &str = "proxmox-acme-client/1.0"; + const TCP_KEEPALIVE_TIME: u32 = 120; + + let options = proxmox_http::HttpOptions { + proxy_config: None, // fixme??? + user_agent: Some(USER_AGENT_STRING.to_string()), + tcp_keepalive: Some(TCP_KEEPALIVE_TIME), + }; + + let http_client = Client::with_options(options); + + Self { + directory_url, + account: None, + directory: None, + nonce: None, + http_client, + } + } + + /// Get the current account, if there is one. + pub fn account(&self) -> Option<&Account> { + self.account.as_ref() + } + + /// Set the account this client should use. + pub fn set_account(&mut self, account: Account) { + self.account = Some(account); + } + + /// Convenience method to create a new account with a list of ACME compatible contact strings + /// (eg. `mailto:someone@example.com`). + /// + /// Please remember to persist the returned `Account` structure somewhere to not lose access to + /// the account! + /// + /// If an RSA key size is provided, an RSA key will be generated. Otherwise an EC key using the + /// P-256 curve will be generated. + pub async fn new_account<'a>( + &'a mut self, + tos_agreed: bool, + contact: Vec, + rsa_bits: Option, + eab_creds: Option<(String, String)>, + ) -> Result<&'a Account, anyhow::Error> { + let mut account = Account::creator() + .set_contacts(contact) + .agree_to_tos(tos_agreed); + + if let Some((eab_kid, eab_hmac_key)) = eab_creds { + account = account.set_eab_credentials(eab_kid, eab_hmac_key)?; + } + + let account = if let Some(bits) = rsa_bits { + account.generate_rsa_key(bits)? + } else { + account.generate_ec_key()? + }; + + let _ = self.register_account(account).await?; + + // 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()) + } + + /// Shortcut to `account().ok_or_else(...).key_authorization()`. + pub fn key_authorization(&self, token: &str) -> Result { + Ok(Self::need_account(&self.account)?.key_authorization(token)?) + } + + /// Shortcut to `account().ok_or_else(...).dns_01_txt_value()`. + /// the key authorization value. + pub fn dns_01_txt_value(&self, token: &str) -> Result { + Ok(Self::need_account(&self.account)?.dns_01_txt_value(token)?) + } + + async fn register_account( + &mut self, + account: AccountCreator, + ) -> Result<&Account, anyhow::Error> { + let mut retry = retry(); + let mut response = loop { + retry.tick()?; + + let (directory, nonce) = Self::get_dir_nonce( + &mut self.http_client, + &self.directory_url, + &mut self.directory, + &mut self.nonce, + ) + .await?; + let request = account.request(directory, nonce)?; + match self.run_request(request).await { + Ok(response) => break response, + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + }; + + let account = account.response(response.location_required()?, &response.body)?; + + self.account = Some(account); + Ok(self.account.as_ref().unwrap()) + } + + /// Update account data. + /// + /// Low-level version: we allow arbitrary data to be passed to the remote here, it's up to the + /// user to know what to do for now. + pub async fn update_account( + &mut self, + data: &T, + ) -> Result<&Account, anyhow::Error> { + let account = Self::need_account(&self.account)?; + + let mut retry = retry(); + let response = loop { + retry.tick()?; + + let (_directory, nonce) = Self::get_dir_nonce( + &mut self.http_client, + &self.directory_url, + &mut self.directory, + &mut self.nonce, + ) + .await?; + + let request = account.post_request(&account.location, nonce, data)?; + match Self::execute(&mut self.http_client, request, &mut self.nonce).await { + Ok(response) => break response, + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + }; + + // unwrap: we've been keeping an immutable reference to it from the top of the method + let _ = account; + self.account.as_mut().unwrap().data = response.json()?; + // fixme: self.save()?; + Ok(self.account.as_ref().unwrap()) + } + + /// Method to create a new order for a set of domains. + /// + /// Please remember to persist the order somewhere (ideally along with the account data) in + /// order to finish & query it later on. + pub async fn new_order(&mut self, domains: I) -> Result + where + I: IntoIterator, + { + let account = Self::need_account(&self.account)?; + + let order = domains + .into_iter() + .fold(OrderData::new(), |order, domain| order.domain(domain)); + + let mut retry = retry(); + loop { + retry.tick()?; + + let (directory, nonce) = Self::get_dir_nonce( + &mut self.http_client, + &self.directory_url, + &mut self.directory, + &mut self.nonce, + ) + .await?; + + let mut new_order = account.new_order(&order, directory, nonce)?; + let mut response = match Self::execute( + &mut self.http_client, + new_order.request.take().unwrap(), + &mut self.nonce, + ) + .await + { + Ok(response) => response, + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + }; + + return Ok( + new_order.response(response.location_required()?, response.bytes().as_ref())? + ); + } + } + + /// Low level "POST-as-GET" request. + async fn post_as_get(&mut self, url: &str) -> Result { + let account = Self::need_account(&self.account)?; + + let mut retry = retry(); + loop { + retry.tick()?; + + let (_directory, nonce) = Self::get_dir_nonce( + &mut self.http_client, + &self.directory_url, + &mut self.directory, + &mut self.nonce, + ) + .await?; + + let request = account.get_request(url, nonce)?; + match Self::execute(&mut self.http_client, request, &mut self.nonce).await { + Ok(response) => return Ok(response), + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + } + } + + /// Low level POST request. + async fn post( + &mut self, + url: &str, + data: &T, + ) -> Result { + let account = Self::need_account(&self.account)?; + + let mut retry = retry(); + loop { + retry.tick()?; + + let (_directory, nonce) = Self::get_dir_nonce( + &mut self.http_client, + &self.directory_url, + &mut self.directory, + &mut self.nonce, + ) + .await?; + + let request = account.post_request(url, nonce, data)?; + match Self::execute(&mut self.http_client, request, &mut self.nonce).await { + Ok(response) => return Ok(response), + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + } + } + + /// Request challenge validation. Afterwards, the challenge should be polled. + pub async fn request_challenge_validation( + &mut self, + url: &str, + ) -> Result { + Ok(self + .post(url, &serde_json::Value::Object(Default::default())) + .await? + .json()?) + } + + /// Assuming the provided URL is an 'Authorization' URL, get and deserialize it. + pub async fn get_authorization(&mut self, url: &str) -> Result { + Ok(self.post_as_get(url).await?.json()?) + } + + /// Assuming the provided URL is an 'Order' URL, get and deserialize it. + pub async fn get_order(&mut self, url: &str) -> Result { + Ok(self.post_as_get(url).await?.json()?) + } + + /// Finalize an Order via its `finalize` URL property and the DER encoded CSR. + pub async fn finalize(&mut self, url: &str, csr: &[u8]) -> Result<(), anyhow::Error> { + let csr = base64::encode_config(csr, base64::URL_SAFE_NO_PAD); + let data = serde_json::json!({ "csr": csr }); + self.post(url, &data).await?; + Ok(()) + } + + /// Download a certificate via its 'certificate' URL property. + /// + /// The certificate will be a PEM certificate chain. + pub async fn get_certificate(&mut self, url: &str) -> Result { + Ok(self.post_as_get(url).await?.body) + } + + /// Revoke an existing certificate (PEM or DER formatted). + pub async fn revoke_certificate( + &mut self, + certificate: &[u8], + reason: Option, + ) -> Result<(), anyhow::Error> { + // TODO: This can also work without an account. + let account = Self::need_account(&self.account)?; + + let revocation = account.revoke_certificate(certificate, reason)?; + + let mut retry = retry(); + loop { + retry.tick()?; + + let (directory, nonce) = Self::get_dir_nonce( + &mut self.http_client, + &self.directory_url, + &mut self.directory, + &mut self.nonce, + ) + .await?; + + let request = revocation.request(directory, nonce)?; + match Self::execute(&mut self.http_client, request, &mut self.nonce).await { + Ok(_response) => return Ok(()), + Err(err) if err.is_bad_nonce() => continue, + Err(err) => return Err(err.into()), + } + } + } + + fn need_account(account: &Option) -> Result<&Account, anyhow::Error> { + account + .as_ref() + .ok_or_else(|| format_err!("cannot use client without an account")) + } + + /// Get the directory URL without querying the `Directory` structure. + /// + /// The difference to [`directory`](Client::directory()) is that this does not + /// attempt to fetch the directory data from the ACME server. + pub fn directory_url(&self) -> &str { + &self.directory_url + } +} + +struct AcmeResponse { + body: Bytes, + location: Option, + got_nonce: bool, +} + +impl AcmeResponse { + /// Convenience helper to assert that a location header was part of the response. + fn location_required(&mut self) -> Result { + self.location + .take() + .ok_or_else(|| format_err!("missing Location header")) + } + + /// Convenience shortcut to perform json deserialization of the returned body. + fn json Deserialize<'a>>(&self) -> Result { + Ok(serde_json::from_slice(&self.body)?) + } + + /// Convenience shortcut to get the body as bytes. + fn bytes(&self) -> &[u8] { + &self.body + } +} + +impl AcmeClient { + /// Non-self-borrowing run_request version for borrow workarounds. + async fn execute( + http_client: &mut Client, + request: AcmeRequest, + nonce: &mut Option, + ) -> Result { + let req_builder = Request::builder().method(request.method).uri(&request.url); + + let http_request = if !request.content_type.is_empty() { + req_builder + .header("Content-Type", request.content_type) + .header("Content-Length", request.body.len()) + .body(request.body.into()) + } else { + req_builder.body(Body::empty()) + } + .map_err(|err| Error::Custom(format!("failed to create http request: {}", err)))?; + + let response = http_client + .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(crate::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 == crate::error::BAD_NONCE { + if !got_nonce { + return Err(Error::InvalidApi( + "badNonce without a new Replay-Nonce header".to_string(), + )); + } + return Err(Error::BadNonce); + } + + Err(Error::Api(error)) + } + + /// Low-level API to run an n API request. This automatically updates the current nonce! + async fn run_request(&mut self, request: AcmeRequest) -> Result { + Self::execute(&mut self.http_client, request, &mut self.nonce).await + } + + /// Get the Directory information. + pub 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 Client, + directory_url: &str, + directory: &'a mut Option, + nonce: &'b mut Option, + ) -> Result<(&'a Directory, Option<&'b str>), Error> { + if let Some(d) = directory { + return Ok((d, nonce.as_deref())); + } + + let response = Self::execute( + http_client, + AcmeRequest { + url: directory_url.to_string(), + method: "GET", + content_type: "", + body: String::new(), + expected: 200, + }, + nonce, + ) + .await?; + + *directory = Some(Directory::from_parts( + directory_url.to_string(), + response.json()?, + )); + + Ok((directory.as_ref().unwrap(), nonce.as_deref())) + } + + /// Like `get_directory`, but if the directory provides no nonce, also performs a `HEAD` + /// request on the new nonce URL. + async fn get_dir_nonce<'a, 'b>( + http_client: &mut Client, + directory_url: &str, + directory: &'a mut Option, + nonce: &'b mut Option, + ) -> Result<(&'a Directory, &'b str), Error> { + // this let construct is a lifetime workaround: + let _ = Self::get_directory(http_client, directory_url, directory, nonce).await?; + let dir = directory.as_ref().unwrap(); // the above fails if it couldn't fill this option + if nonce.is_none() { + // this is also a lifetime issue... + let _ = Self::get_nonce(http_client, nonce, dir.new_nonce_url()).await?; + }; + Ok((dir, nonce.as_deref().unwrap())) + } + + /// Convenience method to get the ToS URL from the contained `Directory`. + /// + /// This requires mutable self as the directory information may be lazily loaded, which can + /// fail. + pub async fn terms_of_service_url(&mut self) -> Result, Error> { + Ok(self.directory().await?.terms_of_service_url()) + } + + async fn get_nonce<'a>( + http_client: &mut Client, + nonce: &'a mut Option, + new_nonce_url: &str, + ) -> Result<&'a str, Error> { + let response = Self::execute( + http_client, + AcmeRequest { + url: new_nonce_url.to_owned(), + method: "HEAD", + content_type: "", + body: String::new(), + expected: 200, + }, + nonce, + ) + .await?; + + if !response.got_nonce { + return Err(Error::InvalidApi( + "no new nonce received from new nonce URL".to_string(), + )); + } + + nonce + .as_deref() + .ok_or_else(|| Error::Client("failed to update nonce".to_string())) + } +} + +/// bad nonce retry count helper +struct Retry(usize); + +const fn retry() -> Retry { + Retry(0) +} + +impl Retry { + fn tick(&mut self) -> Result<(), Error> { + if self.0 >= 3 { + Err(Error::Client("kept getting a badNonce error!".to_string())) + } else { + self.0 += 1; + Ok(()) + } + } +} diff --git a/proxmox-acme/src/lib.rs b/proxmox-acme/src/lib.rs index cf60168a..80e5ab14 100644 --- a/proxmox-acme/src/lib.rs +++ b/proxmox-acme/src/lib.rs @@ -89,3 +89,6 @@ pub const LOCATION: &str = "Location"; pub mod client; #[cfg(feature = "client")] pub use client::Client; + +#[cfg(feature = "async-client")] +pub mod async_client;