Skip to content

feat(gateway): safely rotate ACME credentials - #935

Merged
kvinwang merged 5 commits into
masterfrom
codex/fix-gateway-corrupt-acme-credentials
Aug 5, 2026
Merged

feat(gateway): safely rotate ACME credentials#935
kvinwang merged 5 commits into
masterfrom
codex/fix-gateway-corrupt-acme-credentials

Conversation

@kvinwang

@kvinwang kvinwang commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

A corrupt persisted ACME credential must not take down Gateway or discard certificates that are already serving traffic. Operators also need a controlled way to replace the shared ACME account and its account-bound CAA records — including the documented staging→production directory switch, where the previous behavior silently registered a fresh account while every domain's CAA stayed pinned to the old one, blocking all issuance.

Solution

Fail closed on corrupt credentials — at both layers. A stored credential record that no longer decodes (WaveKV MessagePack layer) or whose embedded JSON is malformed fails the affected issuance/renewal attempt with an error pointing at the rotation RPC, without affecting Gateway startup, TLS serving, or other certificate state. get_acme_credentials distinguishes a missing/deleted key (None) from corruption (Err); treating corruption as absence would silently register a fresh account that the account-bound CAA records refuse. The previous silent re-registration was not a real self-heal for the same reason.

Admin-only RotateAcmeCredentials RPC, sequenced so that failures are convergent:

  1. Validate every ZT domain's DNS credential up front (constructing the DNS client resolves the zone through an authenticated API call). A misconfigured domain aborts here with zero side effects and no rate-limited ACME registration consumed.
  2. Register the replacement account.
  3. Publish the replacement credential to WaveKV immediately. Nodes create an ACME client per operation, so they pick it up on their next attempt after synchronization.
  4. Re-pin issue/issuewild CAA records for every ZT domain, best effort across all domains; the first domain reuses the registration client. Failed domains are named in the returned error together with the recovery path.
  5. Attest the new account last: attestation does not gate issuance, so its agent round trips must not widen the publish→re-pin window. It runs even on partial re-pin failure so the new account is recorded.

Publishing before re-pinning makes partial failure recoverable without registering another account: rerun SetCaa (idempotent) until it succeeds. With the reverse order, every retry would burn one of Let's Encrypt's 10 new registrations per IP per 3 hours and the already-re-pinned domains would forbid the still-published old account.

Cross-node serialization via a best-effort TTL lock in WaveKV, alongside the in-process guard shared with CAA reconciliation. WaveKV has no CAS, so the lock narrows the concurrent-rotation window to replication latency rather than guaranteeing mutual exclusion; operators should still rotate through one gateway at a time. Release is guarded: a rotation that outlived the timeout does not delete the lock of the node that took over.

ACME URL mismatch now refuses to auto-register and points at RotateAcmeCredentials, which re-pins CAA along with the switch. The gateway setup tutorial's staging→production step now includes the rotation call.

AcmeInfo.account_uri derives from the published credentials instead of the best-effort attestation record, so the dashboard reflects a rotation even when quote generation is unavailable; the attestation quote is attached only when it matches the current account.

Private ACME credential material is never returned through the API.

Known limitations

Verification

  • cargo test -p dstack-gateway — 77 passed (distributed_certbot: rotation lock/precondition cases, stale-holder release guard, URL matching, CAA reconciliation guards; kv::acme_credentials_tests: corrupt-record fail-closed, tombstone vs corruption)
  • cargo clippy -- -D warnings -D clippy::expect_used -D clippy::unwrap_used --allow unused_variables — clean (workspace, CI flags)
  • cargo fmt --all -- --check, git diff --check

Copilot AI review requested due to automatic review settings July 31, 2026 03:13

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.

