| index 0d8303b5c..cf85421be 100644 |
| --- a/internal/edge/certsync.go |
| +++ b/internal/edge/certsync.go |
| @@ -429,10 +429,14 @@ type certMirror struct { |
| // backfill's switch (see backfill). nil = never the issuer, which is the |
| // safe default for a mirror built without an Edge. |
| issuing func() bool |
| - // inSync memoes the on-disk stamp each entry last matched the bucket |
| - // at (backfill); backfillFailures counts failed passes for the log |
| + // wildcardParents is the wildcard manager's current parent set, the |
| + // bound on which wildcard PEMs backfill may publish. nil = no manager = |
| + // none. |
| + wildcardParents func() []string |
| + // settled memoes the stamp each on-disk entry was last found settled at |
| + // (backfill); backfillFailures counts failed passes for the log |
| // cadence; backfillOnce guards the one-time "cannot walk" line. |
| - inSync map[string]fileStamp |
| + settled map[string]fileStamp |
| backfillFailures int |
| backfillOnce sync.Once |
| |
| @@ -783,7 +787,8 @@ func (m *certMirror) run(stop <-chan struct{}) { |
| // one-pass copy, run after every successful poll, that converges in one |
| // tick. Steady state on the issuer is one directory listing per poll plus |
| // a stat per entry: an entry whose size and mtime have not moved since it |
| -// was last seen in sync with the bucket is not re-read (m.inSync). |
| +// was last settled — in sync with the bucket, or found not to be this |
| +// deployment's — is not re-read (m.settled). |
| // |
| // Rules, each doing work: |
| // |
| @@ -792,7 +797,8 @@ func (m *certMirror) run(stop <-chan struct{}) { |
| // with a bucket but no agent is ungated for serving and must NOT dump |
| // its disk into the deployment's prefix. The gate is re-read per entry: |
| // a pass over fifty certs can outlive a holding, and the writer map |
| -// gives control/certs/** to the housekeeper alone. |
| +// gives control/certs/** to the housekeeper alone. Losing it ends the |
| +// pass but still reports what the pass did. |
| // - Only a DirCache is walked. The mirror holds the cache as an |
| // autocert.Cache, and that interface cannot enumerate; the on-disk |
| // layout is the only backend that exists (certcache.go) and the one |
| @@ -803,8 +809,8 @@ func (m *certMirror) run(stop <-chan struct{}) { |
| // a kill mid-write — neither is the deployment's to replicate onto every |
| // edge. A per-host entry (<host>, <host>+rsa) must pass m.allowed, the |
| // same registry-backed exact check both read-throughs use; a wildcard |
| -// PEM must be named for the parent its leaf actually covers. Account |
| -// keys are their own shape. |
| +// PEM must be for a parent in the wildcard manager's current set and |
| +// its leaf must cover it. Account keys are their own shape. |
| // - Challenge tokens never publish: they are seconds long, and a copy of |
| // a spent one is litter every edge would go on serving. |
| // - Account keys publish only when the bucket has none. The deployment's |
| @@ -834,7 +840,7 @@ func (m *certMirror) backfill(ctx context.Context) { |
| entries, err := os.ReadDir(string(dir)) |
| if err != nil { |
| if !errors.Is(err, os.ErrNotExist) { |
| - m.logBackfillFailure(1, err) |
| + m.logBackfillFailure(0, err) |
| } |
| return |
| } |
| @@ -855,12 +861,17 @@ func (m *certMirror) backfill(ctx context.Context) { |
| if err != nil { |
| continue |
| } |
| - stamp := fileStamp{size: info.Size(), mtime: info.ModTime()} |
| - if inBucket && m.inSyncAt(name) == stamp { |
| + stamp := fileStamp{size: info.Size(), mtime: info.ModTime().UnixNano(), inBucket: inBucket} |
| + // Settled at this stamp on an earlier pass — in sync with the |
| + // bucket, or not this deployment's to publish — and nothing has |
| + // moved since (the bucket's side is part of the stamp: an entry |
| + // that was in sync but has since left the bucket re-reads). This |
| + // is what keeps steady state at a listing plus a stat per entry. |
| + if m.settledAt(name) == stamp { |
| continue |
| } |
| if !m.issuing() { |
| - return |
| + break |
| } |
| body, err := os.ReadFile(filepath.Join(string(dir), name)) |
| if err != nil { |
| @@ -872,20 +883,28 @@ func (m *certMirror) backfill(ctx context.Context) { |
| // Also the backstop for names the snapshot never parses: the |
| // freshness compare below cannot see those, and without this |
| // they would republish every poll. |
| - m.markInSync(name, stamp) |
| + m.markSettled(name, stamp) |
| continue |
| } |
| if accountKeyName(name) { |
| if inBucket { |
| + m.markSettled(name, stamp) |
| continue |
| } |
| } else { |
| cert, err := parseCertEntry(body) |
| if err != nil || !now.Before(cert.Leaf.NotAfter) || !m.ownsCertName(name, cert) { |
| + // Expiry is a function of time, but a settled entry can only |
| + // move from live to expired, never back; and ownership can |
| + // return (a route re-attached), which is a registry change — |
| + // so an entry settled as not-owned is re-examined only when |
| + // its stamp moves. A re-attached route mints afresh anyway. |
| + m.markSettled(name, stamp) |
| continue |
| } |
| if inBucket { |
| if have := snap.certs[name]; have != nil && !have.Leaf.NotAfter.Before(cert.Leaf.NotAfter) { |
| + m.markSettled(name, stamp) |
| continue |
| } |
| } |
| @@ -895,7 +914,8 @@ func (m *certMirror) backfill(ctx context.Context) { |
| lastErr = err |
| continue |
| } |
| - m.markInSync(name, stamp) |
| + // Not settled: the bucket side of the stamp changes on the next |
| + // poll, and the bytes.Equal path settles it then. |
| published++ |
| } |
| if published > 0 { |
| @@ -909,12 +929,16 @@ func (m *certMirror) backfill(ctx context.Context) { |
| // ownsCertName reports whether a cert-shaped cache entry is one this |
| // deployment should replicate: a per-host entry for a name the registry |
| // still serves (m.allowed — nil admits nothing, the safe default for a |
| -// mirror built without an Edge), or a wildcard PEM whose leaf covers |
| -// exactly the parent it is named for. |
| +// mirror built without an Edge), or a wildcard PEM for a parent in the |
| +// wildcard manager's CURRENT parent set (m.wildcardParents — nil, no |
| +// manager, admits none) whose leaf covers that parent. |
| func (m *certMirror) ownsCertName(name string, cert *tls.Certificate) bool { |
| if parent, ok := strings.CutPrefix(name, "wildcard-"); ok && strings.HasSuffix(parent, ".pem") { |
| parent = strings.TrimSuffix(parent, ".pem") |
| - return parent != "" && slices.Contains(cert.Leaf.DNSNames, "*."+parent) |
| + if parent == "" || m.wildcardParents == nil || !slices.Contains(m.wildcardParents(), parent) { |
| + return false |
| + } |
| + return slices.Contains(cert.Leaf.DNSNames, "*."+parent) |
| } |
| host := strings.TrimSuffix(name, "+rsa") |
| if m.allowed == nil || !m.allowed(host) { |
| @@ -923,27 +947,31 @@ func (m *certMirror) ownsCertName(name string, cert *tls.Certificate) bool { |
| return cert.Leaf.VerifyHostname(host) == nil |
| } |
| |
| -// fileStamp is the size+mtime a backfill pass last saw an entry at while it |
| -// was in sync with the bucket. Not a hash: DirCache writes are atomic |
| -// renames, so a changed body is a changed stamp. |
| +// fileStamp is the state a backfill pass last settled an entry at: the |
| +// file's size+mtime and whether the bucket held the name. Not a hash: |
| +// DirCache writes are atomic renames, so a changed body is a changed |
| +// stamp (nanosecond mtime, so a same-size rewrite within one second on a |
| +// coarse filesystem is the only alias, and autocert never rewrites a name |
| +// that fast). |
| type fileStamp struct { |
| - size int64 |
| - mtime time.Time |
| + size int64 |
| + mtime int64 |
| + inBucket bool |
| } |
| |
| -func (m *certMirror) inSyncAt(name string) fileStamp { |
| +func (m *certMirror) settledAt(name string) fileStamp { |
| m.mu.RLock() |
| defer m.mu.RUnlock() |
| - return m.inSync[name] |
| + return m.settled[name] |
| } |
| |
| -func (m *certMirror) markInSync(name string, st fileStamp) { |
| +func (m *certMirror) markSettled(name string, st fileStamp) { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| - if m.inSync == nil { |
| - m.inSync = map[string]fileStamp{} |
| + if m.settled == nil { |
| + m.settled = map[string]fileStamp{} |
| } |
| - m.inSync[name] = st |
| + m.settled[name] = st |
| } |
| |
| // logBackfillFailure is logPartialPoll's cadence for the publish side. |