Merge pull request #302 from carlosframework/aliases-in-edges-file
edges file: publish attached custom domains under their own names (#287)
8 files changed,
+245
−10
cmd/console/main.go+14 −5docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md+18 −0internal/console/apidomains.go+3 −2internal/domains/aliases.go+6 −0internal/edge/homemap.go+14 −3internal/edge/wildcard_parents_test.go+47 −0internal/host/edgesfile.go+60 −0internal/host/edgesfile_test.go+83 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 335177c32..a70e25246 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -678,11 +678,11 @@ func redirectHosts() []string { |
| // denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and |
| // everything under them, that `carlos domains attach` refuses (issue #151). |
| // This is the operator's list of names the console cannot derive on its own: |
| -// the edge's reserved tunnel hostname, retired console origins, any other |
| -// platform-managed domain. The console's own origin host and |
| -// PLATFORM_APPS_DOMAIN are added by the door itself (deniedDomains, |
| -// internal/console/apidomains.go), so an unset variable is a legal — and |
| -// still safe — deployment. Blank entries are dropped: an empty string in the |
| +// retired console origins, any other platform-managed domain. The edge's |
| +// reserved tunnel hostname is folded in below from CARLOS_TUNNEL_HOST; the |
| +// console's own origin host and PLATFORM_APPS_DOMAIN are added by the door |
| +// itself (deniedDomains, internal/console/apidomains.go), so an unset |
| +// variable is a legal — and still safe — deployment. Blank entries are dropped: an empty string in the |
| // list would be a deny entry no hostname could avoid matching. |
| func denyDomains() []string { |
| var out []string |
| @@ -691,6 +691,15 @@ func denyDomains() []string { |
| out = append(out, d) |
| } |
| } |
| + // The fleet hub's reserved hostname (CARLOS_TUNNEL_HOST — the edge's |
| + // variable, but host.env is one file per box) is Allowed by the edge and |
| + // is no registry route, so no derived deny entry names it; a tenant must |
| + // not attach it. #151 made it the operator's job to list; folding it in |
| + // here makes the MUST hold without anyone remembering. Env is read at |
| + // this layer only, as every other console setting is. |
| + if h := strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_TUNNEL_HOST"))); h != "" { |
| + out = append(out, strings.TrimSuffix(h, ".")) |
| + } |
| return out |
| } |
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 8bf68d77c..e4d111856 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,24 @@ 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 (#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`, |
| +never `platform`), so `Home` backhauls it and `KnownHost` admits it on |
| +every edge — any edge answers its validations. Bounds: the `pointing` |
| +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 |
| +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 |
| +#299: a customer subdomain `CNAME → edge.<apps-domain>` works through |
| +any edge with steering and failover; an apex needs the current edge-IP |
| +set. |
| + |
| **Order amplification, accepted.** The set of names a stranger can induce |
| a fresh ACME order for at the issuer grows from one box's routes to every |
| box's routes. Each such order for a name whose DNS is dead burns that |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 0b1ee0fe4..192c0f220 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -52,8 +52,9 @@ import ( |
| ) |
| // deniedDomains is the deny list every attach is checked against: the |
| -// operator's own CONSOLE_DENY_DOMAINS entries, plus the three names this |
| -// console can derive without being told (spec §5: "all derived from |
| +// operator's CONSOLE_DENY_DOMAINS entries (cmd/console folds the tunnel |
| +// hostname into those), plus the names this console can derive without |
| +// being told (spec §5: "all derived from |
| // console/deployment config, not hardcoded"). |
| // |
| // The derived three are not optional and not the operator's to forget: |
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go| index 27e0e7c61..1a11a9b1a 100644 |
| --- a/internal/domains/aliases.go |
| +++ b/internal/domains/aliases.go |
| @@ -65,6 +65,12 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App) |
| } else if _, taken := byHost[c.Host]; taken { |
| log.Printf("domains: alias %s skipped: a real route owns that host", c.Host) |
| + } else if n, ok := NormalizeAliasHost(c.Host); !ok || n != c.Host { |
| + // The DOOR's own check (NormalizeAliasHost: shape plus an |
| + // interior dot, already lower-case): both doors normalise, |
| + // but this row now becomes a key in a file every edge folds |
| + // (#287), so a hand-written claim must not reach it. |
| + log.Printf("domains: alias %s refused: not a normalised hostname", c.Host) |
| } else { |
| desired[c.Host] = true |
| if err := reg.UpsertAlias(registry.RouteAlias{ |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index d8931fcf2..3ef5cf39a 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -138,6 +138,9 @@ type edgesHostView struct { |
| // existed, which reads as "not platform" — fail closed for the one |
| // consumer that cares (Hosts, the wildcard parent set). |
| Platform bool `json:"platform,omitempty"` |
| + // 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"` |
| } |
| // homeEntry is one folded answer: where to dial, and which box that is. |
| @@ -146,6 +149,8 @@ type homeEntry struct { |
| box string |
| // platform carries the publisher's class mark through the fold. |
| platform bool |
| + // alias marks an attached custom domain's entry (never platform). |
| + alias bool |
| } |
| // homeSnapshot is one poll's result, swapped in whole and immutable once |
| @@ -459,10 +464,16 @@ func foldHomes(files map[string]edgesFileView, self string) map[string]homeEntry |
| if host == "" || h.Role != edgeRoleHome { |
| continue |
| } |
| - if _, dup := hosts[host]; dup { |
| - continue // first box in sorted order wins, everywhere |
| + 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) { |
| + continue |
| + } |
| } |
| - hosts[host] = homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform} |
| + hosts[host] = homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform && !h.Alias, alias: h.Alias} |
| } |
| } |
| return hosts |
diff --git a/internal/edge/wildcard_parents_test.go b/internal/edge/wildcard_parents_test.go| index a97052123..3a233d6bb 100644 |
| --- a/internal/edge/wildcard_parents_test.go |
| +++ b/internal/edge/wildcard_parents_test.go |
| @@ -250,3 +250,50 @@ func TestWildcardRefreshIsSerialized(t *testing.T) { |
| t.Fatalf("parents = %v after generation %d", got, final) |
| } |
| } |
| + |
| +// #287, reader side: an attached custom domain published under its own |
| +// name (never platform-class) backhauls, passes the cert gates' exact |
| +// admission, and derives no wildcard parent; its catch-all form dials but |
| +// is never an exact admission (#180). |
| +func TestAliasEntriesBackhaulAndAdmitButDeriveNoParent(t *testing.T) { |
| + e, _, base := remoteHomedEdge(t, nil) |
| + putJSON(t, base, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"www.customer.com":{"role":"home","platform":false,"alias":true},"*.customer.com":{"role":"home","platform":false,"alias":true},"app.acct.apps.test":{"role":"home","platform":true}}}`) |
| + hm := e.homes.Load() |
| + refreshHomeMap(t, hm) |
| + if _, box, ok := hm.Home("www.customer.com"); !ok || box != "betty" { |
| + t.Fatal("alias entry does not backhaul") |
| + } |
| + if _, _, ok := hm.Home("shop.customer.com"); !ok { |
| + t.Fatal("catch-all alias does not dial one label under it") |
| + } |
| + if !e.deploymentHost("www.customer.com") { |
| + t.Fatal("alias entry not admitted by the cert gates") |
| + } |
| + if e.deploymentHost("*.customer.com") || e.deploymentHost("shop.customer.com") { |
| + t.Fatal("a catch-all alias must never be an exact cert admission (#180)") |
| + } |
| + if got := hm.Hosts(); !reflect.DeepEqual(got, []string{"app.acct.apps.test"}) { |
| + t.Fatalf("customer names leaked into the parent input: %v", got) |
| + } |
| +} |
| + |
| +// A route beats an alias on a cross-box name collision whatever the |
| +// boxes' sort order (#287): a tenant attaching an operator's hostname |
| +// captures nothing beyond its own box. And an alias can never smuggle the |
| +// platform mark. |
| +func TestHomeMapRouteBeatsAliasOnCollision(t *testing.T) { |
| + for _, order := range [][2]string{{"aaa", "zzz"}, {"zzz", "aaa"}} { |
| + routeBox, aliasBox := order[0], order[1] |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/"+routeBox+".json", `{"v":1,"box":"`+routeBox+`","eip":"1.1.1.1","hosts":{"status.platform.test":{"role":"home","platform":false}}}`) |
| + putJSON(t, s, "control/edges/"+aliasBox+".json", `{"v":1,"box":"`+aliasBox+`","eip":"2.2.2.2","hosts":{"status.platform.test":{"role":"home","platform":true,"alias":true}}}`) |
| + m := newHomeMap(s, "self") |
| + refreshHomeMap(t, m) |
| + if _, box, ok := m.Home("status.platform.test"); !ok || box != routeBox { |
| + t.Fatalf("order %v: home = %s; want the route's box %s", order, box, routeBox) |
| + } |
| + if got := m.Hosts(); len(got) != 0 { |
| + t.Fatalf("order %v: an alias entry reached the parent input: %v", order, got) |
| + } |
| + } |
| +} |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index e40f4bea8..715c7467d 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -9,6 +9,7 @@ import ( |
| "math/rand/v2" |
| "os" |
| "strings" |
| + "sync" |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -43,6 +44,11 @@ type edgeHost struct { |
| // homing nothing platform-class. A reader seeing no flag treats the host |
| // as unclassed and derives no parent from it (#300). |
| Platform bool `json:"platform"` |
| + // Alias marks an attached custom domain (route_aliases, #287) as opposed |
| + // to a registry route. Additive (omitempty). The reader's fold lets a |
| + // 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"` |
| } |
| // edgesFile is the wire shape at edgesFileKey(box), schema v1 (spec Part 3): |
| @@ -98,6 +104,10 @@ type edgesFile struct { |
| // of the change gate like every other field except Generation: a region |
| // relabel is a real change and must write, not be swallowed as a |
| // timestamp-only refresh would be. |
| +// withheldAliasSaid throttles the not-published line in edgesFilePass to |
| +// once per alias per process (cleared when the alias publishes). |
| +var withheldAliasSaid sync.Map |
| + |
| func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, box, eip, region string, last *[]byte) error { |
| routes, err := reg.List() |
| if err != nil { |
| @@ -125,6 +135,56 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| // (the leading dot of the first label only) is tried next. |
| hosts[r.Host] = edgeHost{Role: "home", Platform: r.Platform || r.Provisioned} |
| } |
| + // Attached custom domains (#151) live in route_aliases, not routes, and |
| + // were absent from this file (#287): a non-home edge neither backhauled |
| + // them (Home missed) nor answered their validations or let the issuer |
| + // order for them (KnownHost missed). Publish each alias whose primary is |
| + // a served row above, under the alias's own name — the reader wants |
| + // exact names, and every reader already does the right thing with one: |
| + // Home dials the home box with the alias as SNI, and the home box's own |
| + // upstream map serves it. Bounds, each doing work: |
| + // |
| + // - only an alias whose DNS the sweep has stamped "pointing" (at one of |
| + // PLATFORM_EDGE_IPS). Attach needs no DNS proof, so the alias set is |
| + // tenant-chosen and unbounded; publishing every one would hand a |
| + // tenant an unbounded set of names the shared issuer will order for |
| + // (#277's amplification envelope covers routes, which the platform |
| + // provisions). A name that actually resolves to this deployment is |
| + // the name whose validations must be answerable everywhere; |
| + // - never platform-class: a customer's name derives no wildcard parent; |
| + // - marked alias: the reader lets a real route on another box beat it; |
| + // - no "*.parent" catch-all alias: a remote edge could dial it but has |
| + // no cert for it (delegated wildcard parents are box-local), so it |
| + // stays exactly as before — served on its home box only; |
| + // - an alias shadowed by a real route here is the route's, as |
| + // edge.Reload rules. |
| + aliases, err := reg.ListAliases() |
| + if err != nil { |
| + return fmt.Errorf("edges: list aliases: %w", err) |
| + } |
| + for _, a := range aliases { |
| + if a.Alias == "" || strings.HasPrefix(a.Alias, "*.") { |
| + continue |
| + } |
| + if _, served := hosts[a.Host]; !served { |
| + continue |
| + } |
| + if a.DNSStatus != "pointing" { |
| + // Withheld, and said so ONCE per name per process: an alias |
| + // whose DNS resolves to an address outside PLATFORM_EDGE_IPS |
| + // (a region EIP missing from that list is the usual cause) is |
| + // otherwise served on its home box only, silently. |
| + if _, said := withheldAliasSaid.LoadOrStore(a.Alias, true); !said { |
| + log.Printf("edges: alias %s not published — dns_status %q, want \"pointing\" (does PLATFORM_EDGE_IPS list every armed edge?)", a.Alias, a.DNSStatus) |
| + } |
| + continue |
| + } |
| + withheldAliasSaid.Delete(a.Alias) |
| + if _, taken := hosts[a.Alias]; taken { |
| + continue |
| + } |
| + hosts[a.Alias] = edgeHost{Role: "home", Alias: true} |
| + } |
| if eip == "" && *last != nil { |
| var prior edgesFile |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index 79966559a..b56d0392b 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -3,6 +3,8 @@ package host |
| import ( |
| "context" |
| "encoding/json" |
| + "log" |
| + "os" |
| "path/filepath" |
| "strings" |
| "testing" |
| @@ -612,3 +614,84 @@ func TestEdgesFileMarksPlatformRows(t *testing.T) { |
| t.Errorf("mark not emitted for an unclassed row: %s", body) |
| } |
| } |
| + |
| +// #287: attached custom domains are published under their own names — |
| +// only when their primary is served AND their DNS points here, marked as |
| +// aliases, never platform-class, catch-alls never, and never over a real |
| +// route. Detaching one republishes without it. |
| +func TestEdgesFilePublishesPointingAliasesOfServedPrimaries(t *testing.T) { |
| + 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) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "pending.acct.apps.test", Kind: "service", Addr: "10.0.0.1:3001", Status: "pending"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "real.example.net", Kind: "service", Addr: "10.0.0.1:3002", Status: "ready", Platform: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, a := range []registry.RouteAlias{ |
| + {Alias: "www.customer.com", Host: "app.acct.apps.test", Account: "acct", App: "app", DNSStatus: "pointing"}, |
| + {Alias: "unpointed.customer.com", Host: "app.acct.apps.test", Account: "acct", App: "app", DNSStatus: "elsewhere"}, |
| + {Alias: "fresh.customer.com", Host: "app.acct.apps.test", Account: "acct", App: "app"}, |
| + {Alias: "*.customer.com", Host: "app.acct.apps.test", Account: "acct", App: "app", DNSStatus: "pointing"}, |
| + {Alias: "orphan.example.org", Host: "pending.acct.apps.test", Account: "acct", App: "pending", DNSStatus: "pointing"}, |
| + {Alias: "real.example.net", Host: "app.acct.apps.test", Account: "acct", App: "app", DNSStatus: "pointing"}, // shadowed |
| + } { |
| + if err := reg.UpsertAlias(a); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + type hostView struct { |
| + Role string `json:"role"` |
| + Platform bool `json:"platform"` |
| + Alias bool `json:"alias"` |
| + } |
| + publish := func() map[string]hostView { |
| + t.Helper() |
| + 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]hostView `json:"hosts"` |
| + } |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + return f.Hosts |
| + } |
| + var buf strings.Builder |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + hosts := publish() |
| + h, ok := hosts["www.customer.com"] |
| + if !ok || h.Role != "home" || !h.Alias || h.Platform { |
| + t.Errorf("www.customer.com = %+v, %v; want home, alias, not platform", h, ok) |
| + } |
| + for _, absent := range []string{"unpointed.customer.com", "fresh.customer.com", "*.customer.com", "orphan.example.org"} { |
| + if _, ok := hosts[absent]; ok { |
| + t.Errorf("%s must not be published", absent) |
| + } |
| + } |
| + if h := hosts["real.example.net"]; !h.Platform || h.Alias { |
| + t.Errorf("an alias colliding with a real route displaced the route's entry: %+v", h) |
| + } |
| + // A withheld alias says so once per process, not once per pass. |
| + publish() |
| + publish() |
| + if n := strings.Count(buf.String(), "unpointed.customer.com not published"); n != 1 { |
| + t.Errorf("withheld line logged %d times over 3 passes; want 1:\n%s", n, buf.String()) |
| + } |
| + // Detach: gone on the next pass. |
| + if err := reg.DeleteAlias("www.customer.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := publish()["www.customer.com"]; ok { |
| + t.Error("a detached alias was still published") |
| + } |
| +} |