Skip to content
Merged
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
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ instant-acme = "0.8"
sha1 = "0.10"
hex = "0.4"
x509-parser = "0.16"
# Generates the self-signed TLS-ALPN-01 challenge certificate (RFC 8737).
rcgen = "0.13"

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
wiremock = "0.6"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
rcgen = "0.13"
time = "0.3"

[lints.rust]
Expand Down
8 changes: 7 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Cheti — ACME DNS-01 challenge library with pluggable DNS providers.
//! Cheti — ACME challenge library with pluggable DNS-01 providers and
//! TLS-ALPN-01 support.
//!
//! See [the README](https://github.com/kemeter/cheti) for usage examples.
#![doc = include_str!("../README.md")]
Expand All @@ -12,6 +13,7 @@ pub mod renewal;
pub mod solver;
#[cfg(feature = "testing")]
pub mod testing;
pub mod tls_alpn;

pub use account_store::{AccountStore, FileAccountStore};
pub use dns::{find_zone, wait_for_propagation, DEFAULT_RESOLVERS};
Expand All @@ -27,3 +29,7 @@ pub use renewal::{
needs_renewal_ratio_at_checked, needs_renewal_ratio_checked, CertLifetime,
};
pub use solver::Dns01Solver;
pub use tls_alpn::{
build_challenge_certificate, ChallengeCertificate, ChallengeResponder, TlsAlpn01Solver,
ACME_TLS_ALPN_NAME,
};
97 changes: 97 additions & 0 deletions src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use async_trait::async_trait;

use crate::error::DnsError;
use crate::provider::{DnsProvider, PropagationTiming};
use crate::tls_alpn::ChallengeResponder;

/// A `DnsProvider` that records `present`/`cleanup` calls in memory instead
/// of touching a real DNS zone. Clone-able; clones share the same record log.
Expand Down Expand Up @@ -92,6 +93,82 @@ impl DnsProvider for MockDnsProvider {
}
}

/// A [`ChallengeResponder`] that records `present`/`cleanup` calls in memory
/// instead of serving TLS. Clone-able; clones share the same log. Lets a
/// downstream crate integration-test its TLS-ALPN-01 wiring without standing up
/// a real TLS responder.
/// One recorded presentation: `(domain, cert_der, key_der)`.
type Presentation = (String, Vec<u8>, Vec<u8>);

#[derive(Clone, Default)]
pub struct MockChallengeResponder {
presented: Arc<Mutex<Vec<Presentation>>>,
fail_present: bool,
}

impl MockChallengeResponder {
pub fn new() -> Self {
Self::default()
}

/// Make every `present` call return an error, to exercise failure paths.
pub fn failing() -> Self {
Self {
presented: Arc::new(Mutex::new(Vec::new())),
fail_present: true,
}
}

/// Domains with a challenge certificate currently presented.
pub fn presented_domains(&self) -> Vec<String> {
self.presented
.lock()
.expect("mock lock poisoned")
.iter()
.map(|(d, _, _)| d.clone())
.collect()
}

/// True if a challenge certificate is currently presented for `domain`.
pub fn is_presenting(&self, domain: &str) -> bool {
self.presented
.lock()
.expect("mock lock poisoned")
.iter()
.any(|(d, _, _)| d == domain)
}
}

#[async_trait]
impl ChallengeResponder for MockChallengeResponder {
async fn present(
&self,
domain: &str,
cert_der: Vec<u8>,
key_der: Vec<u8>,
) -> Result<(), DnsError> {
if self.fail_present {
return Err(DnsError::Other(format!(
"mock present failure for {domain}"
)));
}
self.presented.lock().expect("mock lock poisoned").push((
domain.to_string(),
cert_der,
key_der,
));
Ok(())
}

async fn cleanup(&self, domain: &str) -> Result<(), DnsError> {
self.presented
.lock()
.expect("mock lock poisoned")
.retain(|(d, _, _)| d != domain);
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -125,4 +202,24 @@ mod tests {
mock.present("a", "1").await.unwrap();
assert!(clone.has_record("a", "1"));
}

#[tokio::test]
async fn challenge_responder_present_then_cleanup_round_trips() {
let mock = MockChallengeResponder::new();
mock.present("example.com", vec![1, 2, 3], vec![4, 5, 6])
.await
.unwrap();
assert!(mock.is_presenting("example.com"));

mock.cleanup("example.com").await.unwrap();
assert!(!mock.is_presenting("example.com"));
assert!(mock.presented_domains().is_empty());
}

#[tokio::test]
async fn failing_challenge_responder_errors_on_present() {
let mock = MockChallengeResponder::failing();
let err = mock.present("x", vec![], vec![]).await.unwrap_err();
assert!(err.to_string().contains("mock present failure"));
}
}
Loading