Skip to content

feat(certbot): issue certificates via dns-persist-01 - #1132

Open
kvinwang wants to merge 23 commits into
nextfrom
feat/certbot-dns-persist-01
Open

feat(certbot): issue certificates via dns-persist-01#1132
kvinwang wants to merge 23 commits into
nextfrom
feat/certbot-dns-persist-01

Conversation

@kvinwang

@kvinwang kvinwang commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every dstack certificate today is issued with dns-01, which requires a fresh
_acme-challenge TXT record per order. That means whatever runs certbot holds a
Cloudflare API token with write access to the operator's zone, permanently —
and in the gateway's case that token lives inside a CVM:

// dstack/gateway/src/kv/mod.rs
pub enum DnsProvider {
    Cloudflare { api_token: String, .. },
}

Attestation covers what the CVM is running. It does not cover what becomes of a
long-lived secret the CVM holds. So the widest credential in a dstack deployment
— one that can rewrite MX, A, anything — exists solely to write one TXT
record per order and delete it again. It also forces the base domain onto a
provider certbot has an integration for; today that list is exactly
{Cloudflare}.

Fix

Add dns-persist-01 (draft-ietf-acme-dns-persist-01, CA/Browser Forum
ballot SC-088v3) as an opt-in validation method. The zone owner publishes one
record naming the CA and the ACME account:

_validation-persist.example.com. IN TXT "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567890; policy=wildcard"

The account key proves who is asking, the record proves the zone owner agreed,
and nothing about it changes between orders. certbot only ever reads DNS, so the
CVM holds no DNS credential at all and the zone can be hosted anywhere.

Shape of the change

ValidationMethod replaces the Dns01Client field on AcmeClient, so the
dns-01-only state — the provider client and the TXT TTL — lives in the one
variant that uses it, and dns-persist-01 cannot be constructed holding a
credential it would never call:

pub enum ValidationMethod {
    Dns01 { client: Dns01Client, txt_ttl: u32 },
    DnsPersist01 { issuer_domain_name: String },
}

Opt in per deployment (challenge = "dns-persist-01" in certbot.toml) or per
gateway ZT domain (ZtDomainConfig.challenge). The default stays dns-01
everywhere and existing deployments are untouched
; a stored ZtDomainConfig
written before the field existed decodes as dns-01, pinned by a test over both
the named and the legacy positional msgpack encodings.

Because certbot cannot write the records under this method, the records are
the setup, and they are surfaced everywhere an operator would look:
certbot dns-records prints them, GetZtDomain/ListZtDomains return them in
required_dns_records, and the gateway logs them wherever it would otherwise
have written DNS.

Details worth a reviewer's attention

CAA has to move with the method. The value was hardcoded to
validationmethods=dns-01; a record left pinned to that refuses every
dns-persist-01 order (and vice versa). It now names the method in use. The
dns-01 string is byte-for-byte unchanged, pinned by a test, so records published
by earlier releases keep matching.

The self-check stays advisory. Our resolver is not the CA's, and under
dns-persist-01 our expectation can be stricter than the CA's — instant-acme
does not expose the challenge's issuer-domain-names, so we compare against the
configured one. A record we cannot see is named in a warning and the order
proceeds; the check can never fail an issuance that would have worked.

Two gateway operations cannot be self-service, and say so instead of failing
silently:

  • SetCaa skips such a domain and logs the records. One dns-persist-01 domain
    must not make the RPC unusable for the dns-01 domains beside it.
  • RotateAcmeCredentials is the sharp edge: it moves the cluster to a new
    account while every _validation-persist record still names the old one, so
    orders for those domains fail until the operator republishes. The response now
    carries the new records in required_dns_records, rendered after the switch
    so they name the account the cluster actually moved to, and domains_updated
    counts only domains whose CAA was re-pinned.

