carlos / platform Public

edge: wildcard ACME DNS-01 issuance — obtain, solve, run loop

Eleven's flow: dedicated account key (minted only on a genuine cache
miss), challenge TXT published through the provider seam with always-runs
cleanup on a fresh context, 12h loop with 1h failure retries, refresh()
wake for new accounts.
Paul Campbell 80a42623a1329310162d252888eebef54313ffd6 parent 307303e
2 files changed, +268 −0
  • internal/edge/wildcard.go +214 −0
  • internal/edge/wildcard_test.go +54 −0
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go
index 0f14de237..195fe2415 100644
--- a/internal/edge/wildcard.go
+++ b/internal/edge/wildcard.go
@@ -27,16 +27,22 @@ import (
"context"
"crypto"
"crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
"crypto/tls"
"crypto/x509"
+ "crypto/x509/pkix"
"encoding/pem"
"errors"
+ "fmt"
"log"
+ "net"
"sort"
"strings"
"sync"
"time"
+ "golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
@@ -273,3 +279,211 @@ func parseWildcardPEM(raw []byte) (*tls.Certificate, error) {
cert.Leaf = leaf
return cert, nil
}
+
+// run keeps the wildcard certs current: an immediate pass at startup (first
+// issuance / cache load), then every 12h, dropping to hourly retries after a
+// failure — and refresh() wakes it early when a route swap surfaces a parent
+// with no usable cert (a new account).
+func (w *wildcardManager) run() {
+ for {
+ delay := 12 * time.Hour
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ for _, p := range parents {
+ if !w.needsObtain(p, time.Now()) {
+ continue
+ }
+ // Another process (or pool sibling) may have obtained it already.
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ w.loadCached(ctx, p)
+ cancel()
+ if !w.needsObtain(p, time.Now()) {
+ continue
+ }
+ ctx, cancel = context.WithTimeout(context.Background(), 10*time.Minute)
+ err := w.obtain(ctx, p)
+ cancel()
+ if err != nil {
+ log.Printf("edge: wildcard *.%s: %v — per-host issuance keeps covering; retrying in 1h", p, err)
+ delay = time.Hour
+ continue
+ }
+ w.mu.Lock()
+ cert := w.certs[p]
+ w.mu.Unlock()
+ log.Printf("edge: wildcard *.%s obtained (valid until %s)", p, cert.Leaf.NotAfter.Format(time.RFC3339))
+ }
+ select {
+ case <-time.After(delay):
+ case <-w.wake:
+ }
+ }
+}
+
+// obtain runs one full ACME DNS-01 issuance for *.parent and swaps the
+// result in.
+func (w *wildcardManager) obtain(ctx context.Context, parent string) error {
+ acctKey, err := w.accountKey(ctx)
+ if err != nil {
+ return fmt.Errorf("account key: %v", err)
+ }
+ client := &acme.Client{Key: acctKey, DirectoryURL: w.directory}
+ if _, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists {
+ return fmt.Errorf("acme register: %v", err)
+ }
+
+ order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: "*." + parent}})
+ if err != nil {
+ return fmt.Errorf("authorize order: %v", err)
+ }
+ for _, zurl := range order.AuthzURLs {
+ if err := w.solveDNS01(ctx, client, parent, zurl); err != nil {
+ return err
+ }
+ }
+
+ certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return err
+ }
+ csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
+ Subject: pkix.Name{CommonName: "*." + parent},
+ DNSNames: []string{"*." + parent},
+ }, certKey)
+ if err != nil {
+ return fmt.Errorf("csr: %v", err)
+ }
+ ders, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
+ if err != nil {
+ return fmt.Errorf("finalize: %v", err)
+ }
+ leaf, err := x509.ParseCertificate(ders[0])
+ if err != nil {
+ return fmt.Errorf("parse issued cert: %v", err)
+ }
+ cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf}
+ // Paranoia: never store what certFor would refuse to serve.
+ if !coversParent(cert, parent) {
+ return fmt.Errorf("issued cert does not cover *.%s", parent)
+ }
+ raw, err := encodeWildcardPEM(cert, certKey)
+ if err != nil {
+ return fmt.Errorf("encode: %v", err)
+ }
+ if err := w.cache.Put(ctx, wildcardPEMKey(parent), raw); err != nil {
+ return fmt.Errorf("persist: %v", err)
+ }
+ w.mu.Lock()
+ w.certs[parent] = cert
+ w.mu.Unlock()
+ return nil
+}
+
+// solveDNS01 answers one authorization: publish the challenge TXT record,
+// wait for the CA to see it, and always clean the record up afterwards.
+func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, parent, authzURL string) error {
+ authz, err := client.GetAuthorization(ctx, authzURL)
+ if err != nil {
+ return fmt.Errorf("get authorization: %v", err)
+ }
+ if authz.Status == acme.StatusValid {
+ return nil // a recent order already proved this name
+ }
+ var chal *acme.Challenge
+ for _, c := range authz.Challenges {
+ if c.Type == "dns-01" {
+ chal = c
+ break
+ }
+ }
+ if chal == nil {
+ return errors.New("CA offered no dns-01 challenge")
+ }
+ txt, err := client.DNS01ChallengeRecord(chal.Token)
+ if err != nil {
+ return err
+ }
+
+ challengeFQDN := "_acme-challenge." + parent
+ cleanup, err := w.dns.present(ctx, challengeFQDN, txt)
+ if err != nil {
+ return fmt.Errorf("publish challenge TXT: %v", err)
+ }
+ // Clean up with a fresh context: the record must not outlive the attempt
+ // even when ctx is already dead (stale challenge TXTs break the NEXT run).
+ defer func() {
+ cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := cleanup(cctx); err != nil {
+ log.Printf("edge: wildcard *.%s: cleanup of challenge TXT failed: %v", parent, err)
+ }
+ }()
+
+ // Best-effort: give the record a moment to reach the authoritative NS
+ // before telling the CA to look. Proceed on timeout — the CA does its own
+ // retries, and a hard failure just comes back through the retry loop.
+ waitForTXT(ctx, challengeFQDN, txt, time.Minute)
+
+ if _, err := client.Accept(ctx, chal); err != nil {
+ return fmt.Errorf("accept challenge: %v", err)
+ }
+ if _, err := client.WaitAuthorization(ctx, authz.URI); err != nil {
+ return fmt.Errorf("authorization: %v", err)
+ }
+ return nil
+}
+
+// wildcardAccountKeyName is the ACME account key's cache key — Eleven's 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(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", 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
+ }
+ der, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ return nil, err
+ }
+ // Crash-safety (temp + rename, 0600) is the cache backend's contract —
+ // 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
+}
+
+// waitForTXT polls DNS until name resolves to a TXT containing value, or the
+// deadline passes (best-effort — see caller).
+func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) && ctx.Err() == nil {
+ if txts, err := net.DefaultResolver.LookupTXT(ctx, name); err == nil {
+ for _, t := range txts {
+ if t == value {
+ return
+ }
+ }
+ }
+ time.Sleep(3 * time.Second)
+ }
+}
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go
index 5ac7b2618..1f9558fda 100644
--- a/internal/edge/wildcard_test.go
+++ b/internal/edge/wildcard_test.go
@@ -8,6 +8,7 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
+ "errors"
"math/big"
"reflect"
"testing"
@@ -223,3 +224,56 @@ func TestLoadCachedRejectsWrongCoverage(t *testing.T) {
t.Fatal("loadCached: must not store a cert whose key doesn't match its leaf")
}
}
+
+// fakeDNS records present/cleanup calls — the seam obtain talks through.
+type fakeDNS struct {
+ presented []string // fqdn|value
+ cleaned []string // fqdn
+ failWith error
+}
+
+func (f *fakeDNS) present(_ context.Context, fqdn, value string) (func(context.Context) error, error) {
+ if f.failWith != nil {
+ return nil, f.failWith
+ }
+ f.presented = append(f.presented, fqdn+"|"+value)
+ return func(context.Context) error {
+ f.cleaned = append(f.cleaned, fqdn)
+ return nil
+ }, nil
+}
+
+func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) {
+ dir := t.TempDir()
+ cache := autocert.DirCache(dir)
+ w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ k1, err := w1.accountKey(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, cache, &fakeDNS{})
+ k2, err := w2.accountKey(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !k1.Equal(k2) {
+ t.Fatal("account key not stable across managers sharing a cache")
+ }
+}
+
+// A cache backend fault must NOT mint a fresh ACME account — only a genuine
+// miss does (a fault treated as a miss would register a new account per
+// hiccup).
+type faultyCache struct{ autocert.Cache }
+
+func (f faultyCache) Get(context.Context, string) ([]byte, error) {
+ return nil, errors.New("backend down")
+}
+
+func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{})
+ if _, err := w.accountKey(context.Background()); err == nil {
+ t.Fatal("backend fault: want error, got a (freshly minted) key")
+ }
+}