Skip to content

polling for challenge ready and certs with timeout and retry-after #104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ aws-lc-rs = { version = "1.8.0", optional = true }
base64 = "0.22"
bytes = "1"
http = "1"
httpdate = "1.0"
http-body = "1"
http-body-util = "0.1.2"
hyper = { version = "1.3.1", features = ["client", "http1", "http2"], optional = true }
Expand Down
8 changes: 6 additions & 2 deletions examples/provision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use tracing::info;

use instant_acme::{
Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder,
OrderStatus,
OrderStatus, PollingStrategy,
};

#[tokio::main]
Expand Down Expand Up @@ -81,7 +81,11 @@ async fn main() -> anyhow::Result<()> {

// Exponentially back off until the order becomes ready or invalid.

let status = order.poll(5, Duration::from_millis(250)).await?;
let polling_strategy = PollingStrategy::ExponentialBackoff {
tries: 5,
delay: Duration::from_millis(250),
};
let status = order.poll(polling_strategy).await?;
if status != OrderStatus::Ready {
return Err(anyhow::anyhow!("unexpected order status: {status:?}"));
}
Expand Down
8 changes: 7 additions & 1 deletion src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ use crate::types::{
};
#[cfg(feature = "time")]
use crate::types::{CertificateIdentifier, RenewalInfo};
use crate::{BytesResponse, Client, Error, HttpClient, crypto, nonce_from_response};
use crate::{
BytesResponse, Client, Error, HttpClient, crypto, nonce_from_response,
retry_after_from_response,
};

/// An ACME account as described in RFC 8555 (section 7.1.2)
///
Expand Down Expand Up @@ -185,6 +188,7 @@ impl Account {
.await?;

let nonce = nonce_from_response(&rsp);
let retry_after = retry_after_from_response(&rsp);
let order_url = rsp
.parts
.headers
Expand Down Expand Up @@ -214,6 +218,7 @@ impl Account {
Ok(Order {
account: self.inner.clone(),
nonce,
retry_after,
state,
url: order_url.ok_or("no order URL found")?,
})
Expand All @@ -229,6 +234,7 @@ impl Account {
Ok(Order {
account: self.inner.clone(),
nonce: nonce_from_response(&rsp),
retry_after: retry_after_from_response(&rsp),
// Order of fields matters! We return errors from Problem::check
// before emitting an error if there is no order url. Or the
// simple no url error hides the causing error in `Problem::check`.
Expand Down
21 changes: 21 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::error::Error as StdError;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;

use async_trait::async_trait;
use bytes::Bytes;
Expand All @@ -28,6 +29,7 @@ pub use account::{Account, ExternalAccountKey};
mod order;
pub use order::{
AuthorizationHandle, Authorizations, ChallengeHandle, Identifiers, KeyAuthorization, Order,
PollingStrategy,
};
mod types;
#[cfg(feature = "time")]
Expand Down Expand Up @@ -158,6 +160,24 @@ fn nonce_from_response(rsp: &BytesResponse) -> Option<String> {
.and_then(|hv| String::from_utf8(hv.as_ref().to_vec()).ok())
}

fn retry_after_from_response(rsp: &BytesResponse) -> Option<Duration> {
rsp.parts
.headers
.get(RETRY_AFTER)
.and_then(|header_value| String::from_utf8(header_value.as_ref().to_vec()).ok())
.and_then(|retry_after_header| {
if let Ok(retry_after_datetime) = httpdate::parse_http_date(&retry_after_header) {
retry_after_datetime
.duration_since(std::time::SystemTime::now())
.ok()
} else if let Ok(retry_after_seconds) = retry_after_header.parse::<u64>() {
Some(Duration::from_secs(retry_after_seconds))
} else {
None
}
})
}

#[cfg(feature = "hyper-rustls")]
struct DefaultClient(HyperClient<hyper_rustls::HttpsConnector<HttpConnector>, Full<Bytes>>);

Expand Down Expand Up @@ -320,6 +340,7 @@ mod crypto {

const JOSE_JSON: &str = "application/jose+json";
const REPLAY_NONCE: &str = "Replay-Nonce";
const RETRY_AFTER: &str = "Retry-After";

#[cfg(test)]
mod tests {
Expand Down
149 changes: 127 additions & 22 deletions src/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::types::{
Authorization, AuthorizationState, AuthorizationStatus, AuthorizedIdentifier, Challenge,
ChallengeType, Empty, FinalizeRequest, OrderState, OrderStatus, Problem,
};
use crate::{Error, Key, crypto, nonce_from_response};
use crate::{Error, Key, crypto, nonce_from_response, retry_after_from_response};

/// An ACME order as described in RFC 8555 (section 7.1.3)
///
Expand All @@ -26,10 +26,39 @@ use crate::{Error, Key, crypto, nonce_from_response};
pub struct Order {
pub(crate) account: Arc<AccountInner>,
pub(crate) nonce: Option<String>,
pub(crate) retry_after: Option<Duration>,
pub(crate) url: String,
pub(crate) state: OrderState,
}

/// Definition of polling strategy to wait for an `Order` status
#[derive(Debug, Clone)]
pub enum PollingStrategy {
/// Exponential backoff
/// Retry polling the `Order` status from the ACME server for `tries` times,
/// waiting `delay` before the first attempt and increasing the delay
/// by a factor of 2 for each subsequent attempt.
/// This strategy is good to achieve a low latency for DNS-01 challenge.
/// (Empirically, we've had good results with 5 tries and an initial delay of 250ms.)
ExponentialBackoff {
/// Number of retries
tries: usize,
/// Initial delay that is doubled for subsequent attempts.
/// Note that for a large `tries` value the delays become exponentially long.
delay: Duration,
},
/// Total timeout with rate limiting
/// Refresh the order state from the ACME server with a timeout `total_timeout`.
/// If a server sets the `Retry-After` header for rate-limiting access to the CA, the
/// provided value is used for polling as long as it fits into the boxed timeout window.
/// If the ACME server does not provide `Retry-After` polling is repeated every 3 seconds.
/// Use this strategy if you want to achieve uniform distributed status polling.
TotalTimeoutWithRateLimiting {
/// A total timeout that is used for polling attempts
total_timeout: Duration,
},
}

impl Order {
/// Retrieve the authorizations for this order
///
Expand Down Expand Up @@ -113,6 +142,7 @@ impl Order {
.post(None::<&Empty>, self.nonce.take(), &self.url)
.await?;
self.nonce = nonce_from_response(&rsp);
self.retry_after = retry_after_from_response(&rsp);
self.state = Problem::check::<OrderState>(rsp).await?;
}

Expand Down Expand Up @@ -157,29 +187,18 @@ impl Order {
}
}

/// Poll the order with exponential backoff until in a final state
///
/// Refresh the order state from the server for `tries` times, waiting `delay` before the
/// first attempt and increasing the delay by a factor of 2 for each subsequent attempt.
/// Poll the order until in a final state
///
/// Yields the [`OrderStatus`] immediately if `Ready` or `Invalid`, or after `tries` attempts.
/// Provide a polling strategy `polling_strategy` to adjust how frequently the ACME server
/// is polled until a final `OrderStatus` state (`Ready` or `Invalid`) is reached.
///
/// (Empirically, we've had good results with 5 tries and an initial delay of 250ms.)
pub async fn poll(&mut self, mut tries: u8, mut delay: Duration) -> Result<OrderStatus, Error> {
loop {
sleep(delay).await;
let state = self.refresh().await?;
if let Some(error) = &state.error {
return Err(Error::Api(error.clone()));
} else if let OrderStatus::Ready | OrderStatus::Invalid = state.status {
return Ok(state.status);
} else if tries <= 1 {
return Ok(state.status);
}

delay *= 2;
tries -= 1;
}
/// Yields the [`OrderStatus`] immediately if `Ready` or `Invalid`.
pub async fn poll(&mut self, polling_strategy: PollingStrategy) -> Result<OrderStatus, Error> {
self.wait_status_internal(
polling_strategy,
&[OrderStatus::Ready, OrderStatus::Invalid],
)
.await
}

/// Refresh the current state of the order
Expand All @@ -190,10 +209,96 @@ impl Order {
.await?;

self.nonce = nonce_from_response(&rsp);
self.retry_after = retry_after_from_response(&rsp);
self.state = Problem::check::<OrderState>(rsp).await?;
Ok(&self.state)
}

/// Wait for certificate with timeout
///
/// Provide a polling strategy `polling_strategy` to adjust how frequently the ACME server
/// is polled to yield a certificate.
///
/// Yields the certificate for the order.
pub async fn wait_certificate(
&mut self,
polling_strategy: PollingStrategy,
) -> Result<Option<String>, Error> {
let _ = self
.wait_status_internal(
polling_strategy,
&[OrderStatus::Valid, OrderStatus::Invalid],
)
.await?;
self.certificate().await
}

/// Wait by timeout until a defined order status is reached
///
/// This method periodically polls the Order status. It yields the status immediately
/// if it is contained in the `order_states` array. Waiting ends if the `timeout` is
/// reached.
///
/// Polling the status is optimized and respects a `Retry-After` header if the ACME server
/// is providing this HTTP header for rate-limiting access. The default polling interval is
/// three seconds if no such HTTP header is there.
async fn wait_status_internal(
&mut self,
polling_strategy: PollingStrategy,
order_states: &[OrderStatus],
) -> Result<OrderStatus, Error> {
let started = std::time::Instant::now();

// Yields the order status immediately if contained in order_states.
let mut next_retry = match polling_strategy {
PollingStrategy::TotalTimeoutWithRateLimiting { .. } => Duration::from_secs(0),
PollingStrategy::ExponentialBackoff { delay, .. } => delay,
};

// This is the same retry fallback as ACME4J
let fallback_retry = Duration::from_secs(3);

loop {
sleep(next_retry).await;

self.refresh().await?;

if let Some(error) = &self.state.error {
return Err(Error::Api(error.clone()));
} else if order_states.contains(&self.state.status) {
return Ok(self.state.status);
};

match polling_strategy {
PollingStrategy::ExponentialBackoff {
mut tries,
delay: _,
} => {
tries -= 1;
if tries < 1 {
break;
}
next_retry *= 2;
}
PollingStrategy::TotalTimeoutWithRateLimiting { total_timeout } => {
next_retry = self.retry_after.take().unwrap_or(fallback_retry);
let now = std::time::Instant::now();

if now > started + total_timeout {
break;
}

// Adjustment of the last retry to not exceed the total timeout.
if now + next_retry > started + total_timeout {
next_retry = started + total_timeout - now;
}
}
}
}

Ok(self.state.status)
}

/// Extract the URL and last known state from the `Order`
pub fn into_parts(self) -> (String, OrderState) {
(self.url, self.state)
Expand Down
31 changes: 29 additions & 2 deletions tests/pebble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::TokioExecutor;
use instant_acme::{
Account, AuthorizationStatus, ChallengeHandle, ChallengeType, Error, ExternalAccountKey,
Identifier, KeyAuthorization, NewAccount, NewOrder, Order, OrderStatus,
Identifier, KeyAuthorization, NewAccount, NewOrder, Order, OrderStatus, PollingStrategy,
};
#[cfg(all(feature = "time", feature = "x509-parser"))]
use instant_acme::{CertificateIdentifier, RevocationRequest};
Expand Down Expand Up @@ -63,6 +63,26 @@ async fn http_01() -> Result<(), Box<dyn StdError>> {
.map(|_| ())
}

#[tokio::test]
#[ignore]
async fn poll_with_timeout() -> Result<(), Box<dyn StdError>> {
try_tracing_init();

let mut identifiers = dns_identifiers(["http01.example.com"]);
identifiers.push(Identifier::Ip(IpAddr::from_str("::1").unwrap()));
identifiers.push(Identifier::Ip(IpAddr::from_str("127.0.0.1").unwrap()));

let mut env = Environment::new(EnvironmentConfig::default()).await?;

env.polling_strategy = PollingStrategy::TotalTimeoutWithRateLimiting {
total_timeout: Duration::from_secs(60),
};

env.test::<Http01>(&NewOrder::new(&identifiers))
.await
.map(|_| ())
}

#[tokio::test]
#[ignore]
async fn dns_01() -> Result<(), Box<dyn StdError>> {
Expand Down Expand Up @@ -397,6 +417,7 @@ struct Environment {
#[allow(dead_code)] // Held for the lifetime of the environment.
challtestsrv: Subprocess,
client: HyperClient<hyper_rustls::HttpsConnector<HttpConnector>, Full<Bytes>>,
polling_strategy: PollingStrategy,
}

impl Environment {
Expand Down Expand Up @@ -489,13 +510,19 @@ impl Environment {
.await?;
info!(account_id = account.id(), "created ACME account");

let polling_strategy = PollingStrategy::ExponentialBackoff {
tries: 10,
delay: Duration::from_millis(250),
};

Ok(Self {
account,
config,
config_file,
pebble,
challtestsrv,
client,
polling_strategy,
})
}

Expand Down Expand Up @@ -530,7 +557,7 @@ impl Environment {
}

// Poll until the order is ready.
let status = order.poll(10, Duration::from_millis(250)).await?;
let status = order.poll(self.polling_strategy.clone()).await?;
if status != OrderStatus::Ready {
return Err(format!("unexpected order status: {status:?}").into());
}
Expand Down
Loading