carlos / platform Public

edge: wildcard issuance honors per-parent backoff; abandon deactivates authz

Paul Campbell f6a763b36c1e39da1233461b83a3084a45e346e5 parent 80a4262
2 files changed, +82 −2
  • internal/edge/wildcard.go +44 −2
  • internal/edge/wildcard_test.go +38 −0
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go
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" {
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go
index 1f9558fda..97e2ff9d8 100644
--- a/internal/edge/wildcard_test.go
+++ b/internal/edge/wildcard_test.go
@@ -277,3 +277,41 @@ func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) {
t.Fatal("backend fault: want error, got a (freshly minted) key")
}
}
+
+// TestPerParentBackoff exercises the backoff bookkeeping directly (no live
+// ACME, no spinning run()'s loop): a parent whose nextAttempt is in the
+// future is skipped, a past nextAttempt clears the skip, and a success
+// (delete) clears the entry entirely.
+func TestPerParentBackoff(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ autocert.DirCache(t.TempDir()), &fakeDNS{})
+
+ if w.backingOff("abs.oncarlos.com", time.Now()) {
+ t.Fatal("no nextAttempt recorded yet: backingOff should be false")
+ }
+
+ // Simulate the failure path: run() sets nextAttempt an hour out.
+ w.mu.Lock()
+ w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(time.Hour)
+ w.mu.Unlock()
+ if !w.backingOff("abs.oncarlos.com", time.Now()) {
+ t.Fatal("nextAttempt an hour in the future: backingOff should be true")
+ }
+
+ // Once the window has passed, the same entry no longer backs off.
+ w.mu.Lock()
+ w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(-time.Minute)
+ w.mu.Unlock()
+ if w.backingOff("abs.oncarlos.com", time.Now()) {
+ t.Fatal("nextAttempt in the past: backingOff should be false")
+ }
+
+ // Simulate the success path: run() deletes the entry outright.
+ w.mu.Lock()
+ w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(time.Hour)
+ delete(w.nextAttempt, "abs.oncarlos.com")
+ w.mu.Unlock()
+ if w.backingOff("abs.oncarlos.com", time.Now()) {
+ t.Fatal("nextAttempt deleted on success: backingOff should be false")
+ }
+}