Skip to content

feat(wildcard-certs): per-zone DNS-01 provider selection - #94

Merged
mhenrixon merged 2 commits into
dashfrom
issue-89-per-zone-dns-provider
Aug 9, 2026
Merged

feat(wildcard-certs): per-zone DNS-01 provider selection#94
mhenrixon merged 2 commits into
dashfrom
issue-89-per-zone-dns-provider

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

  • Commit 1 (Collapse the ACME provider factory into a single registry table #72): collapses internal/server/acme/providers/factory.go's five per-provider edit sites into one registry table (registry.go) plus a single detectionOrder slice, with a test that fails by name if they drift apart. Pure refactor — all three Cloudflare credential paths, route53's no-boot-check contract, and error messages naming the missing variables behave identically.
  • Commit 2 (Per-zone DNS-01 provider selection #89): --acme-dns-provider becomes repeatable and accepts zone=provider mappings alongside the existing bare form (internal/server/acme/mapping.go). At issuance time the provider is selected by longest-suffix zone match; one lego client per distinct provider, all on the same ACME account (internal/server/san_cert_zones.go).
  • One ACME order never spans providers: obtainCertificate refuses a spanning order as an invariant violation, and every batch assembler keeps orders provider-pure — the handshake path narrows to the requested domain's partition (san_cert_manager.go), the dynamic issuer groups batches by partition (domain_issuer.go), and renewal splits a pre-mapping mixed-zone certificate into per-partition orders (domain_renewal.go).
  • Mapped providers validate credentials at startup: a broken mapping fails the boot, while the bare default keeps its HTTP-fallback softness.
  • Wildcard auto-collapse now only happens for zones some DNS provider can actually validate.

Closes #72
Closes #89

Test plan

  • make test (full suite, 1900+ tests) and go test -race ./internal/server/
  • gofmt -l internal/ cmd/ empty, go vet ./..., make lint clean
  • Smoke: kamal-proxy run --acme-email … --acme-dns-provider platform.example=cloudflare --acme-dns-provider legacy.example=hetzner against staging, one wildcard per zone issued via its own provider
  • Boot failure check: mapping naming a provider with no credentials refuses to start

Deviations & judgment calls

  • Collapse the ACME provider factory into a single registry table #72 folded into this branch. Per-zone DNS-01 provider selection #89 explicitly depends on the registry refactor (Collapse the ACME provider factory into a single registry table #72, still open). Stacking two PRs adds churn since the second cannot land without the first; the refactor is commit 1, the feature commit 2, one PR closes both.
  • Registry struct diverges from Collapse the ACME provider factory into a single registry table #72's sketch. The issue proposed only Required [][]string, but three distinct notions exist today: display metadata, the boot credential check (route53 has none — the AWS SDK resolves credentials itself, including IAM roles), and detection (route53 detects on AWS_ACCESS_KEY_ID or AWS_PROFILE). Collapsing them would change behavior; the registry keeps Required/Alternatives/NoBootCheck/Detect so every existing credential combination behaves identically.
  • Invalid bare provider name is now a startup error. Previously --acme-dns-provider clodflare logged a warning and silently continued on HTTP-01 only. With mappings validated strictly at startup, keeping a lenient path for the bare form alone would be inconsistent — and the old behavior silently disabled DNS-01.
  • Handshake batches narrow instead of multi-order. When the pending batch spans provider partitions, the handshake-driven provision issues only the requested domain's partition and returns the rest to pending (they were pending already). Issuing every partition synchronously would multiply handshake latency by the number of providers.
  • Renewal of a mixed-zone cert splits per partition; the old certificate is removed only when every partition succeeded, so a failed partition's domains keep serving the old cert until the next reconcile. ARI replaces rides the first partition only — a certificate can be "replaced" once.
  • Partition boundary is the provider, not the zone — two zones at the same DNS host share an order. The default path never merges with a mapped partition, since auto hides which provider it resolved to.
  • Discovery: autoDetectProvider today effectively always "detects" route53 if reached, because its constructor has no credential check and the AWS SDK constructs without credentials. Pre-existing behavior, preserved exactly; worth its own issue.

…table

Every DNS provider used to be spread across five edit sites: the lego
import, a ProviderInfo entry, a switch case, a bespoke constructor with a
hand-written credential check, and two ordered lists that had to agree by
convention. One registry table now carries all of it, and a test pins the
detection order to the registry so a new provider cannot be forgotten.

Behavior is unchanged for every existing credential combination: all three
Cloudflare paths, route53's no-boot-check contract, and credential errors
that still name the missing variables.

Closes #72
--acme-dns-provider selected one provider process-wide, so a fleet whose
wildcard zones live at different DNS hosts could never issue the second
zone's wildcard. The flag now maps zones to providers, keeping the current
forms working: zone=provider entries pin a zone to its DNS host, one bare
entry stays the default for unmatched zones.

Selection is by longest matching zone suffix at issuance time, and one ACME
order never spans providers: the handshake batch narrows to the requested
domain's partition, the dynamic issuer assembles provider-pure batches, and
renewal of a pre-mapping mixed-zone certificate splits along provider
boundaries. Every mapped provider validates its credentials at startup, on
the shared ACME account.

## Test Coverage
- ParseProviderEntries: bare/mapped grammar, auto refused in mappings,
  duplicate zones and defaults refused
- ProviderFor: longest-suffix matching on label boundaries, wildcards
- obtainCertificate: routes by zone, refuses spanning orders, keeps the
  wildcard-needs-DNS refusal and HTTP-01 fallback
- provisionCertificate: narrows a spanning handshake batch, defers the rest
- domainIssuer.nextBatch: never spans provider partitions
- certRenewer: splits mixed certs, keeps the old cert when a partition fails
- run.go: flag/env parsing lands in config, invalid names fail preRun

## Verification
- [x] gofmt -l internal/ cmd/ clean
- [x] make test + go test -race ./internal/server/ pass
- [x] make lint (golangci-lint) clean

Closes #89
@mhenrixon mhenrixon self-assigned this Aug 9, 2026
@mhenrixon
mhenrixon merged commit 93b42ab into dash Aug 9, 2026
2 checks passed
@mhenrixon
mhenrixon deleted the issue-89-per-zone-dns-provider branch August 9, 2026 18:02
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.

Per-zone DNS-01 provider selection Collapse the ACME provider factory into a single registry table

1 participant