| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "maps" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "slices" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/registry" |
| 11 | ) |
| 12 | |
| 13 | // upsertAlias writes one route_aliases row, the shape the reconcile pass |
| 14 | // (internal/domains) derives from a claim record. The edge only ever reads |
| 15 | // Alias and Host; account/app are carried for realism. |
| 16 | func upsertAlias(t *testing.T, reg *registry.Registry, alias, host string) { |
| 17 | t.Helper() |
| 18 | if err := reg.UpsertAlias(registry.RouteAlias{ |
| 19 | Alias: alias, Host: host, Account: "acct", App: "app", CreatedAt: 1, |
| 20 | }); err != nil { |
| 21 | t.Fatalf("upsert alias %s -> %s: %v", alias, host, err) |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | // TestAliasServesPrimaryBackend is the feature itself: a hostname attached to |
| 26 | // a ready route reaches that route's backend, with the customer's own Host |
| 27 | // header preserved end to end (the app must see the name the browser asked |
| 28 | // for, not the platform hostname). |
| 29 | func TestAliasServesPrimaryBackend(t *testing.T) { |
| 30 | backend, seenHost := hostEchoBackend(t, "hello from the primary") |
| 31 | reg := openReg(t) |
| 32 | if err := reg.Upsert(registry.Route{ |
| 33 | Host: "app.acct.oncarlos.com", Kind: "service", |
| 34 | Addr: backend.Listener.Addr().String(), Status: "ready", |
| 35 | }); err != nil { |
| 36 | t.Fatalf("upsert: %v", err) |
| 37 | } |
| 38 | upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com") |
| 39 | |
| 40 | e, err := New(reg, nil) |
| 41 | if err != nil { |
| 42 | t.Fatalf("New: %v", err) |
| 43 | } |
| 44 | srv := httptest.NewServer(e) |
| 45 | defer srv.Close() |
| 46 | |
| 47 | code, body := getHost(t, srv.URL, "www.customer.example") |
| 48 | if code != http.StatusOK { |
| 49 | t.Fatalf("status = %d, want 200 for an attached hostname", code) |
| 50 | } |
| 51 | if body != "hello from the primary" { |
| 52 | t.Fatalf("body = %q, want the primary route's backend body", body) |
| 53 | } |
| 54 | if *seenHost != "www.customer.example" { |
| 55 | t.Fatalf("backend saw Host %q, want www.customer.example (inbound Host must be preserved)", *seenHost) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // TestAliasSharesUpstreamEntry is the composition pin (#152, multi-upstream |
| 60 | // HA): the alias must map to the primary's OWN entry — the identical proxy |
| 61 | // handler value and the primary's route row — never a proxy rebuilt for the |
| 62 | // alias hostname. Everything downstream is keyed off route.Host (activator |
| 63 | // entry, lease, live symlink, cgroup) and off the proxy value's per-primary |
| 64 | // state, so a second handler would silently fork both. |
| 65 | func TestAliasSharesUpstreamEntry(t *testing.T) { |
| 66 | backend, _ := hostEchoBackend(t, "primary") |
| 67 | reg := openReg(t) |
| 68 | if err := reg.Upsert(registry.Route{ |
| 69 | Host: "app.acct.oncarlos.com", Kind: "service", |
| 70 | Addr: backend.Listener.Addr().String(), Status: "ready", |
| 71 | }); err != nil { |
| 72 | t.Fatalf("upsert: %v", err) |
| 73 | } |
| 74 | upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com") |
| 75 | |
| 76 | e, err := New(reg, nil) |
| 77 | if err != nil { |
| 78 | t.Fatalf("New: %v", err) |
| 79 | } |
| 80 | m := e.current() |
| 81 | primary, ok := m["app.acct.oncarlos.com"] |
| 82 | if !ok { |
| 83 | t.Fatalf("primary route missing from the upstream table") |
| 84 | } |
| 85 | alias, ok := m["www.customer.example"] |
| 86 | if !ok { |
| 87 | t.Fatalf("alias missing from the upstream table") |
| 88 | } |
| 89 | // Identity, not equivalent behaviour: the route carries no Version, so the |
| 90 | // handler is the *httputil.ReverseProxy pointer itself and == compares the |
| 91 | // pointer, which is exactly the claim being pinned. |
| 92 | if alias.proxy != primary.proxy { |
| 93 | t.Fatalf("alias proxy %p is not the primary's proxy %p — an alias must never get a rebuilt handler", alias.proxy, primary.proxy) |
| 94 | } |
| 95 | if alias.route.Host != "app.acct.oncarlos.com" { |
| 96 | t.Fatalf("alias route.Host = %q, want the PRIMARY's host (activator/version/purge keying all read it)", alias.route.Host) |
| 97 | } |
| 98 | if !alias.alias { |
| 99 | t.Fatalf("alias entry not marked alias — wildcard parent derivation would treat a customer domain as a platform host") |
| 100 | } |
| 101 | if primary.alias { |
| 102 | t.Fatalf("primary entry marked alias") |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // TestAliasSharesMultiUpstreamEntry is TestAliasSharesUpstreamEntry against |
| 107 | // the shape the #152 claim is actually about: a primary carrying an Addrs set |
| 108 | // and a probe config, i.e. one served by multiProxy rather than the legacy |
| 109 | // single-addr tcpProxy. Two things must hold, and only this shape can show |
| 110 | // either. |
| 111 | // |
| 112 | // The alias must ride the primary's OWN multiProxy value. That handler is |
| 113 | // closed over the PRIMARY's host and reads its live set from proberSet under |
| 114 | // that key, so an alias handed a rebuilt proxy would look up a host nothing |
| 115 | // probes, find an empty live set, and answer 503 + Retry-After for a route |
| 116 | // whose upstreams are all healthy. |
| 117 | // |
| 118 | // And the alias must add NO prober of its own: probeSpecs is built from the |
| 119 | // route pass only, before aliases are folded in, so an alias appearing there |
| 120 | // would double every upstream's probe traffic and put a duplicate host on the |
| 121 | // dashboard's per-upstream table. |
| 122 | func TestAliasSharesMultiUpstreamEntry(t *testing.T) { |
| 123 | addrA, _ := countingBackend(t, "A") |
| 124 | addrB, _ := countingBackend(t, "B") |
| 125 | reg := openReg(t) |
| 126 | if err := reg.Upsert(registry.Route{ |
| 127 | Host: "ha.acct.oncarlos.com", Kind: "service", Status: "ready", |
| 128 | Addrs: addrA + "," + addrB, |
| 129 | Health: healthJSON(t, frozenHealth()), |
| 130 | }); err != nil { |
| 131 | t.Fatalf("upsert: %v", err) |
| 132 | } |
| 133 | upsertAlias(t, reg, "www.customer.example", "ha.acct.oncarlos.com") |
| 134 | |
| 135 | e, err := New(reg, nil) |
| 136 | if err != nil { |
| 137 | t.Fatalf("New: %v", err) |
| 138 | } |
| 139 | defer e.probers.Stop() |
| 140 | |
| 141 | m := e.current() |
| 142 | primary, ok := m["ha.acct.oncarlos.com"] |
| 143 | if !ok { |
| 144 | t.Fatalf("primary route missing from the upstream table") |
| 145 | } |
| 146 | alias, ok := m["www.customer.example"] |
| 147 | if !ok { |
| 148 | t.Fatalf("alias missing from the upstream table") |
| 149 | } |
| 150 | // Identity, as in the single-addr pin above: no Version on the route, so |
| 151 | // the handler is the *httputil.ReverseProxy multiProxy returned and == |
| 152 | // compares that pointer. |
| 153 | if alias.proxy != primary.proxy { |
| 154 | t.Fatalf("alias proxy %p is not the primary's multiProxy %p — a rebuilt handler would read an unprobed live set and 503 a healthy route", alias.proxy, primary.proxy) |
| 155 | } |
| 156 | if alias.route.Host != "ha.acct.oncarlos.com" { |
| 157 | t.Fatalf("alias route.Host = %q, want the PRIMARY's host", alias.route.Host) |
| 158 | } |
| 159 | // Exactly one probed host, and it is the primary. |
| 160 | st := e.probers.State() |
| 161 | if len(st) != 1 { |
| 162 | t.Fatalf("probed hosts = %v, want exactly one (the primary) — an alias must never get a prober of its own", slices.Sorted(maps.Keys(st))) |
| 163 | } |
| 164 | if _, ok := st["ha.acct.oncarlos.com"]; !ok { |
| 165 | t.Fatalf("probed hosts = %v, want the primary probed", slices.Sorted(maps.Keys(st))) |
| 166 | } |
| 167 | // And the whole point of the identity: a request over the attached |
| 168 | // hostname reaches a live upstream instead of multiProxy's dark-set 503. |
| 169 | srv := httptest.NewServer(e) |
| 170 | defer srv.Close() |
| 171 | code, body := getHost(t, srv.URL, "www.customer.example") |
| 172 | if code != http.StatusOK || (body != "A" && body != "B") { |
| 173 | t.Fatalf("alias request = %d %q, want 200 from one of the primary's upstreams", code, body) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // TestAliasAllowed covers the autocert HostPolicy: an attached hostname is a |
| 178 | // host this box serves, so it may be certed; a stranger's SNI may not. |
| 179 | func TestAliasAllowed(t *testing.T) { |
| 180 | backend, _ := hostEchoBackend(t, "primary") |
| 181 | reg := openReg(t) |
| 182 | if err := reg.Upsert(registry.Route{ |
| 183 | Host: "app.acct.oncarlos.com", Kind: "service", |
| 184 | Addr: backend.Listener.Addr().String(), Status: "ready", |
| 185 | }); err != nil { |
| 186 | t.Fatalf("upsert: %v", err) |
| 187 | } |
| 188 | upsertAlias(t, reg, "www.customer.example", "app.acct.oncarlos.com") |
| 189 | |
| 190 | e, err := New(reg, nil) |
| 191 | if err != nil { |
| 192 | t.Fatalf("New: %v", err) |
| 193 | } |
| 194 | if !e.Allowed("www.customer.example") { |
| 195 | t.Fatalf("Allowed(www.customer.example) = false, want true for an attached hostname") |
| 196 | } |
| 197 | if e.Allowed("stranger.example") { |
| 198 | t.Fatalf("Allowed(stranger.example) = true, want false") |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // TestAliasNeverShadowsRoute: routes always win. An alias row whose hostname |
| 203 | // collides with a real route must not displace it — the route's own backend |
| 204 | // keeps answering. |
| 205 | func TestAliasNeverShadowsRoute(t *testing.T) { |
| 206 | one, _ := hostEchoBackend(t, "one") |
| 207 | two, _ := hostEchoBackend(t, "two") |
| 208 | reg := openReg(t) |
| 209 | if err := reg.Upsert(registry.Route{ |
| 210 | Host: "one.example.com", Kind: "service", |
| 211 | Addr: one.Listener.Addr().String(), Status: "ready", |
| 212 | }); err != nil { |
| 213 | t.Fatalf("upsert one: %v", err) |
| 214 | } |
| 215 | if err := reg.Upsert(registry.Route{ |
| 216 | Host: "two.example.com", Kind: "service", |
| 217 | Addr: two.Listener.Addr().String(), Status: "ready", |
| 218 | }); err != nil { |
| 219 | t.Fatalf("upsert two: %v", err) |
| 220 | } |
| 221 | // An alias claiming a hostname that is already a route of its own. |
| 222 | upsertAlias(t, reg, "two.example.com", "one.example.com") |
| 223 | |
| 224 | e, err := New(reg, nil) |
| 225 | if err != nil { |
| 226 | t.Fatalf("New: %v", err) |
| 227 | } |
| 228 | srv := httptest.NewServer(e) |
| 229 | defer srv.Close() |
| 230 | |
| 231 | code, body := getHost(t, srv.URL, "two.example.com") |
| 232 | if code != http.StatusOK || body != "two" { |
| 233 | t.Fatalf("got %d %q, want 200 \"two\" — a real route must win a collision with an alias", code, body) |
| 234 | } |
| 235 | if up := e.current()["two.example.com"]; up.alias { |
| 236 | t.Fatalf("route entry for two.example.com was overwritten by the alias") |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // TestAliasSkipsMissingPrimary: an alias whose primary is not in the table |
| 241 | // (not ready here, or not local to this box) is skipped entirely rather than |
| 242 | // mapped to nothing — it must 404 as an unknown host and must not be certed. |
| 243 | func TestAliasSkipsMissingPrimary(t *testing.T) { |
| 244 | backend, _ := hostEchoBackend(t, "should never be reached") |
| 245 | reg := openReg(t) |
| 246 | if err := reg.Upsert(registry.Route{ |
| 247 | Host: "pending.acct.oncarlos.com", Kind: "service", |
| 248 | Addr: backend.Listener.Addr().String(), Status: "pending", |
| 249 | }); err != nil { |
| 250 | t.Fatalf("upsert: %v", err) |
| 251 | } |
| 252 | upsertAlias(t, reg, "www.customer.example", "pending.acct.oncarlos.com") |
| 253 | // And one pointing at a host with no route row at all. |
| 254 | upsertAlias(t, reg, "gone.customer.example", "nosuch.acct.oncarlos.com") |
| 255 | |
| 256 | e, err := New(reg, nil) |
| 257 | if err != nil { |
| 258 | t.Fatalf("New: %v", err) |
| 259 | } |
| 260 | srv := httptest.NewServer(e) |
| 261 | defer srv.Close() |
| 262 | |
| 263 | for _, host := range []string{"www.customer.example", "gone.customer.example"} { |
| 264 | if code, _ := getHost(t, srv.URL, host); code != http.StatusNotFound { |
| 265 | t.Fatalf("status for %s = %d, want 404 (alias with no ready primary)", host, code) |
| 266 | } |
| 267 | if e.Allowed(host) { |
| 268 | t.Fatalf("Allowed(%s) = true, want false — no certificate for an alias to nowhere", host) |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | // routeRecordingActivator records the route each wake was handed, so a test |
| 274 | // can prove which host the activator was keyed on. |
| 275 | type routeRecordingActivator struct { |
| 276 | routes []registry.Route |
| 277 | } |
| 278 | |
| 279 | func (a *routeRecordingActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| 280 | a.routes = append(a.routes, rt) |
| 281 | proxy.ServeHTTP(w, r) // "wake" is instant; proxy straight through |
| 282 | return true |
| 283 | } |
| 284 | |
| 285 | func (a *routeRecordingActivator) RetireExcept(keep map[string]bool) {} |
| 286 | |
| 287 | // TestAliasWakesPrimaryOnce is the #129-class regression pin: a request to an |
| 288 | // attached hostname wakes the PRIMARY's single activator entry. The activator |
| 289 | // keys its entry, lease, live symlink and cgroup on route.Host, so an alias |
| 290 | // arriving with its own host would start a second copy of the app beside the |
| 291 | // first and fight it for the socket. |
| 292 | func TestAliasWakesPrimaryOnce(t *testing.T) { |
| 293 | backend, _ := hostEchoBackend(t, "woken") |
| 294 | reg := openReg(t) |
| 295 | if err := reg.Upsert(registry.Route{ |
| 296 | Host: "hib.acct.oncarlos.com", Kind: "instance", |
| 297 | Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: true, |
| 298 | }); err != nil { |
| 299 | t.Fatalf("upsert: %v", err) |
| 300 | } |
| 301 | upsertAlias(t, reg, "www.customer.example", "hib.acct.oncarlos.com") |
| 302 | |
| 303 | act := &routeRecordingActivator{} |
| 304 | e, err := New(reg, act) |
| 305 | if err != nil { |
| 306 | t.Fatalf("New: %v", err) |
| 307 | } |
| 308 | srv := httptest.NewServer(e) |
| 309 | defer srv.Close() |
| 310 | |
| 311 | code, body := getHost(t, srv.URL, "www.customer.example") |
| 312 | if code != http.StatusOK || body != "woken" { |
| 313 | t.Fatalf("got %d %q, want 200 woken", code, body) |
| 314 | } |
| 315 | if len(act.routes) != 1 { |
| 316 | t.Fatalf("activator saw %d wakes, want exactly 1", len(act.routes)) |
| 317 | } |
| 318 | if got := act.routes[0].Host; got != "hib.acct.oncarlos.com" { |
| 319 | t.Fatalf("activator was handed route.Host = %q, want the primary hib.acct.oncarlos.com", got) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // TestCurrentHostsSkipsAliases: wildcard parent derivation reads currentHosts, |
| 324 | // so a customer hostname riding a platform-minted primary must never appear |
| 325 | // there — otherwise a name like x.y.<apps-domain> could mint a wildcard parent |
| 326 | // and burn an ACME issuance on a domain the wildcard never covered. |
| 327 | func TestCurrentHostsSkipsAliases(t *testing.T) { |
| 328 | backend, _ := hostEchoBackend(t, "primary") |
| 329 | reg := openReg(t) |
| 330 | if err := reg.Upsert(registry.Route{ |
| 331 | Host: "app.acct.oncarlos.com", Kind: "service", |
| 332 | Addr: backend.Listener.Addr().String(), Status: "ready", Platform: true, |
| 333 | }); err != nil { |
| 334 | t.Fatalf("upsert: %v", err) |
| 335 | } |
| 336 | upsertAlias(t, reg, "shop.acct.oncarlos.com", "app.acct.oncarlos.com") |
| 337 | |
| 338 | e, err := New(reg, nil) |
| 339 | if err != nil { |
| 340 | t.Fatalf("New: %v", err) |
| 341 | } |
| 342 | got := e.currentHosts() |
| 343 | if !slices.Contains(got, "app.acct.oncarlos.com") { |
| 344 | t.Fatalf("currentHosts = %v, want it to contain the platform-minted primary", got) |
| 345 | } |
| 346 | if slices.Contains(got, "shop.acct.oncarlos.com") { |
| 347 | t.Fatalf("currentHosts = %v, must NOT contain an attached hostname", got) |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | // TestWildcardAliasRequestRouting is Task 4's sub-change 3: a catch-all |
| 352 | // alias row ("*.<host>", the shape Task 3's reconcile pass derives from a |
| 353 | // claim's Catchall+Wildcard capability) must resolve one-label subdomains at |
| 354 | // request time, folding into the SAME upstream map entry the exact primary |
| 355 | // host resolves to (#151/#152's "identical map value" rule — activator |
| 356 | // keying, version header and purge tenancy all read off that one entry). |
| 357 | // Exact hosts always win a collision with the wildcard fallback, a wildcard |
| 358 | // covers exactly one label (mirroring the TLS cert), and the apex must never |
| 359 | // match its own wildcard. |
| 360 | func TestWildcardAliasRequestRouting(t *testing.T) { |
| 361 | backend, seenHost := hostEchoBackend(t, "hello from the primary") |
| 362 | reg := openReg(t) |
| 363 | if err := reg.Upsert(registry.Route{ |
| 364 | Host: "woodstar-relay.bab.oncarlos.com", Kind: "service", |
| 365 | Addr: backend.Listener.Addr().String(), Status: "ready", |
| 366 | }); err != nil { |
| 367 | t.Fatalf("upsert: %v", err) |
| 368 | } |
| 369 | upsertAlias(t, reg, "*.woodstar.app", "woodstar-relay.bab.oncarlos.com") |
| 370 | |
| 371 | e, err := New(reg, nil) |
| 372 | if err != nil { |
| 373 | t.Fatalf("New: %v", err) |
| 374 | } |
| 375 | |
| 376 | // Entry identity: paul.woodstar.app must resolve to the SAME upstream |
| 377 | // map value as the primary host — same *httputil.ReverseProxy pointer, |
| 378 | // same route row — not a value that merely behaves the same. |
| 379 | m := e.current() |
| 380 | primary, ok := m["woodstar-relay.bab.oncarlos.com"] |
| 381 | if !ok { |
| 382 | t.Fatalf("primary route missing from the upstream table") |
| 383 | } |
| 384 | star, ok := m["*.woodstar.app"] |
| 385 | if !ok { |
| 386 | t.Fatalf("catch-all alias missing from the upstream table") |
| 387 | } |
| 388 | if star.proxy != primary.proxy { |
| 389 | t.Fatalf("*.woodstar.app proxy %p is not the primary's proxy %p", star.proxy, primary.proxy) |
| 390 | } |
| 391 | if star.route.Host != primary.route.Host { |
| 392 | t.Fatalf("*.woodstar.app route.Host = %q, want the primary's %q", star.route.Host, primary.route.Host) |
| 393 | } |
| 394 | |
| 395 | up, ok := e.lookupUpstream("paul.woodstar.app") |
| 396 | if !ok { |
| 397 | t.Fatalf("lookupUpstream(paul.woodstar.app) missed") |
| 398 | } |
| 399 | if up.proxy != primary.proxy { |
| 400 | t.Fatalf("lookupUpstream(paul.woodstar.app) resolved to a different entry than the primary (proxy %p vs %p)", up.proxy, primary.proxy) |
| 401 | } |
| 402 | |
| 403 | srv := httptest.NewServer(e) |
| 404 | defer srv.Close() |
| 405 | code, body := getHost(t, srv.URL, "paul.woodstar.app") |
| 406 | if code != http.StatusOK || body != "hello from the primary" { |
| 407 | t.Fatalf("got %d %q, want 200 from the primary's backend", code, body) |
| 408 | } |
| 409 | if *seenHost != "paul.woodstar.app" { |
| 410 | t.Fatalf("backend saw Host %q, want paul.woodstar.app (inbound Host preserved)", *seenHost) |
| 411 | } |
| 412 | |
| 413 | // Two labels deep: a wildcard covers exactly one label, mirroring the |
| 414 | // TLS cert — a.b.woodstar.app must miss. |
| 415 | if _, ok := e.lookupUpstream("a.b.woodstar.app"); ok { |
| 416 | t.Fatalf("lookupUpstream(a.b.woodstar.app) hit, want a miss (two labels — wildcard covers one)") |
| 417 | } |
| 418 | |
| 419 | // The apex itself must never match its own wildcard: "woodstar.app" has |
| 420 | // no dot before "app", so the fallback would (wrongly) probe "*.app", |
| 421 | // never "*.woodstar.app" — this pins that the apex is never routed by |
| 422 | // its own catch-all. |
| 423 | if _, ok := e.lookupUpstream("woodstar.app"); ok { |
| 424 | t.Fatalf("lookupUpstream(woodstar.app) hit, want a miss (apex must not match its own wildcard)") |
| 425 | } |
| 426 | |
| 427 | // An exact alias row wins over the wildcard fallback. |
| 428 | exactBackend, _ := hostEchoBackend(t, "exact wins") |
| 429 | if err := reg.Upsert(registry.Route{ |
| 430 | Host: "other.bab.oncarlos.com", Kind: "service", |
| 431 | Addr: exactBackend.Listener.Addr().String(), Status: "ready", |
| 432 | }); err != nil { |
| 433 | t.Fatalf("upsert other: %v", err) |
| 434 | } |
| 435 | upsertAlias(t, reg, "paul.woodstar.app", "other.bab.oncarlos.com") |
| 436 | if err := e.Reload(); err != nil { |
| 437 | t.Fatalf("reload: %v", err) |
| 438 | } |
| 439 | up, ok = e.lookupUpstream("paul.woodstar.app") |
| 440 | if !ok { |
| 441 | t.Fatalf("lookupUpstream(paul.woodstar.app) missed after exact alias added") |
| 442 | } |
| 443 | if up.route.Host != "other.bab.oncarlos.com" { |
| 444 | t.Fatalf("lookupUpstream(paul.woodstar.app) route.Host = %q, want the EXACT alias's primary other.bab.oncarlos.com", up.route.Host) |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | // TestHostPolicyExcludesWildcardFallback is the CRITICAL-1 review fix: the |
| 449 | // autocert HostPolicy (Allowed) must stay on the EXACT map only, never |
| 450 | // lookupUpstream's wildcard fallback. A catch-all alias ("*.<domain>") gets |
| 451 | // its TLS coverage from the WILDCARD cert (wrapGetCertificate checks certFor |
| 452 | // before ever reaching autocert) — Allowed only gates a NEW per-host ACME |
| 453 | // order, which is unbounded (attacker-chosen labels) and shared across every |
| 454 | // tenant on the box (one ACME account). Before this fix, a stranger's SNI |
| 455 | // random.woodstar.app would pass Allowed and drive a fresh per-host order |
| 456 | // for as long as the wildcard cert wasn't yet obtained — the normal state |
| 457 | // right after an attach, before the customer's CNAME exists. |
| 458 | // |
| 459 | // Request-time routing (lookupUpstream, unaffected by this fix) must still |
| 460 | // resolve the same one-label wildcard host — the policy split is HostPolicy |
| 461 | // vs. serving, not a routing regression. |
| 462 | func TestHostPolicyExcludesWildcardFallback(t *testing.T) { |
| 463 | backend, _ := hostEchoBackend(t, "primary") |
| 464 | reg := openReg(t) |
| 465 | if err := reg.Upsert(registry.Route{ |
| 466 | Host: "woodstar-relay.bab.oncarlos.com", Kind: "service", |
| 467 | Addr: backend.Listener.Addr().String(), Status: "ready", |
| 468 | }); err != nil { |
| 469 | t.Fatalf("upsert: %v", err) |
| 470 | } |
| 471 | upsertAlias(t, reg, "*.woodstar.app", "woodstar-relay.bab.oncarlos.com") |
| 472 | |
| 473 | e, err := New(reg, nil) |
| 474 | if err != nil { |
| 475 | t.Fatalf("New: %v", err) |
| 476 | } |
| 477 | |
| 478 | // (a) Request routing is unchanged: the wildcard-alias host still |
| 479 | // resolves at request time. |
| 480 | if _, ok := e.lookupUpstream("paul.woodstar.app"); !ok { |
| 481 | t.Fatalf("lookupUpstream(paul.woodstar.app) missed — routing must still resolve via the wildcard alias") |
| 482 | } |
| 483 | |
| 484 | // (b) HostPolicy refuses ANY one-label host under the catch-all that |
| 485 | // isn't an exact route/alias row of its own — only exact hosts are |
| 486 | // certable via the per-host path. |
| 487 | if e.Allowed("random.woodstar.app") { |
| 488 | t.Fatalf("Allowed(random.woodstar.app) = true, want false — HostPolicy must not follow the wildcard fallback (unbounded per-host ACME orders from attacker-chosen SNI)") |
| 489 | } |
| 490 | if e.Allowed("paul.woodstar.app") { |
| 491 | t.Fatalf("Allowed(paul.woodstar.app) = true, want false — same rule, no exact row for this host either") |
| 492 | } |
| 493 | |
| 494 | // An EXACT alias row is still certable, same as any other routed host. |
| 495 | upsertAlias(t, reg, "paul.woodstar.app", "woodstar-relay.bab.oncarlos.com") |
| 496 | if err := e.Reload(); err != nil { |
| 497 | t.Fatalf("reload: %v", err) |
| 498 | } |
| 499 | if !e.Allowed("paul.woodstar.app") { |
| 500 | t.Fatalf("Allowed(paul.woodstar.app) = false, want true once it has its OWN exact alias row") |
| 501 | } |
| 502 | // The still-wildcard-only host stays refused. |
| 503 | if e.Allowed("random.woodstar.app") { |
| 504 | t.Fatalf("Allowed(random.woodstar.app) = true, want false — still no exact row for it") |
| 505 | } |
| 506 | // The wildcard label itself must never be certable — even though it is |
| 507 | // the exact map key backing the catch-all fallback, refused locally |
| 508 | // rather than resting on autocert's own IDN rejection of "*". |
| 509 | if e.Allowed("*.woodstar.app") { |
| 510 | t.Fatalf("Allowed(*.woodstar.app) = true, want false — the wildcard label itself must never be certable") |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | // TestHTTPFallbackRedirectsWildcardAlias is the round-2 review fix: the :80 |
| 515 | // handler (httpFallbackHandler) is a THIRD Allowed() consumer the first fix |
| 516 | // pass missed. It answers a routing question ("does something serve this |
| 517 | // host on 443?"), not an issuance question, so it must use lookupUpstream — |
| 518 | // any host that routes via the *.woodstar.app catch-all (paul.woodstar.app, |
| 519 | // random.woodstar.app, any label) gets the classic 302 to https, even though |
| 520 | // none of them has its own exact row and Allowed() (HostPolicy) refuses all |
| 521 | // of them. A host that doesn't route at all still gets unknownHost (404 with |
| 522 | // no fallback URL configured). |
| 523 | func TestHTTPFallbackRedirectsWildcardAlias(t *testing.T) { |
| 524 | backend, _ := hostEchoBackend(t, "primary") |
| 525 | reg := openReg(t) |
| 526 | if err := reg.Upsert(registry.Route{ |
| 527 | Host: "woodstar-relay.bab.oncarlos.com", Kind: "service", |
| 528 | Addr: backend.Listener.Addr().String(), Status: "ready", |
| 529 | }); err != nil { |
| 530 | t.Fatalf("upsert: %v", err) |
| 531 | } |
| 532 | upsertAlias(t, reg, "*.woodstar.app", "woodstar-relay.bab.oncarlos.com") |
| 533 | |
| 534 | e, err := New(reg, nil) |
| 535 | if err != nil { |
| 536 | t.Fatalf("New: %v", err) |
| 537 | } |
| 538 | h := e.httpFallbackHandler() |
| 539 | |
| 540 | for _, host := range []string{"paul.woodstar.app", "random.woodstar.app"} { |
| 541 | req := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil) |
| 542 | req.Host = host |
| 543 | rec := httptest.NewRecorder() |
| 544 | h.ServeHTTP(rec, req) |
| 545 | if rec.Code != http.StatusFound { |
| 546 | t.Fatalf("%s: status = %d, want 302 — it routes via the *.woodstar.app catch-all even with no exact row", host, rec.Code) |
| 547 | } |
| 548 | if loc := rec.Header().Get("Location"); loc != "https://"+host+"/" { |
| 549 | t.Fatalf("%s: Location = %q, want https://%s/", host, loc, host) |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | // A host that doesn't route at all (no exact route, no matching |
| 554 | // wildcard alias) still hits unknownHost — no fallback URL configured |
| 555 | // here, so a plain 404. |
| 556 | req := httptest.NewRequest(http.MethodGet, "http://nowhere.example.com/", nil) |
| 557 | req.Host = "nowhere.example.com" |
| 558 | rec := httptest.NewRecorder() |
| 559 | h.ServeHTTP(rec, req) |
| 560 | if rec.Code != http.StatusNotFound { |
| 561 | t.Fatalf("nowhere.example.com: status = %d, want 404 (unknownHost, no fallback URL)", rec.Code) |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | // TestHTTPFallbackRedirectsTunnelHost is the final-review fix: the old inline |
| 566 | // :80 closure gated on Allowed(host), which special-cases the reserved tunnel |
| 567 | // hostname (see Allowed's own e.tunnels check); the extracted handler gates |
| 568 | // on lookupUpstream alone, which has no tunnel branch at all, so plain |
| 569 | // http://<tunnel-host>/ regressed to unknownHost instead of the classic |
| 570 | // redirect into the hub's own TLS handshake on 443. |
| 571 | func TestHTTPFallbackRedirectsTunnelHost(t *testing.T) { |
| 572 | e, err := New(openReg(t), nil) |
| 573 | if err != nil { |
| 574 | t.Fatalf("New: %v", err) |
| 575 | } |
| 576 | e.EnableTunnels(NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})) |
| 577 | if err := e.Reload(); err != nil { |
| 578 | t.Fatalf("reload: %v", err) |
| 579 | } |
| 580 | h := e.httpFallbackHandler() |
| 581 | |
| 582 | req := httptest.NewRequest(http.MethodGet, "http://tunnel.test/register", nil) |
| 583 | req.Host = "tunnel.test" |
| 584 | rec := httptest.NewRecorder() |
| 585 | h.ServeHTTP(rec, req) |
| 586 | if rec.Code != http.StatusFound { |
| 587 | t.Fatalf("tunnel.test: status = %d, want 302 — the reserved tunnel hostname answers on 443 and must redirect like any other routable host", rec.Code) |
| 588 | } |
| 589 | if loc := rec.Header().Get("Location"); loc != "https://tunnel.test/register" { |
| 590 | t.Fatalf("tunnel.test: Location = %q, want https://tunnel.test/register", loc) |
| 591 | } |
| 592 | } |
| 593 | |