Skip to content

fix(ingress): CAA correctness for Route53, wildcards and the dnsguide check - #139

Merged
kvinwang merged 1 commit into
mainfrom
fix/ingress-caa
Oct 15, 2026
Merged

kvinwang merged 1 commit into
mainfrom
fix/ingress-caa

Conversation

@kvinwang

@kvinwang kvinwang commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Why

  • Route53 create_caa_record ignored the requested name and value. It upserted hard-coded letsencrypt.org + Amazon issuers at the zone apex, dropped accounturi/validationmethods, and under DELEGATION_ZONE (where this CAA is the only protection) wrote the record where the CA never looks.
  • A CAA beside a CNAME does not work by the DNS rules: resolvers follow the CNAME and never see the CAA, and Route53 and Linode refuse the pair. Delegation (gateway pointer + CAA at <domain>.<zone>) and SET_CAA on a served name both put the two on one name. Cloudflare is the exception: it answers a CAA query with the CAA beside a CNAME (verified on real deployments).
  • Wildcard deployments published only issuewild. Per RFC 8659 that does not restrict non-wildcard names, so any CA could issue for foo.<base>.
  • dnsguide.py treated a CAA set without the wanted tag (only iodef, or only issuewild for a plain name) as forbidding issuance, and did not fall back to issue for wildcards. Valid setups waited out DNS_SETUP_TIMEOUT and exited 1.
  • The generic set_* helpers in base.py ignored delete failures and returned at the first matching record, so a stale accounturi pin could survive forever.

