From 08477f611fd8d4f586eb1ae1e590cf533cf6c44a Mon Sep 17 00:00:00 2001 From: Shon Thomas Date: Sat, 9 May 2026 09:52:32 -0800 Subject: [PATCH] feat(dcv): add DnsCname challenge variant for Sectigo CSR-hash flow The Sectigo CSR-hash DCV (Namecheap reissue's modern default) needs a CNAME record, not a TXT. rota's namecheap CA backend was returning the HostName/Target pair under DcvChallenge::Dns01 with a comment acknowledging the gap ("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"). The DCV backend then hardcoded "TXT" in setHosts and Namecheap rejected the underscore-prefixed hostname under TXT-name validation rules: CA backend error: namecheap api error: 2050900: INVALID_NAME: Host name: '_CE9ECBF...' is invalid. Real fix: widen the trait. DcvChallenge::DnsCname carries the same shape as Dns01 but communicates record type to the DCV backend. ChallengeKind::DnsCname mirrors it for backend.supports() checks. * rota-core: add DcvChallenge::DnsCname + ChallengeKind::DnsCname, update kind/kind_str/label match arms. * namecheap CA backend: return DnsCname for HostName/Target, keep Dns01 for TxtName/TxtValue. * namecheap DCV backend: factor a `challenge_parts` helper that carries the record_type ("TXT" or "CNAME"), declare both kinds in supported_kinds, drop the redundant is_txt helper. publish + remove share the merge dance regardless of record type. * cloudflare DCV backend: same shape, plus thread record_type through find_matching_record so the lookup query specifies type. * acme backend: explicit unreachable arms for DnsCname in the preference filter and challenge constructor (ACME never emits a CNAME challenge per RFC 8555). Tests pass (107 daemon, no test changes required since the variant addition flows through existing structural matchers). --- crates/rota-core/src/backend.rs | 31 ++++- crates/rota-daemon/src/backends/acme.rs | 7 ++ crates/rota-daemon/src/backends/cloudflare.rs | 63 +++++------ .../rota-daemon/src/backends/namecheap/ca.rs | 32 +++--- .../rota-daemon/src/backends/namecheap/dcv.rs | 107 +++++++++++------- 5 files changed, 148 insertions(+), 92 deletions(-) diff --git a/crates/rota-core/src/backend.rs b/crates/rota-core/src/backend.rs index cfa155c..4611dce 100644 --- a/crates/rota-core/src/backend.rs +++ b/crates/rota-core/src/backend.rs @@ -37,7 +37,14 @@ 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, } @@ -45,6 +52,7 @@ impl ChallengeKind { pub fn as_str(&self) -> &'static str { match self { Self::Dns01 => "dns-01", + Self::DnsCname => "dns-cname", Self::Http01 => "http-01", } } @@ -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. + /// `_.example.com`). + record_name: String, + /// Target FQDN the CNAME must point to (e.g. + /// `...comodoca.com`). + record_value: String, + /// Time-to-live for the CNAME record in seconds. + ttl: u32, + }, /// HTTP-01: serve `key_authorization` at /// `http:///.well-known/acme-challenge/` over /// plain HTTP on port 80. The CA fetches the URL and signs once @@ -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, } } @@ -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)"), } } diff --git a/crates/rota-daemon/src/backends/acme.rs b/crates/rota-daemon/src/backends/acme.rs index ca74aea..aa5b1e2 100644 --- a/crates/rota-daemon/src/backends/acme.rs +++ b/crates/rota-daemon/src/backends/acme.rs @@ -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) }); @@ -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 { @@ -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); } diff --git a/crates/rota-daemon/src/backends/cloudflare.rs b/crates/rota-daemon/src/backends/cloudflare.rs index c470467..87e9e8a 100644 --- a/crates/rota-daemon/src/backends/cloudflare.rs +++ b/crates/rota-daemon/src/backends/cloudflare.rs @@ -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 { @@ -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(()) } } @@ -267,13 +265,14 @@ impl CloudflareDcv { async fn find_matching_record( &self, zone_id: &str, + record_type: &str, name: &str, value: &str, ) -> Result> { let records: Vec = 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( diff --git a/crates/rota-daemon/src/backends/namecheap/ca.rs b/crates/rota-daemon/src/backends/namecheap/ca.rs index 669d933..98e3f87 100644 --- a/crates/rota-daemon/src/backends/namecheap/ca.rs +++ b/crates/rota-daemon/src/backends/namecheap/ca.rs @@ -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 -> . The CA expects a CNAME, NOT a TXT; + // hostname-validity rules at registrar APIs differ between the + // two record types (Namecheap rejected `_` 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 @@ -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 { diff --git a/crates/rota-daemon/src/backends/namecheap/dcv.rs b/crates/rota-daemon/src/backends/namecheap/dcv.rs index ea999f3..bf80bf9 100644 --- a/crates/rota-daemon/src/backends/namecheap/dcv.rs +++ b/crates/rota-daemon/src/backends/namecheap/dcv.rs @@ -62,7 +62,47 @@ impl NamecheapDcv { } } -const SUPPORTED: &[ChallengeKind] = &[ChallengeKind::Dns01]; +const SUPPORTED: &[ChallengeKind] = &[ChallengeKind::Dns01, ChallengeKind::DnsCname]; + +/// Parts of a `DcvChallenge` rota's namecheap DCV cares about, +/// independent of the DNS record type. Pulling these out lets +/// `publish` and `remove` share the get-merge-set dance regardless +/// of TXT vs CNAME. +struct ChallengeParts<'a> { + record_name: &'a str, + record_value: &'a str, + ttl: u32, + record_type: &'static str, +} + +fn challenge_parts(challenge: &DcvChallenge) -> Result> { + match challenge { + DcvChallenge::Dns01 { + record_name, + record_value, + ttl, + } => Ok(ChallengeParts { + record_name, + record_value, + ttl: *ttl, + record_type: "TXT", + }), + DcvChallenge::DnsCname { + record_name, + record_value, + ttl, + } => Ok(ChallengeParts { + record_name, + record_value, + ttl: *ttl, + record_type: "CNAME", + }), + _ => Err(Error::Registrar(format!( + "namecheap dcv only supports dns-01 and dns-cname, got {}", + challenge.kind_str() + ))), + } +} #[async_trait] impl DcvBackend for NamecheapDcv { @@ -75,62 +115,47 @@ impl DcvBackend for NamecheapDcv { } async fn publish(&self, challenge: &DcvChallenge) -> Result<()> { - let DcvChallenge::Dns01 { - record_name, - record_value, - ttl, - } = challenge - else { - return Err(Error::Registrar(format!( - "namecheap dcv only supports dns-01, got {}", - challenge.kind_str() - ))); - }; - let split = split_record_name(record_name)?; + let parts = challenge_parts(challenge)?; + let split = split_record_name(parts.record_name)?; let mut hosts = self.get_hosts(&split.sld, &split.tld).await?; - // Idempotent: if the exact (host, value) already exists, no-op. - if hosts - .iter() - .any(|h| h.is_txt() && h.name == split.subdomain && h.address == *record_value) - { - debug!(record = %record_name, "namecheap txt already present"); + // Idempotent: if the exact (type, host, value) already exists, no-op. + if hosts.iter().any(|h| { + h.record_type.eq_ignore_ascii_case(parts.record_type) + && h.name == split.subdomain + && h.address == parts.record_value + }) { + debug!(record = %parts.record_name, kind = %challenge.kind_str(), "namecheap dcv record already present"); return Ok(()); } hosts.push(HostRecord { name: split.subdomain, - record_type: "TXT".to_owned(), - address: record_value.clone(), + record_type: parts.record_type.to_owned(), + address: parts.record_value.to_owned(), mx_pref: "10".to_owned(), - ttl: (*ttl).max(60), + ttl: parts.ttl.max(60), }); - info!(record = %record_name, "namecheap publishing dcv txt"); + info!(record = %parts.record_name, kind = %challenge.kind_str(), "namecheap publishing dcv record"); self.set_hosts(&split.sld, &split.tld, &hosts).await } async fn remove(&self, challenge: &DcvChallenge) -> Result<()> { - let DcvChallenge::Dns01 { - record_name, - record_value, - .. - } = challenge - else { - return Err(Error::Registrar(format!( - "namecheap dcv only supports dns-01, got {}", - challenge.kind_str() - ))); - }; - let split = split_record_name(record_name)?; + let parts = challenge_parts(challenge)?; + let split = split_record_name(parts.record_name)?; let hosts = self.get_hosts(&split.sld, &split.tld).await?; let filtered: Vec = hosts .into_iter() - .filter(|h| !(h.is_txt() && h.name == split.subdomain && h.address == *record_value)) + .filter(|h| { + !(h.record_type.eq_ignore_ascii_case(parts.record_type) + && h.name == split.subdomain + && h.address == parts.record_value) + }) .collect(); - info!(record = %record_name, "namecheap removing dcv txt"); + info!(record = %parts.record_name, kind = %challenge.kind_str(), "namecheap removing dcv record"); self.set_hosts(&split.sld, &split.tld, &filtered).await } } @@ -144,12 +169,6 @@ struct HostRecord { ttl: u32, } -impl HostRecord { - fn is_txt(&self) -> bool { - self.record_type.eq_ignore_ascii_case("TXT") - } -} - #[derive(Debug, Clone)] struct SplitName { subdomain: String, @@ -278,6 +297,6 @@ mod tests { assert_eq!(hosts.len(), 3); assert_eq!(hosts[0].name, "@"); assert_eq!(hosts[1].record_type, "CNAME"); - assert!(hosts[2].is_txt()); + assert!(hosts[2].record_type.eq_ignore_ascii_case("TXT")); } }