domains: host cert expiry is the max of per-host and covering wildcard
A grandfathered host (per-host cert issued before its wildcard existed) keeps that file on disk forever — autocert stops renewing it once the wildcard starts serving the host, so the stale file's expiry permanently shadowed the wildcard's in status. The edge always serves whichever cert is actually valid, so hostCertExpiry now returns the max of the per-host and covering-wildcard expiries (0 = unknown stays 0 when both are absent).
2 files changed,
+30
−11
internal/domains/reconcile.go+19 −10internal/domains/reconcile_internal_test.go+11 −1
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index f8d529a35..769c2311d 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -573,19 +573,28 @@ func wildcardParent(host, appsDomain string) string { |
| return "" |
| } |
| -// hostCertExpiry resolves host's cert expiry from the acme cache dir: the |
| -// per-host autocert entry when it exists, else the covering per-account |
| -// wildcard's PEM (wildcard-<parent>.pem — a wildcard-covered host never gets |
| -// a per-host entry, and without this fallback status would call it certless |
| -// forever). 0 = unknown, never a guess. |
| +// hostCertExpiry resolves host's cert expiry from the acme cache dir as the |
| +// LATER of the per-host autocert entry and the covering per-account |
| +// wildcard's PEM (wildcard-<parent>.pem). Two reasons a bare per-host lookup |
| +// isn't enough: a wildcard-covered host never gets a per-host entry at all |
| +// (without the wildcard fallback status would call it certless forever), and |
| +// a host GRANDFATHERED onto a per-host cert from before its wildcard existed |
| +// keeps that file on disk forever — autocert stops renewing it once the |
| +// wildcard starts serving the host, so the stale file's expiry would |
| +// shadow the wildcard's and status would cry "expiring" over a cert the edge |
| +// no longer serves. The edge always serves whichever cert is actually valid, |
| +// so the max of the two is the only truthful answer. 0 = unknown, never a |
| +// guess. |
| func hostCertExpiry(acmeDir, host, appsDomain string) int64 { |
| - if at := certExpiry(filepath.Join(acmeDir, host)); at != 0 { |
| - return at |
| - } |
| + perHost := certExpiry(filepath.Join(acmeDir, host)) |
| + wildcard := int64(0) |
| if parent := wildcardParent(host, appsDomain); parent != "" { |
| - return certExpiry(filepath.Join(acmeDir, "wildcard-"+parent+".pem")) |
| + wildcard = certExpiry(filepath.Join(acmeDir, "wildcard-"+parent+".pem")) |
| } |
| - return 0 |
| + if wildcard > perHost { |
| + return wildcard |
| + } |
| + return perHost |
| } |
| // certExpiry reads the autocert DirCache entry at path (the PEM bundle |
diff --git a/internal/domains/reconcile_internal_test.go b/internal/domains/reconcile_internal_test.go| index 528ff18ae..3b1db6def 100644 |
| --- a/internal/domains/reconcile_internal_test.go |
| +++ b/internal/domains/reconcile_internal_test.go |
| @@ -91,7 +91,17 @@ func TestCertExpiryWildcardFallback(t *testing.T) { |
| if got := hostCertExpiry(dir, host, "oncarlos.com"); got != notAfter.Unix() { |
| t.Fatalf("hostCertExpiry = %d, want %d (wildcard fallback)", got, notAfter.Unix()) |
| } |
| - // A per-host entry, when present, still wins. |
| + // A grandfathered per-host file that expires SOONER than the wildcard |
| + // (autocert stopped renewing it once the wildcard took over serving the |
| + // host): the wildcard's later expiry must win, not the stale file — the |
| + // edge serves the wildcard, so status must not shadow that with a cert |
| + // nothing serves anymore. |
| + stalePerHost := time.Now().Add(20 * 24 * time.Hour).Truncate(time.Second) |
| + writeWildcardPEM(t, filepath.Join(dir, host), host, stalePerHost) |
| + if got := hostCertExpiry(dir, host, "oncarlos.com"); got != notAfter.Unix() { |
| + t.Fatalf("hostCertExpiry with stale per-host file = %d, want %d (wildcard's later expiry wins)", got, notAfter.Unix()) |
| + } |
| + // A per-host entry that is LATER than the wildcard still wins. |
| perHost := time.Now().Add(80 * 24 * time.Hour).Truncate(time.Second) |
| writeWildcardPEM(t, filepath.Join(dir, host), host, perHost) |
| if got := hostCertExpiry(dir, host, "oncarlos.com"); got != perHost.Unix() { |