pub mod cloudflare;
pub mod exec;
pub mod http;
pub mod resolver;
pub mod route53;
use std::sync::Arc;
use futures::future::BoxFuture;
use crate::config::{AcmeDnsConfig, AcmeDnsProvider};
#[must_use]
pub fn challenge_fqdn(domain: &str) -> String {
format!(
"_acme-challenge.{}",
domain.trim().trim_end_matches('.').to_ascii_lowercase()
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct TxtRecord {
pub fqdn: String,
pub value: String,
}
impl TxtRecord {
#[must_use]
pub fn new(domain: &str, value: impl Into<String>) -> Self {
Self {
fqdn: challenge_fqdn(domain),
value: value.into(),
}
}
}
pub trait DnsProvider: Send + Sync {
fn name(&self) -> &'static str;
fn upsert_txt<'a>(&'a self, record: &'a TxtRecord) -> BoxFuture<'a, Result<(), String>>;
fn delete_txt<'a>(&'a self, record: &'a TxtRecord) -> BoxFuture<'a, Result<(), String>>;
fn upsert_txt_batch<'a>(
&'a self,
records: &'a [TxtRecord],
) -> BoxFuture<'a, Result<(), String>> {
Box::pin(async move {
for record in records {
self.upsert_txt(record).await?;
}
Ok(())
})
}
fn delete_txt_batch<'a>(
&'a self,
records: &'a [TxtRecord],
) -> BoxFuture<'a, Result<(), String>> {
Box::pin(async move {
for record in records {
self.delete_txt(record).await?;
}
Ok(())
})
}
}
pub const UPSTREAM_EXCERPT_CHARS: usize = 400;
const REDACTED: &str = "<redacted>";
const MIN_REDACTABLE_SECRET: usize = 8;
#[must_use]
pub fn sanitize_upstream(text: &str, secrets: &[&str]) -> String {
let forms: Vec<String> = secrets
.iter()
.flat_map(|secret| redaction_forms(secret))
.filter(|form| form.len() >= MIN_REDACTABLE_SECRET)
.collect();
let redact = |mut text: String| {
for form in &forms {
text = text.replace(form.as_str(), REDACTED);
}
text
};
let raw = redact(text.to_owned());
let folded: String = raw
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = redact(folded)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let cleaned = redact(collapsed);
if cleaned.chars().count() <= UPSTREAM_EXCERPT_CHARS {
return cleaned;
}
let mut truncated: String = cleaned.chars().take(UPSTREAM_EXCERPT_CHARS).collect();
truncated.push('…');
truncated
}
fn redaction_forms(secret: &str) -> Vec<String> {
let trimmed = secret.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut forms = vec![trimmed.to_owned()];
let folded: String = trimmed
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let folded = folded.trim().to_owned();
if !folded.is_empty() && !forms.contains(&folded) {
forms.push(folded.clone());
}
let collapsed = folded.split_whitespace().collect::<Vec<_>>().join(" ");
if !collapsed.is_empty() && !forms.contains(&collapsed) {
forms.push(collapsed);
}
forms
}
#[derive(Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(transparent)]
pub struct SecretString(String);
impl SecretString {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_blank(&self) -> bool {
self.0.trim().is_empty()
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
impl std::fmt::Display for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
#[derive(Clone, Default, serde::Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct DnsCredential {
pub api_token: Option<SecretString>,
pub access_key_id: Option<String>,
pub secret_access_key: Option<SecretString>,
pub session_token: Option<SecretString>,
pub hosted_zone_id: Option<String>,
pub region: Option<String>,
}
impl std::fmt::Debug for DnsCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DnsCredential(<redacted>)")
}
}
const ENV_API_TOKEN: &str = "AUTUMN_ACME_DNS_API_TOKEN";
const ENV_ACCESS_KEY_ID: &str = "AUTUMN_ACME_DNS_ACCESS_KEY_ID";
const ENV_SECRET_ACCESS_KEY: &str = "AUTUMN_ACME_DNS_SECRET_ACCESS_KEY";
const ENV_SESSION_TOKEN: &str = "AUTUMN_ACME_DNS_SESSION_TOKEN";
const ENV_HOSTED_ZONE_ID: &str = "AUTUMN_ACME_DNS_HOSTED_ZONE_ID";
const ENV_REGION: &str = "AUTUMN_ACME_DNS_REGION";
impl DnsCredential {
#[must_use]
pub fn with_env(mut self, env: &dyn Fn(&str) -> Option<String>) -> Self {
fn non_blank(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
if let Some(v) = non_blank(env(ENV_API_TOKEN)) {
self.api_token = Some(SecretString::new(v));
}
if let Some(v) = non_blank(env(ENV_ACCESS_KEY_ID)) {
self.access_key_id = Some(v);
}
if let Some(v) = non_blank(env(ENV_SECRET_ACCESS_KEY)) {
self.secret_access_key = Some(SecretString::new(v));
}
if let Some(v) = non_blank(env(ENV_SESSION_TOKEN)) {
self.session_token = Some(SecretString::new(v));
}
if let Some(v) = non_blank(env(ENV_HOSTED_ZONE_ID)) {
self.hosted_zone_id = Some(v);
}
if let Some(v) = non_blank(env(ENV_REGION)) {
self.region = Some(v);
}
self
}
#[must_use]
pub fn resolve(
dns_cfg: &AcmeDnsConfig,
store: &crate::credentials::CredentialsStore,
env: &dyn Fn(&str) -> Option<String>,
) -> Self {
store
.get::<Self>(dns_cfg.credential.trim())
.unwrap_or_default()
.with_env(env)
}
}
impl DnsCredential {
#[must_use]
pub fn secret_values(&self) -> Vec<SecretString> {
[
self.api_token.as_ref(),
self.secret_access_key.as_ref(),
self.session_token.as_ref(),
]
.into_iter()
.flatten()
.filter(|secret| !secret.is_blank())
.cloned()
.collect()
}
}
#[must_use]
pub fn process_env(name: &str) -> Option<String> {
std::env::var(name).ok()
}
pub fn validate_credential(
provider: AcmeDnsProvider,
credential_key: &str,
credential: &DnsCredential,
) -> Result<(), String> {
let where_to_put = |field: &str, env: &str| {
format!(
"add it under `[{credential_key}]` in the encrypted credentials store \
(`autumn credentials edit`) as `{field} = \"...\"`, or set the {env} environment \
variable"
)
};
match provider {
AcmeDnsProvider::Cloudflare => match &credential.api_token {
Some(token) if !token.is_blank() => Ok(()),
Some(_) => Err(format!(
"the Cloudflare DNS credential `{credential_key}` has a blank `api_token`: {}",
where_to_put("api_token", ENV_API_TOKEN)
)),
None => Err(format!(
"no Cloudflare API token found for [server.tls.acme.dns] credential \
`{credential_key}`: {}",
where_to_put("api_token", ENV_API_TOKEN)
)),
},
AcmeDnsProvider::Route53 => {
let id_ok = credential
.access_key_id
.as_ref()
.is_some_and(|v| !v.trim().is_empty());
if !id_ok {
return Err(format!(
"no AWS access key id found for [server.tls.acme.dns] credential \
`{credential_key}`: {}",
where_to_put("access_key_id", ENV_ACCESS_KEY_ID)
));
}
let secret_ok = credential
.secret_access_key
.as_ref()
.is_some_and(|v| !v.is_blank());
if !secret_ok {
return Err(format!(
"no AWS secret access key found for [server.tls.acme.dns] credential \
`{credential_key}`: {}",
where_to_put("secret_access_key", ENV_SECRET_ACCESS_KEY)
));
}
Ok(())
}
AcmeDnsProvider::Exec => Ok(()),
}
}
pub fn build_provider(
dns_cfg: &AcmeDnsConfig,
credential: &DnsCredential,
transport: Arc<dyn http::HttpTransport>,
) -> Result<Arc<dyn DnsProvider>, String> {
validate_credential(dns_cfg.provider, dns_cfg.credential.trim(), credential)?;
Ok(match dns_cfg.provider {
AcmeDnsProvider::Cloudflare => Arc::new(cloudflare::CloudflareProvider::new(
credential
.api_token
.clone()
.unwrap_or_else(|| SecretString::new(String::new())),
transport,
)),
AcmeDnsProvider::Route53 => Arc::new(route53::Route53Provider::new(
route53::Route53Credentials {
access_key_id: credential.access_key_id.clone().unwrap_or_default(),
secret_access_key: credential
.secret_access_key
.clone()
.unwrap_or_else(|| SecretString::new(String::new())),
session_token: credential.session_token.clone(),
region: credential
.region
.clone()
.unwrap_or_else(|| "us-east-1".to_owned()),
hosted_zone_id: credential.hosted_zone_id.clone(),
},
transport,
)),
AcmeDnsProvider::Exec => Arc::new(
exec::ExecProvider::new(dns_cfg.command.clone())
.with_secrets(credential.secret_values()),
),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn challenge_fqdn_is_the_base_domain_prefixed() {
assert_eq!(challenge_fqdn("myapp.com"), "_acme-challenge.myapp.com");
assert_eq!(challenge_fqdn("MyApp.com."), "_acme-challenge.myapp.com");
assert_eq!(
challenge_fqdn("myapp.com"),
TxtRecord::new("myapp.com", "v").fqdn
);
}
#[test]
fn upstream_text_is_scrubbed_of_live_secrets() {
let token = "FwoGZXIvYXdzEHwaDNOT-A-REAL-SESSION-TOKEN";
let aws_reply = format!(
"The request signature we calculated does not match. The Canonical String for this \
request should have been 'POST /2013-04-01/hostedzone/Z1/rrset/ \
host:route53.amazonaws.com x-amz-security-token:{token} '"
);
let safe = sanitize_upstream(&aws_reply, &[token]);
assert!(!safe.contains(token), "leaked: {safe}");
assert!(safe.contains("<redacted>"), "got: {safe}");
assert!(
safe.contains("signature we calculated does not match"),
"got: {safe}"
);
}
#[test]
fn upstream_text_is_bounded_and_stripped_of_control_characters() {
let long = "x".repeat(UPSTREAM_EXCERPT_CHARS * 3);
let bounded = sanitize_upstream(&long, &[]);
assert!(bounded.chars().count() <= UPSTREAM_EXCERPT_CHARS + 1);
assert!(bounded.ends_with('…'));
let hostile = "line one\n2026-01-01 ERROR forged\r\n\u{1b}[31mred\u{1b}[0m";
let safe = sanitize_upstream(hostile, &[]);
assert!(!safe.contains('\n'), "got: {safe:?}");
assert!(!safe.contains('\r'), "got: {safe:?}");
assert!(!safe.contains('\u{1b}'), "got: {safe:?}");
assert!(safe.contains("forged"), "the text itself is kept: {safe:?}");
}
#[test]
fn a_multiline_secret_is_redacted_despite_control_folding() {
const KEY: &str = "-----BEGIN PRIVATE KEY-----\nMIIBVgIBADANBgkqhkiG9w0BAQ\nSECRETLINE9f3a\n-----END PRIVATE KEY-----";
const TSIG: &str = "hmac-sha256\tautumn-key\tc3VwZXJzZWNyZXR2YWx1ZQ==";
let traced = format!("+ nsupdate -k {KEY} failed");
let safe = sanitize_upstream(&traced, &[KEY]);
assert!(
!safe.contains("SECRETLINE9f3a"),
"a multi-line credential leaked: {safe}"
);
assert!(safe.contains("<redacted>"), "got: {safe}");
assert!(safe.contains("nsupdate"), "got: {safe}");
let folded_text = traced.replace('\n', " ");
let safe = sanitize_upstream(&folded_text, &[KEY]);
assert!(
!safe.contains("SECRETLINE9f3a"),
"a pre-normalized trace leaked the credential: {safe}"
);
let safe = sanitize_upstream(&format!("key rejected: {TSIG}"), &[TSIG]);
assert!(
!safe.contains("c3VwZXJzZWNyZXR2YWx1ZQ=="),
"a tab-separated credential leaked: {safe}"
);
}
#[test]
fn a_too_short_secret_is_not_used_as_a_redaction_pattern() {
assert_eq!(
sanitize_upstream("the zone was not found", &["e"]),
"the zone was not found"
);
assert_eq!(sanitize_upstream("a b c", &[""]), "a b c");
}
#[test]
fn secret_string_never_renders_its_value() {
let secret = SecretString::new("cf-super-secret-token");
assert_eq!(format!("{secret:?}"), "<redacted>");
assert_eq!(format!("{secret}"), "<redacted>");
assert!(!format!("{secret:?} {secret}").contains("super-secret"));
assert_eq!(secret.expose(), "cf-super-secret-token");
}
#[test]
fn dns_credential_debug_is_redacted_wholesale() {
let credential = DnsCredential {
api_token: Some(SecretString::new("cf-super-secret-token")),
access_key_id: Some("AKIAEXAMPLE".to_owned()),
secret_access_key: Some(SecretString::new("aws-super-secret")),
..DnsCredential::default()
};
let rendered = format!("{credential:?}");
assert!(!rendered.contains("super-secret"), "leaked: {rendered}");
assert!(!rendered.contains("AKIAEXAMPLE"), "leaked: {rendered}");
}
#[test]
fn env_overlays_the_stored_credential() {
let credential = DnsCredential {
api_token: Some(SecretString::new("from-store")),
..DnsCredential::default()
};
let env = |name: &str| match name {
ENV_API_TOKEN => Some("from-env".to_owned()),
ENV_REGION => Some("eu-west-1".to_owned()),
ENV_ACCESS_KEY_ID => Some(" ".to_owned()),
_ => None,
};
let resolved = credential.with_env(&env);
assert_eq!(resolved.api_token.as_ref().unwrap().expose(), "from-env");
assert_eq!(resolved.region.as_deref(), Some("eu-west-1"));
assert!(resolved.access_key_id.is_none());
}
#[test]
fn the_exec_provider_is_built_with_the_credentials_secrets() {
let credential = DnsCredential {
api_token: Some(SecretString::new("cf-live-token-DO-NOT-LEAK")),
secret_access_key: Some(SecretString::new("aws-secret-DO-NOT-LEAK")),
session_token: Some(SecretString::new("sts-token-DO-NOT-LEAK")),
access_key_id: Some("AKIAEXAMPLE".to_owned()),
..DnsCredential::default()
};
let secrets = credential.secret_values();
assert_eq!(secrets.len(), 3, "every secret field must be redactable");
assert!(secrets.iter().all(|s| s.expose().contains("DO-NOT-LEAK")));
let sparse = DnsCredential {
api_token: Some(SecretString::new(" ")),
..DnsCredential::default()
};
assert!(sparse.secret_values().is_empty());
}
#[test]
fn validate_credential_names_the_missing_field_and_where_to_put_it() {
let empty = DnsCredential::default();
let err = validate_credential(AcmeDnsProvider::Cloudflare, "acme_dns", &empty)
.expect_err("cloudflare needs an api_token");
assert!(err.contains("api_token"), "got: {err}");
assert!(err.contains("autumn credentials edit"), "got: {err}");
assert!(err.contains("AUTUMN_ACME_DNS_API_TOKEN"), "got: {err}");
assert!(err.contains("acme_dns"), "must name the key: {err}");
let err = validate_credential(AcmeDnsProvider::Route53, "acme_dns", &empty)
.expect_err("route53 needs an access key");
assert!(err.contains("access_key_id"), "got: {err}");
let partial = DnsCredential {
access_key_id: Some("AKIAEXAMPLE".to_owned()),
..DnsCredential::default()
};
let err = validate_credential(AcmeDnsProvider::Route53, "acme_dns", &partial)
.expect_err("route53 needs a secret too");
assert!(err.contains("secret_access_key"), "got: {err}");
let blank = DnsCredential {
api_token: Some(SecretString::new(" ")),
..DnsCredential::default()
};
assert!(validate_credential(AcmeDnsProvider::Cloudflare, "acme_dns", &blank).is_err());
assert!(validate_credential(AcmeDnsProvider::Exec, "acme_dns", &empty).is_ok());
}
#[test]
fn validate_credential_errors_never_echo_a_secret() {
let credential = DnsCredential {
api_token: Some(SecretString::new(" ")),
access_key_id: Some("AKIAEXAMPLE".to_owned()),
..DnsCredential::default()
};
let err = validate_credential(AcmeDnsProvider::Route53, "acme_dns", &credential)
.expect_err("missing secret_access_key");
assert!(!err.contains("AKIAEXAMPLE"), "leaked: {err}");
}
}