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
3import (
4 "context"
5 "crypto/tls"
6 "errors"
7 "os"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "golang.org/x/crypto/acme"
14
15 "github.com/carlosframework/platform/internal/certwarm"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// THE trap this whole file exists to avoid. autocert picks the key type from
20// the hello. A hello with no signature schemes, no curves and no cipher
21// suites reads as "no ECDSA", so autocert mints an RSA cert under
22// "<host>+rsa" while every browser asks for "<host>" — the order spent, the
23// name still uncovered, and the first real visitor triggering a second
24// issuance.
25//
26// This pins the warm's hello against the repo's own port of autocert's
27// chooser, so the two cannot drift.
28func TestWarmHelloAsksForAnECDSACert(t *testing.T) {
29 if !helloSupportsECDSA(warmHello("app.example.com")) {
30 t.Fatal("the warm's hello reads as RSA-only — autocert would mint <host>+rsa and no browser would ever be served it")
31 }
32 // The bare hello is the bug, kept here so the test documents WHY the
33 // cipher-suite entry above is load-bearing rather than decorative.
34 if helloSupportsECDSA(&tls.ClientHelloInfo{ServerName: "app.example.com"}) {
35 t.Fatal("an empty hello now reads as ECDSA-capable — warmHello's cipher suite may no longer be needed, re-derive the trap before deleting it")
36 }
37}
38
39func TestWarmHelloCarriesTheServerName(t *testing.T) {
40 if got := warmHello("App.Example.com").ServerName; got != "App.Example.com" {
41 t.Fatalf("ServerName = %q", got)
42 }
43}
44
45// The warm set is "exact hosts we serve that are not under the apps domain
46// and not wildcard-covered". Each exclusion below is a separate bug if it
47// regresses.
48func TestWarmSetSelectsCustomDomainsOnly(t *testing.T) {
49 e := &Edge{}
50 e.upstreams.Store(&map[string]upstream{
51 "www.customer.test": {},
52 "shop.customer.test": {},
53 "app.acct.oncarlos.test": {}, // platform host — apps domain
54 "c1.app.acct.oncarlos.test": {}, // canary — apps domain, 3 labels
55 "*.catchall.test": {}, // catch-all — wildcard covers it
56 "one.go.tito.test": {}, // one label under a configured parent
57 "deep.one.go.tito.test": {}, // TWO labels under it: NOT covered
58 })
59 parents := func() []string { return []string{"go.tito.test"} }
60
61 got := e.warmSet("oncarlos.test", parents)
62 want := []string{"deep.one.go.tito.test", "shop.customer.test", "www.customer.test"}
63 if strings.Join(got, ",") != strings.Join(want, ",") {
64 t.Fatalf("warm set = %v, want %v", got, want)
65 }
66}
67
68// A parent whose cert has not been obtained yet must STILL exclude its
69// children. Testing coverage by asking certFor (which returns nil until the
70// wildcard lands) would warm every app of a new account per-host during
71// exactly the window before their wildcard arrives — the burst the shape
72// test exists to prevent.
73func TestWarmSetExcludesChildrenOfAnUnobtainedParent(t *testing.T) {
74 e := &Edge{}
75 e.upstreams.Store(&map[string]upstream{"new.acct.test": {}})
76 // currentParents lists the parent; no cert exists for it anywhere.
77 if got := e.warmSet("oncarlos.test", func() []string { return []string{"acct.test"} }); len(got) != 0 {
78 t.Fatalf("warm set = %v; a configured parent covers its children whether or not its cert exists yet", got)
79 }
80}
81
82func TestWarmSetWithNoWildcardManagerCoversNothing(t *testing.T) {
83 e := &Edge{}
84 e.upstreams.Store(&map[string]upstream{"www.customer.test": {}})
85 if got := e.warmSet("oncarlos.test", nil); len(got) != 1 || got[0] != "www.customer.test" {
86 t.Fatalf("warm set = %v, want the custom domain", got)
87 }
88}
89
90// ALL answers must be in the edge set. A name mid-migration resolves to the
91// old host AND ours; an any-match gate passes it, the order goes out, and
92// validation fails from the CA's vantage anyway — burning the shared
93// account's failed-validation budget, which is exactly what the gate is for.
94func TestDNSGateRequiresEveryAnswerToBeOurs(t *testing.T) {
95 edges := func() []string { return []string{"203.0.113.7", "198.51.100.9"} }
96 for name, tc := range map[string]struct {
97 ips []string
98 err error
99 want bool
100 }{
101 "all ours": {ips: []string{"203.0.113.7", "198.51.100.9"}, want: true},
102 "one of ours": {ips: []string{"203.0.113.7"}, want: true},
103 "partly elsewhere": {ips: []string{"203.0.113.7", "192.0.2.1"}, want: false},
104 "wholly elsewhere": {ips: []string{"192.0.2.1"}, want: false},
105 "no answer": {ips: nil, want: false},
106 "resolver failed": {err: errors.New("SERVFAIL"), want: false},
107 } {
108 gate := dnsGate(func(string) ([]string, error) { return tc.ips, tc.err }, edges)
109 if got := gate("www.customer.test"); got != tc.want {
110 t.Errorf("%s: gate = %v, want %v", name, got, tc.want)
111 }
112 }
113}
114
115// An unreadable edge set is not evidence about anybody's DNS. Failing closed
116// costs one pass of delay; failing open spends a shared rate limit.
117func TestDNSGateFailsClosedOnAnEmptyEdgeSet(t *testing.T) {
118 gate := dnsGate(
119 func(string) ([]string, error) { return []string{"203.0.113.7"}, nil },
120 func() []string { return nil },
121 )
122 if gate("www.customer.test") {
123 t.Fatal("an empty edge set opened the gate")
124 }
125}
126
127// The heart of the design: a host that already has a cert is asked for AGAIN
128// every pass. autocert renews through in-memory timers that die on every
129// fleet roll and re-arm only via a GetCertificate that reaches m.cert — so a
130// skip-if-fresh check would silently stop renewing every custom domain whose
131// visitors never land on the issuer.
132func TestWarmReAsksForAlreadyWarmHostsEveryPass(t *testing.T) {
133 var mu sync.Mutex
134 calls := map[string]int{}
135 w := newCertWarm(
136 func() []string { return []string{"www.customer.test"} },
137 func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
138 mu.Lock()
139 defer mu.Unlock()
140 calls[h.ServerName]++
141 return &tls.Certificate{}, nil
142 },
143 func(string) bool { return true },
144 nil,
145 nil,
146 )
147 for i := 0; i < 3; i++ {
148 w.pass()
149 }
150 if calls["www.customer.test"] != 3 {
151 t.Fatalf("GetCertificate calls = %d, want one per pass — the renewal re-arm depends on it", calls["www.customer.test"])
152 }
153}
154
155// A renewal re-arm is NOT gated on DNS. It is a cache read that costs the CA
156// nothing, and gating it would strand exactly the domain whose resolver is
157// briefly sick — dropping its renewal on the floor for as long as the
158// resolver stays unhappy.
159func TestWarmRenewalReArmIgnoresTheDNSGate(t *testing.T) {
160 calls := 0
161 pointing := true
162 w := newCertWarm(
163 func() []string { return []string{"www.customer.test"} },
164 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil },
165 func(string) bool { return pointing },
166 nil,
167 nil,
168 )
169 w.pass() // first order: gate open, host becomes warmed
170 pointing = false
171 w.pass() // DNS now fails; the re-arm must still happen
172 if calls != 2 {
173 t.Fatalf("calls = %d, want 2 — a warmed host's re-arm is not DNS-gated", calls)
174 }
175}
176
177// A never-warmed host IS DNS-gated: ordering for a half-pointed name spends
178// the shared account's failed-validation budget.
179func TestWarmDoesNotOrderForAHostThatDoesNotPointHere(t *testing.T) {
180 calls := 0
181 w := newCertWarm(
182 func() []string { return []string{"www.customer.test"} },
183 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil },
184 func(string) bool { return false },
185 nil,
186 nil,
187 )
188 w.pass()
189 if calls != 0 {
190 t.Fatalf("calls = %d, want 0 — the DNS gate must hold the first order", calls)
191 }
192}
193
194// A non-issuer does no work at all: unlike the wildcard manager there is no
195// cache to load first, because the warm serves nothing.
196func TestWarmStandsDownWhenNotTheIssuer(t *testing.T) {
197 calls := 0
198 w := newCertWarm(
199 func() []string { return []string{"www.customer.test"} },
200 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil },
201 func(string) bool { return true },
202 func() (context.Context, bool) { return nil, false },
203 nil,
204 )
205 if d := w.pass(); d != warmInterval {
206 t.Fatalf("delay = %s", d)
207 }
208 if calls != 0 {
209 t.Fatalf("a non-issuer placed %d call(s)", calls)
210 }
211}
212
213// A rate limit must back off much harder than an ordinary failure: hourly
214// retries INTO a rate limit are what turn a transient limit into a sustained
215// one, and every host shares the one ACME account's budget.
216func TestWarmBacksOffHarderOnARateLimit(t *testing.T) {
217 rateLimited := &acme.Error{ProblemType: "urn:ietf:params:acme:error:rateLimited"}
218 w := newCertWarm(nil, nil, nil, nil, nil)
219 w.fail(context.Background(), "a.test", rateLimited, certwarm.Record{})
220 w.fail(context.Background(), "b.test", errors.New("connection refused"), certwarm.Record{})
221 w.mu.Lock()
222 defer w.mu.Unlock()
223 limited, ordinary := time.Until(w.nextAttempt["a.test"]), time.Until(w.nextAttempt["b.test"])
224 if limited <= ordinary {
225 t.Fatalf("rate-limited backoff %s is not harder than the ordinary %s", limited, ordinary)
226 }
227}
228
229// A failing host is not retried until its backoff expires — and a healthy
230// host in the same set is not held up by it.
231func TestWarmHonoursPerHostBackoff(t *testing.T) {
232 calls := map[string]int{}
233 w := newCertWarm(
234 func() []string { return []string{"bad.test", "good.test"} },
235 func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
236 calls[h.ServerName]++
237 if h.ServerName == "bad.test" {
238 return nil, errors.New("order failed")
239 }
240 return &tls.Certificate{}, nil
241 },
242 func(string) bool { return true },
243 nil,
244 nil,
245 )
246 w.pass()
247 w.pass()
248 if calls["bad.test"] != 1 {
249 t.Errorf("bad.test tried %d times, want 1 — backoff not honoured", calls["bad.test"])
250 }
251 if calls["good.test"] != 2 {
252 t.Errorf("good.test tried %d times, want 2 — a failing sibling must not stall it", calls["good.test"])
253 }
254}
255
256// New orders are capped per pass, and a capped pass comes back SOON so a
257// backlog drains in minutes rather than trickling out over days. Renewal
258// re-arms are never capped.
259func TestWarmCapsNewOrdersAndReturnsSoon(t *testing.T) {
260 var hosts []string
261 for i := 0; i < warmOrdersPerPass+5; i++ {
262 hosts = append(hosts, string(rune('a'+i))+".customer.test")
263 }
264 calls := 0
265 w := newCertWarm(
266 func() []string { return hosts },
267 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil },
268 func(string) bool { return true },
269 nil,
270 nil,
271 )
272 d := w.pass()
273 if calls != warmOrdersPerPass {
274 t.Fatalf("placed %d orders, want the cap of %d", calls, warmOrdersPerPass)
275 }
276 if d != warmCappedDelay {
277 t.Fatalf("capped pass returned %s, want the short %s so the backlog drains", d, warmCappedDelay)
278 }
279}
280
281// ---- the durable outcome records (#299 §3e) ---------------------------------
282
283func warmRecordIn(t *testing.T, s release.Store, host string) (certwarm.Record, bool) {
284 t.Helper()
285 body, _, err := s.Get(context.Background(), certwarm.Key(host))
286 if errors.Is(err, os.ErrNotExist) {
287 return certwarm.Record{}, false
288 }
289 if err != nil {
290 t.Fatalf("reading the record for %s: %v", host, err)
291 }
292 rec, err := certwarm.Parse(body)
293 if err != nil {
294 t.Fatalf("parsing the record for %s: %v", host, err)
295 }
296 return rec, true
297}
298
299// THE point of §3e. Attempt counts used to live in process memory, so every
300// fleet roll and every failover restarted the clock on a domain that had
301// been broken for days — and the console could never say more than
302// "issuing-cert". The count and the "failing since" stamp must be carried
303// forward from the bucket, not from the heap.
304func TestWarmCarriesAttemptCountsAcrossAFailover(t *testing.T) {
305 s := release.NewDirStore(t.TempDir())
306 newIssuer := func() *certWarm {
307 return newCertWarm(
308 func() []string { return []string{"bad.customer.test"} },
309 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") },
310 func(string) bool { return true },
311 nil,
312 s,
313 )
314 }
315 first := newIssuer()
316 first.pass()
317 rec, ok := warmRecordIn(t, s, "bad.customer.test")
318 if !ok || rec.Attempts != 1 {
319 t.Fatalf("after one pass: record %+v ok=%v; want attempts 1", rec, ok)
320 }
321 failingSince := rec.FirstFailedAt
322
323 // A brand-new process, with no memory of the first one: exactly what a
324 // roll or a lease handover produces.
325 for i := 2; i <= 4; i++ {
326 w := newIssuer()
327 w.pass()
328 rec, ok = warmRecordIn(t, s, "bad.customer.test")
329 if !ok || rec.Attempts != i {
330 t.Fatalf("failover %d: record %+v ok=%v; want attempts %d — the count reset", i, rec, ok, i)
331 }
332 }
333 if rec.FirstFailedAt != failingSince {
334 t.Errorf("first_failed_at moved from %d to %d — \"failing since\" must survive a failover", failingSince, rec.FirstFailedAt)
335 }
336}
337
338// A rate limit is carried as its own class, and with the twelve-hour retry
339// time, because those are the two facts that make it explicable rather than
340// just another failure.
341func TestWarmRecordsTheRateLimitClassAndItsNextAttempt(t *testing.T) {
342 s := release.NewDirStore(t.TempDir())
343 w := newCertWarm(
344 func() []string { return []string{"limited.customer.test"} },
345 func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
346 return nil, &acme.Error{ProblemType: "urn:ietf:params:acme:error:rateLimited"}
347 },
348 func(string) bool { return true },
349 nil,
350 s,
351 )
352 w.pass()
353 rec, ok := warmRecordIn(t, s, "limited.customer.test")
354 if !ok {
355 t.Fatal("no record written for a rate-limited host")
356 }
357 if rec.Class != certwarm.ClassRateLimited {
358 t.Errorf("class = %q, want %q", rec.Class, certwarm.ClassRateLimited)
359 }
360 if until := time.Until(time.Unix(rec.NextAttemptAt, 0)); until < warmRateLimitBackoff-time.Minute {
361 t.Errorf("next attempt in %s, want about %s", until, warmRateLimitBackoff)
362 }
363}
364
365// A record exists only while a host is FAILING. Success removes it, which is
366// what makes absence the healthy case and keeps the console's fold one
367// object per broken host rather than one per domain.
368func TestWarmClearsTheRecordOnRecovery(t *testing.T) {
369 s := release.NewDirStore(t.TempDir())
370 broken := true
371 w := newCertWarm(
372 func() []string { return []string{"flaky.customer.test"} },
373 func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
374 if broken {
375 return nil, errors.New("order failed")
376 }
377 return &tls.Certificate{}, nil
378 },
379 func(string) bool { return true },
380 nil,
381 s,
382 )
383 w.pass()
384 if _, ok := warmRecordIn(t, s, "flaky.customer.test"); !ok {
385 t.Fatal("no record after the failure")
386 }
387 broken = false
388 // The per-host backoff is in memory, so a fresh issuer is the honest way
389 // to reach the next attempt without sleeping an hour.
390 recovered := newCertWarm(w.hosts, w.getCert, w.pointsHere, nil, s)
391 recovered.pass()
392 if rec, ok := warmRecordIn(t, s, "flaky.customer.test"); ok {
393 t.Errorf("record %+v survived recovery — absence must be the healthy case", rec)
394 }
395}
396
397// A detached domain leaves the warm set, and its record goes with it.
398// Deleting on an inference is forbidden for CERTIFICATES (§3f) because those
399// are the member's property; a record of our own attempts is ours to drop.
400func TestWarmPrunesRecordsForHostsThatLeftTheSet(t *testing.T) {
401 s := release.NewDirStore(t.TempDir())
402 hosts := []string{"gone.customer.test", "stays.customer.test"}
403 w := newCertWarm(
404 func() []string { return hosts },
405 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") },
406 func(string) bool { return true },
407 nil,
408 s,
409 )
410 w.pass()
411 for _, h := range hosts {
412 if _, ok := warmRecordIn(t, s, h); !ok {
413 t.Fatalf("no record for %s after the failure", h)
414 }
415 }
416 hosts = []string{"stays.customer.test"}
417 w.pass()
418 if rec, ok := warmRecordIn(t, s, "gone.customer.test"); ok {
419 t.Errorf("record %+v for a detached host survived the prune", rec)
420 }
421 if _, ok := warmRecordIn(t, s, "stays.customer.test"); !ok {
422 t.Error("the prune took a record for a host still in the set")
423 }
424}
425
426// The one prune case that would delete EVERY record is a warm set that
427// briefly folds to nothing — an edges-file read that came back empty, say.
428// pass() returns before any of this on an empty set, so it can never happen;
429// this pins that, because the guard is one `if` away from being refactored out.
430func TestWarmPruneNeverRunsOnAnEmptySet(t *testing.T) {
431 s := release.NewDirStore(t.TempDir())
432 hosts := []string{"bad.customer.test"}
433 w := newCertWarm(
434 func() []string { return hosts },
435 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") },
436 func(string) bool { return true },
437 nil,
438 s,
439 )
440 w.pass()
441 hosts = nil
442 w.pass()
443 if _, ok := warmRecordIn(t, s, "bad.customer.test"); !ok {
444 t.Error("an empty warm set pruned every record — a transient fold to nothing must never delete")
445 }
446}
447
448// A non-issuer writes nothing at all. Two boxes both recording their own
449// view of a host would be exactly the whole-file overwrite §3e's per-host
450// CAS exists to avoid, with the added wrinkle that the non-issuer's view is
451// not even real — it places no orders.
452func TestWarmStandDownWritesNoRecords(t *testing.T) {
453 s := release.NewDirStore(t.TempDir())
454 w := newCertWarm(
455 func() []string { return []string{"bad.customer.test"} },
456 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") },
457 func(string) bool { return true },
458 func() (context.Context, bool) { return nil, false },
459 s,
460 )
461 w.pass()
462 if _, ok := warmRecordIn(t, s, "bad.customer.test"); ok {
463 t.Error("a stood-down box wrote an outcome record")
464 }
465}
466
467// The records are observability, never a precondition. A bucket that cannot
468// be listed or written must not stop the warm from warming: the certificate
469// is the job, the record only explains it.
470func TestWarmKeepsWarmingWhenTheRecordStoreIsBroken(t *testing.T) {
471 s := &failingStore{Store: release.NewDirStore(t.TempDir()), putErr: errors.New("bucket down")}
472 calls := 0
473 w := newCertWarm(
474 func() []string { return []string{"www.customer.test"} },
475 func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return nil, errors.New("order failed") },
476 func(string) bool { return true },
477 nil,
478 s,
479 )
480 if d := w.pass(); d != warmInterval {
481 t.Fatalf("delay = %s, want the ordinary %s", d, warmInterval)
482 }
483 if calls != 1 {
484 t.Fatalf("GetCertificate calls = %d, want 1 — a broken record store stopped the warm", calls)
485 }
486}
487