diff --git a/Cargo.lock b/Cargo.lock index f00a319..18d2323 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,12 +45,6 @@ dependencies = [ "yansi-term", ] -[[package]] -name = "anyhow" -version = "1.0.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" - [[package]] name = "async-task" version = "4.7.1" @@ -458,12 +452,10 @@ dependencies = [ name = "nginx-acme" version = "0.1.1" dependencies = [ - "anyhow", "base64", "bytes", "constcat", "foreign-types", - "futures-channel", "http", "http-body", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index 56a0a7a..4a358cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,9 @@ rust-version = "1.81.0" crate-type = ["cdylib"] [dependencies] -anyhow = "1.0.98" base64 = "0.22.1" bytes = "1.10.1" constcat = "0.6.1" -futures-channel = "0.3.31" http = "1.3.1" http-body = "1.0.1" http-body-util = "0.1.3" diff --git a/src/acme.rs b/src/acme.rs index 21b1762..6876326 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -9,8 +9,8 @@ use core::time::Duration; use std::collections::VecDeque; use std::string::{String, ToString}; -use anyhow::{anyhow, Result}; use bytes::Bytes; +use error::{NewAccountError, NewCertificateError, RequestError}; use http::Uri; use ngx::allocator::{Allocator, Box}; use ngx::async_::sleep; @@ -18,8 +18,9 @@ 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 types::{AccountStatus, ProblemCategory}; -use self::account_key::AccountKey; +use self::account_key::{AccountKey, AccountKeyError}; use self::types::{AuthorizationStatus, ChallengeKind, ChallengeStatus, OrderStatus}; use crate::conf::identifier::Identifier; use crate::conf::issuer::Issuer; @@ -28,13 +29,25 @@ use crate::net::http::HttpClient; use crate::time::Time; pub mod account_key; +pub mod error; pub mod solvers; pub mod types; const DEFAULT_RETRY_INTERVAL: Duration = Duration::from_secs(1); -const MAX_RETRY_INTERVAL: Duration = Duration::from_secs(8); + +/// Upper limit for locally generated increasing backoff interval. +const MAX_BACKOFF_INTERVAL: Duration = Duration::from_secs(8); + +/// Upper limit for server-generated retry intervals (Retry-After). +const MAX_SERVER_RETRY_INTERVAL: Duration = Duration::from_secs(60); + static REPLAY_NONCE: http::HeaderName = http::HeaderName::from_static("replay-nonce"); +pub enum NewAccountOutput<'a> { + Created(&'a str), + Found(&'a str), +} + pub struct NewCertificateOutput { pub chain: Bytes, pub pkey: PKey, @@ -97,8 +110,13 @@ fn try_get_header( impl<'a, Http> AcmeClient<'a, Http> where Http: HttpClient, + RequestError: From<::Error>, { - pub fn new(http: Http, issuer: &'a Issuer, log: NonNull) -> Result { + pub fn new( + http: Http, + issuer: &'a Issuer, + log: NonNull, + ) -> Result { let key = AccountKey::try_from( issuer .pkey @@ -137,21 +155,21 @@ where self.solvers.iter().any(|s| s.supports(kind)) } - async fn get_directory(&self) -> Result { + async fn get_directory(&self) -> Result { let res = self.get(&self.issuer.uri).await?; - let directory = serde_json::from_slice(res.body())?; + let directory = deserialize_body(res.body())?; Ok(directory) } - async fn get_nonce(&self) -> Result { + async fn get_nonce(&self) -> Result { let res = self.get(&self.directory.new_nonce).await?; try_get_header(res.headers(), &REPLAY_NONCE) - .ok_or(anyhow!("no nonce in response headers")) + .ok_or(RequestError::Nonce) .map(String::from) } - pub async fn get(&self, url: &Uri) -> Result> { + pub async fn get(&self, url: &Uri) -> Result, RequestError> { let req = http::Request::builder() .uri(url) .method(http::Method::GET) @@ -164,7 +182,7 @@ where &self, url: &Uri, payload: P, - ) -> Result> { + ) -> Result, RequestError> { let mut nonce = if let Some(nonce) = self.nonce.get() { nonce } else { @@ -215,15 +233,27 @@ where // 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"))? + .ok_or(RequestError::Nonce)? .to_string(); - let err: types::Problem = serde_json::from_slice(res.body())?; - - let retriable = matches!( - err.kind, - types::ErrorKind::BadNonce | types::ErrorKind::RateLimited - ); + let err: types::Problem = deserialize_body(res.body())?; + + let retriable = match err.kind { + types::ErrorKind::RateLimited => { + // The server may ask us to retry in several hours or days. + if let Some(val) = res + .headers() + .get(http::header::RETRY_AFTER) + .and_then(parse_retry_after) + .filter(|x| x > &MAX_SERVER_RETRY_INTERVAL) + { + return Err(RequestError::RateLimited(val)); + } + true + } + types::ErrorKind::BadNonce => true, + _ => false, + }; if retriable && wait_for_retry(&res, &mut tries).await { ngx_log_debug!(self.log.as_ptr(), "retrying failed request ({err})"); @@ -239,20 +269,23 @@ where Ok(res) } - pub async fn new_account(&mut self) -> Result { - self.directory = self.get_directory().await?; + pub async fn new_account(&mut self) -> Result, NewAccountError> { + self.directory = self + .get_directory() + .await + .map_err(NewAccountError::Directory)?; if self.directory.meta.external_account_required == Some(true) && self.issuer.eab_key.is_none() { - return Err(anyhow!("external account key required")); + return Err(NewAccountError::ExternalAccount); } let external_account_binding = self .issuer .eab_key .as_ref() - .map(|x| -> Result<_> { + .map(|x| -> Result<_, RequestError> { let key = crate::jws::ShaWithHmacKey::new(&x.key, 256); let payload = serde_json::to_vec(&self.key)?; let message = crate::jws::sign_jws( @@ -273,19 +306,25 @@ where ..Default::default() }; - let payload = serde_json::to_string(&payload)?; + let payload = serde_json::to_string(&payload).map_err(RequestError::RequestFormat)?; 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); + let account: types::Account = deserialize_body(res.body())?; + if !matches!(account.status, AccountStatus::Valid) { + return Err(NewAccountError::Status(account.status)); + } + + let key_id: &str = + try_get_header(res.headers(), http::header::LOCATION).ok_or(NewAccountError::Url)?; - Ok(serde_json::from_slice(res.body())?) + self.account = Some(key_id.to_string()); + + let key_id = self.account.as_ref().unwrap(); + match res.status() { + http::StatusCode::CREATED => Ok(NewAccountOutput::Created(key_id)), + _ => Ok(NewAccountOutput::Found(key_id)), + } } pub fn is_ready(&self) -> bool { @@ -295,7 +334,7 @@ where pub async fn new_certificate( &self, req: &CertificateOrder<&str, A>, - ) -> Result + ) -> Result where A: Allocator, { @@ -313,30 +352,27 @@ where not_after: None, }; - let payload = serde_json::to_string(&payload)?; + let payload = serde_json::to_string(&payload).map_err(RequestError::RequestFormat)?; 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 = try_get_header(res.headers(), http::header::LOCATION) + .and_then(|x| Uri::try_from(x).ok()) + .ok_or(NewCertificateError::OrderUrl)?; - let order_url = Uri::try_from(order_url)?; - let order: types::Order = serde_json::from_slice(res.body())?; + let order: types::Order = deserialize_body(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())?; + let mut authorization: types::Authorization = deserialize_body(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) + return Err(NewCertificateError::NoSupportedChallenges); } match authorization.status { @@ -351,11 +387,7 @@ where authorization.identifier ); } - status => anyhow::bail!( - "unexpected authorization status for {:?}: {:?}", - authorization.identifier, - status - ), + status => return Err(NewCertificateError::AuthorizationStatus(status)), } } @@ -371,38 +403,48 @@ where } let mut res = self.post(&order_url, b"").await?; - let mut order: types::Order = serde_json::from_slice(res.body())?; + let mut order: types::Order = deserialize_body(res.body())?; if order.status != OrderStatus::Ready { - anyhow::bail!("not ready"); + if let Some(err) = order.error { + return Err(err.into()); + } + return Err(NewCertificateError::OrderStatus(order.status)); } - let csr = make_certificate_request(&order.identifiers, &pkey).and_then(|x| x.to_der())?; + let csr = make_certificate_request(&order.identifiers, &pkey) + .and_then(|x| x.to_der()) + .map_err(NewCertificateError::Csr)?; 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())?; + order = deserialize_body(res.body())?; } - Err(err) => { - if !err.to_string().contains("orderNotReady") { - return Err(err); - } - order.status = OrderStatus::Processing + Err(RequestError::Protocol(problem)) + if matches!( + problem.category(), + ProblemCategory::Order | ProblemCategory::Malformed + ) => + { + return Err(problem.into()) } + _ => order.status = OrderStatus::Processing, }; - let mut tries = backoff(MAX_RETRY_INTERVAL, self.finalize_timeout); + let mut tries = backoff(MAX_BACKOFF_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())?; + order = deserialize_body(res.body())?; } - let certificate = order.certificate.ok_or(anyhow!("certificate not ready"))?; + let certificate = order + .certificate + .ok_or(NewCertificateError::CertificateUrl)?; let chain = self.post(&certificate, b"").await?.into_body(); @@ -414,7 +456,7 @@ where order: &AuthorizationContext<'_>, url: http::Uri, authorization: types::Authorization, - ) -> Result<()> { + ) -> Result<(), NewCertificateError> { let identifier = authorization.identifier.as_ref(); // Find and set up first supported challenge. @@ -425,7 +467,7 @@ where let solver = self.find_solver_for(&x.kind)?; Some((x, solver)) }) - .ok_or(anyhow!("no supported challenge for {identifier:?}"))?; + .ok_or(NewCertificateError::NoSupportedChallenges)?; solver.register(order, &identifier, challenge)?; @@ -434,20 +476,20 @@ where }; let res = self.post(&challenge.url, b"{}").await?; - let result: types::Challenge = serde_json::from_slice(res.body())?; + let result: types::Challenge = deserialize_body(res.body())?; if !matches!( result.status, ChallengeStatus::Pending | ChallengeStatus::Processing | ChallengeStatus::Valid ) { - return Err(anyhow!("unexpected challenge status {:?}", result.status)); + return Err(NewCertificateError::ChallengeStatus(result.status)); } - let mut tries = backoff(MAX_RETRY_INTERVAL, self.authorization_timeout); + let mut tries = backoff(MAX_BACKOFF_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())?; + let result: types::Authorization = deserialize_body(res.body())?; if result.status != AuthorizationStatus::Pending || !wait_for_retry(&res, &mut tries).await @@ -464,7 +506,16 @@ where ); if result.status != AuthorizationStatus::Valid { - return Err(anyhow!("authorization failed ({:?})", result.status)); + if let Some(err) = result + .challenges + .iter() + .find(|x| x.kind == challenge.kind) + .and_then(|x| x.error.clone()) + { + return Err(err.into()); + } else { + return Err(NewCertificateError::AuthorizationStatus(result.status)); + } } Ok(()) @@ -519,7 +570,8 @@ async fn wait_for_retry( .headers() .get(http::header::RETRY_AFTER) .and_then(parse_retry_after) - .unwrap_or(interval); + .unwrap_or(interval) + .min(MAX_SERVER_RETRY_INTERVAL); sleep(retry_after).await; true @@ -539,6 +591,15 @@ fn backoff(max: Duration, timeout: Duration) -> impl Iterator { .map(move |(_, x)| x.min(max)) } +/// Deserializes JSON response body as T and converts error type. +#[inline(always)] +fn deserialize_body<'a, T>(bytes: &'a Bytes) -> Result +where + T: serde::Deserialize<'a>, +{ + serde_json::from_slice(bytes).map_err(RequestError::ResponseFormat) +} + fn parse_retry_after(val: &http::HeaderValue) -> Option { let val = val.to_str().ok()?; diff --git a/src/acme/error.rs b/src/acme/error.rs new file mode 100644 index 0000000..6e0e626 --- /dev/null +++ b/src/acme/error.rs @@ -0,0 +1,157 @@ +// 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::error::Error as StdError; +use core::time::Duration; + +use ngx::allocator::{unsize_box, Box}; +use thiserror::Error; + +use super::solvers::SolverError; +use super::types::{AccountStatus, Problem, ProblemCategory}; +use crate::net::http::HttpClientError; + +#[derive(Debug, Error)] +pub enum NewAccountError { + #[error("directory update failed ({0})")] + Directory(RequestError), + + #[error("external account key required")] + ExternalAccount, + + #[error(transparent)] + Protocol(#[from] Problem), + + #[error("account request failed ({0})")] + Request(RequestError), + + #[error("unexpected account status {0:?}")] + Status(AccountStatus), + + #[error("no account URL in response")] + Url, +} + +impl From for NewAccountError { + fn from(value: RequestError) -> Self { + match value { + RequestError::Protocol(problem) => Self::Protocol(problem), + _ => Self::Request(value), + } + } +} + +impl NewAccountError { + pub fn is_invalid(&self) -> bool { + match self { + Self::ExternalAccount => true, + Self::Protocol(err) => matches!( + err.category(), + ProblemCategory::Account | ProblemCategory::Malformed + ), + Self::Status(_) => true, + _ => false, + } + } +} + +#[derive(Debug, Error)] +pub enum NewCertificateError { + #[error("unexpected authorization status {0:?}")] + AuthorizationStatus(super::types::AuthorizationStatus), + + #[error("no certificate in the validated order")] + CertificateUrl, + + #[error("unexpected challenge status {0:?}")] + ChallengeStatus(super::types::ChallengeStatus), + + #[error("csr generation failed ({0})")] + Csr(openssl::error::ErrorStack), + + #[error("no supported challenges")] + NoSupportedChallenges, + + #[error("unexpected order status {0:?}")] + OrderStatus(super::types::OrderStatus), + + #[error("invalid or missing order URL")] + OrderUrl, + + #[error(transparent)] + PrivateKey(#[from] crate::conf::pkey::PKeyGenError), + + #[error(transparent)] + Protocol(#[from] Problem), + + #[error(transparent)] + Request(RequestError), + + #[error(transparent)] + Solver(#[from] SolverError), +} + +impl From for NewCertificateError { + fn from(value: RequestError) -> Self { + match value { + RequestError::Protocol(problem) => Self::Protocol(problem), + _ => Self::Request(value), + } + } +} + +impl NewCertificateError { + pub fn is_invalid(&self) -> bool { + match self { + Self::Protocol(err) => matches!( + err.category(), + ProblemCategory::Order | ProblemCategory::Malformed + ), + _ => false, + } + } +} + +#[derive(Debug, Error)] +pub enum RequestError { + #[error(transparent)] + Client(Box), + + #[error("cannot deserialize problem document ({0})")] + ErrorFormat(#[from] serde_json::Error), + + #[error("cannot build request ({0})")] + Http(#[from] http::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("cannot obtain replay nonce")] + Nonce, + + #[error(transparent)] + Protocol(#[from] Problem), + + #[error("rate limit exceeded, next attempt in {0:?}")] + RateLimited(Duration), + + #[error("cannot serialize request ({0})")] + RequestFormat(serde_json::Error), + + #[error("cannot deserialize response ({0})")] + ResponseFormat(serde_json::Error), + + #[error("cannot sign request body ({0})")] + Sign(#[from] crate::jws::Error), +} + +impl From for RequestError { + fn from(value: HttpClientError) -> Self { + match value { + HttpClientError::Io(err) => Self::Io(err), + _ => Self::Client(unsize_box!(Box::new(value))), + } + } +} diff --git a/src/conf/issuer.rs b/src/conf/issuer.rs index 23c2d2e..8af6dbf 100644 --- a/src/conf/issuer.rs +++ b/src/conf/issuer.rs @@ -33,6 +33,8 @@ use crate::state::certificate::{CertificateContext, CertificateContextInner}; use crate::state::issuer::{IssuerContext, IssuerState}; use crate::time::{Time, TimeRange}; +pub const ACCOUNT_URL_FILE: &str = "account.url"; + const ACCOUNT_KEY_FILE: &str = "account.key"; const NGX_ACME_DEFAULT_RESOLVER_TIMEOUT: ngx_msec_t = 30000; const NGX_CONF_UNSET_FLAG: ngx_flag_t = nginx_sys::NGX_CONF_UNSET as _; @@ -118,6 +120,15 @@ impl Issuer { .is_some_and(|x| x.read().state != IssuerState::Invalid) } + /// Marks the last issuer login attempt as failed. + pub fn set_error(&self, err: &dyn StdError) -> Time { + if let Some(data) = self.data.as_ref() { + data.write().set_error(err) + } else { + Time::MAX + } + } + /// Marks the issuer as misconfigured or otherwise unusable. pub fn set_invalid(&self, err: &dyn StdError) { if let Some(data) = self.data.as_ref() { diff --git a/src/conf/order.rs b/src/conf/order.rs index 7e2483c..2e5f23c 100644 --- a/src/conf/order.rs +++ b/src/conf/order.rs @@ -43,20 +43,22 @@ where } /// Generates a stable unique identifier for this order. - pub fn cache_key(&self) -> std::string::String + pub fn cache_key(&self) -> PrintableOrderId<'_, S, A> where S: fmt::Display + hash::Hash, { - if self.identifiers.is_empty() { - return "".into(); - } - - let name = self.identifiers[0].value(); + PrintableOrderId(self) + } - let mut hasher = SipHasher::default(); - self.hash(&mut hasher); + /// Attempts to find the first DNS identifier, with fallback to a first identifier of any kind. + pub fn first_name(&self) -> Option<&S> { + let dns = self + .identifiers + .iter() + .find(|x| matches!(x, Identifier::Dns(_))); - std::format!("{name}-{hash:x}", hash = hasher.finish()) + dns.or_else(|| self.identifiers.first()) + .map(Identifier::value) } pub fn to_str_order(&self, alloc: NewA) -> CertificateOrder<&str, NewA> @@ -247,6 +249,30 @@ impl CertificateOrder { } } +/// Unique identifier for the CertificateOrder. +/// +/// This identifier should be suitable for logs, file names or cache keys. +pub struct PrintableOrderId<'a, S, A>(&'a CertificateOrder) +where + A: ngx::allocator::Allocator; + +impl fmt::Display for PrintableOrderId<'_, S, A> +where + A: ngx::allocator::Allocator, + S: fmt::Display + hash::Hash, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Some(name) = self.0.first_name() else { + return Ok(()); + }; + + let mut hasher = SipHasher::default(); + self.0.hash(&mut hasher); + + write!(f, "{name}-{hash:x}", hash = hasher.finish()) + } +} + fn validate_host(pool: &Pool, mut host: ngx_str_t) -> Result { let mut pool = pool.clone(); let rc = Status(unsafe { nginx_sys::ngx_http_validate_host(&mut host, pool.as_mut(), 1) }); diff --git a/src/lib.rs b/src/lib.rs index 856fc56..9ab46ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ use openssl::x509::X509; use time::TimeRange; use zeroize::Zeroizing; +use crate::acme::error::RequestError; use crate::acme::AcmeClient; use crate::conf::{AcmeMainConfig, AcmeServerConfig, NGX_HTTP_ACME_COMMANDS}; use crate::net::http::NgxHttpClient; @@ -212,31 +213,11 @@ async fn ngx_http_acme_update_certificates(amcf: &AcmeMainConfig) -> Time { let issuer_next = match ngx_http_acme_update_certificates_for_issuer(amcf, issuer).await { Ok(x) => x, Err(err) => { - // Check if the server rejected this ACME account configuration. - if err - .downcast_ref::() - .is_some_and(|err| { - matches!(err.category(), acme::types::ProblemCategory::Account) - }) - { - ngx_log_error!( - NGX_LOG_ERR, - log.as_ptr(), - "acme issuer \"{}\" is not valid: {}", - issuer.name, - err - ); - - issuer.set_invalid(err.as_ref()); - continue; - } - ngx_log_error!( - NGX_LOG_INFO, + NGX_LOG_WARN, log.as_ptr(), - "update failed for acme issuer \"{}\": {}", - issuer.name, - err + "{err} while processing renewals for acme issuer \"{}\"", + issuer.name ); now + ACME_DEFAULT_INTERVAL } @@ -250,7 +231,7 @@ async fn ngx_http_acme_update_certificates(amcf: &AcmeMainConfig) -> Time { async fn ngx_http_acme_update_certificates_for_issuer( amcf: &AcmeMainConfig, issuer: &conf::issuer::Issuer, -) -> anyhow::Result