Skip to content

Commit 9c9da5e

Browse files
committed
docs: document dns-persist-01 certificate issuance
1 parent 617e8a9 commit 9c9da5e

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
- guest-agent: v1 `Attest` accepts `include_boottime_gpu_evidence` and returns the boot-time GPU attestation evidence in `AttestResponse.boottime_gpu_evidence`, so a verifier fetches the attestation and the GPU evidence in one round trip. It arrives as a `GpuEvidenceBundle` list -- the same shape `AttestGpu` returns, so a consumer writes one bundle parser and dispatches on `format`: `nvidia-nvattest-boottime-json-v1` is the record written at boot, `nvidia-nvattest-collect-evidence-json-v1` is collected on demand against a caller's nonce, and a verifier for one does not appraise the other. Absence is the empty list. The bundle's `evidence` is the nvattest output byte for byte as read from disk, because the only thing binding it to the boot is sha256 over precisely those bytes against the measured `gpu-attestation` event. `Attest` is also v1's sole CVM attestation entry point: the `VersionedAttestation` it returns already carries the TDX quote and event log, and unlike `GetQuote` it answers on every supported platform
2626
- sdk: `AppCompose` in the Go SDK gained `init_script`, `storage_fs`, `swap_size`, `event_log_version`, `port_policy` and `verity_volumes`, and `Requirements` gained `gpu_policy` in the Go and Python SDKs
2727
- shared API authentication (`dstack-api-auth`) protecting the full VMM HTTP/pRPC/UI surface and unifying Gateway/KMS admin auth: bearer/`X-Admin-Token`/HTTP Basic/bcrypt htpasswd, constant-time verification (#796)
28+
- certbot/gateway: opt-in `dns-persist-01` certificate validation ([draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist-01)), which issues without a DNS provider credential at all. `dns-01` needs write access to the zone on every order, so a gateway CVM holds a Cloudflare API token for the life of the deployment -- a token that rewrites the whole zone, not just `_acme-challenge`. Under `dns-persist-01` the zone owner publishes one `_validation-persist.<name>` TXT record naming the CA and the ACME account; nothing about it changes between orders, so certbot only ever reads DNS and the zone can be hosted anywhere, with no provider integration. Set `challenge = "dns-persist-01"` in `certbot.toml`, or `challenge` on a gateway ZT domain; the default stays `dns-01` and existing deployments are untouched. `certbot dns-records` prints the records to publish, `GetZtDomain`/`ListZtDomains` return them in `required_dns_records`, and the gateway logs them wherever it would otherwise have written DNS. CAA records carry `validationmethods=dns-persist-01` to match, so switching methods means republishing both. Two gateway operations change shape for such a domain: `SetCaa` skips it, having nothing to reconcile without write access, and `RotateAcmeCredentials` leaves it broken until the operator republishes -- the record still names the old account -- so the response now returns the new records in `required_dns_records`. **Experimental**: the draft is still changing, and Let's Encrypt serves the challenge on staging only pending an open working-group issue. Documented in `docs/certbot-dns-persist-01.md`
2829
- gateway: `Admin.Status` reports `health_gating`, so an operator can see whether this node's health polling is switched on. With it off, instances that opted in sit at `unknown` forever and are all in rotation, which is otherwise indistinguishable on the dashboard from being held out pending a first answer
2930
- gateway: `Admin.SetInstanceReady` takes a CVM instance out of its app's load-balancing rotation without stopping it; instance-id routing stays open so the instance can still be investigated, and the setting survives re-registration
3031
- gateway: operator-set per-instance overrides now live under their own KV keys — `admin/<instance_id>/ready` and `admin/<instance_id>/port_policy` — instead of inside the instance record, so a CVM re-registration can no longer drop them and setting one cannot discard a peer's unsynced change to the other. An override left in an instance record by an earlier build is moved across on load

docs/certbot-dns-persist-01.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Certificate issuance without a DNS credential (`dns-persist-01`)
2+
3+
`dns-01` asks certbot to write a fresh `_acme-challenge` TXT record for every
4+
order, so whatever runs certbot holds a DNS API token with write access to the
5+
zone, forever. In dstack that token lives inside the gateway CVM. Attestation
6+
covers what the CVM is running, but a token is a token: anything that gets hold
7+
of it can rewrite the zone, including records that have nothing to do with
8+
certificates.
9+
10+
`dns-persist-01` moves the proof out of the issuance loop. The zone owner
11+
publishes one record naming the CA and the ACME account allowed to issue:
12+
13+
```dns
14+
_validation-persist.example.com. IN TXT "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567890"
15+
```
16+
17+
The account key proves who is asking, the record proves the zone owner agreed,
18+
and neither changes between orders. certbot reads DNS and never writes it, so
19+
the CVM holds no DNS credential and the zone can be hosted anywhere — no
20+
Cloudflare account, no API token, no provider integration.
21+
22+
> **Experimental.** `dns-persist-01` is specified in
23+
> [draft-ietf-acme-dns-persist-01][draft], which is still changing: an open
24+
> working-group issue may add a client-key-derived value to the record, and
25+
> Let's Encrypt has said it will not deploy the challenge to production until
26+
> that is resolved. It is live on Let's Encrypt **staging** and in
27+
> [Pebble][pebble]. Treat the record format as unstable, and expect to
28+
> republish when the draft settles.
29+
30+
[draft]: https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist-01
31+
[pebble]: https://github.com/letsencrypt/pebble
32+
33+
## What the record means
34+
35+
| Part | Effect |
36+
| --- | --- |
37+
| `letsencrypt.org` | Issuer Domain Name. A CA ignores records naming a different issuer, so one label can hold records for several CAs. |
38+
| `accounturi=` | The ACME account authorized to issue. Compared byte for byte — no case folding, no URI normalization. |
39+
| `policy=wildcard` | Extends the record to `*.example.com`. Without it the CA authorizes `example.com` alone and refuses wildcard orders. |
40+
| `persistUntil=` | Optional UNIX timestamp after which the CA stops accepting the record. |
41+
42+
A wildcard order authorizes from its base name — `*.example.com` is validated
43+
against `_validation-persist.example.com`, not
44+
`_validation-persist.*.example.com` — so one record covers a name and its
45+
wildcard.
46+
47+
Two things about the syntax bite in practice, because a CA rejects the whole
48+
record rather than ignoring the offending part: **no trailing semicolon**, and
49+
**no whitespace inside a value**. certbot renders records that satisfy both;
50+
copy them verbatim rather than retyping.
51+
52+
The scope stops at the names above. Let's Encrypt does not walk up the tree, so
53+
a record on `example.com` does not authorize `sub.example.com` — give each base
54+
name its own record.
55+
56+
## Standalone certbot
57+
58+
`certbot` never writes DNS in this mode, so setup is: create the account, read
59+
the records off it, publish them, then issue.
60+
61+
```toml
62+
# certbot.toml
63+
workdir = "/var/lib/certbot"
64+
acme_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
65+
challenge = "dns-persist-01"
66+
issuer_domain_name = "letsencrypt.org"
67+
# auto_set_caa promises certbot keeps CAA in sync, which it cannot do without
68+
# write access. Leave it off and publish the CAA records below by hand.
69+
auto_set_caa = false
70+
domains = ["example.com", "*.example.com"]
71+
renew_interval = 3600
72+
renew_days_before = 10
73+
renew_timeout = 120
74+
max_dns_wait = 300
75+
```
76+
77+
`cf_api_token` is unused and can be left out; certbot warns if one is set.
78+
79+
```console
80+
$ certbot init -c certbot.toml
81+
INFO certbot::bot: creating new ACME account
82+
INFO certbot::bot: created new ACME account: https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890
83+
84+
$ certbot dns-records -c certbot.toml
85+
_validation-persist.example.com. IN TXT "letsencrypt.org; accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890; policy=wildcard"
86+
example.com. IN CAA 0 issue "letsencrypt.org;validationmethods=dns-persist-01;accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890"
87+
example.com. IN CAA 0 issuewild "letsencrypt.org;validationmethods=dns-persist-01;accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890"
88+
```
89+
90+
Publish all three, wait for them to propagate, then issue:
91+
92+
```console
93+
$ certbot renew --once -c certbot.toml
94+
INFO certbot::acme_client: requesting new certificates for example.com, *.example.com
95+
INFO certbot::bot: created new certificate
96+
```
97+
98+
Renewals need nothing further. The record stays, and `certbot renew` reuses it
99+
for every order.
100+
101+
The CAA records are optional but recommended — they stop any other account, at
102+
Let's Encrypt or elsewhere, from being issued for your name. Note the
103+
`validationmethods=dns-persist-01` in them: a CAA record left pinned to
104+
`dns-01` refuses every `dns-persist-01` order, so switching methods means
105+
updating CAA and the validation record together.
106+
107+
### When issuance fails
108+
109+
certbot checks its own resolver before starting an order, and says exactly what
110+
it expected to find:
111+
112+
```
113+
WARN certbot::acme_client: no TXT record at _validation-persist.example.com matches the expected value: letsencrypt.org; accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890; policy=wildcard
114+
Error: order is invalid: API error: No valid TXT record found for DNS-PERSIST-01 challenge (urn:ietf:params:acme:error:unauthorized)
115+
```
116+
117+
The check is advisory and never blocks an order: certbot's resolver is not the
118+
CA's, and its expectation can be stricter than what the CA would accept. A
119+
warning with a successful issuance underneath it is a resolver difference, not
120+
a problem.
121+
122+
If the CA rejects the order, compare the published record against
123+
`certbot dns-records` character by character. The usual causes are a stale
124+
`accounturi` after the account was recreated, a missing `policy=wildcard` on a
125+
wildcard order, and an issuer domain name that does not match the CA.
126+
127+
## dstack-gateway
128+
129+
A ZT domain picks its method with the `challenge` field, which defaults to
130+
`dns-01` — existing domains are unaffected:
131+
132+
```json
133+
{ "domain": "app.example.com", "port": 443, "challenge": "dns-persist-01" }
134+
```
135+
136+
Such a domain needs no `dns_cred_id`, and the gateway CVM never receives a DNS
137+
credential for it. `GetZtDomain` and `ListZtDomains` return the records to
138+
publish in `required_dns_records`, and the gateway logs them whenever it cannot
139+
write DNS itself:
140+
141+
```
142+
WARN cert[app.example.com]: publish this record by hand: _validation-persist.app.example.com. IN TXT "letsencrypt.org; accounturi=...; policy=wildcard"
143+
```
144+
145+
The gateway issues for `*.{domain}` only, so each domain needs one validation
146+
record with `policy=wildcard`.
147+
148+
For a non-production ACME server, set `issuer_domain_name` in the global
149+
certbot config to whatever that server puts in `issuer-domain-names`
150+
`pebble.letsencrypt.org` for Pebble. Empty means `letsencrypt.org`.
151+
152+
Two operations behave differently on these domains:
153+
154+
- **`SetCaa`** skips them. There is nothing to reconcile without write access;
155+
the records are logged instead, and the summary reports how many were left to
156+
the operator.
157+
- **`RotateAcmeCredentials`** is not self-service. Rotation moves the cluster
158+
to a new ACME account, and every `_validation-persist` record still names the
159+
old one, so orders for those domains fail until the operator republishes. The
160+
response returns the new records in `required_dns_records` and the gateway
161+
logs them; publish before the next renewal comes due.
162+
163+
## Related
164+
165+
- [dstack-gateway](dstack-gateway.md) — gateway architecture and TLS termination
166+
- [deployment.md](deployment.md#4-zero-trust-https-optional) — the `dns-01` setup this replaces

0 commit comments

Comments
 (0)