| index b5d50aa46..2da352900 100644 |
| --- a/internal/steering/steering.go |
| +++ b/internal/steering/steering.go |
| @@ -132,11 +132,29 @@ func armedEdges(edges []edgesFileView) []edgesFileView { |
| // refuse steering:"latency" on a pool-scoped route (Task 3); this |
| // fold refuses it AGAIN, because a hand-edited bucket record can |
| // still carry both — the door guards the write path, not the bucket. |
| +// - The host must lie under appsDomain — equal to it, or ending in |
| +// "."+appsDomain. ValidRecordHost (internal/provision) deliberately |
| +// allows an honestly opted-in, out-of-zone host through the door (an |
| +// addrs record, shared pool, e.g. a console's own hostname on a |
| +// domain the deployment does not hold the Route53 zone for) — that |
| +// check is about path-traversal safety, not about which zone a name |
| +// lives in, and it has no reason to know. A steered route on such a |
| +// host reaching Route53 is what jams the fold: ChangeResourceRecordSets |
| +// rejects a name "not contained in" the target zone (or the call fails |
| +// AccessDenied under a name-bound policy), and every record in that |
| +// batch — every OTHER steered host too — fails with it, EVERY tick, |
| +// forever, because desiredState hands converge one batch and Route53 |
| +// applies a batch atomically. This check is the fold's own zone |
| +// boundary, independent of what the door already allows: a steered |
| +// host outside it is excluded, loudly, naming the CNAME path that |
| +// actually reaches the shared pool — custom domains steer via CNAME to |
| +// edge.<apps-domain>; a direct Route53 record only ever lives in the |
| +// steering zone itself. |
| // |
| // The result is sorted by Host, then run through dedupeCaseCollisions — |
| // see that function's doc for why the case-fold collision check belongs |
| // HERE and not later, once records exist. |
| -func steeredHosts(routes []registry.Route) []registry.Route { |
| +func steeredHosts(routes []registry.Route, appsDomain string) []registry.Route { |
| out := make([]registry.Route, 0, len(routes)) |
| for _, r := range routes { |
| switch r.Steering { |
| @@ -156,12 +174,33 @@ func steeredHosts(routes []registry.Route) []registry.Route { |
| log.Printf("steering: route %s excluded: pool %q set (steering requires the shared pool)", r.Host, r.Pool) |
| continue |
| } |
| + if appsDomain == "" { |
| + log.Printf("steering: route %s excluded: no apps domain configured — only an in-zone host can receive a direct steering record; custom domains steer via CNAME to edge.<apps-domain>, and a direct Route53 record only ever lives in the steering zone itself", r.Host) |
| + continue |
| + } |
| + if !inAppsZone(r.Host, appsDomain) { |
| + log.Printf("steering: route %s excluded: host is not under the apps domain %q — custom domains steer via CNAME to edge.<apps-domain>; a direct Route53 record only ever lives in the steering zone itself (Route53 would reject the whole batch, wedging every other steered host too)", r.Host, appsDomain) |
| + continue |
| + } |
| out = append(out, r) |
| } |
| sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| return dedupeCaseCollisions(out) |
| } |
| |
| +// inAppsZone reports whether host lies under appsDomain — equal to it, or a |
| +// subdomain of it — case-insensitively and trailing-dot-tolerant (registry |
| +// Host values and appsDomain both arrive un-normalized; fqdn's own |
| +// normalization runs after this check, not before, so this compares raw |
| +// input on both sides itself). appsDomain arrives already trimmed of |
| +// whitespace and a trailing dot (desiredState does that once, before any |
| +// call here). |
| +func inAppsZone(host, appsDomain string) bool { |
| + h := strings.ToLower(strings.TrimSuffix(host, ".")) |
| + d := strings.ToLower(appsDomain) |
| + return h == d || strings.HasSuffix(h, "."+d) |
| +} |
| + |
| // dedupeCaseCollisions drops the second of any two routes whose Host |
| // values differ only by case — "App.example.com" and "app.example.com" |
| // both fold to the same DNS name via fqdn (the lowercasing FINDING 1 |
| @@ -277,14 +316,17 @@ func dedupeRecords(records []desiredRecord) []desiredRecord { |
| // |
| // appsDomain empty with at least one armed edge is treated the same as |
| // any other missing input: no synthetic edge.<apps-domain> records are |
| -// minted (there is no domain to build the hostname from) — steered |
| -// per-host records are unaffected either way. The skip is logged on |
| -// EVERY call, not throttled: this function has no state to remember a |
| -// prior call's log across ticks (it is pure), so a box misconfigured |
| -// with no apps domain logs this once per housekeeper pass — the same |
| -// convention every other loud skip in this file follows (no dedup |
| -// window), and a deliberate choice: a duty this infrequent (Task 6's |
| -// 5min+jitter cadence) does not need throttling to stay readable. |
| +// minted (there is no domain to build the hostname from), AND every |
| +// per-host steered route is excluded too (steeredHosts' own appsDomain |
| +// check) — with no apps domain there is no zone to test a host against, |
| +// so no per-host name can be proven in-zone, and only an in-zone name may |
| +// ever be written (see steeredHosts). The skip is logged on EVERY call, |
| +// not throttled: this function has no state to remember a prior call's |
| +// log across ticks (it is pure), so a box misconfigured with no apps |
| +// domain logs this once per housekeeper pass — the same convention every |
| +// other loud skip in this file follows (no dedup window), and a |
| +// deliberate choice: a duty this infrequent (Task 6's 5min+jitter |
| +// cadence) does not need throttling to stay readable. |
| func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain string) []desiredRecord { |
| armed := armedEdges(edges) |
| if len(armed) == 0 { |
| @@ -292,8 +334,17 @@ func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain str |
| } |
| |
| var out []desiredRecord |
| + // TrimSpace OUTSIDE TrimSuffix, deliberately: TrimSuffix only strips a |
| + // dot that is the very last byte, so the other order (TrimSuffix then |
| + // TrimSpace) would trim the dot before the space and leave |
| + // "example.com. "'s dot behind after the space comes off. Unreachable |
| + // today — the one production caller (internal/host/steering_loop.go) |
| + // already does strings.TrimSpace(os.Getenv(...)) before handing |
| + // appsDomain to ConvergeDeployment — but a wrong order here would |
| + // misfire silently the day that stops being true. |
| + domain := strings.TrimSuffix(strings.TrimSpace(appsDomain), ".") |
| |
| - if domain := strings.TrimSpace(strings.TrimSuffix(appsDomain, ".")); domain == "" { |
| + if domain == "" { |
| log.Printf("steering: no apps domain configured — the edge.<apps-domain> steering hostname is not minted this pass") |
| } else { |
| edgeHost := fqdn("edge." + domain) |
| @@ -302,7 +353,7 @@ func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain str |
| } |
| } |
| |
| - for _, r := range steeredHosts(routes) { |
| + for _, r := range steeredHosts(routes, domain) { |
| name := fqdn(r.Host) |
| for _, e := range armed { |
| out = append(out, desiredRecord{Name: name, EIP: e.EIP, Box: e.Box, Region: e.Region}) |