carlos / platform Public

edge: the platform orders and renews custom-domain certs (#299 stage 3)

Per-host certs were minted lazily on the first TLS handshake. That is
fine for a name people visit from the box that serves it, and wrong for
a custom domain on a steering deployment, for two compounding reasons.

The first visitor pays a multi-second ACME order — and if the
customer's DNS only just landed, they are also the one who discovers it
failed.

Renewal is the worse half. autocert renews through per-host
domainRenewal timers that live in process memory. They die on every
fleet roll and re-arm only when a handshake reaches m.cert again. Under
latency steering a custom domain's visitors may never land on the
issuer, so after a roll NOTHING re-arms the timer and the cert quietly
expires at day 90 — on a host that was serving perfectly all along.

So the warm calls GetCertificate for every host in its set on EVERY
pass, unconditionally. For a healthy cert that is one cache read plus
the startRenew that re-arms the timer, and the re-arm is the entire
point: a skip-if-fresh check would defeat it, because m.cert returns
any unexpired cert and a cert 29 days from expiry is a cache hit.

The set is "exact hosts we serve that are not under the apps domain and
not wildcard-covered". That predicate is deliberately simpler than
"aliases": first-route claims mint a real ROUTE, so an alias-only rule
would never warm "carlos deploy -kind static -host <site>", the shipped
headline flow. Excluding the apps domain is what keeps a first run from
being a burst — canaries are three labels under it and no wildcard
covers them. Wildcard coverage is tested BY SHAPE, not by asking
certFor, which returns nil for a parent whose cert has not landed yet
and would warm every app of a new account per-host in exactly that
window.

New orders are DNS-gated, and ALL resolved answers must be ours: a name
mid-migration resolves both old and new, an any-match gate passes it,
and validation then fails from the CA's vantage anyway — burning the
shared account's failed-validation budget, which is the thing the gate
exists to protect. Renewal re-arms are NOT gated: they cost the CA
nothing, and gating them would strand the domain whose resolver is
briefly sick.

The synthetic hello advertises an ECDSA cipher suite, and that is
load-bearing rather than decoration. autocert picks the key type from
the hello; an empty one reads as RSA-only, so the warm would mint
<host>+rsa while every browser asks for <host> — order spent, name
uncovered, first visitor triggering a second issuance. Pinned against
the repo's own port of autocert's chooser so the two cannot drift.

Armed only with a deployment store, so an agent-less "carlos edge"
never starts placing proactive orders on a hobbyist's box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 6e6a2e276d7950701f5573fc5f6409a5a49a81be parent 0b4225f
4 files changed, +716 −0
  • internal/edge/certwarm.go +376 −0
  • internal/edge/certwarm_test.go +269 −0
  • internal/edge/edge.go +39 −0
  • internal/edge/homemap.go +32 −0
diff --git a/internal/edge/certwarm.go b/internal/edge/certwarm.go
new file mode 100644
index 000000000..018cbab10
--- /dev/null
+++ b/internal/edge/certwarm.go
@@ -0,0 +1,376 @@
+// Cert warm (#299 stage 3): the platform orders and RENEWS certificates for
+// the custom domains it serves, instead of waiting for a visitor's handshake
+// to trigger the first issuance.
+//
+// Why this exists at all. Per-host certs are minted lazily by autocert on the
+// first TLS handshake for a name. That is fine for a name people visit from
+// the box that serves it, and wrong for a custom domain on a steering
+// deployment, for two compounding reasons:
+//
+// - The FIRST visitor pays a multi-second ACME order, and if the customer's
+// DNS only just landed, they are also the one who discovers it failed.
+// - RENEWAL is worse. autocert renews through per-host `domainRenewal`
+// timers that live in process memory. They die on every fleet roll and
+// re-arm only when a handshake reaches m.cert again. Under latency
+// steering a custom domain's visitors may never land on the issuer, so
+// after a roll NOTHING re-arms the timer and the cert quietly expires at
+// day 90 — on a host that was serving perfectly the whole time.
+//
+// So the warm calls GetCertificate for every host in its set on EVERY pass,
+// unconditionally. That is not wasteful and it is not a retry loop: for a
+// healthy cert it is one cache read plus the startRenew that re-arms the
+// timer, and the re-arm is the entire point. A skip-if-fresh check would
+// defeat it — autocert's m.cert returns any unexpired cached cert, so a cert
+// 29 days from expiry is a cache hit and no renewal is ever scheduled.
+package edge
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "log"
+ "net"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/crypto/acme"
+)
+
+const (
+ // warmInterval is the quiet-path cadence. Renewal is the job with the
+ // real deadline (autocert renews at ~30 days remaining), so hourly is
+ // far more often than strictly needed — the cost of a pass over a
+ // healthy set is a cache read per host, and being early is free.
+ warmInterval = time.Hour
+ // warmBackoff is the per-host floor after a failed order. Matches the
+ // wildcard manager's hourly retry.
+ warmBackoff = time.Hour
+ // warmRateLimitBackoff is the floor after the CA says rateLimited.
+ // Deliberately much harder than warmBackoff: hourly retries INTO a rate
+ // limit are what turn a transient limit into a sustained one, and every
+ // host in the deployment shares the one ACME account's budget.
+ warmRateLimitBackoff = 12 * time.Hour
+ // warmOrdersPerPass bounds NEW orders per pass. Renewal re-arms are not
+ // orders and are never capped — they are the reason the pass exists.
+ // When the cap bites, pass() returns a short delay so a backlog drains
+ // in minutes rather than trickling out over days (the wildcard pass()
+ // precedent).
+ warmOrdersPerPass = 10
+ // warmCappedDelay is how soon to come back with capped work remaining.
+ warmCappedDelay = time.Minute
+)
+
+// certWarm proactively orders and renews per-host certificates.
+//
+// It is a sibling of wildcardManager, with three deliberate departures from
+// it, each of which is a bug if copied across:
+//
+// - No holding-context claim. The wildcard manager runs its own ACME
+// client, so it can hand the holding's context to the order. The warm's
+// trigger is autocert's GetCertificate, which takes NO context and builds
+// its own background one with a 5-minute timeout. So the gate is
+// re-checked BETWEEN orders instead, and a ≤5-minute overhang past a lost
+// holding is accepted — inside the double-holder envelope issuerHostPolicy
+// already documents. If the holding ends mid-order, the finishing
+// cachePut re-checks the gate and the cert lands local-only; the new
+// issuer re-orders it. Bounded waste, not a hazard.
+// - No cache-load-before-gate step. The wildcard manager loads cached certs
+// before standing down because it SERVES from its own map. The warm
+// serves nothing — per-host serving is the mirror plus autocert — so a
+// non-issuer scanning caches would be pure waste. It stands down at the
+// gate.
+// - The unconditional GetCertificate described in the package comment.
+type certWarm struct {
+ // hosts is the warm set: exact hostnames this deployment serves that are
+ // not under the apps domain and not covered by a wildcard. See
+ // Edge.warmSet.
+ hosts func() []string
+ // getCert is autocert's per-host path (NOT the wildcard-wrapped one — a
+ // wrapped call would be answered by the wildcard and order nothing).
+ getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error)
+ // pointsHere reports whether host's DNS resolves to this deployment.
+ // Ordering for a half-pointed name burns the shared account's
+ // failed-validation budget; see dnsGate.
+ pointsHere func(host string) bool
+ // issuer gates ORDERING, exactly as it gates the wildcard manager's.
+ issuer IssuerGate
+
+ mu sync.Mutex
+ // nextAttempt is the per-host backoff floor.
+ nextAttempt map[string]time.Time
+ // warmed is the set of hosts a cert has been obtained for at least once,
+ // so a first order can be told from a renewal re-arm for the order cap
+ // and for logging.
+ warmed map[string]bool
+
+ lastStandDownLog time.Time
+}
+
+func newCertWarm(hosts func() []string, getCert func(*tls.ClientHelloInfo) (*tls.Certificate, error), pointsHere func(string) bool, issuer IssuerGate) *certWarm {
+ return &certWarm{
+ hosts: hosts,
+ getCert: getCert,
+ pointsHere: pointsHere,
+ issuer: issuer,
+ nextAttempt: map[string]time.Time{},
+ warmed: map[string]bool{},
+ }
+}
+
+// issuerGate is this warm's read of the deployment's gate, ungated default
+// applied (see IssuerGate, certsync.go).
+func (c *certWarm) issuerGate() (context.Context, bool) {
+ if c.issuer == nil {
+ return context.Background(), true
+ }
+ return c.issuer()
+}
+
+func (c *certWarm) run() {
+ for {
+ time.Sleep(c.pass())
+ }
+}
+
+// pass warms every host in the set once and returns how long to wait.
+func (c *certWarm) pass() time.Duration {
+ hosts := c.hosts()
+ if len(hosts) == 0 {
+ return warmInterval
+ }
+ // Stand down before ANY work: unlike the wildcard manager there is no
+ // cache to load first, so a non-issuer has nothing useful to do here.
+ if _, ok := c.issuerGate(); !ok {
+ c.logStandDown(len(hosts))
+ return warmInterval
+ }
+ orders, capped := 0, false
+ for _, h := range hosts {
+ now := time.Now()
+ c.mu.Lock()
+ until, backing := c.nextAttempt[h]
+ seen := c.warmed[h]
+ c.mu.Unlock()
+ if backing && now.Before(until) {
+ continue
+ }
+ // The gate is re-checked BETWEEN orders, not once per pass: a pass
+ // over a large set can outlive a holding, and every order after the
+ // holding ends is an order this box has no authority to place.
+ if _, ok := c.issuerGate(); !ok {
+ c.logStandDown(len(hosts) - orders)
+ return warmInterval
+ }
+ // A host with no cert yet is a NEW order and is both capped and
+ // DNS-gated. A host already warmed is a renewal re-arm: uncapped and
+ // ungated, because it is a cache read that costs the CA nothing and
+ // re-arms the timer that renewal depends on. Gating the re-arm on DNS
+ // would strand exactly the domain whose resolver is briefly sick.
+ if !seen {
+ if orders >= warmOrdersPerPass {
+ capped = true
+ continue
+ }
+ if !c.pointsHere(h) {
+ continue
+ }
+ orders++
+ }
+ if err := c.warmOne(h); err != nil {
+ c.fail(h, err)
+ continue
+ }
+ c.mu.Lock()
+ delete(c.nextAttempt, h)
+ first := !c.warmed[h]
+ c.warmed[h] = true
+ c.mu.Unlock()
+ if first {
+ log.Printf("edge: cert warmed for %s", h)
+ }
+ }
+ if capped {
+ return warmCappedDelay
+ }
+ return warmInterval
+}
+
+// warmOne asks autocert for host's certificate, which mints it when absent
+// and re-arms its renewal timer when present.
+func (c *certWarm) warmOne(host string) error {
+ _, err := c.getCert(warmHello(host))
+ return err
+}
+
+// fail arms the per-host backoff, harder when the CA is rate-limiting.
+func (c *certWarm) fail(host string, err error) {
+ backoff := warmBackoff
+ var ae *acme.Error
+ if errors.As(err, &ae) && ae.ProblemType == "urn:ietf:params:acme:error:rateLimited" {
+ backoff = warmRateLimitBackoff
+ log.Printf("edge: cert warm for %s rate-limited by the CA — backing off %s (the whole deployment shares one ACME account)", host, backoff)
+ } else {
+ log.Printf("edge: cert warm for %s: %v — retrying in %s", host, err, backoff)
+ }
+ c.mu.Lock()
+ c.nextAttempt[host] = time.Now().Add(backoff)
+ c.mu.Unlock()
+}
+
+func (c *certWarm) logStandDown(hosts int) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if time.Since(c.lastStandDownLog) < time.Hour {
+ return
+ }
+ c.lastStandDownLog = time.Now()
+ log.Printf("edge: cert warm standing down for %d host(s) — another box holds the issuer lease", hosts)
+}
+
+// warmHello builds the synthetic ClientHelloInfo the warm hands autocert.
+//
+// The CipherSuites entry is LOAD-BEARING and must not be dropped as
+// decoration. autocert picks the key type from the hello via supportsECDSA
+// (ported here as helloSupportsECDSA): with a hello carrying no signature
+// schemes, no curves and no cipher suites, all three checks fall through to
+// false, autocert chooses certKey{isRSA: true}, and the warm mints an RSA
+// cert under "<host>+rsa" — while every real browser asks for "<host>" and
+// gets nothing. The order would be spent, the name still uncovered, and the
+// first visitor would trigger a SECOND issuance.
+//
+// One ECDSA suite is enough: helloSupportsECDSA's first two checks skip on
+// nil, and its cipher-suite loop returns true on this suite.
+func warmHello(host string) *tls.ClientHelloInfo {
+ return &tls.ClientHelloInfo{
+ ServerName: host,
+ CipherSuites: []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256},
+ }
+}
+
+// warmSet is the set of hostnames the cert warm orders and renews for:
+// EXACT names this deployment serves that are not under the apps domain and
+// not covered by a wildcard.
+//
+// The predicate is deliberately simpler than "aliases" or "custom domains".
+// A custom domain is a foreign domain by definition — the attach door refuses
+// apps-domain hosts outright — so "not under the apps domain" covers attached
+// aliases and first-route claims alike, with no need to reason about which
+// registry table a name arrived through. That matters: first-route claims
+// (`carlos deploy -kind static -host <site>`) mint a real ROUTE, not an
+// alias, so an alias-only rule would silently never warm the shipped headline
+// flow.
+//
+// What it excludes, and why each one:
+//
+// - "*." rows — covered by a wildcard PEM, and autocert refuses the
+// wildcard label anyway.
+// - anything under the apps domain — canary hosts and platform hosts. This
+// is the exclusion that keeps a first run from being a burst: canaries are
+// three labels under the apps domain, no wildcard parent covers them, and
+// a naive "everything we serve" set would order a cert AND a CT-log entry
+// for every one of them.
+// - anything one label under a CONFIGURED wildcard parent, tested BY SHAPE
+// against currentParents() rather than by asking certFor. certFor returns
+// nil for a parent whose cert has not been obtained yet, so a shape-blind
+// test would warm every app of a new account per-host during exactly the
+// window before their wildcard lands.
+func (e *Edge) warmSet(appsDomain string, parents func() []string) []string {
+ appsDomain = strings.ToLower(strings.TrimSpace(appsDomain))
+ covered := wildcardCoverer(parents)
+ seen := map[string]bool{}
+ var out []string
+ consider := func(h string) {
+ h = strings.ToLower(strings.TrimSpace(h))
+ if h == "" || seen[h] || strings.HasPrefix(h, "*.") {
+ return
+ }
+ if appsDomain != "" && (h == appsDomain || strings.HasSuffix(h, "."+appsDomain)) {
+ return
+ }
+ if covered(h) {
+ return
+ }
+ seen[h] = true
+ out = append(out, h)
+ }
+ for h := range e.current() {
+ consider(h)
+ }
+ for _, h := range e.homes.Load().ServedHosts() {
+ consider(h)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// wildcardCoverer reports whether a host is exactly one label under a
+// wildcard parent this box has configured or derived — by SHAPE, never by
+// whether the parent's cert exists yet (see warmSet). A nil parents func
+// covers nothing, which is the correct answer for a deployment with no
+// wildcard manager at all.
+func wildcardCoverer(parents func() []string) func(string) bool {
+ return func(host string) bool {
+ if parents == nil {
+ return false
+ }
+ for _, p := range parents() {
+ rest, ok := strings.CutSuffix(host, "."+p)
+ if ok && rest != "" && !strings.Contains(rest, ".") {
+ return true
+ }
+ }
+ return false
+ }
+}
+
+// dnsGate builds the "does this name point at us?" test the warm applies
+// before placing a NEW order.
+//
+// Let's Encrypt caps failed validations at 5 per account per hostname per
+// hour, and the ACME account is shared by every name this issuer mints. So a
+// half-configured customer domain that we order for anyway does not just fail
+// its own order — it spends budget every other tenant needs. The gate is what
+// keeps one customer's unfinished DNS from being everyone's problem.
+//
+// ALL resolved answers must be in the edge set, not merely one. A name in
+// mid-migration resolves to both the old host and ours; an any-match test
+// passes it, the order goes out, and validation fails from the CA's vantage
+// anyway — burning exactly the budget this gate exists to protect. Requiring
+// all of them means the gate only opens once the customer has actually
+// finished.
+//
+// An empty edge set fails CLOSED (no host points anywhere), matching the
+// domains pass's stand-down: a set we could not read is not evidence about
+// anybody's DNS, and the cost of waiting a pass is one hour of delay against
+// the cost of burning a shared rate limit.
+func dnsGate(resolve func(string) ([]string, error), edgeIPs func() []string) func(string) bool {
+ return func(host string) bool {
+ set := map[string]bool{}
+ for _, ip := range edgeIPs() {
+ set[ip] = true
+ }
+ if len(set) == 0 {
+ return false
+ }
+ ips, err := resolve(host)
+ if err != nil || len(ips) == 0 {
+ return false
+ }
+ for _, ip := range ips {
+ if !set[ip] {
+ return false
+ }
+ }
+ return true
+ }
+}
+
+// warmResolver is the DNS gate's lookup: the system resolver, bounded so a
+// hung one costs this host its turn in the pass and not the pass itself.
+func warmResolver(host string) ([]string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return net.DefaultResolver.LookupHost(ctx, host)
+}
diff --git a/internal/edge/certwarm_test.go b/internal/edge/certwarm_test.go
new file mode 100644
index 000000000..95aa55b75
--- /dev/null
+++ b/internal/edge/certwarm_test.go
@@ -0,0 +1,269 @@
+package edge
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/acme"
+)
+
+// THE trap this whole file exists to avoid. autocert picks the key type from
+// the hello. A hello with no signature schemes, no curves and no cipher
+// suites reads as "no ECDSA", so autocert mints an RSA cert under
+// "<host>+rsa" while every browser asks for "<host>" — the order spent, the
+// name still uncovered, and the first real visitor triggering a second
+// issuance.
+//
+// This pins the warm's hello against the repo's own port of autocert's
+// chooser, so the two cannot drift.
+func TestWarmHelloAsksForAnECDSACert(t *testing.T) {
+ if !helloSupportsECDSA(warmHello("app.example.com")) {
+ t.Fatal("the warm's hello reads as RSA-only — autocert would mint <host>+rsa and no browser would ever be served it")
+ }
+ // The bare hello is the bug, kept here so the test documents WHY the
+ // cipher-suite entry above is load-bearing rather than decorative.
+ if helloSupportsECDSA(&tls.ClientHelloInfo{ServerName: "app.example.com"}) {
+ t.Fatal("an empty hello now reads as ECDSA-capable — warmHello's cipher suite may no longer be needed, re-derive the trap before deleting it")
+ }
+}
+
+func TestWarmHelloCarriesTheServerName(t *testing.T) {
+ if got := warmHello("App.Example.com").ServerName; got != "App.Example.com" {
+ t.Fatalf("ServerName = %q", got)
+ }
+}
+
+// The warm set is "exact hosts we serve that are not under the apps domain
+// and not wildcard-covered". Each exclusion below is a separate bug if it
+// regresses.
+func TestWarmSetSelectsCustomDomainsOnly(t *testing.T) {
+ e := &Edge{}
+ e.upstreams.Store(&map[string]upstream{
+ "www.customer.test": {},
+ "shop.customer.test": {},
+ "app.acct.oncarlos.test": {}, // platform host — apps domain
+ "c1.app.acct.oncarlos.test": {}, // canary — apps domain, 3 labels
+ "*.catchall.test": {}, // catch-all — wildcard covers it
+ "one.go.tito.test": {}, // one label under a configured parent
+ "deep.one.go.tito.test": {}, // TWO labels under it: NOT covered
+ })
+ parents := func() []string { return []string{"go.tito.test"} }
+
+ got := e.warmSet("oncarlos.test", parents)
+ want := []string{"deep.one.go.tito.test", "shop.customer.test", "www.customer.test"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("warm set = %v, want %v", got, want)
+ }
+}
+
+// A parent whose cert has not been obtained yet must STILL exclude its
+// children. Testing coverage by asking certFor (which returns nil until the
+// wildcard lands) would warm every app of a new account per-host during
+// exactly the window before their wildcard arrives — the burst the shape
+// test exists to prevent.
+func TestWarmSetExcludesChildrenOfAnUnobtainedParent(t *testing.T) {
+ e := &Edge{}
+ e.upstreams.Store(&map[string]upstream{"new.acct.test": {}})
+ // currentParents lists the parent; no cert exists for it anywhere.
+ if got := e.warmSet("oncarlos.test", func() []string { return []string{"acct.test"} }); len(got) != 0 {
+ t.Fatalf("warm set = %v; a configured parent covers its children whether or not its cert exists yet", got)
+ }
+}
+
+func TestWarmSetWithNoWildcardManagerCoversNothing(t *testing.T) {
+ e := &Edge{}
+ e.upstreams.Store(&map[string]upstream{"www.customer.test": {}})
+ if got := e.warmSet("oncarlos.test", nil); len(got) != 1 || got[0] != "www.customer.test" {
+ t.Fatalf("warm set = %v, want the custom domain", got)
+ }
+}
+
+// ALL answers must be in the edge set. A name mid-migration resolves to the
+// old host AND ours; an any-match gate passes it, the order goes out, and
+// validation fails from the CA's vantage anyway — burning the shared
+// account's failed-validation budget, which is exactly what the gate is for.
+func TestDNSGateRequiresEveryAnswerToBeOurs(t *testing.T) {
+ edges := func() []string { return []string{"203.0.113.7", "198.51.100.9"} }
+ for name, tc := range map[string]struct {
+ ips []string
+ err error
+ want bool
+ }{
+ "all ours": {ips: []string{"203.0.113.7", "198.51.100.9"}, want: true},
+ "one of ours": {ips: []string{"203.0.113.7"}, want: true},
+ "partly elsewhere": {ips: []string{"203.0.113.7", "192.0.2.1"}, want: false},
+ "wholly elsewhere": {ips: []string{"192.0.2.1"}, want: false},
+ "no answer": {ips: nil, want: false},
+ "resolver failed": {err: errors.New("SERVFAIL"), want: false},
+ } {
+ gate := dnsGate(func(string) ([]string, error) { return tc.ips, tc.err }, edges)
+ if got := gate("www.customer.test"); got != tc.want {
+ t.Errorf("%s: gate = %v, want %v", name, got, tc.want)
+ }
+ }
+}
+
+// An unreadable edge set is not evidence about anybody's DNS. Failing closed
+// costs one pass of delay; failing open spends a shared rate limit.
+func TestDNSGateFailsClosedOnAnEmptyEdgeSet(t *testing.T) {
+ gate := dnsGate(
+ func(string) ([]string, error) { return []string{"203.0.113.7"}, nil },
+ func() []string { return nil },
+ )
+ if gate("www.customer.test") {
+ t.Fatal("an empty edge set opened the gate")
+ }
+}
+
+// The heart of the design: a host that already has a cert is asked for AGAIN
+// every pass. autocert renews through in-memory timers that die on every
+// fleet roll and re-arm only via a GetCertificate that reaches m.cert — so a
+// skip-if-fresh check would silently stop renewing every custom domain whose
+// visitors never land on the issuer.
+func TestWarmReAsksForAlreadyWarmHostsEveryPass(t *testing.T) {
+ var mu sync.Mutex
+ calls := map[string]int{}
+ w := newCertWarm(
+ func() []string { return []string{"www.customer.test"} },
+ func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ mu.Lock()
+ defer mu.Unlock()
+ calls[h.ServerName]++
+ return &tls.Certificate{}, nil
+ },
+ func(string) bool { return true },
+ nil,
+ )
+ for i := 0; i < 3; i++ {
+ w.pass()
+ }
+ if calls["www.customer.test"] != 3 {
+ t.Fatalf("GetCertificate calls = %d, want one per pass — the renewal re-arm depends on it", calls["www.customer.test"])
+ }
+}
+
+// A renewal re-arm is NOT gated on DNS. It is a cache read that costs the CA
+// nothing, and gating it would strand exactly the domain whose resolver is
+// briefly sick — dropping its renewal on the floor for as long as the
+// resolver stays unhappy.
+func TestWarmRenewalReArmIgnoresTheDNSGate(t *testing.T) {
+ calls := 0
+ pointing := true
+ w := newCertWarm(
+ func() []string { return []string{"www.customer.test"} },
+ func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil },
+ func(string) bool { return pointing },
+ nil,
+ )
+ w.pass() // first order: gate open, host becomes warmed
+ pointing = false
+ w.pass() // DNS now fails; the re-arm must still happen
+ if calls != 2 {
+ t.Fatalf("calls = %d, want 2 — a warmed host's re-arm is not DNS-gated", calls)
+ }
+}
+
+// A never-warmed host IS DNS-gated: ordering for a half-pointed name spends
+// the shared account's failed-validation budget.
+func TestWarmDoesNotOrderForAHostThatDoesNotPointHere(t *testing.T) {
+ calls := 0
+ w := newCertWarm(
+ func() []string { return []string{"www.customer.test"} },
+ func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil },
+ func(string) bool { return false },
+ nil,
+ )
+ w.pass()
+ if calls != 0 {
+ t.Fatalf("calls = %d, want 0 — the DNS gate must hold the first order", calls)
+ }
+}
+
+// A non-issuer does no work at all: unlike the wildcard manager there is no
+// cache to load first, because the warm serves nothing.
+func TestWarmStandsDownWhenNotTheIssuer(t *testing.T) {
+ calls := 0
+ w := newCertWarm(
+ func() []string { return []string{"www.customer.test"} },
+ func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil },
+ func(string) bool { return true },
+ func() (context.Context, bool) { return nil, false },
+ )
+ if d := w.pass(); d != warmInterval {
+ t.Fatalf("delay = %s", d)
+ }
+ if calls != 0 {
+ t.Fatalf("a non-issuer placed %d call(s)", calls)
+ }
+}
+
+// A rate limit must back off much harder than an ordinary failure: hourly
+// retries INTO a rate limit are what turn a transient limit into a sustained
+// one, and every host shares the one ACME account's budget.
+func TestWarmBacksOffHarderOnARateLimit(t *testing.T) {
+ rateLimited := &acme.Error{ProblemType: "urn:ietf:params:acme:error:rateLimited"}
+ w := newCertWarm(nil, nil, nil, nil)
+ w.fail("a.test", rateLimited)
+ w.fail("b.test", errors.New("connection refused"))
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ limited, ordinary := time.Until(w.nextAttempt["a.test"]), time.Until(w.nextAttempt["b.test"])
+ if limited <= ordinary {
+ t.Fatalf("rate-limited backoff %s is not harder than the ordinary %s", limited, ordinary)
+ }
+}
+
+// A failing host is not retried until its backoff expires — and a healthy
+// host in the same set is not held up by it.
+func TestWarmHonoursPerHostBackoff(t *testing.T) {
+ calls := map[string]int{}
+ w := newCertWarm(
+ func() []string { return []string{"bad.test", "good.test"} },
+ func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ calls[h.ServerName]++
+ if h.ServerName == "bad.test" {
+ return nil, errors.New("order failed")
+ }
+ return &tls.Certificate{}, nil
+ },
+ func(string) bool { return true },
+ nil,
+ )
+ w.pass()
+ w.pass()
+ if calls["bad.test"] != 1 {
+ t.Errorf("bad.test tried %d times, want 1 — backoff not honoured", calls["bad.test"])
+ }
+ if calls["good.test"] != 2 {
+ t.Errorf("good.test tried %d times, want 2 — a failing sibling must not stall it", calls["good.test"])
+ }
+}
+
+// New orders are capped per pass, and a capped pass comes back SOON so a
+// backlog drains in minutes rather than trickling out over days. Renewal
+// re-arms are never capped.
+func TestWarmCapsNewOrdersAndReturnsSoon(t *testing.T) {
+ var hosts []string
+ for i := 0; i < warmOrdersPerPass+5; i++ {
+ hosts = append(hosts, string(rune('a'+i))+".customer.test")
+ }
+ calls := 0
+ w := newCertWarm(
+ func() []string { return hosts },
+ func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil },
+ func(string) bool { return true },
+ nil,
+ )
+ d := w.pass()
+ if calls != warmOrdersPerPass {
+ t.Fatalf("placed %d orders, want the cap of %d", calls, warmOrdersPerPass)
+ }
+ if d != warmCappedDelay {
+ t.Fatalf("capped pass returned %s, want the short %s so the backlog drains", d, warmCappedDelay)
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index c08c3f022..bb5cbf6bc 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -47,6 +47,7 @@ import (
"golang.org/x/crypto/acme/autocert"
+ "github.com/carlosframework/platform/internal/edgesfile"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -2415,12 +2416,18 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun
if mirror != nil {
getCert = mirror.wrapGetCertificate(getCert)
}
+ // The per-host path, captured before the wildcard wrap goes on top of it:
+ // this is what the cert warm below calls. Calling the WRAPPED chain would
+ // let the wildcard answer for a covered name and order nothing.
+ perHostCert := getCert
// 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.
+ var wm *wildcardManager
if w := newWildcardFromEnv(e, certCache); w != nil {
+ wm = w
getCert = wrapGetCertificate(w, getCert)
e.OnReload(w.refresh)
attachWildcardToMirror(mirror, w)
@@ -2432,6 +2439,38 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun
log.Printf("edge: wildcard certs on (static %v, apps domain %q, delegation zone %q)",
splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS")), os.Getenv("PLATFORM_APPS_DOMAIN"), w.delegationZone)
}
+ // Cert warm (#299 stage 3, certwarm.go): the platform ORDERS and RENEWS
+ // custom domains' certs instead of waiting for a visitor's handshake.
+ //
+ // It calls perHostCert — the chain as it stood BEFORE the wildcard wrap —
+ // because the warm's set already excludes every wildcard-covered name,
+ // and a wrapped call would be answered by the wildcard and order nothing.
+ //
+ // Armed only with a deployment store: an agent-less `carlos edge` is
+ // ungated (issuerGate nil means yes), and would otherwise start placing
+ // proactive ACME orders on a hobbyist's box.
+ if e.staticStore != nil {
+ appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
+ parents := func() []string { return nil }
+ if wm != nil {
+ parents = wm.currentParents
+ }
+ envIPs := splitDomains(os.Getenv("PLATFORM_EDGE_IPS"))
+ store := e.staticStore
+ edgeIPs := func() []string {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ return edgesfile.EdgeIPs(ctx, store, envIPs, time.Now())
+ }
+ warm := newCertWarm(
+ func() []string { return e.warmSet(appsDomain, parents) },
+ perHostCert,
+ dnsGate(warmResolver, edgeIPs),
+ e.issuerGate,
+ )
+ go warm.run()
+ log.Printf("edge: cert warm on (%s pass) — custom domains are ordered and renewed proactively, not on first handshake", warmInterval)
+ }
tlsCfg.GetCertificate = getCert
if mirror != nil {
// Started last: every field the loop reads (wake above) is in place.
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go
index aa675bfd8..4057747db 100644
--- a/internal/edge/homemap.go
+++ b/internal/edge/homemap.go
@@ -427,6 +427,38 @@ func (m *homeMap) Hosts() []string {
return out
}
+// ServedHosts lists every EXACT hostname any box in this deployment homes —
+// routes and attached custom domains alike, with no class filter.
+//
+// Deliberately NOT Hosts(): that one applies parentInput, which keeps only
+// platform-class rows because its consumer derives wildcard PARENTS from
+// them. The cert warm's question is the opposite one — "what exact names does
+// this deployment answer for?" — and a custom domain is precisely the
+// non-platform row Hosts() drops. Filtering by class here would warm every
+// canary and no customer domain, which is backwards.
+//
+// Catch-all "*." keys are excluded: they are covered by a wildcard PEM, and
+// autocert refuses the wildcard label anyway (Allowed). Only dial-able
+// entries count — a cert-only wildcard row homes nothing.
+//
+// An unlabelled box (self == "") reports nothing, as Home does: an edge that
+// cannot tell its own rows from another box's must not answer at all.
+func (m *homeMap) ServedHosts() []string {
+ if m == nil || m.self == "" {
+ return nil
+ }
+ snap := m.snapshot()
+ out := make([]string, 0, len(snap.hosts))
+ for h, e := range snap.hosts {
+ if !e.dial || strings.HasPrefix(h, "*.") {
+ continue
+ }
+ out = append(out, h)
+ }
+ sort.Strings(out)
+ return out
+}
+
// setHostsWake installs the callback a changed host set fires (Refresh).
// Under the map's own lock: run() is already polling when the wildcard
// manager is wired in after it.