package edge import ( "context" "fmt" "log" "os" "path/filepath" "reflect" "sort" "strings" "sync" "sync/atomic" "testing" "time" "golang.org/x/crypto/acme/autocert" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/registry" ) // waitWoke polls an atomic wake counter — the wake is fired off the // refresh goroutine, so a test observes it, never assumes it. func waitWoke(t *testing.T, woke *atomic.Int32, want int32) { t.Helper() deadline := time.Now().Add(2 * time.Second) for woke.Load() != want { if time.Now().After(deadline) { t.Fatalf("woke = %d; want %d", woke.Load(), want) } time.Sleep(time.Millisecond) } } // #300: the home map exposes the deployment's platform-class names, and a // changed platform name set wakes whoever asked (the wildcard manager). func TestHomeMapHostsAndWakeOnChange(t *testing.T) { s := meshTestStore(t) 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}}}`) 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"}}}`) m := newHomeMap(s, "annie") var woke atomic.Int32 m.setHostsWake(func() { woke.Add(1) }) refreshHomeMap(t, m) if got, want := m.Hosts(), []string{"cribado.bad.apps.test"}; !reflect.DeepEqual(got, want) { t.Fatalf("Hosts = %v; want %v (own box, catch-all and unclassed rows excluded)", got, want) } waitWoke(t, &woke, 1) refreshHomeMap(t, m) // unchanged: no wake time.Sleep(20 * time.Millisecond) if woke.Load() != 1 { t.Fatalf("an unchanged refresh woke the manager (%d)", woke.Load()) } // Home moved, and an UNCLASSED name came and went: still no wake. 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"}}}`) refreshHomeMap(t, m) time.Sleep(20 * time.Millisecond) if woke.Load() != 1 { t.Fatalf("a moved home / unclassed churn woke the manager (%d)", woke.Load()) } // Same count, different platform name (a swap in one poll): wakes. 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}}}`) refreshHomeMap(t, m) waitWoke(t, &woke, 2) 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}}}`) refreshHomeMap(t, m) waitWoke(t, &woke, 3) if got, want := m.Hosts(), []string{"new.bae.apps.test", "other.bad.apps.test"}; !reflect.DeepEqual(got, want) { t.Fatalf("Hosts = %v; want %v", got, want) } // A name LOSING its mark (a home box rolled back below the mark) is a // change: the parent must leave, and the manager must hear about it. putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"4.4.4.4","hosts":{"new.bae.apps.test":{"role":"home"}}}`) refreshHomeMap(t, m) waitWoke(t, &woke, 4) if got, want := m.Hosts(), []string{"other.bad.apps.test"}; !reflect.DeepEqual(got, want) { t.Fatalf("Hosts after unmark = %v; want %v", got, want) } // A marked catch-all coming and going moves nothing and wakes nobody. 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}}}`) refreshHomeMap(t, m) time.Sleep(20 * time.Millisecond) if woke.Load() != 4 { t.Fatalf("a catch-all woke the manager (%d)", woke.Load()) } if got, want := m.Hosts(), []string{"other.bad.apps.test"}; !reflect.DeepEqual(got, want) { t.Fatalf("Hosts after catch-all = %v; want %v", got, want) } var nilMap *homeMap if nilMap.Hosts() != nil { t.Fatal("nil map must report no hosts") } nilMap.setHostsWake(func() {}) // no panic // An unlabelled box (hand-run `carlos edge`) would fold its OWN file in // as another box's: it reports nothing, as Home does. unlabelled := newHomeMap(s, "") refreshHomeMap(t, unlabelled) if got := unlabelled.Hosts(); got != nil { t.Fatalf("unlabelled box reported %v", got) } } // A file written before the class mark existed carries no flag: nothing // in it derives a parent until its box republishes. Fail closed. func TestHomeMapHostsIgnoresPreMarkFiles(t *testing.T) { s := meshTestStore(t) putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"cribado.bad.apps.test":{"role":"home"}}}`) m := newHomeMap(s, "annie") refreshHomeMap(t, m) if got := m.Hosts(); len(got) != 0 { t.Fatalf("pre-mark file contributed %v", got) } if _, _, ok := m.Home("cribado.bad.apps.test"); !ok { t.Fatal("the class mark must not affect backhaul") } } // platformHomedOn is homedOn with the publisher's class mark set — the // shape a real edges file has for platform-minted rows (#300). func platformHomedOn(t *testing.T, m *homeMap, box, eip string, hosts ...string) { t.Helper() entries := make([]string, 0, len(hosts)) for _, h := range hosts { entries = append(entries, fmt.Sprintf("%q:{\"role\":\"home\",\"platform\":true}", h)) } putJSON(t, m.s, "control/edges/"+box+".json", fmt.Sprintf( `{"v":1,"box":%q,"eip":%q,"hosts":{%s}}`, box, eip, strings.Join(entries, ","))) refreshHomeMap(t, m) } // deploymentHosts is this box's routes plus the home map's names, deduped. func TestDeploymentHostsUnionsTheHomeMap(t *testing.T) { e, _, _ := remoteHomedEdge(t, []string{"own.acct.apps.test"}) platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "cribado.bad.apps.test", "own.acct.apps.test") got := e.deploymentHosts() sort.Strings(got) if want := []string{"cribado.bad.apps.test", "own.acct.apps.test"}; !reflect.DeepEqual(got, want) { t.Fatalf("deploymentHosts = %v; want %v", got, want) } // No deployment store, no home map: a single-box deployment is its own. lone := testIssuerEdge("own.acct.apps.test") if lone.homes.Load() != nil { t.Fatal("test edge unexpectedly has a home map") } if got := lone.deploymentHosts(); !reflect.DeepEqual(got, lone.currentHosts()) { t.Fatalf("no home map: deploymentHosts %v != currentHosts %v", got, lone.currentHosts()) } } // The manager built from env derives the parent of an app it only ever // backhauls to — the region-edge shape that failed on tito stage 6. func TestWildcardParentsIncludeRemoteHomedAccounts(t *testing.T) { e, _, _ := remoteHomedEdge(t, []string{"static.bdj.apps.test"}) platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "cribado.bad.apps.test", "foreign.report") reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) e.reg = reg t.Setenv("CARLOS_WILDCARD_DOMAINS", "") t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") t.Setenv("CARLOS_DNS_PROVIDER", "route53") t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "") w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) if w == nil { t.Fatal("want a manager") } w.refresh() // testIssuerEdge's own rows carry no trust flag, so currentHosts (#290, // pinned in wildcard_test.go) contributes nothing here: what is left is // exactly the remote account — and never the foreign domain. if got, want := w.currentParents(), []string{"bad.apps.test"}; !reflect.DeepEqual(got, want) { t.Fatalf("parents = %v; want %v (remote account included, foreign domain not)", got, want) } } // The production wiring, end to end: a remote box's file landing grows the // manager's parent set on the home map's poll, and its leaving shrinks it. func TestHomeMapWakeReachesTheWildcardManager(t *testing.T) { e, _, base := remoteHomedEdge(t, nil) platformHomedOn(t, e.homes.Load(), "betty", "2.2.2.2", "seed.bdj.apps.test") reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) e.reg = reg t.Setenv("CARLOS_WILDCARD_DOMAINS", "") t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") t.Setenv("CARLOS_DNS_PROVIDER", "route53") t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "") w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) hm := e.homes.Load() hm.setHostsWake(w.refresh) w.refresh() if got := w.currentParents(); !reflect.DeepEqual(got, []string{"bdj.apps.test"}) { t.Fatalf("seed parents = %v", got) } 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}}}`) refreshHomeMap(t, hm) waitParents(t, w, []string{"bad.apps.test", "bdj.apps.test"}) if err := base.Delete(context.Background(), "control/edges/carol.json"); err != nil { t.Fatal(err) } refreshHomeMap(t, hm) waitParents(t, w, []string{"bdj.apps.test"}) } func waitParents(t *testing.T, w *wildcardManager, want []string) { t.Helper() deadline := time.Now().Add(2 * time.Second) for { if got := w.currentParents(); reflect.DeepEqual(got, want) { return } if time.Now().After(deadline) { t.Fatalf("parents = %v; want %v", w.currentParents(), want) } time.Sleep(time.Millisecond) } } // refresh() is serialized: whatever interleaving the three callers produce, // the parent set that lands last is the one derived from the latest hosts. func TestWildcardRefreshIsSerialized(t *testing.T) { var gen atomic.Int32 hosts := func() []string { n := gen.Load() // A slow read: the interleaving window the mutex closes. time.Sleep(200 * time.Microsecond) // Sqid-shaped labels: parentsFor derives a parent only from a label // that could be an account (#215). return []string{"app." + domains.Sqid(int64(n)) + ".apps.test"} } w := newWildcardManager(nil, "apps.test", hosts, func() []string { return nil }, "", autocert.DirCache(t.TempDir()), nil) var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 25; j++ { gen.Add(1) w.refresh() } }() } wg.Wait() final := gen.Load() w.refresh() if got := w.currentParents(); !reflect.DeepEqual(got, []string{domains.Sqid(int64(final)) + ".apps.test"}) { t.Fatalf("parents = %v after generation %d", got, final) } } // #287, reader side: an attached custom domain published under its own // name (never platform-class) backhauls, passes the cert gates' exact // admission, and derives no wildcard parent; its catch-all form dials but // is never an exact admission (#180). func TestAliasEntriesBackhaulAndAdmitButDeriveNoParent(t *testing.T) { e, _, base := remoteHomedEdge(t, nil) 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}}}`) hm := e.homes.Load() refreshHomeMap(t, hm) if _, box, ok := hm.Home("www.customer.com"); !ok || box != "betty" { t.Fatal("alias entry does not backhaul") } if _, _, ok := hm.Home("shop.customer.com"); !ok { t.Fatal("catch-all alias does not dial one label under it") } if !e.deploymentHost("www.customer.com") { t.Fatal("alias entry not admitted by the cert gates") } if e.deploymentHost("*.customer.com") || e.deploymentHost("shop.customer.com") { t.Fatal("a catch-all alias must never be an exact cert admission (#180)") } if got := hm.Hosts(); !reflect.DeepEqual(got, []string{"app.acct.apps.test"}) { t.Fatalf("customer names leaked into the parent input: %v", got) } } // A route beats an alias on a cross-box name collision whatever the // boxes' sort order (#287): a tenant attaching an operator's hostname // captures nothing beyond its own box. And an alias can never smuggle the // platform mark. func TestHomeMapRouteBeatsAliasOnCollision(t *testing.T) { for _, order := range [][2]string{{"aaa", "zzz"}, {"zzz", "aaa"}} { routeBox, aliasBox := order[0], order[1] s := meshTestStore(t) putJSON(t, s, "control/edges/"+routeBox+".json", `{"v":1,"box":"`+routeBox+`","eip":"1.1.1.1","hosts":{"status.platform.test":{"role":"home","platform":false}}}`) 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}}}`) m := newHomeMap(s, "self") refreshHomeMap(t, m) if _, box, ok := m.Home("status.platform.test"); !ok || box != routeBox { t.Fatalf("order %v: home = %s; want the route's box %s", order, box, routeBox) } if got := m.Hosts(); len(got) != 0 { t.Fatalf("order %v: an alias entry reached the parent input: %v", order, got) } } } // #305, reader side: a marked "*." entry is a delegated parent for // the wildcard manager AND a dial target for hosts one label under it; an // unmarked catch-all (a pre-#305 file, or a route's own catch-all row) is // only the latter. A wildcard entry appearing wakes the manager. func TestHomeMapWildcardParents(t *testing.T) { s := meshTestStore(t) 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}}}`) m := newHomeMap(s, "annie") var woke atomic.Int32 m.setHostsWake(func() { woke.Add(1) }) refreshHomeMap(t, m) waitWoke(t, &woke, 1) if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"customer.example"}) { t.Fatalf("WildcardParents = %v", got) } if got := m.Hosts(); !reflect.DeepEqual(got, []string{"app.acct.apps.test"}) { t.Fatalf("a wildcard entry leaked into Hosts: %v", got) } for _, h := range []string{"pds.customer.example", "x.plain.example"} { if _, box, ok := m.Home(h); !ok || box != "betty" { t.Errorf("%s: not dialed via the catch-all", h) } } // A second delegated parent lands: wake. 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}}}`) refreshHomeMap(t, m) waitWoke(t, &woke, 2) if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"customer.example", "second.example"}) { t.Fatalf("WildcardParents = %v", got) } // An unmarked catch-all coming or going still wakes nobody. 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"}}}`) refreshHomeMap(t, m) time.Sleep(20 * time.Millisecond) if woke.Load() != 2 { t.Fatalf("an unmarked catch-all woke the manager (%d)", woke.Load()) } var unlabelled *homeMap if unlabelled.WildcardParents() != nil { t.Fatal("nil map reported parents") } } // The manager built from env on a region edge unions the remote delegated // parent: it is in the parent set AND delegated (challenge via the // delegation zone), alongside its own local rows. func TestWildcardManagerUnionsRemoteDelegatedParents(t *testing.T) { e, _, base := remoteHomedEdge(t, nil) reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) e.reg = reg if err := reg.UpsertWildcardDomain(registry.WildcardDomain{Host: "local.example", Account: "acct", App: "app"}); err != nil { t.Fatal(err) } putJSON(t, base, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"*.customer.example":{"role":"home","wildcard":true}}}`) refreshHomeMap(t, e.homes.Load()) t.Setenv("CARLOS_WILDCARD_DOMAINS", "") t.Setenv("PLATFORM_APPS_DOMAIN", "apps.test") t.Setenv("CARLOS_DNS_PROVIDER", "route53") t.Setenv("CARLOS_ACME_DELEGATION_ZONE", "acme.apps.test") w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir())) if w == nil { t.Fatal("want a manager") } w.refresh() if got := w.currentParents(); !reflect.DeepEqual(got, []string{"customer.example", "local.example"}) { t.Fatalf("parents = %v", got) } if !w.isDelegated("customer.example") || !w.isDelegated("local.example") { t.Fatal("remote or local delegated parent not marked delegated") } } // Two boxes publishing the same "*." key merge CLASSES and pick one // dial target (route beats alias, any beats none), whatever the sort order // — a delegated parent's PEM must load everywhere even when another box // routes the key too. A cert-only entry is a parent but never a dial target. func TestHomeMapWildcardCollisionMergesClasses(t *testing.T) { for _, order := range [][2]string{{"aaa", "zzz"}, {"zzz", "aaa"}} { routeBox, wildBox := order[0], order[1] s := meshTestStore(t) putJSON(t, s, "control/edges/"+routeBox+".json", `{"v":1,"box":"`+routeBox+`","eip":"1.1.1.1","hosts":{"*.customer.example":{"role":"home","platform":true}}}`) 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}}}`) m := newHomeMap(s, "self") refreshHomeMap(t, m) if _, box, ok := m.Home("x.customer.example"); !ok || box != routeBox { t.Fatalf("order %v: dial = %s/%v; want the route's box %s", order, box, ok, routeBox) } if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"certonly.example", "customer.example"}) { t.Fatalf("order %v: WildcardParents = %v", order, got) } if _, _, ok := m.Home("x.certonly.example"); ok { t.Fatalf("order %v: a cert-only wildcard entry was a dial target", order) } } // Role wildcard on an EXACT key is refused (no KnownHost admission // without a dial); a file with no EIP still contributes cert halves. s2 := meshTestStore(t) 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}}}`) m2 := newHomeMap(s2, "self") refreshHomeMap(t, m2) if got := m2.WildcardParents(); !reflect.DeepEqual(got, []string{"noeip.example"}) { t.Fatalf("EIP-less file: WildcardParents = %v", got) } if m2.KnownHost("victim.example") || m2.KnownHost("served.example") { t.Fatal("an exact key from an EIP-less file or with role wildcard was admitted") } if _, _, ok := m2.Home("x.noeip.example"); ok { t.Fatal("an EIP-less file offered a dial target") } // A remote parent of bad shape is not a parent. s := meshTestStore(t) 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}}}`) m := newHomeMap(s, "self") refreshHomeMap(t, m) if got := m.WildcardParents(); !reflect.DeepEqual(got, []string{"ok.example"}) { t.Fatalf("WildcardParents = %v", got) } } // The fleet's delegated set is capped where the per-box table is: local // rows first, the overflow logged and dropped. func TestUnionDelegatedCapsAndWarns(t *testing.T) { delegatedCapLogged.Store(0) // once-per-process throttles: isolate from -count>1 delegationZoneUnsetOnce = sync.Once{} var buf strings.Builder log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) local := []string{"l1.example", "l2.example"} remote := make([]string, 0, 40) for i := 0; i < 40; i++ { remote = append(remote, fmt.Sprintf("r%02d.example", i)) } remote = append(remote, "L1.example", "r00.example") // duplicates never cost a slot got := unionDelegated(local, remote, "acme.apps.test") if len(got) != maxDelegatedParents || got[0] != "l1.example" || got[1] != "l2.example" || got[2] != "r00.example" { t.Fatalf("union = %d entries, head %v", len(got), got[:3]) } if got := unionDelegated([]string{"a.example"}, []string{"A.example", "a.example", "b.example"}, "z"); !reflect.DeepEqual(got, []string{"a.example", "b.example"}) { t.Fatalf("dedupe = %v", got) } if !strings.Contains(buf.String(), "capped at 32") { t.Fatalf("overflow not logged:\n%s", buf.String()) } buf.Reset() if got := unionDelegated(local, []string{"r.example"}, ""); len(got) != 3 || !strings.Contains(buf.String(), "CARLOS_ACME_DELEGATION_ZONE is unset") { t.Fatalf("no-zone case: %v / %s", got, buf.String()) } buf.Reset() unionDelegated(local, []string{"r.example"}, "") // a standing condition is said once if buf.Len() != 0 { t.Fatalf("zone-unset line repeated:\n%s", buf.String()) } if got := unionDelegated(local, nil, ""); len(got) != 2 || buf.Len() != 0 { t.Fatalf("local-only case logged or changed: %v / %s", got, buf.String()) } }