carlos / platform Public

domains: cert expiry falls back to the covering wildcard PEM

certExpiry parses the acme cache dir shape, which makes it a consumer of
the edge's new wildcard-<parent>.pem entries: a wildcard-covered host
never gets a per-host cache file, and without the fallback status would
report it certless forever.
Paul Campbell 5a5d6111e4a468b584b8ac78e61b8edb62a95dae parent cc08baf
2 files changed, +140 −1
  • internal/domains/reconcile.go +36 −1
  • internal/domains/reconcile_internal_test.go +104 −0
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index 6f1250be9..f8d529a35 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -313,7 +313,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// --- 5. TLS health --------------------------------------------------------
if cfg.AcmeDir != "" {
for _, rt := range routes {
- at := certExpiry(filepath.Join(cfg.AcmeDir, rt.Host))
+ at := hostCertExpiry(cfg.AcmeDir, rt.Host, cfg.AppsDomain)
if err := reg.SetCertExpiresAt(rt.Host, at); err != nil {
log.Printf("domains: stamp cert expiry on %s: %v", rt.Host, err)
}
@@ -553,6 +553,41 @@ func mintedRoute(host, app, account string, src registry.Route) registry.Route {
return rt
}
+// wildcardParent returns the parent domain whose per-account wildcard cert
+// would cover host — host exactly two labels under appsDomain
+// (<app>.<acct>.<appsDomain>) → <acct>.<appsDomain> — or "" when no wildcard
+// could cover it (canary depth, a parent apex, a foreign domain, no apps
+// domain configured).
+func wildcardParent(host, appsDomain string) string {
+ if appsDomain == "" {
+ return ""
+ }
+ suffix := "." + strings.ToLower(appsDomain)
+ rest, ok := strings.CutSuffix(strings.ToLower(host), suffix)
+ if !ok {
+ return ""
+ }
+ if labels := strings.Split(rest, "."); len(labels) == 2 {
+ return labels[1] + suffix
+ }
+ 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.
+func hostCertExpiry(acmeDir, host, appsDomain string) int64 {
+ if at := certExpiry(filepath.Join(acmeDir, host)); at != 0 {
+ return at
+ }
+ if parent := wildcardParent(host, appsDomain); parent != "" {
+ return certExpiry(filepath.Join(acmeDir, "wildcard-"+parent+".pem"))
+ }
+ return 0
+}
+
// certExpiry reads the autocert DirCache entry at path (the PEM bundle
// cachePut writes: private key block first, then the cert chain) and returns
// the leaf's NotAfter as unix seconds. Anything short of a parseable
diff --git a/internal/domains/reconcile_internal_test.go b/internal/domains/reconcile_internal_test.go
new file mode 100644
index 000000000..528ff18ae
--- /dev/null
+++ b/internal/domains/reconcile_internal_test.go
@@ -0,0 +1,104 @@
+package domains
+
+// Internal-package tests for wildcardParent/hostCertExpiry: reconcile_test.go
+// is package domains_test and cannot reach these unexported helpers, so this
+// file follows slugname_test.go's existing convention of a same-package test
+// file living alongside it.
+
+import (
+ "bytes"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// writeWildcardPEM writes a self-signed cert for name valid until notAfter,
+// in the acme-cache bundle layout certExpiry parses: key block first, then
+// the certificate.
+func writeWildcardPEM(t *testing.T, path, name string, notAfter time.Time) {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: name},
+ DNSNames: []string{name},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: notAfter,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var buf bytes.Buffer
+ if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
+ t.Fatal(err)
+ }
+ if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestWildcardParent(t *testing.T) {
+ cases := []struct {
+ host, appsDomain, want string
+ }{
+ {"kass.abs.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
+ {"canary.kass.abs.oncarlos.com", "oncarlos.com", ""}, // canary depth: not covered
+ {"abs.oncarlos.com", "oncarlos.com", ""}, // apex of a parent
+ {"custom.example.com", "oncarlos.com", ""}, // foreign domain
+ {"kass.abs.oncarlos.com", "", ""}, // no apps domain configured
+ {"Kass.ABS.oncarlos.com", "oncarlos.com", "abs.oncarlos.com"},
+ }
+ for _, c := range cases {
+ if got := wildcardParent(c.host, c.appsDomain); got != c.want {
+ t.Errorf("wildcardParent(%q, %q) = %q, want %q", c.host, c.appsDomain, got, c.want)
+ }
+ }
+}
+
+// A wildcard-covered host has no per-host acme cache entry — its expiry must
+// come from the covering wildcard-<parent>.pem instead of reporting 0
+// (certless) forever.
+func TestCertExpiryWildcardFallback(t *testing.T) {
+ dir := t.TempDir()
+ // certExpiry parses "key PEM block(s) first, then CERTIFICATE" — build a
+ // minimal self-signed cert the same way the edge's wildcard PEM is laid
+ // out. Reuse/adapt the cert-building helper if reconcile_test.go already
+ // has one; otherwise inline ecdsa+x509 self-sign exactly as in
+ // internal/edge/wildcard_test.go's selfSignedWildcard.
+ notAfter := time.Now().Add(45 * 24 * time.Hour).Truncate(time.Second)
+ writeWildcardPEM(t, filepath.Join(dir, "wildcard-abs.oncarlos.com.pem"), "*.abs.oncarlos.com", notAfter)
+
+ host := "kass.abs.oncarlos.com"
+ 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.
+ 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() {
+ t.Fatalf("hostCertExpiry with per-host file = %d, want %d", got, perHost.Unix())
+ }
+ // Nothing at all → 0, unknown, never a guess.
+ if got := hostCertExpiry(dir, "kass.zzz.oncarlos.com", "oncarlos.com"); got != 0 {
+ t.Fatalf("hostCertExpiry with no files = %d, want 0", got)
+ }
+}