@kvinwang
kvinwang force-pushed the codex/feat-gateway-exact-sni-certificates branch from 13cde75 to f6190fd Compare August 4, 2026 15:25
@kvinwang
kvinwang force-pushed the codex/fix-gateway-corrupt-acme-credentials branch from d2c19d6 to 2e4c354 Compare August 4, 2026 15:25
@kvinwang kvinwang changed the title [STACKED on #934] fix(gateway): fail closed on corrupt ACME credentials [STACKED on #934] feat(gateway): safely rotate ACME credentials Aug 5, 2026
@kvinwang
kvinwang force-pushed the codex/feat-gateway-exact-sni-certificates branch from f6190fd to 48f65e3 Compare August 5, 2026 05:10
@kvinwang
kvinwang force-pushed the codex/fix-gateway-corrupt-acme-credentials branch from 4649a10 to 823e39a Compare August 5, 2026 05:10
@kvinwang
kvinwang force-pushed the codex/fix-gateway-corrupt-acme-credentials branch from 823e39a to 888e360 Compare August 5, 2026 05:45
@kvinwang
kvinwang deleted the branch master August 5, 2026 05:45
@kvinwang kvinwang closed this Aug 5, 2026
@kvinwang kvinwang reopened this Aug 5, 2026
@kvinwang kvinwang changed the title [STACKED on #934] feat(gateway): safely rotate ACME credentials feat(gateway): safely rotate ACME credentials Aug 5, 2026
@kvinwang
kvinwang changed the base branch from codex/feat-gateway-exact-sni-certificates to master August 5, 2026 05:48
Rework RotateAcmeCredentials so a partial failure no longer strands the
cluster on an unpublished account:

- validate every domain's DNS credential before registering the
  replacement account, so a misconfigured domain aborts with no side
  effects and no rate-limited ACME registration consumed
- publish the new credentials before re-pinning CAA; a partial re-pin
  now converges by rerunning SetCaa instead of registering yet another
  account on every retry
- re-pin CAA best-effort across all domains and name the failed ones,
  with the recovery path, in the returned error
- serialize rotation across nodes with a best-effort TTL lock in WaveKV;
  without CAS it narrows the race window rather than guaranteeing mutual
  exclusion, and the comments say so
- refuse to auto-register a fresh account on ACME URL mismatch, which
  left every domain's CAA pinned to the old account; point at
  RotateAcmeCredentials, which re-pins CAA along with the switch
- derive AcmeInfo.account_uri from the published credentials so the
  dashboard reflects a rotation even when the best-effort attestation
  write is skipped; attach the quote only when it matches that account
- add the rotation step to the staging-to-production tutorial

Verification:
- cargo test -p dstack-gateway: 74 passed (7 in distributed_certbot)
- cargo clippy -p dstack-gateway --all-features -- -D warnings --allow unused_variables: clean
…ocking

Address review findings on the rotation rework:

- get_acme_credentials now distinguishes a missing or deleted key from a
  record that no longer decodes; corruption at the WaveKV encoding layer
  previously read as absence and silently registered a fresh ACME
  account that the account-bound CAA records refuse. Issuance and
  AcmeInfo now fail with a pointer at RotateAcmeCredentials instead.
- move account attestation after CAA re-pinning: its agent round trips
  do not gate issuance and must not widen the window where the
  published account and the CAA records disagree; still run it on
  partial re-pin failure so the new account is recorded
- release the rotation lock only when the visible value is the one this
  rotation wrote, so a holder that outlived the timeout cannot delete
  the lock of the node that took over
- re-pin the first domain through the registration client instead of
  constructing a second DNS client and re-fetching the ACME directory;
  this also removes an expect() that failed CI's panic lints
  (-D clippy::expect_used)

Verification:
- cargo test -p dstack-gateway: 77 passed (new: corrupt-record
  fail-closed at the KV layer, tombstone vs corruption, stale-holder
  lock release guard)
- cargo clippy -- -D warnings -D clippy::expect_used -D clippy::unwrap_used
  --allow unused_variables: clean (workspace)
- cargo fmt --all -- --check: clean
@kvinwang
kvinwang merged commit 5831c59 into master Aug 5, 2026
16 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