edge: wildcard parents are derived from the deployment's hosts, not the box's (#300)
A region edge adopts only everywhere-class routes, so its wildcard manager never derived the account parent of an exec app homed on another box — and never loaded the wildcard PEM the mirror had already written to its disk. Every hostname served solely by an account wildcard (every exec app minted since wildcards arrived) failed TLS on a non-home edge: cribado.bad.platform.tito.io through tito UK/SE, and flagship AU holding 3 parents against IE's 12. The manager now reads Edge.deploymentHosts — currentHosts plus every exact name the home map says another box homes — and the home map wakes it when the deployment's name set changes, so a parent appears on the 30s poll rather than the 12h tick. parentsFor's two-labels-under- the-apps-domain rule is the trust bound, the same one the box's own rows pass through; foreign names in edges files contribute nothing. Non-issuers still order nothing; the issuer mints wildcards for accounts homed elsewhere, which is what one issuer per deployment means.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+182
−3
internal/edge/edge.go+26 −0internal/edge/homemap.go+60 −0internal/edge/wildcard.go+6 −3internal/edge/wildcard_parents_test.go+90 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 3e4e35d7a..ebcc77270 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -717,6 +717,29 @@ func (e *Edge) current() map[string]upstream { |
| // entry carries the PRIMARY's route row verbatim, Platform flag included, so |
| // without the flag a customer domain would inherit its platform primary's |
| // trust and mint a parent for a zone the platform doesn't own. |
| +// deploymentHosts is currentHosts plus every exact name the home map says |
| +// another box homes — the input the wildcard manager's parent set is built |
| +// from (#300). A box with no home map (no deployment store) is its own |
| +// deployment and gets currentHosts alone. |
| +func (e *Edge) deploymentHosts() []string { |
| + own := e.currentHosts() |
| + hm := e.homes.Load() |
| + if hm == nil { |
| + return own |
| + } |
| + seen := make(map[string]bool, len(own)) |
| + for _, h := range own { |
| + seen[h] = true |
| + } |
| + for _, h := range hm.Hosts() { |
| + if !seen[h] { |
| + seen[h] = true |
| + own = append(own, h) |
| + } |
| + } |
| + return own |
| +} |
| + |
| func (e *Edge) currentHosts() []string { |
| m := e.current() |
| out := make([]string, 0, len(m)) |
| @@ -2381,6 +2404,9 @@ func RunWithActivatorReady(args []string, act Activator, cache *Cache, ready fun |
| getCert = wrapGetCertificate(w, getCert) |
| e.OnReload(w.refresh) |
| attachWildcardToMirror(mirror, w) |
| + // A remote box's edges file landing (or leaving) can add or drop a |
| + // parent (#300): re-derive on the home map's poll, not the 12h tick. |
| + e.homes.Load().setHostsWake(w.refresh) |
| w.refresh() |
| go w.run() |
| log.Printf("edge: wildcard certs on (static %v, apps domain %q, delegation zone %q)", |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index 5b115d9bd..56b7f3a83 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -79,6 +79,7 @@ import ( |
| "net/http/httputil" |
| "os" |
| "slices" |
| + "sort" |
| "strings" |
| "sync" |
| "time" |
| @@ -169,6 +170,8 @@ type homeMap struct { |
| mu sync.RWMutex |
| snap *homeSnapshot |
| + // hostsWake fires after a Refresh that changed the NAME set (setHostsWake). |
| + hostsWake func() |
| // partialPolls counts polls that could not read (or parse) every object, |
| // for the first-and-every-tenth log cadence. |
| partialPolls int |
| @@ -292,10 +295,67 @@ func (m *homeMap) refreshHosts(ctx context.Context) error { |
| snap := &homeSnapshot{files: next, hosts: foldHomes(next, m.self)} |
| m.mu.Lock() |
| m.snap = snap |
| + wake := m.hostsWake |
| m.mu.Unlock() |
| + if wake != nil && !sameHostSet(prev.hosts, snap.hosts) { |
| + // The deployment's name set moved: a wildcard parent may have |
| + // appeared for an account homed elsewhere (#300). Outside the lock — |
| + // the wake reads back through Hosts(). |
| + wake() |
| + } |
| return nil |
| } |
| +// sameHostSet reports whether two folds name the same hosts (homes may |
| +// differ; only the NAME set feeds the wildcard parents). |
| +func sameHostSet(a, b map[string]homeEntry) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + for h := range a { |
| + if _, ok := b[h]; !ok { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// Hosts is every exact name the deployment's OTHER boxes home — the fold's |
| +// keys. Sorted, nil-safe. The wildcard manager unions these with this box's |
| +// own routes so its parent set is the deployment's, not the box's (#300): |
| +// a region edge that adopts only everywhere-class routes would otherwise |
| +// never derive the account parent of an exec app homed elsewhere, and so |
| +// never load the wildcard PEM the mirror had already written to its disk. |
| +// parentsFor's two-labels-under-the-apps-domain rule is the trust bound |
| +// here, the same one the box's own registry rows pass through. |
| +func (m *homeMap) Hosts() []string { |
| + if m == nil { |
| + return nil |
| + } |
| + snap := m.snapshot() |
| + out := make([]string, 0, len(snap.hosts)) |
| + for h := range snap.hosts { |
| + if 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. |
| +func (m *homeMap) setHostsWake(fn func()) { |
| + if m == nil { |
| + return |
| + } |
| + m.mu.Lock() |
| + m.hostsWake = fn |
| + m.mu.Unlock() |
| +} |
| + |
| // parseEdgesFile decodes one box's file. A parse failure gets the same |
| // treatment an unreadable object gets — carry the last good parse forward — |
| // because a half-written body is no more usable than a body that never |
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index 22bc0f261..07b3e07a8 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -68,7 +68,7 @@ type wildcardManager struct { |
| staticDomains []string |
| appsDomain string |
| - hosts func() []string // live route hosts (Edge.currentHosts) |
| + hosts func() []string // the deployment's live hosts (Edge.deploymentHosts) |
| // delegated lists customer domains approved for delegated-challenge |
| // wildcard issuance (spec §4: claim-derived; nil = none). Their |
| @@ -774,7 +774,7 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| } |
| // Delegated source: the claim-derivation pass (Task 3, internal/domains) |
| // writes wildcard_domains; refresh() reads it fresh on every call, same |
| - // as e.currentHosts above. A read error returns the LAST successful |
| + // as e.deploymentHosts above. A read error returns the LAST successful |
| // list, not nil/empty: refresh() only runs on startup and on OnReload |
| // (run()'s 12h ticker never re-invokes the delegated source itself), so |
| // on a quiet box a transient error would otherwise empty the delegated |
| @@ -805,7 +805,10 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| mu.Unlock() |
| return out |
| } |
| - w := newWildcardManager(static, appsDomain, e.currentHosts, delegated, zone, cache, dns) |
| + // Hosts are the DEPLOYMENT's (this box's routes plus every name another |
| + // box homes, #300), so a region edge derives the account parents of apps |
| + // it only ever backhauls to — and loads their mirrored wildcards. |
| + w := newWildcardManager(static, appsDomain, e.deploymentHosts, delegated, zone, cache, dns) |
| // DNS-01 orders are gated on the same election per-host HTTP-01 orders |
| // are (certsync.go): one issuer per deployment, whichever protocol the |
| // name needs. The cache handle is already the publishing one, so the |
diff --git a/internal/edge/wildcard_parents_test.go b/internal/edge/wildcard_parents_test.go| new file mode 100644 |
| index 000000000..fd853d7b1 |
| --- /dev/null |
| +++ b/internal/edge/wildcard_parents_test.go |
| @@ -0,0 +1,90 @@ |
| +package edge |
| + |
| +import ( |
| + "path/filepath" |
| + "reflect" |
| + "sort" |
| + "testing" |
| + |
| + "golang.org/x/crypto/acme/autocert" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// #300: the home map exposes the deployment's exact names, and a changed |
| +// name set wakes whoever asked (the wildcard manager). |
| +func TestHomeMapHostsAndWakeOnChange(t *testing.T) { |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"own.acct.apps.test":{"role":"home"}}}`) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"cribado.bad.apps.test":{"role":"home"},"*.catch.test":{"role":"home"}}}`) |
| + m := newHomeMap(s, "annie") |
| + woke := 0 |
| + m.setHostsWake(func() { woke++ }) |
| + refreshHomeMap(t, m) |
| + if got, want := m.Hosts(), []string{"cribado.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("Hosts = %v; want %v (own box excluded, catch-all excluded)", got, want) |
| + } |
| + if woke != 1 { |
| + t.Fatalf("first fold woke %d times; want 1", woke) |
| + } |
| + refreshHomeMap(t, m) // unchanged: no wake |
| + if woke != 1 { |
| + t.Fatalf("an unchanged refresh woke the manager (%d)", woke) |
| + } |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"3.3.3.3","hosts":{"cribado.bad.apps.test":{"role":"home"},"*.catch.test":{"role":"home"}}}`) |
| + refreshHomeMap(t, m) // home moved, names did not: no wake |
| + if woke != 1 { |
| + t.Fatalf("a moved home woke the manager (%d)", woke) |
| + } |
| + putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"}}}`) |
| + refreshHomeMap(t, m) |
| + if woke != 2 { |
| + t.Fatalf("a new remote host did not wake the manager (%d)", woke) |
| + } |
| + var nilMap *homeMap |
| + if nilMap.Hosts() != nil { |
| + t.Fatal("nil map must report no hosts") |
| + } |
| + nilMap.setHostsWake(func() {}) // no panic |
| +} |
| + |
| +// deploymentHosts is this box's routes plus the home map's names, deduped. |
| +func TestDeploymentHostsUnionsTheHomeMap(t *testing.T) { |
| + e, _, _ := remoteHomedEdge(t, []string{"own.acct.apps.test"}, "cribado.bad.apps.test", "own.acct.apps.test") |
| + got := e.deploymentHosts() |
| + sort.Strings(got) |
| + if want := []string{"cribado.bad.apps.test", "own.acct.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("deploymentHosts = %v; want %v", got, want) |
| + } |
| + lone := testIssuerEdge("own.acct.apps.test") |
| + if got := lone.deploymentHosts(); !reflect.DeepEqual(got, lone.currentHosts()) { |
| + t.Fatalf("no home map: deploymentHosts %v != currentHosts %v", got, lone.currentHosts()) |
| + } |
| +} |
| + |
| +// The manager built from env derives the parent of an app it only ever |
| +// backhauls to — the region-edge shape that failed on tito stage 6. |
| +func TestWildcardParentsIncludeRemoteHomedAccounts(t *testing.T) { |
| + e, _, _ := remoteHomedEdge(t, []string{"static.bdj.apps.test"}, "cribado.bad.apps.test", "foreign.report") |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + e.reg = reg |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| + t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "route53") |
| + t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "") |
| + w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| + if w == nil { |
| + t.Fatal("want a manager") |
| + } |
| + w.refresh() |
| + // testIssuerEdge's own rows carry no trust flag, so currentHosts (#290, |
| + // pinned in wildcard_test.go) contributes nothing here: what is left is |
| + // exactly the remote account — and never the foreign domain. |
| + if got, want := w.currentParents(), []string{"bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("parents = %v; want %v (remote account included, foreign domain not)", got, want) |
| + } |
| +} |