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
31 changes: 28 additions & 3 deletions crates/rota-core/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,22 @@ pub struct IssuedCert {
/// lets the renewer hint at the CA's challenge-type selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChallengeKind {
/// ACME DNS-01: a TXT record at the challenge name.
Dns01,
/// CNAME-shaped DNS DCV (Sectigo CSR-hash, Comodo legacy CNAME):
/// a CNAME record at the challenge name pointing to a CA-controlled
/// validation target. Distinct from `Dns01` because the record TYPE
/// drives the registrar's API and validators differ on hostname
/// rules between record types.
DnsCname,
Http01,
}

impl ChallengeKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Dns01 => "dns-01",
Self::DnsCname => "dns-cname",
Self::Http01 => "http-01",
}
}
Expand All @@ -68,6 +76,22 @@ pub enum DcvChallenge {
/// Time-to-live for the TXT record in seconds.
ttl: u32,
},
/// CNAME-shaped DNS DCV: publish a CNAME record at `record_name`
/// pointing to `record_value`. Sectigo (via Namecheap reissue)
/// uses this for the modern CSR-hash flow; the CA's validator
/// follows the CNAME and checks for a known structure under its
/// own zone (e.g. `comodoca.com`). The DCV backend writes a CNAME
/// rather than a TXT; otherwise mechanically identical to Dns01.
DnsCname {
/// FQDN of the CNAME record (e.g.
/// `_<MD5>.example.com`).
record_name: String,
/// Target FQDN the CNAME must point to (e.g.
/// `<SHA256_FIRST32>.<SHA256_LAST32>.<unique>.comodoca.com`).
record_value: String,
/// Time-to-live for the CNAME record in seconds.
ttl: u32,
},
/// HTTP-01: serve `key_authorization` at
/// `http://<domain>/.well-known/acme-challenge/<token>` over
/// plain HTTP on port 80. The CA fetches the URL and signs once
Expand All @@ -91,6 +115,7 @@ impl DcvChallenge {
pub fn kind(&self) -> ChallengeKind {
match self {
Self::Dns01 { .. } => ChallengeKind::Dns01,
Self::DnsCname { .. } => ChallengeKind::DnsCname,
Self::Http01 { .. } => ChallengeKind::Http01,
}
}
Expand All @@ -102,11 +127,11 @@ impl DcvChallenge {
}

/// Short label identifying *what* this challenge is for, for
/// audit log details. DNS-01 returns the record name; HTTP-01
/// returns the domain.
/// audit log details. DNS challenges return the record name;
/// HTTP-01 returns the domain.
pub fn label(&self) -> String {
match self {
Self::Dns01 { record_name, .. } => record_name.clone(),
Self::Dns01 { record_name, .. } | Self::DnsCname { record_name, .. } => record_name.clone(),
Self::Http01 { domain, .. } => format!("{domain} (http-01)"),
}
}
Expand Down
7 changes: 7 additions & 0 deletions crates/rota-daemon/src/backends/acme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,15 @@ impl CABackend for AcmeCa {
// error. Inspect the (publicly Deref-exposed) challenge list
// immutably first to pick the kind, then make exactly one
// mutable `challenge()` call.
// ACME (RFC 8555) only knows DNS-01 (TXT) and HTTP-01.
// ChallengeKind::DnsCname is a non-ACME concept (Sectigo
// CSR-hash flow); ACME never emits a CNAME challenge so we
// skip that variant when matching against ACME's offer.
let chosen_kind = preference.iter().copied().find(|k| {
let acme_kind = match k {
ChallengeKind::Dns01 => ChallengeType::Dns01,
ChallengeKind::Http01 => ChallengeType::Http01,
ChallengeKind::DnsCname => return false,
};
authz.challenges.iter().any(|c| c.r#type == acme_kind)
});
Expand All @@ -234,6 +239,7 @@ impl CABackend for AcmeCa {
let acme_kind = match kind {
ChallengeKind::Dns01 => ChallengeType::Dns01,
ChallengeKind::Http01 => ChallengeType::Http01,
ChallengeKind::DnsCname => unreachable!("filtered above"),
};
let c = authz.challenge(acme_kind).expect("kind verified above");
let challenge = match kind {
Expand All @@ -247,6 +253,7 @@ impl CABackend for AcmeCa {
token: c.token.clone(),
key_authorization: c.key_authorization().as_str().to_owned(),
},
ChallengeKind::DnsCname => unreachable!("filtered above"),
};
challenges.push(challenge);
}
Expand Down
63 changes: 31 additions & 32 deletions crates/rota-daemon/src/backends/cloudflare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,26 @@ impl CloudflareDcv {
}
}

const SUPPORTED: &[ChallengeKind] = &[ChallengeKind::Dns01];
const SUPPORTED: &[ChallengeKind] = &[ChallengeKind::Dns01, ChallengeKind::DnsCname];

fn challenge_record(challenge: &DcvChallenge) -> Result<(&str, &str, &str, u32)> {
match challenge {
DcvChallenge::Dns01 {
record_name,
record_value,
ttl,
} => Ok(("TXT", record_name, record_value, *ttl)),
DcvChallenge::DnsCname {
record_name,
record_value,
ttl,
} => Ok(("CNAME", record_name, record_value, *ttl)),
_ => Err(Error::Registrar(format!(
"cloudflare dcv only supports dns-01 and dns-cname, got {}",
challenge.kind_str()
))),
}
}

#[async_trait]
impl DcvBackend for CloudflareDcv {
Expand All @@ -175,67 +194,46 @@ impl DcvBackend for CloudflareDcv {
}

async fn publish(&self, challenge: &DcvChallenge) -> Result<()> {
let DcvChallenge::Dns01 {
record_name,
record_value,
ttl,
} = challenge
else {
return Err(Error::Registrar(format!(
"cloudflare dcv only supports dns-01, got {}",
challenge.kind_str()
)));
};
let (record_type, record_name, record_value, ttl) = challenge_record(challenge)?;
let zone_id = self.find_zone_id(record_name).await?;

if self
.find_matching_record(&zone_id, record_name, record_value)
.find_matching_record(&zone_id, record_type, record_name, record_value)
.await?
.is_some()
{
debug!(record = %record_name, "cloudflare txt already present");
debug!(record = %record_name, kind = %challenge.kind_str(), "cloudflare dcv record already present");
return Ok(());
}

let body = json!({
"type": "TXT",
"type": record_type,
"name": record_name,
"content": record_value,
"ttl": (*ttl).max(TXT_TTL_FALLBACK),
"ttl": ttl.max(TXT_TTL_FALLBACK),
});
let _: DnsRecordResponse = self
.client
.post(&format!("/zones/{zone_id}/dns_records"), body)
.await?;
info!(record = %record_name, "cloudflare publishing dcv txt");
info!(record = %record_name, kind = %challenge.kind_str(), "cloudflare publishing dcv record");
Ok(())
}

async fn remove(&self, challenge: &DcvChallenge) -> Result<()> {
let DcvChallenge::Dns01 {
record_name,
record_value,
..
} = challenge
else {
return Err(Error::Registrar(format!(
"cloudflare dcv only supports dns-01, got {}",
challenge.kind_str()
)));
};
let (record_type, record_name, record_value, _ttl) = challenge_record(challenge)?;
let zone_id = self.find_zone_id(record_name).await?;
let Some(record_id) = self
.find_matching_record(&zone_id, record_name, record_value)
.find_matching_record(&zone_id, record_type, record_name, record_value)
.await?
else {
// Idempotent: nothing to delete.
return Ok(());
};
let _: DeletedRecord = self
.client
.delete(&format!("/zones/{zone_id}/dns_records/{record_id}"))
.await?;
info!(record = %record_name, "cloudflare removed dcv txt");
info!(record = %record_name, kind = %challenge.kind_str(), "cloudflare removed dcv record");
Ok(())
}
}
Expand Down Expand Up @@ -267,13 +265,14 @@ impl CloudflareDcv {
async fn find_matching_record(
&self,
zone_id: &str,
record_type: &str,
name: &str,
value: &str,
) -> Result<Option<String>> {
let records: Vec<DnsRecord> = self
.client
.get(&format!(
"/zones/{zone_id}/dns_records?type=TXT&name={name}"
"/zones/{zone_id}/dns_records?type={record_type}&name={name}"
))
.await?;
Ok(
Expand Down
32 changes: 19 additions & 13 deletions crates/rota-daemon/src/backends/namecheap/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,27 @@ impl CABackend for NamecheapCa {
.await?;
resp.ensure_ok()?;

let (record_name, record_value) = if let (Some(name), Some(value)) =
let challenge = if let (Some(record_name), Some(record_value)) =
(resp.first_text("TxtName"), resp.first_text("TxtValue"))
{
(name, value)
} else if let (Some(name), Some(target)) =
DcvChallenge::Dns01 {
record_name,
record_value,
ttl: DCV_TTL_SECONDS,
}
} else if let (Some(record_name), Some(record_value)) =
(resp.first_text("HostName"), resp.first_text("Target"))
{
// CNAME validation surfaces as Name -> Target. We treat it as a
// TXT record from the trait surface; backends that only accept
// CNAMEs will reject downstream and we'll widen the trait then.
(name, target)
// Sectigo CSR-hash and the legacy Comodo CNAME flow both surface
// as <HostName> -> <Target>. The CA expects a CNAME, NOT a TXT;
// hostname-validity rules at registrar APIs differ between the
// two record types (Namecheap rejected `_<MD5>` as TXT name
// before the trait was widened to carry record type).
DcvChallenge::DnsCname {
record_name,
record_value,
ttl: DCV_TTL_SECONDS,
}
} else {
// Dump the response at debug level so an operator with
// `RUST_LOG=debug` can file an actionable bug report without
Expand All @@ -124,14 +134,10 @@ impl CABackend for NamecheapCa {
));
};

info!(record = %record_name, "namecheap reissue accepted, dcv pending");
info!(record = %challenge.label(), kind = %challenge.kind_str(), "namecheap reissue accepted, dcv pending");
// Namecheap reissue folds every SAN under one DCV record, so
// the trait's Vec always has exactly one element here.
Ok(vec![DcvChallenge::Dns01 {
record_name,
record_value,
ttl: DCV_TTL_SECONDS,
}])
Ok(vec![challenge])
}

async fn await_issuance(&self, _domains: &[String]) -> Result<IssuedCert> {
Expand Down
Loading
Loading