carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Cert warm (#299 stage 3): the platform orders and RENEWS certificates for
2// the custom domains it serves, instead of waiting for a visitor's handshake
3// to trigger the first issuance.
4//
5// Why this exists at all. Per-host certs are minted lazily by autocert on the
6// first TLS handshake for a name. That is fine for a name people visit from
7// the box that serves it, and wrong for a custom domain on a steering
8// deployment, for two compounding reasons:
9//
10// - The FIRST visitor pays a multi-second ACME order, and if the customer's
11// DNS only just landed, they are also the one who discovers it failed.
12// - RENEWAL is worse. autocert renews through per-host `domainRenewal`
13// timers that live in process memory. They die on every fleet roll and
14// re-arm only when a handshake reaches m.cert again. Under latency
15// steering a custom domain's visitors may never land on the issuer, so
16// after a roll NOTHING re-arms the timer and the cert quietly expires at
17// day 90 — on a host that was serving perfectly the whole time.
18//
19// So the warm calls GetCertificate for every host in its set on EVERY pass,
20// unconditionally. That is not wasteful and it is not a retry loop: for a
21// healthy cert it is one cache read plus the startRenew that re-arms the
22// timer, and the re-arm is the entire point. A skip-if-fresh check would
23// defeat it — autocert's m.cert returns any unexpired cached cert, so a cert
24// 29 days from expiry is a cache hit and no renewal is ever scheduled.
25package edge
26
27import (
28 "context"
29 "crypto/tls"
30 "errors"
31 "log"
32 "net"
33 "sort"
34 "strings"
35 "sync"
36 "time"
37
38 "golang.org/x/crypto/acme"
39
40 "github.com/carlosframework/platform/internal/certwarm"
41 "github.com/carlosframework/platform/internal/release"
42)
43
44const (
45 // warmInterval is the quiet-path cadence. Renewal is the job with the
46 // real deadline (autocert renews at ~30 days remaining), so hourly is
47 // far more often than strictly needed — the cost of a pass over a
48 // healthy set is a cache read per host, and being early is free.
49 warmInterval = time.Hour
50 // warmBackoff is the per-host floor after a failed order. Matches the
51 // wildcard manager's hourly retry.
52 warmBackoff = time.Hour
53 // warmRateLimitBackoff is the floor after the CA says rateLimited.
54 // Deliberately much harder than warmBackoff: hourly retries INTO a rate
55 // limit are what turn a transient limit into a sustained one, and every
56 // host in the deployment shares the one ACME account's budget.
57 warmRateLimitBackoff = 12 * time.Hour
58 // warmOrdersPerPass bounds NEW orders per pass. Renewal re-arms are not
59 // orders and are never capped — they are the reason the pass exists.
60 // When the cap bites, pass() returns a short delay so a backlog drains
61 // in minutes rather than trickling out over days (the wildcard pass()
62 // precedent).
63 warmOrdersPerPass = 10
64 // warmCappedDelay is how soon to come back with capped work remaining.
65 warmCappedDelay = time.Minute
66)
67
68// certWarm proactively orders and renews per-host certificates.
69//
70// It is a sibling of wildcardManager, with three deliberate departures from
71// it, each of which is a bug if copied across:
72//
73// - No holding-context claim. The wildcard manager runs its own ACME
74// client, so it can hand the holding's context to the order. The warm's
75// trigger is autocert's GetCertificate, which takes NO context and builds
76// its own background one with a 5-minute timeout. So the gate is
77// re-checked BETWEEN orders instead, and a ≤5-minute overhang past a lost
78// holding is accepted — inside the double-holder envelope issuerHostPolicy
79// already documents. If the holding ends mid-order, the finishing
80// cachePut re-checks the gate and the cert lands local-only; the new
81// issuer re-orders it. Bounded waste, not a hazard.
82// - No cache-load-before-gate step. The wildcard manager loads cached certs
83// before standing down because it SERVES from its own map. The warm
84// serves nothing — per-host serving is the mirror plus autocert — so a
85// non-issuer scanning caches would be pure waste. It stands down at the
86// gate.
87// - The unconditional GetCertificate described in the package comment.
88type certWarm struct {
89 // hosts is the warm set: exact hostnames this deployment serves that are
90 // not under the apps domain and not covered by a wildcard. See
91 // Edge.warmSet.
92 hosts func() []string
93 // getCert is autocert's per-host path (NOT the wildcard-wrapped one — a
94 // wrapped call would be answered by the wildcard and order nothing).
95 getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error)
96 // pointsHere reports whether host's DNS resolves to this deployment.
97 // Ordering for a half-pointed name burns the shared account's
98 // failed-validation budget; see dnsGate.
99 pointsHere func(host string) bool
100 // issuer gates ORDERING, exactly as it gates the wildcard manager's.
101 issuer IssuerGate
102 // store is the deployment bucket, for the durable outcome records
103 // (#299 §3e, internal/certwarm). Nil disables recording entirely and
104 // changes nothing else: warming, backoff and logging are unaffected, so
105 // an agent-less `carlos edge` and every test that does not care keep
106 // working exactly as before.
107 store release.Store
108
109 mu sync.Mutex
110 // nextAttempt is the per-host backoff floor.
111 nextAttempt map[string]time.Time
112 // warmed is the set of hosts a cert has been obtained for at least once,
113 // so a first order can be told from a renewal re-arm for the order cap
114 // and for logging.
115 warmed map[string]bool
116
117 lastStandDownLog time.Time
118 // lastSweep and lastExpirySweep pace the cert sweep (certsweep.go).
119 // Deliberately NOT guarded by mu, unlike everything else on this struct:
120 // they are touched only from sweep(), which is only ever reached from
121 // pass(), which only ever runs on run()'s single goroutine. A mutex here
122 // would suggest a concurrency that does not exist.
123 lastSweep time.Time
124 lastExpirySweep time.Time
125}
126
127func newCertWarm(hosts func() []string, getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), pointsHere func(string) bool, issuer IssuerGate, store release.Store) *certWarm {
128 return &certWarm{
129 hosts: hosts,
130 getCert: getCert,
131 pointsHere: pointsHere,
132 issuer: issuer,
133 store: store,
134 nextAttempt: map[string]time.Time{},
135 warmed: map[string]bool{},
136 }
137}
138
139// issuerGate is this warm's read of the deployment's gate, ungated default
140// applied (see IssuerGate, certsync.go).
141func (c *certWarm) issuerGate() (context.Context, bool) {
142 if c.issuer == nil {
143 return context.Background(), true
144 }
145 return c.issuer()
146}
147
148func (c *certWarm) run() {
149 for {
150 time.Sleep(c.pass())
151 }
152}
153
154// pass warms every host in the set once and returns how long to wait.
155func (c *certWarm) pass() time.Duration {
156 hosts := c.hosts()
157 // Stand down before ANY work: unlike the wildcard manager there is no
158 // cache to load first, so a non-issuer has nothing useful to do here.
159 // The gate comes BEFORE the empty-set check, not after, because the
160 // sweep at the bottom of this pass has work to do in a deployment with
161 // no custom domains at all — spent challenge tokens and long-expired
162 // certs are not the warm set's business.
163 ctx, ok := c.issuerGate()
164 if !ok {
165 c.logStandDown(len(hosts))
166 return warmInterval
167 }
168 if len(hosts) == 0 {
169 c.sweep(ctx, time.Now())
170 return warmInterval
171 }
172 // The durable outcome records (#299 §3e), read ONCE per pass. Two things
173 // come out of this read and neither is available in memory: the attempt
174 // count a previous issuer accumulated (so a failover does not restart the
175 // clock on a domain that has been broken for days), and the set of hosts
176 // that currently HAVE a record, which is what makes both the
177 // delete-on-success and the prune below decidable without a Get per host.
178 records := c.loadRecords(ctx)
179 inSet := make(map[string]bool, len(hosts))
180 for _, h := range hosts {
181 inSet[h] = true
182 }
183 orders, capped := 0, false
184 for _, h := range hosts {
185 now := time.Now()
186 c.mu.Lock()
187 until, backing := c.nextAttempt[h]
188 seen := c.warmed[h]
189 c.mu.Unlock()
190 if backing && now.Before(until) {
191 continue
192 }
193 // The gate is re-checked BETWEEN orders, not once per pass: a pass
194 // over a large set can outlive a holding, and every order after the
195 // holding ends is an order this box has no authority to place.
196 gctx, ok := c.issuerGate()
197 if !ok {
198 c.logStandDown(len(hosts) - orders)
199 return warmInterval
200 }
201 // A host with no cert yet is a NEW order and is both capped and
202 // DNS-gated. A host already warmed is a renewal re-arm: uncapped and
203 // ungated, because it is a cache read that costs the CA nothing and
204 // re-arms the timer that renewal depends on. Gating the re-arm on DNS
205 // would strand exactly the domain whose resolver is briefly sick.
206 if !seen {
207 if orders >= warmOrdersPerPass {
208 capped = true
209 continue
210 }
211 if !c.pointsHere(h) {
212 continue
213 }
214 orders++
215 }
216 if err := c.warmOne(h); err != nil {
217 c.fail(gctx, h, err, records[h])
218 continue
219 }
220 c.mu.Lock()
221 delete(c.nextAttempt, h)
222 first := !c.warmed[h]
223 c.warmed[h] = true
224 c.mu.Unlock()
225 if first {
226 log.Printf("edge: cert warmed for %s", h)
227 }
228 // A record exists only while a host is FAILING, so success removes
229 // it. That is what makes "no record" the healthy case and keeps the
230 // console's fold one object per broken host rather than one per
231 // domain (internal/certwarm's package comment).
232 if _, had := records[h]; had {
233 c.clearRecord(gctx, h, "recovered")
234 delete(records, h)
235 }
236 }
237 c.prune(ctx, records, inSet)
238 // Housekeeping last: it is the least urgent thing this pass does, and
239 // running it after the orders means a slow bucket delays litter removal
240 // rather than certificate issuance (certsweep.go).
241 c.sweep(ctx, time.Now())
242 if capped {
243 return warmCappedDelay
244 }
245 return warmInterval
246}
247
248// warmOne asks autocert for host's certificate, which mints it when absent
249// and re-arms its renewal timer when present.
250func (c *certWarm) warmOne(host string) error {
251 _, err := c.getCert(warmHello(host))
252 return err
253}
254
255// fail arms the per-host backoff, harder when the CA is rate-limiting, and
256// records the outcome durably.
257//
258// prior is this host's record as the pass read it (the zero Record when
259// there is none). The attempt count and the "failing since" stamp are
260// carried FORWARD from it rather than recomputed from memory — that is the
261// whole reason the record is in the bucket rather than on the heap.
262func (c *certWarm) fail(ctx context.Context, host string, err error, prior certwarm.Record) {
263 backoff := warmBackoff
264 class := certwarm.ClassOther
265 var ae *acme.Error
266 if errors.As(err, &ae) && ae.ProblemType == "urn:ietf:params:acme:error:rateLimited" {
267 backoff = warmRateLimitBackoff
268 class = certwarm.ClassRateLimited
269 log.Printf("edge: cert warm for %s rate-limited by the CA — backing off %s (the whole deployment shares one ACME account)", host, backoff)
270 } else {
271 log.Printf("edge: cert warm for %s: %v — retrying in %s", host, err, backoff)
272 }
273 now := time.Now()
274 next := now.Add(backoff)
275 c.mu.Lock()
276 c.nextAttempt[host] = next
277 c.mu.Unlock()
278
279 first := prior.FirstFailedAt
280 if first == 0 {
281 first = now.Unix()
282 }
283 c.writeRecord(ctx, certwarm.Record{
284 Host: host,
285 Attempts: prior.Attempts + 1,
286 FirstFailedAt: first,
287 LastFailedAt: now.Unix(),
288 NextAttemptAt: next.Unix(),
289 Class: class,
290 Error: err.Error(),
291 }, now)
292}
293
294func (c *certWarm) logStandDown(hosts int) {
295 c.mu.Lock()
296 defer c.mu.Unlock()
297 if time.Since(c.lastStandDownLog) < time.Hour {
298 return
299 }
300 c.lastStandDownLog = time.Now()
301 log.Printf("edge: cert warm standing down for %d host(s) — another box holds the issuer lease", hosts)
302}
303
304// warmHello builds the synthetic ClientHelloInfo the warm hands autocert.
305//
306// The CipherSuites entry is LOAD-BEARING and must not be dropped as
307// decoration. autocert picks the key type from the hello via supportsECDSA
308// (ported here as helloSupportsECDSA): with a hello carrying no signature
309// schemes, no curves and no cipher suites, all three checks fall through to
310// false, autocert chooses certKey{isRSA: true}, and the warm mints an RSA
311// cert under "<host>+rsa" — while every real browser asks for "<host>" and
312// gets nothing. The order would be spent, the name still uncovered, and the
313// first visitor would trigger a SECOND issuance.
314//
315// One ECDSA suite is enough: helloSupportsECDSA's first two checks skip on
316// nil, and its cipher-suite loop returns true on this suite.
317func warmHello(host string) *tls.ClientHelloInfo {
318 return &tls.ClientHelloInfo{
319 ServerName: host,
320 CipherSuites: []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256},
321 }
322}
323
324// warmSet is the set of hostnames the cert warm orders and renews for:
325// EXACT names this deployment serves that are not under the apps domain and
326// not covered by a wildcard.
327//
328// The predicate is deliberately simpler than "aliases" or "custom domains".
329// A custom domain is a foreign domain by definition — the attach door refuses
330// apps-domain hosts outright — so "not under the apps domain" covers attached
331// aliases and first-route claims alike, with no need to reason about which
332// registry table a name arrived through. That matters: first-route claims
333// (`carlos deploy -kind static -host <site>`) mint a real ROUTE, not an
334// alias, so an alias-only rule would silently never warm the shipped headline
335// flow.
336//
337// What it excludes, and why each one:
338//
339// - "*." rows — covered by a wildcard PEM, and autocert refuses the
340// wildcard label anyway.
341// - anything under the apps domain — canary hosts and platform hosts. This
342// is the exclusion that keeps a first run from being a burst: canaries are
343// three labels under the apps domain, no wildcard parent covers them, and
344// a naive "everything we serve" set would order a cert AND a CT-log entry
345// for every one of them.
346// - anything one label under a CONFIGURED wildcard parent, tested BY SHAPE
347// against currentParents() rather than by asking certFor. certFor returns
348// nil for a parent whose cert has not been obtained yet, so a shape-blind
349// test would warm every app of a new account per-host during exactly the
350// window before their wildcard lands.
351func (e *Edge) warmSet(appsDomain string, parents func() []string) []string {
352 appsDomain = strings.ToLower(strings.TrimSpace(appsDomain))
353 covered := wildcardCoverer(parents)
354 seen := map[string]bool{}
355 var out []string
356 consider := func(h string) {
357 h = strings.ToLower(strings.TrimSpace(h))
358 if h == "" || seen[h] || strings.HasPrefix(h, "*.") {
359 return
360 }
361 if appsDomain != "" && (h == appsDomain || strings.HasSuffix(h, "."+appsDomain)) {
362 return
363 }
364 if covered(h) {
365 return
366 }
367 seen[h] = true
368 out = append(out, h)
369 }
370 for h := range e.current() {
371 consider(h)
372 }
373 for _, h := range e.homes.Load().ServedHosts() {
374 consider(h)
375 }
376 sort.Strings(out)
377 return out
378}
379
380// wildcardCoverer reports whether a host is exactly one label under a
381// wildcard parent this box has configured or derived — by SHAPE, never by
382// whether the parent's cert exists yet (see warmSet). A nil parents func
383// covers nothing, which is the correct answer for a deployment with no
384// wildcard manager at all.
385func wildcardCoverer(parents func() []string) func(string) bool {
386 return func(host string) bool {
387 if parents == nil {
388 return false
389 }
390 for _, p := range parents() {
391 rest, ok := strings.CutSuffix(host, "."+p)
392 if ok && rest != "" && !strings.Contains(rest, ".") {
393 return true
394 }
395 }
396 return false
397 }
398}
399
400// dnsGate builds the "does this name point at us?" test the warm applies
401// before placing a NEW order.
402//
403// Let's Encrypt caps failed validations at 5 per account per hostname per
404// hour, and the ACME account is shared by every name this issuer mints. So a
405// half-configured customer domain that we order for anyway does not just fail
406// its own order — it spends budget every other tenant needs. The gate is what
407// keeps one customer's unfinished DNS from being everyone's problem.
408//
409// ALL resolved answers must be in the edge set, not merely one. A name in
410// mid-migration resolves to both the old host and ours; an any-match test
411// passes it, the order goes out, and validation fails from the CA's vantage
412// anyway — burning exactly the budget this gate exists to protect. Requiring
413// all of them means the gate only opens once the customer has actually
414// finished.
415//
416// An empty edge set fails CLOSED (no host points anywhere), matching the
417// domains pass's stand-down: a set we could not read is not evidence about
418// anybody's DNS, and the cost of waiting a pass is one hour of delay against
419// the cost of burning a shared rate limit.
420func dnsGate(resolve func(string) ([]string, error), edgeIPs func() []string) func(string) bool {
421 return func(host string) bool {
422 set := map[string]bool{}
423 for _, ip := range edgeIPs() {
424 set[ip] = true
425 }
426 if len(set) == 0 {
427 return false
428 }
429 ips, err := resolve(host)
430 if err != nil || len(ips) == 0 {
431 return false
432 }
433 for _, ip := range ips {
434 if !set[ip] {
435 return false
436 }
437 }
438 return true
439 }
440}
441
442// warmResolver is the DNS gate's lookup: the system resolver, bounded so a
443// hung one costs this host its turn in the pass and not the pass itself.
444func warmResolver(host string) ([]string, error) {
445 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
446 defer cancel()
447 return net.DefaultResolver.LookupHost(ctx, host)
448}
449
450// ---- the durable outcome records (#299 §3e) ---------------------------------
451//
452// Every one of these degrades to a no-op rather than to a failure. A bucket
453// blip must never stop the warm from warming: the records are how the
454// console explains a failure, not how the fix happens, and a pass that
455// cannot write one has still ordered the certificate.
456
457// warmStoreTimeout budgets the record I/O for one pass. Generous relative to
458// the work (a listing plus a handful of small objects) and tiny relative to
459// the hourly cadence, so a slow bucket delays a pass rather than wedging it.
460const warmStoreTimeout = 30 * time.Second
461
462// loadRecords reads every outcome record in the deployment.
463//
464// The cost is one object per BROKEN host, not one per domain — see the
465// package comment on internal/certwarm — so this stays a listing plus a
466// small handful of Gets in any deployment that is mostly working, and grows
467// only in one that is mostly not.
468//
469// A read failure returns what it has. Partial knowledge is the right answer
470// here in both directions: a host whose record failed to load is treated as
471// having none, so its attempt count restarts at one (an undercount, which
472// only ever delays the console reaching "failed") and its record is left
473// alone by the prune (never a delete on the strength of a read we could not
474// complete).
475func (c *certWarm) loadRecords(ctx context.Context) map[string]certwarm.Record {
476 out := map[string]certwarm.Record{}
477 if c.store == nil {
478 return out
479 }
480 ctx, cancel := context.WithTimeout(ctx, warmStoreTimeout)
481 defer cancel()
482 keys, err := c.store.List(ctx, certwarm.Prefix)
483 if err != nil {
484 log.Printf("edge: cert warm: could not list %s: %v — attempt counts fall back to this process's own", certwarm.Prefix, err)
485 return out
486 }
487 for _, key := range keys {
488 host := certwarm.HostFromKey(key)
489 if host == "" {
490 continue
491 }
492 body, _, err := c.store.Get(ctx, key)
493 if err != nil {
494 log.Printf("edge: cert warm: could not read %s: %v", key, err)
495 continue
496 }
497 rec, err := certwarm.Parse(body)
498 if err != nil {
499 // A record this build cannot read is left in place, not deleted:
500 // it may be a NEWER issuer's, and a rolling fleet has both.
501 log.Printf("edge: cert warm: could not parse %s: %v", key, err)
502 continue
503 }
504 out[host] = rec
505 }
506 return out
507}
508
509// writeRecord publishes one host's outcome, CAS so a transient double-holder
510// cannot half-overwrite the other's record.
511func (c *certWarm) writeRecord(ctx context.Context, rec certwarm.Record, now time.Time) {
512 if c.store == nil {
513 return
514 }
515 body, err := rec.Encode(now)
516 if err != nil {
517 log.Printf("edge: cert warm: could not encode the record for %s: %v", rec.Host, err)
518 return
519 }
520 ctx, cancel := context.WithTimeout(ctx, warmStoreTimeout)
521 defer cancel()
522 if err := putCAS(ctx, c.store, certwarm.Key(rec.Host), body); err != nil {
523 log.Printf("edge: cert warm: could not record the failure for %s: %v", rec.Host, err)
524 }
525}
526
527// clearRecord removes one host's record. why names the occasion in the log —
528// "recovered" or "detached" — because those are opposite news and a single
529// deletion line would hide which one happened.
530func (c *certWarm) clearRecord(ctx context.Context, host, why string) {
531 if c.store == nil {
532 return
533 }
534 ctx, cancel := context.WithTimeout(ctx, warmStoreTimeout)
535 defer cancel()
536 if err := c.store.Delete(ctx, certwarm.Key(host)); err != nil {
537 log.Printf("edge: cert warm: could not clear the %s record for %s: %v", why, host, err)
538 return
539 }
540 log.Printf("edge: cert warm: cleared the outcome record for %s (%s)", host, why)
541}
542
543// prune drops records for hosts that are no longer in the warm set — a
544// detached custom domain, or one whose route went away.
545//
546// This deliberately DOES delete on the strength of an inference ("not in the
547// set"), which §3f forbids for certificates. The distinction is ownership,
548// not caution: a certificate is the member's property and a wrong deletion
549// takes their site down, whereas a warm record is the platform's own note
550// about its own attempts and a wrong deletion costs an attempt count that
551// the next failure rebuilds. The prune is also fenced by pass()'s early
552// return on an empty set, so the one case that would delete EVERYTHING —
553// a warm set that briefly folds to nothing — never reaches here.
554func (c *certWarm) prune(ctx context.Context, records map[string]certwarm.Record, inSet map[string]bool) {
555 if c.store == nil || len(records) == 0 {
556 return
557 }
558 for host := range records {
559 if inSet[host] {
560 continue
561 }
562 c.clearRecord(ctx, host, "detached")
563 }
564}
565