carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3// Issuer-side certificate publishing (spec:
4// docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md, Part 2).
5//
6// With more than one edge in a deployment, exactly ONE of them talks to the
7// CA — the housekeeper (internal/host/housekeeper.go) — and every artifact
8// that issuance produces is published to the control bucket under
9// control/certs/ so every OTHER edge can serve it. Two halves live here:
10//
11// - publishingCache wraps the autocert.Cache the whole issuance path
12// already funnels through (certcache.go's seam, reserved for exactly
13// this). autocert writes its ACME account key, every per-host cert+key
14// PEM, and every HTTP-01 token response through its Cache — cluster
15// support is that interface's documented purpose — and wildcard.go's
16// DNS-01 manager shares the same handle. Wrapping it therefore captures
17// per-host certs, wildcards, tokens and account keys with no autocert
18// fork and no second abstraction.
19//
20// - the issuance gate: HostPolicy (per-host HTTP-01) and the wildcard
21// manager's order path (DNS-01) both refuse to START work on a box that
22// is not the housekeeper. Serving is never gated: an edge serves from
23// its local cache and from the mirror of this prefix regardless.
24//
25// Cert writes never bump control/serial — the serial is the console's
26// (writer map, Part 1); the mirror side polls this prefix on its own
27// cadence. Renewals run ~30 days before expiry, so a poll cadence measured
28// in seconds is never on a deadline.
29
30import (
31 "bytes"
32 "context"
33 "crypto/sha256"
34 "crypto/tls"
35 "crypto/x509"
36 "errors"
37 "fmt"
38 "log"
39 "math/rand/v2"
40 "net"
41 "net/http"
42 "os"
43 "path"
44 "path/filepath"
45 "slices"
46 "strings"
47 "sync"
48 "time"
49
50 "golang.org/x/crypto/acme"
51 "golang.org/x/crypto/acme/autocert"
52 "golang.org/x/net/idna"
53
54 "github.com/carlosframework/platform/internal/lbhealth"
55 "github.com/carlosframework/platform/internal/release"
56)
57
58// certPrefix is where every issuance artifact lands in the control bucket.
59// One prefix for the lot, named by the cache key autocert (or wildcard.go)
60// chose, so the mirror can rebuild a whole cache from a single list — and so
61// the fleet door's ACL has exactly one prefix to keep denying.
62const certPrefix = "control/certs/"
63
64// certKey is the bucket key for cache entry name. The cache name goes
65// through verbatim: "example.com", "example.com+rsa", "acme_account+key",
66// "<token>+http-01", "wildcard-<parent>.pem", "wildcard-account.key".
67func certKey(name string) string { return certPrefix + name }
68
69// IssuerGate answers "may this process start certificate issuance, and under
70// what authority does that work run?" — the housekeeper's gate
71// (internal/host), handed DOWN at edge construction because internal/host
72// imports internal/edge and never the reverse. The context is cancelled the
73// instant this box stops being the housekeeper, so an order in flight
74// unwinds with the authority it started under.
75//
76// A nil gate means "ungated": `carlos edge` run without the agent, and every
77// test, has no election in the process at all, and refusing issuance there
78// would leave the box with no certificates rather than with today's
79// behaviour.
80type IssuerGate func() (context.Context, bool)
81
82// issuerGate is the Edge's read of its gate, with the ungated default
83// applied. See the type's doc for why nil means "yes".
84func (e *Edge) issuerGate() (context.Context, bool) {
85 if e.issuer == nil {
86 return context.Background(), true
87 }
88 return e.issuer()
89}
90
91// errNotIssuer is what HostPolicy answers on a box that is not the
92// deployment's certificate issuer. Sentinel (errors.Is-able) so the gate is
93// distinguishable from "that host isn't mine", which is a different fault
94// with a different fix.
95//
96// The message text is deliberately generic, not diagnostic: autocert's own
97// HTTPHandler writes this exact string, verbatim, as the body of an
98// unauthenticated :80 response (http.Error(w, err.Error(), 403) at
99// autocert.go's HTTPHandler) — OUR code never gets a chance to intercept or
100// reword it before it reaches the wire. "acme: this box is not the
101// deployment's certificate issuer" told any anonymous prober both that
102// multi-edge issuance exists and which box is not the issuer; the identity
103// that matters everywhere else in this file is errors.Is(err, errNotIssuer),
104// never the string, so softening it costs nothing internal.
105var errNotIssuer = errors.New("acme: not available here")
106
107// armCertIssuance wires cert DISTRIBUTION and the issuance GATE together,
108// because they are one decision and not two: a box may only be told "you are
109// not the issuer" if some other box's certificates can reach it.
110//
111// WITH a deployment store: every issuance artifact publishes to
112// control/certs/ and the gate applies — one elected issuer mints, every
113// other edge serves the published copy.
114//
115// WITHOUT one there is nothing to publish to and, just as importantly,
116// nothing to mirror FROM. The gate is cleared and the box issues for itself
117// exactly as it always has. Three real box classes land here, and gating any
118// of them would leave it permanently certificate-less — no new orders AND no
119// serving out of its own on-disk cache, since autocert consults HostPolicy
120// before its cache lookup:
121//
122// - fleet boxes (CARLOS_FLEET) carrying no deployment bucket of their own:
123// the housekeeper never contends there, and there is nothing to mirror
124// from. The fleet store DOOR is not what decides this and never was: the
125// edge does not use the door at all — enableStaticFromEnv reads
126// CARLOS_DEPLOYMENT_BUCKET / CARLOS_DEPLOYMENT_DIR and opens the store
127// directly, and the door is the agent's client. A grandfathered fleet box
128// that still carries the bucket env therefore reads control/certs/ over
129// its own IAM, and is gated AND mirrored like any other non-issuer — a
130// transition state, resolved by the rollout dropping that env. It also
131// never BACKFILLS (#318): mayBackfill requires contending in the
132// election, and a fleet box's housekeeper mode is "never" — its disk
133// holds a customer's certs, not this deployment's;
134// - boxes with neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR;
135// - boxes with a bucket but no CARLOS_RELEASE_PUBKEY, where
136// enableStaticFromEnv leaves staticStore nil.
137//
138// Clearing the field (rather than only skipping the HostPolicy wrap) is what
139// keeps the wildcard manager symmetric too: newWildcardFromEnv reads the
140// same gate a few lines later.
141func (e *Edge) armCertIssuance(inner autocert.Cache) autocert.Cache {
142 if e.staticStore == nil {
143 e.issuer = nil
144 log.Printf("edge: cert publishing off (no deployment store configured) — this box issues and keeps its own certs, as before")
145 return inner
146 }
147 log.Printf("edge: publishing issued certs to %s* in the deployment bucket", certPrefix)
148 return &publishingCache{inner: inner, s: e.staticStore, issuer: e.issuerGate}
149}
150
151// issuerHostPolicy is the autocert HostPolicy: the registry check this edge
152// has always applied, plus the issuance gate.
153//
154// The registry check is deploymentHost (edge.go) rather than Allowed: a
155// deployment's issuer must be able to mint for any EXACTLY-named route in the
156// deployment, not only the ones this box happens to home, or a home-boxed
157// route whose hellos reach the issuer through the backhaul can never get a
158// certificate at all (#277). Exact-only, so PR #180's rule is untouched: a
159// label under a catch-all still drives no order. The errNotIssuer gate below
160// is unaffected — non-issuers still order nothing, for any name.
161//
162// Two autocert call sites reach this, and both are deliberate:
163// GetCertificate consults the policy BEFORE its cache lookup
164// (autocert.go:292 in x/crypto v0.52.0), and HTTPHandler consults it before
165// serving an HTTP-01 token (autocert.go:405). On a non-issuer both therefore
166// decline — which is correct only because the mirror layer sits behind them
167// and serves both certs and challenges from control/certs/. Nothing here may
168// be relaxed into "allow if we have it cached": autocert treats an EXPIRED
169// cached cert as a miss and would go straight to an order, so a policy that
170// says yes for a cached name is a policy that lets a non-issuer order.
171//
172// It does NOT stop renewals, and nothing here should be read as claiming it
173// does: autocert's renewal timers (renewal.go's domainRenewal.do) never
174// consult HostPolicy. A timer started while this box held the lease keeps
175// firing after a stand-down; each firing re-checks the cache first, and only
176// orders when the cache has nothing fresh. Two consequences, both landing on
177// Task 5: the mirror MUST write fresh certs into this box's LOCAL cache, so
178// that cache-first check short-circuits the timer before it orders; and
179// until it does, a stood-down box's timer fails HTTP-01 against our own
180// gated handler every ~30 minutes, burning the shared account's
181// failed-validation budget. (A tls-alpn-01 validation would even succeed —
182// the token-cert branch at autocert.go:288 precedes the policy check — and
183// then the gated publisher declines to publish the result.)
184//
185// The flag is not a fence. Under a hung lease renewal two boxes can
186// transiently both believe they hold — the housekeeper's own stand-down is
187// bounded by its 30s poll, and a lost renewal is only noticed on that poll.
188// Brief double-issuance against the shared ACME account is inside the
189// spec's accepted envelope (one account, one rate-limit bucket, renewals ~30
190// days early), so there is deliberately NO fencing token here and no
191// readiness predicate on the election: a fence would trade a rare duplicate
192// order for a deployment that can stop issuing altogether.
193func (e *Edge) issuerHostPolicy() autocert.HostPolicy {
194 return func(_ context.Context, host string) error {
195 if !e.deploymentHost(host) {
196 return fmt.Errorf("acme: host %q not in registry", host)
197 }
198 if _, ok := e.issuerGate(); !ok {
199 return errNotIssuer
200 }
201 return nil
202 }
203}
204
205// publishingCache is the autocert.Cache every issuance artifact goes
206// through: the local cache first (durability, and autocert's own read path
207// keeps working byte-for-byte), then a best-effort copy into the control
208// bucket.
209//
210// Bucket failures are LOUD and never returned: a returned error aborts an
211// issuance that has already succeeded locally, trading a cert this box could
212// have served for a bucket blip. The mirror's next poll (and, failing that,
213// the next renewal) re-publishes.
214type publishingCache struct {
215 inner autocert.Cache
216 s release.Store
217 // issuer gates the BUCKET half only — the local cache is written
218 // unconditionally, because a non-issuer must still be able to serve what
219 // it was handed. nil = ungated (see IssuerGate).
220 //
221 // It is the gate's boolean that is used and not its context: publishing
222 // is not an order, and cancelling a publish because the holding ended
223 // mid-write would strand a freshly minted cert on one box's disk with
224 // nothing scheduled to re-publish it. The write runs on its own budget
225 // (publishCtx) instead.
226 issuer IssuerGate
227}
228
229// certPublishTimeout budgets ONE bucket publish or delete. It exists because
230// autocert calls this cache from under its own locks: deleteHTTPToken and
231// deleteCertToken call Cache.Delete while HOLDING m.challengeMu for write,
232// and both httpToken (the :80 challenge handler) and GetCertificate's
233// tls-alpn-01 branch read under that same mutex. An unbudgeted bucket call
234// there wedges challenge serving and ALPN handshakes on the issuer for as
235// long as the network takes. The Put path is no better off inheriting
236// autocert's own 5-10 minute order deadline.
237//
238// A var, not a const, so tests can shorten it.
239var certPublishTimeout = 10 * time.Second
240
241// publishCtx budgets one bucket call, detached from the caller's
242// cancellation. WithoutCancel is deliberate: an order that has already been
243// abandoned still has to clean up its challenge token, and a cancelled
244// context would skip exactly the delete that prevents litter every edge
245// would go on to mirror and serve.
246func publishCtx(ctx context.Context) (context.Context, context.CancelFunc) {
247 return context.WithTimeout(context.WithoutCancel(ctx), certPublishTimeout)
248}
249
250func (p *publishingCache) Get(ctx context.Context, key string) ([]byte, error) {
251 return p.inner.Get(ctx, key)
252}
253
254// Put writes locally, then publishes. Local first: if the disk write fails
255// there is no cert, and publishing one the box itself cannot serve would be
256// a lie in the bucket.
257func (p *publishingCache) Put(ctx context.Context, key string, data []byte) error {
258 if err := p.inner.Put(ctx, key, data); err != nil {
259 return err
260 }
261 if !p.publishes(key) {
262 return nil
263 }
264 pctx, cancel := publishCtx(ctx)
265 defer cancel()
266 if err := putCert(pctx, p.s, key, data); err != nil {
267 log.Printf("certsync: bucket publish failed for %q: %v — the cert is live on this box; the issuer's next renewal republishes", key, err)
268 }
269 return nil
270}
271
272// Delete removes both copies. The bucket copy goes even when the local
273// delete fails: the entry autocert deletes is a spent HTTP-01 token, and a
274// token left in a prefix every edge mirrors is litter every edge would keep
275// serving.
276//
277// It is gated like Put, and that is a deliberate trade: an issuer that
278// stands down mid-order leaves its token behind in the bucket, because
279// ISSUANCE artifacts stay the housekeeper's to write and un-write (#318
280// widened only the backfill of settled cert entries, never tokens), and a
281// box that is no longer the housekeeper does not get to delete there. The
282// spec's answer to that litter is a TTL stamp swept from the mirror side,
283// not a second writer.
284func (p *publishingCache) Delete(ctx context.Context, key string) error {
285 err := p.inner.Delete(ctx, key)
286 if !p.publishes(key) {
287 return err
288 }
289 pctx, cancel := publishCtx(ctx)
290 defer cancel()
291 if derr := p.s.Delete(pctx, certKey(key)); derr != nil {
292 log.Printf("certsync: bucket delete failed for %q: %v — a spent challenge token may linger until it is overwritten", key, derr)
293 }
294 return err
295}
296
297// publishes reports whether this box should write key to the bucket: only
298// the issuer publishes, and only names that survive the round trip through a
299// bucket key do.
300func (p *publishingCache) publishes(key string) bool {
301 if p.s == nil {
302 return false
303 }
304 if p.issuer != nil {
305 if _, ok := p.issuer(); !ok {
306 // Not the issuer. Silent, like every other not-held path in this
307 // deployment: it is the permanent normal state of N-1 boxes.
308 return false
309 }
310 }
311 if !publishableCertName(key) {
312 log.Printf("certsync: refusing to publish cache entry %q — the name does not round-trip as a bucket key", key)
313 return false
314 }
315 return true
316}
317
318// publishableCertName rejects cache names that would not survive as
319// "control/certs/<name>": a "/" would put the entry outside the flat prefix
320// the mirror maps back onto cache names, and a traversal segment has no
321// business in a key derived from a hostname. Nothing autocert or wildcard.go
322// generates fails this (autocert's HTTP-01 key is path.Base + "+http-01",
323// and domains are punycode-validated before they reach a cache key); it is
324// here so that a future key shape fails visibly rather than scattering
325// objects through the control prefix.
326func publishableCertName(name string) bool {
327 if name == "" || strings.Contains(name, "/") {
328 return false
329 }
330 for _, seg := range []string{"..", "\\"} {
331 if strings.Contains(name, seg) {
332 return false
333 }
334 }
335 return true
336}
337
338// putCert writes key's body regardless of prior state: create-only when
339// absent, etag-replace when present, re-reading on a lost race — the
340// putReplacing idiom from internal/host's snapshot loop, needed here for the
341// same reason (release.Store.Put with an empty ifMatch is create-only, so a
342// renewal would otherwise lose to its own previous cert).
343//
344// Last-writer-wins is the DESIGN here, not a compromise. On the issuance
345// path, the transient double-holder the housekeeper's poll window allows
346// writes an equally valid cert for the same name, and whichever lands last
347// is servable. On the backfill path (#318: any contending box), a writer
348// racing a stale view can land an OLDER leaf — repaired, not prevented:
349// fileStamp's bucket-identity half un-settles the box holding the newer
350// one, whose next pass republishes it. A freshness check inside this
351// function would still race (check-then-put), so the repair loop is the
352// guarantee, and it converges because the NotAfter compare is monotone.
353func putCert(ctx context.Context, s release.Store, name string, body []byte) error {
354 return putCAS(ctx, s, certKey(name), body)
355}
356
357// putCAS is putCert's write, by key rather than by cert name — the same
358// read-etag-then-If-Match loop, shared so the cert warm's outcome records
359// (#299 §3e, certwarm.go) get the identical concurrency posture instead of a
360// second copy of it that can drift.
361func putCAS(ctx context.Context, s release.Store, key string, body []byte) error {
362 for attempt := 0; attempt < 3; attempt++ {
363 _, etag, err := s.Get(ctx, key)
364 switch {
365 case errors.Is(err, os.ErrNotExist):
366 etag = ""
367 case err != nil:
368 return fmt.Errorf("get %s: %w", key, err)
369 }
370 if _, err := s.Put(ctx, key, body, etag); err == nil {
371 return nil
372 } else if !errors.Is(err, release.ErrCASConflict) {
373 return fmt.Errorf("put %s: %w", key, err)
374 }
375 }
376 return fmt.Errorf("%s: lost CAS race three times", key)
377}
378
379// ---- the mirror: every edge serves every cert --------------------------------
380//
381// certMirror is the serving half of the pair at the top of this file, and the
382// reason the issuance gate above is safe to shut. It POLLS control/certs/
383// (its serving side never writes there; the one writer it carries is the
384// backfill below, bounded to cert entries this box owns — #318) and keeps
385// the whole prefix in memory, so any edge can answer a
386// handshake for a name some OTHER edge issued, and can answer an HTTP-01
387// challenge for an order some OTHER edge is running.
388//
389// Three consumers, in the order a request meets them:
390//
391// - GetCertificate (wrapGetCertificate, wildcard.go): the mirror is the
392// PRIMARY serving path on a non-issuer, ahead of autocert, because
393// autocert's HostPolicy runs BEFORE its cache lookup — a gated manager
394// serves nothing at all, cached or not.
395// - the :80 challenge handler (challengeHandler): in FRONT of autocert's
396// HTTPHandler for the same reason — that handler 403s a non-issuer before
397// it ever looks a token up.
398// - the LOCAL autocert cache: every refresh copies fresh cert material into
399// it. autocert's renewal timers never consult HostPolicy (see
400// issuerHostPolicy), and renewal.go's do() checks the cache FIRST, so a
401// mirror-refreshed local cache is what stops a stood-down box's stale
402// timers from ordering — and what carries a box through the boot and
403// crash windows where the election has not yet been decided.
404//
405// Account keys are the one thing that is mirrored but never served: they are
406// issuer material and have no business in a map a handshake can reach.
407
408// certMirrorInterval is the poll cadence. Renewals run ~30 days before
409// expiry, so nothing on this path is on a deadline — except live HTTP-01
410// validations, which the poll deliberately does NOT try to catch (see
411// challengeResponse's read-through).
412var certMirrorInterval = 30 * time.Second
413
414// certMirrorTimeout budgets one whole poll: list plus the gets. Generous,
415// because it runs on its own goroutine and blocks nothing; bounded, because a
416// stalled bucket must not wedge the loop forever.
417var certMirrorTimeout = 2 * time.Minute
418
419// certMirrorBootTimeout budgets the ONE refresh that runs before the
420// listeners bind. Much shorter than a poll's: a stalled bucket must delay the
421// front door by seconds, not minutes, and the box still has its own cache to
422// serve from while the loop catches up.
423var certMirrorBootTimeout = 20 * time.Second
424
425// certRefreshConcurrency bounds the in-flight gets in Refresh. It is a
426// latency fix, not a throughput one: the pass costs
427// ceil(count/concurrency) x latency instead of count x latency, which at
428// 101 certs and a 250ms round trip is about 2s instead of 25s.
429//
430// Ten, not sixteen, because the AWS SDK's default transport keeps ten idle
431// connections per host (DefaultHTTPTransportMaxIdleConnsPerHost) and nothing
432// here overrides it. Sixteen in flight means six connections closed at the
433// end of every pass and re-handshaked on the next one, forever, on a 30s
434// poll — about a dozen extra cross-region TLS handshakes a minute for one
435// extra wave's worth of latency.
436const certRefreshConcurrency = 10
437
438// certSnapshot is one poll's result, swapped in whole. Immutable once
439// published, so readers hold no lock beyond the pointer read.
440type certSnapshot struct {
441 // entries is every mirrored name → body, account keys included (the local
442 // sync needs them; Cert refuses to serve them).
443 entries map[string][]byte
444 // certs is the parsed form of every cert-shaped entry, keyed by the same
445 // name. Parsing happens ONCE per poll per changed entry, never per
446 // handshake. A name whose body does not parse is absent, not nil.
447 certs map[string]*tls.Certificate
448}
449
450var emptyCertSnapshot = &certSnapshot{entries: map[string][]byte{}, certs: map[string]*tls.Certificate{}}
451
452type certMirror struct {
453 s release.Store
454 // local is this box's OWN autocert cache — the raw one, NOT the
455 // publishing wrapper: a mirror write is a copy inwards, and pushing it
456 // back out to the bucket would have the issuer republishing its own
457 // material on every poll.
458 local autocert.Cache
459
460 // mayBackfill reports whether this box may publish its disk's certs at
461 // all — the backfill's switch (see backfill): true on a box run under an
462 // agent (whose election makes it an accountable member of the
463 // deployment), false on a hand-run `carlos edge` and nil — never — for a
464 // mirror built without an Edge.
465 mayBackfill func() bool
466 // issuing reports whether this box holds the issuer gate RIGHT NOW —
467 // what remains single-writer inside backfill: seeding the ACME account
468 // key (#318 widened cert publishing to every agent-run box). nil = never
469 // the issuer, the safe default for a mirror built without an Edge.
470 issuing func() bool
471 // wildcardParents is the wildcard manager's current parent set, the
472 // bound on which wildcard PEMs backfill may publish. nil = no manager =
473 // none.
474 wildcardParents func() []string
475 // settled memoes the stamp each on-disk entry was last found settled at
476 // (backfill); backfillFailures counts failed passes for the log
477 // cadence; backfillOnce guards the one-time "cannot walk" line.
478 settled map[string]fileStamp
479 backfillFailures int
480 backfillOnce sync.Once
481
482 // wake is called after a poll that changed wildcard material — the
483 // wildcard manager's refresh (R4), so a takeover propagates on the poll
484 // instead of on the manager's own 12h tick. nil when wildcards are off.
485 wake func()
486
487 // allowed reports whether a hostname is one this box serves, or one
488 // another box in this deployment exactly homes (Edge.deploymentHost) —
489 // the bound on BOTH read-throughs, whose keys are attacker-chosen SNI and
490 // an attacker-chosen Host header. Exact names only, either side: a label
491 // under a catch-all spends neither budget (#180, and KnownHost's doc).
492 // nil = unbounded, which is tests only.
493 allowed func(string) bool
494
495 // gated reports whether issuance is currently BARRED on this box, which
496 // is what decides where the mirror sits in the handshake chain (see
497 // wrapGetCertificate). Read per hello, because the election moves under a
498 // running process. nil = treat as gated, the safe default: it serves the
499 // same certificates either way, it only costs an issuer its lazily-armed
500 // renewal timers, and a mirror built without a gate (tests) has no
501 // issuance to arm.
502 gated func() bool
503
504 // refreshMu serializes Refresh's whole body. Production has exactly one
505 // caller today (the 30s loop, run()), so this is insurance rather than a
506 // live fix: two overlapping polls (a second caller added later, or a
507 // boot-time refresh racing the loop's first tick) could otherwise both
508 // read m.snapshot() as prev, build independently, and publish out of
509 // order — the SECOND Store to finish under m.mu.Lock() wins regardless
510 // of which list was actually more recent, so a slow poll could clobber a
511 // fresher one. Locking the body makes Refresh calls queue and apply in
512 // the order they were made.
513 refreshMu sync.Mutex
514
515 mu sync.RWMutex
516 snap *certSnapshot
517 // partialPolls counts polls that could not read every object, for the
518 // first-and-every-tenth log cadence.
519 partialPolls int
520
521 // health is the load balancer health state the first landed refresh
522 // latches (region HA spec 2 §6); nil without region HA.
523 health *lbhealth.State
524
525 // watch resolves the process's change-index watch (#336), handed down by
526 // the agent through WithChangeWatch and read on every poll: the agent
527 // arms its watch on its own schedule, possibly after the edge starts.
528 // nil, or a nil watch, is today's mirror: every poll a full Refresh.
529 // now is the poll's clock (nil is time.Now; tests inject one).
530 watch func() *release.ChangeWatch
531 now func() time.Time
532 // watchTimeout bounds the poll of the watch (0 is
533 // release.ChangeIndexPollTimeout; tests shorten it).
534 watchTimeout time.Duration
535
536 // Guarded by refreshMu: what the published snapshot was read at.
537 // etags is each entry's ETag as its Get returned it ("" = unknown, read
538 // it again); lastFull is the last full rebuild that landed and fullAge
539 // the jittered backstop after which the next poll rebuilds in full;
540 // actedGen is the control/certs generation, captured BEFORE the read,
541 // that the snapshot is known to reflect (actedOK false = no such
542 // generation: the watch could not vouch, or some object went unread).
543 etags map[string]string
544 lastFull time.Time
545 fullAge time.Duration
546 actedGen uint64
547 actedOK bool
548 // localPending: the last copy into the local cache left entries it
549 // could not write; an unchanged poll retries it from the snapshot.
550 localPending bool
551
552 // synced remembers, per name, the mirrored bytes last reconciled into the
553 // local cache, so a steady state costs no local I/O at all.
554 syncMu sync.Mutex
555 synced map[string][]byte
556
557 // rt is the read-through memo (hits and misses), its in-flight set, and
558 // the global token bucket that bounds live bucket reads.
559 rtMu sync.Mutex
560 rt map[string]readThroughEntry
561 rtInflight map[string]chan struct{}
562 rtHTTP rtBudget // :80, the HTTP-01 challenge path
563 rtALPN rtBudget // 443, the tls-alpn-01 token-cert path
564 lastRTLog time.Time
565
566 // localCerts memoes the last-resort reads of this box's own cache;
567 // lastLocalLog throttles the line that says the mirror had nothing.
568 localMu sync.Mutex
569 localCerts map[string]localCertEntry
570 lastLocalLog time.Time
571}
572
573// newCertMirror is the Edge's mirror, or nil when there is nothing to mirror
574// FROM. The nil case is exactly armCertIssuance's no-store case — the two
575// decisions are the same decision, and a box that lands there is ungated and
576// serves its own cache through autocert as it always has.
577func (e *Edge) newCertMirror(local autocert.Cache) *certMirror {
578 if e.staticStore == nil {
579 return nil
580 }
581 m := newCertMirror(e.staticStore, local)
582 m.health = e.lbHealth
583 m.watch = e.changeWatch
584 m.gated = func() bool {
585 _, held := e.issuerGate()
586 return !held
587 }
588 // Both read-throughs are keyed by remote input (SNI on 443, the Host
589 // header on :80), so they are bounded to the names this DEPLOYMENT serves:
590 // this box's own routes, plus the exact names another box's edges file
591 // homes. deploymentHost is the same exact-match, registry-backed check
592 // autocert's HostPolicy uses (issuerHostPolicy, above), which is the right
593 // bound for the same reason it always was — a per-host order only ever
594 // exists for a name that passed it — now that the policy admits the
595 // deployment's remote-homed routes too (#277).
596 m.allowed = e.deploymentHost
597 // Backfill runs on any box that CONTENDS in the deployment's issuer
598 // election (#318): contending is what makes the box an accountable
599 // member of the deployment, whether or not it currently holds the
600 // lease. Two box classes are excluded, each for its own reason: a
601 // hand-run `carlos edge` with a bucket but no agent is "held" for
602 // serving and must NOT dump its disk into the deployment's prefix
603 // (e.issuer nil, and no contender predicate was ever handed down); and
604 // a grandfathered FLEET box still carrying the bucket env is agent-run
605 // with a gate func, but its housekeeper mode is "never" — its disk
606 // holds a customer's certs, which are not this deployment's to
607 // replicate onto every platform edge (WithIssuerContender, and the
608 // armCertIssuance doc's grandfathered-fleet-box paragraph).
609 m.mayBackfill = func() bool { return e.issuer != nil && e.contends != nil && e.contends() }
610 // Strict: only the box holding the lease RIGHT NOW may seed the
611 // deployment's ACME account identity (see backfill's account-key rule).
612 m.issuing = func() bool {
613 if e.issuer == nil {
614 return false
615 }
616 _, held := e.issuer()
617 return held
618 }
619 return m
620}
621
622// attachWildcardToMirror is the mirror↔wildcard-manager wiring, in one place
623// so a test can see it: a wildcard the ISSUER minted reaches this box's
624// handshakes on the next poll rather than on the manager's 12h tick (wake),
625// and the manager's current parent set bounds which wildcard PEMs the
626// issuer's backfill may publish. Either side nil is a no-op.
627func attachWildcardToMirror(m *certMirror, w *wildcardManager) {
628 if m == nil || w == nil {
629 return
630 }
631 m.wake = w.refresh
632 m.wildcardParents = w.currentParents
633}
634
635func newCertMirror(s release.Store, local autocert.Cache) *certMirror {
636 return &certMirror{
637 s: s,
638 local: local,
639 snap: emptyCertSnapshot,
640 synced: map[string][]byte{},
641 rt: map[string]readThroughEntry{},
642 rtInflight: map[string]chan struct{}{},
643 localCerts: map[string]localCertEntry{},
644 }
645}
646
647// Refresh rebuilds the whole map from one list plus a get per key. It is the
648// boot read before the listeners bind, every poll while the change index is
649// disarmed, and the armed loop's backstop and "unknown" read (poll, #336).
650// A rebuild from the listing is what makes a withdrawn entry (a spent
651// challenge token) actually leave; poll's changed-only read builds from a
652// listing too, for the same reason.
653//
654// An error leaves the last-good snapshot in place and is returned for the
655// caller to log. That is the edge's standing degrade-not-die posture, and it
656// matters more here than usual: an empty map on a bucket blip would be a TLS
657// outage on every non-issuer in the deployment.
658func (m *certMirror) Refresh(ctx context.Context) error {
659 m.refreshMu.Lock()
660 defer m.refreshMu.Unlock()
661 return m.refreshFull(ctx)
662}
663
664// refreshFull is Refresh's body, under refreshMu.
665func (m *certMirror) refreshFull(ctx context.Context) error {
666 // The generation this read will reflect, captured BEFORE the list: a
667 // write that lands during the read leaves control/certs moved past it,
668 // so the next poll looks again (#336). Without a usable watch there is
669 // none, and the next poll cannot skip.
670 gen, genOK := m.certsGen()
671 keys, err := m.s.List(ctx, certPrefix)
672 if err != nil {
673 return fmt.Errorf("list %s: %w", certPrefix, err)
674 }
675 prev := m.snapshot()
676 next := make(map[string][]byte, len(keys))
677 etags := make(map[string]string, len(keys))
678
679 // The eligible names first, then their bodies CONCURRENTLY. The fetch is
680 // one round trip per object and the objects are independent, so doing
681 // them one after another makes the whole pass cost
682 // count x latency — which is fine beside the bucket and is not fine
683 // across the planet.
684 //
685 // That is not hypothetical (amadan #118 thread, diagnosed 2026-09-19).
686 // The Sydney box reads this deployment's bucket in eu-west-1 at ~250ms a
687 // round trip. At 101 published certs the serial pass took ~25s, blowing
688 // certMirrorBootTimeout, and since this refresh runs BEFORE either
689 // listener binds, the edge was not serving until it finished. The
690 // self-update gate waits 20s for the edge to answer on loopback, so that
691 // box failed its gate, rolled back, failed the rollback's gate too, and
692 // took no update at all from 2026-09-07 to 2026-09-19.
693 //
694 // The count is what makes it a fleet problem rather than an AU one: every
695 // box pays count x latency, so each new published cert moves every box
696 // closer to the same cliff. us-east-1 at ~75ms is about a third of the
697 // way there at today's count.
698 var items []certFetch
699 for _, key := range keys {
700 name, ok := strings.CutPrefix(key, certPrefix)
701 // publishableCertName is the same filter the publish side applies:
702 // anything nested under the prefix was not written by this deployment's
703 // issuer and has no cache name to map back onto.
704 if !ok || !publishableCertName(name) {
705 continue
706 }
707 items = append(items, certFetch{key: key, name: name})
708 }
709 tried := len(items)
710 failed, lastErr := applyCertFetches(items, m.fetchCerts(ctx, items), prev, next, etags)
711 if tried > 0 && failed == tried {
712 // Nothing at all could be read: that is a bucket fault, not an object
713 // fault, and it is reported like a failed List — last-good stands.
714 return fmt.Errorf("get %s*: every one of %d object(s) failed, last: %w", certPrefix, tried, lastErr)
715 }
716 if failed > 0 {
717 m.logPartialPoll(failed, tried, lastErr)
718 }
719 m.publish(ctx, prev, next, etags)
720 // A partial rebuild still counts as the full rebuild: the objects it
721 // could not read carry an unknown ETag, so the next poll's changed-only
722 // read fetches exactly those again. It reflects no generation, though,
723 // so that next poll cannot skip.
724 m.lastFull, m.fullAge = m.clock(), jitteredBackstop()
725 m.actedGen, m.actedOK = gen, genOK && failed == 0
726 return nil
727}
728
729// poll is the 30s loop's refresh (#336). Disarmed, it is Refresh. Armed, it
730// polls the change index (a 304 when nothing moved, one GET either way) and
731// then does the least that keeps the snapshot current:
732//
733// - nothing, when control/certs has not moved since the generation the
734// snapshot reflects and the last full rebuild is younger than the
735// jittered backstop;
736// - a changed-only read (refreshChanged) when it has moved;
737// - today's full Refresh when the watch cannot vouch for the scope (the
738// index unreadable: "unknown", never "unchanged"), when no full rebuild
739// has landed yet, or at the backstop.
740//
741// Poll's moved list is not consulted: the watch is shared with the agent's
742// tick and record view, so whichever polls first consumes it. Generations
743// are compared with the one this mirror last acted on instead.
744func (m *certMirror) poll(ctx context.Context) error {
745 w := m.changeWatch()
746 if w == nil {
747 return m.Refresh(ctx)
748 }
749 // Bounded on its own: the watch is shared, and a hung index GET from
750 // another caller holds its poll lock. Any error, giving up on that lock
751 // included, is "unknown": a full read, as before the index.
752 _, perr := pollWatch(ctx, w, m.watchTimeout)
753 m.refreshMu.Lock()
754 defer m.refreshMu.Unlock()
755 gen, ok := w.Gen(certScope)
756 switch {
757 case perr != nil, !ok, m.lastFull.IsZero(), !m.clock().Before(m.lastFull.Add(m.fullAge)):
758 return m.refreshFull(ctx)
759 case m.actedOK && gen == m.actedGen:
760 // Nothing moved in the bucket, but a copy into the local cache
761 // that failed is retried from the snapshot already held: no
762 // bucket read (final review R1, finding 7).
763 if m.localPending {
764 m.localPending = m.syncLocal(ctx, m.snapshot())
765 }
766 return nil
767 }
768 return m.refreshChanged(ctx, gen)
769}
770
771// refreshChanged rebuilds the snapshot from one ETag listing plus a get per
772// new or rewritten object, under refreshMu. gen is the control/certs
773// generation captured before the listing. It keeps every guarantee of the
774// full rebuild:
775//
776// - a key absent from the listing (a spent challenge token) is absent from
777// the new snapshot, because the snapshot is built from the listing;
778// - a failed get of a changed object carries the previous copy forward
779// with an unknown ETag, so the next poll fetches it again, and is never
780// a failed poll, even when it is the only object that changed;
781// - the new snapshot is built whole and swapped in by publish, which also
782// copies changed material into the local cache and wakes the wildcard
783// manager, exactly as Refresh does.
784//
785// A store without the ETag listing, or a listing that fails, is "unknown":
786// today's full Refresh runs instead.
787func (m *certMirror) refreshChanged(ctx context.Context, gen uint64) error {
788 lister, ok := m.s.(release.ETagLister)
789 if !ok {
790 return m.refreshFull(ctx)
791 }
792 listed, err := lister.ListETags(ctx, certPrefix)
793 if err != nil {
794 return m.refreshFull(ctx)
795 }
796 prev := m.snapshot()
797 next := make(map[string][]byte, len(listed))
798 etags := make(map[string]string, len(listed))
799 var items []certFetch
800 for _, le := range listed {
801 name, ok := strings.CutPrefix(le.Key, certPrefix)
802 if !ok || !publishableCertName(name) {
803 continue
804 }
805 if was := m.etags[name]; was != "" && was == le.ETag {
806 if body, ok := prev.entries[name]; ok {
807 next[name], etags[name] = body, was
808 continue
809 }
810 }
811 items = append(items, certFetch{key: le.Key, name: name})
812 }
813 tried := len(items)
814 failed, lastErr := applyCertFetches(items, m.fetchCerts(ctx, items), prev, next, etags)
815 // No "every object failed" rule here, unlike Refresh: the listing just
816 // answered, so the bucket is up, and a changed-only read is often ONE
817 // object. Treating that one failure as a bucket fault would turn a single
818 // unreadable key into a failed poll until the backstop, holding back
819 // every deletion in the listing and the loop's backfill with it. The
820 // failed objects carry their previous copies with unknown ETags and the
821 // generation stays unacted, so the next poll reads them again.
822 if failed > 0 {
823 m.logPartialPoll(failed, tried, lastErr)
824 }
825 m.publish(ctx, prev, next, etags)
826 m.actedGen, m.actedOK = gen, failed == 0
827 return nil
828}
829
830// certFetch is one object a refresh reads; certFetchResult its outcome.
831type certFetch struct {
832 key, name string
833}
834
835type certFetchResult struct {
836 body []byte
837 etag string
838 err error
839}
840
841// fetchCerts gets every item's body, at most certRefreshConcurrency at once,
842// and returns the results in item order.
843func (m *certMirror) fetchCerts(ctx context.Context, items []certFetch) []certFetchResult {
844 results := make([]certFetchResult, len(items))
845 // A fixed pool pulling indices off a channel, NOT a goroutine per object
846 // parked on a semaphore. The distinction only shows up at scale, and it is
847 // the whole point of having a bound: goroutine-per-object costs one stack
848 // per cert before a single one is read, so a deployment with a million
849 // published certs would spend gigabytes on goroutines that are all waiting
850 // for one of sixteen slots.
851 //
852 // Workers also select on ctx.Done, so a cancelled boot refresh stops
853 // handing out work instead of draining the whole queue through Get. (It
854 // still waits for the gets already in flight: Refresh has never been a
855 // hard bound on its own wall clock, and certMirrorBootTimeout bounds the
856 // requests, not this function's return.)
857 workers := min(certRefreshConcurrency, len(items))
858 idx := make(chan int)
859 var wg sync.WaitGroup
860 for range workers {
861 wg.Add(1)
862 go func() {
863 defer wg.Done()
864 for i := range idx {
865 body, etag, err := m.s.Get(ctx, items[i].key)
866 results[i] = certFetchResult{body: body, etag: etag, err: err}
867 }
868 }()
869 }
870 for i := range items {
871 select {
872 case idx <- i:
873 case <-ctx.Done():
874 // Leaves results[i:] zero — a nil body with a nil error, which
875 // the apply loop reads as a successful empty get. Set the
876 // context's error on the rest so they take the failed-get branch
877 // and carry the previous copy forward instead.
878 for j := i; j < len(items); j++ {
879 results[j] = certFetchResult{err: ctx.Err()}
880 }
881 close(idx)
882 wg.Wait()
883 return results
884 }
885 }
886 close(idx)
887 wg.Wait()
888 return results
889}
890
891// applyCertFetches folds fetched bodies into next (and their ETags into
892// etags), carrying prev's copy of any object that failed, and returns the
893// failure count and the last failure.
894func applyCertFetches(items []certFetch, results []certFetchResult, prev *certSnapshot, next map[string][]byte, etags map[string]string) (failed int, lastErr error) {
895 // Applied in key order, not completion order, so a partial poll reports
896 // the same lastErr whichever way the fetches happened to interleave.
897 for i, it := range items {
898 r := results[i]
899 switch {
900 case errors.Is(r.err, os.ErrNotExist):
901 // Raced a delete between the list and the get — a spent token. Not
902 // a fault; the entry is simply gone.
903 continue
904 case r.err != nil:
905 // ONE bad object is not a bad poll. Aborting here would discard the
906 // whole rebuild — every unrelated cert that landed this tick with
907 // it — and skip syncLocal and the wildcard wake; a single
908 // permanently unreadable key would freeze the mirror forever. Carry
909 // the previous copy of this name forward and keep going, with an
910 // unknown ETag so the next changed-only read fetches it again.
911 failed++
912 lastErr = r.err
913 if body, ok := prev.entries[it.name]; ok {
914 next[it.name], etags[it.name] = body, ""
915 }
916 continue
917 }
918 next[it.name], etags[it.name] = r.body, r.etag
919 }
920 return failed, lastErr
921}
922
923// publish swaps in the snapshot built from next, whole, and does what every
924// landed refresh does after it: the local cache copy-in, the health latch and
925// the wildcard wake. Under refreshMu.
926func (m *certMirror) publish(ctx context.Context, prev *certSnapshot, next map[string][]byte, etags map[string]string) {
927 snap := buildCertSnapshot(prev, next)
928 m.mu.Lock()
929 m.snap = snap
930 m.mu.Unlock()
931 m.etags = etags
932 m.localPending = m.syncLocal(ctx, snap)
933 // The local cache the handshake reads now holds what the bucket does:
934 // the certificate condition is met for good (§6). A later failed
935 // refresh keeps serving this copy and leaves the latch set.
936 m.health.CertsLoaded()
937 if m.wake != nil && wildcardMaterialChanged(prev, snap) {
938 // A takeover (or a first wildcard for a new account) reaches the
939 // serving path in one poll instead of up to twelve hours.
940 m.wake()
941 }
942}
943
944// pollWatch polls w under a bound of its own (timeout, or
945// release.ChangeIndexPollTimeout when 0) inside ctx. The watch is shared with
946// the agent's tick, whose hung index GET would otherwise hold this loop, and
947// the health heartbeat it stamps, behind the watch's poll lock.
948func pollWatch(ctx context.Context, w *release.ChangeWatch, timeout time.Duration) ([]string, error) {
949 if timeout <= 0 {
950 timeout = release.ChangeIndexPollTimeout
951 }
952 pctx, cancel := context.WithTimeout(ctx, timeout)
953 defer cancel()
954 return w.Poll(pctx)
955}
956
957// certScope is the change-index scope every write under certPrefix bumps.
958const certScope = "control/certs"
959
960func (m *certMirror) changeWatch() *release.ChangeWatch {
961 if m.watch == nil {
962 return nil
963 }
964 return m.watch()
965}
966
967// certsGen is control/certs' generation as the watch last saw it, ok false
968// when there is no usable watch.
969func (m *certMirror) certsGen() (uint64, bool) {
970 if w := m.changeWatch(); w != nil {
971 return w.Gen(certScope)
972 }
973 return 0, false
974}
975
976func (m *certMirror) clock() time.Time {
977 if m.now != nil {
978 return m.now()
979 }
980 return time.Now()
981}
982
983// jitteredBackstop is release.ChangeIndexBackstop jittered by up to 10% either
984// way (R3), drawn per full read so a fleet's rebuilds do not line up.
985func jitteredBackstop() time.Duration {
986 b := release.ChangeIndexBackstop
987 return b - b/10 + time.Duration(rand.Int64N(int64(b/5)+1))
988}
989
990// logPartialPoll reports unreadable objects on the loop's own first-and-every-
991// tenth cadence: a permanently bad key would otherwise say the same thing
992// every 30 seconds forever.
993func (m *certMirror) logPartialPoll(failed, tried int, err error) {
994 m.mu.Lock()
995 m.partialPolls++
996 n := m.partialPolls
997 m.mu.Unlock()
998 if n == 1 || n%10 == 0 {
999 log.Printf("certsync: %d of %d object(s) under %s unreadable this poll (#%d, last: %v) — the previous copy of each is carried forward", failed, tried, certPrefix, n, err)
1000 }
1001}
1002
1003// buildCertSnapshot parses what changed and carries the rest of the previous
1004// parse forward — a handshake never pays for x509 parsing, and a quiet
1005// deployment pays for it once.
1006func buildCertSnapshot(prev *certSnapshot, entries map[string][]byte) *certSnapshot {
1007 snap := &certSnapshot{entries: entries, certs: make(map[string]*tls.Certificate, len(entries))}
1008 for name, body := range entries {
1009 if !certShapedName(name) {
1010 continue
1011 }
1012 if strings.HasSuffix(name, alpnTokenSuffix) {
1013 // tls-alpn-01 token certs are never served from a snapshot — see
1014 // alpnTokenCert — so parsing them here would be work done every
1015 // poll for an answer nothing may use. Leaving them unparsed also
1016 // makes serving a stale one structurally impossible.
1017 continue
1018 }
1019 if _, seen := prev.entries[name]; seen && bytes.Equal(prev.entries[name], body) {
1020 // Unchanged. Carry the previous RESULT forward, failure included:
1021 // re-parsing identical bytes cannot answer differently, and
1022 // re-logging a corrupt entry every 30s forever is not "loud", it is
1023 // noise.
1024 if old, ok := prev.certs[name]; ok {
1025 snap.certs[name] = old
1026 }
1027 continue
1028 }
1029 cert, err := parseCertEntry(body)
1030 if err != nil {
1031 // Loud, and skipped rather than served: a body that does not parse
1032 // is a body no handshake can use, and the next poll re-reads it.
1033 log.Printf("certsync: mirrored entry %q does not parse as a certificate (%v) — skipping it", name, err)
1034 continue
1035 }
1036 snap.certs[name] = cert
1037 }
1038 return snap
1039}
1040
1041func (m *certMirror) snapshot() *certSnapshot {
1042 m.mu.RLock()
1043 defer m.mu.RUnlock()
1044 return m.snap
1045}
1046
1047// Cert returns the mirrored body for a cache name. Account keys are refused
1048// here, not filtered at poll time, because the local sync legitimately needs
1049// them: the refusal belongs at the SERVING door.
1050func (m *certMirror) Cert(name string) ([]byte, bool) {
1051 if accountKeyName(name) {
1052 return nil, false
1053 }
1054 b, ok := m.snapshot().entries[name]
1055 return b, ok
1056}
1057
1058// Challenge returns the HTTP-01 response body for token, if the mirror holds
1059// it. The cache name is autocert's own (autocert.go:896,
1060// httpTokenCacheKey): path.Base(<challenge path>) + "+http-01" — flat, with
1061// no /.well-known/ prefix.
1062func (m *certMirror) Challenge(token string) ([]byte, bool) {
1063 b, ok := m.snapshot().entries[token+httpTokenSuffix]
1064 return b, ok
1065}
1066
1067const (
1068 httpTokenSuffix = "+http-01" // autocert's HTTP-01 response entries
1069 alpnTokenSuffix = "+token" // autocert's tls-alpn-01 challenge certs
1070 // autocertAccountKeyName is autocert's own ACME account key entry
1071 // (autocert.go's accountKey cache name). wildcardAccountKeyName
1072 // (wildcard.go) is the DNS-01 manager's.
1073 autocertAccountKeyName = "acme_account+key"
1074)
1075
1076// accountKeyName reports whether name is ACME ACCOUNT material — the issuer's
1077// identity at the CA, never a serving artifact.
1078func accountKeyName(name string) bool {
1079 return name == autocertAccountKeyName || name == wildcardAccountKeyName
1080}
1081
1082// certShapedName reports whether an entry is expected to parse as a
1083// certificate: everything except account keys and HTTP-01 response bodies.
1084// That leaves per-host certs (<host>, <host>+rsa), wildcard PEMs
1085// (wildcard-<parent>.pem) and tls-alpn-01 token certs (<host>+token) — the
1086// last of which only the live read-through parses (buildCertSnapshot skips
1087// them).
1088func certShapedName(name string) bool {
1089 return !accountKeyName(name) && !strings.HasSuffix(name, httpTokenSuffix)
1090}
1091
1092// parseCertEntry parses an autocert cache entry — the private key PEM
1093// followed by the chain, which is also wildcard.go's encodeWildcardPEM shape —
1094// into a usable tls.Certificate. Both halves come out of the one blob, and
1095// X509KeyPair is what checks that the key actually matches the leaf.
1096func parseCertEntry(raw []byte) (*tls.Certificate, error) {
1097 cert, err := tls.X509KeyPair(raw, raw)
1098 if err != nil {
1099 return nil, err
1100 }
1101 if cert.Leaf == nil {
1102 // X509KeyPair has populated Leaf since Go 1.23; belt and braces, since
1103 // every serving decision below reads it.
1104 leaf, err := x509.ParseCertificate(cert.Certificate[0])
1105 if err != nil {
1106 return nil, err
1107 }
1108 cert.Leaf = leaf
1109 }
1110 return &cert, nil
1111}
1112
1113// run polls until stop closes — the edge's standing background-loop shape
1114// (Watch): fixed ticker, errors counted and logged at the first and then
1115// every tenth, recovery logged once.
1116func (m *certMirror) run(stop <-chan struct{}) {
1117 log.Printf("edge: cert mirror armed (%s poll of %s*) — this box serves every cert and challenge the issuer publishes", certMirrorInterval, certPrefix)
1118 tick := time.NewTicker(certMirrorInterval)
1119 defer tick.Stop()
1120 errs := 0
1121 for {
1122 select {
1123 case <-stop:
1124 return
1125 case <-tick.C:
1126 }
1127 ctx, cancel := context.WithTimeout(context.Background(), certMirrorTimeout)
1128 err := m.poll(ctx)
1129 cancel()
1130 if err != nil {
1131 errs++
1132 if errs == 1 || errs%10 == 0 {
1133 log.Printf("edge: cert mirror refresh failed (#%d): %v — serving the last good copy", errs, err)
1134 }
1135 continue
1136 }
1137 if errs > 0 {
1138 log.Printf("edge: cert mirror refresh recovered after %d failure(s)", errs)
1139 errs = 0
1140 }
1141 ctx, cancel = context.WithTimeout(context.Background(), certMirrorTimeout)
1142 m.backfill(ctx)
1143 cancel()
1144 }
1145}
1146
1147// backfill publishes what this box's cache holds and the bucket does not —
1148// every box's half of "every edge serves every cert" for material minted
1149// BEFORE publishing existed (#291), on whichever box's disk it sits (#318).
1150//
1151// publishingCache publishes on Put, which is to say on issuance and renewal.
1152// A deployment that arms a second edge after a year of single-box service
1153// therefore starts with a bucket holding nothing but what was minted since
1154// the gate went in: the flagship roll found 4 objects against 50 live
1155// certs, and every handshake on a new region edge failed until renewal —
1156// 30 to 60 days out. Renewal is the wrong repair for that; this is a
1157// one-pass copy, run after every successful poll, that converges in one
1158// tick. Steady state on the issuer is one directory listing per poll plus
1159// a stat per entry: an entry whose size and mtime have not moved since it
1160// was last settled — in sync with the bucket, or found not to be this
1161// deployment's — is not re-read (m.settled).
1162//
1163// Rules, each doing work:
1164//
1165// - Only on a box that contends in the election, NOT only on the issuer
1166// (m.mayBackfill: agent-run AND contending in the issuer election).
1167// The 2026-08-27 rung-1 rehearsal (#318) found the issuer-only rule
1168// left every cert minted on a box that never held the lease since
1169// #291 sitting on that box's disk alone — exactly the bytes a
1170// failover loses with the box. Cert entries in control/certs/** are
1171// therefore multi-writer now, kept convergent by the freshness rules
1172// below (bytes-equal settles; an older leaf never displaces a newer
1173// one), and the writer map's housekeeper-only line for this prefix is
1174// amended to "any contending box, certs only". A concurrent writer
1175// racing a stale view CAN transiently land an older leaf over a newer
1176// one; the identity half of fileStamp is what makes that transient —
1177// the box holding the newer leaf un-settles on the changed bucket sum
1178// and republishes next pass. The serving-side "held
1179// or ungated" read is still the wrong predicate: a hand-run `carlos
1180// edge` with a bucket but no agent is ungated for serving and must
1181// NOT dump its disk into the deployment's prefix — m.mayBackfill is
1182// false exactly there. One consequence to hold in mind when a cert
1183// PURGE ever lands: any box's mirrored on-disk copy now resurrects a
1184// deleted bucket entry on its next pass, so a purge must tombstone or
1185// it will not stick.
1186// - Only a DirCache is walked. The mirror holds the cache as an
1187// autocert.Cache, and that interface cannot enumerate; the on-disk
1188// layout is the only backend that exists (certcache.go) and the one
1189// whose entries ARE plain files named by cache key.
1190// - Only names this deployment owns publish. The disk of a long-lived box
1191// holds a cert for every route ever detached or renamed (autocert never
1192// deletes), and DirCache.Put leaves "<name><random>" temp files behind
1193// a kill mid-write — neither is the deployment's to replicate onto every
1194// edge. A per-host entry (<host>, <host>+rsa) must pass m.allowed, the
1195// same registry-backed exact check both read-throughs use; a wildcard
1196// PEM must be for a parent in the wildcard manager's current set and
1197// its leaf must cover it. Account keys are their own shape.
1198// - Challenge tokens never publish: they are seconds long, and a copy of
1199// a spent one is litter every edge would go on serving.
1200// - Account keys publish only when the bucket has none, and only from
1201// the box holding the STRICT issuer gate (m.issuing) — the one
1202// backfill decision that stays single-writer. The deployment's ACME
1203// identity is whichever key was published first (the mirror seeds it
1204// onto every box, and a takeover issuer orders under it); a box's own
1205// older key must never displace it, or the deployment would swap
1206// accounts on every failover — and N agent boxes racing an EMPTY
1207// bucket with their own keys would leave certs ordered under one
1208// account stored beside another's key, which is why the widened cert
1209// rule above does not extend here. A non-issuer's unseeded key is
1210// deliberately NOT settled: the box may hold the lease on a later
1211// pass, and a settled stamp would sit on exactly the pass that could
1212// finally publish it.
1213// - A cert-shaped entry publishes when the bucket lacks it, or holds an
1214// older leaf (NotAfter earlier than this box's) — the renewed-before-
1215// the-gate case. Anything that does not parse, or has already expired,
1216// stays on disk: a stale rehearsal name is nothing to distribute, and
1217// the expiry check is what keeps this from re-publishing it forever.
1218// - Failures are per-entry, retried next poll, and logged on the file's
1219// first-and-every-tenth cadence: a write-denied bucket must not say so
1220// every 30 seconds forever.
1221func (m *certMirror) backfill(ctx context.Context) {
1222 if m.mayBackfill == nil || !m.mayBackfill() || m.s == nil {
1223 return
1224 }
1225 dir, ok := m.local.(autocert.DirCache)
1226 if !ok {
1227 m.backfillOnce.Do(func() {
1228 log.Printf("certsync: backfill off — the local cert cache is not an on-disk cache and cannot be walked; only certs minted from now on reach %s*", certPrefix)
1229 })
1230 return
1231 }
1232 entries, err := os.ReadDir(string(dir))
1233 if err != nil {
1234 if !errors.Is(err, os.ErrNotExist) {
1235 m.logBackfillFailure(0, err)
1236 }
1237 return
1238 }
1239 snap := m.snapshot()
1240 now := time.Now()
1241 var published, failed int
1242 var lastErr error
1243 for _, ent := range entries {
1244 name := ent.Name()
1245 if !ent.Type().IsRegular() || !publishableCertName(name) {
1246 continue
1247 }
1248 if strings.HasSuffix(name, httpTokenSuffix) || strings.HasSuffix(name, alpnTokenSuffix) {
1249 continue
1250 }
1251 remote, inBucket := snap.entries[name]
1252 info, err := ent.Info()
1253 if err != nil {
1254 continue
1255 }
1256 stamp := fileStamp{size: info.Size(), mtime: info.ModTime().UnixNano(), bucketSum: bucketSum(remote, inBucket)}
1257 // Settled at this stamp on an earlier pass — in sync with the
1258 // bucket, or not this deployment's to publish — and nothing has
1259 // moved since. The bucket copy's IDENTITY is part of the stamp: an
1260 // entry that was in sync but has since left the bucket, or been
1261 // overwritten by another box, re-reads — that re-read is the #318
1262 // repair path (see fileStamp). This is what keeps steady state at
1263 // a listing plus a stat per entry.
1264 if m.settledAt(name) == stamp {
1265 continue
1266 }
1267 body, err := os.ReadFile(filepath.Join(string(dir), name))
1268 if err != nil {
1269 failed++
1270 lastErr = err
1271 continue
1272 }
1273 if inBucket && bytes.Equal(remote, body) {
1274 // Also the backstop for names the snapshot never parses: the
1275 // freshness compare below cannot see those, and without this
1276 // they would republish every poll.
1277 m.markSettled(name, stamp)
1278 continue
1279 }
1280 if accountKeyName(name) {
1281 if inBucket {
1282 m.markSettled(name, stamp)
1283 continue
1284 }
1285 if m.issuing == nil || !m.issuing() {
1286 // Only the elected issuer seeds the deployment's ACME
1287 // identity (the rule above). Not settled, deliberately —
1288 // see the same rule for why.
1289 continue
1290 }
1291 } else {
1292 cert, err := parseCertEntry(body)
1293 if err != nil || !now.Before(cert.Leaf.NotAfter) || !m.ownsCertName(name, cert) {
1294 // Expiry is a function of time, but a settled entry can only
1295 // move from live to expired, never back; and ownership can
1296 // return (a route re-attached), which is a registry change —
1297 // so an entry settled as not-owned is re-examined only when
1298 // its stamp moves. A re-attached route mints afresh anyway.
1299 m.markSettled(name, stamp)
1300 continue
1301 }
1302 if inBucket {
1303 if have := snap.certs[name]; have != nil && !have.Leaf.NotAfter.Before(cert.Leaf.NotAfter) {
1304 m.markSettled(name, stamp)
1305 continue
1306 }
1307 }
1308 }
1309 if err := putCert(ctx, m.s, name, body); err != nil {
1310 failed++
1311 lastErr = err
1312 continue
1313 }
1314 // Not settled: the bucket side of the stamp changes on the next
1315 // poll, and the bytes.Equal path settles it then.
1316 published++
1317 }
1318 if published > 0 {
1319 log.Printf("certsync: backfilled %d cache entr%s from this box into %s* — every edge serves them on its next poll", published, plural(published, "y", "ies"), certPrefix)
1320 }
1321 if failed > 0 {
1322 m.logBackfillFailure(failed, lastErr)
1323 }
1324}
1325
1326// ownsCertName reports whether a cert-shaped cache entry is one this
1327// deployment should replicate: a per-host entry for a name the registry
1328// still serves (m.allowed — nil admits nothing, the safe default for a
1329// mirror built without an Edge), or a wildcard PEM for a parent in the
1330// wildcard manager's CURRENT parent set (m.wildcardParents — nil, no
1331// manager, admits none) whose leaf covers that parent.
1332func (m *certMirror) ownsCertName(name string, cert *tls.Certificate) bool {
1333 if parent, ok := strings.CutPrefix(name, "wildcard-"); ok && strings.HasSuffix(parent, ".pem") {
1334 parent = strings.TrimSuffix(parent, ".pem")
1335 if parent == "" || m.wildcardParents == nil || !slices.Contains(m.wildcardParents(), parent) {
1336 return false
1337 }
1338 return slices.Contains(cert.Leaf.DNSNames, "*."+parent)
1339 }
1340 host := strings.TrimSuffix(name, "+rsa")
1341 if m.allowed == nil || !m.allowed(host) {
1342 return false
1343 }
1344 return cert.Leaf.VerifyHostname(host) == nil
1345}
1346
1347// fileStamp is the state a backfill pass last settled an entry at: the
1348// file's size+mtime, and the IDENTITY of the bucket's copy (a sha256 of
1349// its bytes; zero = absent), not merely its presence. The local half is
1350// not a hash: DirCache writes are atomic renames, so a changed body is a
1351// changed stamp (nanosecond mtime, so a same-size rewrite within one
1352// second on a coarse filesystem is the only alias, and autocert never
1353// rewrites a name that fast). The bucket half MUST be identity: with
1354// cert entries multi-writer (#318), another box racing a stale view can
1355// land an older leaf over a newer one this box already settled against —
1356// a presence bool would leave the settled box blind to the clobber
1357// forever (its file unmoved, the name still "in bucket"), while the sum
1358// changes with the clobber, un-settles exactly the box holding the newer
1359// leaf, and its next pass republishes it. Convergence within a poll or
1360// two, newest leaf wins.
1361type fileStamp struct {
1362 size int64
1363 mtime int64
1364 bucketSum [sha256.Size]byte
1365}
1366
1367// bucketSum is fileStamp's bucket-identity half. The zero value means
1368// "absent", which cannot collide with a real entry: nothing under
1369// control/certs/ is ever zero bytes, and sha256 of any body is nonzero.
1370func bucketSum(body []byte, inBucket bool) [sha256.Size]byte {
1371 if !inBucket {
1372 return [sha256.Size]byte{}
1373 }
1374 return sha256.Sum256(body)
1375}
1376
1377func (m *certMirror) settledAt(name string) fileStamp {
1378 m.mu.RLock()
1379 defer m.mu.RUnlock()
1380 return m.settled[name]
1381}
1382
1383func (m *certMirror) markSettled(name string, st fileStamp) {
1384 m.mu.Lock()
1385 defer m.mu.Unlock()
1386 if m.settled == nil {
1387 m.settled = map[string]fileStamp{}
1388 }
1389 m.settled[name] = st
1390}
1391
1392// logBackfillFailure is logPartialPoll's cadence for the publish side.
1393func (m *certMirror) logBackfillFailure(failed int, err error) {
1394 m.mu.Lock()
1395 m.backfillFailures++
1396 n := m.backfillFailures
1397 m.mu.Unlock()
1398 if n == 1 || n%10 == 0 {
1399 log.Printf("certsync: backfill: %d entr%s not published (#%d, last: %v) — retried on the next poll", failed, plural(failed, "y", "ies"), n, err)
1400 }
1401}
1402
1403func plural(n int, one, many string) string {
1404 if n == 1 {
1405 return one
1406 }
1407 return many
1408}
1409
1410// syncLocal copies mirrored material into this box's OWN autocert cache. It
1411// is the quiet half of the mirror, and the one that matters when nothing is
1412// looking:
1413//
1414// - autocert's renewal timers bypass HostPolicy and check the cache FIRST
1415// (renewal.go's do()), so a stood-down box whose timers are still armed
1416// finds a fresh cert here and never reaches the CA;
1417// - a box that boots, or crashes and restarts, has no election result for
1418// the first ~20 minutes; a warm local cache serves through that window
1419// even before the mirror map is consulted;
1420// - the wildcard manager loads parents from this same cache (loadCached),
1421// which is how a wildcard the issuer minted reaches a non-issuer's
1422// handshakes at all.
1423//
1424// Never written locally: challenge tokens of either kind. They are seconds
1425// long, the issuer owns their deletion, and a copy on every box's disk is
1426// litter nothing sweeps.
1427//
1428// Writes are best-effort and loud: a full disk must not stop the mirror from
1429// SERVING what it already holds in memory. It reports whether any entry could
1430// not be reconciled, so the armed poll can retry an unchanged snapshot.
1431func (m *certMirror) syncLocal(ctx context.Context, snap *certSnapshot) (failedAny bool) {
1432 if m.local == nil {
1433 return false
1434 }
1435 m.syncMu.Lock()
1436 defer m.syncMu.Unlock()
1437 var failed int
1438 var lastErr error
1439 for name, body := range snap.entries {
1440 if strings.HasSuffix(name, httpTokenSuffix) || strings.HasSuffix(name, alpnTokenSuffix) {
1441 continue
1442 }
1443 // Unchanged since the last reconcile: the local copy was already
1444 // settled then, and nothing but autocert itself (which only ever writes
1445 // fresher material) can have moved it since. Steady state costs no
1446 // local I/O at all.
1447 if prev, ok := m.synced[name]; ok && bytes.Equal(prev, body) {
1448 continue
1449 }
1450 switch err := m.reconcileLocal(ctx, name, body, snap.certs[name]); {
1451 case err == nil:
1452 m.synced[name] = body
1453 case errors.Is(err, errUnparseableEntry):
1454 // Already reported by the snapshot build; nothing to say twice.
1455 default:
1456 failed++
1457 lastErr = err
1458 }
1459 }
1460 if failed > 0 {
1461 // One line per poll, not one per name: a local cache that has stopped
1462 // accepting writes fails for EVERY name at once, and the fault is the
1463 // cache, not the certificate.
1464 log.Printf("certsync: %d mirrored entry/entries could not be reconciled into this box's local cert cache (last: %v) — they are still served from memory", failed, lastErr)
1465 }
1466 // Forget names the bucket no longer holds, so the map tracks the prefix
1467 // rather than growing with every token this box has ever seen.
1468 for name := range m.synced {
1469 if _, ok := snap.entries[name]; !ok {
1470 delete(m.synced, name)
1471 }
1472 }
1473 return failed > 0
1474}
1475
1476// errUnparseableEntry marks the one reconcile failure that is the ENTRY's
1477// fault rather than the local cache's — already reported once by the snapshot
1478// build, and not worth a second line.
1479var errUnparseableEntry = errors.New("certsync: mirrored entry does not parse")
1480
1481// reconcileLocal writes one entry into the local cache when the local copy is
1482// worse. A nil error means the local copy can be considered settled.
1483func (m *certMirror) reconcileLocal(ctx context.Context, name string, body []byte, cert *tls.Certificate) error {
1484 local, err := m.local.Get(ctx, name)
1485 if err != nil && !errors.Is(err, autocert.ErrCacheMiss) {
1486 // An unreadable local cache is a real fault (permissions, disk).
1487 return fmt.Errorf("read %q: %w", name, err)
1488 }
1489 have := err == nil
1490 if accountKeyName(name) {
1491 // ACME ACCOUNT material. Seeded when absent, so that a box winning the
1492 // election later takes over the DEPLOYMENT's account — one account, one
1493 // rate-limit bucket, which is the envelope the issuance gate's
1494 // no-fencing decision is priced against. Never overwritten: a box that
1495 // already registered its own account has orders and authorizations tied
1496 // to that key, and replacing it under them buys nothing.
1497 if have {
1498 return nil
1499 }
1500 if err := m.local.Put(ctx, name, body); err != nil {
1501 return fmt.Errorf("seed the deployment's ACME account key %q: %w", name, err)
1502 }
1503 log.Printf("certsync: seeded the deployment's ACME account key (%s) into the local cache", name)
1504 return nil
1505 }
1506 if cert == nil {
1507 // Did not parse (already reported by buildCertSnapshot). Writing an
1508 // unparseable body over a working local cert would be a downgrade.
1509 return errUnparseableEntry
1510 }
1511 if have {
1512 if cur, err := parseCertEntry(local); err == nil && !cur.Leaf.NotAfter.Before(cert.Leaf.NotAfter) {
1513 // The local copy is at least as fresh — this box may BE the issuer,
1514 // one write ahead of what it has published. Leave it alone.
1515 return nil
1516 }
1517 }
1518 if err := m.local.Put(ctx, name, body); err != nil {
1519 return fmt.Errorf("write %q: %w", name, err)
1520 }
1521 return nil
1522}
1523
1524// wildcardPEMName reports whether an entry is a wildcard cert PEM
1525// (wildcard.go's wildcardPEMKey shape).
1526func wildcardPEMName(name string) bool {
1527 return strings.HasPrefix(name, "wildcard-") && strings.HasSuffix(name, ".pem")
1528}
1529
1530// wildcardMaterialChanged reports whether this poll brought a wildcard PEM
1531// that is new or different. Only then is the wildcard manager nudged: its own
1532// loop sleeps 12h between passes, so a takeover — or a first wildcard for a
1533// new account, minted on the issuer — would otherwise wait that long to reach
1534// this box's handshakes. A withdrawal is not a wake: there is nothing new to
1535// load, and the manager keeps serving what it has until it expires.
1536func wildcardMaterialChanged(prev, next *certSnapshot) bool {
1537 for name, body := range next.entries {
1538 if !wildcardPEMName(name) {
1539 continue
1540 }
1541 if !bytes.Equal(prev.entries[name], body) {
1542 return true
1543 }
1544 }
1545 return false
1546}
1547
1548// ---- serving: the four-layer chain ------------------------------------------
1549//
1550// wrapGetCertificate is the mirror's half of the handshake path. The FULL
1551// chain a hello meets, outermost first (RunWithActivatorReady composes it):
1552//
1553// wildcard (wildcard.go) → mirror → local autocert → local cache, direct
1554//
1555// The order is forced by autocert's own shape, not by preference. autocert
1556// runs HostPolicy — which carries the issuance gate — BEFORE its cache
1557// lookup (autocert.go:292), so on a non-issuer autocert serves NOTHING: not
1558// what the mirror handed it, not what it holds on disk itself. Putting the
1559// mirror behind it would therefore serve nothing at all on every box but one.
1560//
1561// The last layer is the local cache read DIRECTLY, bypassing autocert, and it
1562// is not redundant: it is what a box serves when the bucket is unreachable at
1563// boot (empty mirror) but its own disk still holds yesterday's certs, and
1564// what covers the ~20 minute window after a boot or a crash while the
1565// housekeeper election is undecided.
1566//
1567// The short-circuit ahead of autocert applies while this box is GATED, and
1568// only then. autocert arms its renewal timers LAZILY, out of GetCertificate
1569// (m.cert -> startRenew, autocert.go:464) — a mirror that answered every
1570// hello would leave an ISSUER with no renewal timers at all, and its certs
1571// would expire ninety days later with nothing in the journal. An ungated box
1572// loses nothing by going through autocert first: syncLocal has already put
1573// every mirrored cert in the cache autocert reads, and the mirror stays
1574// behind it as a fallback for anything autocert cannot serve.
1575func (m *certMirror) wrapGetCertificate(inner func(*tls.ClientHelloInfo) (*tls.Certificate, error)) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
1576 return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
1577 // Converted ONCE, here, and used by every layer below: the mirror
1578 // lookup, the tls-alpn-01 lookup and the local cache read all have to
1579 // agree with the name autocert published under.
1580 name := serverName(hello.ServerName)
1581 if len(hello.SupportedProtos) == 1 && hello.SupportedProtos[0] == acme.ALPNProto {
1582 // A tls-alpn-01 validation. autocert answers its OWN order from
1583 // memory; anything else is another edge's order, and the CA reached
1584 // this box because every edge answers the same names.
1585 cert, err := inner(hello)
1586 if err == nil {
1587 return cert, nil
1588 }
1589 if c := m.alpnTokenCert(helloContext(hello), name); c != nil {
1590 return c, nil
1591 }
1592 return nil, err
1593 }
1594 wantRSA := !helloSupportsECDSA(hello)
1595 if m.isGated() {
1596 if c := m.certificate(name, wantRSA); c != nil {
1597 return c, nil
1598 }
1599 }
1600 cert, err := inner(hello)
1601 if err == nil {
1602 return cert, nil
1603 }
1604 // Ungated and autocert could not answer (an order that failed, a name
1605 // it will not order for): the mirror is still the better answer than a
1606 // dropped handshake. Gated, this repeat is a cheap map miss.
1607 if c := m.certificate(name, wantRSA); c != nil {
1608 return c, nil
1609 }
1610 if c := m.localCertificate(helloContext(hello), name, wantRSA); c != nil {
1611 return c, nil
1612 }
1613 return nil, err
1614 }
1615}
1616
1617// serverName normalizes a client-supplied name to the cache name autocert
1618// would have used: lowercased, no trailing dot, and punycode via
1619// idna.Lookup.ToASCII — the same conversion autocert applies before its own
1620// cache lookup (autocert.go:307), because some clients (curl among them) send
1621// unicode SNI verbatim and a cert is always published under the A-label.
1622//
1623// A name IDNA refuses is kept in its lowercased form rather than dropped:
1624// every layer below treats it as an ordinary miss, which is what it is, and
1625// the refusal belongs to autocert (which rejects it outright) rather than to
1626// the mirror.
1627func serverName(sni string) string {
1628 name := strings.ToLower(strings.TrimSuffix(sni, "."))
1629 if ascii, err := idna.Lookup.ToASCII(name); err == nil {
1630 return ascii
1631 }
1632 return name
1633}
1634
1635// isGated reads the issuance gate, defaulting to "gated" (see the field).
1636func (m *certMirror) isGated() bool {
1637 return m.gated == nil || m.gated()
1638}
1639
1640// helloContext is the handshake's context, or a background one for the
1641// hand-built hellos tests use.
1642func helloContext(hello *tls.ClientHelloInfo) context.Context {
1643 if ctx := hello.Context(); ctx != nil {
1644 return ctx
1645 }
1646 return context.Background()
1647}
1648
1649// certificate returns a mirrored cert for name, or nil. The two cache names
1650// are autocert's own ("<host>" for ECDSA, "<host>+rsa" for legacy clients);
1651// the preferred one is tried first and the other accepted rather than
1652// dropping the handshake, since on a non-issuer no order can produce the
1653// missing type.
1654func (m *certMirror) certificate(name string, wantRSA bool) *tls.Certificate {
1655 if name == "" {
1656 return nil
1657 }
1658 snap := m.snapshot()
1659 for _, key := range certEntryNames(name, wantRSA) {
1660 if c := snap.certs[key]; usableFor(c, name) {
1661 return c
1662 }
1663 }
1664 return nil
1665}
1666
1667// certEntryNames lists the cache names that can hold a cert for host, most
1668// preferred first.
1669func certEntryNames(host string, wantRSA bool) [2]string {
1670 if wantRSA {
1671 return [2]string{host + "+rsa", host}
1672 }
1673 return [2]string{host, host + "+rsa"}
1674}
1675
1676// usableFor reports whether cert can be served for name right now. An expired
1677// or wrong-named entry is treated as absent so the chain keeps falling
1678// through — a dead cert fails the handshake just as surely as no cert, and
1679// the next layer may have a live one.
1680func usableFor(cert *tls.Certificate, name string) bool {
1681 return cert != nil && cert.Leaf != nil &&
1682 time.Now().Before(cert.Leaf.NotAfter) &&
1683 cert.Leaf.VerifyHostname(name) == nil
1684}
1685
1686// helloSupportsECDSA is autocert's own supportsECDSA (autocert.go), ported
1687// because it is unexported there and the mirror must look under the SAME
1688// cache name autocert would have chosen for this client.
1689func helloSupportsECDSA(hello *tls.ClientHelloInfo) bool {
1690 if hello.SignatureSchemes != nil {
1691 ecdsaOK := false
1692 schemeLoop:
1693 for _, scheme := range hello.SignatureSchemes {
1694 const tlsECDSAWithSHA1 tls.SignatureScheme = 0x0203
1695 switch scheme {
1696 case tlsECDSAWithSHA1, tls.ECDSAWithP256AndSHA256,
1697 tls.ECDSAWithP384AndSHA384, tls.ECDSAWithP521AndSHA512:
1698 ecdsaOK = true
1699 break schemeLoop
1700 }
1701 }
1702 if !ecdsaOK {
1703 return false
1704 }
1705 }
1706 if hello.SupportedCurves != nil {
1707 ecdsaOK := false
1708 for _, curve := range hello.SupportedCurves {
1709 if curve == tls.CurveP256 {
1710 ecdsaOK = true
1711 break
1712 }
1713 }
1714 if !ecdsaOK {
1715 return false
1716 }
1717 }
1718 for _, suite := range hello.CipherSuites {
1719 switch suite {
1720 case tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA,
1721 tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
1722 tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
1723 tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,
1724 tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1725 tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1726 tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305:
1727 return true
1728 }
1729 }
1730 return false
1731}
1732
1733// localCertTTL bounds how long a last-resort read is remembered. Short
1734// enough that a renewal reaching the local cache is picked up promptly, long
1735// enough that a flood of handshakes for one name is not a file read each.
1736const localCertTTL = 30 * time.Second
1737
1738type localCertEntry struct {
1739 cert *tls.Certificate
1740 at time.Time
1741}
1742
1743// localCertificate is the last layer: this box's own autocert cache, read
1744// DIRECTLY, because a gated autocert.Manager refuses the name before it ever
1745// looks there. Both outcomes are remembered briefly so a busy name costs one
1746// file read per TTL rather than one per handshake.
1747func (m *certMirror) localCertificate(ctx context.Context, name string, wantRSA bool) *tls.Certificate {
1748 if m.local == nil || name == "" {
1749 return nil
1750 }
1751 for _, key := range certEntryNames(name, wantRSA) {
1752 m.localMu.Lock()
1753 e, ok := m.localCerts[key]
1754 fresh := ok && time.Since(e.at) < localCertTTL
1755 m.localMu.Unlock()
1756 if fresh {
1757 if usableFor(e.cert, name) {
1758 return e.cert
1759 }
1760 continue
1761 }
1762 var cert *tls.Certificate
1763 if raw, err := m.local.Get(ctx, key); err == nil {
1764 if c, err := parseCertEntry(raw); err == nil {
1765 cert = c
1766 }
1767 }
1768 m.localMu.Lock()
1769 if len(m.localCerts) > readThroughCacheMax {
1770 // Crude but bounded: the map is a cache, and every entry is
1771 // re-readable from disk.
1772 clear(m.localCerts)
1773 }
1774 m.localCerts[key] = localCertEntry{cert: cert, at: time.Now()}
1775 m.localMu.Unlock()
1776 if usableFor(cert, name) {
1777 // Worth saying: the mirror had nothing for a name this box is
1778 // serving anyway. Once a minute for the whole box, not once per
1779 // name — every host on a box whose bucket is unreachable lands here
1780 // at once, and one line is enough to know it.
1781 m.localMu.Lock()
1782 quiet := time.Since(m.lastLocalLog) < time.Minute
1783 if !quiet {
1784 m.lastLocalLog = time.Now()
1785 }
1786 m.localMu.Unlock()
1787 if !quiet {
1788 log.Printf("edge: serving %s from this box's local cert cache — the mirror has no copy and issuance is gated here", name)
1789 }
1790 return cert
1791 }
1792 }
1793 return nil
1794}
1795
1796// ---- the :80 challenge path -------------------------------------------------
1797
1798const acmeChallengePrefix = "/.well-known/acme-challenge/"
1799
1800// challengeHandler answers HTTP-01 challenges from the mirror BEFORE
1801// autocert's own handler sees them, and delegates everything else. The order
1802// is forced, again, by autocert: HTTPHandler runs HostPolicy first
1803// (autocert.go:405), so on a non-issuer it 403s a challenge whose answer is
1804// sitting in the mirror. Behind this, autocert still answers its OWN live
1805// orders from memory, which is what the issuer needs.
1806func (m *certMirror) challengeHandler(next http.Handler) http.Handler {
1807 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1808 if !strings.HasPrefix(r.URL.Path, acmeChallengePrefix) {
1809 next.ServeHTTP(w, r)
1810 return
1811 }
1812 // path.Base is the token autocert itself would derive
1813 // (httpTokenCacheKey), so both handlers agree on the name.
1814 if body, ok := m.challengeResponse(r.Context(), requestHost(r.Host), path.Base(r.URL.Path)); ok {
1815 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
1816 _, _ = w.Write(body)
1817 return
1818 }
1819 next.ServeHTTP(w, r)
1820 })
1821}
1822
1823// challengeResponse answers from the polled map, then — and this is the part
1824// the 30s poll cannot do — reads through to the bucket. A validation's whole
1825// life is a few seconds: the issuer writes the token, sends Accept, and the
1826// CA arrives at whichever edge DNS handed it. A poll that runs every 30s
1827// misses that window every time.
1828func (m *certMirror) challengeResponse(ctx context.Context, host, token string) ([]byte, bool) {
1829 if body, ok := m.Challenge(token); ok {
1830 // Already polled: a map hit costs nothing and the token is the CA's own
1831 // 128+ bits of entropy, so it needs no host bound of its own.
1832 return body, true
1833 }
1834 if !validChallengeToken(token) {
1835 return nil, false
1836 }
1837 // The LIVE lookup is bounded by the host under validation, exactly as
1838 // autocert bounds its own token lookup (HTTPHandler runs hostPolicy on
1839 // r.Host first, autocert.go:405). A real CA always sends the domain it is
1840 // validating, so the bound costs a genuine validation nothing — and
1841 // without it an unauthenticated :80 flood sustains a bucket GET per
1842 // distinct path, forever, from anywhere. The bound is the DEPLOYMENT's
1843 // exact names, not this box's (m.allowed, Edge.deploymentHost): DNS for a
1844 // home-boxed route can point at any edge, and the one it points at is the
1845 // one the CA arrives at (#277).
1846 if m.allowed != nil && !m.allowed(host) {
1847 return nil, false
1848 }
1849 body, _, ok := m.readThrough(ctx, token+httpTokenSuffix, &m.rtHTTP)
1850 return body, ok
1851}
1852
1853// requestHost is the Host header without its port, normalized the same way
1854// SNI is (serverName) so it meets Edge.Allowed's exact registry map in the
1855// shape routes are stored in.
1856func requestHost(hostport string) string {
1857 host := hostport
1858 if h, _, err := net.SplitHostPort(host); err == nil {
1859 host = h
1860 }
1861 return serverName(host)
1862}
1863
1864// validChallengeToken bounds what an unauthenticated URL path may turn into a
1865// bucket read: ACME tokens are base64url of at least 128 bits of entropy, so
1866// nothing shorter or otherwise shaped can be one. It also keeps the key
1867// inside the flat prefix (publishableCertName's concern, from the other
1868// side).
1869func validChallengeToken(token string) bool {
1870 if len(token) < 16 || len(token) > 128 {
1871 return false
1872 }
1873 for _, r := range token {
1874 switch {
1875 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
1876 default:
1877 return false
1878 }
1879 }
1880 return true
1881}
1882
1883// alpnTokenCert answers a tls-alpn-01 validation for an order some OTHER edge
1884// is running. autocert prefers tls-alpn-01 over http-01 and only falls back
1885// to it after a whole failed order (autocert.go's AuthorizeOrderLoop), so
1886// without this every issuance in an N-edge deployment burns a failed
1887// validation with probability (N-1)/N before it ever tries the HTTP path.
1888//
1889// Bounded harder than the HTTP-01 path, because the name here is
1890// attacker-chosen SNI rather than a high-entropy token: only names this
1891// deployment serves — this box's own routes, or another box's exactly-named
1892// ones (m.allowed, Edge.deploymentHost) — are ever looked up. A stranger's
1893// invented label under a mirrored catch-all is not one of them.
1894func (m *certMirror) alpnTokenCert(ctx context.Context, host string) *tls.Certificate {
1895 if host == "" {
1896 return nil
1897 }
1898 if m.allowed != nil && !m.allowed(host) {
1899 return nil
1900 }
1901 // Deliberately NOT from the poll's snapshot. Unlike an HTTP-01 entry —
1902 // whose name carries the order's unique token — a token CERT is keyed by
1903 // hostname alone, so the previous order's entry for the same host is a
1904 // live collision, not a stale copy of the same answer. Serving it hands
1905 // the CA the wrong key authorization and burns the order. The snapshot is
1906 // a poll behind at best, and far worse if a gated Delete left litter
1907 // behind, so this material is only ever read live.
1908 _, cert, _ := m.readThrough(ctx, host+alpnTokenSuffix, &m.rtALPN)
1909 if usableFor(cert, host) {
1910 return cert
1911 }
1912 return nil
1913}
1914
1915// ---- read-through: the bounded live lookup ----------------------------------
1916
1917const (
1918 // readThroughTimeout budgets one live bucket read. It runs inside a
1919 // handshake or a :80 request, so it is short by construction.
1920 readThroughTimeout = 2 * time.Second
1921 // readThroughHitTTL is about one poll: after that the poll itself holds
1922 // the entry (or it has been withdrawn, which is also the answer). It
1923 // applies to names that are unique per order — HTTP-01 tokens.
1924 readThroughHitTTL = 30 * time.Second
1925 // readThroughMissTTL keeps a scanner walking the challenge path from
1926 // turning into a bucket GET per request.
1927 readThroughMissTTL = 5 * time.Second
1928 // readThroughCacheMax bounds the memo; over it, the map is dropped whole.
1929 readThroughCacheMax = 512
1930 // readThroughBurst / readThroughRefill are one budget's rate: distinct
1931 // names defeat the negative cache, so the last bound is a rate. Under a
1932 // flood the read-through degrades to "off" — the 30s poll still covers
1933 // everything that is not a live validation.
1934 //
1935 // There is one budget PER LISTENER (rtHTTP, rtALPN), never one shared:
1936 // :80's challenge path is reachable by anyone, and a flood there must not
1937 // spend the budget 443's tls-alpn-01 rescue needs. They fail
1938 // independently or not at all.
1939 readThroughBurst = 20
1940 readThroughRefill = 5 // per second
1941)
1942
1943// rtBudget is one token bucket. Guarded by certMirror.rtMu.
1944type rtBudget struct {
1945 tokens float64
1946 last time.Time
1947}
1948
1949// spend takes one token, reporting whether there was one. Caller holds rtMu.
1950func (b *rtBudget) spend(now time.Time) bool {
1951 if b.last.IsZero() {
1952 b.tokens = readThroughBurst
1953 } else {
1954 b.tokens = min(readThroughBurst, b.tokens+now.Sub(b.last).Seconds()*readThroughRefill)
1955 }
1956 b.last = now
1957 if b.tokens < 1 {
1958 return false
1959 }
1960 b.tokens--
1961 return true
1962}
1963
1964type readThroughEntry struct {
1965 body []byte
1966 cert *tls.Certificate
1967 ok bool
1968 at time.Time
1969}
1970
1971// readThroughTokenTTL is how long a tls-alpn-01 token CERT may be remembered.
1972// Seconds, not a poll: the name is the bare hostname, so the next order for
1973// that host reuses it, and two orders can be seconds apart. A var so tests
1974// can zero it.
1975var readThroughTokenTTL = 3 * time.Second
1976
1977// readThroughTTL is how long a HIT for name may be reused.
1978func readThroughTTL(name string) time.Duration {
1979 if strings.HasSuffix(name, alpnTokenSuffix) {
1980 return readThroughTokenTTL
1981 }
1982 return readThroughHitTTL
1983}
1984
1985func (e readThroughEntry) fresh(name string) bool {
1986 ttl := readThroughMissTTL
1987 if e.ok {
1988 ttl = readThroughTTL(name)
1989 }
1990 return time.Since(e.at) < ttl
1991}
1992
1993// readThrough fetches one control/certs/ entry outside the poll cycle: memoed
1994// (hits and misses), single-flighted per name, rate-limited globally, and
1995// budgeted per call. Errors are answers here, not faults — a bucket that is
1996// down simply means this box cannot help with someone else's live
1997// validation, and the CA retries.
1998func (m *certMirror) readThrough(ctx context.Context, name string, budget *rtBudget) ([]byte, *tls.Certificate, bool) {
1999 m.rtMu.Lock()
2000 if e, ok := m.rt[name]; ok && e.fresh(name) {
2001 m.rtMu.Unlock()
2002 return e.body, e.cert, e.ok
2003 }
2004 if done, ok := m.rtInflight[name]; ok {
2005 // Somebody is already asking. The CA opens several connections for one
2006 // validation; they share the one bucket read.
2007 m.rtMu.Unlock()
2008 select {
2009 case <-done:
2010 case <-ctx.Done():
2011 return nil, nil, false
2012 }
2013 m.rtMu.Lock()
2014 e := m.rt[name]
2015 m.rtMu.Unlock()
2016 return e.body, e.cert, e.ok
2017 }
2018 if !budget.spend(time.Now()) {
2019 m.rtMu.Unlock()
2020 return nil, nil, false
2021 }
2022 done := make(chan struct{})
2023 m.rtInflight[name] = done
2024 m.rtMu.Unlock()
2025
2026 // Detached from the caller: a client that hangs up mid-validation must not
2027 // cancel the read every other in-flight waiter is sharing.
2028 fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), readThroughTimeout)
2029 body, _, err := m.s.Get(fetchCtx, certKey(name))
2030 cancel()
2031 e := readThroughEntry{at: time.Now()}
2032 switch {
2033 case err == nil:
2034 e.body, e.ok = body, true
2035 if certShapedName(name) {
2036 if cert, perr := parseCertEntry(body); perr == nil {
2037 e.cert = cert
2038 } else {
2039 log.Printf("certsync: live read of %q does not parse as a certificate: %v", name, perr)
2040 }
2041 }
2042 case errors.Is(err, os.ErrNotExist):
2043 // The ordinary case: no such token. Negative-cached, not logged.
2044 default:
2045 m.logReadThroughError(name, err)
2046 }
2047 m.rtMu.Lock()
2048 if len(m.rt) > readThroughCacheMax {
2049 clear(m.rt)
2050 }
2051 m.rt[name] = e
2052 delete(m.rtInflight, name)
2053 m.rtMu.Unlock()
2054 close(done)
2055 return e.body, e.cert, e.ok
2056}
2057
2058// logReadThroughError says once a minute that live lookups are failing. Not
2059// per call: the rate limiter still allows several a second, and a bucket
2060// outage is one fault however many validations hit it.
2061func (m *certMirror) logReadThroughError(name string, err error) {
2062 m.rtMu.Lock()
2063 quiet := time.Since(m.lastRTLog) < time.Minute
2064 if !quiet {
2065 m.lastRTLog = time.Now()
2066 }
2067 m.rtMu.Unlock()
2068 if !quiet {
2069 log.Printf("certsync: live read of %q failed: %v — the %s poll still covers everything but live validations", name, err, certMirrorInterval)
2070 }
2071}
2072