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// Wildcard TLS (ACME DNS-01) for instant app hostnames — the port of
4// Eleven's wildcard.go this file's TLS wiring always reserved space for
5// (spec: docs/superpowers/specs/2026-08-05-wildcard-certs-design.md).
6//
7// A TLS wildcard matches exactly one label, and app hosts are two labels
8// under the apps domain (<app>.<acct>.<apps-domain>), so there is no single
9// *.<apps-domain> cert. Instead one wildcard per PARENT:
10//
11// - dynamic parents <acct>.<apps-domain>, derived from the live route
12// table under PLATFORM_APPS_DOMAIN — a new account's first route starts
13// issuance immediately, so every later app under it has working TLS the
14// moment its route exists (and CT logs see the account, not app names);
15// - static parents from CARLOS_WILDCARD_DOMAINS (tito: go.tito.io, whose
16// instance hosts are one label deep).
17//
18// Canary hosts (<name>.<app>.<acct>.…, one label too deep) and custom
19// domains stay per-host HTTP-01 — except a custom domain approved as a
20// delegated wildcard parent (spec §4), which gets its own *.<parent>
21// wildcard via a challenge TXT published in our own zone. The wildcard
22// only changes how a handshake GETS a certificate: routing, HostPolicy and
23// the :80 handler are
24// untouched, and every failure (DNS provider outage, ACME outage,
25// unreadable cache) degrades to exactly the per-host behaviour — never a
26// dead edge.
27
28import (
29 "bytes"
30 "context"
31 "crypto"
32 "crypto/ecdsa"
33 "crypto/elliptic"
34 "crypto/rand"
35 "crypto/tls"
36 "crypto/x509"
37 "crypto/x509/pkix"
38 "encoding/pem"
39 "errors"
40 "fmt"
41 "log"
42 "net"
43 "os"
44 "sort"
45 "strings"
46 "sync"
47 "sync/atomic"
48 "time"
49
50 "golang.org/x/crypto/acme"
51 "golang.org/x/crypto/acme/autocert"
52
53 "github.com/carlosframework/platform/internal/domains"
54 "github.com/carlosframework/platform/internal/provision"
55)
56
57const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"
58
59// wildcardRenewBefore is how close to expiry we re-issue. LE certs live 90
60// days; renewing with 30 left tolerates a month of provider outage.
61const wildcardRenewBefore = 30 * 24 * time.Hour
62
63type wildcardManager struct {
64 // cache is the shared cert cache (certcache.go) — the same backend
65 // autocert uses for per-host certs, so a future pooled-edge S3 backend
66 // shares wildcards too. Keys: wildcardPEMKey / wildcardAccountKeyName.
67 cache autocert.Cache
68 dns dnsProvider
69 directory string
70
71 staticDomains []string
72 appsDomain string
73 hosts func() []string // the deployment's live hosts (Edge.deploymentHosts)
74
75 // delegated lists customer domains approved for delegated-challenge
76 // wildcard issuance (spec §4: claim-derived; nil = none). Their
77 // challenge TXTs publish into delegationZone — our own zone — at
78 // _acme-challenge.<parent>.<delegationZone>, which the customer has
79 // CNAMEd _acme-challenge.<parent> to. The platform never touches,
80 // or knows, the customer's DNS provider.
81 delegated func() []string
82 delegationZone string
83
84 // delegatedSet is refresh()'s snapshot of delegated(), read by
85 // challengeFQDN under mu (issuance runs on run()'s goroutine).
86 delegatedSet map[string]bool
87
88 // refreshMu serializes refresh() (see there); mu guards the fields.
89 refreshMu sync.Mutex
90 mu sync.Mutex
91 parents []string // last computed parent set (sorted)
92 certs map[string]*tls.Certificate
93
94 // nextAttempt holds a per-parent backoff floor: run() skips a parent
95 // until this time has passed. Set on obtain failure, cleared on success —
96 // keeps a wake (which fires on ANY parent lacking a usable cert) from
97 // retrying a persistently failing parent more than hourly (see run()).
98 nextAttempt map[string]time.Time
99
100 // wake nudges run() out of its 12h sleep when refresh() finds a parent
101 // with no usable cert (a new account's first route). Capacity 1: a wake
102 // is a level, not a count.
103 wake chan struct{}
104
105 // issuer gates ORDERING (certsync.go): in a multi-edge deployment only
106 // the housekeeper talks to the CA, and its context ends the moment the
107 // holding does, so a stand-down mid-order is observable inside the order.
108 // nil = ungated, which is every single-box deployment and `carlos edge`
109 // without the agent. Loading and serving a cert some other box obtained
110 // is never gated — that is the whole point of the shared cache.
111 issuer IssuerGate
112
113 // lastNotIssuerLog rate-limits the one line a non-issuer prints when it
114 // stands down from a pass. Read/written under mu.
115 lastNotIssuerLog time.Time
116}
117
118func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, delegated func() []string, delegationZone string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
119 return &wildcardManager{
120 cache: cache,
121 dns: dns,
122 directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory),
123 staticDomains: staticDomains,
124 appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)),
125 hosts: hosts,
126 delegated: delegated,
127 delegationZone: strings.ToLower(strings.TrimSpace(delegationZone)),
128 certs: map[string]*tls.Certificate{},
129 nextAttempt: map[string]time.Time{},
130 delegatedSet: map[string]bool{},
131 wake: make(chan struct{}, 1),
132 }
133}
134
135// maxDelegatedParents bounds the delegated set this box will carry —
136// local rows plus every parent other boxes publish (#305). The per-box
137// cap on wildcard_domains (internal/domains, 32) exists because every
138// delegated parent shares one ACME account's new-order budget; a fleet's
139// union must not multiply that by the box count, or one broken delegation
140// zone would have the issuer retrying 32×N orders an hour. Local rows come
141// first; the overflow is logged, once per pass, never silently dropped.
142const maxDelegatedParents = 32
143
144// unionDelegated is the delegated source's merge: local rows, then remote
145// parents, capped. A remote parent on a box with NO delegation zone is a
146// rollout mistake worth a line — this box could win the issuer lease and
147// would then fail every order for it against the customer's own zone.
148func unionDelegated(local, remote []string, zone string) []string {
149 seen := make(map[string]bool, len(local)+len(remote))
150 out := make([]string, 0, len(local)+len(remote))
151 add := func(d string) {
152 d = strings.ToLower(strings.TrimSpace(d))
153 if d == "" || seen[d] {
154 return
155 }
156 seen[d] = true
157 out = append(out, d)
158 }
159 for _, d := range local {
160 add(d)
161 }
162 nLocal := len(out)
163 for _, d := range remote {
164 add(d)
165 }
166 if len(out) > nLocal && zone == "" {
167 delegationZoneUnsetOnce.Do(func() {
168 log.Printf("edge: %d delegated wildcard parent(s) published by other boxes but CARLOS_ACME_DELEGATION_ZONE is unset here — set it fleet-wide, or this box cannot issue for them if it becomes the issuer", len(out)-nLocal)
169 })
170 }
171 if len(out) > maxDelegatedParents {
172 if n := int32(len(out)); delegatedCapLogged.Swap(n) != n {
173 log.Printf("edge: delegated wildcard parents capped at %d (%d distinct: %d local, %d remote) — the overflow gets no wildcard", maxDelegatedParents, len(out), nLocal, len(out)-nLocal)
174 }
175 out = out[:maxDelegatedParents]
176 }
177 return out
178}
179
180// Standing conditions are said once: an unset zone stays unset, and the cap
181// line repeats only when the count it reports changes.
182var (
183 delegationZoneUnsetOnce sync.Once
184 delegatedCapLogged atomic.Int32
185)
186
187// parentsFor computes the wildcard parent set: staticAndDelegated (the
188// static CARLOS_WILDCARD_DOMAINS plus any delegated parents refresh() has
189// already vetted — both arrive pre-merged through this one parameter) union
190// <acct>.<appsDomain> for every route host exactly two labels under
191// appsDomain (<app>.<acct>.<appsDomain>). Canary hosts (three labels), the
192// apex, and foreign domains contribute nothing. Sorted and deduped so the
193// set is deterministic for logs and tests.
194//
195// <acct> must be shaped like an account sqid (domains.ValidSqid): every
196// account parent the platform mints is <sqid>.<appsDomain>, and a label that
197// could never be a sqid names no account. Without that bound a route someone
198// placed at x.<anything>.<appsDomain> would order a wildcard certificate for
199// a parent nobody owns, one per invented label, against the deployment's
200// shared ACME budget (#215). The console refuses such first routes for
201// sqid-shaped labels that are not the attaching account's own; this is the
202// edge's half, for the labels no account could hold.
203func parentsFor(staticAndDelegated []string, appsDomain string, hosts []string) []string {
204 seen := map[string]bool{}
205 var out []string
206 add := func(p string) {
207 if !seen[p] {
208 seen[p] = true
209 out = append(out, p)
210 }
211 }
212 for _, d := range staticAndDelegated {
213 add(strings.ToLower(d))
214 }
215 if appsDomain != "" {
216 suffix := "." + strings.ToLower(appsDomain)
217 for _, h := range hosts {
218 rest, ok := strings.CutSuffix(strings.ToLower(h), suffix)
219 if !ok {
220 continue
221 }
222 if labels := strings.Split(rest, "."); len(labels) == 2 && domains.ValidSqid(labels[1]) {
223 add(labels[1] + suffix)
224 }
225 }
226 }
227 sort.Strings(out)
228 return out
229}
230
231// refresh recomputes the parent set from the live route table (called from
232// the reload hook on every route swap, and once at startup), folding in
233// delegated parents (spec §4) alongside the static and per-account ones.
234// Platform parents always win: a delegated entry that collides with a
235// static domain, or falls under the apps domain, is dropped here — before
236// it reaches parentsFor's union or delegatedSet — because letting it
237// through would redirect a PLATFORM parent's challenge to a delegation
238// target where no CNAME exists, silently degrading its wildcard renewal to
239// per-host. If any parent lacks a usable cert it wakes run() — that is what
240// makes a NEW account's wildcard start issuing the moment its first route
241// lands, instead of on the next 12h tick. A wake cannot bypass a failing
242// parent's backoff: run() still skips any parent whose nextAttempt hasn't
243// passed, so a wake only advances parents that aren't already backing off.
244func (w *wildcardManager) refresh() {
245 // One derivation at a time: hosts() and delegated() are read outside
246 // w.mu, and three callers (the reload hook, the cert mirror's wake, the
247 // home map's wake — two of them 30s pollers) could otherwise interleave
248 // so that an older read lands last and drops a parent until the next
249 // wake. Serialized, the last writer is the last reader.
250 w.refreshMu.Lock()
251 defer w.refreshMu.Unlock()
252 var delegated []string
253 if w.delegated != nil {
254 static := make(map[string]bool, len(w.staticDomains))
255 for _, d := range w.staticDomains {
256 static[strings.ToLower(strings.TrimSpace(d))] = true
257 }
258 appsSuffix := ""
259 if w.appsDomain != "" {
260 appsSuffix = "." + w.appsDomain
261 }
262 for _, d := range w.delegated() {
263 if d = strings.ToLower(strings.TrimSpace(d)); d == "" {
264 continue
265 }
266 if static[d] || (appsSuffix != "" && strings.HasSuffix(d, appsSuffix)) {
267 continue
268 }
269 if w.delegationZone != "" && len("_acme-challenge."+d+"."+w.delegationZone) > 253 {
270 log.Printf("edge: delegated wildcard %s dropped: composed challenge name exceeds 253 bytes", d)
271 continue
272 }
273 delegated = append(delegated, d)
274 }
275 }
276 parents := parentsFor(append(append([]string(nil), w.staticDomains...), delegated...), w.appsDomain, w.hosts())
277 now := time.Now()
278 w.mu.Lock()
279 w.parents = parents
280 set := make(map[string]bool, len(delegated))
281 for _, d := range delegated {
282 set[d] = true
283 }
284 w.delegatedSet = set
285 need := false
286 for _, p := range parents {
287 c := w.certs[p]
288 if c == nil || now.Add(wildcardRenewBefore).After(c.Leaf.NotAfter) {
289 need = true
290 break
291 }
292 }
293 w.mu.Unlock()
294 if need {
295 select {
296 case w.wake <- struct{}{}:
297 default:
298 }
299 }
300}
301
302// currentParents is the last computed parent set — the mirror's bound on
303// which wildcard PEMs the issuer's backfill may publish (certsync.go).
304func (w *wildcardManager) currentParents() []string {
305 w.mu.Lock()
306 defer w.mu.Unlock()
307 return append([]string(nil), w.parents...)
308}
309
310// certFor returns the wildcard cert for serverName, or nil when the name
311// isn't exactly one label under a current parent (a wildcard doesn't cover
312// the apex or deeper levels) or no usable cert is loaded — nil sends the
313// handshake to the per-host autocert path.
314func (w *wildcardManager) certFor(serverName string) *tls.Certificate {
315 sn := strings.ToLower(strings.TrimSuffix(serverName, "."))
316 w.mu.Lock()
317 defer w.mu.Unlock()
318 for _, p := range w.parents {
319 sub, ok := strings.CutSuffix(sn, "."+p)
320 if !ok || sub == "" || strings.Contains(sub, ".") {
321 continue
322 }
323 cert := w.certs[p]
324 if cert == nil || time.Now().After(cert.Leaf.NotAfter) {
325 // Never serve a dead cert: a wildcard whose renewal has been
326 // broken past its actual expiry falls back to per-host issuance
327 // (the pre-wildcard behaviour) instead of hard-failing every
328 // handshake under the parent.
329 return nil
330 }
331 return cert
332 }
333 return nil
334}
335
336func (w *wildcardManager) needsObtain(parent string, now time.Time) bool {
337 w.mu.Lock()
338 cert := w.certs[parent]
339 w.mu.Unlock()
340 return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter)
341}
342
343// backingOff reports whether parent is still inside its post-failure backoff
344// window — run() skips it until nextAttempt passes, so a wake (which fires
345// on ANY parent lacking a usable cert) can't retry a persistently failing
346// parent more than hourly.
347func (w *wildcardManager) backingOff(parent string, now time.Time) bool {
348 w.mu.Lock()
349 defer w.mu.Unlock()
350 next, ok := w.nextAttempt[parent]
351 return ok && now.Before(next)
352}
353
354// loadCached pulls parent's wildcard from the cert cache into memory,
355// reporting whether a parseable cert was loaded. Called by run() before any
356// obtain attempt, which covers both startup (certs an earlier process
357// obtained) and a pooled-edge future (certs a sibling box obtained).
358func (w *wildcardManager) loadCached(ctx context.Context, parent string) bool {
359 raw, err := w.cache.Get(ctx, wildcardPEMKey(parent))
360 if errors.Is(err, autocert.ErrCacheMiss) {
361 return false
362 }
363 if err == nil {
364 var cert *tls.Certificate
365 if cert, err = parseWildcardPEM(raw); err == nil {
366 if !coversParent(cert, parent) {
367 err = errors.New("cached cert does not cover parent or key mismatch")
368 } else {
369 w.mu.Lock()
370 w.certs[parent] = cert
371 w.mu.Unlock()
372 log.Printf("edge: wildcard *.%s loaded from cache (valid until %s)", parent, cert.Leaf.NotAfter.Format(time.RFC3339))
373 return true
374 }
375 }
376 }
377 log.Printf("edge: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", parent, err)
378 return false
379}
380
381// coversParent reports whether cert is usable as *.<parent>: the leaf must
382// name a one-label host under parent and the private key must match the
383// leaf. A cached entry failing this is treated as unreadable — served
384// certs must never be worse than no cert (per-host issuance covers).
385func coversParent(cert *tls.Certificate, parent string) bool {
386 if cert.Leaf == nil || cert.Leaf.VerifyHostname("acme-probe."+parent) != nil {
387 return false
388 }
389 priv, ok := cert.PrivateKey.(interface{ Public() crypto.PublicKey })
390 if !ok {
391 return false
392 }
393 pub, ok := priv.Public().(interface{ Equal(crypto.PublicKey) bool })
394 return ok && pub.Equal(cert.Leaf.PublicKey)
395}
396
397// challengeFQDN returns where parent's DNS-01 TXT is published: the
398// delegation target inside our own zone for delegated parents, or
399// directly at _acme-challenge.<parent> otherwise. With no delegation
400// zone configured the direct form is used for everyone — the
401// misconfigured state degrades to the historical behavior and per-host
402// issuance keeps covering (obtain will fail on the CA's lookup, log,
403// and back off — never a dead edge).
404func (w *wildcardManager) challengeFQDN(parent string) string {
405 if w.delegationZone != "" && w.isDelegated(parent) {
406 return "_acme-challenge." + parent + "." + w.delegationZone
407 }
408 return "_acme-challenge." + parent
409}
410
411// isDelegated reports whether parent is in the delegated set.
412func (w *wildcardManager) isDelegated(parent string) bool {
413 w.mu.Lock()
414 defer w.mu.Unlock()
415 return w.delegatedSet[parent]
416}
417
418// wildcardPEMKey is the cache key for a parent's wildcard cert+key PEM —
419// Eleven's file name, so a dir cache written by that lineage reads with no
420// migration, and the keys sit additively beside autocert's per-host entries.
421func wildcardPEMKey(domain string) string {
422 return "wildcard-" + domain + ".pem"
423}
424
425// ---- PEM encode/parse (key + chain in one blob; the cert cache owns
426// placement, permissions, and write atomicity) -------------------------------
427
428func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error) {
429 var buf bytes.Buffer
430 der, err := x509.MarshalECPrivateKey(key)
431 if err != nil {
432 return nil, err
433 }
434 if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil {
435 return nil, err
436 }
437 for _, c := range cert.Certificate {
438 if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil {
439 return nil, err
440 }
441 }
442 return buf.Bytes(), nil
443}
444
445func parseWildcardPEM(raw []byte) (*tls.Certificate, error) {
446 cert := &tls.Certificate{}
447 for {
448 var block *pem.Block
449 block, raw = pem.Decode(raw)
450 if block == nil {
451 break
452 }
453 switch block.Type {
454 case "EC PRIVATE KEY":
455 key, err := x509.ParseECPrivateKey(block.Bytes)
456 if err != nil {
457 return nil, err
458 }
459 cert.PrivateKey = key
460 case "CERTIFICATE":
461 cert.Certificate = append(cert.Certificate, block.Bytes)
462 }
463 }
464 if cert.PrivateKey == nil || len(cert.Certificate) == 0 {
465 return nil, errors.New("missing key or certificate in PEM")
466 }
467 leaf, err := x509.ParseCertificate(cert.Certificate[0])
468 if err != nil {
469 return nil, err
470 }
471 cert.Leaf = leaf
472 return cert, nil
473}
474
475// run keeps the wildcard certs current: an immediate pass at startup (first
476// issuance / cache load), then every 12h, dropping to hourly retries after a
477// failure — and refresh() wakes it early when a route swap surfaces a parent
478// with no usable cert (a new account).
479func (w *wildcardManager) run() {
480 for {
481 delay := w.pass()
482 select {
483 case <-time.After(delay):
484 case <-w.wake:
485 }
486 }
487}
488
489// pass is one sweep over the current parent set — load what the cache
490// already has, order what is still missing — and returns how long run()
491// should wait before the next one. Split out of run() so the gating and
492// backoff decisions are testable without spinning the loop.
493func (w *wildcardManager) pass() time.Duration {
494 delay := 12 * time.Hour
495 w.mu.Lock()
496 parents := append([]string(nil), w.parents...)
497 w.mu.Unlock()
498 standDowns := 0
499 for _, p := range parents {
500 if !w.needsObtain(p, time.Now()) {
501 continue
502 }
503 if w.backingOff(p, time.Now()) {
504 // A wake (fired because some OTHER parent needs a cert) does
505 // not shorten this parent's own hourly retry.
506 continue
507 }
508 // Another process (or pool sibling) may have obtained it already.
509 // Ungated on purpose: reading a cert someone else issued — including
510 // one this box's mirror of control/certs/ has just landed — is
511 // SERVING, and every edge serves.
512 ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
513 w.loadCached(ctx, p)
514 cancel()
515 if !w.needsObtain(p, time.Now()) {
516 continue
517 }
518 // The order gate (certsync.go): one elected issuer talks to the CA.
519 // A non-issuer stands down HERE, after the cache load and before the
520 // CA — so it still picks up the issuer's wildcard, and never arms the
521 // per-parent backoff (standing down is not a failure; the moment this
522 // box wins the lease it must be able to order immediately).
523 held, ok := w.issuerGate()
524 if !ok {
525 standDowns++
526 continue
527 }
528 // The order runs under the HOLDING's context, not a bare background
529 // one: an ACME order takes seconds to minutes, and work started as
530 // the issuer must not outlive the authority it started under.
531 ctx, cancel = context.WithTimeout(held, 10*time.Minute)
532 err := w.obtain(ctx, p)
533 cancel()
534 if err != nil {
535 // A missing zone is configuration, not a blip: asking hourly would
536 // only repeat the line. It is still re-checked, twice a day, so a
537 // zone added to the provider later is picked up without a restart.
538 retry := time.Hour
539 if errors.Is(err, errNoZone) {
540 retry = noZoneRetry
541 }
542 log.Printf("edge: wildcard *.%s: %v — per-host issuance keeps covering; retrying in %s", p, err, retry)
543 if retry < delay {
544 delay = retry
545 }
546 w.mu.Lock()
547 w.nextAttempt[p] = time.Now().Add(retry)
548 w.mu.Unlock()
549 continue
550 }
551 w.mu.Lock()
552 delete(w.nextAttempt, p)
553 cert := w.certs[p]
554 w.mu.Unlock()
555 log.Printf("edge: wildcard *.%s obtained (valid until %s)", p, cert.Leaf.NotAfter.Format(time.RFC3339))
556 }
557 if standDowns > 0 {
558 w.logStandDown(standDowns)
559 // Come back in an hour, not twelve. A non-issuer's next pass is what
560 // LOADS the wildcard the issuer has meanwhile minted into this box's
561 // cache — certFor serves from the in-memory map loadCached fills — and
562 // refresh() only wakes the loop on a route swap, which a quiet box may
563 // not see for a day. No ACME traffic is involved in the retry.
564 if delay > time.Hour {
565 delay = time.Hour
566 }
567 }
568 return delay
569}
570
571// issuerGate is this manager's read of the deployment's issuer gate, with
572// the ungated default applied (see IssuerGate, certsync.go).
573func (w *wildcardManager) issuerGate() (context.Context, bool) {
574 if w.issuer == nil {
575 return context.Background(), true
576 }
577 return w.issuer()
578}
579
580// logStandDown says once an hour, at most, that this box wanted wildcards it
581// is not allowed to order. Not silent — a box with no wildcard and no reason
582// in the journal is the hard kind of production puzzle — and not per-pass
583// either: refresh() can wake a pass on every route swap, and on a
584// non-issuer every one of those would repeat the same line.
585func (w *wildcardManager) logStandDown(parents int) {
586 now := time.Now()
587 w.mu.Lock()
588 quiet := now.Sub(w.lastNotIssuerLog) < time.Hour
589 if !quiet {
590 w.lastNotIssuerLog = now
591 }
592 w.mu.Unlock()
593 if quiet {
594 return
595 }
596 log.Printf("edge: wildcard: %d parent(s) need issuance but this box is not the deployment's certificate issuer — serving what the issuer publishes", parents)
597}
598
599// noZoneRetry is how long a parent whose challenge zone the DNS provider
600// does not hold waits before it is looked at again (see pass).
601const noZoneRetry = 12 * time.Hour
602
603// obtain runs one full ACME DNS-01 issuance for *.parent and swaps the
604// result in.
605func (w *wildcardManager) obtain(ctx context.Context, parent string) error {
606 // Before the CA hears anything: can the provider publish the challenge
607 // at all? A parent whose zone lives elsewhere (per-account platform
608 // parents on a deployment whose apps domain is not in the provider's
609 // account, the case that found this) would otherwise place an order
610 // every hour, fail at present, and deactivate it again — no harm to the
611 // rate limits, since the authorization is deactivated and never
612 // validated, but a pointless round trip to the CA and a journal line per
613 // parent per hour. The lookup is the one present would make first.
614 if zc, ok := w.dns.(zoneChecker); ok {
615 if err := zc.checkZone(ctx, w.challengeFQDN(parent)); err != nil {
616 return fmt.Errorf("cannot publish the DNS-01 challenge: %w", err)
617 }
618 }
619 acctKey, err := w.accountKey(ctx)
620 if err != nil {
621 return fmt.Errorf("account key: %v", err)
622 }
623 client := &acme.Client{Key: acctKey, DirectoryURL: w.directory}
624 if _, err := client.Register(ctx, &acme.Account{}, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists {
625 return fmt.Errorf("acme register: %v", err)
626 }
627
628 order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: "*." + parent}})
629 if err != nil {
630 return fmt.Errorf("authorize order: %v", err)
631 }
632 for _, zurl := range order.AuthzURLs {
633 if err := w.solveDNS01(ctx, client, parent, zurl); err != nil {
634 return err
635 }
636 }
637
638 certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
639 if err != nil {
640 return err
641 }
642 csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
643 Subject: pkix.Name{CommonName: "*." + parent},
644 DNSNames: []string{"*." + parent},
645 }, certKey)
646 if err != nil {
647 return fmt.Errorf("csr: %v", err)
648 }
649 ders, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
650 if err != nil {
651 return fmt.Errorf("finalize: %v", err)
652 }
653 leaf, err := x509.ParseCertificate(ders[0])
654 if err != nil {
655 return fmt.Errorf("parse issued cert: %v", err)
656 }
657 cert := &tls.Certificate{Certificate: ders, PrivateKey: certKey, Leaf: leaf}
658 // Paranoia: never store what certFor would refuse to serve.
659 if !coversParent(cert, parent) {
660 return fmt.Errorf("issued cert does not cover *.%s", parent)
661 }
662 raw, err := encodeWildcardPEM(cert, certKey)
663 if err != nil {
664 return fmt.Errorf("encode: %v", err)
665 }
666 if err := w.cache.Put(ctx, wildcardPEMKey(parent), raw); err != nil {
667 return fmt.Errorf("persist: %v", err)
668 }
669 w.mu.Lock()
670 w.certs[parent] = cert
671 w.mu.Unlock()
672 return nil
673}
674
675// solveDNS01 answers one authorization: publish the challenge TXT record,
676// wait for the CA to see it, and always clean the record up afterwards.
677func (w *wildcardManager) solveDNS01(ctx context.Context, client *acme.Client, parent, authzURL string) (retErr error) {
678 authz, err := client.GetAuthorization(ctx, authzURL)
679 if err != nil {
680 return fmt.Errorf("get authorization: %v", err)
681 }
682 if authz.Status == acme.StatusValid {
683 return nil // a recent order already proved this name
684 }
685 // On failure, deactivate the pending authorization rather than abandoning
686 // it — pending authzs count against LE's per-account cap (autocert does
687 // the same "to reduce rate limit quotas"). Best-effort on a fresh context.
688 defer func() {
689 if retErr == nil || authz.Status != acme.StatusPending {
690 return
691 }
692 dctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
693 defer cancel()
694 if err := client.RevokeAuthorization(dctx, authz.URI); err != nil {
695 log.Printf("edge: wildcard *.%s: deactivate abandoned authorization: %v", parent, err)
696 }
697 }()
698 var chal *acme.Challenge
699 for _, c := range authz.Challenges {
700 if c.Type == "dns-01" {
701 chal = c
702 break
703 }
704 }
705 if chal == nil {
706 return errors.New("CA offered no dns-01 challenge")
707 }
708 txt, err := client.DNS01ChallengeRecord(chal.Token)
709 if err != nil {
710 return err
711 }
712
713 challengeFQDN := w.challengeFQDN(parent)
714 cleanup, err := w.dns.present(ctx, challengeFQDN, txt)
715 if err != nil {
716 return fmt.Errorf("publish challenge TXT: %v", err)
717 }
718 // Clean up with a fresh context: the record must not outlive the attempt
719 // even when ctx is already dead (stale challenge TXTs break the NEXT run).
720 defer func() {
721 cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
722 defer cancel()
723 if err := cleanup(cctx); err != nil {
724 log.Printf("edge: wildcard *.%s: cleanup of challenge TXT failed: %v", parent, err)
725 }
726 }()
727
728 // Best-effort: give the record a moment to reach the authoritative NS
729 // before telling the CA to look. Proceed on timeout — the CA does its own
730 // retries, and a hard failure just comes back through the retry loop.
731 waitForTXT(ctx, challengeFQDN, txt, time.Minute)
732
733 if _, err := client.Accept(ctx, chal); err != nil {
734 return fmt.Errorf("accept challenge: %v", err)
735 }
736 if _, err := client.WaitAuthorization(ctx, authz.URI); err != nil {
737 return fmt.Errorf("authorization: %v", err)
738 }
739 return nil
740}
741
742// wildcardAccountKeyName is the ACME account key's cache key — Eleven's file
743// name, for the same no-migration reason as wildcardPEMKey.
744const wildcardAccountKeyName = "wildcard-account.key"
745
746// accountKey loads (or mints) the ACME account key. Separate from autocert's
747// account on purpose: this flow manages its own registration lifecycle.
748func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, error) {
749 raw, err := w.cache.Get(ctx, wildcardAccountKeyName)
750 if err == nil {
751 block, _ := pem.Decode(raw)
752 if block == nil {
753 return nil, fmt.Errorf("%s: not PEM", wildcardAccountKeyName)
754 }
755 return x509.ParseECPrivateKey(block.Bytes)
756 }
757 if !errors.Is(err, autocert.ErrCacheMiss) {
758 // Only a genuine miss mints a key: treating a backend fault as "no
759 // key yet" would register a fresh ACME account on every hiccup. Fail
760 // and let obtain()'s hourly retry come back around.
761 return nil, err
762 }
763 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
764 if err != nil {
765 return nil, err
766 }
767 der, err := x509.MarshalECPrivateKey(key)
768 if err != nil {
769 return nil, err
770 }
771 // Crash-safety (temp + rename, 0600) is the cache backend's contract —
772 // the dir backend inherits it from autocert.DirCache.
773 raw = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
774 if err := w.cache.Put(ctx, wildcardAccountKeyName, raw); err != nil {
775 return nil, err
776 }
777 return key, nil
778}
779
780// newWildcardFromEnv builds the manager from the environment, or returns nil
781// when wildcards are off: no CARLOS_WILDCARD_DOMAINS and no
782// PLATFORM_APPS_DOMAIN (nothing to cover — silent, the historical default),
783// or no DNS provider configured (logged: half a config deserves a reason in
784// the journal). nil means RunWithActivator wires nothing and the edge
785// behaves exactly as before this feature existed.
786func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
787 static := splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS"))
788 appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
789 if len(static) == 0 && appsDomain == "" {
790 return nil
791 }
792 // Steering (spec Part 4, steeringprobe.go): the deployment's Route53
793 // health checks handshake with SNI edge.<apps-domain>, which is one label
794 // under the apps domain — one label SHALLOWER than the per-account
795 // parents this manager derives, so nothing here covered it and every
796 // check failed at the handshake, withdrawing every record it was meant to
797 // protect. Adding the apps domain itself as a parent is the whole fix,
798 // and it is exactly what a static parent IS (CARLOS_WILDCARD_DOMAINS
799 // carries go.tito.io for the same reason: a domain whose one-label-deep
800 // hosts need certs).
801 //
802 // DNS-01 through this manager, rather than a narrow per-host exception
803 // for the one name, for three reasons — and NOT because a per-host order
804 // could not complete (an earlier draft of this comment claimed that and
805 // was wrong: autocert orders under context.Background() with a 5-minute
806 // budget, autocert.go:274, so a health checker hanging up on the
807 // handshake does not cancel the order it triggered):
808 //
809 // 1. the exception would have to breach the Allowed rule (PR #180) in
810 // TWO places, not one: issuerHostPolicy's registry check, and the
811 // mirror's m.allowed bound on the live HTTP-01 read-through
812 // (certsync.go's challengeResponse). Allowed is deliberately
813 // exact-registry-only, and it is what keeps an attacker-chosen name
814 // from turning into an order or a bucket read;
815 // 2. once a deployment steers, the name resolves to EVERY armed edge, so
816 // the CA reaches an arbitrary one and per-host issuance would hang on
817 // the cross-box challenge mirror for a name that is deployment
818 // infrastructure rather than a member's route;
819 // 3. every order attempt is paid for in live failed health checks (the
820 // probe fails until the cert exists, and the records it gates are
821 // withdrawn meanwhile) and in the shared ACME account's
822 // failed-validation budget. The wildcard path spends neither: it
823 // answers in our own zone, on its own schedule, and needs no A record
824 // to exist first — which matters, because the record only appears
825 // once the converger runs.
826 //
827 // Gated on the steering zone, not on the apps domain: both production
828 // deployments set an apps domain today and neither steers, and minting an
829 // unasked-for *.<apps-domain> on them (a real ACME order, a real CT log
830 // entry, and a change to which cert their existing one-label hosts are
831 // served from) is not something a dormant feature gets to do.
832 //
833 // One posture change to state outright, because it is what this parent
834 // COSTS: a stranger's one-label unknown host under the apps domain
835 // (anything.<apps-domain>) now COMPLETES the TLS handshake — certFor
836 // serves the wildcard before HostPolicy is ever consulted — and reaches
837 // the ordinary unknown-host answer: since #174 the pending page's 503
838 // (pending.go), which gives up to the not-found text or the -fallback
839 // URL after its retry window; before that a 404 or a -fallback 302. Before this
840 // parent existed it failed at the handshake instead, because autocert's
841 // policy refuses a name that is not a registry route. Nothing is
842 // disclosed that a routed host does not already disclose, and every
843 // per-ACCOUNT parent has had exactly this property since wildcards
844 // landed — but it is a real change on a steering deployment and belongs
845 // here beside the CT-log entry, not only in a review thread.
846 //
847 // The parent is derived from e.steeringHost, NOT from this function's own
848 // appsDomain read: New() normalized the environment once (edge.go,
849 // steeringProbeHostFromEnv — which trims a trailing dot, and the Getenv
850 // above does not). Two reads meant PLATFORM_APPS_DOMAIN "oncarlos.com."
851 // answered probes on edge.oncarlos.com while arming a parent
852 // "oncarlos.com." that certFor's CutSuffix could never match: no cert,
853 // every health check failing, on the one name the box promises to serve.
854 // One value, one normalization, both halves.
855 if e.steeringHost != "" {
856 static = append(static, strings.TrimPrefix(e.steeringHost, "edge."))
857 }
858 dns, reason := dnsProviderFromEnv()
859 if dns == nil {
860 log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason)
861 return nil
862 }
863 // Delegated parents (spec §4): the SOURCE is claim-derived and lands
864 // with the claim-derivation pass (Plan C); until then the zone is
865 // configured but the set is empty — inert by construction.
866 rawZone := os.Getenv("CARLOS_ACME_DELEGATION_ZONE")
867 zone := strings.ToLower(strings.TrimSpace(rawZone))
868 if zone != "" && !provision.ValidHostShape(zone) {
869 // Degrade, don't die: the edge must still boot on a bad zone value.
870 // Delegation goes off; per-host and direct (own-zone) DNS-01 keep
871 // covering everyone else.
872 log.Printf("edge: CARLOS_ACME_DELEGATION_ZONE %q invalid — delegation off, per-host and direct DNS-01 continue", rawZone)
873 zone = ""
874 }
875 // Delegated source: the claim-derivation pass (Task 3, internal/domains)
876 // writes wildcard_domains; refresh() reads it fresh on every call, same
877 // as e.deploymentHosts above. A read error returns the LAST successful
878 // list, not nil/empty: refresh() runs on startup, on OnReload, and on the
879 // cert mirror's and home map's wakes (run()'s 12h ticker never
880 // re-invokes the delegated source itself), so
881 // on a quiet box a transient error would otherwise empty the delegated
882 // set until some unrelated registry write next moves the fingerprint —
883 // unbounded, and it would also stop parentsFor from including a parent
884 // whose cert is already cached and valid (certFor iterates w.parents).
885 // Caching here is what makes the log line's "unchanged this pass"
886 // literally true.
887 var (
888 mu sync.Mutex
889 lastGood []string
890 )
891 delegated := func() []string {
892 wds, err := e.reg.ListWildcardDomains()
893 if err != nil {
894 mu.Lock()
895 out := append([]string(nil), lastGood...)
896 mu.Unlock()
897 log.Printf("edge: wildcard domains read failed: %v — delegated set unchanged this pass", err)
898 return unionDelegated(out, e.homes.Load().WildcardParents(), zone)
899 }
900 out := make([]string, 0, len(wds))
901 for _, w := range wds {
902 out = append(out, w.Host)
903 }
904 mu.Lock()
905 lastGood = out
906 mu.Unlock()
907 // Plus every delegated parent another box publishes (#305): the
908 // issuer minted its wildcard, the mirror put the PEM on this disk,
909 // and refresh's static/apps-domain precedence applies to these
910 // exactly as to local rows. Read-through, never folded into
911 // lastGood: the home map is its own last-good.
912 return unionDelegated(out, e.homes.Load().WildcardParents(), zone)
913 }
914 // Hosts are the DEPLOYMENT's (this box's routes plus every name another
915 // box homes, #300), so a region edge derives the account parents of apps
916 // it only ever backhauls to — and loads their mirrored wildcards.
917 w := newWildcardManager(static, appsDomain, e.deploymentHosts, delegated, zone, cache, dns)
918 // DNS-01 orders are gated on the same election per-host HTTP-01 orders
919 // are (certsync.go): one issuer per deployment, whichever protocol the
920 // name needs. The cache handle is already the publishing one, so the
921 // wildcard PEM and its ACME account key distribute with everything else.
922 w.issuer = e.issuerGate
923 return w
924}
925
926// wrapGetCertificate serves the wildcard for covered names and hands
927// everything else to inner (autocert's per-host path) — split out so the
928// fall-through contract is testable without a TLS listener.
929func wrapGetCertificate(w *wildcardManager, inner func(*tls.ClientHelloInfo) (*tls.Certificate, error)) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
930 return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
931 // A tls-alpn-01 validation handshake must reach autocert's challenge
932 // cert, never the wildcard — the CA is probing the per-host order the
933 // inner path holds.
934 if len(hello.SupportedProtos) == 1 && hello.SupportedProtos[0] == acme.ALPNProto {
935 return inner(hello)
936 }
937 if c := w.certFor(hello.ServerName); c != nil {
938 return c, nil
939 }
940 return inner(hello)
941 }
942}
943
944// waitForTXT polls DNS until name resolves to a TXT containing value, or the
945// deadline passes (best-effort — see caller).
946func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) {
947 deadline := time.Now().Add(timeout)
948 for time.Now().Before(deadline) && ctx.Err() == nil {
949 if txts, err := net.DefaultResolver.LookupTXT(ctx, name); err == nil {
950 for _, t := range txts {
951 if t == value {
952 return
953 }
954 }
955 }
956 time.Sleep(3 * time.Second)
957 }
958}
959