edges file / console: deny entry at the config layer, the door's normaliser, a once-per-name withheld line (#287 round 2)
The tunnel-host deny entry moves to cmd/console's denyDomains — the package never read env and should not start; the plain alias branch uses NormalizeAliasHost, the door's own check, not the weaker shape test; an alias withheld for dns_status says so once per process so a region EIP missing from PLATFORM_EDGE_IPS is not a silent home-box-only domain. Stale comments about the operator listing the tunnel host corrected. Depends on #303/#304: the pointing stamp must be written on the HOME box, which only the per-box health pass does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+51
−22
cmd/console/main.go+14 −5internal/console/apidomains.go+3 −11internal/domains/aliases.go+6 −5internal/host/edgesfile.go+17 −1internal/host/edgesfile_test.go+11 −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/internal/console/apidomains.go b/internal/console/apidomains.go| index 04a6d099a..192c0f220 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -40,7 +40,6 @@ import ( |
| "log" |
| "net/http" |
| "net/url" |
| - "os" |
| "sort" |
| "strconv" |
| "strings" |
| @@ -53,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: |
| @@ -98,14 +98,6 @@ func (s *Server) deniedDomains() []string { |
| deny = append(deny, h) |
| } |
| } |
| - // The fleet hub's reserved hostname (CARLOS_TUNNEL_HOST, the edge's env — |
| - // host.env is shared with the console on the same box) is Allowed by the |
| - // edge but is no registry route, so nothing else here names it; a tenant |
| - // must not attach it (#151 said "MUST be in CONSOLE_DENY_DOMAINS" — |
| - // this makes it so without relying on the operator remembering). |
| - if h := normalizeHost(os.Getenv("CARLOS_TUNNEL_HOST")); h != "" { |
| - deny = append(deny, h) |
| - } |
| return deny |
| } |
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go| index 79b52473f..1a11a9b1a 100644 |
| --- a/internal/domains/aliases.go |
| +++ b/internal/domains/aliases.go |
| @@ -65,11 +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 !validHostShape(c.Host) { |
| - // Same check the wildcard branch below applies: 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: invalid host shape", 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/host/edgesfile.go b/internal/host/edgesfile.go| index bfa9e6d18..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" |
| @@ -103,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 { |
| @@ -158,12 +163,23 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| return fmt.Errorf("edges: list aliases: %w", err) |
| } |
| for _, a := range aliases { |
| - if a.Alias == "" || strings.HasPrefix(a.Alias, "*.") || a.DNSStatus != "pointing" { |
| + 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 |
| } |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index b29c01bbe..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" |
| @@ -663,6 +665,9 @@ func TestEdgesFilePublishesPointingAliasesOfServedPrimaries(t *testing.T) { |
| } |
| 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 { |
| @@ -676,6 +681,12 @@ func TestEdgesFilePublishesPointingAliasesOfServedPrimaries(t *testing.T) { |
| 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) |