The record grammar rejects rather than ignores. It is an RFC 8659
issue-value, and a CA fails the whole record on a trailing semicolon, a
repeated tag, or whitespace inside a value. The parser here mirrors the one CAs
run (Boulder's va/dns_persist.go), so certbot never renders a record the CA
would refuse nor accepts one it would.

Verification

End-to-end against Let's Encrypt staging, with a real Cloudflare-hosted
zone (kvin.wang) and no DNS credential in the certbot config at all -- the
three records were published by hand from what dns-records printed:

$ certbot init -c persist.toml
INFO certbot::bot: created new ACME account: https://acme-staging-v02.api.letsencrypt.org/acme/acct/329633914

$ certbot dns-records -c persist.toml
_validation-persist.persist01.kvin.wang. IN TXT "letsencrypt.org; accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/329633914; policy=wildcard"
persist01.kvin.wang. IN CAA 0 issue "letsencrypt.org;validationmethods=dns-persist-01;accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/329633914"
persist01.kvin.wang. IN CAA 0 issuewild "letsencrypt.org;validationmethods=dns-persist-01;accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/329633914"

# published all three verbatim, then:
$ certbot renew --once -c persist.toml
DEBUG certbot::acme_client: requesting new certificates for persist01.kvin.wang, *.persist01.kvin.wang
INFO certbot::bot: created new certificate

$ certbot renew --once --force -c persist.toml
INFO certbot::acme_client: renewed certificate for /tmp/le-persist/wd/live/cert.pem
subject=CN = persist01.kvin.wang
issuer=C = US, O = Let's Encrypt, CN = (STAGING) Artificial Amaranth YE1
X509v3 Subject Alternative Name:
    DNS:*.persist01.kvin.wang, DNS:persist01.kvin.wang
notBefore=Aug 25 13:17:56 2026 GMT   notAfter=Nov 23 13:17:55 2026 GMT

The single record answered both authorizations at the real CA, which is the
claim this design rests on: the debug log shows the base-name authorization
expecting letsencrypt.org; accounturi=... and the wildcard one expecting the
same with ; policy=wildcard, both satisfied by the one published record.

After issuance and a forced renewal, the zone holds exactly the three records
that were published by hand -- no _acme-challenge, nothing added, nothing
removed. certbot never wrote DNS, which is the point of the mode.

Failure path at the real CA. A second account (fresh workdir, so a fresh
accounturi) against the same published record:

WARN certbot::acme_client: no TXT record at _validation-persist.persist01.kvin.wang matches the expected value: letsencrypt.org; accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/329634364; policy=wildcard
Error: order is invalid: API error: Checking DNS-PERSIST-01 challenge TXT record with issuer-domain-name "letsencrypt.org": accounturi mismatch: expected "https://acme-staging-v02.api.letsencrypt.org/acme/acct/329634364", got "https://acme-staging-v02.api.letsencrypt.org/acme/acct/329633914" (urn:ietf:params:acme:error:unauthorized)

Staging's rejection is more specific than Pebble's, so the doc now quotes this
one; it also notes that a genuinely mismatched record costs the full
max_dns_wait before the order is sent, since the advisory check waits out its
budget first.

End-to-end against Pebble v2.10.1, which implements the draft
(va.validateDNSPersist01, _validation-persist, policy=wildcard,
persistUntil), with pebble-challtestsrv as the zone. Full flow, no DNS
credential anywhere in the config:

$ certbot init -c certbot.toml
INFO certbot::bot: created new ACME account: https://pebble:14000/my-account/634e4f419da14086

$ certbot dns-records -c certbot.toml
_validation-persist.e2e.test. IN TXT "pebble.letsencrypt.org; accounturi=https://pebble:14000/my-account/634e4f419da14086; policy=wildcard"
e2e.test. IN CAA 0 issue "pebble.letsencrypt.org;validationmethods=dns-persist-01;accounturi=https://pebble:14000/my-account/634e4f419da14086"
e2e.test. IN CAA 0 issuewild "pebble.letsencrypt.org;validationmethods=dns-persist-01;accounturi=https://pebble:14000/my-account/634e4f419da14086"

# publish the TXT record once, then:
$ certbot renew --once -c certbot.toml
INFO certbot::acme_client: requesting new certificates for e2e.test, *.e2e.test
INFO certbot::bot: created new certificate

$ certbot renew --once --force -c certbot.toml     # renewal touches no DNS
INFO certbot::bot: renewed certificate for /e2e/workdir/live/cert.pem
X509v3 Subject Alternative Name: critical
    DNS:e2e.test, DNS:*.e2e.test
notBefore=Aug 25 09:53:33 2026 GMT   notAfter=Nov 23 09:53:32 2026 GMT

One record covers the bare name and the wildcard — which is the behaviour the
CA implements, not an assumption: a wildcard authorization strips the *. and
looks up the base name.

Failure paths, same harness. With a record naming a different account, the
warning names exactly what was expected and the CA rejects the order:

WARN certbot::acme_client: no TXT record at _validation-persist.e2e.test matches the expected value: pebble.letsencrypt.org; accounturi=https://pebble:14000/my-account/592e796ac9c5cd4b; policy=wildcard
Error: order is invalid: API error: No valid TXT record found for DNS-PERSIST-01 challenge (urn:ietf:params:acme:error:unauthorized)

auto_set_caa = true, set-caa, and a leftover cf_api_token each produce
their own message rather than a silent skip:

Error: auto_set_caa is not supported with dns-persist-01, which has no DNS write access; set auto_set_caa = false and publish the records from `certbot dns-records` by hand
Error: cannot set CAA records without DNS write access; publish the records printed by `certbot dns-records` instead
WARN certbot::bot: ignoring cf_api_token: dns-persist-01 needs no DNS provider credential

dns-01 regression, live. The same binary, same Pebble, against the repo's
mock-cf-dns Cloudflare API from test-suites/full-stack-compose — a wildcard
certificate still issues through the provider-API path:

$ certbot renew --once -c certbot-dns01.toml
INFO certbot::acme_client: requesting new certificates for *.e2e.test
INFO certbot::bot: created new certificate

Unit tests. 20 in dns_persist covering the grammar against the draft's own
example and each rule a CA enforces (case-insensitive tags, byte-exact
accounturi, wildcard policy in both directions, trailing semicolon, duplicate
tag, whitespace in a value, persistUntil expiry, several records at one
label); 6 in acme_client pinning the rendered records, including the
byte-identical dns-01 CAA value; 1 in gateway/src/kv pinning the
ZtDomainConfig decode.

Rebased onto next after #1129 (the instant-acme upgrade this needed),
#1130 (authoritative-nameserver DNS checks) and #1133 (the ACME follow-ups)
landed. Both merges kept the incoming structure whole:

  • fix(certbot): read the dns-01 challenge from the authoritative nameservers #1130's check_dns resolves each challenge's authoritative nameservers and
    falls back to the system resolver. Only the "does this answer match" step
    widens, from an exact comparison to whatever the challenge expects. Its zone
    walk now drops any leading underscore label rather than _acme-challenge
    specifically, so _validation-persist takes the same path instead of burning
    a round trip on a name that cannot carry NS records.
  • fix(certbot): tighten the ACME challenge-domain rule and the paths that report failures #1133's challenge_domain helper — which exists because
    AuthorizedIdentifier's Display renders the wildcard prefix and would
    publish at _acme-challenge.*.example.com — is generalised over the challenge
    kind rather than duplicated. That trap is identical for _validation-persist,
    so its test now asserts both methods keep the bare name.

Every e2e run below is on the rebased tree.

dns-01 regression, live. The same binary, same Pebble, against the repo's
mock-cf-dns Cloudflare API from test-suites/full-stack-compose — a wildcard
certificate still issues through the provider-API path:

$ certbot renew --once -c certbot-dns01.toml
INFO certbot::acme_client: requesting new certificates for *.e2e.test
INFO certbot::bot: created new certificate

Sidenote, not something this PR changes: dns-01 with both example.com
and *.example.com in one order fails on next today, and fails identically
with this branch reverted, so it is pre-existing rather than a regression. The
two authorizations are answered at the same _acme-challenge.example.com, and
authorize() calls remove_txt_records() before each write, so the second
deletes the first's record and only one challenge can pass. Worth its own
issue. dns-persist-01 does not have the problem — one persistent record
satisfies both authorizations, which is what the two-SAN run above exercises.

Build/lint. cargo fmt --all --check, cargo clippy -- -D warnings -D clippy::expect_used -D clippy::unwrap_used --allow unused_variables (the CI
invocation), cargo clippy -p certbot -p certbot-cli -p dstack-gateway --all-targets -- -D warnings, cargo test -p certbot -p dstack-gateway
(31 + 290 passing), prek run --all-files, and reuse lint all pass.

Not covered: a live run against Let's Encrypt staging. It offers the
challenge today, but with accounturi omitted from the challenge object
(Boulder's AccountURIPrefix is unset there), so the account URI comes from our
own kid either way — which is what this implementation does, since
instant-acme exposes neither field. Pebble is the closer match to the current
draft.

Review follow-ups

Five findings from review, each verified against the CA's own implementation
rather than taken on description:

The self-check read records more strictly than Boulder does. RFC 8659's
grammar is parameter = tag *WSP "=" *WSP value and Boulder trims each half
separately (va/dns_persist.go); this trimmed the parameter as a whole, so
accounturi = <uri> parsed its tag as "accounturi " and reported the
mandatory parameter missing. Boulder also enforces uniqueness only on tags it
recognizes, since the draft has it ignore unknown ones outright, and folds the
issuer name (lowercase, root dot dropped) before deciding whether a record is
its own. All three now match, with the empty-accounturi case Boulder rejects
added too.

The cost was not theoretical. Against Let's Encrypt staging, with the published
record padded around its separators -- legal, and issued from -- before and
after:

$ certbot renew --once --force -c persist.toml     # before
WARN certbot::acme_client: DNS propagation timeout after 191.9s, max wait time is 180s. proceeding anyway...
WARN certbot::acme_client: no TXT record at _validation-persist.persist01.kvin.wang matches the expected value: ...
INFO certbot::bot: created new certificate
real 3m18.6s

$ certbot renew --once --force -c persist.toml     # after
INFO certbot::bot: renewed certificate for /tmp/le-persist/wd/live/cert.pem
real 0m4.9s

Issuance succeeded either way -- the check is advisory -- but every renewal
burned the full wait and logged two warnings naming a record that was in fact
correct.

The gateway's issuer_domain_name could not be set. It was read by the
dns-persist-01 path and documented as the knob for a non-Let's-Encrypt ACME
server, but no proto field, RPC or config path ever wrote it, so it was
permanently empty and the gateway was hardwired to letsencrypt.org -- against
Pebble, every order would have failed with no way to correct it. It is now a
field on SetCertbotConfigRequest/CertbotConfigResponse and in the
dashboard's Certbot Configuration.

A dashboard edit silently downgraded a domain to dns-01. UpdateZtDomain
replaces the whole record and an empty challenge decodes as dns-01, but the
edit form never round-tripped the field: changing a priority would have moved a
dns-persist-01 domain onto the dns-01 branch, which then fails on the DNS
credential such a deployment deliberately does not have. The form now carries
the challenge forward, the add form offers it (so the UI can create these
domains at all), and the listing shows which challenge a domain uses instead of
naming a credential a dns-persist-01 domain never has.

The advisory wait could not stay advisory. DNS_PERSIST_MAX_DNS_WAIT was
300s, the same as the default renew_timeout that wraps the whole order, and
the wait is measured only after the order and its authorizations are fetched --
so the outer timeout always fired first, aborting with "certificate request
timed out" instead of proceeding to the CA, and the warning an operator is told
to look for was never logged. Fixed for both challenges in the round below, by
deriving the wait from renew_timeout instead of picking a constant.

issuer_domain_name was ignored on dns-01. The key documents itself as
naming the CA "in dns-persist-01 and CAA records", but dns-01 hardcoded
letsencrypt.org, so an operator running dns-01 against another CA with
auto_set_caa on would have published CAA forbidding the very CA in use. Both
methods now read the configured name, whose default is letsencrypt.org, so an
untouched configuration writes exactly what it wrote before.

Bootstrapping without a domain

RotateAcmeCredentials used to refuse to run until a ZT domain existed, which
put a fresh dns-persist-01 deployment in a loop: the record an operator has to
publish names the ACME account, the gateway only registered one lazily on its
first issuance attempt, and that attempt could not succeed before the record was
published. The way out was to trigger a renewal, let it fail, and read the
records off the account the failure had registered.

Registration asserts nothing about a domain -- it is a POST to newAccount,
with no DNS access and no challenge involved -- so the requirement was an
artifact of AcmeClient::new_account taking a ValidationMethod in order to
return a client. AcmeClient::register_account now returns just the account
(AcmeAccount { credentials, account_uri }), new_account is that plus a
load, and rotation registers before it looks at any domain. A cluster with no
domain registers and stops there; one with domains re-pins their CAA exactly as
before, and the "first domain reuses the registration client" special case is
gone with it -- every domain now takes the same path.

The setup order in the doc is correspondingly straight: SetCertbotConfig
RotateAcmeCredentialsAddZtDomain → publish the records it reports →
RenewZtDomainCert.

Verified against Let's Encrypt staging through the CLI, which registers over the
same path: a fresh workdir registers acct/329645064 and dns-records
immediately renders the record naming it, while the existing account still
renews (DNS:*.persist01.kvin.wang, DNS:persist01.kvin.wang), so the
register-then-load split did not disturb the account it replaces. The
gateway's own no-domain path is covered by a unit test pointed at an unroutable
directory: it fails at the ACME server rather than on a missing domain, which is
what proves the requirement is gone.

Second review round

Two reviews came back on the fixes above; between them they found that one of
those fixes was half a fix, and that another had traded a dormant bug for a
sharper one. Both were right.

The DNS wait now derives from the order budget instead of being a constant.
120s fixed the dns-persist-01 arm and left dns-01 — the arm nearly every
deployment uses — with the same 300s-inside-300s collision, and it was a
hardcoded assumption about renew_timeout, which an operator can lower from the
dashboard to 30s. It is now one rule for both: advisory_dns_wait(configured, renew_timeout) caps the wait at half the budget, so the check always reaches
its "proceed anyway" exit and the warning naming the missing record.

The CLI has the same shape and worse defaults — renew_timeout 120s wrapping a
300s max_dns_wait — which neither review looked at, so the clamp lives in the
certbot crate and both callers use it. Against Let's Encrypt staging, same
config (renew_timeout = 120, max_dns_wait = 300), same missing record:

$ certbot renew --once -c persist.toml      # before
Error: requesting cert timeout
real 2m0.1s

$ certbot renew --once -c persist.toml      # after
WARN certbot::acme_client: DNS propagation timeout after 63.9s, max wait time is 60s. proceeding anyway...
WARN certbot::acme_client: no TXT record at _validation-persist.persist01.kvin.wang matches the expected value: letsencrypt.org; accounturi=.../acct/329634364; policy=wildcard
Error: order is invalid: API error: ... accounturi mismatch: expected ".../acct/329634364", got ".../acct/329633914"
real 1m6.4s

Before, the operator gets requesting cert timeout and no idea why. After, they
get the record that was missing and the CA's own verdict.

issuer_domain_name is validated where it is set. Making it reach dns-01
CAA turned an inert config key into a load-bearing one, and nothing checked it:
set_caa_records installs a ; guard, deletes the existing issue/issuewild
records
, then writes the new content — so "lets encrypt.org", or an explicit
"" on the CLI path (which passed the empty string straight through where the
gateway mapped it to letsencrypt.org), would leave the zone holding a
malformed or empty-issuer issue property and nothing valid behind it. That is
CAA denying every issuer, published by certbot itself.

resolve_issuer_domain_name now does both jobs for every caller: empty means
letsencrypt.org, and a value that is not a DNS name is refused —
SetCertbotConfig rejects it, and certbot refuses to start with it.

The corrupt-record repair path can no longer forget it. merge_certbot_config
required four fields to replace an unreadable record and then started from the
defaults, so a deployment on Pebble that repaired its config silently reverted to
letsencrypt.org — the exact hazard acme_url is in that required set for.
issuer_domain_name joins it. renew_timeout_secs = 0 is refused too, since the
DNS wait is derived from it.

The dashboard no longer hides the setup. showAddZtDomainModal() blanked
every field except the challenge select, so a second add silently inherited the
first one's choice; it resets now. And AddZtDomain returns
required_dns_records which the page discarded — an operator picking
dns-persist-01 got "ZT-Domain added" and no hint that nothing would be issued
until they published a record. The records are now shown after the add, and
again behind a Records button on each dns-persist-01 row, since rotation
invalidates them.

Minor: the KV field's doc comment still said the setting was read only by
dns-persist-01, and set_certbot_config's audit log listed every field except
the one just changed. Both fixed. The setup-order doc also lost the
issuer-name guidance when it was rewritten in 4b47a448; it is back, with the
new rules.

cargo test -p certbot -p dstack-gateway: 43 + 296.

Third review round

The round above introduced one regression and left three edges; all four are
closed here, verified against Pebble.

The records dialog fired for dns-01 domains, with dns-persist-01 copy.
Mine, from f162c07d5b. required_dns_records is non-empty for dns-01 too --
required_dns_records skips the TXT record but still returns the two CAA lines
-- so adding an ordinary dns-01 domain popped a dialog saying the gateway
"holds no DNS credential for it and cannot write these itself", every clause of
which is false there, ending with an instruction to hand-publish records the
gateway writes itself. Gated on the challenge, like the Records button in the
same commit already was.

The dashboard now has the button its own message points at. The empty-records
path tells an operator to register an ACME account first, and the documented
setup order makes that step 2 -- but RotateAcmeCredentials had no control
anywhere in the dashboard, so a dashboard-only operator hit a dead end. Added to
the Certbot Configuration section, behind a confirmation that says what rotation
costs, and it shows the records the response returns.

new_account could drop an account it had just registered. Splitting
register_account out left new_account as register-then-load, and load
fetches the ACME directory -- so a transient failure in that window returned
Err after newAccount had succeeded, losing credentials neither caller had
persisted yet and spending one of Let's Encrypt's 10 registrations per IP per 3
hours. The two halves are now taken from one call: register returns the live
account and the credentials together, new_account builds the client from what
it already holds, and register_account encodes the same credentials for
callers that want only those. No second request, no window.

The DNS wait overshot the budget it had just been clamped to. check_dns
slept then checked its deadline, so it overran by up to a full backoff step
(32s). The clamp reserves half of renew_timeout, which the overshoot could eat
whole: at renew_timeout = 64 the wait is 32s, but the sleep checkpoints land
at 31.75s, so the loop slept again and reached its exit at ~63.8s -- past the
order's budget. On Pebble, same config, same missing record:

$ certbot renew --once -c persist.toml     # before
WARN DNS propagation timeout after 63.768947807s, max wait time is 32s. proceeding anyway...
Error: requesting cert timeout
elapsed 65s

$ certbot renew --once -c persist.toml     # after
WARN DNS propagation timeout after 32.00083212s, max wait time is 32s. proceeding anyway...
Error: order is invalid: API error: ... accounturi mismatch: expected ".../551f3c8bd38b2ed0", got ".../2f7d3ea2d45e5b4d"
elapsed 34s

The deadline is now checked before sleeping and the sleep is cut to what is left
of it, so the exit lands on the cap rather than past it and the order still has
its budget. The clamp also logs when it lowers a configured value, which it
previously did silently -- an operator raising max_dns_wait and seeing no
change had nothing to read.

The issuer name validator was looser than the grammar it protects. It
accepted _letsencrypt.org, -letsencrypt.org, letsencrypt-.org and labels of
any length, none of which match RFC 8659's label = (ALPHA/DIGIT) *( *("-") (ALPHA/DIGIT) ) -- so a strict CA reads the issue property as malformed, which
is the state the validator exists to keep out of a zone, reached through a value
it waved through. Now held to that grammar, with the 63-octet DNS label bound.

Full dns-persist-01 flow re-verified on Pebble after the registration change:
account registered, records printed, published with padded separators (the
parser fix's case), certificate issued for DNS:p3.e2e.test, DNS:*.p3.e2e.test.

cargo test -p certbot -p dstack-gateway: 44 + 296.

Fourth review round

Every finding this round was in the dashboard button the previous round added,
which is a fair verdict on where new surface area costs the most.

A partial rotation is now reported, not raised. do_rotate_acme_credentials
bailed when a domain's CAA could not be re-pinned — after registering the
account and publishing the credentials. The dashboard rendered that as "Failed
to register ACME account", so the obvious next move is clicking again, which
registers another account against a rate-limited quota and rewrites CAA a second
time: exactly what the bail's own text warned against. It also meant
required_dns_records never reached the caller on the one path where the
records matter most. RotateAcmeCredentialsResponse gains
repin_failed_domains; the rotation returns success, names the domains, and the
log keeps the full "rerun SetCaa, do not rotate again" line at error!.

The Account URI no longer goes stale. It is server-rendered, and rotation
only refreshed the ZT-domain table — so the one authoritative place an operator
reads the account kept showing the replaced one for the rest of the session,
while the dialog beside it showed records naming the new one. The cell is
updated from the response.

The confirmation says what the button actually does. It warned about
republishing dns-persist-01 records — true — and said nothing about the two
things that can hurt: it registers a rate-limited account, and it rewrites the
CAA of every dns-01 domain by installing a ; guard and deleting the existing
records first, so a provider failure part-way through can leave a domain with
CAA that blocks all issuance until a later SetCaa succeeds.

Polish. The new DNS-budget line fired on every issuance in every deployment
— the stock defaults have both values at 300s, so configured > capped is
always true — and was worded as if an operator setting had been overridden; it
is now debug! and phrased as the budget it is. And the issuer-name validator's
five rejection reasons had been merged into one message that named no character:
my_ca.example.com was refused without mentioning the underscore, which is the
character this round newly started rejecting and the one most likely to be
reached for. Each reason says what it rejected again, with a test on the
underscore case.

cargo test -p certbot -p dstack-gateway: 45 + 296.

Maturity

Deliberately opt-in and documented as experimental. draft-01 is the latest
revision, and Let's Encrypt has stated it will not deploy to production
until an open working-group issue about client-computed record
content is resolved — that change would alter the record format. Nothing here
runs unless an operator sets challenge, and the default path is untouched.

Documented in docs/certbot-dns-persist-01.md.

Copilot AI lite review requested due to automatic review settings August 25, 2026 09:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Base automatically changed from fix/acme-dns-persist-challenge to next August 25, 2026 10:07
@kvinwang kvinwang changed the title feat(certbot): issue certificates without a DNS credential via dns-persist-01 feat(certbot): issue certificates via dns-persist-01 Aug 25, 2026
@kvinwang kvinwang closed this Aug 25, 2026
@kvinwang kvinwang reopened this Aug 25, 2026
@kvinwang
kvinwang force-pushed the feat/certbot-dns-persist-01 branch from aba2699 to 9c9da5e Compare August 25, 2026 11:07
dns-01 rewrites the zone on every order, so certbot holds a DNS provider
credential for the life of the deployment. dns-persist-01
(draft-ietf-acme-dns-persist-01) proves control with a `_validation-persist`
TXT record published once, out of band: the record names the CA and the ACME
account, nothing about it changes between orders, and certbot only ever reads
DNS.

`ValidationMethod` replaces the `Dns01Client` field on `AcmeClient`, so the
dns-01-only state -- the provider client and the TXT TTL -- lives in the one
variant that has any use for it, and dns-persist-01 cannot be constructed
holding a credential it would never call. `check_dns` widens from an exact
match on the key authorization to matching whatever the challenge expects,
and the cleanup pass skips records certbot did not create: a persistent
record is the operator's and outlives every order.

The self-check stays advisory for both methods. Our resolver is not the CA's,
and under dns-persist-01 our expectation can be stricter than the CA's -- the
challenge's `issuer-domain-names` are not exposed by instant-acme -- so a
record we cannot see is named in a warning and the order proceeds.

CAA content now names the challenge in use, because a record pinned to
`validationmethods=dns-01` refuses every dns-persist-01 order. The dns-01
string is unchanged byte for byte, pinned by a test, so records published by
earlier releases keep matching.

`required_dns_records` renders the whole one-time setup -- validation record
plus CAA -- from an account URI rather than a live client, so a caller holding
only stored credentials can render it without a round trip to the CA. The
record grammar is an RFC 8659 issue-value, and the parser mirrors what CAs
run down to the parts that reject rather than ignore (trailing semicolon,
repeated tag, whitespace in a value), so certbot never renders a record the
CA would refuse or accepts one it would.

The gateway keeps its dns-01 behaviour; the call sites move to the new
constructor unchanged.
Under dns-persist-01 certbot cannot write the records it needs, so the
records are the setup. `certbot dns-records` prints them as zone-file lines
for the configured domains, ready to paste into any provider, once
`certbot init` has registered the account they name.

`challenge` and `issuer_domain_name` join certbot.toml, and `cf_api_token`
becomes optional -- a token left configured alongside dns-persist-01 is
warned about rather than silently ignored. `auto_set_caa` is refused outright
with dns-persist-01: it promises certbot keeps CAA in sync, and without write
access nothing can keep that promise.
A gateway CVM running dns-01 holds a Cloudflare token with write access to
the operator's whole zone. Attestation covers what the CVM runs, not what
becomes of a secret it holds, so that token is the widest credential in the
deployment and it exists only to write one TXT record per order.
dns-persist-01 removes it: control comes from a `_validation-persist` record
the operator publishes once, and the CVM never gets DNS write access at all.

`ZtDomainConfig.challenge` picks the method per domain and defaults to
dns-01, so records written before the field existed decode as the method
those deployments were using -- pinned by a test over both the named and the
legacy positional msgpack encodings. Such a domain needs no DNS credential,
and `validation_for` never looks one up for it.

`GetZtDomain` and `ListZtDomains` return the records to publish in
`required_dns_records`, rendered from the stored account URI with no ACME
round trip so the listing endpoints stay cheap; it comes back empty rather
than failing when no account exists yet.

Two operations cannot be self-service for such a domain, and say so rather
than failing silently:

- `SetCaa` skips it and logs the records instead. There is nothing to
  reconcile without write access, and one such domain must not make the RPC
  unusable for the dns-01 domains beside it; the summary reports how many
  were left to the operator.
- `RotateAcmeCredentials` moves the cluster to a new account while every
  `_validation-persist` record still names the old one, so orders for those
  domains fail until the operator republishes. The response now carries the
  new records in `required_dns_records`, rendered after the switch so they
  name the account the cluster actually moved to, and `domains_updated`
  counts only the domains whose CAA was re-pinned.
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