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/ecdsa"
6 "crypto/elliptic"
7 "crypto/rand"
8 "crypto/tls"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "errors"
12 "math/big"
13 "reflect"
14 "slices"
15 "sort"
16 "strings"
17 "testing"
18 "time"
19
20 "golang.org/x/crypto/acme/autocert"
21
22 "github.com/carlosframework/platform/internal/registry"
23)
24
25// selfSignedWildcard mints a throwaway *.parent cert valid for the given
26// window — enough for matching/renewal/PEM tests, no ACME involved.
27func selfSignedWildcard(t *testing.T, parent string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) {
28 t.Helper()
29 return selfSignedLeaf(t, "*."+parent, notAfter)
30}
31
32// selfSignedLeaf mints a throwaway cert for one DNS name (a host, or a
33// "*.parent" wildcard) valid until notAfter.
34func selfSignedLeaf(t *testing.T, dnsName string, notAfter time.Time) (*tls.Certificate, *ecdsa.PrivateKey) {
35 t.Helper()
36 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
37 if err != nil {
38 t.Fatal(err)
39 }
40 tmpl := &x509.Certificate{
41 SerialNumber: big.NewInt(1),
42 Subject: pkix.Name{CommonName: dnsName},
43 DNSNames: []string{dnsName},
44 NotBefore: time.Now().Add(-time.Hour),
45 NotAfter: notAfter,
46 }
47 der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
48 if err != nil {
49 t.Fatal(err)
50 }
51 leaf, err := x509.ParseCertificate(der)
52 if err != nil {
53 t.Fatal(err)
54 }
55 return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, key
56}
57
58func TestParentsFor(t *testing.T) {
59 hosts := []string{
60 "kass.abs.oncarlos.com", // app host → parent abs.oncarlos.com
61 "blog.abs.oncarlos.com", // same parent, deduped
62 "echo.bab.oncarlos.com", // second account
63 "canary.kass.abs.oncarlos.com", // canary depth → no wildcard parent
64 "oncarlos.com", // apex → skipped
65 "console.example.com", // foreign domain → skipped
66 "UPPER.CASE.ONCARLOS.COM", // case-normalized → case.oncarlos.com
67 "x.my-shop.oncarlos.com", // a label no account could hold → no parent (#215)
68 "x.acct3.oncarlos.com", // nor one with a digit in it
69 "x.ops.oncarlos.com", // nor "o": not in the sqid alphabet
70 }
71 got := parentsFor([]string{"go.tito.io"}, "oncarlos.com", hosts)
72 want := []string{"abs.oncarlos.com", "bab.oncarlos.com", "case.oncarlos.com", "go.tito.io"}
73 if !reflect.DeepEqual(got, want) {
74 t.Fatalf("parentsFor = %v, want %v", got, want)
75 }
76 // No apps domain → static only.
77 if got := parentsFor([]string{"go.tito.io"}, "", hosts); !reflect.DeepEqual(got, []string{"go.tito.io"}) {
78 t.Fatalf("static only = %v", got)
79 }
80 // Nothing at all.
81 if got := parentsFor(nil, "", nil); got != nil {
82 t.Fatalf("empty = %v, want nil", got)
83 }
84}
85
86func TestCertForMatchesExactlyOneLabel(t *testing.T) {
87 w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com",
88 func() []string { return []string{"kass.abs.oncarlos.com"} },
89 nil, "", autocert.DirCache(t.TempDir()), nil)
90 w.refresh()
91 cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
92 w.mu.Lock()
93 w.certs["abs.oncarlos.com"] = cert
94 w.mu.Unlock()
95
96 cases := []struct {
97 sni string
98 want bool
99 }{
100 {"kass.abs.oncarlos.com", true}, // exactly one label under parent
101 {"NEW.abs.oncarlos.com.", true}, // never-deployed name, case+dot normalized: instant
102 {"abs.oncarlos.com", false}, // the parent itself (wildcard doesn't cover apex)
103 {"x.y.abs.oncarlos.com", false}, // two labels deep
104 {"kass.bab.oncarlos.com", false}, // parent without a loaded cert (bab not in parents)
105 {"slug.go.tito.io", false}, // static parent present but no cert loaded yet
106 }
107 for _, c := range cases {
108 if got := w.certFor(c.sni) != nil; got != c.want {
109 t.Errorf("certFor(%q) served=%v, want %v", c.sni, got, c.want)
110 }
111 }
112
113 // An expired wildcard is never served — fall back to per-host issuance.
114 expired, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(-time.Hour))
115 w.mu.Lock()
116 w.certs["abs.oncarlos.com"] = expired
117 w.mu.Unlock()
118 if w.certFor("kass.abs.oncarlos.com") != nil {
119 t.Fatal("expired wildcard was served")
120 }
121}
122
123func TestWildcardPEMRoundTripAndRenewDecision(t *testing.T) {
124 cert, key := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
125 raw, err := encodeWildcardPEM(cert, key)
126 if err != nil {
127 t.Fatal(err)
128 }
129 back, err := parseWildcardPEM(raw)
130 if err != nil {
131 t.Fatal(err)
132 }
133 if !back.Leaf.NotAfter.Equal(cert.Leaf.NotAfter) {
134 t.Fatalf("roundtrip NotAfter = %v, want %v", back.Leaf.NotAfter, cert.Leaf.NotAfter)
135 }
136 if _, err := parseWildcardPEM([]byte("junk")); err == nil {
137 t.Fatal("parseWildcardPEM(junk): want error")
138 }
139
140 w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", autocert.DirCache(t.TempDir()), nil)
141 if !w.needsObtain("abs.oncarlos.com", time.Now()) {
142 t.Fatal("no cert loaded: needsObtain should be true")
143 }
144 w.mu.Lock()
145 w.certs["abs.oncarlos.com"] = cert
146 w.mu.Unlock()
147 if w.needsObtain("abs.oncarlos.com", time.Now()) {
148 t.Fatal("60 days left: needsObtain should be false")
149 }
150 if !w.needsObtain("abs.oncarlos.com", cert.Leaf.NotAfter.Add(-15*24*time.Hour)) {
151 t.Fatal("15 days left (<30): needsObtain should be true")
152 }
153}
154
155func TestLoadCachedAndRefreshWake(t *testing.T) {
156 dir := t.TempDir()
157 cache := autocert.DirCache(dir)
158 cert, key := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
159 raw, err := encodeWildcardPEM(cert, key)
160 if err != nil {
161 t.Fatal(err)
162 }
163 if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), raw); err != nil {
164 t.Fatal(err)
165 }
166
167 w := newWildcardManager(nil, "oncarlos.com",
168 func() []string { return []string{"kass.abs.oncarlos.com"} }, nil, "", cache, nil)
169 if !w.loadCached(context.Background(), "abs.oncarlos.com") {
170 t.Fatal("loadCached: want true for cached parent")
171 }
172 if w.loadCached(context.Background(), "bab.oncarlos.com") {
173 t.Fatal("loadCached: want false for uncached parent")
174 }
175
176 // refresh derives the parent and, since its cert is already loaded and
177 // fresh, does NOT wake the run loop; a parent with no cert does.
178 w.refresh()
179 select {
180 case <-w.wake:
181 t.Fatal("refresh with fresh cert should not wake")
182 default:
183 }
184 w2 := newWildcardManager(nil, "oncarlos.com",
185 func() []string { return []string{"kass.abs.oncarlos.com"} }, nil, "", autocert.DirCache(t.TempDir()), nil)
186 w2.refresh()
187 select {
188 case <-w2.wake:
189 default:
190 t.Fatal("refresh with missing cert should wake the run loop")
191 }
192}
193
194func TestLoadCachedRejectsWrongCoverage(t *testing.T) {
195 // (a) a cert valid for the WRONG parent, stashed under abs.oncarlos.com's
196 // key — loadCached must refuse it and store nothing.
197 wrongParent, wrongParentKey := selfSignedWildcard(t, "bab.oncarlos.com", time.Now().Add(60*24*time.Hour))
198 rawWrongParent, err := encodeWildcardPEM(wrongParent, wrongParentKey)
199 if err != nil {
200 t.Fatal(err)
201 }
202 cache := autocert.DirCache(t.TempDir())
203 if err := cache.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawWrongParent); err != nil {
204 t.Fatal(err)
205 }
206 w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, nil)
207 if w.loadCached(context.Background(), "abs.oncarlos.com") {
208 t.Fatal("loadCached: want false for cert covering the wrong parent")
209 }
210 w.mu.Lock()
211 _, stored := w.certs["abs.oncarlos.com"]
212 w.mu.Unlock()
213 if stored {
214 t.Fatal("loadCached: must not store a cert that doesn't cover the parent")
215 }
216
217 // (b) a cert whose stored key does NOT match the leaf's public key.
218 cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
219 otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
220 if err != nil {
221 t.Fatal(err)
222 }
223 rawMismatch, err := encodeWildcardPEM(cert, otherKey)
224 if err != nil {
225 t.Fatal(err)
226 }
227 cache2 := autocert.DirCache(t.TempDir())
228 if err := cache2.Put(context.Background(), wildcardPEMKey("abs.oncarlos.com"), rawMismatch); err != nil {
229 t.Fatal(err)
230 }
231 w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache2, nil)
232 if w2.loadCached(context.Background(), "abs.oncarlos.com") {
233 t.Fatal("loadCached: want false for key/leaf mismatch")
234 }
235 w2.mu.Lock()
236 _, stored2 := w2.certs["abs.oncarlos.com"]
237 w2.mu.Unlock()
238 if stored2 {
239 t.Fatal("loadCached: must not store a cert whose key doesn't match its leaf")
240 }
241}
242
243// fakeDNS records present/cleanup calls — the seam obtain talks through.
244type fakeDNS struct {
245 presented []string // fqdn|value
246 cleaned []string // fqdn
247 failWith error
248}
249
250func (f *fakeDNS) present(_ context.Context, fqdn, value string) (func(context.Context) error, error) {
251 if f.failWith != nil {
252 return nil, f.failWith
253 }
254 f.presented = append(f.presented, fqdn+"|"+value)
255 return func(context.Context) error {
256 f.cleaned = append(f.cleaned, fqdn)
257 return nil
258 }, nil
259}
260
261func TestWildcardAccountKeyStableAcrossManagers(t *testing.T) {
262 dir := t.TempDir()
263 cache := autocert.DirCache(dir)
264 w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{})
265 k1, err := w1.accountKey(context.Background())
266 if err != nil {
267 t.Fatal(err)
268 }
269 w2 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{})
270 k2, err := w2.accountKey(context.Background())
271 if err != nil {
272 t.Fatal(err)
273 }
274 if !k1.Equal(k2) {
275 t.Fatal("account key not stable across managers sharing a cache")
276 }
277}
278
279// A cache backend fault must NOT mint a fresh ACME account — only a genuine
280// miss does (a fault treated as a miss would register a new account per
281// hiccup).
282type faultyCache struct{ autocert.Cache }
283
284func (f faultyCache) Get(context.Context, string) ([]byte, error) {
285 return nil, errors.New("backend down")
286}
287
288func TestAccountKeyBackendFaultDoesNotMint(t *testing.T) {
289 w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
290 nil, "", faultyCache{autocert.DirCache(t.TempDir())}, &fakeDNS{})
291 if _, err := w.accountKey(context.Background()); err == nil {
292 t.Fatal("backend fault: want error, got a (freshly minted) key")
293 }
294}
295
296// TestPerParentBackoff exercises the backoff bookkeeping directly (no live
297// ACME, no spinning run()'s loop): a parent whose nextAttempt is in the
298// future is skipped, a past nextAttempt clears the skip, and a success
299// (delete) clears the entry entirely.
300func TestPerParentBackoff(t *testing.T) {
301 w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
302 nil, "", autocert.DirCache(t.TempDir()), &fakeDNS{})
303
304 if w.backingOff("abs.oncarlos.com", time.Now()) {
305 t.Fatal("no nextAttempt recorded yet: backingOff should be false")
306 }
307
308 // Simulate the failure path: run() sets nextAttempt an hour out.
309 w.mu.Lock()
310 w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(time.Hour)
311 w.mu.Unlock()
312 if !w.backingOff("abs.oncarlos.com", time.Now()) {
313 t.Fatal("nextAttempt an hour in the future: backingOff should be true")
314 }
315
316 // Once the window has passed, the same entry no longer backs off.
317 w.mu.Lock()
318 w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(-time.Minute)
319 w.mu.Unlock()
320 if w.backingOff("abs.oncarlos.com", time.Now()) {
321 t.Fatal("nextAttempt in the past: backingOff should be false")
322 }
323
324 // Simulate the success path: run() deletes the entry outright.
325 w.mu.Lock()
326 w.nextAttempt["abs.oncarlos.com"] = time.Now().Add(time.Hour)
327 delete(w.nextAttempt, "abs.oncarlos.com")
328 w.mu.Unlock()
329 if w.backingOff("abs.oncarlos.com", time.Now()) {
330 t.Fatal("nextAttempt deleted on success: backingOff should be false")
331 }
332}
333
334func TestWrappedGetCertificateFallsThrough(t *testing.T) {
335 w := newWildcardManager(nil, "oncarlos.com",
336 func() []string { return []string{"kass.abs.oncarlos.com"} },
337 nil, "", autocert.DirCache(t.TempDir()), &fakeDNS{})
338 w.refresh()
339 cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour))
340 w.mu.Lock()
341 w.certs["abs.oncarlos.com"] = cert
342 w.mu.Unlock()
343
344 fallbackHit := ""
345 inner := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
346 fallbackHit = hello.ServerName
347 return nil, errors.New("inner path")
348 }
349 wrapped := wrapGetCertificate(w, inner)
350
351 got, err := wrapped(&tls.ClientHelloInfo{ServerName: "new.abs.oncarlos.com"})
352 if err != nil || got != cert {
353 t.Fatalf("covered name: got (%v, %v), want the wildcard", got, err)
354 }
355 if fallbackHit != "" {
356 t.Fatalf("covered name reached the inner path (%q)", fallbackHit)
357 }
358 if _, err := wrapped(&tls.ClientHelloInfo{ServerName: "custom.example.com"}); err == nil || fallbackHit != "custom.example.com" {
359 t.Fatalf("uncovered name must fall through to inner; fallbackHit=%q err=%v", fallbackHit, err)
360 }
361
362 // A tls-alpn-01 validation ClientHello for a COVERED name must still
363 // reach autocert's challenge cert, not the wildcard — otherwise that
364 // validation fails every time.
365 fallbackHit = ""
366 if _, err := wrapped(&tls.ClientHelloInfo{ServerName: "new.abs.oncarlos.com", SupportedProtos: []string{"acme-tls/1"}}); err == nil || fallbackHit != "new.abs.oncarlos.com" {
367 t.Fatalf("tls-alpn-01 hello for covered name must reach inner; fallbackHit=%q err=%v", fallbackHit, err)
368 }
369}
370
371func TestNewWildcardFromEnv(t *testing.T) {
372 e := &Edge{}
373 e.upstreams.Store(&map[string]upstream{})
374 cache := autocert.DirCache(t.TempDir())
375
376 t.Setenv("CARLOS_WILDCARD_DOMAINS", "")
377 t.Setenv("PLATFORM_APPS_DOMAIN", "")
378 t.Setenv("CARLOS_DNS_PROVIDER", "")
379 t.Setenv("CARLOS_DNSIMPLE_TOKEN", "")
380 if w := newWildcardFromEnv(e, cache); w != nil {
381 t.Fatal("nothing configured: want nil (per-host only, the historical default)")
382 }
383 // Domains but no provider → off (logged), never a broken half-config.
384 t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
385 if w := newWildcardFromEnv(e, cache); w != nil {
386 t.Fatal("domains without provider: want nil")
387 }
388 t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok")
389 w := newWildcardFromEnv(e, cache)
390 if w == nil {
391 t.Fatal("configured: want a manager")
392 }
393 if !reflect.DeepEqual(w.staticDomains, []string{"go.tito.io"}) {
394 t.Fatalf("staticDomains = %v", w.staticDomains)
395 }
396}
397
398func TestOnReloadHookFires(t *testing.T) {
399 reg := openReg(t) // edge_test.go's existing registry fixture
400 e, err := New(reg, nil)
401 if err != nil {
402 t.Fatal(err)
403 }
404 fired := 0
405 e.OnReload(func() { fired++ })
406 if err := e.Reload(); err != nil {
407 t.Fatal(err)
408 }
409 if fired != 1 {
410 t.Fatalf("hook fired %d times, want 1", fired)
411 }
412}
413
414// TestCurrentHostsPlatformOnly guards the wildcard-safety filter: only
415// platform-minted routes contribute to currentHosts — console-minted
416// (Platform==true) or records-provisioned (Provisioned==true) — since
417// parentsFor derives wildcard parents from that list. A host shaped like
418// <x>.<y>.<apps-domain> that landed in the route table by neither path
419// must never mint a wildcard parent and burn an ACME issuance for it.
420//
421// Provisioned rows joined the set on 2026-08-24: a box holding ONLY
422// provisioned rows (every multi-region edge) derived no parents and served
423// no mirrored wildcard. A provisioned custom domain is listed here but
424// mints nothing — parentsFor's two-labels-under-the-apps-domain rule is the
425// bound, pinned below.
426func TestCurrentHostsPlatformOnly(t *testing.T) {
427 e := &Edge{}
428 e.upstreams.Store(&map[string]upstream{
429 "app.acct.oncarlos.com": {route: registry.Route{Host: "app.acct.oncarlos.com", Platform: true}},
430 "review.bcd.oncarlos.com": {route: registry.Route{Host: "review.bcd.oncarlos.com", Provisioned: true}},
431 "story.example.com": {route: registry.Route{Host: "story.example.com", Provisioned: true}},
432 "custom.example.com": {route: registry.Route{Host: "custom.example.com", Platform: false}},
433 "evil.acct2.oncarlos.com": {route: registry.Route{Host: "evil.acct2.oncarlos.com", Platform: false}},
434 })
435 got := e.currentHosts()
436 sort.Strings(got)
437 want := []string{"app.acct.oncarlos.com", "review.bcd.oncarlos.com", "story.example.com"}
438 if !reflect.DeepEqual(got, want) {
439 t.Fatalf("currentHosts = %v, want %v", got, want)
440 }
441 parents := parentsFor(nil, "oncarlos.com", got)
442 wantParents := []string{"acct.oncarlos.com", "bcd.oncarlos.com"}
443 if !reflect.DeepEqual(parents, wantParents) {
444 t.Fatalf("parents = %v, want %v (a provisioned custom domain mints no parent; the unowned evil row never reaches here)", parents, wantParents)
445 }
446}
447
448func TestDelegatedParentsJoinTheSet(t *testing.T) {
449 w := newWildcardManager(nil, "oncarlos.com", func() []string {
450 return []string{"app.bab.oncarlos.com"}
451 }, func() []string {
452 return []string{"woodstar.app"}
453 }, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
454 w.refresh()
455 w.mu.Lock()
456 parents := append([]string(nil), w.parents...)
457 w.mu.Unlock()
458 want := []string{"bab.oncarlos.com", "woodstar.app"}
459 if !reflect.DeepEqual(parents, want) {
460 t.Fatalf("parents = %v, want %v", parents, want)
461 }
462}
463
464func TestChallengeFQDNDelegation(t *testing.T) {
465 w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
466 func() []string { return []string{"woodstar.app"} },
467 "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
468 w.refresh()
469 // A delegated parent's TXT publishes into OUR zone, at the target
470 // the customer's CNAME points to.
471 if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want {
472 t.Errorf("delegated: %q, want %q", got, want)
473 }
474 // A platform-zone parent publishes directly, exactly as before.
475 if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want {
476 t.Errorf("own-zone: %q, want %q", got, want)
477 }
478}
479
480func TestChallengeFQDNWithoutZoneNeverDelegates(t *testing.T) {
481 // No delegation zone configured: even a listed delegated parent
482 // publishes directly (misconfiguration degrades to the historical
483 // behavior, which per-host issuance then covers — never a dead edge).
484 w := newWildcardManager(nil, "", func() []string { return nil },
485 func() []string { return []string{"woodstar.app"} },
486 "", autocert.DirCache(t.TempDir()), &fakeDNS{})
487 w.refresh()
488 if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app"; got != want {
489 t.Errorf("%q, want %q", got, want)
490 }
491}
492
493func TestNilDelegatedSourceIsFine(t *testing.T) {
494 // Every existing construction site passes nil — the zero-source
495 // manager must behave exactly as before this feature existed.
496 w := newWildcardManager([]string{"go.tito.io"}, "", func() []string { return nil },
497 nil, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
498 w.refresh()
499 w.mu.Lock()
500 parents := append([]string(nil), w.parents...)
501 w.mu.Unlock()
502 if !reflect.DeepEqual(parents, []string{"go.tito.io"}) {
503 t.Fatalf("parents = %v, want [go.tito.io]", parents)
504 }
505 if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want {
506 t.Errorf("%q, want %q", got, want)
507 }
508}
509
510// TestPlatformParentsWinOverDelegated exercises the precedence rule refresh()
511// enforces: a delegated entry that collides with a static domain or falls
512// under the apps domain is dropped before it can hijack a PLATFORM parent's
513// challenge — that parent's TXT must still publish directly, never into the
514// delegation zone where no customer CNAME exists.
515func TestPlatformParentsWinOverDelegated(t *testing.T) {
516 w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com",
517 func() []string { return []string{"app.bab.oncarlos.com"} },
518 func() []string { return []string{"go.tito.io", "bab.oncarlos.com", "woodstar.app"} },
519 "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
520 w.refresh()
521
522 // Colliding entries lose: both the static domain and the dynamic
523 // per-account parent publish their challenge directly, exactly as if
524 // they had never appeared in the delegated source.
525 if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want {
526 t.Errorf("static collision: challengeFQDN = %q, want %q", got, want)
527 }
528 if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want {
529 t.Errorf("apps-domain collision: challengeFQDN = %q, want %q", got, want)
530 }
531 // A genuinely foreign delegated parent still gets the delegated form.
532 if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want {
533 t.Errorf("non-colliding delegated: challengeFQDN = %q, want %q", got, want)
534 }
535}
536
537func TestNewWildcardFromEnvDelegationZone(t *testing.T) {
538 t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
539 t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
540 t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
541 t.Setenv("CARLOS_ACME_DELEGATION_ZONE", " Acme.OnCarlos.Com ")
542 e := &Edge{}
543 w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
544 if w == nil {
545 t.Fatal("manager should be on")
546 }
547 if w.delegationZone != "acme.oncarlos.com" {
548 t.Errorf("delegationZone = %q, want lowercased+trimmed acme.oncarlos.com", w.delegationZone)
549 }
550}
551
552// TestNewWildcardFromEnvInvalidDelegationZoneDegrades covers "validate at
553// boot, degrade don't die": a malshaped CARLOS_ACME_DELEGATION_ZONE must
554// never stop the edge from booting wildcards — it just turns delegation off
555// while per-host and direct (own-zone) DNS-01 keep covering everyone else.
556func TestNewWildcardFromEnvInvalidDelegationZoneDegrades(t *testing.T) {
557 t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
558 t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
559 t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
560 t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "*.bad zone")
561 e := &Edge{}
562 w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
563 if w == nil {
564 t.Fatal("invalid delegation zone must not stop the edge from booting")
565 }
566 if w.delegationZone != "" {
567 t.Errorf("delegationZone = %q, want empty (delegation off)", w.delegationZone)
568 }
569}
570
571// TestDelegatedSourceReadsRegistry pins Task 4's sub-change 1: newWildcardFromEnv
572// must wire its delegated source to a LIVE registry read (Task 2's
573// ListWildcardDomains), not the nil #168 left as a placeholder. Two
574// wildcard_domains rows, written the way the reconcile pass (Task 3) writes
575// them, must both surface as delegated parents after refresh().
576func TestDelegatedSourceReadsRegistry(t *testing.T) {
577 reg := openReg(t) // edge_test.go's registry fixture
578 for _, wd := range []registry.WildcardDomain{
579 {Host: "woodstar.app", Account: "acct", App: "app", CreatedAt: 1},
580 {Host: "example.net", Account: "acct2", App: "app2", CreatedAt: 2},
581 } {
582 if err := reg.UpsertWildcardDomain(wd); err != nil {
583 t.Fatalf("UpsertWildcardDomain(%s): %v", wd.Host, err)
584 }
585 }
586 e, err := New(reg, nil)
587 if err != nil {
588 t.Fatalf("New: %v", err)
589 }
590
591 t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
592 t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
593 t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
594 t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "acme.oncarlos.com")
595 w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
596 if w == nil {
597 t.Fatal("manager should be on")
598 }
599 w.refresh()
600
601 w.mu.Lock()
602 parents := append([]string(nil), w.parents...)
603 w.mu.Unlock()
604 for _, host := range []string{"woodstar.app", "example.net"} {
605 if !slices.Contains(parents, host) {
606 t.Errorf("parents = %v, want %s (from a wildcard_domains registry row)", parents, host)
607 }
608 if !w.isDelegated(host) {
609 t.Errorf("isDelegated(%s) = false, want true", host)
610 }
611 }
612
613 // IMPORTANT-1 (review fix): a read error after a successful read must
614 // return the LAST-GOOD list, not empty it out — refresh() only runs at
615 // startup and on OnReload (run()'s 12h ticker never re-invokes the
616 // delegated source), so on a quiet box an emptied set would stay empty
617 // until some unrelated write next moved the registry fingerprint, and
618 // certFor would stop serving an already-cached, still-valid wildcard for
619 // every delegated parent in the meantime.
620 if err := reg.Close(); err != nil {
621 t.Fatalf("close registry: %v", err)
622 }
623 got := w.delegated()
624 want := []string{"example.net", "woodstar.app"}
625 slices.Sort(got)
626 if !slices.Equal(got, want) {
627 t.Fatalf("delegated() after a read error = %v, want the last-good list %v", got, want)
628 }
629}
630
631// TestRefreshDropsOverlongChallengeNames pins sub-change 2: a delegated host
632// whose composed DNS-01 challenge name ("_acme-challenge."+host+"."+zone)
633// would exceed the DNS 253-byte FQDN limit must never reach parents — an
634// AuthorizeOrder for it would fail at the CA anyway, and dropping it here
635// (rather than surfacing that failure through obtain()'s hourly retry loop)
636// keeps a single pathological claim from producing an endless retry log line
637// for a name that can never succeed.
638func TestRefreshDropsOverlongChallengeNames(t *testing.T) {
639 zone := "acme.oncarlos.com"
640 // "_acme-challenge." (16) + longHost (262) + "." + zone (17) > 253.
641 longHost := strings.Repeat("a", 250) + ".example.com"
642 w := newWildcardManager(nil, "", func() []string { return nil },
643 func() []string { return []string{longHost} },
644 zone, autocert.DirCache(t.TempDir()), &fakeDNS{})
645 w.refresh()
646
647 w.mu.Lock()
648 parents := append([]string(nil), w.parents...)
649 w.mu.Unlock()
650 if slices.Contains(parents, longHost) {
651 t.Fatalf("parents = %v, want %s dropped (composed challenge name exceeds 253 bytes)", parents, longHost)
652 }
653}
654