eleven / messenger Public

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>
Paul Campbell ae856608f7077afaf1f39cc54aad628f80877683 parent e07a109
5 files changed, +264 −65
  • certcache.go +41 −0
  • certcache_test.go +97 −0
  • router.go +10 −2
  • wildcard.go +65 −51
  • wildcard_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) {