| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "reflect" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/registry" |
| 9 | ) |
| 10 | |
| 11 | // A route claiming exec backing while a carlos-app@<host> unit is ACTIVE |
| 12 | // is exactly the #14 shape (kass): adopt keeps working, but config sync |
| 13 | // silently skips the route. The adopt pass warns so the mismatch is |
| 14 | // visible in the hourly log instead of discovered by absence. |
| 15 | func TestWarnBackingMismatch(t *testing.T) { |
| 16 | routes := []registry.Route{ |
| 17 | {Host: "mismatch.example.com", Kind: "instance", App: "kass"}, // exec-claimed, unit active -> warn |
| 18 | {Host: "honest-unit.example.com", Kind: "instance", App: "kass", Backing: registry.BackingUnit}, // consistent |
| 19 | {Host: "honest-exec.example.com", Kind: "instance", App: "kass"}, // exec, no unit |
| 20 | {Host: "alias.example.com", Kind: "instance", App: "kass", Platform: true}, // aliases never warn |
| 21 | } |
| 22 | active := map[string]bool{"mismatch.example.com": true, "honest-unit.example.com": true} |
| 23 | got := warnBackingMismatch(routes, func(hosts []string) map[string]bool { return active }) |
| 24 | if len(got) != 1 || got[0] != "mismatch.example.com" { |
| 25 | t.Fatalf("warned = %v, want exactly [mismatch.example.com]", got) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | // One probe call carries every candidate — the PR #16 deferred minor this |
| 30 | // rework exists for. A per-host seam would be called len(candidates) times. |
| 31 | func TestWarnBackingMismatchProbesAllCandidatesInOneCall(t *testing.T) { |
| 32 | routes := []registry.Route{ |
| 33 | {Kind: "instance", Host: "a.example"}, |
| 34 | {Kind: "instance", Host: "b.example"}, |
| 35 | {Kind: "instance", Host: "unit.example", Backing: registry.BackingUnit}, |
| 36 | {Kind: "proxy", Host: "not-instance.example"}, |
| 37 | } |
| 38 | var calls [][]string |
| 39 | warnBackingMismatch(routes, func(hosts []string) map[string]bool { |
| 40 | calls = append(calls, hosts) |
| 41 | return map[string]bool{"b.example": true} |
| 42 | }) |
| 43 | if len(calls) != 1 { |
| 44 | t.Fatalf("probe called %d times, want 1", len(calls)) |
| 45 | } |
| 46 | want := []string{"a.example", "b.example"} |
| 47 | if !reflect.DeepEqual(calls[0], want) { |
| 48 | t.Errorf("candidates = %v, want %v", calls[0], want) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Zero candidates must mean zero probe calls — no systemctl exec at all. |
| 53 | func TestWarnBackingMismatchSkipsProbeWithNoCandidates(t *testing.T) { |
| 54 | routes := []registry.Route{{Kind: "instance", Host: "u", Backing: registry.BackingUnit}} |
| 55 | warnBackingMismatch(routes, func(hosts []string) map[string]bool { |
| 56 | t.Fatal("probe called with no candidates") |
| 57 | return nil |
| 58 | }) |
| 59 | } |
| 60 | |
| 61 | // parseIsActiveBatch maps systemctl is-active output onto hosts positionally. |
| 62 | // A line-count mismatch cannot be attributed positionally — attributing it |
| 63 | // anyway would tell an operator to delete the wrong host's decrypted |
| 64 | // secrets file, so a mismatch must produce an empty map, not a guess. |
| 65 | func TestParseIsActiveBatch(t *testing.T) { |
| 66 | hosts := []string{"a", "b", "c"} |
| 67 | cases := []struct { |
| 68 | name string |
| 69 | out string |
| 70 | want map[string]bool |
| 71 | }{ |
| 72 | {"exact match", "active\ninactive\nactive\n", map[string]bool{"a": true, "c": true}}, |
| 73 | {"short output", "active\ninactive\n", map[string]bool{}}, |
| 74 | {"extra output", "active\ninactive\nactive\nactive\n", map[string]bool{}}, |
| 75 | {"empty output", "", map[string]bool{}}, |
| 76 | {"reloading counts as active", "reloading\ninactive\nactive\n", map[string]bool{"a": true, "c": true}}, |
| 77 | {"failed does not count", "failed\ninactive\nactive\n", map[string]bool{"c": true}}, |
| 78 | } |
| 79 | for _, c := range cases { |
| 80 | t.Run(c.name, func(t *testing.T) { |
| 81 | got := parseIsActiveBatch([]byte(c.out), hosts) |
| 82 | if !reflect.DeepEqual(got, c.want) { |
| 83 | t.Errorf("parseIsActiveBatch(%q) = %v, want %v", c.out, got, c.want) |
| 84 | } |
| 85 | }) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // ---- the IMDS gate advisory ------------------------------------------------ |
| 90 | |
| 91 | // parseNftSkuids reads the uids allowed to reach the metadata address. It is |
| 92 | // scoped to rules that NAME that address: a box's other skuid rules (SSH, a |
| 93 | // backup agent) are not the IMDS gate, and counting them would report a gate |
| 94 | // that allows uids it does not — the fail-open direction, where this advisory |
| 95 | // goes quiet about exactly the silent starvation it exists to shout about. |
| 96 | func TestParseNftSkuids(t *testing.T) { |
| 97 | realistic := `table inet filter { |
| 98 | chain output { |
| 99 | type filter hook output priority filter; policy accept; |
| 100 | ip daddr 169.254.169.254 skuid 0 accept |
| 101 | ip daddr 169.254.169.254 skuid 998 accept |
| 102 | ip daddr 169.254.169.254 counter drop |
| 103 | tcp dport 22 skuid 1000 accept |
| 104 | } |
| 105 | }` |
| 106 | // The shape the infra repo's scripts/harden-host.sh actually writes: |
| 107 | // inline, numeric, `meta skuid <n> accept`, one rule per allowed uid, |
| 108 | // with a bare drop closing the gate. No named sets today. |
| 109 | hardenHost := `ip daddr 169.254.169.254 meta skuid 0 accept |
| 110 | ip daddr 169.254.169.254 meta skuid 998 accept |
| 111 | ip daddr 169.254.169.254 drop` |
| 112 | cases := []struct { |
| 113 | name string |
| 114 | out string |
| 115 | want map[int]bool |
| 116 | }{ |
| 117 | {"harden-host.sh's own rule shape", hardenHost, map[int]bool{0: true, 998: true}}, |
| 118 | {"a DROP rule is not an allowlist entry", "ip daddr 169.254.169.254 meta skuid 998 drop\n", map[int]bool{}}, |
| 119 | {"a REJECT rule is not one either", "ip daddr 169.254.169.254 meta skuid 998 reject\n", map[int]bool{}}, |
| 120 | {"a rule with no verdict allows nothing", "ip daddr 169.254.169.254 meta skuid 998 counter\n", map[int]bool{}}, |
| 121 | {"two IMDS rules, and an unrelated skuid rule ignored", realistic, map[int]bool{0: true, 998: true}}, |
| 122 | {"set form", "ip daddr 169.254.169.254 skuid { 998, 999 } accept\n", map[int]bool{998: true, 999: true}}, |
| 123 | {"no IMDS rule at all", "tcp dport 22 skuid 1000 accept\n", map[int]bool{}}, |
| 124 | {"garbage", "not a ruleset at all", map[int]bool{}}, |
| 125 | {"empty", "", map[int]bool{}}, |
| 126 | {"skuid with no value", "ip daddr 169.254.169.254 skuid\n", map[int]bool{}}, |
| 127 | {"negated match is not an allowlist", "ip daddr 169.254.169.254 skuid != 0 drop\n", map[int]bool{}}, |
| 128 | } |
| 129 | for _, c := range cases { |
| 130 | t.Run(c.name, func(t *testing.T) { |
| 131 | got := parseNftSkuids([]byte(c.out)) |
| 132 | if !reflect.DeepEqual(got, c.want) { |
| 133 | t.Errorf("parseNftSkuids() = %v, want %v", got, c.want) |
| 134 | } |
| 135 | }) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // "the uid is missing from the gate" and "I could not read the gate" are |
| 140 | // different claims, and acting on the wrong one sends an operator to edit a |
| 141 | // firewall that is fine. Each gets its own line, and only the first prints a |
| 142 | // command — the platform never writes the rule, it prints it (spec §4b). |
| 143 | func TestAdviseHostRoleGateMissingSaysWhichClaimItIsMaking(t *testing.T) { |
| 144 | granted := registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "host-role,port:25"} |
| 145 | uidOf := func(string) (int, bool) { return 998, true } |
| 146 | |
| 147 | t.Run("uid absent from a readable gate", func(t *testing.T) { |
| 148 | logged := captureLog(t) |
| 149 | adviseHostRoleGateMissing([]registry.Route{granted}, uidOf, |
| 150 | func() (map[int]bool, bool) { return map[int]bool{0: true}, true }) |
| 151 | out := logged() |
| 152 | for _, w := range []string{"carlos-keymail (uid 998)", "keymail.dev", "starved SILENTLY", |
| 153 | "nft add rule inet filter output ip daddr 169.254.169.254 skuid 998 accept", "harden-host.sh"} { |
| 154 | if !strings.Contains(out, w) { |
| 155 | t.Errorf("log = %q, want it to contain %q", out, w) |
| 156 | } |
| 157 | } |
| 158 | }) |
| 159 | |
| 160 | t.Run("gate unreadable", func(t *testing.T) { |
| 161 | logged := captureLog(t) |
| 162 | adviseHostRoleGateMissing([]registry.Route{granted}, uidOf, |
| 163 | func() (map[int]bool, bool) { return nil, false }) |
| 164 | out := logged() |
| 165 | if !strings.Contains(out, "could not be read") { |
| 166 | t.Errorf("log = %q, want the unreadable-gate claim", out) |
| 167 | } |
| 168 | if strings.Contains(out, "nft add rule") { |
| 169 | t.Errorf("log = %q, want NO firewall command when the gate could not be read", out) |
| 170 | } |
| 171 | }) |
| 172 | |
| 173 | t.Run("uid present in the gate says nothing", func(t *testing.T) { |
| 174 | logged := captureLog(t) |
| 175 | adviseHostRoleGateMissing([]registry.Route{granted}, uidOf, |
| 176 | func() (map[int]bool, bool) { return map[int]bool{998: true}, true }) |
| 177 | if out := logged(); out != "" { |
| 178 | t.Errorf("log = %q, want silence on a correctly gated box", out) |
| 179 | } |
| 180 | }) |
| 181 | } |
| 182 | |
| 183 | // The advisory is quiet where it would be wrong or useless: a route with no |
| 184 | // host_role grant, a route that cannot materialize capabilities at all, and a |
| 185 | // user that does not exist yet (converge creates it when host_role first |
| 186 | // materializes, so nothing is running under that uid to be starved). None of |
| 187 | // those may even reach for `nft`. |
| 188 | func TestAdviseHostRoleGateMissingIsQuietWithNothingToSay(t *testing.T) { |
| 189 | cases := []struct { |
| 190 | name string |
| 191 | routes []registry.Route |
| 192 | uidOf func(string) (int, bool) |
| 193 | }{ |
| 194 | { |
| 195 | name: "no host_role grant", |
| 196 | routes: []registry.Route{{Host: "a", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "port:25"}}, |
| 197 | }, |
| 198 | { |
| 199 | name: "exec-backed route cannot materialize host_role", |
| 200 | routes: []registry.Route{{Host: "b", Kind: "instance", App: "keymail", Status: "ready", Caps: "host-role"}}, |
| 201 | }, |
| 202 | { |
| 203 | name: "unparseable grants column is buildPlans' line to print, not this one's", |
| 204 | routes: []registry.Route{{Host: "c", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "port:025"}}, |
| 205 | }, |
| 206 | { |
| 207 | name: "the static user does not exist yet", |
| 208 | routes: []registry.Route{{Host: "d", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "host-role"}}, |
| 209 | uidOf: func(string) (int, bool) { return 0, false }, |
| 210 | }, |
| 211 | } |
| 212 | for _, c := range cases { |
| 213 | t.Run(c.name, func(t *testing.T) { |
| 214 | logged := captureLog(t) |
| 215 | uidOf := c.uidOf |
| 216 | if uidOf == nil { |
| 217 | uidOf = func(string) (int, bool) { |
| 218 | t.Error("looked a user up with no candidate route") |
| 219 | return 0, false |
| 220 | } |
| 221 | } |
| 222 | gate := func() (map[int]bool, bool) { |
| 223 | if c.uidOf == nil { |
| 224 | t.Error("read the box's firewall with no candidate route") |
| 225 | } |
| 226 | return nil, true |
| 227 | } |
| 228 | adviseHostRoleGateMissing(c.routes, uidOf, gate) |
| 229 | if out := logged(); out != "" { |
| 230 | t.Errorf("log = %q, want silence", out) |
| 231 | } |
| 232 | }) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // A readable ruleset with NO IMDS gate in it is a third fact, with a third |
| 237 | // fix: the box is not gated at all, so the answer is not "add a uid" but |
| 238 | // "harden this box first" (tito has no gate today). It must not be folded |
| 239 | // into either of the other two lines, and it must not go quiet — a host_role |
| 240 | // app on an ungated box is F1's exposure with nothing in front of it. |
| 241 | func TestAdviseHostRoleGateMissingNamesAnUngatedBox(t *testing.T) { |
| 242 | granted := registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: registry.BackingUnit, Status: "ready", Caps: "host-role"} |
| 243 | logged := captureLog(t) |
| 244 | adviseHostRoleGateMissing([]registry.Route{granted}, |
| 245 | func(string) (int, bool) { return 998, true }, |
| 246 | func() (map[int]bool, bool) { return map[int]bool{}, true }) |
| 247 | out := logged() |
| 248 | for _, w := range []string{"no IMDS uid gate found", "scripts/harden-host.sh", "uid 998"} { |
| 249 | if !strings.Contains(out, w) { |
| 250 | t.Errorf("log = %q, want it to contain %q", out, w) |
| 251 | } |
| 252 | } |
| 253 | if strings.Contains(out, "could not be read") { |
| 254 | t.Errorf("log = %q, want the ungated-box claim, not the unreadable-gate one", out) |
| 255 | } |
| 256 | } |
| 257 | |