Skip to content

Instantly share code, notes, and snippets.

@xeioex
Created September 3, 2025 01:23
Show Gist options
  • Save xeioex/9a5f7571c0b889041019b05822520a49 to your computer and use it in GitHub Desktop.
Save xeioex/9a5f7571c0b889041019b05822520a49 to your computer and use it in GitHub Desktop.
// Copyright (c) F5, Inc.
//
// This source code is licensed under the Apache License, Version 2.0 license found in the
// LICENSE file in the root directory of this source tree.
use core::cell::RefCell;
use core::ptr::NonNull;
use core::time::Duration;
use std::collections::VecDeque;
use std::string::{String, ToString};
use anyhow::{anyhow, Result};
use bytes::Bytes;
use http::Uri;
use ngx::allocator::{Allocator, Box};
use ngx::async_::sleep;
use ngx::collections::Vec;
use ngx::ngx_log_debug;
use openssl::pkey::{PKey, PKeyRef, Private};
use openssl::x509::{self, extension as x509_ext, X509Req};
use self::account_key::AccountKey;
use self::types::{AuthorizationStatus, ChallengeKind, ChallengeStatus, OrderStatus};
use crate::conf::identifier::Identifier;
use crate::conf::issuer::Issuer;
use crate::conf::order::CertificateOrder;
use crate::net::http::HttpClient;
use crate::time::Time;
pub mod account_key;
pub mod solvers;
pub mod types;
const DEFAULT_RETRY_INTERVAL: Duration = Duration::from_secs(1);
const MAX_RETRY_INTERVAL: Duration = Duration::from_secs(8);
static REPLAY_NONCE: http::HeaderName = http::HeaderName::from_static("replay-nonce");
pub struct NewCertificateOutput {
pub chain: Bytes,
pub pkey: PKey<Private>,
}
pub struct AuthorizationContext<'a> {
pub thumbprint: &'a [u8],
}
pub struct AcmeClient<'a, Http>
where
Http: HttpClient,
{
issuer: &'a Issuer,
http: Http,
log: NonNull<nginx_sys::ngx_log_t>,
key: AccountKey,
account: Option<String>,
nonce: NoncePool,
directory: types::Directory,
solvers: Vec<Box<dyn solvers::ChallengeSolver + Send + 'a>>,
authorization_timeout: Duration,
finalize_timeout: Duration,
network_error_retries: usize,
}
#[derive(Default)]
pub struct NoncePool(RefCell<VecDeque<String>>);
impl NoncePool {
pub fn get(&self) -> Option<String> {
self.0.borrow_mut().pop_front()
}
pub fn add(&self, nonce: String) {
self.0.borrow_mut().push_back(nonce);
}
pub fn add_from_response<T>(&self, res: &http::Response<T>) {
if let Some(nonce) = try_get_header(res.headers(), &REPLAY_NONCE) {
self.add(nonce.to_string());
}
}
}
#[inline]
fn try_get_header<K: http::header::AsHeaderName>(
headers: &http::HeaderMap,
key: K,
) -> Option<&str> {
headers.get(key).and_then(|x| x.to_str().ok())
}
impl<'a, Http> AcmeClient<'a, Http>
where
Http: HttpClient,
{
pub fn new(http: Http, issuer: &'a Issuer, log: NonNull<nginx_sys::ngx_log_t>) -> Result<Self> {
let key = AccountKey::try_from(
issuer
.pkey
.as_ref()
.expect("checked during configuration load")
.as_ref(),
)?;
Ok(Self {
issuer,
http,
log,
key,
account: None,
nonce: Default::default(),
directory: Default::default(),
solvers: Vec::new(),
authorization_timeout: Duration::from_secs(60),
finalize_timeout: Duration::from_secs(60),
network_error_retries: 3,
})
}
pub fn add_solver(&mut self, s: impl solvers::ChallengeSolver + Send + 'a) {
self.solvers.push(ngx::allocator::unsize_box!(Box::new(s)))
}
pub fn find_solver_for(
&self,
kind: &ChallengeKind,
) -> Option<&Box<dyn solvers::ChallengeSolver + Send + 'a>> {
self.solvers.iter().find(|x| x.supports(kind))
}
pub fn is_supported_challenge(&self, kind: &ChallengeKind) -> bool {
self.solvers.iter().any(|s| s.supports(kind))
}
async fn get_directory(&mut self) -> Result<types::Directory> {
let res = self.get(&self.issuer.uri).await?;
let directory = serde_json::from_slice(res.body())?;
Ok(directory)
}
async fn get_nonce(&self) -> Result<String> {
let res = self.get(&self.directory.new_nonce).await?;
try_get_header(res.headers(), &REPLAY_NONCE)
.ok_or(anyhow!("no nonce in response headers"))
.map(String::from)
}
pub async fn get(&self, url: &Uri) -> Result<http::Response<Bytes>> {
let req = http::Request::builder()
.uri(url)
.method(http::Method::GET)
.header(http::header::CONTENT_LENGTH, 0)
.body(String::new())?;
Ok(self.http.request(req).await?)
}
pub async fn post<P: AsRef<[u8]>>(
&self,
url: &Uri,
payload: P,
) -> Result<http::Response<Bytes>> {
let mut nonce = if let Some(nonce) = self.nonce.get() {
nonce
} else {
self.get_nonce().await?
};
let mut tries = core::iter::repeat(DEFAULT_RETRY_INTERVAL).take(self.network_error_retries);
ngx_log_debug!(self.log.as_ptr(), "sending request to {url:?}");
let res = loop {
let body = crate::jws::sign_jws(
&self.key,
self.account.as_deref(),
&url.to_string(),
Some(&nonce),
payload.as_ref(),
)?
.to_string();
let req = http::Request::builder()
.uri(url)
.method(http::Method::POST)
.header(http::header::CONTENT_LENGTH, body.len())
.header(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/jose+json"),
)
.body(body)?;
let res = match self.http.request(req).await {
Ok(res) => res,
Err(err) => {
// TODO: limit retries to connection errors
if let Some(tm) = tries.next() {
sleep(tm).await;
ngx_log_debug!(self.log.as_ptr(), "retrying failed request ({err})");
continue;
} else {
return Err(err.into());
}
}
};
if res.status().is_success() {
break res;
}
// 8555.6.5, when retrying in response to a "badNonce" error, the client MUST use
// the nonce provided in the error response.
nonce = try_get_header(res.headers(), &REPLAY_NONCE)
.ok_or(anyhow!("no nonce in response"))?
.to_string();
let err: types::Problem = serde_json::from_slice(res.body())?;
let retriable = matches!(
err.kind,
types::ErrorKind::BadNonce | types::ErrorKind::RateLimited
);
if retriable && wait_for_retry(&res, &mut tries).await {
ngx_log_debug!(self.log.as_ptr(), "retrying failed request ({err})");
continue;
}
self.nonce.add(nonce);
return Err(err.into());
};
self.nonce.add_from_response(&res);
Ok(res)
}
pub async fn new_account(&mut self) -> Result<types::Account> {
self.directory = self.get_directory().await?;
if self.directory.meta.external_account_required == Some(true)
&& self.issuer.eab_key.is_none()
{
return Err(anyhow!("external account key required"));
}
let external_account_binding = self
.issuer
.eab_key
.as_ref()
.map(|x| -> Result<_> {
let key = crate::jws::ShaWithHmacKey::new(&x.key, 256);
let payload = serde_json::to_vec(&self.key)?;
let message = crate::jws::sign_jws(
&key,
Some(x.kid),
&self.directory.new_account.to_string(),
None,
&payload,
)?;
Ok(message)
})
.transpose()?;
let payload = types::AccountRequest {
terms_of_service_agreed: self.issuer.accept_tos,
contact: &self.issuer.contacts,
external_account_binding,
..Default::default()
};
let payload = serde_json::to_string(&payload)?;
let res = self.post(&self.directory.new_account, payload).await?;
let key_id = res
.headers()
.get("location")
.ok_or(anyhow!("account URL unavailable"))?
.to_str()?
.to_string();
self.account = Some(key_id);
self.nonce.add_from_response(&res);
Ok(serde_json::from_slice(res.body())?)
}
pub fn is_ready(&self) -> bool {
self.account.is_some()
}
pub async fn new_certificate<A>(
&mut self,
req: &CertificateOrder<&str, A>,
) -> Result<NewCertificateOutput>
where
A: Allocator,
{
ngx_log_debug!(
self.log.as_ptr(),
"new certificate request: {:?}",
req.identifiers
);
let identifiers: Vec<Identifier<&str>> =
req.identifiers.iter().map(|x| x.as_ref()).collect();
let payload = types::OrderRequest {
identifiers: &identifiers,
not_before: None,
not_after: None,
};
let payload = serde_json::to_string(&payload)?;
let res = self.post(&self.directory.new_order, payload).await?;
let order_url = res
.headers()
.get("location")
.and_then(|x| x.to_str().ok())
.ok_or(anyhow!("no order URL"))?;
let order_url = Uri::try_from(order_url)?;
let order: types::Order = serde_json::from_slice(res.body())?;
let mut authorizations: Vec<(http::Uri, types::Authorization)> = Vec::new();
for auth_url in order.authorizations {
let res = self.post(&auth_url, b"").await?;
let mut authorization: types::Authorization = serde_json::from_slice(res.body())?;
authorization
.challenges
.retain(|x| self.is_supported_challenge(&x.kind));
if authorization.challenges.is_empty() {
anyhow::bail!("no supported challenge for {:?}", authorization.identifier)
}
match authorization.status {
types::AuthorizationStatus::Pending => {
authorizations.push((auth_url, authorization))
}
types::AuthorizationStatus::Valid => {
ngx_log_debug!(
self.log.as_ptr(),
"authorization {:?}: identifier {:?} already validated",
auth_url,
authorization.identifier
);
}
status => anyhow::bail!(
"unexpected authorization status for {:?}: {:?}",
authorization.identifier,
status
),
}
}
let pkey = req.key.generate()?;
let order = AuthorizationContext {
thumbprint: self.key.thumbprint(),
};
for (url, authorization) in authorizations {
self.do_authorization(&order, url, authorization).await?;
}
let mut res = self.post(&order_url, b"").await?;
let mut order: types::Order = serde_json::from_slice(res.body())?;
if order.status != OrderStatus::Ready {
anyhow::bail!("not ready");
}
let csr = make_certificate_request(&order.identifiers, &pkey).and_then(|x| x.to_der())?;
let payload = std::format!(r#"{{"csr":"{}"}}"#, crate::jws::base64url(csr));
match self.post(&order.finalize, payload).await {
Ok(x) => {
drop(order);
res = x;
order = serde_json::from_slice(res.body())?;
}
Err(err) => {
if !err.to_string().contains("orderNotReady") {
return Err(err);
}
order.status = OrderStatus::Processing
}
};
let mut tries = backoff(MAX_RETRY_INTERVAL, self.finalize_timeout);
while order.status == OrderStatus::Processing && wait_for_retry(&res, &mut tries).await {
drop(order);
res = self.post(&order_url, b"").await?;
order = serde_json::from_slice(res.body())?;
}
let certificate = order.certificate.ok_or(anyhow!("certificate not ready"))?;
let chain = self.post(&certificate, b"").await?.into_body();
Ok(NewCertificateOutput { chain, pkey })
}
async fn do_authorization(
&self,
order: &AuthorizationContext<'_>,
url: http::Uri,
authorization: types::Authorization,
) -> Result<()> {
let identifier = authorization.identifier.as_ref();
// Find and set up first supported challenge.
let (challenge, solver) = authorization
.challenges
.iter()
.find_map(|x| {
let solver = self.find_solver_for(&x.kind)?;
Some((x, solver))
})
.ok_or(anyhow!("no supported challenge for {identifier:?}"))?;
solver.register(order, &identifier, challenge)?;
scopeguard::defer! {
let _ = solver.unregister(&identifier, challenge);
};
let res = self.post(&challenge.url, b"{}").await?;
let result: types::Challenge = serde_json::from_slice(res.body())?;
if !matches!(
result.status,
ChallengeStatus::Pending | ChallengeStatus::Processing | ChallengeStatus::Valid
) {
return Err(anyhow!("unexpected challenge status {:?}", result.status));
}
let mut tries = backoff(MAX_RETRY_INTERVAL, self.authorization_timeout);
wait_for_retry(&res, &mut tries).await;
let result = loop {
let res = self.post(&url, b"").await?;
let result: types::Authorization = serde_json::from_slice(res.body())?;
if result.status != AuthorizationStatus::Pending
|| !wait_for_retry(&res, &mut tries).await
{
break result;
}
};
ngx_log_debug!(
self.log.as_ptr(),
"authorization status for {:?}: {:?}",
authorization.identifier,
result.status
);
if result.status != AuthorizationStatus::Valid {
return Err(anyhow!("authorization failed ({:?})", result.status));
}
Ok(())
}
}
pub fn make_certificate_request(
identifiers: &[Identifier<&str>],
pkey: &PKeyRef<Private>,
) -> Result<X509Req, openssl::error::ErrorStack> {
let mut req = X509Req::builder()?;
let mut x509_name = x509::X509NameBuilder::new()?;
x509_name.append_entry_by_text("CN", identifiers[0].value())?;
let x509_name = x509_name.build();
req.set_subject_name(&x509_name)?;
let mut extensions = openssl::stack::Stack::new()?;
let mut subject_alt_name = x509_ext::SubjectAlternativeName::new();
for identifier in identifiers {
match identifier {
Identifier::Dns(name) => {
subject_alt_name.dns(name);
}
Identifier::Ip(addr) => {
subject_alt_name.ip(addr);
}
_ => (),
};
}
let subject_alt_name = subject_alt_name.build(&req.x509v3_context(None))?;
extensions.push(subject_alt_name)?;
req.add_extensions(&extensions)?;
req.set_pubkey(pkey)?;
req.sign(pkey, openssl::hash::MessageDigest::sha256())?;
Ok(req.build())
}
/// Waits until the next retry attempt is allowed.
async fn wait_for_retry<B>(
res: &http::Response<B>,
policy: &mut impl Iterator<Item = Duration>,
) -> bool {
let Some(interval) = policy.next() else {
return false;
};
let retry_after = res
.headers()
.get(http::header::RETRY_AFTER)
.and_then(parse_retry_after)
.unwrap_or(interval);
sleep(retry_after).await;
true
}
/// Generate increasing intervals saturated at `max` until `timeout` has passed.
fn backoff(max: Duration, timeout: Duration) -> impl Iterator<Item = Duration> {
let first = (Duration::ZERO, Duration::from_secs(1));
let stop = Time::now() + timeout;
core::iter::successors(Some(first), move |prev: &(Duration, Duration)| {
if Time::now() >= stop {
return None;
}
Some((prev.1, prev.0.saturating_add(prev.1)))
})
.map(move |(_, x)| x.min(max))
}
fn parse_retry_after(val: &http::HeaderValue) -> Option<Duration> {
let val = val.to_str().ok()?;
// Retry-After: <http-date>
if let Ok(time) = Time::parse(val) {
return Some(time - Time::now());
}
// Retry-After: <delay-seconds>
val.parse().map(Duration::from_secs).ok()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment