diff --git a/Cargo.toml b/Cargo.toml index edf4e38..43a2971 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/examples/provision.rs b/examples/provision.rs index 3425e80..89a3752 100644 --- a/examples/provision.rs +++ b/examples/provision.rs @@ -7,7 +7,7 @@ use tracing::info; use instant_acme::{ Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder, - OrderStatus, + OrderStatus, PollingStrategy, }; #[tokio::main] @@ -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:?}")); } diff --git a/src/account.rs b/src/account.rs index a1733b1..66b6869 100644 --- a/src/account.rs +++ b/src/account.rs @@ -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) /// @@ -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 @@ -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")?, }) @@ -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`. diff --git a/src/lib.rs b/src/lib.rs index 6169a30..8ab6487 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; @@ -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")] @@ -158,6 +160,24 @@ fn nonce_from_response(rsp: &BytesResponse) -> Option { .and_then(|hv| String::from_utf8(hv.as_ref().to_vec()).ok()) } +fn retry_after_from_response(rsp: &BytesResponse) -> Option { + 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::() { + Some(Duration::from_secs(retry_after_seconds)) + } else { + None + } + }) +} + #[cfg(feature = "hyper-rustls")] struct DefaultClient(HyperClient, Full>); @@ -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 { diff --git a/src/order.rs b/src/order.rs index 341205a..52ec1ee 100644 --- a/src/order.rs +++ b/src/order.rs @@ -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) /// @@ -26,10 +26,39 @@ use crate::{Error, Key, crypto, nonce_from_response}; pub struct Order { pub(crate) account: Arc, pub(crate) nonce: Option, + pub(crate) retry_after: Option, 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 /// @@ -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::(rsp).await?; } @@ -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 { - 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 { + self.wait_status_internal( + polling_strategy, + &[OrderStatus::Ready, OrderStatus::Invalid], + ) + .await } /// Refresh the current state of the order @@ -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::(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, 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 { + 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) diff --git a/tests/pebble.rs b/tests/pebble.rs index 8337e37..4da1d92 100644 --- a/tests/pebble.rs +++ b/tests/pebble.rs @@ -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}; @@ -63,6 +63,26 @@ async fn http_01() -> Result<(), Box> { .map(|_| ()) } +#[tokio::test] +#[ignore] +async fn poll_with_timeout() -> Result<(), Box> { + 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::(&NewOrder::new(&identifiers)) + .await + .map(|_| ()) +} + #[tokio::test] #[ignore] async fn dns_01() -> Result<(), Box> { @@ -397,6 +417,7 @@ struct Environment { #[allow(dead_code)] // Held for the lifetime of the environment. challtestsrv: Subprocess, client: HyperClient, Full>, + polling_strategy: PollingStrategy, } impl Environment { @@ -489,6 +510,11 @@ 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, @@ -496,6 +522,7 @@ impl Environment { pebble, challtestsrv, client, + polling_strategy, }) } @@ -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()); }