server: put the router's cert cache behind a seam (autocert.Cache) (#661)
Phase 0 item 8 of #550: router boxes in a pool must eventually share issued certs, so every issuance artifact now goes through one autocert.Cache chosen by newCertCache (certcache.go, env LCHAT_CERT_CACHE: unset/dir = today's DirCache layout, s3 reserved). The wildcard manager's own file I/O (wildcard-<domain>.pem + wildcard-account.key) is routed through the same cache with the historical file names as keys, so the dir backend is byte-identical to the pre-seam /data/acme — an existing cache keeps working over a restart with no migration — and one future S3 backend covers per-host certs, wildcard PEMs, and the ACME account key at once. No sigv4 promotion needed: the AWS SDK v2 S3 client is already a module dependency (internal/activator), so the S3 impl stays a TODO stub.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+264
−65
certcache.go+41 −0certcache_test.go+97 −0router.go+10 −2wildcard.go+65 −51wildcard_test.go+51 −12
diff --git a/certcache.go b/certcache.go| new file mode 100644 |
| index 0000000..e975064 |
| --- /dev/null |
| +++ b/certcache.go |
| @@ -0,0 +1,41 @@ |
| +package main |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "strings" |
| + |
| + "golang.org/x/crypto/acme/autocert" |
| +) |
| + |
| +// The certificate-cache seam (issue #550 Part 12, Phase 0 item 8): router |
| +// boxes in a pool must eventually SHARE issued certs — the per-host autocert |
| +// certs, the DNS-01 wildcard PEMs, and the wildcard ACME account key — so |
| +// every issuance artifact goes through one autocert.Cache chosen here, never |
| +// direct file I/O. autocert already defines exactly the interface a remote |
| +// backend needs (Get/Put/Delete with ctx, ErrCacheMiss for absent), so there |
| +// is no parallel abstraction: an S3 backend is a drop-in behind this |
| +// constructor. |
| + |
| +// newCertCache returns the certificate cache selected by LCHAT_CERT_CACHE. |
| +// Unset (or "dir") is today's on-disk layout — autocert.DirCache over dir, |
| +// cache keys as plain file names — byte-identical to what a box already has |
| +// at /data/acme, so an existing cache keeps working over a restart with no |
| +// migration. "s3" is reserved for the pooled-router backend; until it exists |
| +// it refuses to start, because failing loudly beats silently minting certs |
| +// into a local dir the rest of the pool can't see. |
| +func newCertCache(dir string) (autocert.Cache, error) { |
| + switch backend := strings.TrimSpace(os.Getenv("LCHAT_CERT_CACHE")); backend { |
| + case "", "dir": |
| + return autocert.DirCache(dir), nil |
| + case "s3": |
| + // TODO(#550): a thin Get/Put/Delete over S3 GetObject/PutObject/ |
| + // DeleteObject, mapping a missing key to autocert.ErrCacheMiss. The |
| + // AWS SDK v2 S3 client is already a module dependency |
| + // (internal/activator uses it for hibernation), so no new client code |
| + // is needed — only bucket/prefix config and the pooled-router rollout. |
| + return nil, fmt.Errorf("LCHAT_CERT_CACHE=s3: reserved, not implemented yet") |
| + default: |
| + return nil, fmt.Errorf("LCHAT_CERT_CACHE=%q: want unset, \"dir\", or \"s3\"", backend) |
| + } |
| +} |
diff --git a/certcache_test.go b/certcache_test.go| new file mode 100644 |
| index 0000000..2f329df |
| --- /dev/null |
| +++ b/certcache_test.go |
| @@ -0,0 +1,97 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "golang.org/x/crypto/acme/autocert" |
| +) |
| + |
| +// testCertCacheConformance is the contract every cache backend (dir today, S3 |
| +// later) must satisfy — autocert and the wildcard manager rely on all three: |
| +// Get/Put round-trip, ErrCacheMiss (not a bare error) for an absent key, and |
| +// idempotent Delete. |
| +func testCertCacheConformance(t *testing.T, cache autocert.Cache) { |
| + t.Helper() |
| + ctx := context.Background() |
| + |
| + if _, err := cache.Get(ctx, "absent"); err != autocert.ErrCacheMiss { |
| + t.Fatalf("Get(absent) = %v, want ErrCacheMiss", err) |
| + } |
| + want := []byte("pem-bytes") |
| + if err := cache.Put(ctx, "chat.example.com", want); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := cache.Get(ctx, "chat.example.com"); err != nil || !bytes.Equal(got, want) { |
| + t.Fatalf("Get after Put = %q, %v", got, err) |
| + } |
| + // Overwrite is the renewal path. |
| + if err := cache.Put(ctx, "chat.example.com", []byte("renewed")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := cache.Get(ctx, "chat.example.com"); string(got) != "renewed" { |
| + t.Fatalf("overwrite lost: %q", got) |
| + } |
| + if err := cache.Delete(ctx, "chat.example.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := cache.Get(ctx, "chat.example.com"); err != autocert.ErrCacheMiss { |
| + t.Fatalf("Get after Delete = %v, want ErrCacheMiss", err) |
| + } |
| + if err := cache.Delete(ctx, "never-existed"); err != nil { |
| + t.Fatalf("Delete(absent) = %v, want nil", err) |
| + } |
| +} |
| + |
| +func TestNewCertCacheDirBackend(t *testing.T) { |
| + dir := t.TempDir() |
| + t.Setenv("LCHAT_CERT_CACHE", "") // unset = dir |
| + cache, err := newCertCache(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + testCertCacheConformance(t, cache) |
| + |
| + // The dir backend must be byte-identical to the pre-seam layout: a key is |
| + // a plain file name inside the acme cache dir, 0600 (every entry may hold |
| + // private key material) — an existing /data/acme keeps working over a |
| + // restart with no migration. |
| + ctx := context.Background() |
| + if err := cache.Put(ctx, "chat.example.com", []byte("pem")); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, "chat.example.com") |
| + if raw, err := os.ReadFile(path); err != nil || string(raw) != "pem" { |
| + t.Fatalf("on-disk layout drifted: %q, %v", raw, err) |
| + } |
| + if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("cert file mode = %v, err %v (want 0600)", fi.Mode(), err) |
| + } |
| + // And the reverse: a cert file an older binary wrote directly is served. |
| + if err := os.WriteFile(filepath.Join(dir, "old.example.com"), []byte("old"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if raw, err := cache.Get(ctx, "old.example.com"); err != nil || string(raw) != "old" { |
| + t.Fatalf("pre-seam file not readable through the cache: %q, %v", raw, err) |
| + } |
| +} |
| + |
| +func TestNewCertCacheEnvSelection(t *testing.T) { |
| + t.Setenv("LCHAT_CERT_CACHE", "dir") |
| + if _, err := newCertCache(t.TempDir()); err != nil { |
| + t.Fatalf("explicit dir backend: %v", err) |
| + } |
| + // s3 is reserved: refusing to start beats a router silently minting certs |
| + // into a local dir the rest of a pool can't see. |
| + t.Setenv("LCHAT_CERT_CACHE", "s3") |
| + if _, err := newCertCache(t.TempDir()); err == nil { |
| + t.Fatal("s3 backend accepted before it exists") |
| + } |
| + t.Setenv("LCHAT_CERT_CACHE", "floppy") |
| + if _, err := newCertCache(t.TempDir()); err == nil { |
| + t.Fatal("unknown backend accepted") |
| + } |
| +} |
diff --git a/router.go b/router.go| index d09537d..6482817 100644 |
| --- a/router.go |
| +++ b/router.go |
| @@ -243,9 +243,17 @@ func runRouter(args []string) { |
| } |
| rt.fallbackURL = *fallbackURL |
| + // One cert cache (certcache.go) backs every issuance artifact — autocert's |
| + // per-host certs here AND the wildcard manager's PEMs/account key below — |
| + // so swapping the backend (LCHAT_CERT_CACHE) moves all of it at once. |
| + certCache, err := newCertCache(*acmeCache) |
| + if err != nil { |
| + log.Fatalf("router: %v", err) |
| + } |
| + |
| m := &autocert.Manager{ |
| Prompt: autocert.AcceptTOS, |
| - Cache: autocert.DirCache(*acmeCache), |
| + Cache: certCache, |
| HostPolicy: func(_ context.Context, host string) error { |
| if rt.allowed(host) { |
| return nil |
| @@ -265,7 +273,7 @@ func runRouter(args []string) { |
| if token := os.Getenv("LCHAT_DNSIMPLE_TOKEN"); token == "" { |
| log.Printf("router: LCHAT_WILDCARD_DOMAINS set but LCHAT_DNSIMPLE_TOKEN missing — wildcard certs stay off (per-host issuance continues)") |
| } else { |
| - wc := newWildcardManager(doms, *acmeCache, token) |
| + wc := newWildcardManager(doms, certCache, token) |
| go wc.run() |
| inner := tlsCfg.GetCertificate |
| tlsCfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { |
diff --git a/wildcard.go b/wildcard.go| index 06e62a6..5e698aa 100644 |
| --- a/wildcard.go |
| +++ b/wildcard.go |
| @@ -17,13 +17,12 @@ import ( |
| "log" |
| "net" |
| "net/http" |
| - "os" |
| - "path/filepath" |
| "strings" |
| "sync/atomic" |
| "time" |
| "golang.org/x/crypto/acme" |
| + "golang.org/x/crypto/acme/autocert" |
| ) |
| // Wildcard TLS (ACME DNS-01) for the space base domain(s). |
| @@ -51,18 +50,22 @@ const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory" |
| const wildcardRenewBefore = 30 * 24 * time.Hour |
| type wildcardManager struct { |
| - domains []string |
| - cacheDir string // the autocert cache dir; our PEMs live alongside |
| + domains []string |
| + // cache is the shared cert cache (certcache.go) — the same backend |
| + // autocert uses for per-host certs, so pooled routers share the wildcard |
| + // too. Under the dir backend the keys land as the historical file names |
| + // beside autocert's certs (wildcard-<domain>.pem, wildcard-account.key). |
| + cache autocert.Cache |
| dns *dnsimpleDNS |
| directory string |
| // One atomically-swapped cert per domain, read on every 11ch.at handshake. |
| certs map[string]*atomic.Pointer[tls.Certificate] |
| } |
| -func newWildcardManager(domains []string, cacheDir, dnsToken string) *wildcardManager { |
| +func newWildcardManager(domains []string, cache autocert.Cache, dnsToken string) *wildcardManager { |
| w := &wildcardManager{ |
| - domains: domains, |
| - cacheDir: cacheDir, |
| + domains: domains, |
| + cache: cache, |
| dns: &dnsimpleDNS{ |
| base: envOr("LCHAT_DNSIMPLE_BASE", "https://api.dnsimple.com"), |
| token: dnsToken, |
| @@ -70,20 +73,35 @@ func newWildcardManager(domains []string, cacheDir, dnsToken string) *wildcardMa |
| directory: envOr("LCHAT_ACME_DIRECTORY", letsEncryptDirectory), |
| certs: map[string]*atomic.Pointer[tls.Certificate]{}, |
| } |
| + // The startup load is synchronous (runRouter wires certFor into the TLS |
| + // config right after this), so bound it: a dir read is instant, but a |
| + // remote cache backend must degrade to "re-obtain" rather than hang boot. |
| + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) |
| + defer cancel() |
| for _, d := range domains { |
| w.certs[d] = &atomic.Pointer[tls.Certificate]{} |
| - if cert, err := loadWildcardPEM(w.pemPath(d)); err == nil { |
| - w.certs[d].Store(cert) |
| - log.Printf("router: wildcard *.%s loaded from cache (valid until %s)", d, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| - } else if !os.IsNotExist(err) { |
| - log.Printf("router: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", d, err) |
| + raw, err := w.cache.Get(ctx, wildcardPEMKey(d)) |
| + if errors.Is(err, autocert.ErrCacheMiss) { |
| + continue // first run for this domain — run() obtains one |
| } |
| + if err == nil { |
| + var cert *tls.Certificate |
| + if cert, err = parseWildcardPEM(raw); err == nil { |
| + w.certs[d].Store(cert) |
| + log.Printf("router: wildcard *.%s loaded from cache (valid until %s)", d, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| + continue |
| + } |
| + } |
| + log.Printf("router: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", d, err) |
| } |
| return w |
| } |
| -func (w *wildcardManager) pemPath(domain string) string { |
| - return filepath.Join(w.cacheDir, "wildcard-"+domain+".pem") |
| +// wildcardPEMKey is the cache key for a domain's wildcard cert+key PEM. It is |
| +// deliberately the pre-seam file name, so the dir backend reads the PEMs an |
| +// older binary wrote to the acme cache dir with no migration. |
| +func wildcardPEMKey(domain string) string { |
| + return "wildcard-" + domain + ".pem" |
| } |
| // certFor returns the wildcard cert for serverName, or nil when the name isn't |
| @@ -140,7 +158,7 @@ func (w *wildcardManager) needsObtain(domain string, now time.Time) bool { |
| // obtain runs one full ACME DNS-01 issuance for *.domain and swaps the result in. |
| func (w *wildcardManager) obtain(ctx context.Context, domain string) error { |
| - acctKey, err := w.accountKey() |
| + acctKey, err := w.accountKey(ctx) |
| if err != nil { |
| return fmt.Errorf("account key: %v", err) |
| } |
| @@ -179,7 +197,11 @@ func (w *wildcardManager) obtain(ctx context.Context, domain string) error { |
| return fmt.Errorf("parse issued cert: %v", err) |
| } |
| cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf} |
| - if err := saveWildcardPEM(w.pemPath(domain), cert, certKey); err != nil { |
| + raw, err := encodeWildcardPEM(cert, certKey) |
| + if err != nil { |
| + return fmt.Errorf("encode: %v", err) |
| + } |
| + if err := w.cache.Put(ctx, wildcardPEMKey(domain), raw); err != nil { |
| return fmt.Errorf("persist: %v", err) |
| } |
| w.certs[domain].Store(cert) |
| @@ -239,17 +261,27 @@ func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, d |
| return nil |
| } |
| +// wildcardAccountKeyName is the ACME account key's cache key — the pre-seam |
| +// file name, for the same no-migration reason as wildcardPEMKey. |
| +const wildcardAccountKeyName = "wildcard-account.key" |
| + |
| // accountKey loads (or mints) the ACME account key. Separate from autocert's |
| // account on purpose: this flow manages its own registration lifecycle. |
| -func (w *wildcardManager) accountKey() (*ecdsa.PrivateKey, error) { |
| - path := filepath.Join(w.cacheDir, "wildcard-account.key") |
| - if raw, err := os.ReadFile(path); err == nil { |
| +func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error) { |
| + raw, err := w.cache.Get(ctx, wildcardAccountKeyName) |
| + if err == nil { |
| block, _ := pem.Decode(raw) |
| if block == nil { |
| - return nil, fmt.Errorf("%s: not PEM", path) |
| + return nil, fmt.Errorf("%s: not PEM", wildcardAccountKeyName) |
| } |
| return x509.ParseECPrivateKey(block.Bytes) |
| } |
| + if !errors.Is(err, autocert.ErrCacheMiss) { |
| + // Only a genuine miss mints a key: treating a backend fault as "no key |
| + // yet" would register a fresh ACME account on every hiccup. Fail and |
| + // let obtain()'s hourly retry come back around. |
| + return nil, err |
| + } |
| key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| if err != nil { |
| return nil, err |
| @@ -258,17 +290,10 @@ func (w *wildcardManager) accountKey() (*ecdsa.PrivateKey, error) { |
| if err != nil { |
| return nil, err |
| } |
| - if err := os.MkdirAll(w.cacheDir, 0o700); err != nil { |
| - return nil, err |
| - } |
| - raw := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) |
| - // Temp + rename, like the cert PEM: a crash mid-write must not leave a |
| - // corrupt account key that reads fine but never works. |
| - tmp := path + ".tmp" |
| - if err := os.WriteFile(tmp, raw, 0o600); err != nil { |
| - return nil, err |
| - } |
| - if err := os.Rename(tmp, path); err != nil { |
| + // Crash-safety (temp + rename, 0600) is the cache backend's contract now — |
| + // the dir backend inherits it from autocert.DirCache. |
| + raw = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) |
| + if err := w.cache.Put(ctx, wildcardAccountKeyName, raw); err != nil { |
| return nil, err |
| } |
| return key, nil |
| @@ -290,38 +315,27 @@ func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) |
| } |
| } |
| -// ---- PEM persistence (key + chain in one 0600 file, like the autocert cache) -- |
| +// ---- PEM encode/parse (key + chain in one blob; the cert cache owns |
| +// placement, permissions, and write atomicity) ---------------------------------- |
| -func saveWildcardPEM(path string, cert *tls.Certificate, key *ecdsa.PrivateKey) error { |
| +func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error) { |
| var buf bytes.Buffer |
| der, err := x509.MarshalECPrivateKey(key) |
| if err != nil { |
| - return err |
| + return nil, err |
| } |
| if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil { |
| - return err |
| + return nil, err |
| } |
| for _, c := range cert.Certificate { |
| if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil { |
| - return err |
| + return nil, err |
| } |
| } |
| - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| - return err |
| - } |
| - // Temp + rename so a crash never leaves a half-written cert for load to trip on. |
| - tmp := path + ".tmp" |
| - if err := os.WriteFile(tmp, buf.Bytes(), 0o600); err != nil { |
| - return err |
| - } |
| - return os.Rename(tmp, path) |
| + return buf.Bytes(), nil |
| } |
| -func loadWildcardPEM(path string) (*tls.Certificate, error) { |
| - raw, err := os.ReadFile(path) |
| - if err != nil { |
| - return nil, err |
| - } |
| +func parseWildcardPEM(raw []byte) (*tls.Certificate, error) { |
| cert := &tls.Certificate{} |
| for { |
| var block *pem.Block |
| @@ -341,7 +355,7 @@ func loadWildcardPEM(path string) (*tls.Certificate, error) { |
| } |
| } |
| if cert.PrivateKey == nil || len(cert.Certificate) == 0 { |
| - return nil, fmt.Errorf("%s: missing key or certificate", path) |
| + return nil, errors.New("missing key or certificate in PEM") |
| } |
| leaf, err := x509.ParseCertificate(cert.Certificate[0]) |
| if err != nil { |
diff --git a/wildcard_test.go b/wildcard_test.go| index ee6c5e4..2e70dea 100644 |
| --- a/wildcard_test.go |
| +++ b/wildcard_test.go |
| @@ -16,6 +16,8 @@ import ( |
| "path/filepath" |
| "testing" |
| "time" |
| + |
| + "golang.org/x/crypto/acme/autocert" |
| ) |
| // selfSignedWildcard mints a throwaway *.domain cert for dispatch tests. |
| @@ -44,7 +46,7 @@ func selfSignedWildcard(t *testing.T, domain string, notAfter time.Time) (*tls.C |
| } |
| func TestCertForMatchesExactlyOneLabel(t *testing.T) { |
| - w := newWildcardManager([]string{"11ch.at"}, t.TempDir(), "tok") |
| + w := newWildcardManager([]string{"11ch.at"}, autocert.DirCache(t.TempDir()), "tok") |
| // Nothing loaded yet: every host falls through to autocert. |
| if w.certFor("abc.11ch.at") != nil { |
| @@ -60,11 +62,11 @@ func TestCertForMatchesExactlyOneLabel(t *testing.T) { |
| } |
| } |
| for _, host := range []string{ |
| - "11ch.at", // apex isn't covered by *.11ch.at |
| - "a.b.11ch.at", // deeper level isn't either |
| - "chat.example.com", // other domains take the autocert path |
| - "evil-11ch.at", // suffix must be a label boundary |
| - "x.11ch.at.evil.com", // not our domain at all |
| + "11ch.at", // apex isn't covered by *.11ch.at |
| + "a.b.11ch.at", // deeper level isn't either |
| + "chat.example.com", // other domains take the autocert path |
| + "evil-11ch.at", // suffix must be a label boundary |
| + "x.11ch.at.evil.com", // not our domain at all |
| } { |
| if w.certFor(host) != nil { |
| t.Errorf("%s: wildcard wrongly served", host) |
| @@ -82,7 +84,7 @@ func TestCertForMatchesExactlyOneLabel(t *testing.T) { |
| } |
| func TestWrappedGetCertificateFallsThrough(t *testing.T) { |
| - w := newWildcardManager([]string{"11ch.at"}, t.TempDir(), "tok") |
| + w := newWildcardManager([]string{"11ch.at"}, autocert.DirCache(t.TempDir()), "tok") |
| cert, _ := selfSignedWildcard(t, "11ch.at", time.Now().Add(60*24*time.Hour)) |
| w.certs["11ch.at"].Store(cert) |
| @@ -106,22 +108,30 @@ func TestWrappedGetCertificateFallsThrough(t *testing.T) { |
| func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| dir := t.TempDir() |
| - w := newWildcardManager([]string{"11ch.at"}, dir, "tok") |
| + cache := autocert.DirCache(dir) |
| + w := newWildcardManager([]string{"11ch.at"}, cache, "tok") |
| if !w.needsObtain("11ch.at", time.Now()) { |
| t.Fatal("no cert loaded but needsObtain is false") |
| } |
| cert, key := selfSignedWildcard(t, "11ch.at", time.Now().Add(60*24*time.Hour)) |
| - if err := saveWildcardPEM(w.pemPath("11ch.at"), cert, key); err != nil { |
| + raw, err := encodeWildcardPEM(cert, key) |
| + if err != nil { |
| t.Fatal(err) |
| } |
| - if fi, err := os.Stat(w.pemPath("11ch.at")); err != nil || fi.Mode().Perm() != 0o600 { |
| + if err := cache.Put(context.Background(), wildcardPEMKey("11ch.at"), raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The dir backend must keep the pre-seam on-disk contract: the historical |
| + // file name inside the acme cache dir, key material 0600. |
| + pemPath := filepath.Join(dir, "wildcard-11ch.at.pem") |
| + if fi, err := os.Stat(pemPath); err != nil || fi.Mode().Perm() != 0o600 { |
| t.Fatalf("pem mode = %v, err %v (key material must be 0600)", fi.Mode(), err) |
| } |
| // A fresh manager picks the persisted cert up at startup (the restart path). |
| - w2 := newWildcardManager([]string{"11ch.at"}, dir, "tok") |
| + w2 := newWildcardManager([]string{"11ch.at"}, cache, "tok") |
| loaded := w2.certs["11ch.at"].Load() |
| if loaded == nil || !loaded.Leaf.NotAfter.Equal(cert.Leaf.NotAfter) { |
| t.Fatal("cached cert not loaded on startup") |
| @@ -134,10 +144,39 @@ func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) { |
| t.Fatal("cert 10 days from expiry not flagged for renewal") |
| } |
| os.WriteFile(filepath.Join(dir, "wildcard-bad.example.pem"), []byte("junk"), 0o600) |
| - w3 := newWildcardManager([]string{"bad.example"}, dir, "tok") |
| + w3 := newWildcardManager([]string{"bad.example"}, cache, "tok") |
| if w3.certs["bad.example"].Load() != nil { |
| t.Fatal("junk pem loaded as a cert") |
| } |
| + |
| + // The no-migration guarantee, from the other side: a PEM a pre-seam binary |
| + // wrote with plain file I/O at the historical path is loaded through the |
| + // cache unchanged (/data/acme must survive the upgrade + a restart). |
| + if err := os.WriteFile(filepath.Join(dir, "wildcard-old.example.pem"), raw, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + w4 := newWildcardManager([]string{"old.example"}, cache, "tok") |
| + if w4.certs["old.example"].Load() == nil { |
| + t.Fatal("pre-seam pem file not loaded via the cache") |
| + } |
| +} |
| + |
| +// The ACME account key must round-trip through the cache: minting a fresh key |
| +// per issuance would register a new ACME account every renewal. |
| +func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) { |
| + cache := autocert.DirCache(t.TempDir()) |
| + ctx := context.Background() |
| + k1, err := newWildcardManager([]string{"11ch.at"}, cache, "tok").accountKey(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + k2, err := newWildcardManager([]string{"11ch.at"}, cache, "tok").accountKey(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !k1.Equal(k2) { |
| + t.Fatal("account key not persisted across managers") |
| + } |
| } |
| func TestDNSimpleRecordsClient(t *testing.T) { |