carlos / platform Public

edge: platform parents always win over delegated, boot-validate the zone

Final whole-branch review fixes for delegated-challenge wildcards:

- refresh() now drops any delegated entry that collides with a static
  domain or falls under the apps domain, before it can reach the parent
  union or delegatedSet — a colliding entry would otherwise redirect a
  platform parent's challenge to a delegation target with no CNAME,
  silently degrading that wildcard's renewal to per-host.
- newWildcardFromEnv validates CARLOS_ACME_DELEGATION_ZONE with
  provision.ValidHostShape at boot; an invalid value logs and disables
  delegation instead of Fatal-ing — the edge must still boot.
- the wildcard-certs-on startup log line now reports the delegation zone.
- doc drift closed: file header, parentsFor and refresh doc comments, and
  internal/host/agent.go's env vocabulary list.
Paul Campbell 715442403551c57f5311070cb0dd4684a92e2702 parent 1e3f9f8
4 files changed, +100 −19
  • internal/edge/edge.go +2 −2
  • internal/edge/wildcard.go +49 −17
  • internal/edge/wildcard_test.go +46 −0
  • internal/host/agent.go +3 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index dd84325ee..026164837 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -1257,8 +1257,8 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun
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"))
+ 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)
}
// The non-ACME :80 handler. Known hosts get the classic redirect to https
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go
index fee78814e..32ce6e5e1 100644
--- a/internal/edge/wildcard.go
+++ b/internal/edge/wildcard.go
@@ -16,8 +16,11 @@ package edge
// 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
+// domains stay per-host HTTP-01 — except a custom domain approved as a
+// delegated wildcard parent (spec §4), which gets its own *.<parent>
+// wildcard via a challenge TXT published in our own zone. 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.
@@ -45,6 +48,8 @@ import (
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
+
+ "github.com/carlosframework/platform/internal/provision"
)
const letsEncryptDirectory = "https://acme-v02.api.letsencrypt.org/directory"
@@ -111,12 +116,14 @@ func newWildcardManager(staticDomains []string, appsDomain string, hosts func()
}
}
-// parentsFor computes the wildcard parent set: the static domains plus
+// parentsFor computes the wildcard parent set: staticAndDelegated (the
+// static CARLOS_WILDCARD_DOMAINS plus any delegated parents refresh() has
+// already vetted — both arrive pre-merged through this one parameter) union
// <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 {
+func parentsFor(staticAndDelegated []string, appsDomain string, hosts []string) []string {
seen := map[string]bool{}
var out []string
add := func(p string) {
@@ -125,7 +132,7 @@ func parentsFor(static []string, appsDomain string, hosts []string) []string {
out = append(out, p)
}
}
- for _, d := range static {
+ for _, d := range staticAndDelegated {
add(strings.ToLower(d))
}
if appsDomain != "" {
@@ -145,19 +152,37 @@ func parentsFor(static []string, appsDomain string, hosts []string) []string {
}
// 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. A wake cannot bypass a failing parent's backoff: run()
-// still skips any parent whose nextAttempt hasn't passed, so a wake only
-// advances parents that aren't already backing off.
+// the reload hook on every route swap, and once at startup), folding in
+// delegated parents (spec §4) alongside the static and per-account ones.
+// Platform parents always win: a delegated entry that collides with a
+// static domain, or falls under the apps domain, is dropped here — before
+// it reaches parentsFor's union or delegatedSet — because letting it
+// through would redirect a PLATFORM parent's challenge to a delegation
+// target where no CNAME exists, silently degrading its wildcard renewal to
+// per-host. 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. A wake cannot bypass a failing
+// parent's backoff: run() still skips any parent whose nextAttempt hasn't
+// passed, so a wake only advances parents that aren't already backing off.
func (w *wildcardManager) refresh() {
var delegated []string
if w.delegated != nil {
+ static := make(map[string]bool, len(w.staticDomains))
+ for _, d := range w.staticDomains {
+ static[strings.ToLower(strings.TrimSpace(d))] = true
+ }
+ appsSuffix := ""
+ if w.appsDomain != "" {
+ appsSuffix = "." + w.appsDomain
+ }
for _, d := range w.delegated() {
- if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
- delegated = append(delegated, d)
+ if d = strings.ToLower(strings.TrimSpace(d)); d == "" {
+ continue
+ }
+ if static[d] || (appsSuffix != "" && strings.HasSuffix(d, appsSuffix)) {
+ continue
}
+ delegated = append(delegated, d)
}
}
parents := parentsFor(append(append([]string(nil), w.staticDomains...), delegated...), w.appsDomain, w.hosts())
@@ -287,9 +312,7 @@ func (w *wildcardManager) challengeFQDN(parent string) string {
return "_acme-challenge." + parent
}
-// isDelegated checks delegatedSet membership only — if a parent is ever
-// both platform-derived and claim-derived, delegated status wins and its
-// challenge publishes to the delegation zone.
+// isDelegated reports whether parent is in the delegated set.
func (w *wildcardManager) isDelegated(parent string) bool {
w.mu.Lock()
defer w.mu.Unlock()
@@ -587,7 +610,16 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
// Delegated parents (spec §4): the SOURCE is claim-derived and lands
// with the claim-derivation pass (Plan C); until then the zone is
// configured but the set is empty — inert by construction.
- return newWildcardManager(static, appsDomain, e.currentHosts, nil, os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), cache, dns)
+ rawZone := os.Getenv("CARLOS_ACME_DELEGATION_ZONE")
+ zone := strings.ToLower(strings.TrimSpace(rawZone))
+ if zone != "" && !provision.ValidHostShape(zone) {
+ // Degrade, don't die: the edge must still boot on a bad zone value.
+ // Delegation goes off; per-host and direct (own-zone) DNS-01 keep
+ // covering everyone else.
+ log.Printf("edge: CARLOS_ACME_DELEGATION_ZONE %q invalid — delegation off, per-host and direct DNS-01 continue", rawZone)
+ zone = ""
+ }
+ return newWildcardManager(static, appsDomain, e.currentHosts, nil, zone, cache, dns)
}
// wrapGetCertificate serves the wildcard for covered names and hands
diff --git a/internal/edge/wildcard_test.go b/internal/edge/wildcard_test.go
index 5bc537c3d..8ad08d544 100644
--- a/internal/edge/wildcard_test.go
+++ b/internal/edge/wildcard_test.go
@@ -481,6 +481,33 @@ func TestNilDelegatedSourceIsFine(t *testing.T) {
}
}
+// TestPlatformParentsWinOverDelegated exercises the precedence rule refresh()
+// enforces: a delegated entry that collides with a static domain or falls
+// under the apps domain is dropped before it can hijack a PLATFORM parent's
+// challenge — that parent's TXT must still publish directly, never into the
+// delegation zone where no customer CNAME exists.
+func TestPlatformParentsWinOverDelegated(t *testing.T) {
+ w := newWildcardManager([]string{"go.tito.io"}, "oncarlos.com",
+ func() []string { return []string{"app.bab.oncarlos.com"} },
+ func() []string { return []string{"go.tito.io", "bab.oncarlos.com", "woodstar.app"} },
+ "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+
+ // Colliding entries lose: both the static domain and the dynamic
+ // per-account parent publish their challenge directly, exactly as if
+ // they had never appeared in the delegated source.
+ if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want {
+ t.Errorf("static collision: challengeFQDN = %q, want %q", got, want)
+ }
+ if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want {
+ t.Errorf("apps-domain collision: challengeFQDN = %q, want %q", got, want)
+ }
+ // A genuinely foreign delegated parent still gets the delegated form.
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want {
+ t.Errorf("non-colliding delegated: challengeFQDN = %q, want %q", got, want)
+ }
+}
+
func TestNewWildcardFromEnvDelegationZone(t *testing.T) {
t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
@@ -495,3 +522,22 @@ func TestNewWildcardFromEnvDelegationZone(t *testing.T) {
t.Errorf("delegationZone = %q, want lowercased+trimmed acme.oncarlos.com", w.delegationZone)
}
}
+
+// TestNewWildcardFromEnvInvalidDelegationZoneDegrades covers "validate at
+// boot, degrade don't die": a malshaped CARLOS_ACME_DELEGATION_ZONE must
+// never stop the edge from booting wildcards — it just turns delegation off
+// while per-host and direct (own-zone) DNS-01 keep covering everyone else.
+func TestNewWildcardFromEnvInvalidDelegationZoneDegrades(t *testing.T) {
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
+ t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "*.bad zone")
+ e := &Edge{}
+ w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
+ if w == nil {
+ t.Fatal("invalid delegation zone must not stop the edge from booting")
+ }
+ if w.delegationZone != "" {
+ t.Errorf("delegationZone = %q, want empty (delegation off)", w.delegationZone)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 11d17dc30..b24f60254 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -94,6 +94,9 @@
// wildcard certs: unset/dnsimple needs the token (SSM → unit env),
// route53 rides the box IAM role; nothing set = wildcards off,
// per-host HTTP-01 only
+// - CARLOS_ACME_DELEGATION_ZONE — our own zone that delegated wildcard
+// parents' challenge TXTs publish into (spec §4); unset or an invalid
+// shape = delegation off, per-host and direct DNS-01 continue
// - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks
// compare against. Default: this box's public IPv4 from IMDS
// (best-effort; if neither is knowable, DNS stamping stands down