| 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) |
| + } |
| +} |