Skip to content

Decouple deploy-registered domains from cross-service SAN batches and bound renewal deferral #102

Description

@mhenrixon

Problem

Two compounding design choices couple a deploy-registered "primary" domain's certificate lifecycle to unrelated hosts, concentrating its renewal risk into the last week before expiry:

1. Handshake batches span services. provisionCertificate (internal/server/san_cert_manager.go) collects all pending deploy-registered hosts into the triggering handshake's order — up to MaxSANsPerCertificate (100) — partitioned by ACME directory (#100) and DNS-provider zone, but not by service. The dynamic issuer already batches per-service (domain_issuer.go nextBatch); the handshake path doesn't. Consequence: service A's primary domain lands on one certificate with service B's and C's hosts, and stays coupled to them for the certificate's entire life.

2. Quarantine deferral pushes coupled renewals to the wire. certRenewer.renew (internal/server/domain_renewal.go) defers renewal of a partially-quarantined or partially-unreachable certificate until quarantineCompactionWindow (7 days before expiry), then compacts. The trade-off is documented and sound in isolation — shrinking early unmaps domains from a still-valid cert and forfeits the identical-set renewal exemption — but combined with (1) it means: one flapping host in another service holds your primary domain's renewal hostage until the final 7 days. If ACME then has a bad week (or the flap continues), the primary domain reaches expiry — and today that also means failed handshakes (see the companion issue on the synchronous <24h reprovision path).

The batch guard (#97) protects issuance well (preflight probes, culprit attribution, quarantine ladder). This issue is about the membership decision that precedes it and the deferral policy that follows it.

Proposed hardening (independent pieces, in value order)

  1. Partition handshake batches by service, exactly as the dynamic issuer does. Fewer-certs efficiency is preserved within a service; a typo'd host in service B can no longer join — or later stall — service A's certificate. Localized change in provisionCertificate's candidate collection (it already partitions by directory since fix(san-cert): honor per-service --tls-staging in the SAN cert manager #100; the service key rides the same loop).
  2. Deploy-registered certs compact earlier than dynamic ones. Tenant domains from a --tls-domains-source are expendable individually; deploy hosts are the operator's own names. A wider compaction window for certs covering registered hosts (e.g. 14–21 days) bounds how late their renewal can slip, at the cost of an occasional forfeited renewal exemption — the right trade for the domains that matter most.
  3. Surface deferral as a metric, not just a log. SetCertificateExpiry exists; renewal deferrals ("Deferring renewal" paths) are log-only. A deferred_renewals gauge (or a per-cert renewal_deferred_since timestamp) lets operators alert before the compaction window instead of discovering the coupling during an outage.

Not proposed: touching the ARI/identical-set exemption logic or the quarantine ladder itself — both are correct; the issue is who shares a certificate and how late deferral may run.

Note on the CN ("primary domain" in cert viewers)

Every order sorts identifiers, so the certificate's CN / first SAN is the alphabetically smallest member — for a mixed batch, an arbitrary host. This never affects validation (browsers match SANs only, and lego ≥4.30 omits CN entirely when the first identifier exceeds 64 chars; newer LE profiles omit CN regardless). If the operator's canonical host should be the cert's visible subject, ObtainRequest.Domains order can put it first while sanCertID keeps hashing the sorted set — cosmetic, cheap, optional.

Found during the SAN grouping / primary-domain-validity audit (2026-08-13).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestproxydash-proxy (Go) work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions