| 1 | package 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 | |
| 28 | import ( |
| 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 | |
| 57 | const 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. |
| 61 | const wildcardRenewBefore = 30 * 24 * time.Hour |
| 62 | |
| 63 | type 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 | |
| 118 | func 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. |
| 142 | const 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. |
| 148 | func 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. |
| 182 | var ( |
| 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. |
| 203 | func 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. |
| 244 | func (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). |
| 304 | func (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. |
| 314 | func (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 | |
| 336 | func (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. |
| 347 | func (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). |
| 358 | func (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). |
| 385 | func 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). |
| 404 | func (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. |
| 412 | func (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. |
| 421 | func 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 | |
| 428 | func 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 | |
| 445 | func 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). |
| 479 | func (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. |
| 493 | func (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). |
| 573 | func (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. |
| 585 | func (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). |
| 601 | const noZoneRetry = 12 * time.Hour |
| 602 | |
| 603 | // obtain runs one full ACME DNS-01 issuance for *.parent and swaps the |
| 604 | // result in. |
| 605 | func (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. |
| 677 | func (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. |
| 744 | const 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. |
| 748 | func (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. |
| 786 | func 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. |
| 929 | func 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). |
| 946 | func 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 | |