What

  • The provider picks the pointer form, as before. Callers say the name carries a CAA (dnsman.py set_alias --with-caa, used by dns01.sh for the delegated name and for a non-wildcard domain with SET_CAA=true). set_alias_record then publishes an A record (set_address_record, replacing any CNAME/AAAA) unless the provider sets CAA_BESIDE_CNAME, which Cloudflare does, so Cloudflare keeps the CNAME. All other deployments keep a CNAME. Trade-off for the A record (documented): it follows a gateway move only every RENEW_INTERVAL, and only one gateway address is published. Linode still always publishes A. If the name already holds a CAA, for example after SET_CAA is turned off (the container cannot tell its own CAA from the operator's, so it leaves it), set_alias keeps the A record and logs why, instead of failing on a rejected or ineffective CNAME.
  • Route53: set_address_record deletes the CNAME/AAAA and upserts the A in one change batch, so the name never goes unanswered. Record names are decoded from Route53's octal escapes (\052 → *), so wildcard records are found. set_caa_record merges the value into the record set at the requested name in one UPSERT. Only values with the same tag are replaced, and a repeat run makes no change. A failed CAA listing makes dnsman.py exit 1.
  • base.py: set_a/cname/txt/caa_record delete every non-matching record (for CAA, only those with the same tag) and return False if any delete fails. set_a_record, set_txt_record and set_caa_record create the new value first and only then delete the stale ones (for CAA, same-tag only), so a failed create leaves the old A/TXT/CAA in place instead of none (under delegation the CAA is the only issuance guard). Values may coexist briefly. Only the CNAME↔A switch keeps delete-then-create, since the two cannot coexist. Route53 and Namecheap (fix(ingress): publish Namecheap CAA records instead of skipping them #144) override it with a single zone write. The delegation hook's challenge TXT is the exception: it passes set_txt --tolerate-stale, because an extra challenge value does no harm.
  • dns01 (SET_CAA and delegation paths): wildcards publish both issue and issuewild, and dnsguide prints both.
  • Behaviour change for *.<zone apex> with SET_CAA=true: the issue record at the apex replaces every existing issue value there, including the Amazon issuers that older versions wrongly added on Route53. It governs every name in the zone that has no CAA of its own. The container warns once per start when the base answers SOA for itself.
  • README also documents two cases. First, two deployments with different ACME accounts serving x.com and *.x.com overwrite each other's issue record. Second, a wildcard's webhook payload contains two CAA records with the same name, so receivers must not upsert by (type, name).
  • check_caa: a set with no relevant tag is unrestricted, and a wildcard falls back to issue when there is no issuewild. A critical record with an unknown tag still forbids issuance. The known tags are Let's Encrypt's (issue, issuewild, iodef, issuemail, issuevmc), and flag bit 1 counts as critical, as in Boulder.

Relies on #135's Namecheap fixes (name-scoped records, content-based deletes) and #144's Namecheap CAA writes, both merged. Namecheap now uses the base set_alias_record, so going back to a CNAME also removes the A record.

Tests

Re-run on top of current main (after #133, #135, #136, #143, #144) with the same fake-provider lab: every case below passes. With CAA_BESIDE_CNAME Cloudflare publishes CNAME + CAA under delegation and SET_CAA, with no writes on rerun; Linode, Namecheap and Route53 are unchanged. The original results:

  • scripts/tests/test_dnsguide.py: 33 pass. scripts/tests/test_route53.py (new): 3 pass. The escaped-name test fails on the previous head. Both suites pass on the host and in dstack-ingress:2.6.
  • File-backed fake provider APIs for Cloudflare, Linode, Namecheap and Route53 (fake boto3). The fakes enforce CNAME exclusivity, Route53 lists * as \052, and the DoH mock follows CNAMEs the way a resolver does. The test runs process_domain from dns01.sh with stubbed issuance. Namecheap was run on this branch merged with fix(ingress): stop Namecheap updates from wiping unrelated records #135 and fix(ingress): publish Namecheap CAA records instead of skipping them #144.
    • Delegation, app.example.com and *.app.example.com, rc 0 on all four providers: A + TXT + CAA (issue, plus issuewild for the wildcard) at <name>.deleg.net, and the delegation checks pass. A rerun makes no writes. Upgrading from a CNAME at the delegated name: Route53 sends DELETE CNAME + UPSERT A in one batch.
    • SET_CAA on app.example.com with an existing CNAME: replaced by A, and the CAA is written, on all four providers. A rerun makes no writes.
    • SET_CAA on *.example.com: the wildcard keeps its CNAME (Linode: A, as before). CAA issue + issuewild are written at the apex. The apex warning appears on the first pass only.
    • app.example.com + *.app.example.com together: the base gets A + CAA, and the wildcard keeps its CNAME.
    • SET_CAA on, then off: rc 0 on all four providers. The A and CAA records stay, and later passes make no writes. Before this fix, Route53 deleted the A record and then failed the CNAME write. A plain domain whose zone has a CAA only at the apex still gets a CNAME.
    • Namecheap, SET_CAA on, then off, then the CAA deleted: A + CAA, then A kept while the CAA exists (no writes), then only a CNAME at the name. set_alias --with-caa followed by plain set_alias leaves only the CNAME (before, both A and CNAME). Unrelated www A and blog CNAME are untouched, and reruns make no writes.
    • Plain deployment (SET_CAA=false): CNAME on Cloudflare, Namecheap and Route53. A rerun makes no writes.
    • Route53 upgrade from main with a wildcard: existing \052.example.com. CNAME, Amazon issuewild values and iodef at the apex. Result: rc 0 and the CNAME is kept. The Amazon values are replaced by issue/issuewild with accounturi, and iodef is kept. A rerun makes no writes. set_alias --with-caa on *.example.com with the escaped CNAME: one batch, then no change. An unresolvable gateway exits 1 with no change.
    • CAA replacement under failures (dnsman.py set_caa, old issue/issuewild/iodef present): Cloudflare/Linode with DNSMOCK_FAIL=<p>:create exit 1 and keep the old issue (before: exit 1 with no issue left). With <p>:delete, exit 1 with old and new present, pruned on the next pass. Success leaves exactly the new issue and keeps issuewild/iodef; a rerun makes no writes. Route53 (r53:change) and Namecheap (nc:sethosts) are unchanged. The full process_domain SET_CAA run (on/off, account change, wildcard) gives the same records as before, with only the DELETE moved after the POST.
  • A and TXT replacement under failures (set_alias --with-caa with a moved gateway, set_txt): a failed create (cf:create, linode:create, nc:sethosts, r53:change) exits 1 and keeps the old value on all four providers (before: Cloudflare and Linode were left with none). Success leaves exactly the new value, and a rerun makes no writes; Namecheap and Route53 now need one write instead of two. CNAME→A and A→CNAME switches are unchanged. The full process_domain matrix gives identical exit codes; only Cloudflare/Linode create/delete-failure cases differ, now keeping the old A/TXT.
  • Hook auth when the stale-TXT delete fails (Cloudflare, Linode, Route53): the new value is written and the hook exits 0. Plain set_txt still exits 1.
  • shellcheck -x: no new findings.

import os
import sys
import types
import unittest
@kvinwang
kvinwang changed the base branch from main to fix/ingress-namecheap October 14, 2026 10:46
@kvinwang
kvinwang force-pushed the fix/ingress-namecheap branch from a4c5bb9 to 2958653 Compare October 15, 2026 14:25
@kvinwang
kvinwang changed the base branch from fix/ingress-namecheap to main October 15, 2026 14:36
… check

- Route53 create_caa_record ignored the requested name and value: it
  upserted hard-coded issuers at the zone apex without accounturi, so
  under DELEGATION_ZONE the only issuance guard was written where the CA
  never looks. It now merges the value into the record set at the
  requested name in one UPSERT, replacing only the same tag. Record
  names are decoded from Route53's octal escapes (\052 -> *).
- Where a CAA shares the name with the gateway pointer (the delegated
  name, or a plain domain with SET_CAA=true), callers tell
  set_alias_record so (--with-caa) and the provider picks the form, as
  before. By the DNS rules a CNAME hides a CAA beside it, and Route53
  and Linode refuse the pair, so the default is an A record via the new
  set_address_record (one change batch on Route53). Cloudflare answers a
  CAA query with the CAA beside a CNAME (CAA_BESIDE_CNAME) and keeps the
  CNAME. A name that already holds a CAA is treated the same way.
- Wildcards published only issuewild, which per RFC 8659 leaves
  foo.<base> unrestricted; they now publish issue as well, and warn once
  per start when the base is a zone apex.
- check_caa treated a set without the relevant tag as forbidding
  issuance and did not fall back to issue for wildcards; it now follows
  RFC 8659 tag relevance and the critical flag as Boulder does.
- The base set_* helpers ignored delete failures and stopped at the
  first match, so a stale accounturi pin could survive. They now create
  the new value first, then delete every stale one, failing if a delete
  fails (the delegation hook's challenge TXT tolerates a stale value).

Signed-off-by: Kevin Wang <wy721@qq.com>
@kvinwang
kvinwang merged commit a91142a into main Oct 15, 2026
6 checks passed
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.

2 participants