| new file mode 100644 |
| index 000000000..5bc8d5326 |
| --- /dev/null |
| +++ b/internal/edge/wildcard.go |
| @@ -0,0 +1,254 @@ |
| +package edge |
| + |
| +// Wildcard TLS (ACME DNS-01) for instant app hostnames — the port of |
| +// Eleven's wildcard.go this file's TLS wiring always reserved space for |
| +// (spec: docs/superpowers/specs/2026-08-05-wildcard-certs-design.md). |
| +// |
| +// A TLS wildcard matches exactly one label, and app hosts are two labels |
| +// under the apps domain (<app>.<acct>.<apps-domain>), so there is no single |
| +// *.<apps-domain> cert. Instead one wildcard per PARENT: |
| +// |
| +// - dynamic parents <acct>.<apps-domain>, derived from the live route |
| +// table under PLATFORM_APPS_DOMAIN — a new account's first route starts |
| +// issuance immediately, so every later app under it has working TLS the |
| +// moment its route exists (and CT logs see the account, not app names); |
| +// - static parents from CARLOS_WILDCARD_DOMAINS (tito: go.tito.io, whose |
| +// instance hosts are one label deep). |
| +// |
| +// Canary hosts (<name>.<app>.<acct>.…, one label too deep) and custom |
| +// domains stay per-host HTTP-01. The wildcard only changes how a handshake |
| +// GETS a certificate: routing, HostPolicy and the :80 handler are |
| +// untouched, and every failure (DNS provider outage, ACME outage, |
| +// unreadable cache) degrades to exactly the per-host behaviour — never a |
| +// dead edge. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdsa" |
| + "crypto/tls" |
| + "crypto/x509" |
| + "encoding/pem" |
| + "errors" |
| + "log" |
| + "sort" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "golang.org/x/crypto/acme/autocert" |
| +) |
| + |
| +const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory" |
| + |
| +// wildcardRenewBefore is how close to expiry we re-issue. LE certs live 90 |
| +// days; renewing with 30 left tolerates a month of provider outage. |
| +const wildcardRenewBefore = 30 * 24 * time.Hour |
| + |
| +type wildcardManager struct { |
| + // cache is the shared cert cache (certcache.go) — the same backend |
| + // autocert uses for per-host certs, so a future pooled-edge S3 backend |
| + // shares wildcards too. Keys: wildcardPEMKey / wildcardAccountKeyName. |
| + cache autocert.Cache |
| + dns dnsProvider |
| + directory string |
| + |
| + staticDomains []string |
| + appsDomain string |
| + hosts func() []string // live route hosts (Edge.currentHosts) |
| + |
| + mu sync.Mutex |
| + parents []string // last computed parent set (sorted) |
| + certs map[string]*tls.Certificate |
| + |
| + // wake nudges run() out of its 12h sleep when refresh() finds a parent |
| + // with no usable cert (a new account's first route). Capacity 1: a wake |
| + // is a level, not a count. |
| + wake chan struct{} |
| +} |
| + |
| +func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, cache autocert.Cache, dns dnsProvider) *wildcardManager { |
| + return &wildcardManager{ |
| + cache: cache, |
| + dns: dns, |
| + directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory), |
| + staticDomains: staticDomains, |
| + appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)), |
| + hosts: hosts, |
| + certs: map[string]*tls.Certificate{}, |
| + wake: make(chan struct{}, 1), |
| + } |
| +} |
| + |
| +// parentsFor computes the wildcard parent set: the static domains plus |
| +// <acct>.<appsDomain> for every route host exactly two labels under |
| +// appsDomain (<app>.<acct>.<appsDomain>). Canary hosts (three labels), the |
| +// apex, and foreign domains contribute nothing. Sorted and deduped so the |
| +// set is deterministic for logs and tests. |
| +func parentsFor(static []string, appsDomain string, hosts []string) []string { |
| + seen := map[string]bool{} |
| + var out []string |
| + add := func(p string) { |
| + if !seen[p] { |
| + seen[p] = true |
| + out = append(out, p) |
| + } |
| + } |
| + for _, d := range static { |
| + add(strings.ToLower(d)) |
| + } |
| + if appsDomain != "" { |
| + suffix := "." + strings.ToLower(appsDomain) |
| + for _, h := range hosts { |
| + rest, ok := strings.CutSuffix(strings.ToLower(h), suffix) |
| + if !ok { |
| + continue |
| + } |
| + if labels := strings.Split(rest, "."); len(labels) == 2 { |
| + add(labels[1] + suffix) |
| + } |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// refresh recomputes the parent set from the live route table (called from |
| +// the reload hook on every route swap, and once at startup). If any parent |
| +// lacks a usable cert it wakes run() — that is what makes a NEW account's |
| +// wildcard start issuing the moment its first route lands, instead of on |
| +// the next 12h tick. |
| +func (w *wildcardManager) refresh() { |
| + parents := parentsFor(w.staticDomains, w.appsDomain, w.hosts()) |
| + now := time.Now() |
| + w.mu.Lock() |
| + w.parents = parents |
| + need := false |
| + for _, p := range parents { |
| + c := w.certs[p] |
| + if c == nil || now.Add(wildcardRenewBefore).After(c.Leaf.NotAfter) { |
| + need = true |
| + break |
| + } |
| + } |
| + w.mu.Unlock() |
| + if need { |
| + select { |
| + case w.wake <- struct{}{}: |
| + default: |
| + } |
| + } |
| +} |
| + |
| +// certFor returns the wildcard cert for serverName, or nil when the name |
| +// isn't exactly one label under a current parent (a wildcard doesn't cover |
| +// the apex or deeper levels) or no usable cert is loaded — nil sends the |
| +// handshake to the per-host autocert path. |
| +func (w *wildcardManager) certFor(serverName string) *tls.Certificate { |
| + sn := strings.ToLower(strings.TrimSuffix(serverName, ".")) |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + for _, p := range w.parents { |
| + sub, ok := strings.CutSuffix(sn, "."+p) |
| + if !ok || sub == "" || strings.Contains(sub, ".") { |
| + continue |
| + } |
| + cert := w.certs[p] |
| + if cert == nil || time.Now().After(cert.Leaf.NotAfter) { |
| + // Never serve a dead cert: a wildcard whose renewal has been |
| + // broken past its actual expiry falls back to per-host issuance |
| + // (the pre-wildcard behaviour) instead of hard-failing every |
| + // handshake under the parent. |
| + return nil |
| + } |
| + return cert |
| + } |
| + return nil |
| +} |
| + |
| +func (w *wildcardManager) needsObtain(parent string, now time.Time) bool { |
| + w.mu.Lock() |
| + cert := w.certs[parent] |
| + w.mu.Unlock() |
| + return cert == nil || now.Add(wildcardRenewBefore).After(cert.Leaf.NotAfter) |
| +} |
| + |
| +// loadCached pulls parent's wildcard from the cert cache into memory, |
| +// reporting whether a parseable cert was loaded. Called by run() before any |
| +// obtain attempt, which covers both startup (certs an earlier process |
| +// obtained) and a pooled-edge future (certs a sibling box obtained). |
| +func (w *wildcardManager) loadCached(ctx context.Context, parent string) bool { |
| + raw, err := w.cache.Get(ctx, wildcardPEMKey(parent)) |
| + if errors.Is(err, autocert.ErrCacheMiss) { |
| + return false |
| + } |
| + if err == nil { |
| + var cert *tls.Certificate |
| + if cert, err = parseWildcardPEM(raw); err == nil { |
| + w.mu.Lock() |
| + w.certs[parent] = cert |
| + w.mu.Unlock() |
| + log.Printf("edge: wildcard *.%s loaded from cache (valid until %s)", parent, cert.Leaf.NotAfter.Format(time.RFC3339)) |
| + return true |
| + } |
| + } |
| + log.Printf("edge: wildcard *.%s: cached cert unreadable (%v) — will re-obtain", parent, err) |
| + return false |
| +} |
| + |
| +// wildcardPEMKey is the cache key for a parent's wildcard cert+key PEM — |
| +// Eleven's file name, so a dir cache written by that lineage reads with no |
| +// migration, and the keys sit additively beside autocert's per-host entries. |
| +func wildcardPEMKey(domain string) string { |
| + return "wildcard-" + domain + ".pem" |
| +} |
| + |
| +// ---- PEM encode/parse (key + chain in one blob; the cert cache owns |
| +// placement, permissions, and write atomicity) ------------------------------- |
| + |
| +func encodeWildcardPEM(cert *tls.Certificate, key *ecdsa.PrivateKey) ([]byte, error) { |
| + var buf bytes.Buffer |
| + der, err := x509.MarshalECPrivateKey(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil { |
| + return nil, err |
| + } |
| + for _, c := range cert.Certificate { |
| + if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: c}); err != nil { |
| + return nil, err |
| + } |
| + } |
| + return buf.Bytes(), nil |
| +} |
| + |
| +func parseWildcardPEM(raw []byte) (*tls.Certificate, error) { |
| + cert := &tls.Certificate{} |
| + for { |
| + var block *pem.Block |
| + block, raw = pem.Decode(raw) |
| + if block == nil { |
| + break |
| + } |
| + switch block.Type { |
| + case "EC PRIVATE KEY": |
| + key, err := x509.ParseECPrivateKey(block.Bytes) |
| + if err != nil { |
| + return nil, err |
| + } |
| + cert.PrivateKey = key |
| + case "CERTIFICATE": |
| + cert.Certificate = append(cert.Certificate, block.Bytes) |
| + } |
| + } |
| + if cert.PrivateKey == nil || len(cert.Certificate) == 0 { |
| + return nil, errors.New("missing key or certificate in PEM") |
| + } |
| + leaf, err := x509.ParseCertificate(cert.Certificate[0]) |
| + if err != nil { |
| + return nil, err |
| + } |
| + cert.Leaf = leaf |
| + return cert, nil |
| +} |