package host import ( "reflect" "strings" "testing" "github.com/carlosframework/platform/internal/registry" ) // A route claiming exec backing while a carlos-app@ unit is ACTIVE // is exactly the #14 shape (kass): adopt keeps working, but config sync // silently skips the route. The adopt pass warns so the mismatch is // visible in the hourly log instead of discovered by absence. func TestWarnBackingMismatch(t *testing.T) { routes := []registry.Route{ {Host: "mismatch.example.com", Kind: "instance", App: "kass"}, // exec-claimed, unit active -> warn {Host: "honest-unit.example.com", Kind: "instance", App: "kass", Backing: registry.BackingUnit}, // consistent {Host: "honest-exec.example.com", Kind: "instance", App: "kass"}, // exec, no unit {Host: "alias.example.com", Kind: "instance", App: "kass", Platform: true}, // aliases never warn } active := map[string]bool{"mismatch.example.com": true, "honest-unit.example.com": true} got := warnBackingMismatch(routes, func(hosts []string) map[string]bool { return active }) if len(got) != 1 || got[0] != "mismatch.example.com" { t.Fatalf("warned = %v, want exactly [mismatch.example.com]", got) } } // One probe call carries every candidate — the PR #16 deferred minor this // rework exists for. A per-host seam would be called len(candidates) times. func TestWarnBackingMismatchProbesAllCandidatesInOneCall(t *testing.T) { routes := []registry.Route{ {Kind: "instance", Host: "a.example"}, {Kind: "instance", Host: "b.example"}, {Kind: "instance", Host: "unit.example", Backing: registry.BackingUnit}, {Kind: "proxy", Host: "not-instance.example"}, } var calls [][]string warnBackingMismatch(routes, func(hosts []string) map[string]bool { calls = append(calls, hosts) return map[string]bool{"b.example": true} }) if len(calls) != 1 { t.Fatalf("probe called %d times, want 1", len(calls)) } want := []string{"a.example", "b.example"} if !reflect.DeepEqual(calls[0], want) { t.Errorf("candidates = %v, want %v", calls[0], want) } } // Zero candidates must mean zero probe calls — no systemctl exec at all. func TestWarnBackingMismatchSkipsProbeWithNoCandidates(t *testing.T) { routes := []registry.Route{{Kind: "instance", Host: "u", Backing: registry.BackingUnit}} warnBackingMismatch(routes, func(hosts []string) map[string]bool { t.Fatal("probe called with no candidates") return nil }) } // parseIsActiveBatch maps systemctl is-active output onto hosts positionally. // A line-count mismatch cannot be attributed positionally — attributing it // anyway would tell an operator to delete the wrong host's decrypted // secrets file, so a mismatch must produce an empty map, not a guess. func TestParseIsActiveBatch(t *testing.T) { hosts := []string{"a", "b", "c"} cases := []struct { name string out string want map[string]bool }{ {"exact match", "active\ninactive\nactive\n", map[string]bool{"a": true, "c": true}}, {"short output", "active\ninactive\n", map[string]bool{}}, {"extra output", "active\ninactive\nactive\nactive\n", map[string]bool{}}, {"empty output", "", map[string]bool{}}, {"reloading counts as active", "reloading\ninactive\nactive\n", map[string]bool{"a": true, "c": true}}, {"failed does not count", "failed\ninactive\nactive\n", map[string]bool{"c": true}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := parseIsActiveBatch([]byte(c.out), hosts) if !reflect.DeepEqual(got, c.want) { t.Errorf("parseIsActiveBatch(%q) = %v, want %v", c.out, got, c.want) } }) } } // ---- the IMDS gate advisory ------------------------------------------------ // parseNftSkuids reads the uids allowed to reach the metadata address. It is // scoped to rules that NAME that address: a box's other skuid rules (SSH, a // backup agent) are not the IMDS gate, and counting them would report a gate // that allows uids it does not — the fail-open direction, where this advisory // goes quiet about exactly the silent starvation it exists to shout about. func TestParseNftSkuids(t *testing.T) { realistic := `table inet filter { chain output { type filter hook output priority filter; policy accept; ip daddr 169.254.169.254 skuid 0 accept ip daddr 169.254.169.254 skuid 998 accept ip daddr 169.254.169.254 counter drop tcp dport 22 skuid 1000 accept } }` // The shape the infra repo's scripts/harden-host.sh actually writes: // inline, numeric, `meta skuid accept`, one rule per allowed uid, // with a bare drop closing the gate. No named sets today. hardenHost := `ip daddr 169.254.169.254 meta skuid 0 accept ip daddr 169.254.169.254 meta skuid 998 accept ip daddr 169.254.169.254 drop` cases := []struct { name string out string want map[int]bool }{ {"harden-host.sh's own rule shape", hardenHost, map[int]bool{0: true, 998: true}}, {"a DROP rule is not an allowlist entry", "ip daddr 169.254.169.254 meta skuid 998 drop\n", map[int]bool{}}, {"a REJECT rule is not one either", "ip daddr 169.254.169.254 meta skuid 998 reject\n", map[int]bool{}}, {"a rule with no verdict allows nothing", "ip daddr 169.254.169.254 meta skuid 998 counter\n", map[int]bool{}}, {"two IMDS rules, and an unrelated skuid rule ignored", realistic, map[int]bool{0: true, 998: true}}, {"set form", "ip daddr 169.254.169.254 skuid { 998, 999 } accept\n", map[int]bool{998: true, 999: true}}, {"no IMDS rule at all", "tcp dport 22 skuid 1000 accept\n", map[int]bool{}}, {"garbage", "not a ruleset at all", map[int]bool{}}, {"empty", "", map[int]bool{}}, {"skuid with no value", "ip daddr 169.254.169.254 skuid\n", map[int]bool{}}, {"negated match is not an allowlist", "ip daddr 169.254.169.254 skuid != 0 drop\n", map[int]bool{}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := parseNftSkuids([]byte(c.out)) if !reflect.DeepEqual(got, c.want) { t.Errorf("parseNftSkuids() = %v, want %v", got, c.want) } }) } } // "the uid is missing from the gate" and "I could not read the gate" are // different claims, and acting on the wrong one sends an operator to edit a // firewall that is fine. Each gets its own line, and only the first prints a // command — the platform never writes the rule, it prints it (spec §4b). func TestAdviseHostRoleGateMissingSaysWhichClaimItIsMaking(t *testing.T) { granted := registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "host-role,port:25"} uidOf := func(string) (int, bool) { return 998, true } t.Run("uid absent from a readable gate", func(t *testing.T) { logged := captureLog(t) adviseHostRoleGateMissing([]registry.Route{granted}, uidOf, func() (map[int]bool, bool) { return map[int]bool{0: true}, true }) out := logged() for _, w := range []string{"carlos-keymail (uid 998)", "keymail.dev", "starved SILENTLY", "nft add rule inet filter output ip daddr 169.254.169.254 skuid 998 accept", "harden-host.sh"} { if !strings.Contains(out, w) { t.Errorf("log = %q, want it to contain %q", out, w) } } }) t.Run("gate unreadable", func(t *testing.T) { logged := captureLog(t) adviseHostRoleGateMissing([]registry.Route{granted}, uidOf, func() (map[int]bool, bool) { return nil, false }) out := logged() if !strings.Contains(out, "could not be read") { t.Errorf("log = %q, want the unreadable-gate claim", out) } if strings.Contains(out, "nft add rule") { t.Errorf("log = %q, want NO firewall command when the gate could not be read", out) } }) t.Run("uid present in the gate says nothing", func(t *testing.T) { logged := captureLog(t) adviseHostRoleGateMissing([]registry.Route{granted}, uidOf, func() (map[int]bool, bool) { return map[int]bool{998: true}, true }) if out := logged(); out != "" { t.Errorf("log = %q, want silence on a correctly gated box", out) } }) } // The advisory is quiet where it would be wrong or useless: a route with no // host_role grant, a route that cannot materialize capabilities at all, and a // user that does not exist yet (converge creates it when host_role first // materializes, so nothing is running under that uid to be starved). None of // those may even reach for `nft`. func TestAdviseHostRoleGateMissingIsQuietWithNothingToSay(t *testing.T) { cases := []struct { name string routes []registry.Route uidOf func(string) (int, bool) }{ { name: "no host_role grant", routes: []registry.Route{{Host: "a", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "port:25"}}, }, { name: "exec-backed route cannot materialize host_role", routes: []registry.Route{{Host: "b", Kind: "instance", App: "keymail", Status: "ready", Caps: "host-role"}}, }, { name: "unparseable grants column is buildPlans' line to print, not this one's", routes: []registry.Route{{Host: "c", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "port:025"}}, }, { name: "the static user does not exist yet", routes: []registry.Route{{Host: "d", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "host-role"}}, uidOf: func(string) (int, bool) { return 0, false }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { logged := captureLog(t) uidOf := c.uidOf if uidOf == nil { uidOf = func(string) (int, bool) { t.Error("looked a user up with no candidate route") return 0, false } } gate := func() (map[int]bool, bool) { if c.uidOf == nil { t.Error("read the box's firewall with no candidate route") } return nil, true } adviseHostRoleGateMissing(c.routes, uidOf, gate) if out := logged(); out != "" { t.Errorf("log = %q, want silence", out) } }) } } // A readable ruleset with NO IMDS gate in it is a third fact, with a third // fix: the box is not gated at all, so the answer is not "add a uid" but // "harden this box first" (tito has no gate today). It must not be folded // into either of the other two lines, and it must not go quiet — a host_role // app on an ungated box is F1's exposure with nothing in front of it. func TestAdviseHostRoleGateMissingNamesAnUngatedBox(t *testing.T) { granted := registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "host-role"} logged := captureLog(t) adviseHostRoleGateMissing([]registry.Route{granted}, func(string) (int, bool) { return 998, true }, func() (map[int]bool, bool) { return map[int]bool{}, true }) out := logged() for _, w := range []string{"no IMDS uid gate found", "scripts/harden-host.sh", "uid 998"} { if !strings.Contains(out, w) { t.Errorf("log = %q, want it to contain %q", out, w) } } if strings.Contains(out, "could not be read") { t.Errorf("log = %q, want the ungated-box claim, not the unreadable-gate one", out) } }