| index 195fe2415..41d0b9f8f 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -68,6 +68,12 @@ type wildcardManager struct { |
| parents []string // last computed parent set (sorted) |
| certs map[string]*tls.Certificate |
| |
| + // nextAttempt holds a per-parent backoff floor: run() skips a parent |
| + // until this time has passed. Set on obtain failure, cleared on success — |
| + // keeps a wake (which fires on ANY parent lacking a usable cert) from |
| + // retrying a persistently failing parent more than hourly (see run()). |
| + nextAttempt map[string]time.Time |
| + |
| // wake nudges run() out of its 12h sleep when refresh() finds a parent |
| // with no usable cert (a new account's first route). Capacity 1: a wake |
| // is a level, not a count. |
| @@ -83,6 +89,7 @@ func newWildcardManager(staticDomains []string, appsDomain string, hosts func() |
| appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)), |
| hosts: hosts, |
| certs: map[string]*tls.Certificate{}, |
| + nextAttempt: map[string]time.Time{}, |
| wake: make(chan struct{}, 1), |
| } |
| } |
| @@ -124,7 +131,9 @@ func parentsFor(static []string, appsDomain string, hosts []string) []string { |
| // the reload hook on every route swap, and once at startup). If any parent |
| // lacks a usable cert it wakes run() — that is what makes a NEW account's |
| // wildcard start issuing the moment its first route lands, instead of on |
| -// the next 12h tick. |
| +// the next 12h tick. A wake cannot bypass a failing parent's backoff: run() |
| +// still skips any parent whose nextAttempt hasn't passed, so a wake only |
| +// advances parents that aren't already backing off. |
| func (w *wildcardManager) refresh() { |
| parents := parentsFor(w.staticDomains, w.appsDomain, w.hosts()) |
| now := time.Now() |
| @@ -180,6 +189,17 @@ func (w *wildcardManager) needsObtain(parent string, now time.Time) bool { |
| return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter) |
| } |
| |
| +// backingOff reports whether parent is still inside its post-failure backoff |
| +// window — run() skips it until nextAttempt passes, so a wake (which fires |
| +// on ANY parent lacking a usable cert) can't retry a persistently failing |
| +// parent more than hourly. |
| +func (w *wildcardManager) backingOff(parent string, now time.Time) bool { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + next, ok := w.nextAttempt[parent] |
| + return ok && now.Before(next) |
| +} |
| + |
| // loadCached pulls parent's wildcard from the cert cache into memory, |
| // reporting whether a parseable cert was loaded. Called by run() before any |
| // obtain attempt, which covers both startup (certs an earlier process |
| @@ -294,6 +314,11 @@ func (w *wildcardManager) run() { |
| if !w.needsObtain(p, time.Now()) { |
| continue |
| } |
| + if w.backingOff(p, time.Now()) { |
| + // A wake (fired because some OTHER parent needs a cert) does |
| + // not shorten this parent's own hourly retry. |
| + continue |
| + } |
| // Another process (or pool sibling) may have obtained it already. |
| ctx, cancel := context.WithTimeout(context.Background(), time.Minute) |
| w.loadCached(ctx, p) |
| @@ -307,9 +332,13 @@ func (w *wildcardManager) run() { |
| if err != nil { |
| log.Printf("edge: wildcard *.%s: %v — per-host issuance keeps covering; retrying in 1h", p, err) |
| delay = time.Hour |
| + w.mu.Lock() |
| + w.nextAttempt[p] = time.Now().Add(time.Hour) |
| + w.mu.Unlock() |
| continue |
| } |
| w.mu.Lock() |
| + delete(w.nextAttempt, p) |
| cert := w.certs[p] |
| w.mu.Unlock() |
| log.Printf("edge: wildcard *.%s obtained (valid until %s)", p, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| @@ -382,7 +411,7 @@ func (w *wildcardManager) obtain(ctx context.Context, parent string) error { |
| |
| // 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 { |
| +func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, parent, authzURL string) (retErr error) { |
| authz, err := client.GetAuthorization(ctx, authzURL) |
| if err != nil { |
| return fmt.Errorf("get authorization: %v", err) |
| @@ -390,6 +419,19 @@ func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, p |
| if authz.Status == acme.StatusValid { |
| return nil // a recent order already proved this name |
| } |
| + // On failure, deactivate the pending authorization rather than abandoning |
| + // it — pending authzs count against LE's per-account cap (autocert does |
| + // the same "to reduce rate limit quotas"). Best-effort on a fresh context. |
| + defer func() { |
| + if retErr == nil || authz.Status != acme.StatusPending { |
| + return |
| + } |
| + dctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + if err := client.RevokeAuthorization(dctx, authz.URI); err != nil { |
| + log.Printf("edge: wildcard *.%s: deactivate abandoned authorization: %v", parent, err) |
| + } |
| + }() |
| var chal *acme.Challenge |
| for _, c := range authz.Challenges { |
| if c.Type == "dns-01" { |