carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
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.
15func 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.
31func 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.
53func 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.
65func 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.
96func 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
110ip daddr 169.254.169.254 meta skuid 998 accept
111ip 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).
143func 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`.
188func 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.
241func 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