| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "log" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "sync/atomic" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "golang.org/x/crypto/acme/autocert" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/domains" |
| 20 | "github.com/carlosframework/platform/internal/registry" |
| 21 | ) |
| 22 | |
| 23 | // waitWoke polls an atomic wake counter — the wake is fired off the |
| 24 | // refresh goroutine, so a test observes it, never assumes it. |
| 25 | func waitWoke(t *testing.T, woke *atomic.Int32, want int32) { |
| 26 | t.Helper() |
| 27 | deadline := time.Now().Add(2 * time.Second) |
| 28 | for woke.Load() != want { |
| 29 | if time.Now().After(deadline) { |
| 30 | t.Fatalf("woke = %d; want %d", woke.Load(), want) |
| 31 | } |
| 32 | time.Sleep(time.Millisecond) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // #300: the home map exposes the deployment's platform-class names, and a |
| 37 | // changed platform name set wakes whoever asked (the wildcard manager). |
| 38 | func TestHomeMapHostsAndWakeOnChange(t *testing.T) { |
| 39 | s := meshTestStore(t) |
| 40 | putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"own.acct.apps.test":{"role":"home","platform":true}}}`) |
| 41 | putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"cribado.bad.apps.test":{"role":"home","platform":true},"*.catch.test":{"role":"home","platform":true},"stray.foo.apps.test":{"role":"home"}}}`) |
| 42 | m := newHomeMap(s, "annie") |
| 43 | var woke atomic.Int32 |
| 44 | m.setHostsWake(func() { woke.Add(1) }) |
| 45 | refreshHomeMap(t, m) |
| 46 | if got, want := m.Hosts(), []string{"cribado.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| 47 | t.Fatalf("Hosts = %v; want %v (own box, catch-all and unclassed rows excluded)", got, want) |
| 48 | } |
| 49 | waitWoke(t, &woke, 1) |
| 50 | refreshHomeMap(t, m) // unchanged: no wake |
| 51 | time.Sleep(20 * time.Millisecond) |
| 52 | if woke.Load() != 1 { |
| 53 | t.Fatalf("an unchanged refresh woke the manager (%d)", woke.Load()) |
| 54 | } |
| 55 | // Home moved, and an UNCLASSED name came and went: still no wake. |
| 56 | putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"3.3.3.3","hosts":{"cribado.bad.apps.test":{"role":"home","platform":true},"*.catch.test":{"role":"home","platform":true},"other.foo.apps.test":{"role":"home"}}}`) |
| 57 | refreshHomeMap(t, m) |
| 58 | time.Sleep(20 * time.Millisecond) |
| 59 | if woke.Load() != 1 { |
| 60 | t.Fatalf("a moved home / unclassed churn woke the manager (%d)", woke.Load()) |
| 61 | } |
| 62 | // Same count, different platform name (a swap in one poll): wakes. |
| 63 | putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"3.3.3.3","hosts":{"other.bad.apps.test":{"role":"home","platform":true},"*.catch.test":{"role":"home","platform":true}}}`) |
| 64 | refreshHomeMap(t, m) |
| 65 | waitWoke(t, &woke, 2) |
| 66 | putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home","platform":true}}}`) |
| 67 | refreshHomeMap(t, m) |
| 68 | waitWoke(t, &woke, 3) |
| 69 | if got, want := m.Hosts(), []string{"new.bae.apps.test", "other.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| 70 | t.Fatalf("Hosts = %v; want %v", got, want) |
| 71 | } |
| 72 | |
| 73 | // A name LOSING its mark (a home box rolled back below the mark) is a |
| 74 | // change: the parent must leave, and the manager must hear about it. |
| 75 | putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"}}}`) |
| 76 | refreshHomeMap(t, m) |
| 77 | waitWoke(t, &woke, 4) |
| 78 | if got, want := m.Hosts(), []string{"other.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| 79 | t.Fatalf("Hosts after unmark = %v; want %v", got, want) |
| 80 | } |
| 81 | // A marked catch-all coming and going moves nothing and wakes nobody. |
| 82 | putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"},"*.claimed.apps.test":{"role":"home","platform":true}}}`) |
| 83 | refreshHomeMap(t, m) |
| 84 | time.Sleep(20 * time.Millisecond) |
| 85 | if woke.Load() != 4 { |
| 86 | t.Fatalf("a catch-all woke the manager (%d)", woke.Load()) |
| 87 | } |
| 88 | if got, want := m.Hosts(), []string{"other.bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| 89 | t.Fatalf("Hosts after catch-all = %v; want %v", got, want) |
| 90 | } |
| 91 | |
| 92 | var nilMap *homeMap |
| 93 | if nilMap.Hosts() != nil { |
| 94 | t.Fatal("nil map must report no hosts") |
| 95 | } |
| 96 | nilMap.setHostsWake(func() {}) // no panic |
| 97 | // An unlabelled box (hand-run `carlos edge`) would fold its OWN file in |
| 98 | // as another box's: it reports nothing, as Home does. |
| 99 | unlabelled := newHomeMap(s, "") |
| 100 | refreshHomeMap(t, unlabelled) |
| 101 | if got := unlabelled.Hosts(); got != nil { |
| 102 | t.Fatalf("unlabelled box reported %v", got) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // A file written before the class mark existed carries no flag: nothing |
| 107 | // in it derives a parent until its box republishes. Fail closed. |
| 108 | func TestHomeMapHostsIgnoresPreMarkFiles(t *testing.T) { |
| 109 | s := meshTestStore(t) |
| 110 | putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"cribado.bad.apps.test":{"role":"home"}}}`) |
| 111 | m := newHomeMap(s, "annie") |
| 112 | refreshHomeMap(t, m) |
| 113 | if got := m.Hosts(); len(got) != 0 { |
| 114 | t.Fatalf("pre-mark file contributed %v", got) |
| 115 | } |
| 116 | if _, _, ok := m.Home("cribado.bad.apps.test"); !ok { |
| 117 | t.Fatal("the class mark must not affect backhaul") |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // platformHomedOn is homedOn with the publisher's class mark set — the |
| 122 | // shape a real edges file has for platform-minted rows (#300). |
| 123 | func platformHomedOn(t *testing.T, m *homeMap, box, eip string, hosts ...string) { |
| 124 | t.Helper() |
| 125 | entries := make([]string, 0, len(hosts)) |
| 126 | for _, h := range hosts { |
| 127 | entries = append(entries, fmt.Sprintf("%q:{\"role\":\"home\",\"platform\":true}", h)) |
| 128 | } |
| 129 | putJSON(t, m.s, "control/edges/"+box+".json", fmt.Sprintf( |
| 130 | `{"v":1,"box":%q,"eip":%q,"hosts":{%s}}`, box, eip, strings.Join(entries, ","))) |
| 131 | refreshHomeMap(t, m) |
| 132 | } |
| 133 | |
| 134 | // deploymentHosts is this box's routes plus the home map's names, deduped. |
| 135 | func TestDeploymentHostsUnionsTheHomeMap(t *testing.T) { |
| 136 | e, _, _ := remoteHomedEdge(t, []string{"own.acct.apps.test"}) |
| 137 | platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "cribado.bad.apps.test", "own.acct.apps.test") |
| 138 | got := e.deploymentHosts() |
| 139 | sort.Strings(got) |
| 140 | if want := []string{"cribado.bad.apps.test", "own.acct.apps.test"}; !reflect.DeepEqual(got, want) { |
| 141 | t.Fatalf("deploymentHosts = %v; want %v", got, want) |
| 142 | } |
| 143 | // No deployment store, no home map: a single-box deployment is its own. |
| 144 | lone := testIssuerEdge("own.acct.apps.test") |
| 145 | if lone.homes.Load() != nil { |
| 146 | t.Fatal("test edge unexpectedly has a home map") |
| 147 | } |
| 148 | if got := lone.deploymentHosts(); !reflect.DeepEqual(got, lone.currentHosts()) { |
| 149 | t.Fatalf("no home map: deploymentHosts %v != currentHosts %v", got, lone.currentHosts()) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // The manager built from env derives the parent of an app it only ever |
| 154 | // backhauls to — the region-edge shape that failed on tito stage 6. |
| 155 | func TestWildcardParentsIncludeRemoteHomedAccounts(t *testing.T) { |
| 156 | e, _, _ := remoteHomedEdge(t, []string{"static.bdj.apps.test"}) |
| 157 | platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "cribado.bad.apps.test", "foreign.report") |
| 158 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | t.Cleanup(func() { reg.Close() }) |
| 163 | e.reg = reg |
| 164 | t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| 165 | t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") |
| 166 | t.Setenv("CARLOS_DNS_PROVIDER", "route53") |
| 167 | t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "") |
| 168 | w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| 169 | if w == nil { |
| 170 | t.Fatal("want a manager") |
| 171 | } |
| 172 | w.refresh() |
| 173 | // testIssuerEdge's own rows carry no trust flag, so currentHosts (#290, |
| 174 | // pinned in wildcard_test.go) contributes nothing here: what is left is |
| 175 | // exactly the remote account — and never the foreign domain. |
| 176 | if got, want := w.currentParents(), []string{"bad.apps.test"}; !reflect.DeepEqual(got, want) { |
| 177 | t.Fatalf("parents = %v; want %v (remote account included, foreign domain not)", got, want) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // The production wiring, end to end: a remote box's file landing grows the |
| 182 | // manager's parent set on the home map's poll, and its leaving shrinks it. |
| 183 | func TestHomeMapWakeReachesTheWildcardManager(t *testing.T) { |
| 184 | e, _, base := remoteHomedEdge(t, nil) |
| 185 | platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "seed.bdj.apps.test") |
| 186 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 187 | if err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | t.Cleanup(func() { reg.Close() }) |
| 191 | e.reg = reg |
| 192 | t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| 193 | t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") |
| 194 | t.Setenv("CARLOS_DNS_PROVIDER", "route53") |
| 195 | t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "") |
| 196 | w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| 197 | hm := e.homes.Load() |
| 198 | hm.setHostsWake(w.refresh) |
| 199 | w.refresh() |
| 200 | if got := w.currentParents(); !reflect.DeepEqual(got, []string{"bdj.apps.test"}) { |
| 201 | t.Fatalf("seed parents = %v", got) |
| 202 | } |
| 203 | putJSON(t, base, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"cribado.bad.apps.test":{"role":"home","platform":true}}}`) |
| 204 | refreshHomeMap(t, hm) |
| 205 | waitParents(t, w, []string{"bad.apps.test", "bdj.apps.test"}) |
| 206 | if err := base.Delete(context.Background(), "control/edges/carol.json"); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | refreshHomeMap(t, hm) |
| 210 | waitParents(t, w, []string{"bdj.apps.test"}) |
| 211 | } |
| 212 | |
| 213 | func waitParents(t *testing.T, w *wildcardManager, want []string) { |
| 214 | t.Helper() |
| 215 | deadline := time.Now().Add(2 * time.Second) |
| 216 | for { |
| 217 | if got := w.currentParents(); reflect.DeepEqual(got, want) { |
| 218 | return |
| 219 | } |
| 220 | if time.Now().After(deadline) { |
| 221 | t.Fatalf("parents = %v; want %v", w.currentParents(), want) |
| 222 | } |
| 223 | time.Sleep(time.Millisecond) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // refresh() is serialized: whatever interleaving the three callers produce, |
| 228 | // the parent set that lands last is the one derived from the latest hosts. |
| 229 | func TestWildcardRefreshIsSerialized(t *testing.T) { |
| 230 | var gen atomic.Int32 |
| 231 | hosts := func() []string { |
| 232 | n := gen.Load() |
| 233 | // A slow read: the interleaving window the mutex closes. |
| 234 | time.Sleep(200 * time.Microsecond) |
| 235 | // Sqid-shaped labels: parentsFor derives a parent only from a label |
| 236 | // that could be an account (#215). |
| 237 | return []string{"app." + domains.Sqid(int64(n)) + ".apps.test"} |
| 238 | } |
| 239 | w := newWildcardManager(nil, "apps.test", hosts, func() []string { return nil }, "", autocert.DirCache(t.TempDir()), nil) |
| 240 | var wg sync.WaitGroup |
| 241 | for i := 0; i < 8; i++ { |
| 242 | wg.Add(1) |
| 243 | go func() { |
| 244 | defer wg.Done() |
| 245 | for j := 0; j < 25; j++ { |
| 246 | gen.Add(1) |
| 247 | w.refresh() |
| 248 | } |
| 249 | }() |
| 250 | } |
| 251 | wg.Wait() |
| 252 | final := gen.Load() |
| 253 | w.refresh() |
| 254 | if got := w.currentParents(); !reflect.DeepEqual(got, []string{domains.Sqid(int64(final)) + ".apps.test"}) { |
| 255 | t.Fatalf("parents = %v after generation %d", got, final) |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // #287, reader side: an attached custom domain published under its own |
| 260 | // name (never platform-class) backhauls, passes the cert gates' exact |
| 261 | // admission, and derives no wildcard parent; its catch-all form dials but |
| 262 | // is never an exact admission (#180). |
| 263 | func TestAliasEntriesBackhaulAndAdmitButDeriveNoParent(t *testing.T) { |
| 264 | e, _, base := remoteHomedEdge(t, nil) |
| 265 | 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}}}`) |
| 266 | hm := e.homes.Load() |
| 267 | refreshHomeMap(t, hm) |
| 268 | if _, box, ok := hm.Home("www.customer.com"); !ok || box != "betty" { |
| 269 | t.Fatal("alias entry does not backhaul") |
| 270 | } |
| 271 | if _, _, ok := hm.Home("shop.customer.com"); !ok { |
| 272 | t.Fatal("catch-all alias does not dial one label under it") |
| 273 | } |
| 274 | if !e.deploymentHost("www.customer.com") { |
| 275 | t.Fatal("alias entry not admitted by the cert gates") |
| 276 | } |
| 277 | if e.deploymentHost("*.customer.com") || e.deploymentHost("shop.customer.com") { |
| 278 | t.Fatal("a catch-all alias must never be an exact cert admission (#180)") |
| 279 | } |
| 280 | if got := hm.Hosts(); !reflect.DeepEqual(got, []string{"app.acct.apps.test"}) { |
| 281 | t.Fatalf("customer names leaked into the parent input: %v", got) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // A route beats an alias on a cross-box name collision whatever the |
| 286 | // boxes' sort order (#287): a tenant attaching an operator's hostname |
| 287 | // captures nothing beyond its own box. And an alias can never smuggle the |
| 288 | // platform mark. |
| 289 | func TestHomeMapRouteBeatsAliasOnCollision(t *testing.T) { |
| 290 | for _, order := range [][2]string{{"aaa", "zzz"}, {"zzz", "aaa"}} { |
| 291 | routeBox, aliasBox := order[0], order[1] |
| 292 | s := meshTestStore(t) |
| 293 | putJSON(t, s, "control/edges/"+routeBox+".json", `{"v":1,"box":"`+routeBox+`","eip":"1.1.1.1","hosts":{"status.platform.test":{"role":"home","platform":false}}}`) |
| 294 | 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}}}`) |
| 295 | m := newHomeMap(s, "self") |
| 296 | refreshHomeMap(t, m) |
| 297 | if _, box, ok := m.Home("status.platform.test"); !ok || box != routeBox { |
| 298 | t.Fatalf("order %v: home = %s; want the route's box %s", order, box, routeBox) |
| 299 | } |
| 300 | if got := m.Hosts(); len(got) != 0 { |
| 301 | t.Fatalf("order %v: an alias entry reached the parent input: %v", order, got) |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | // #305, reader side: a marked "*.<parent>" entry is a delegated parent for |
| 307 | // the wildcard manager AND a dial target for hosts one label under it; an |
| 308 | // unmarked catch-all (a pre-#305 file, or a route's own catch-all row) is |
| 309 | // only the latter. A wildcard entry appearing wakes the manager. |
| 310 | func TestHomeMapWildcardParents(t *testing.T) { |
| 311 | s := meshTestStore(t) |
| 312 | putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true},"*.plain.example":{"role":"home"},"app.acct.apps.test":{"role":"home","platform":true}}}`) |
| 313 | m := newHomeMap(s, "annie") |
| 314 | var woke atomic.Int32 |
| 315 | m.setHostsWake(func() { woke.Add(1) }) |
| 316 | refreshHomeMap(t, m) |
| 317 | waitWoke(t, &woke, 1) |
| 318 | if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"customer.example"}) { |
| 319 | t.Fatalf("WildcardParents = %v", got) |
| 320 | } |
| 321 | if got := m.Hosts(); !reflect.DeepEqual(got, []string{"app.acct.apps.test"}) { |
| 322 | t.Fatalf("a wildcard entry leaked into Hosts: %v", got) |
| 323 | } |
| 324 | for _, h := range []string{"pds.customer.example", "x.plain.example"} { |
| 325 | if _, box, ok := m.Home(h); !ok || box != "betty" { |
| 326 | t.Errorf("%s: not dialed via the catch-all", h) |
| 327 | } |
| 328 | } |
| 329 | // A second delegated parent lands: wake. |
| 330 | putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true},"*.second.example":{"role":"home","wildcard":true},"app.acct.apps.test":{"role":"home","platform":true}}}`) |
| 331 | refreshHomeMap(t, m) |
| 332 | waitWoke(t, &woke, 2) |
| 333 | if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"customer.example", "second.example"}) { |
| 334 | t.Fatalf("WildcardParents = %v", got) |
| 335 | } |
| 336 | // An unmarked catch-all coming or going still wakes nobody. |
| 337 | putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true},"*.second.example":{"role":"home","wildcard":true},"app.acct.apps.test":{"role":"home","platform":true},"*.plain2.example":{"role":"home"}}}`) |
| 338 | refreshHomeMap(t, m) |
| 339 | time.Sleep(20 * time.Millisecond) |
| 340 | if woke.Load() != 2 { |
| 341 | t.Fatalf("an unmarked catch-all woke the manager (%d)", woke.Load()) |
| 342 | } |
| 343 | var unlabelled *homeMap |
| 344 | if unlabelled.WildcardParents() != nil { |
| 345 | t.Fatal("nil map reported parents") |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | // The manager built from env on a region edge unions the remote delegated |
| 350 | // parent: it is in the parent set AND delegated (challenge via the |
| 351 | // delegation zone), alongside its own local rows. |
| 352 | func TestWildcardManagerUnionsRemoteDelegatedParents(t *testing.T) { |
| 353 | e, _, base := remoteHomedEdge(t, nil) |
| 354 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 355 | if err != nil { |
| 356 | t.Fatal(err) |
| 357 | } |
| 358 | t.Cleanup(func() { reg.Close() }) |
| 359 | e.reg = reg |
| 360 | if err := reg.UpsertWildcardDomain(registry.WildcardDomain{Host: "local.example", Account: "acct", App: "app"}); err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | putJSON(t, base, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true}}}`) |
| 364 | refreshHomeMap(t, e.homes.Load()) |
| 365 | t.Setenv("CARLOS_WILDCARD_DOMAINS", "") |
| 366 | t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") |
| 367 | t.Setenv("CARLOS_DNS_PROVIDER", "route53") |
| 368 | t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "acme.apps.test") |
| 369 | w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) |
| 370 | if w == nil { |
| 371 | t.Fatal("want a manager") |
| 372 | } |
| 373 | w.refresh() |
| 374 | if got := w.currentParents(); !reflect.DeepEqual(got, []string{"customer.example", "local.example"}) { |
| 375 | t.Fatalf("parents = %v", got) |
| 376 | } |
| 377 | if !w.isDelegated("customer.example") || !w.isDelegated("local.example") { |
| 378 | t.Fatal("remote or local delegated parent not marked delegated") |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | // Two boxes publishing the same "*.<parent>" key merge CLASSES and pick one |
| 383 | // dial target (route beats alias, any beats none), whatever the sort order |
| 384 | // — a delegated parent's PEM must load everywhere even when another box |
| 385 | // routes the key too. A cert-only entry is a parent but never a dial target. |
| 386 | func TestHomeMapWildcardCollisionMergesClasses(t *testing.T) { |
| 387 | for _, order := range [][2]string{{"aaa", "zzz"}, {"zzz", "aaa"}} { |
| 388 | routeBox, wildBox := order[0], order[1] |
| 389 | s := meshTestStore(t) |
| 390 | putJSON(t, s, "control/edges/"+routeBox+".json", `{"v":1,"box":"`+routeBox+`","eip":"1.1.1.1","hosts":{"*.customer.example":{"role":"home","platform":true}}}`) |
| 391 | putJSON(t, s, "control/edges/"+wildBox+".json", `{"v":1,"box":"`+wildBox+`","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"wildcard","wildcard":true},"*.certonly.example":{"role":"wildcard","wildcard":true}}}`) |
| 392 | m := newHomeMap(s, "self") |
| 393 | refreshHomeMap(t, m) |
| 394 | if _, box, ok := m.Home("x.customer.example"); !ok || box != routeBox { |
| 395 | t.Fatalf("order %v: dial = %s/%v; want the route's box %s", order, box, ok, routeBox) |
| 396 | } |
| 397 | if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"certonly.example", "customer.example"}) { |
| 398 | t.Fatalf("order %v: WildcardParents = %v", order, got) |
| 399 | } |
| 400 | if _, _, ok := m.Home("x.certonly.example"); ok { |
| 401 | t.Fatalf("order %v: a cert-only wildcard entry was a dial target", order) |
| 402 | } |
| 403 | } |
| 404 | // Role wildcard on an EXACT key is refused (no KnownHost admission |
| 405 | // without a dial); a file with no EIP still contributes cert halves. |
| 406 | s2 := meshTestStore(t) |
| 407 | putJSON(t, s2, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"","hosts":{"*.noeip.example":{"role":"home","wildcard":true},"victim.example":{"role":"wildcard"},"served.example":{"role":"home","platform":true}}}`) |
| 408 | m2 := newHomeMap(s2, "self") |
| 409 | refreshHomeMap(t, m2) |
| 410 | if got := m2.WildcardParents(); !reflect.DeepEqual(got, []string{"noeip.example"}) { |
| 411 | t.Fatalf("EIP-less file: WildcardParents = %v", got) |
| 412 | } |
| 413 | if m2.KnownHost("victim.example") || m2.KnownHost("served.example") { |
| 414 | t.Fatal("an exact key from an EIP-less file or with role wildcard was admitted") |
| 415 | } |
| 416 | if _, _, ok := m2.Home("x.noeip.example"); ok { |
| 417 | t.Fatal("an EIP-less file offered a dial target") |
| 418 | } |
| 419 | // A remote parent of bad shape is not a parent. |
| 420 | s := meshTestStore(t) |
| 421 | putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.nodot":{"role":"wildcard","wildcard":true},"*.*.double.example":{"role":"wildcard","wildcard":true},"*.ok.example":{"role":"wildcard","wildcard":true}}}`) |
| 422 | m := newHomeMap(s, "self") |
| 423 | refreshHomeMap(t, m) |
| 424 | if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"ok.example"}) { |
| 425 | t.Fatalf("WildcardParents = %v", got) |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // The fleet's delegated set is capped where the per-box table is: local |
| 430 | // rows first, the overflow logged and dropped. |
| 431 | func TestUnionDelegatedCapsAndWarns(t *testing.T) { |
| 432 | delegatedCapLogged.Store(0) // once-per-process throttles: isolate from -count>1 |
| 433 | delegationZoneUnsetOnce = sync.Once{} |
| 434 | var buf strings.Builder |
| 435 | log.SetOutput(&buf) |
| 436 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 437 | local := []string{"l1.example", "l2.example"} |
| 438 | remote := make([]string, 0, 40) |
| 439 | for i := 0; i < 40; i++ { |
| 440 | remote = append(remote, fmt.Sprintf("r%02d.example", i)) |
| 441 | } |
| 442 | remote = append(remote, "L1.example", "r00.example") // duplicates never cost a slot |
| 443 | got := unionDelegated(local, remote, "acme.apps.test") |
| 444 | if len(got) != maxDelegatedParents || got[0] != "l1.example" || got[1] != "l2.example" || got[2] != "r00.example" { |
| 445 | t.Fatalf("union = %d entries, head %v", len(got), got[:3]) |
| 446 | } |
| 447 | if got := unionDelegated([]string{"a.example"}, []string{"A.example", "a.example", "b.example"}, "z"); !reflect.DeepEqual(got, []string{"a.example", "b.example"}) { |
| 448 | t.Fatalf("dedupe = %v", got) |
| 449 | } |
| 450 | if !strings.Contains(buf.String(), "capped at 32") { |
| 451 | t.Fatalf("overflow not logged:\n%s", buf.String()) |
| 452 | } |
| 453 | buf.Reset() |
| 454 | if got := unionDelegated(local, []string{"r.example"}, ""); len(got) != 3 || !strings.Contains(buf.String(), "CARLOS_ACME_DELEGATION_ZONE is unset") { |
| 455 | t.Fatalf("no-zone case: %v / %s", got, buf.String()) |
| 456 | } |
| 457 | buf.Reset() |
| 458 | unionDelegated(local, []string{"r.example"}, "") // a standing condition is said once |
| 459 | if buf.Len() != 0 { |
| 460 | t.Fatalf("zone-unset line repeated:\n%s", buf.String()) |
| 461 | } |
| 462 | if got := unionDelegated(local, nil, ""); len(got) != 2 || buf.Len() != 0 { |
| 463 | t.Fatalf("local-only case logged or changed: %v / %s", got, buf.String()) |
| 464 | } |
| 465 | } |
| 466 | |