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:
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 rrd;
|
||||||
|
|
||||||
pub mod tape;
|
pub mod tape;
|
||||||
|
|
||||||
|
pub mod acme;
|
||||||
|
Loading…
Reference in New Issue
Block a user