edge: answer the steering health check — cert coverage and a real 200
The health-probe gate the T5/T6 reviews routed here: without both halves of this, steering's rung-2 withdrawal is decorative. TLS. The Route53 checks handshake with SNI edge.<apps-domain>, one label SHALLOWER than the per-account wildcard parents this edge derives, so nothing covered the name and every check failed at the handshake — withdrawing exactly the records it was meant to protect. With steering armed, the apps domain itself joins the wildcard parent set, which is what a static parent already IS (go.tito.io is there for the same reason). DNS-01 is not a preference here but the only path that can work: the sole client that ever sends that SNI is the health checker, and a per-host autocert order is triggered lazily BY a handshake and cancelled with it, so no checker-driven order could outlive the checker's own timeout. It also needs no A record to exist first, which matters because the record only appears once the converger runs. HTTP. edge.<apps-domain> is not a registry route, so it fell through to unknownHost — a 404, or on a -fallback edge (every flagship box) a 302, which Route53 scores as HEALTHY. A false-healthy check never withdraws anything and says nothing in the journal. The edge now answers GET/HEAD / on that host, and only that host and only that path, with a tiny 200, placed ahead of every other serving path on both listeners. Gated on CARLOS_STEERING_ZONE_ID (validated by the converger's own SteeringZoneID), never on PLATFORM_APPS_DOMAIN alone: both production deployments set an apps domain and neither steers, so that gate would answer for a hostname no zone steers and mint an unasked-for *.<apps-domain> on live boxes. No new environment variable either way. Tests: a real handshake-and-GET for edge.<apps-domain> against a served wildcard (200), the fallback edge NOT 302ing the probe, non-/ and non-GET falling through to ordinary routing, the parent set arming only when steered, and byte-identical behaviour with the zone unset. Both new behaviours were verified to FAIL with the wiring disabled. Plus the dead-box rollout note: nothing retires an edges file, so a terminated box's EIP is contributed forever and the health check is the only withdrawal.
5 files changed,
+414
−1
docs/superpowers/plans/2026-08-23-multi-region-steering.md+28 −0internal/edge/edge.go+25 −1internal/edge/steeringprobe.go+113 −0internal/edge/steeringprobe_test.go+217 −0internal/edge/wildcard.go+31 −0
diff --git a/docs/superpowers/plans/2026-08-23-multi-region-steering.md b/docs/superpowers/plans/2026-08-23-multi-region-steering.md| index 7390757f7..e252a5931 100644 |
| --- a/docs/superpowers/plans/2026-08-23-multi-region-steering.md |
| +++ b/docs/superpowers/plans/2026-08-23-multi-region-steering.md |
| @@ -349,3 +349,31 @@ func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain str |
| ## Rollout notes (filled by Task 8 at execution) |
| - (filled at execution) |
| + |
| +Pre-filled by Task 7 (fold into the list above at Task 8): |
| + |
| +- **A dead box is never retired by anything but the health check.** A box |
| + writes `control/edges/<box>.json` and nothing ever deletes it: an |
| + instance that is terminated, or simply stops running the agent, keeps |
| + contributing its EIP to `desiredState` forever, so the converger keeps |
| + writing a latency record for an address nobody answers on. The Route53 |
| + health check is the ONLY withdrawal — which is why the probe has to |
| + actually pass on a live box (Task 7's edge half) — and it withdraws the |
| + ANSWER, not the record. Retiring a region is therefore a two-step |
| + operator action: stop the box, then delete its edges file from the |
| + control bucket. Until that second step, the zone still names it. |
| +- **The steering env has THREE consumers on a box, not one.** |
| + `CARLOS_STEERING_ZONE_ID` gates (a) the converger duty in the agent, |
| + (b) the edge's steering-probe answer AND its `*.<apps-domain>` wildcard |
| + parent — the cert the health check handshake needs, which no per-host |
| + order can ever produce — and (c) the console's custom-domain guidance, |
| + which only offers the CNAME to `edge.<apps-domain>` where the value is |
| + set. All three read the same variable and validate it identically; a box |
| + whose edge/console did not get it stays on the old behaviour silently |
| + (dormant, not broken). The edge additionally needs a DNS provider |
| + configured (`CARLOS_DNS_PROVIDER`) — without one the apps-domain |
| + wildcard is never issued and every health check fails at the handshake. |
| +- **`PLATFORM_EDGE_IPS` must list every armed edge's EIP once steering is |
| + armed**, not just the local box's: the DNS check that stamps a custom |
| + domain `pointing` compares the resolved address against that list, and a |
| + steered name resolves to whichever edge the resolver is nearest. |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 31d8e8b14..19a565b38 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -96,6 +96,12 @@ type Edge struct { |
| // because HostPolicy refuses to mint certs for unknown hosts (anything else |
| // would let strangers burn the ACME rate limit). The :80 path always works. |
| fallbackURL string |
| + // steeringHost is this deployment's DNS-steering hostname |
| + // (edge.<apps-domain>), or "" when the deployment does not steer — which |
| + // is every deployment today. Set once at construction from the |
| + // environment; see steeringprobe.go for why it is a serving concern at |
| + // all. |
| + steeringHost string |
| // Static serving config (EnableStatic): the deployment store the site |
| // tars are fetched from, the release pubkey channel pointers must verify |
| // against, and the on-box cache the trees are extracted into. staticStore |
| @@ -229,7 +235,7 @@ type upstream struct { |
| // New builds an Edge over reg and performs the startup reload. act may be nil |
| // (no hibernation). |
| func New(reg *registry.Registry, act Activator) (*Edge, error) { |
| - e := &Edge{reg: reg, act: act, probers: newProberSet(), gzipOn: gzipEnabled()} |
| + e := &Edge{reg: reg, act: act, probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv()} |
| if err := e.Reload(); err != nil { |
| return nil, err |
| } |
| @@ -786,6 +792,16 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| if h, _, err := net.SplitHostPort(host); err == nil { |
| host = h |
| } |
| + // The deployment's steering health check (steeringprobe.go), answered |
| + // FIRST — ahead of the tunnel hub, the hop, routing and, decisively, the |
| + // unknown-host fallback. A -fallback edge redirects an unknown host with |
| + // a 302, which Route53 scores as healthy: reached in that order, the one |
| + // mechanism that withdraws a dead edge would report every edge alive |
| + // forever. Inert (one comparison against "") unless this deployment |
| + // steers. |
| + if e.steeringProbe(w, r, host) { |
| + return |
| + } |
| // The reserved tunnel hostname is answered by the hub itself — before the |
| // route lookup, so registration cannot be intercepted by a route, and |
| // after the host:port split, so a dialer sending "tunnel.host:443" still |
| @@ -1321,6 +1337,14 @@ func (e *Edge) httpFallbackHandler() http.Handler { |
| if h, _, err := net.SplitHostPort(host); err == nil { |
| host = h |
| } |
| + // The steering hostname answers here too (ServeHTTP's own first |
| + // branch, and the same reasoning): the health check is HTTPS today, |
| + // but a :80 probe — or an operator's curl — must not be handed this |
| + // edge's fallback redirect for a name that is deployment |
| + // infrastructure. |
| + if e.steeringProbe(w, r, host) { |
| + return |
| + } |
| // The reserved tunnel hostname is answered by the hub itself on 443 |
| // (ServeHTTP, and Allowed's same check for issuance) but is not a |
| // registry route, so lookupUpstream alone never sees it — without this, |
diff --git a/internal/edge/steeringprobe.go b/internal/edge/steeringprobe.go| new file mode 100644 |
| index 000000000..b740824d0 |
| --- /dev/null |
| +++ b/internal/edge/steeringprobe.go |
| @@ -0,0 +1,113 @@ |
| +package edge |
| + |
| +// The steering probe: the small 200 that decides whether DNS steering's |
| +// rung-2 withdrawal is real or decorative (spec: |
| +// docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md, Part 4). |
| +// |
| +// The converger (internal/steering/route53.go) binds every latency record it |
| +// writes to a Route53 health check that probes the edge's EIP over HTTPS |
| +// with SNI edge.<apps-domain> and GET /. That check is the ONLY thing that |
| +// withdraws a dead edge's answers, so it has to pass on a live box — and on |
| +// an unmodified edge it could not, twice over: |
| +// |
| +// - TLS: edge.<apps-domain> is one label under the apps domain, and every |
| +// wildcard this edge holds is a per-ACCOUNT parent one label DEEPER |
| +// (*.<acct>.<apps-domain>). No cert covered the name, so the probe died |
| +// at the handshake and every record looked dead. The fix is in |
| +// wildcard.go: with steering armed, the apps domain itself joins the |
| +// wildcard parent set, so *.<apps-domain> covers exactly this name — |
| +// issued over DNS-01, which is the only issuance path that can work |
| +// here. A per-host order cannot: the sole TLS client that ever sends |
| +// this SNI is the health checker, and autocert orders LAZILY out of the |
| +// handshake, so every order would be cancelled by the checker's |
| +// few-second timeout before it could finish, forever. |
| +// |
| +// - HTTP: the hostname is not a registry route, so it fell through to |
| +// unknownHost — a 404, or on a -fallback edge (every flagship box) a |
| +// 302, which Route53 counts as HEALTHY. A false-healthy check is worse |
| +// than no check: it never withdraws anything, and nothing in the journal |
| +// says so. Hence the answer below, placed AHEAD of every other serving |
| +// path. |
| +// |
| +// Inert unless the deployment steers: steeringProbeHostFromEnv returns "" |
| +// unless CARLOS_STEERING_ZONE_ID is set AND usable — the same read, with the |
| +// same validation, that the converger performs — so on every box today the |
| +// probe is one comparison against an empty string and nothing else changes. |
| +// PLATFORM_APPS_DOMAIN alone is NOT the gate: both production deployments |
| +// set it, and gating there would answer for a hostname no zone steers and |
| +// order a wildcard nobody asked for. |
| + |
| +import ( |
| + "io" |
| + "log" |
| + "net/http" |
| + "os" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/steering" |
| +) |
| + |
| +// steeringProbeBody is what the health check reads. Deliberately tiny and |
| +// deliberately not a status page: it says the edge answered, nothing about |
| +// what it serves or who it serves it for. |
| +const steeringProbeBody = "carlos steering probe ok\n" |
| + |
| +// steeringProbeHostFromEnv derives this deployment's steering hostname, or "" |
| +// when the deployment does not steer. Read once, at Edge construction (the |
| +// gzipEnabled precedent), because a hostname that changes under a running |
| +// edge is a redeploy, not a reload. |
| +func steeringProbeHostFromEnv() string { |
| + if steering.SteeringZoneID() == "" { |
| + return "" |
| + } |
| + domain := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")), "."))) |
| + if domain == "" { |
| + // Armed but with nothing to build the name from. The converger says |
| + // the same thing on its own pass (desiredState mints no steering |
| + // hostname without an apps domain); saying it here too is what tells |
| + // an operator reading the EDGE's journal why no probe is answered. |
| + log.Printf("edge: steering zone is set but PLATFORM_APPS_DOMAIN is not — no steering hostname to answer; health checks on this box will fail until it is set") |
| + return "" |
| + } |
| + host := "edge." + domain |
| + log.Printf("edge: steering probe armed for %s (GET / answers 200; every other path on that host routes normally)", host) |
| + return host |
| +} |
| + |
| +// steeringProbe answers the deployment's Route53 health check, reporting |
| +// whether it did. |
| +// |
| +// Narrow on purpose, in all three axes: |
| +// |
| +// - the HOST must be the steering hostname exactly (case-insensitively, |
| +// trailing dot tolerated — DNS names are case-insensitive and a client |
| +// may send either form); |
| +// - the PATH must be exactly "/" — the health check's own path |
| +// (healthCheckPath, internal/steering/route53.go). Anything else on that |
| +// host carries on to the ordinary routing path, so this is a reserved |
| +// ANSWER, not a reserved hostname; |
| +// - the METHOD must be GET or HEAD. Nothing here mutates, so anything else |
| +// falls through rather than being handled. |
| +// |
| +// Callers place it before every other branch — including the fallback |
| +// redirect, which would otherwise 302 the check into false health. |
| +func (e *Edge) steeringProbe(w http.ResponseWriter, r *http.Request, host string) bool { |
| + if e.steeringHost == "" { |
| + return false |
| + } |
| + if !strings.EqualFold(strings.TrimSuffix(host, "."), e.steeringHost) { |
| + return false |
| + } |
| + if r.URL.Path != "/" || (r.Method != http.MethodGet && r.Method != http.MethodHead) { |
| + return false |
| + } |
| + w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| + // A health check must never be answered from anyone's cache: the whole |
| + // point of the probe is that it reflects THIS box, right now. |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(http.StatusOK) |
| + if r.Method == http.MethodGet { |
| + _, _ = io.WriteString(w, steeringProbeBody) |
| + } |
| + return true |
| +} |
diff --git a/internal/edge/steeringprobe_test.go b/internal/edge/steeringprobe_test.go| new file mode 100644 |
| index 000000000..2dad7c5f5 |
| --- /dev/null |
| +++ b/internal/edge/steeringprobe_test.go |
| @@ -0,0 +1,217 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "crypto/tls" |
| + "crypto/x509" |
| + "errors" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/crypto/acme/autocert" |
| +) |
| + |
| +// steeringEdge is an Edge with the steering environment armed, built through |
| +// New so the derivation under test is the production one. |
| +func steeringEdge(t *testing.T, zone, appsDomain, fallback string) *Edge { |
| + t.Helper() |
| + t.Setenv("CARLOS_STEERING_ZONE_ID", zone) |
| + t.Setenv("PLATFORM_APPS_DOMAIN", appsDomain) |
| + e, err := New(openReg(t), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.fallbackURL = fallback |
| + return e |
| +} |
| + |
| +// TestSteeringProbeHostFromEnv: the hostname is derived only where the |
| +// deployment actually steers. A missing or malformed zone id — every box |
| +// today — leaves it empty, which is the whole inertness claim. |
| +func TestSteeringProbeHostFromEnv(t *testing.T) { |
| + cases := []struct{ name, zone, domain, want string }{ |
| + {"nothing set (every deployment today)", "", "", ""}, |
| + {"apps domain alone is NOT the gate", "", "oncarlos.com", ""}, |
| + {"malformed zone id stays dormant", "/hostedzone/Z123", "oncarlos.com", ""}, |
| + {"armed", "Z0ABCDEF1234", "oncarlos.com", "edge.oncarlos.com"}, |
| + {"armed, case and dot normalized", "Z0ABCDEF1234", " OnCarlos.com. ", "edge.oncarlos.com"}, |
| + {"armed with no apps domain: nothing to build from", "Z0ABCDEF1234", "", ""}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + t.Setenv("CARLOS_STEERING_ZONE_ID", c.zone) |
| + t.Setenv("PLATFORM_APPS_DOMAIN", c.domain) |
| + if got := steeringProbeHostFromEnv(); got != c.want { |
| + t.Errorf("steeringProbeHostFromEnv() = %q, want %q", got, c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestSteeringProbeAnswersAheadOfTheFallback is the health-probe gate itself: |
| +// on a steering deployment the check's exact request (GET / with the steering |
| +// hostname) is answered 200 even though a fallback URL is configured — the |
| +// 302 that fallback would otherwise return is what Route53 scores as healthy, |
| +// which would make the withdrawal decorative. |
| +func TestSteeringProbeAnswersAheadOfTheFallback(t *testing.T) { |
| + e := steeringEdge(t, "Z0ABCDEF1234", "example.com", "https://marketing.example.net") |
| + |
| + get := func(method, host, path string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + r := httptest.NewRequest(method, "http://"+host+path, nil) |
| + r.Host = host |
| + w := httptest.NewRecorder() |
| + e.ServeHTTP(w, r) |
| + return w |
| + } |
| + |
| + w := get("GET", "edge.example.com", "/") |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("GET / on the steering host = %d, want 200 (a -fallback edge must not 302 the health check)", w.Code) |
| + } |
| + if w.Body.String() != steeringProbeBody { |
| + t.Errorf("body = %q, want %q", w.Body.String(), steeringProbeBody) |
| + } |
| + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { |
| + t.Errorf("Content-Type = %q, want text/plain", ct) |
| + } |
| + if cc := w.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Errorf("Cache-Control = %q, want no-store", cc) |
| + } |
| + |
| + // Port and trailing dot are both legal ways for a client to name it. |
| + if w := get("GET", "EDGE.example.com.:443", "/"); w.Code != http.StatusOK { |
| + t.Errorf("case/dot/port-qualified host = %d, want 200", w.Code) |
| + } |
| + |
| + // HEAD answers, with no body (the health check may use either). |
| + if w := get("HEAD", "edge.example.com", "/"); w.Code != http.StatusOK || w.Body.Len() != 0 { |
| + t.Errorf("HEAD / = %d body %q, want 200 with no body", w.Code, w.Body.String()) |
| + } |
| + |
| + // The probe is an ANSWER, not a reserved hostname: everything else on |
| + // that host takes the ordinary path (here: the fallback redirect). |
| + for _, c := range []struct{ method, path string }{ |
| + {"GET", "/other"}, |
| + {"POST", "/"}, |
| + } { |
| + if w := get(c.method, "edge.example.com", c.path); w.Code != http.StatusFound { |
| + t.Errorf("%s %s on the steering host = %d, want the ordinary 302 fallback", c.method, c.path, w.Code) |
| + } |
| + } |
| + |
| + // :80 answers the same way, for the same reason. |
| + r := httptest.NewRequest("GET", "http://edge.example.com/", nil) |
| + r.Host = "edge.example.com" |
| + w80 := httptest.NewRecorder() |
| + e.httpFallbackHandler().ServeHTTP(w80, r) |
| + if w80.Code != http.StatusOK || w80.Body.String() != steeringProbeBody { |
| + t.Errorf(":80 GET / = %d %q, want 200 %q", w80.Code, w80.Body.String(), steeringProbeBody) |
| + } |
| +} |
| + |
| +// TestSteeringProbeInertWithoutSteering: with no steering zone the edge is |
| +// byte-identical to what it was — the steering hostname is just another |
| +// unknown host, redirected to the fallback on both listeners. |
| +func TestSteeringProbeInertWithoutSteering(t *testing.T) { |
| + e := steeringEdge(t, "", "example.com", "https://marketing.example.net") |
| + if e.steeringHost != "" { |
| + t.Fatalf("steeringHost = %q on an unsteered deployment, want empty", e.steeringHost) |
| + } |
| + for _, h := range []http.Handler{e, e.httpFallbackHandler()} { |
| + r := httptest.NewRequest("GET", "http://edge.example.com/", nil) |
| + r.Host = "edge.example.com" |
| + w := httptest.NewRecorder() |
| + h.ServeHTTP(w, r) |
| + if w.Code != http.StatusFound || w.Header().Get("Location") != "https://marketing.example.net" { |
| + t.Errorf("unsteered edge: %d %q, want the unchanged 302 fallback", w.Code, w.Header().Get("Location")) |
| + } |
| + } |
| +} |
| + |
| +// TestSteeringArmsTheAppsDomainWildcardParent: the cert half. The health |
| +// check handshakes with SNI edge.<apps-domain>, one label SHALLOWER than the |
| +// per-account parents, so the apps domain itself must join the parent set — |
| +// and only when the deployment steers. |
| +func TestSteeringArmsTheAppsDomainWildcardParent(t *testing.T) { |
| + cache := autocert.DirCache(t.TempDir()) |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| + t.Setenv("PLATFORM_APPS_DOMAIN", "oncarlos.com") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "") |
| + t.Setenv("CARLOS_DNSIMPLE_TOKEN", "tok") |
| + |
| + e := &Edge{} |
| + e.upstreams.Store(&map[string]upstream{}) |
| + w := newWildcardFromEnv(e, cache) |
| + if w == nil { |
| + t.Fatal("want a manager") |
| + } |
| + if len(w.staticDomains) != 0 { |
| + t.Fatalf("unsteered staticDomains = %v, want none (the apps domain must not become a parent)", w.staticDomains) |
| + } |
| + if got := parentsFor(w.staticDomains, w.appsDomain, nil); len(got) != 0 { |
| + t.Fatalf("unsteered parents = %v, want none", got) |
| + } |
| + |
| + e.steeringHost = "edge.oncarlos.com" |
| + w = newWildcardFromEnv(e, cache) |
| + if !reflect.DeepEqual(w.staticDomains, []string{"oncarlos.com"}) { |
| + t.Fatalf("steered staticDomains = %v, want [oncarlos.com]", w.staticDomains) |
| + } |
| + // The parent has to cover the probe name exactly. |
| + w.parents = parentsFor(w.staticDomains, w.appsDomain, nil) |
| + cert, _ := selfSignedWildcard(t, "oncarlos.com", time.Now().Add(time.Hour)) |
| + w.certs["oncarlos.com"] = cert |
| + if w.certFor("edge.oncarlos.com") == nil { |
| + t.Error("the steering hostname must be covered by the apps-domain wildcard") |
| + } |
| +} |
| + |
| +// TestSteeringProbeOverTLS is the end-to-end shape the Route53 health check |
| +// performs: a real handshake for edge.<apps-domain>, verified by name against |
| +// the issuing certificate, followed by GET / — the pair that has to answer |
| +// 200 for a withdrawal to mean anything. |
| +func TestSteeringProbeOverTLS(t *testing.T) { |
| + e := steeringEdge(t, "Z0ABCDEF1234", "example.com", "https://marketing.example.net") |
| + |
| + cert, _ := selfSignedWildcard(t, "example.com", time.Now().Add(time.Hour)) |
| + w := &wildcardManager{ |
| + parents: []string{"example.com"}, |
| + certs: map[string]*tls.Certificate{"example.com": cert}, |
| + } |
| + srv := httptest.NewUnstartedServer(e) |
| + srv.TLS = &tls.Config{GetCertificate: wrapGetCertificate(w, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { |
| + // No per-host path in this test: if the wildcard does not cover the |
| + // name, the handshake fails exactly as it would on a live box. |
| + return nil, errors.New("no per-host certificate") |
| + })} |
| + srv.StartTLS() |
| + t.Cleanup(srv.Close) |
| + |
| + pool := x509.NewCertPool() |
| + pool.AddCert(cert.Leaf) |
| + client := &http.Client{ |
| + Timeout: 10 * time.Second, |
| + Transport: &http.Transport{ |
| + TLSClientConfig: &tls.Config{RootCAs: pool}, |
| + DialContext: func(_ context.Context, network, _ string) (net.Conn, error) { |
| + return net.Dial(network, srv.Listener.Addr().String()) |
| + }, |
| + }, |
| + } |
| + resp, err := client.Get("https://edge.example.com/") |
| + if err != nil { |
| + t.Fatalf("handshake + GET against the steering hostname: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + body, _ := io.ReadAll(resp.Body) |
| + if resp.StatusCode != http.StatusOK || string(body) != steeringProbeBody { |
| + t.Fatalf("GET https://edge.example.com/ = %d %q, want 200 %q", resp.StatusCode, body, steeringProbeBody) |
| + } |
| +} |
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index b8c21463c..aa3fdda91 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -683,6 +683,37 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| if len(static) == 0 && appsDomain == "" { |
| return nil |
| } |
| + // Steering (spec Part 4, steeringprobe.go): the deployment's Route53 |
| + // health checks handshake with SNI edge.<apps-domain>, which is one label |
| + // under the apps domain — one label SHALLOWER than the per-account |
| + // parents this manager derives, so nothing here covered it and every |
| + // check failed at the handshake, withdrawing every record it was meant to |
| + // protect. Adding the apps domain itself as a parent is the whole fix, |
| + // and it is exactly what a static parent IS (CARLOS_WILDCARD_DOMAINS |
| + // carries go.tito.io for the same reason: a domain whose one-label-deep |
| + // hosts need certs). |
| + // |
| + // DNS-01 is not merely convenient here, it is the only path that works: |
| + // the sole client that ever sends that SNI is the health checker, and a |
| + // per-host order is triggered lazily BY a handshake and cancelled with |
| + // it, so no checker-driven order could ever outlive the checker's own |
| + // few-second timeout. This manager's loop orders on its own schedule, |
| + // answers the challenge in our own zone, and needs no A record to exist |
| + // first — which matters, because the record only appears once the |
| + // converger runs. |
| + // |
| + // Gated on the steering zone, not on the apps domain: both production |
| + // deployments set an apps domain today and neither steers, and minting an |
| + // unasked-for *.<apps-domain> on them (a real ACME order, a real CT log |
| + // entry, and a change to which cert their existing one-label hosts are |
| + // served from) is not something a dormant feature gets to do. |
| + // |
| + // Read off the Edge rather than the environment a second time: New() |
| + // derived it once (edge.go), so the name this box ANSWERS on and the |
| + // parent it gets a certificate for cannot come from two different reads. |
| + if e.steeringHost != "" { |
| + static = append(static, appsDomain) |
| + } |
| dns, reason := dnsProviderFromEnv() |
| if dns == nil { |
| log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason) |