carlos / platform Public

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.
Paul Campbell cc08baf50f864a19d8c9a19696067e7071250cf5 parent f6a763b
3 files changed, +179 −6
  • internal/edge/edge.go +51 −6
  • internal/edge/wildcard.go +33 −0
  • internal/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)
+ }
+}