| 1 | // Alias reconcile pass — derives the registry's route_aliases table AND its |
| 2 | // wildcard_domains and route_paths tables from the global claims recorded in |
| 3 | // the bucket (#151). A claim contributes an ordinary alias row when it names |
| 4 | // a Route (see the loop below); a Wildcard claim additionally contributes a |
| 5 | // wildcard_domains row (a delegated-ACME wildcard-cert parent), a |
| 6 | // Wildcard+Catchall claim also contributes a "*.<host>" route_aliases row |
| 7 | // pointing at the same primary, and a claim's Paths map contributes one |
| 8 | // route_paths row per prefix (path-based routing). See claims.go for the |
| 9 | // claim shape and registry.go's RouteAlias/WildcardDomain/RoutePath for the |
| 10 | // storage shapes; this file is the seam that turns one into the other. |
| 11 | package domains |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "log" |
| 16 | "slices" |
| 17 | "sort" |
| 18 | "strconv" |
| 19 | "strings" |
| 20 | "sync" |
| 21 | "time" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/registry" |
| 24 | "github.com/carlosframework/platform/internal/release" |
| 25 | ) |
| 26 | |
| 27 | // maxWildcardDomains caps wildcard_domains per box: every delegated parent |
| 28 | // shares one ACME account's new-order budget, so an unbounded claim set |
| 29 | // could starve platform issuance. Deterministic (claims come in list |
| 30 | // order); drops are logged, never silent. |
| 31 | const maxWildcardDomains = 32 |
| 32 | |
| 33 | // sidecarSuffix mirrors provision.SidecarSuffix, pinned here rather than |
| 34 | // imported — the same reason claims.go's hostLabelRe gives: internal/provision |
| 35 | // (its reconcile.go) already imports internal/domains for account/route |
| 36 | // materialization, so importing provision back would be a cycle. |
| 37 | // |
| 38 | // A sidecar host is refused a route at the structural level (edge.go's |
| 39 | // Reload skips Kind=="sidecar"), but that guarantee protects only the |
| 40 | // SIDECAR row itself. This constant guards the other direction, at the ONE |
| 41 | // place that can mint a route_aliases row from nothing: a claim naming a |
| 42 | // reserved-suffix host as either end of an alias would insert that host |
| 43 | // into the edge's routing map serving an ORDINARY route's upstream — which |
| 44 | // Reload's Kind check never sees, because the row it reads is the alias's |
| 45 | // primary, not a Kind=="sidecar" row at all. |
| 46 | const sidecarSuffix = ".sidecar.internal" |
| 47 | |
| 48 | // maxInstanceAliases caps the alias rows instance catch-alls derive on one |
| 49 | // box. Every alias row costs a serial DNS lookup in the box's health stamp |
| 50 | // and about 140 bytes in its status snapshot, which readers skip whole |
| 51 | // above 1 MiB — and a skipped box drops out of the deployment's route and |
| 52 | // edge-address folds. The cap keeps a large app from carrying a box past |
| 53 | // that. Deterministic (claims in list order, routes in host order), so a |
| 54 | // rerun keeps the same names; the overflow is logged, never silent. |
| 55 | const maxInstanceAliases = 1000 |
| 56 | |
| 57 | // instanceCatchallSaid holds the instance catch-alls whose refusal has |
| 58 | // already been logged by this process, keyed by claim host and reason. The |
| 59 | // pass runs every couple of seconds on every box, and a catch-all left |
| 60 | // without its parent claim is a standing state, not an event. |
| 61 | var instanceCatchallSaid sync.Map |
| 62 | |
| 63 | func sayInstanceCatchallOnce(host, reason string) { |
| 64 | if _, said := instanceCatchallSaid.LoadOrStore(host+"\x00"+reason, true); !said { |
| 65 | log.Printf("domains: instance catch-all %s: %s", host, reason) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // deriveInstanceAliases adds the alias rows the instance catch-alls derive |
| 70 | // (Claim.Instances): for each catch-all "*.<parent>", one row |
| 71 | // <label>.<parent> -> <instance host> per LOCAL instance route of the |
| 72 | // claim's account and app, where <label> is the route's Slug — the first |
| 73 | // label of an instance host, which is what the instance create door makes |
| 74 | // it. The rows are the same rows an exact alias claim for each name would |
| 75 | // produce, so every reader of route_aliases (the edge, the edges file, the |
| 76 | // readings sweep, the issuer) treats them exactly as it treats those. |
| 77 | // |
| 78 | // What a derived name never beats, in order: |
| 79 | // |
| 80 | // - a claim on that exact name, WHOEVER holds it — judged from the |
| 81 | // bucket LISTING, so a claim whose body failed to read this pass still |
| 82 | // wins. An exact claim is a |
| 83 | // decision somebody made about this one name; a derived name is a |
| 84 | // default. This is also what lets a same-account exact claim (www, |
| 85 | // auth) or another app's exact claim sit beside the catch-all without |
| 86 | // a special case. |
| 87 | // - a real route on this box, or one any live box in the deployment |
| 88 | // publishes (deploymentRoutes) — "routes always win", as for every |
| 89 | // alias. |
| 90 | // |
| 91 | // The catch-all itself is honoured only while its parent's own claim is |
| 92 | // held by the same account (AttachInstanceCatchall's door rule, re-checked |
| 93 | // here because the parent claim can be released afterwards). Any other |
| 94 | // field on the claim (Route, Wildcard, Catchall, Paths) means it was not |
| 95 | // written by the door, and it derives nothing. |
| 96 | // |
| 97 | // Instance routes are taken from this box's registry, so each box derives |
| 98 | // the names of the instances it serves — the same per-box scope the plain |
| 99 | // alias half has — and a name follows its instance when the instance moves. |
| 100 | // Only provisioned, non-sidecar, non-replica rows whose Slug is a single |
| 101 | // label prefixing their host count. That excludes hand-added routes (whose |
| 102 | // account column may be a legacy name that reads as ops), replica rows (a |
| 103 | // replica serves its home's account from another box under its own host; |
| 104 | // the name belongs to the home) and first-route static rows (no Slug). |
| 105 | // |
| 106 | // A label two of the app's instances share derives NO name. That can only |
| 107 | // happen after an operator changed the app's manifest domain between them |
| 108 | // (every host under one domain has its own label), and the two instances |
| 109 | // may well be homed on different boxes: a rule each box applied to its own |
| 110 | // routes would map the one name to two databases depending on which edge a |
| 111 | // request landed on. The duplicate is seen deployment-wide, from the app's |
| 112 | // instance records in the bucket (labelHosts), so every box refuses alike, |
| 113 | // and the refusal is logged. |
| 114 | // |
| 115 | // labelHosts is asked once per catch-all, and only when this box has an |
| 116 | // instance it could name. If it fails, the catch-all's existing rows are |
| 117 | // kept as they are for this pass rather than dropped: a listing blip must |
| 118 | // not take every team's name down. |
| 119 | func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []registry.Route, byHost map[string]registry.Route, existing []registry.RouteAlias, deploymentRoutes map[string]bool, desired map[string]bool, labelHosts func(account, app string) (map[string]int, error), reg *registry.Registry, now func() time.Time) { |
| 120 | claimed := make(map[string]Claim, len(claims)) |
| 121 | for _, c := range claims { |
| 122 | claimed[c.Host] = c |
| 123 | } |
| 124 | derived := 0 |
| 125 | have := make(map[string]registry.RouteAlias, len(existing)) |
| 126 | for _, a := range existing { |
| 127 | have[a.Alias] = a |
| 128 | } |
| 129 | for _, c := range claims { |
| 130 | if !c.Instances { |
| 131 | continue |
| 132 | } |
| 133 | parent, ok := ParseInstanceCatchallHost(c.Host) |
| 134 | if !ok || InstanceCatchallHost(parent) != c.Host { |
| 135 | sayInstanceCatchallOnce(c.Host, "refused: not a normalised *.<parent> hostname") |
| 136 | continue |
| 137 | } |
| 138 | if c.Route != "" || c.Wildcard || c.Catchall || len(c.Paths) > 0 { |
| 139 | sayInstanceCatchallOnce(c.Host, "refused: the claim carries a route, wildcard, catchall or paths, which no door writes on a catch-all") |
| 140 | continue |
| 141 | } |
| 142 | if strings.HasSuffix(parent, sidecarSuffix) { |
| 143 | sayInstanceCatchallOnce(c.Host, "refused: reserved sidecar suffix") |
| 144 | continue |
| 145 | } |
| 146 | // A pending parent (#215) is not held: nobody has proved they |
| 147 | // control it, so it cannot lend a catch-all its names either. |
| 148 | if apex, held := claimed[parent]; !held || apex.Account != c.Account || apex.Pending { |
| 149 | sayInstanceCatchallOnce(c.Host, "inert: its parent "+parent+" is not claimed by account "+c.Account) |
| 150 | continue |
| 151 | } |
| 152 | var hostsOf map[string]int |
| 153 | for _, rt := range routes { |
| 154 | if !rt.Provisioned || rt.IsSidecar() || rt.Replica || rt.App != c.App || release.RouteAccount(rt) != c.Account { |
| 155 | continue |
| 156 | } |
| 157 | label := rt.Slug |
| 158 | if !hostLabelRe.MatchString(label) || !strings.HasPrefix(rt.Host, label+".") { |
| 159 | continue |
| 160 | } |
| 161 | if hostsOf == nil { |
| 162 | counts, err := labelHosts(c.Account, c.App) |
| 163 | if err != nil { |
| 164 | log.Printf("domains: instance catch-all %s: list the app's instances: %v — keeping its names as they are this pass", c.Host, err) |
| 165 | for _, a := range existing { |
| 166 | if a.Account == c.Account && a.App == c.App && strings.HasSuffix(a.Alias, "."+parent) && |
| 167 | !strings.Contains(strings.TrimSuffix(a.Alias, "."+parent), ".") { |
| 168 | desired[a.Alias] = true |
| 169 | } |
| 170 | } |
| 171 | break |
| 172 | } |
| 173 | hostsOf = counts |
| 174 | } |
| 175 | if hostsOf[label] > 1 { |
| 176 | sayInstanceCatchallOnce(c.Host, "label "+label+" is shared by "+strconv.Itoa(hostsOf[label])+" instances of "+c.App+" (its instance domain was changed); "+label+"."+parent+" serves none of them until one is deleted") |
| 177 | continue |
| 178 | } |
| 179 | name := label + "." + parent |
| 180 | if n, ok := NormalizeAliasHost(name); !ok || n != name { |
| 181 | continue // too long once joined to parent |
| 182 | } |
| 183 | // listed, not claimed: a claim whose body could not be read this |
| 184 | // pass is still a claim (ClaimCache.listWithHosts). |
| 185 | if listed[name] { |
| 186 | continue |
| 187 | } |
| 188 | if _, taken := byHost[name]; taken || deploymentRoutes[name] || desired[name] { |
| 189 | continue |
| 190 | } |
| 191 | if derived >= maxInstanceAliases { |
| 192 | sayInstanceCatchallOnce(c.Host, "this box is at its cap of "+strconv.Itoa(maxInstanceAliases)+" derived names; the rest of the app's instances here serve on their own hosts only") |
| 193 | break |
| 194 | } |
| 195 | derived++ |
| 196 | desired[name] = true |
| 197 | if a, ok := have[name]; ok && a.Host == rt.Host && a.Account == c.Account && a.App == c.App { |
| 198 | continue // already the row we want: no write, so no reload |
| 199 | } |
| 200 | if err := reg.UpsertAlias(registry.RouteAlias{ |
| 201 | Alias: name, Host: rt.Host, Account: c.Account, App: c.App, |
| 202 | CreatedAt: now().Unix(), |
| 203 | }); err != nil { |
| 204 | log.Printf("domains: upsert instance alias %s: %v", name, err) |
| 205 | } |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // ReconcileAliases derives route_aliases, wildcard_domains and route_paths |
| 211 | // from the global claim records. All three tables are wholly pass-owned: |
| 212 | // every row traces to a claim, so removal needs no ownership flag. The |
| 213 | // authoritative tenancy gate is HERE — the door's route check is |
| 214 | // best-effort (it cannot see the registry); a claim only materializes when |
| 215 | // its primary route exists locally AND its account/app match the claim. |
| 216 | // Routes always win a host collision: an alias (plain or catch-all) must |
| 217 | // never shadow a real route. |
| 218 | // |
| 219 | // deploymentRoutes is every hostname a live box in this deployment publishes |
| 220 | // as a REAL route (edgesfile.DeploymentRoutes). It extends "routes always |
| 221 | // win" from this box to the whole fleet, which is the only place the rule |
| 222 | // can be enforced at all: byHost below sees only local routes, and the |
| 223 | // attach door cannot see the registry, because an operator-minted route has |
| 224 | // no instance record to read. nil means "nothing known" and is PERMISSIVE by |
| 225 | // contract — a bucket the fold could not read must never break ordinary |
| 226 | // attach. |
| 227 | // cache, when non-nil, folds the claims through a ClaimCache instead of |
| 228 | // re-reading every claim body. This pass runs on the fast-converge leg — a |
| 229 | // seconds cadence, on every serial bump — so the difference is one bucket |
| 230 | // call against one plus a GET per claim in the deployment, every few |
| 231 | // seconds, on every box. nil is legal and means today's uncached fold. |
| 232 | func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time, deploymentRoutes map[string]bool, cache *ClaimCache) error { |
| 233 | claims, listed, err := cache.listWithHosts(ctx, s) |
| 234 | if err != nil { |
| 235 | return err |
| 236 | } |
| 237 | routes, err := reg.List() |
| 238 | if err != nil { |
| 239 | return err |
| 240 | } |
| 241 | byHost := make(map[string]registry.Route, len(routes)) |
| 242 | for _, rt := range routes { |
| 243 | byHost[rt.Host] = rt |
| 244 | } |
| 245 | existing, err := reg.ListAliases() |
| 246 | if err != nil { |
| 247 | return err |
| 248 | } |
| 249 | desired := map[string]bool{} |
| 250 | desiredWild := map[string]registry.WildcardDomain{} |
| 251 | desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix |
| 252 | wildCount := 0 |
| 253 | for _, c := range claims { |
| 254 | // A pending claim (#215) is a reservation by someone who has not |
| 255 | // proved they control the hostname, so it derives nothing at all: |
| 256 | // no alias, no prefix rows, no wildcard parent, no catch-all. That |
| 257 | // one skip is what keeps an unproven claim from being served and, |
| 258 | // because the edge orders certificates only for names it routes, |
| 259 | // from getting a certificate. |
| 260 | if c.Pending { |
| 261 | continue |
| 262 | } |
| 263 | // Plain alias derivation (#164): a routeless (first-route) claim is |
| 264 | // not an alias — that skip is scoped to THIS half only, so a |
| 265 | // routeless Wildcard claim still falls through to the wildcard |
| 266 | // derivation below. |
| 267 | if c.Route != "" { |
| 268 | rt, ok := byHost[c.Route] |
| 269 | if strings.HasSuffix(c.Route, sidecarSuffix) || strings.HasSuffix(c.Host, sidecarSuffix) { |
| 270 | // Refused on EITHER end: a reserved-suffix primary must |
| 271 | // never be given a routable name, and an alias whose OWN |
| 272 | // name is under the suffix would insert a sidecar |
| 273 | // hostname into the routing map serving an ordinary |
| 274 | // route's upstream — the direction edge.go's Kind check |
| 275 | // cannot see, because this row's Kind is never "sidecar". |
| 276 | log.Printf("domains: alias %s refused: reserved sidecar suffix (primary %s)", c.Host, c.Route) |
| 277 | } else if !ok { |
| 278 | // primary not on this box — another box's claim |
| 279 | } else if rt.App != c.App || release.RouteAccount(rt) != c.Account { |
| 280 | log.Printf("domains: alias %s refused: route %s belongs to %s/%s, claim says %s/%s", |
| 281 | c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App) |
| 282 | } else if _, taken := byHost[c.Host]; taken { |
| 283 | log.Printf("domains: alias %s skipped: a real route owns that host", c.Host) |
| 284 | } else if n, ok := NormalizeAliasHost(c.Host); !ok || n != c.Host { |
| 285 | // The DOOR's own check (NormalizeAliasHost: shape plus an |
| 286 | // interior dot, already lower-case): both doors normalise, |
| 287 | // but this row now becomes a key in a file every edge folds |
| 288 | // (#287), so a hand-written claim must not reach it. |
| 289 | log.Printf("domains: alias %s refused: not a normalised hostname", c.Host) |
| 290 | } else if deploymentRoutes[c.Host] { |
| 291 | // Another box in this deployment serves this hostname as a |
| 292 | // REAL route. byHost above can only see this box, and the |
| 293 | // attach door cannot see the registry at all, so this is the |
| 294 | // only layer that can refuse a claim on someone else's |
| 295 | // operator-minted route. Because the desired set is rebuilt |
| 296 | // every pass, a claim that materialized before this rule |
| 297 | // existed is dropped here too. |
| 298 | // |
| 299 | // Ordered AFTER the shape check on purpose: both refusals are |
| 300 | // log-only (no caller sees them, so there is no ordering |
| 301 | // oracle here), and a malformed hostname should be named as |
| 302 | // malformed rather than as somebody else's route. |
| 303 | log.Printf("domains: alias %s refused: another box serves that hostname as a route", c.Host) |
| 304 | } else { |
| 305 | desired[c.Host] = true |
| 306 | if err := reg.UpsertAlias(registry.RouteAlias{ |
| 307 | Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App, |
| 308 | CreatedAt: now().Unix(), |
| 309 | }); err != nil { |
| 310 | log.Printf("domains: upsert alias %s: %v", c.Host, err) |
| 311 | } |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | // Path derivation (prefix routing). A claim's Paths map splits its own |
| 316 | // hostname's traffic by leading URL segment; each entry names the |
| 317 | // hostname of the route that serves that segment. |
| 318 | // |
| 319 | // Gated on the claim's host being a LOCAL route whose tenancy matches |
| 320 | // the claim — the same per-box rule the alias half above follows, and |
| 321 | // for the same reason: the prefix map belongs to that route, so a box |
| 322 | // that does not serve the route derives nothing. |
| 323 | // |
| 324 | // The TARGET is resolved through the same local byHost, which makes |
| 325 | // co-location a hard constraint of the feature: a prefix's target |
| 326 | // route must be homed on the same box as the hostname's own route. |
| 327 | // There is no backhaul equivalent for a path target — backhaul fires |
| 328 | // on a host miss, and the host resolves fine here — so a target on |
| 329 | // another box derives no row anywhere at all. See the unresolved log |
| 330 | // below, and design spec §6. |
| 331 | // |
| 332 | // Ordered BEFORE the wildcard section on purpose: Paths is a third |
| 333 | // claim capability independent of Wildcard (claims.go's comment on |
| 334 | // the field), and the wildcard section below `continue`s for every |
| 335 | // non-Wildcard claim — placed after that, a Paths-only claim would |
| 336 | // never reach this block. |
| 337 | if len(c.Paths) > 0 { |
| 338 | hostRt, ok := byHost[c.Host] |
| 339 | if strings.HasSuffix(c.Host, sidecarSuffix) { |
| 340 | // The alias half's refusal, for the table path-based |
| 341 | // routing added afterwards. A route_paths row keyed on a |
| 342 | // reserved-suffix hostname is the same mistake in a |
| 343 | // different table: it inserts a sidecar's name into the |
| 344 | // edge's routing map, and edge.go's Kind check never sees |
| 345 | // it because the row it reads is a path row, not a |
| 346 | // Kind=="sidecar" route. |
| 347 | log.Printf("domains: paths on %s refused: reserved sidecar suffix", c.Host) |
| 348 | } else if !ok { |
| 349 | // The hostname's own route is not on this box; another box |
| 350 | // derives these rows. |
| 351 | } else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account { |
| 352 | log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s", |
| 353 | c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App) |
| 354 | } else { |
| 355 | // Sorted so the cap below truncates DETERMINISTICALLY: every |
| 356 | // box must derive the identical subset, or the same URL would |
| 357 | // resolve differently depending on which edge answered. |
| 358 | prefixes := make([]string, 0, len(c.Paths)) |
| 359 | for p := range c.Paths { |
| 360 | prefixes = append(prefixes, p) |
| 361 | } |
| 362 | sort.Strings(prefixes) |
| 363 | if len(prefixes) > MaxPathsPerClaim { |
| 364 | log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order", |
| 365 | c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim) |
| 366 | prefixes = prefixes[:MaxPathsPerClaim] |
| 367 | } |
| 368 | // Unresolved targets are counted and named ONCE per host per |
| 369 | // pass, not logged per prefix: this pass runs every ~2s on |
| 370 | // every box, and a 10k-entry map whose targets are all |
| 371 | // elsewhere would otherwise write 10k lines every two |
| 372 | // seconds. The count is the operator's signal; the first few |
| 373 | // names are enough to identify which map is wrong. |
| 374 | unresolved := 0 |
| 375 | var unresolvedEg []string |
| 376 | for _, prefix := range prefixes { |
| 377 | target := c.Paths[prefix] |
| 378 | if !validPathPrefix(prefix) { |
| 379 | log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix) |
| 380 | continue |
| 381 | } |
| 382 | if strings.HasSuffix(target, sidecarSuffix) { |
| 383 | // The sharper half of the same refusal, and the one |
| 384 | // the account check above cannot make: a sidecar is |
| 385 | // the claim's OWN account's, so pointing a prefix at |
| 386 | // it passes every tenancy rule this block has. It |
| 387 | // must still be refused, because the target is the |
| 388 | // one field here that becomes a proxied upstream — |
| 389 | // this is the door through which an account could |
| 390 | // dial its own undialable member. |
| 391 | log.Printf("domains: path %s/%s refused: target %s is under the reserved sidecar suffix — nothing routes to a sidecar", |
| 392 | c.Host, prefix, target) |
| 393 | continue |
| 394 | } |
| 395 | tgtRt, ok := byHost[target] |
| 396 | // The authorization rule this feature adds: a prefix must |
| 397 | // never point another tenant's traffic at your backend. |
| 398 | // Account only — an account routing between its own apps |
| 399 | // (classic and Go ARE two apps) is the whole use case. |
| 400 | // Checked before readiness so a cross-tenant target is |
| 401 | // named as cross-tenant rather than swept into the |
| 402 | // unresolved count below. |
| 403 | if ok && release.RouteAccount(tgtRt) != c.Account { |
| 404 | log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s", |
| 405 | c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account) |
| 406 | continue |
| 407 | } |
| 408 | if !ok || tgtRt.Status != "ready" { |
| 409 | // The target is not a local READY route, which has |
| 410 | // three distinct causes with three different |
| 411 | // remedies, and the log below cannot tell them apart |
| 412 | // from here: |
| 413 | // |
| 414 | // 1. The target route does not exist anywhere — a |
| 415 | // typo in the published map, or a route deleted |
| 416 | // out from under it. Remedy: fix the map, or |
| 417 | // re-create the route. |
| 418 | // 2. The target exists but is HOMED ON ANOTHER BOX. |
| 419 | // byHost holds this box's routes only, and there |
| 420 | // is no backhaul equivalent for a path target |
| 421 | // (backhaul fires on a HOST miss), so nothing |
| 422 | // derives the row anywhere. Remedy: co-locate — |
| 423 | // a prefix's target must be homed on the same box |
| 424 | // as the hostname's own route (design spec §6). |
| 425 | // 3. The target is local but not ready yet — a |
| 426 | // route mid-provision. Self-resolving; the row |
| 427 | // appears on the pass after it goes ready. |
| 428 | // |
| 429 | // No row either way, so the prefix falls through to |
| 430 | // the host's own route — the right failure direction |
| 431 | // for a migration: a missing destination serves the |
| 432 | // ORIGIN, never a 404. Silent traffic is the danger |
| 433 | // here, not broken traffic, which is why this is |
| 434 | // logged at all. |
| 435 | unresolved++ |
| 436 | if len(unresolvedEg) < 3 && !slices.Contains(unresolvedEg, target) { |
| 437 | unresolvedEg = append(unresolvedEg, target) |
| 438 | } |
| 439 | continue |
| 440 | } |
| 441 | desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{ |
| 442 | Host: c.Host, Prefix: prefix, Target: target, |
| 443 | Account: c.Account, App: c.App, |
| 444 | } |
| 445 | } |
| 446 | if unresolved > 0 { |
| 447 | log.Printf("domains: paths on %s: %d prefix(es) name no local ready route (e.g. %s) — the target is missing, or it is homed on another box; a path target must be homed on the SAME box as %s. Those prefixes serve %s's own route.", |
| 448 | c.Host, unresolved, strings.Join(unresolvedEg, ", "), c.Host, c.Host) |
| 449 | } |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | // Wildcard/catch-all derivation. A claim with Catchall but not |
| 454 | // Wildcard cannot come from the write path (SetClaimCapabilities |
| 455 | // refuses it), but a hand-written or pre-migration claim could carry |
| 456 | // it — tolerated, logged, nothing derived. |
| 457 | if c.Catchall && !c.Wildcard { |
| 458 | log.Printf("domains: catch-all %s skipped: claim has catchall without wildcard", c.Host) |
| 459 | } |
| 460 | if !c.Wildcard { |
| 461 | continue |
| 462 | } |
| 463 | if !validHostShape(c.Host) { |
| 464 | log.Printf("domains: wildcard %s refused: invalid host shape", c.Host) |
| 465 | continue |
| 466 | } |
| 467 | primaryHost := c.Route |
| 468 | if primaryHost == "" { |
| 469 | primaryHost = c.Host // first-route claim: its own materialized primary |
| 470 | } |
| 471 | // A named-Route claim's own Host can itself be a local route (e.g. a |
| 472 | // domain already routed AND also carrying a wildcard cert opt-in for |
| 473 | // its subdomains). That's fine when the tenant matches; a mismatched |
| 474 | // tenant means a different app/account already owns this hostname |
| 475 | // directly, and the wildcard claim must not shadow it. The |
| 476 | // empty-Route (first-route) case is safe by construction: there |
| 477 | // primaryHost == c.Host, so this is the same route checked below. |
| 478 | if c.Route != "" { |
| 479 | if hostRt, taken := byHost[c.Host]; taken { |
| 480 | if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account { |
| 481 | log.Printf("domains: wildcard %s refused: a real route owns that host under %s/%s", |
| 482 | c.Host, release.RouteAccount(hostRt), hostRt.App) |
| 483 | continue |
| 484 | } |
| 485 | } |
| 486 | } |
| 487 | rt, ok := byHost[primaryHost] |
| 488 | if !ok { |
| 489 | continue // primary not on this box — another box's claim |
| 490 | } |
| 491 | if rt.App != c.App || release.RouteAccount(rt) != c.Account { |
| 492 | log.Printf("domains: wildcard %s refused: route %s belongs to %s/%s, claim says %s/%s", |
| 493 | c.Host, primaryHost, release.RouteAccount(rt), rt.App, c.Account, c.App) |
| 494 | continue |
| 495 | } |
| 496 | if wildCount >= maxWildcardDomains { |
| 497 | log.Printf("domains: wildcard %s dropped: per-box cap %d reached", c.Host, maxWildcardDomains) |
| 498 | continue |
| 499 | } |
| 500 | wildCount++ |
| 501 | desiredWild[c.Host] = registry.WildcardDomain{ |
| 502 | Host: c.Host, Account: c.Account, App: c.App, CreatedAt: now().Unix(), |
| 503 | } |
| 504 | if c.Catchall { |
| 505 | star := "*." + c.Host |
| 506 | if strings.HasSuffix(star, sidecarSuffix) || strings.HasSuffix(primaryHost, sidecarSuffix) { |
| 507 | // Same both-ends guard as the plain-alias derivation above, |
| 508 | // through this route_aliases writer's OTHER call site: a |
| 509 | // catch-all's alias name is star, and star inherits c.Host's |
| 510 | // suffix, so this also catches a wildcard host minted under |
| 511 | // the reserved suffix. |
| 512 | log.Printf("domains: catch-all %s refused: reserved sidecar suffix (primary %s)", star, primaryHost) |
| 513 | } else if _, taken := byHost[star]; taken { |
| 514 | log.Printf("domains: catch-all %s skipped: a real route owns that host", star) |
| 515 | } else if deploymentRoutes[star] { |
| 516 | // THE STAR, never c.Host. A first-route claim's own host is |
| 517 | // published as a real (non-alias) route by the claimant's OWN |
| 518 | // box — that is what "first route" means — so keying this on |
| 519 | // c.Host would refuse the catch-all for every wildcard |
| 520 | // first-route claim in the deployment, on the very box that |
| 521 | // owns it. The woodstar.app class, live today. What must not |
| 522 | // be shadowed here is a real route literally NAMED "*.<host>", |
| 523 | // which is what star asks about. |
| 524 | log.Printf("domains: catch-all %s refused: another box serves that hostname as a route", star) |
| 525 | } else { |
| 526 | desired[star] = true |
| 527 | if err := reg.UpsertAlias(registry.RouteAlias{ |
| 528 | Alias: star, Host: primaryHost, Account: c.Account, App: c.App, |
| 529 | CreatedAt: now().Unix(), |
| 530 | }); err != nil { |
| 531 | log.Printf("domains: upsert catch-all %s: %v", star, err) |
| 532 | } |
| 533 | } |
| 534 | } |
| 535 | } |
| 536 | deriveInstanceAliases(claims, listed, routes, byHost, existing, deploymentRoutes, desired, |
| 537 | func(account, app string) (map[string]int, error) { return InstanceLabelCounts(ctx, s, account, app) }, reg, now) |
| 538 | for _, a := range existing { |
| 539 | if !desired[a.Alias] { |
| 540 | if err := reg.DeleteAlias(a.Alias); err != nil { |
| 541 | log.Printf("domains: remove alias %s: %v", a.Alias, err) |
| 542 | } |
| 543 | } |
| 544 | } |
| 545 | |
| 546 | // wildcard_domains is wholly pass-owned, like route_aliases. |
| 547 | existingWild, err := reg.ListWildcardDomains() |
| 548 | if err != nil { |
| 549 | return err |
| 550 | } |
| 551 | for h, w := range desiredWild { |
| 552 | cur := false |
| 553 | for _, e := range existingWild { |
| 554 | if e.Host == h && e.Account == w.Account && e.App == w.App { |
| 555 | cur = true |
| 556 | break |
| 557 | } |
| 558 | } |
| 559 | if !cur { |
| 560 | if err := reg.UpsertWildcardDomain(w); err != nil { |
| 561 | log.Printf("domains: upsert wildcard %s: %v", h, err) |
| 562 | } |
| 563 | } |
| 564 | } |
| 565 | for _, e := range existingWild { |
| 566 | if _, ok := desiredWild[e.Host]; !ok { |
| 567 | if err := reg.DeleteWildcardDomain(e.Host); err != nil { |
| 568 | log.Printf("domains: remove wildcard %s: %v", e.Host, err) |
| 569 | } |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | // route_paths is wholly pass-owned, like route_aliases and |
| 574 | // wildcard_domains: every row traces to a claim's Paths entry, so a |
| 575 | // prefix removed from a republished map disappears here with no delete |
| 576 | // verb anywhere. That is exactly how a per-account rollback reaches the |
| 577 | // edge. |
| 578 | existingPaths, err := reg.ListPaths() |
| 579 | if err != nil { |
| 580 | return err |
| 581 | } |
| 582 | for _, p := range desiredPaths { |
| 583 | if err := reg.UpsertPath(p); err != nil { |
| 584 | log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err) |
| 585 | } |
| 586 | } |
| 587 | for _, p := range existingPaths { |
| 588 | if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok { |
| 589 | if err := reg.DeletePath(p.Host, p.Prefix); err != nil { |
| 590 | log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err) |
| 591 | } |
| 592 | } |
| 593 | } |
| 594 | return nil |
| 595 | } |
| 596 | |