edge: serve wildcard certs from the TLS path
GetCertificate tries the wildcard first and falls through to autocert per-host HTTP-01; OnReload recomputes parents on every route swap so a new account's wildcard starts issuing the moment its first route lands. Unconfigured = the pre-wildcard edge, byte for byte.
3 files changed,
+179
−6
internal/edge/edge.go+51 −6internal/edge/wildcard.go+33 −0internal/edge/wildcard_test.go+95 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index d4835aabd..88308d2d9 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -10,9 +10,10 @@ |
| // additions carried in: -dev (plain-HTTP mode, so multi-instance routing runs |
| // locally with no DNS/TLS) and the first-observed-fingerprint reload. Dropped |
| // on port: titogo's Stripe relay, auth/identity, root-domain platformIndex; |
| -// Eleven's mesh and wildcard DNS-01 certs. New here: TCP upstreams (Route.Addr) |
| -// so whole-app tenants sit behind the edge without unix-socket support, and |
| -// the static serving class stub (static.go). |
| +// Eleven's mesh (its wildcard DNS-01 certs were later ported back — |
| +// wildcard.go). New here: TCP upstreams (Route.Addr) so whole-app tenants sit |
| +// behind the edge without unix-socket support, and the static serving class |
| +// stub (static.go). |
| package edge |
| import ( |
| @@ -117,6 +118,11 @@ type Edge struct { |
| // line per request. Lazily created under purgeLogMu. |
| purgeLogMu sync.Mutex |
| purgeLogN map[string]int |
| + // reloadHook, when set (OnReload), runs after every successful route |
| + // swap — the wildcard manager recomputes its parent set from it. Atomic |
| + // because registration happens in RunWithActivator after Watch may |
| + // already be reloading. |
| + reloadHook atomic.Pointer[func()] |
| } |
| // upstream is one served host: the handler for its backend, plus — when the |
| @@ -318,9 +324,18 @@ func (e *Edge) Reload() error { |
| if e.act != nil { |
| e.act.RetireExcept(managed) |
| } |
| + if fn := e.reloadHook.Load(); fn != nil { |
| + (*fn)() |
| + } |
| return nil |
| } |
| +// OnReload registers fn to run after every successful Reload — after the new |
| +// route set is swapped in, so fn sees the post-reload table via current(). |
| +func (e *Edge) OnReload(fn func()) { |
| + e.reloadHook.Store(&fn) |
| +} |
| + |
| func (e *Edge) current() map[string]upstream { |
| if p := e.upstreams.Load(); p != nil { |
| return *p |
| @@ -328,6 +343,26 @@ func (e *Edge) current() map[string]upstream { |
| return nil |
| } |
| +// currentHosts lists the hosts of PLATFORM-minted routes only |
| +// (upstream.route.Platform == true) — the wildcard manager's view of the |
| +// route table for parent derivation. Not every served host: parents are |
| +// derived from these hosts, and if a custom domain shaped like |
| +// x.y.<apps-domain> ever entered the route table, it would mint a wildcard |
| +// parent and burn a Let's Encrypt issuance on something the wildcard was |
| +// never meant to cover. Platform-minted app routes set Platform:true; static |
| +// parents (e.g. tito's go.tito.io) come from CARLOS_WILDCARD_DOMAINS via env, |
| +// so they are unaffected by this filter. |
| +func (e *Edge) currentHosts() []string { |
| + m := e.current() |
| + out := make([]string, 0, len(m)) |
| + for h, up := range m { |
| + if up.route.Platform { |
| + out = append(out, h) |
| + } |
| + } |
| + return out |
| +} |
| + |
| // Allowed reports whether host is configured — used as the autocert |
| // HostPolicy, so a certificate is only ever requested for a host we serve. |
| func (e *Edge) Allowed(host string) bool { |
| @@ -1042,10 +1077,20 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| return fmt.Errorf("acme: host %q not in registry", host) |
| }, |
| } |
| - // Per-host HTTP-01 only for now. When per-tenant subdomains outgrow the |
| - // Let's Encrypt per-domain rate limit (~50 new certs/week), Eleven's |
| - // wildcard.go (ACME DNS-01) drops in here, wrapping tlsCfg.GetCertificate. |
| tlsCfg := m.TLSConfig() |
| + // Wildcard certs (wildcard.go, ACME DNS-01): covered names — one label |
| + // under a per-account or configured parent — are served the wildcard; |
| + // everything else (custom domains, canary hosts, parents not yet |
| + // obtained) stays on autocert's per-host HTTP-01 above. nil = not |
| + // configured = exactly the pre-wildcard edge. |
| + if w := newWildcardFromEnv(e, certCache); w != nil { |
| + tlsCfg.GetCertificate = wrapGetCertificate(w, tlsCfg.GetCertificate) |
| + e.OnReload(w.refresh) |
| + w.refresh() |
| + go w.run() |
| + log.Printf("edge: wildcard certs on (static %v, apps domain %q)", |
| + splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN")) |
| + } |
| // The non-ACME :80 handler. Known hosts get the classic redirect to https |
| // (GET/HEAD only, 302); unknown hosts go to the fallback URL instead of a |
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index 41d0b9f8f..d3d7a2988 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -37,6 +37,7 @@ import ( |
| "fmt" |
| "log" |
| "net" |
| + "os" |
| "sort" |
| "strings" |
| "sync" |
| @@ -514,6 +515,38 @@ func (w *wildcardManager) accountKey(ctx context.Context) (*ecdsa.PrivateKey, er |
| return key, nil |
| } |
| +// newWildcardFromEnv builds the manager from the environment, or returns nil |
| +// when wildcards are off: no CARLOS_WILDCARD_DOMAINS and no |
| +// PLATFORM_APPS_DOMAIN (nothing to cover — silent, the historical default), |
| +// or no DNS provider configured (logged: half a config deserves a reason in |
| +// the journal). nil means RunWithActivator wires nothing and the edge |
| +// behaves exactly as before this feature existed. |
| +func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| + static := splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")) |
| + appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN"))) |
| + if len(static) == 0 && appsDomain == "" { |
| + return nil |
| + } |
| + dns, reason := dnsProviderFromEnv() |
| + if dns == nil { |
| + log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason) |
| + return nil |
| + } |
| + return newWildcardManager(static, appsDomain, e.currentHosts, cache, dns) |
| +} |
| + |
| +// wrapGetCertificate serves the wildcard for covered names and hands |
| +// everything else to inner (autocert's per-host path) — split out so the |
| +// fall-through contract is testable without a TLS listener. |
| +func wrapGetCertificate(w *wildcardManager, inner func(*tls.ClientHelloInfo) (*tls.Certificate, error)) func(*tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + if c := w.certFor(hello.ServerName); c != nil { |
| + return c, nil |
| + } |
| + return inner(hello) |
| + } |
| +} |
| + |
| // waitForTXT polls DNS until name resolves to a TXT containing value, or the |
| // deadline passes (best-effort — see caller). |
| func waitForTXT(ctx context.Context, name, value string, timeout time.Duration) { |
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go| index 97e2ff9d8..e16e6bf2c 100644 |
| --- a/internal/edge/wildcard_test.go |
| +++ b/internal/edge/wildcard_test.go |
| @@ -15,6 +15,8 @@ import ( |
| "time" |
| "golang.org/x/crypto/acme/autocert" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| // selfSignedWildcard mints a throwaway *.parent cert valid for the given |
| @@ -315,3 +317,96 @@ func TestPerParentBackoff(t *testing.T) { |
| t.Fatal("nextAttempt deleted on success: backingOff should be false") |
| } |
| } |
| + |
| +func TestWrappedGetCertificateFallsThrough(t *testing.T) { |
| + w := newWildcardManager(nil, "oncarlos.com", |
| + func() []string { return []string{"kass.abs.oncarlos.com"} }, |
| + autocert.DirCache(t.TempDir()), &fakeDNS{}) |
| + w.refresh() |
| + cert, _ := selfSignedWildcard(t, "abs.oncarlos.com", time.Now().Add(60*24*time.Hour)) |
| + w.mu.Lock() |
| + w.certs["abs.oncarlos.com"] = cert |
| + w.mu.Unlock() |
| + |
| + fallbackHit := "" |
| + inner := func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + fallbackHit = hello.ServerName |
| + return nil, errors.New("inner path") |
| + } |
| + wrapped := wrapGetCertificate(w, inner) |
| + |
| + got, err := wrapped(&tls.ClientHelloInfo{ServerName: "new.abs.oncarlos.com"}) |
| + if err != nil || got != cert { |
| + t.Fatalf("covered name: got (%v, %v), want the wildcard", got, err) |
| + } |
| + if fallbackHit != "" { |
| + t.Fatalf("covered name reached the inner path (%q)", fallbackHit) |
| + } |
| + if _, err := wrapped(&tls.ClientHelloInfo{ServerName: "custom.example.com"}); err == nil || fallbackHit != "custom.example.com" { |
| + t.Fatalf("uncovered name must fall through to inner; fallbackHit=%q err=%v", fallbackHit, err) |
| + } |
| +} |
| + |
| +func TestNewWildcardFromEnv(t *testing.T) { |
| + e := &Edge{} |
| + e.upstreams.Store(&map[string]upstream{}) |
| + cache := autocert.DirCache(t.TempDir()) |
| + |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| + t.Setenv("PLATFORM_APPS_DOMAIN", "") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "") |
| + t.Setenv("CARLOS_DNSIMPLE_TOKEN", "") |
| + if w := newWildcardFromEnv(e, cache); w != nil { |
| + t.Fatal("nothing configured: want nil (per-host only, the historical default)") |
| + } |
| + // Domains but no provider → off (logged), never a broken half-config. |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io") |
| + if w := newWildcardFromEnv(e, cache); w != nil { |
| + t.Fatal("domains without provider: want nil") |
| + } |
| + t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok") |
| + w := newWildcardFromEnv(e, cache) |
| + if w == nil { |
| + t.Fatal("configured: want a manager") |
| + } |
| + if !reflect.DeepEqual(w.staticDomains, []string{"go.tito.io"}) { |
| + t.Fatalf("staticDomains = %v", w.staticDomains) |
| + } |
| +} |
| + |
| +func TestOnReloadHookFires(t *testing.T) { |
| + reg := openReg(t) // edge_test.go's existing registry fixture |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fired := 0 |
| + e.OnReload(func() { fired++ }) |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if fired != 1 { |
| + t.Fatalf("hook fired %d times, want 1", fired) |
| + } |
| +} |
| + |
| +// TestCurrentHostsPlatformOnly guards the wildcard-safety filter: only |
| +// PLATFORM-minted routes (Platform==true) contribute to currentHosts, since |
| +// parentsFor derives wildcard parents from that list. A custom domain |
| +// shaped like <x>.<y>.<apps-domain> that somehow landed in the route table |
| +// (Platform==false) must never mint a wildcard parent and burn an ACME |
| +// issuance for it — only the routes the domains-reconcile pass itself |
| +// minted are trusted for that. |
| +func TestCurrentHostsPlatformOnly(t *testing.T) { |
| + e := &Edge{} |
| + e.upstreams.Store(&map[string]upstream{ |
| + "app.acct.oncarlos.com": {route: registry.Route{Host: "app.acct.oncarlos.com", Platform: true}}, |
| + "custom.example.com": {route: registry.Route{Host: "custom.example.com", Platform: false}}, |
| + "evil.acct2.oncarlos.com": {route: registry.Route{Host: "evil.acct2.oncarlos.com", Platform: false}}, |
| + }) |
| + got := e.currentHosts() |
| + want := []string{"app.acct.oncarlos.com"} |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("currentHosts = %v, want %v", got, want) |
| + } |
| +} |