Merge pull request #311 from carlosframework/delegated-wildcards-deployment
edges file: delegated customer wildcards are published and loaded on every edge (#305)
6 files changed,
+463
−21
docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md+19 −2internal/edge/homemap.go+93 −17internal/edge/wildcard.go+60 −2internal/edge/wildcard_parents_test.go+163 −0internal/host/edgesfile.go+55 −0internal/host/edgesfile_test.go+73 −0
diff --git a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md| index 70efa2bd9..3924eed02 100644 |
| --- a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md |
| +++ b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md |
| @@ -256,6 +256,23 @@ follow-up issue, with one constraint stated in advance: an alias whose |
| host is a `*.<parent>` catch-all must stay wildcard-cert-only if it is |
| ever published — the same #180 bound this amendment refuses to widen. |
| +*Amendment 2026-08-26 (#305).* Delegated customer wildcard parents |
| +(`wildcard_domains` rows — a claim with the Wildcard capability, primary |
| +homed here) publish as `"*.<parent>": {wildcard: true}` with role `home` |
| +when this box serves the catch-all (a Catchall claim, alias row at a |
| +served primary) and role `wildcard` — the cert half alone, no dial |
| +target — otherwise; never `platform`. Every edge folds them: the fold |
| +MERGES classes on a cross-box collision and picks one dial target |
| +(route beats alias, any beats none), `Home` dials only entries with a |
| +dial half, and the wildcard manager unions `WildcardParents()` into its |
| +delegated set, capped at the per-box table's 32 fleet-wide (local rows |
| +first, overflow logged) — a non-issuer loads the parent's mirrored PEM, |
| +the issuer minted it — so `pds.customer.example` terminates on a region |
| +edge exactly as on the home box. Precedence is unchanged: a static or |
| +apps-domain parent still beats a delegated one. `CARLOS_ACME_DELEGATION_ZONE` |
| +is therefore fleet-wide env: a box without it logs when remote parents |
| +exist, since it could win the issuer lease. |
| + |
| *Amendment 2026-08-26 (#287).* The publisher writes each exact alias |
| whose primary is a served row AND whose DNS the sweep has stamped |
| `pointing` under the alias's own name (`role: "home"`, `alias: true`, |
| @@ -265,8 +282,8 @@ gate keeps the issuer's orderable set to names that actually resolve |
| here (attach needs no DNS proof, so the raw alias set is tenant-chosen |
| and unbounded); the fold lets a route beat an alias on a cross-box name |
| collision, whatever the boxes' sort order; a `*.<parent>` catch-all |
| -alias is NOT published — its delegated wildcard parent is box-local, so |
| -a remote edge could dial it but never terminate it. Two residuals stay |
| +alias is NOT published from the alias table — its dial target comes from |
| +the wildcard entry below. Two residuals stay |
| as they were: an ORDER still only starts from a hello reaching the |
| issuer (#286), so a steered custom domain whose hellos never land there |
| stays certless; and nothing purges `control/certs/` on detach. Net for |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index 3ef5cf39a..aa675bfd8 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -105,6 +105,11 @@ const ( |
| // not take its whole file — or the poll — down with it. |
| const edgeRoleHome = "home" |
| +// edgeRoleWildcard is the cert-only half of a delegated wildcard parent |
| +// (#305): the parent's PEM should be loaded everywhere, but this box does |
| +// not serve the catch-all, so the entry is no dial target. |
| +const edgeRoleWildcard = "wildcard" |
| + |
| // homeMapInterval / homeMapTimeout / homeMapBootTimeout mirror the cert |
| // mirror's cadence and budgets exactly (certsync.go), for the same reasons: a |
| // home move is a placement event that converge heals, the poll runs on its own |
| @@ -141,6 +146,9 @@ type edgesHostView struct { |
| // Alias marks an attached custom domain (#287). On a name collision |
| // across boxes a route entry beats an alias entry (foldHomes). |
| Alias bool `json:"alias,omitempty"` |
| + // Wildcard marks a delegated customer wildcard parent's catch-all entry |
| + // (#305): WildcardParents feeds it to the wildcard manager. |
| + Wildcard bool `json:"wildcard,omitempty"` |
| } |
| // homeEntry is one folded answer: where to dial, and which box that is. |
| @@ -151,6 +159,11 @@ type homeEntry struct { |
| platform bool |
| // alias marks an attached custom domain's entry (never platform). |
| alias bool |
| + // wildcard marks a delegated wildcard parent's catch-all entry. |
| + wildcard bool |
| + // dial reports whether the entry is a backhaul target (role home). A |
| + // cert-only wildcard entry folds with dial false and Home skips it. |
| + dial bool |
| } |
| // homeSnapshot is one poll's result, swapped in whole and immutable once |
| @@ -326,29 +339,62 @@ func (m *homeMap) refreshHosts(ctx context.Context) error { |
| func samePlatformSet(a, b map[string]homeEntry) bool { |
| na, nb := 0, 0 |
| for h, e := range a { |
| - if !parentInput(h, e) { |
| + if !parentInput(h, e) && !wildcardInput(h, e) { |
| continue |
| } |
| na++ |
| - if eb, ok := b[h]; !ok || !parentInput(h, eb) { |
| + if eb, ok := b[h]; !ok || (parentInput(h, e) != parentInput(h, eb)) || (wildcardInput(h, e) != wildcardInput(h, eb)) { |
| return false |
| } |
| } |
| for h, e := range b { |
| - if parentInput(h, e) { |
| + if parentInput(h, e) || wildcardInput(h, e) { |
| nb++ |
| } |
| } |
| return na == nb |
| } |
| -// parentInput is the one filter Hosts and samePlatformSet share: a marked, |
| -// exact (non catch-all) name. A catch-all coming or going cannot move a |
| -// parent, so it must not wake the manager either. |
| +// parentInput is the filter Hosts and samePlatformSet share for ACCOUNT |
| +// parents: a marked, exact (non catch-all) name. wildcardInput is its twin |
| +// for delegated parents (WildcardParents). Anything else coming or going |
| +// cannot move a parent, so it must not wake the manager either. |
| func parentInput(host string, e homeEntry) bool { |
| return e.platform && !strings.HasPrefix(host, "*.") |
| } |
| +func wildcardInput(host string, e homeEntry) bool { |
| + if !e.wildcard || !strings.HasPrefix(host, "*.") { |
| + return false |
| + } |
| + parent := host[2:] |
| + // The shape a local wildcard_domains row passed (validHostShape at |
| + // reconcile), applied to a remote one: a real dotted name, no further |
| + // wildcard label, nothing a challenge FQDN could not be built from. |
| + return parent != "" && strings.Contains(parent, ".") && !strings.ContainsAny(parent, "*/ \t") |
| +} |
| + |
| +// WildcardParents is every delegated customer wildcard parent another box |
| +// publishes ("*.<parent>" entries marked wildcard, #305), as bare parents. |
| +// The wildcard manager unions these with its own wildcard_domains rows so |
| +// a region edge loads the parent's mirrored PEM and terminates the |
| +// one-label hosts Home already dials for it. Sorted, nil-safe, nothing for |
| +// an unlabelled box (as Hosts). |
| +func (m *homeMap) WildcardParents() []string { |
| + if m == nil || m.self == "" { |
| + return nil |
| + } |
| + snap := m.snapshot() |
| + out := make([]string, 0) |
| + for h, e := range snap.hosts { |
| + if wildcardInput(h, e) { |
| + out = append(out, strings.TrimPrefix(h, "*.")) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| // Hosts is every exact PLATFORM-class name the deployment's OTHER boxes |
| // home. 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): |
| @@ -457,23 +503,53 @@ func foldHomes(files map[string]edgesFileView, self string) map[string]homeEntry |
| hosts := make(map[string]homeEntry) |
| for _, k := range keys { |
| f := files[k] |
| - if f.Box == "" || f.Box == self || f.EIP == "" { |
| + if f.Box == "" || f.Box == self { |
| continue |
| } |
| for host, h := range f.Hosts { |
| - if host == "" || h.Role != edgeRoleHome { |
| + if host == "" { |
| continue |
| } |
| - if prev, dup := hosts[host]; dup { |
| - // First box in sorted order wins, everywhere — except that a |
| - // ROUTE beats an ALIAS whatever the order: a tenant attaching |
| - // an operator's hostname on one box must not capture it on |
| - // every other (#287). |
| - if !(prev.alias && !h.Alias) { |
| + switch h.Role { |
| + case edgeRoleHome: |
| + // A dial target needs an address. A file with no EIP (a box |
| + // with no discoverable public IP) still carries its cert |
| + // halves below; its dial halves are simply not dialable. |
| + if f.EIP == "" { |
| + if !h.Wildcard || !strings.HasPrefix(host, "*.") { |
| + continue |
| + } |
| + h.Role = edgeRoleWildcard |
| + } |
| + case edgeRoleWildcard: |
| + // The cert-only role is defined for marked catch-all keys |
| + // alone; on an exact key it would be a KnownHost admission |
| + // with no dial behind it — refused. |
| + if !h.Wildcard || !strings.HasPrefix(host, "*.") { |
| continue |
| } |
| + default: |
| + continue |
| + } |
| + in := homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform && !h.Alias && !h.Wildcard, alias: h.Alias, wildcard: h.Wildcard, dial: h.Role == edgeRoleHome} |
| + prev, dup := hosts[host] |
| + if !dup { |
| + hosts[host] = in |
| + continue |
| + } |
| + // A collision merges CLASSES and picks ONE dial target: the |
| + // classes are facts about the name (a delegated parent's PEM |
| + // must load everywhere even if some other box also routes the |
| + // key), the dial target is a choice. First box in sorted order |
| + // wins the dial — except that a ROUTE beats an ALIAS whatever |
| + // the order (#287), and any dial target beats none. |
| + merged := prev |
| + merged.platform = prev.platform || in.platform |
| + merged.wildcard = prev.wildcard || in.wildcard |
| + if in.dial && (!prev.dial || (prev.alias && !in.alias)) { |
| + merged.eip, merged.box, merged.alias, merged.dial = in.eip, in.box, in.alias, true |
| } |
| - hosts[host] = homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform && !h.Alias, alias: h.Alias} |
| + hosts[host] = merged |
| } |
| } |
| return hosts |
| @@ -505,11 +581,11 @@ func (m *homeMap) Home(host string) (eip, box string, ok bool) { |
| return "", "", false |
| } |
| hosts := m.snapshot().hosts |
| - if h, ok := hosts[host]; ok { |
| + if h, ok := hosts[host]; ok && h.dial { |
| return h.eip, h.box, true |
| } |
| if i := strings.IndexByte(host, '.'); i > 0 { |
| - if h, ok := hosts["*"+host[i:]]; ok { |
| + if h, ok := hosts["*"+host[i:]]; ok && h.dial { |
| return h.eip, h.box, true |
| } |
| } |
diff --git a/internal/edge/wildcard.go b/internal/edge/wildcard.go| index 681c3bfa1..eb78a74e9 100644 |
| --- a/internal/edge/wildcard.go |
| +++ b/internal/edge/wildcard.go |
| @@ -44,6 +44,7 @@ import ( |
| "sort" |
| "strings" |
| "sync" |
| + "sync/atomic" |
| "time" |
| "golang.org/x/crypto/acme" |
| @@ -130,6 +131,58 @@ func newWildcardManager(staticDomains []string, appsDomain string, hosts func() |
| } |
| } |
| +// maxDelegatedParents bounds the delegated set this box will carry — |
| +// local rows plus every parent other boxes publish (#305). The per-box |
| +// cap on wildcard_domains (internal/domains, 32) exists because every |
| +// delegated parent shares one ACME account's new-order budget; a fleet's |
| +// union must not multiply that by the box count, or one broken delegation |
| +// zone would have the issuer retrying 32×N orders an hour. Local rows come |
| +// first; the overflow is logged, once per pass, never silently dropped. |
| +const maxDelegatedParents = 32 |
| + |
| +// unionDelegated is the delegated source's merge: local rows, then remote |
| +// parents, capped. A remote parent on a box with NO delegation zone is a |
| +// rollout mistake worth a line — this box could win the issuer lease and |
| +// would then fail every order for it against the customer's own zone. |
| +func unionDelegated(local, remote []string, zone string) []string { |
| + seen := make(map[string]bool, len(local)+len(remote)) |
| + out := make([]string, 0, len(local)+len(remote)) |
| + add := func(d string) { |
| + d = strings.ToLower(strings.TrimSpace(d)) |
| + if d == "" || seen[d] { |
| + return |
| + } |
| + seen[d] = true |
| + out = append(out, d) |
| + } |
| + for _, d := range local { |
| + add(d) |
| + } |
| + nLocal := len(out) |
| + for _, d := range remote { |
| + add(d) |
| + } |
| + if len(out) > nLocal && zone == "" { |
| + delegationZoneUnsetOnce.Do(func() { |
| + log.Printf("edge: %d delegated wildcard parent(s) published by other boxes but CARLOS_ACME_DELEGATION_ZONE is unset here — set it fleet-wide, or this box cannot issue for them if it becomes the issuer", len(out)-nLocal) |
| + }) |
| + } |
| + if len(out) > maxDelegatedParents { |
| + if n := int32(len(out)); delegatedCapLogged.Swap(n) != n { |
| + log.Printf("edge: delegated wildcard parents capped at %d (%d distinct: %d local, %d remote) — the overflow gets no wildcard", maxDelegatedParents, len(out), nLocal, len(out)-nLocal) |
| + } |
| + out = out[:maxDelegatedParents] |
| + } |
| + return out |
| +} |
| + |
| +// Standing conditions are said once: an unset zone stays unset, and the cap |
| +// line repeats only when the count it reports changes. |
| +var ( |
| + delegationZoneUnsetOnce sync.Once |
| + delegatedCapLogged atomic.Int32 |
| +) |
| + |
| // 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 |
| @@ -804,7 +857,7 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| out := append([]string(nil), lastGood...) |
| mu.Unlock() |
| log.Printf("edge: wildcard domains read failed: %v — delegated set unchanged this pass", err) |
| - return out |
| + return unionDelegated(out, e.homes.Load().WildcardParents(), zone) |
| } |
| out := make([]string, 0, len(wds)) |
| for _, w := range wds { |
| @@ -813,7 +866,12 @@ func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager { |
| mu.Lock() |
| lastGood = out |
| mu.Unlock() |
| - return out |
| + // Plus every delegated parent another box publishes (#305): the |
| + // issuer minted its wildcard, the mirror put the PEM on this disk, |
| + // and refresh's static/apps-domain precedence applies to these |
| + // exactly as to local rows. Read-through, never folded into |
| + // lastGood: the home map is its own last-good. |
| + return unionDelegated(out, e.homes.Load().WildcardParents(), zone) |
| } |
| // 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 |
diff --git a/internal/edge/wildcard_parents_test.go b/internal/edge/wildcard_parents_test.go| index 3a233d6bb..ddc3f3c54 100644 |
| --- a/internal/edge/wildcard_parents_test.go |
| +++ b/internal/edge/wildcard_parents_test.go |
| @@ -3,6 +3,8 @@ package edge |
| import ( |
| "context" |
| "fmt" |
| + "log" |
| + "os" |
| "path/filepath" |
| "reflect" |
| "sort" |
| @@ -297,3 +299,164 @@ func TestHomeMapRouteBeatsAliasOnCollision(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// #305, reader side: a marked "*.<parent>" entry is a delegated parent for |
| +// the wildcard manager AND a dial target for hosts one label under it; an |
| +// unmarked catch-all (a pre-#305 file, or a route's own catch-all row) is |
| +// only the latter. A wildcard entry appearing wakes the manager. |
| +func TestHomeMapWildcardParents(t *testing.T) { |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true},"*.plain.example":{"role":"home"},"app.acct.apps.test":{"role":"home","platform":true}}}`) |
| + m := newHomeMap(s, "annie") |
| + var woke atomic.Int32 |
| + m.setHostsWake(func() { woke.Add(1) }) |
| + refreshHomeMap(t, m) |
| + waitWoke(t, &woke, 1) |
| + if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"customer.example"}) { |
| + t.Fatalf("WildcardParents = %v", got) |
| + } |
| + if got := m.Hosts(); !reflect.DeepEqual(got, []string{"app.acct.apps.test"}) { |
| + t.Fatalf("a wildcard entry leaked into Hosts: %v", got) |
| + } |
| + for _, h := range []string{"pds.customer.example", "x.plain.example"} { |
| + if _, box, ok := m.Home(h); !ok || box != "betty" { |
| + t.Errorf("%s: not dialed via the catch-all", h) |
| + } |
| + } |
| + // A second delegated parent lands: wake. |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true},"*.second.example":{"role":"home","wildcard":true},"app.acct.apps.test":{"role":"home","platform":true}}}`) |
| + refreshHomeMap(t, m) |
| + waitWoke(t, &woke, 2) |
| + if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"customer.example", "second.example"}) { |
| + t.Fatalf("WildcardParents = %v", got) |
| + } |
| + // An unmarked catch-all coming or going still wakes nobody. |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true},"*.second.example":{"role":"home","wildcard":true},"app.acct.apps.test":{"role":"home","platform":true},"*.plain2.example":{"role":"home"}}}`) |
| + refreshHomeMap(t, m) |
| + time.Sleep(20 * time.Millisecond) |
| + if woke.Load() != 2 { |
| + t.Fatalf("an unmarked catch-all woke the manager (%d)", woke.Load()) |
| + } |
| + var unlabelled *homeMap |
| + if unlabelled.WildcardParents() != nil { |
| + t.Fatal("nil map reported parents") |
| + } |
| +} |
| + |
| +// The manager built from env on a region edge unions the remote delegated |
| +// parent: it is in the parent set AND delegated (challenge via the |
| +// delegation zone), alongside its own local rows. |
| +func TestWildcardManagerUnionsRemoteDelegatedParents(t *testing.T) { |
| + e, _, base := remoteHomedEdge(t, nil) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + e.reg = reg |
| + if err := reg.UpsertWildcardDomain(registry.WildcardDomain{Host: "local.example", Account: "acct", App: "app"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + putJSON(t, base, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true}}}`) |
| + refreshHomeMap(t, e.homes.Load()) |
| + t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| + t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") |
| + t.Setenv("CARLOS_DNS_PROVIDER", "route53") |
| + t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "acme.apps.test") |
| + w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| + if w == nil { |
| + t.Fatal("want a manager") |
| + } |
| + w.refresh() |
| + if got := w.currentParents(); !reflect.DeepEqual(got, []string{"customer.example", "local.example"}) { |
| + t.Fatalf("parents = %v", got) |
| + } |
| + if !w.isDelegated("customer.example") || !w.isDelegated("local.example") { |
| + t.Fatal("remote or local delegated parent not marked delegated") |
| + } |
| +} |
| + |
| +// Two boxes publishing the same "*.<parent>" key merge CLASSES and pick one |
| +// dial target (route beats alias, any beats none), whatever the sort order |
| +// — a delegated parent's PEM must load everywhere even when another box |
| +// routes the key too. A cert-only entry is a parent but never a dial target. |
| +func TestHomeMapWildcardCollisionMergesClasses(t *testing.T) { |
| + for _, order := range [][2]string{{"aaa", "zzz"}, {"zzz", "aaa"}} { |
| + routeBox, wildBox := order[0], order[1] |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/"+routeBox+".json", `{"v":1,"box":"`+routeBox+`","eip":"1.1.1.1","hosts":{"*.customer.example":{"role":"home","platform":true}}}`) |
| + putJSON(t, s, "control/edges/"+wildBox+".json", `{"v":1,"box":"`+wildBox+`","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"wildcard","wildcard":true},"*.certonly.example":{"role":"wildcard","wildcard":true}}}`) |
| + m := newHomeMap(s, "self") |
| + refreshHomeMap(t, m) |
| + if _, box, ok := m.Home("x.customer.example"); !ok || box != routeBox { |
| + t.Fatalf("order %v: dial = %s/%v; want the route's box %s", order, box, ok, routeBox) |
| + } |
| + if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"certonly.example", "customer.example"}) { |
| + t.Fatalf("order %v: WildcardParents = %v", order, got) |
| + } |
| + if _, _, ok := m.Home("x.certonly.example"); ok { |
| + t.Fatalf("order %v: a cert-only wildcard entry was a dial target", order) |
| + } |
| + } |
| + // Role wildcard on an EXACT key is refused (no KnownHost admission |
| + // without a dial); a file with no EIP still contributes cert halves. |
| + s2 := meshTestStore(t) |
| + putJSON(t, s2, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"","hosts":{"*.noeip.example":{"role":"home","wildcard":true},"victim.example":{"role":"wildcard"},"served.example":{"role":"home","platform":true}}}`) |
| + m2 := newHomeMap(s2, "self") |
| + refreshHomeMap(t, m2) |
| + if got := m2.WildcardParents(); !reflect.DeepEqual(got, []string{"noeip.example"}) { |
| + t.Fatalf("EIP-less file: WildcardParents = %v", got) |
| + } |
| + if m2.KnownHost("victim.example") || m2.KnownHost("served.example") { |
| + t.Fatal("an exact key from an EIP-less file or with role wildcard was admitted") |
| + } |
| + if _, _, ok := m2.Home("x.noeip.example"); ok { |
| + t.Fatal("an EIP-less file offered a dial target") |
| + } |
| + // A remote parent of bad shape is not a parent. |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.nodot":{"role":"wildcard","wildcard":true},"*.*.double.example":{"role":"wildcard","wildcard":true},"*.ok.example":{"role":"wildcard","wildcard":true}}}`) |
| + m := newHomeMap(s, "self") |
| + refreshHomeMap(t, m) |
| + if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"ok.example"}) { |
| + t.Fatalf("WildcardParents = %v", got) |
| + } |
| +} |
| + |
| +// The fleet's delegated set is capped where the per-box table is: local |
| +// rows first, the overflow logged and dropped. |
| +func TestUnionDelegatedCapsAndWarns(t *testing.T) { |
| + delegatedCapLogged.Store(0) // once-per-process throttles: isolate from -count>1 |
| + delegationZoneUnsetOnce = sync.Once{} |
| + var buf strings.Builder |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + local := []string{"l1.example", "l2.example"} |
| + remote := make([]string, 0, 40) |
| + for i := 0; i < 40; i++ { |
| + remote = append(remote, fmt.Sprintf("r%02d.example", i)) |
| + } |
| + remote = append(remote, "L1.example", "r00.example") // duplicates never cost a slot |
| + got := unionDelegated(local, remote, "acme.apps.test") |
| + if len(got) != maxDelegatedParents || got[0] != "l1.example" || got[1] != "l2.example" || got[2] != "r00.example" { |
| + t.Fatalf("union = %d entries, head %v", len(got), got[:3]) |
| + } |
| + if got := unionDelegated([]string{"a.example"}, []string{"A.example", "a.example", "b.example"}, "z"); !reflect.DeepEqual(got, []string{"a.example", "b.example"}) { |
| + t.Fatalf("dedupe = %v", got) |
| + } |
| + if !strings.Contains(buf.String(), "capped at 32") { |
| + t.Fatalf("overflow not logged:\n%s", buf.String()) |
| + } |
| + buf.Reset() |
| + if got := unionDelegated(local, []string{"r.example"}, ""); len(got) != 3 || !strings.Contains(buf.String(), "CARLOS_ACME_DELEGATION_ZONE is unset") { |
| + t.Fatalf("no-zone case: %v / %s", got, buf.String()) |
| + } |
| + buf.Reset() |
| + unionDelegated(local, []string{"r.example"}, "") // a standing condition is said once |
| + if buf.Len() != 0 { |
| + t.Fatalf("zone-unset line repeated:\n%s", buf.String()) |
| + } |
| + if got := unionDelegated(local, nil, ""); len(got) != 2 || buf.Len() != 0 { |
| + t.Fatalf("local-only case logged or changed: %v / %s", got, buf.String()) |
| + } |
| +} |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index 715c7467d..b44827d49 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -49,6 +49,12 @@ type edgeHost struct { |
| // route beat an alias on a name collision across boxes: a tenant |
| // attaching an operator's hostname must never capture it fleet-wide. |
| Alias bool `json:"alias,omitempty"` |
| + // Wildcard marks a delegated customer wildcard parent (wildcard_domains, |
| + // #305): the key is "*.<parent>" and every edge should load the parent's |
| + // mirrored wildcard PEM. With role "home" the box also serves the |
| + // catch-all (dial target); with role "wildcard" it is the cert half |
| + // alone. Additive (omitempty); never platform. |
| + Wildcard bool `json:"wildcard,omitempty"` |
| } |
| // edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3): |
| @@ -186,6 +192,55 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| hosts[a.Alias] = edgeHost{Role: "home", Alias: true} |
| } |
| + // Delegated customer wildcards (#305): the wildcard_domains rows are the |
| + // parents this box obtains "*.<parent>" for (a claim with the Wildcard |
| + // capability, primary homed here). Published as the catch-all key with |
| + // its own class so a remote edge both dials one-label hosts under it |
| + // (Home's rule) and loads the parent's mirrored wildcard PEM — the two |
| + // halves #302 could not deliver from an alias row alone. Never platform. |
| + wds, err := reg.ListWildcardDomains() |
| + if err != nil { |
| + return fmt.Errorf("edges: list wildcard domains: %w", err) |
| + } |
| + // Two halves, published separately: the CERT half (every edge loads the |
| + // parent's PEM) always; the DIAL half (role home — remote edges backhaul |
| + // one-label hosts under the parent) only when this box actually serves |
| + // the catch-all, i.e. the claim has Catchall and its "*.<parent>" alias |
| + // row points at a served primary. A Wildcard claim without Catchall |
| + // serves only its exact aliases (published above, each on its own |
| + // terms); advertising a dial target for it would have region edges |
| + // terminate and backhaul any invented name under the parent to a box |
| + // that answers 503. |
| + catchallServed := map[string]bool{} |
| + for _, a := range aliases { |
| + if parent, ok := strings.CutPrefix(a.Alias, "*."); ok { |
| + if _, served := hosts[a.Host]; served { |
| + catchallServed[parent] = true |
| + } |
| + } |
| + } |
| + for _, wd := range wds { |
| + if wd.Host == "" || strings.HasPrefix(wd.Host, "*.") { |
| + continue |
| + } |
| + key := "*." + wd.Host |
| + if prev, taken := hosts[key]; taken { |
| + // A real route owns the key (its catch-all row, published above): |
| + // the route keeps its role and class, and the parent's class is |
| + // ADDED — the reader's fold merges classes the same way, and a |
| + // parent whose PEM must load everywhere is a fact about the name, |
| + // not about which row got there first. |
| + prev.Wildcard = true |
| + hosts[key] = prev |
| + continue |
| + } |
| + role := "wildcard" |
| + if catchallServed[wd.Host] { |
| + role = "home" |
| + } |
| + hosts[key] = edgeHost{Role: role, Wildcard: true} |
| + } |
| + |
| if eip == "" && *last != nil { |
| var prior edgesFile |
| if err := json.Unmarshal(*last, &prior); err == nil { |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index b56d0392b..c19c1e219 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| @@ -620,6 +621,7 @@ func TestEdgesFileMarksPlatformRows(t *testing.T) { |
| // aliases, never platform-class, catch-alls never, and never over a real |
| // route. Detaching one republishes without it. |
| func TestEdgesFilePublishesPointingAliasesOfServedPrimaries(t *testing.T) { |
| + withheldAliasSaid = sync.Map{} // once per PROCESS: isolate from -count>1 |
| s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| if err := reg.Upsert(registry.Route{Host: "app.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Platform: true}); err != nil { |
| t.Fatal(err) |
| @@ -695,3 +697,74 @@ func TestEdgesFilePublishesPointingAliasesOfServedPrimaries(t *testing.T) { |
| t.Error("a detached alias was still published") |
| } |
| } |
| + |
| +// #305: delegated customer wildcard parents publish as "*.<parent>" in their |
| +// own class — never platform, never over an existing key. |
| +func TestEdgesFilePublishesDelegatedWildcardParents(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.acct.apps.test") |
| + if err := reg.Upsert(registry.Route{Host: "*.taken.example", Kind: "service", Addr: "10.0.0.1:3001", Status: "ready", Platform: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // customer.example has a served catch-all (Catchall claim) → dial half |
| + // too; certonly.example is a Wildcard claim without Catchall → cert |
| + // half only; pending.example's catch-all points at an unserved primary. |
| + if err := reg.Upsert(registry.Route{Host: "pend.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3002", Status: "pending"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, a := range []registry.RouteAlias{ |
| + {Alias: "*.customer.example", Host: "app.acct.apps.test", Account: "acct", App: "app"}, |
| + {Alias: "*.pending.example", Host: "pend.acct.apps.test", Account: "acct", App: "pend"}, |
| + } { |
| + if err := reg.UpsertAlias(a); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, w := range []registry.WildcardDomain{ |
| + {Host: "customer.example", Account: "acct", App: "app"}, |
| + {Host: "certonly.example", Account: "acct", App: "app"}, |
| + {Host: "pending.example", Account: "acct", App: "pend"}, |
| + {Host: "*.odd.example", Account: "acct", App: "app"}, // never written by the pass; refused here too |
| + {Host: "taken.example", Account: "acct", App: "app"}, // a real route owns the key |
| + } { |
| + if err := reg.UpsertWildcardDomain(w); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + var lastBody []byte |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f struct { |
| + Hosts map[string]struct { |
| + Role string `json:"role"` |
| + Platform bool `json:"platform"` |
| + Wildcard bool `json:"wildcard"` |
| + } `json:"hosts"` |
| + } |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + h, ok := f.Hosts["*.customer.example"] |
| + if !ok || h.Role != "home" || !h.Wildcard || h.Platform { |
| + t.Fatalf("*.customer.example = %+v, %v; want home, wildcard, not platform", h, ok) |
| + } |
| + for _, name := range []string{"*.certonly.example", "*.pending.example"} { |
| + h, ok := f.Hosts[name] |
| + if !ok || h.Role != "wildcard" || !h.Wildcard || h.Platform { |
| + t.Errorf("%s = %+v, %v; want the cert half only (role wildcard)", name, h, ok) |
| + } |
| + } |
| + if _, ok := f.Hosts["*.*.odd.example"]; ok { |
| + t.Error("a malformed wildcard_domains row was published") |
| + } |
| + // A real route owns the key: it keeps role and class, and the parent's |
| + // class is added — the reader must still load the PEM everywhere. |
| + if h := f.Hosts["*.taken.example"]; !h.Platform || !h.Wildcard || h.Role != "home" { |
| + t.Errorf("route-owned key = %+v; want platform AND wildcard, role home", h) |
| + } |
| +} |