add acme client
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>
This commit is contained in:
		
				
					committed by
					
						 Dietmar Maurer
						Dietmar Maurer
					
				
			
			
				
	
			
			
			
						parent
						
							cb67ecaddb
						
					
				
				
					commit
					f2f526b61d
				
			
							
								
								
									
										673
									
								
								src/acme/client.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										673
									
								
								src/acme/client.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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(()) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										5
									
								
								src/acme/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/acme/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| mod client; | ||||
| pub use client::AcmeClient; | ||||
|  | ||||
| pub(crate) mod plugin; | ||||
| pub(crate) use plugin::get_acme_plugin; | ||||
							
								
								
									
										299
									
								
								src/acme/plugin.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								src/acme/plugin.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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(()) | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| @ -32,3 +32,5 @@ pub mod auth; | ||||
| pub mod rrd; | ||||
|  | ||||
| pub mod tape; | ||||
|  | ||||
| pub mod acme; | ||||
|  | ||||
		Reference in New Issue
	
	Block a user