Skip to content

feat(dcv): add DnsCname challenge variant for Sectigo CSR-hash flow#38

Merged
albedosehen merged 1 commit into
mainfrom
feat/dcv-cname-variant
May 9, 2026
Merged

feat(dcv): add DnsCname challenge variant for Sectigo CSR-hash flow#38
albedosehen merged 1 commit into
mainfrom
feat/dcv-cname-variant

Conversation

@albedosehen
Copy link
Copy Markdown
Contributor

Why

Sectigo CSR-hash DCV (the Namecheap reissue default) requires a CNAME, not a TXT. rota's namecheap CA backend was returning the HostName/Target pair as Dns01 with a comment acknowledging the gap. Namecheap then rejected the underscore-prefixed hostname when rota's namecheap DCV backend submitted it as TXT:

CA backend error: namecheap api error: 2050900: INVALID_NAME: Host name: '_CE9ECBF...' is invalid.

What

DcvChallenge::DnsCname + ChallengeKind::DnsCname carry the same payload shape as Dns01 but tell the DCV backend to publish a CNAME instead of a TXT. Hostname-validity rules at registrar APIs differ between the two record types — TXT rejects what CNAME accepts and vice versa, so the type has to flow end-to-end.

  • rota-core: new variant, kind/kind_str/label updated.
  • namecheap CA: return DnsCname for HostName/Target; Dns01 stays for TxtName/TxtValue (legacy products).
  • namecheap DCV: declare both kinds, factor challenge_parts so publish/remove share the merge dance regardless of record type.
  • cloudflare DCV: same; thread record_type through find_matching_record so the query specifies type.
  • acme: explicit unreachable arms — RFC 8555 never emits a CNAME challenge.

Verified

  • cargo fmt --all --check clean
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo test --workspace --locked 152 tests pass (rota-core 40, rota-daemon 107, rota-cli 5)

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).
@albedosehen albedosehen merged commit 5cff79c into main May 9, 2026
1 check passed
@albedosehen albedosehen deleted the feat/dcv-cname-variant branch May 9, 2026 17:52
albedosehen added a commit that referenced this pull request May 9, 2026
rota's `get_info` was looking for `<CertificateReturned>` element
text and `<CACertificate>` element text. Neither matches Namecheap's
actual `ssl.getInfo&Returncertificate=true` response, which carries
`CertificateReturned` as an ATTRIBUTE on `<Certificates>` and packs
PEMs in nested `<Certificate>` elements:

  <Certificates CertificateReturned="true" ReturnType="INDIVIDUAL">
    <Certificate><![CDATA[LEAF_PEM]]></Certificate>
    <CaCertificates>
      <Certificate Type="INTERMEDIATE">
        <Certificate><![CDATA[INTERMEDIATE_1_PEM]]></Certificate>
      </Certificate>
      ...
    </CaCertificates>
  </Certificates>

Result: cert_pem and chain_pem both empty, `is_issued()` false,
polling never terminates even when status==active. So PR #40's
chain-follow lands on the right SSL ID but `await_issuance` still
hangs at the extraction step. Found by extracting the cert manually
out of band when `getInfo` returned status=active for oneiric.dev's
in-flight order: rota's parser yielded empty strings even though
the PEMs were sitting right there in the response.

Fix: new `ApiResponse::pem_blocks(label)` method scans the raw
response for `-----BEGIN <label>-----`...`-----END <label>-----`
armor and returns each block in document order. `get_info` calls
`pem_blocks("CERTIFICATE")`; first block is the leaf, rest are the
chain (concatenated with newlines). The CSR present in the same
response is safely skipped because its label is "CERTIFICATE
REQUEST" and `BEGIN CERTIFICATE-----` doesn't substring-match
`BEGIN CERTIFICATE REQUEST-----`.

This is the 6th and (hopefully) final layer in the rota+Namecheap
end-to-end renewal pipeline, after PRs #36 (reverted), #37 (CDATA
unwrap), #38 (DnsCname variant), #39 (lowercase HostName), #40
(ReplacedBy chain + Status XML path). Tests: 3 new in xml::tests
covering the leaf+chain extraction, the CSR-skip rule, and the
empty-input edge case. Total daemon test count 111 (was 108).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant