carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux && rootrunner
2
3package host
4
5import (
6 "bytes"
7 "context"
8 "crypto/ed25519"
9 "encoding/json"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "github.com/carlosframework/platform/internal/hostactors"
16 "github.com/carlosframework/platform/internal/hostfence"
17)
18
19func activationRootFixture(t *testing.T) string {
20 t.Helper()
21 if os.Geteuid() != 0 {
22 t.Fatal("real root required")
23 }
24 d, err := os.MkdirTemp("/var/lib", "carlos-activation-test-")
25 if err != nil {
26 t.Fatal(err)
27 }
28 t.Cleanup(func() { os.RemoveAll(d) })
29 return d
30}
31func TestRootActivationSecrets(t *testing.T) {
32 d := activationRootFixture(t)
33 binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: strings.Repeat("a", 32)}
34 p := filepath.Join(d, "host-evidence.key")
35 result, err := installActivationEvidenceKey(p, binding, false)
36 if err != nil {
37 t.Fatal(err)
38 }
39 key, err := readClaimEvidenceKey(p)
40 if err != nil {
41 t.Fatal(err)
42 }
43 wire, _ := json.Marshal(result.Binding)
44 if !ed25519.Verify(key.Public().(ed25519.PublicKey), wire, result.Proof) {
45 t.Fatal("proof does not bind installed key")
46 }
47 again, err := installActivationEvidenceKey(p, binding, false)
48 if err != nil || !bytes.Equal(again.Binding.PublicKey, result.Binding.PublicKey) {
49 t.Fatal("replay rotated key", err)
50 }
51 st, _ := os.Stat(p)
52 if st.Mode().Perm() != 0600 {
53 t.Fatal(st.Mode())
54 }
55 before, _ := os.ReadFile(p)
56 _, err = installActivationEvidenceKey(p, binding, true)
57 if err != nil {
58 t.Fatal(err)
59 }
60 after, _ := os.ReadFile(p)
61 if !bytes.Equal(before, after) {
62 t.Fatal("dry run changed key")
63 }
64 dry := filepath.Join(d, "absent.key")
65 if _, err = installActivationEvidenceKey(dry, binding, true); err != nil {
66 t.Fatal(err)
67 }
68 if _, err = os.Lstat(dry); !os.IsNotExist(err) {
69 t.Fatal("dry run created key")
70 }
71 for _, kind := range []string{"symlink", "hardlink", "writable-parent", "mode"} {
72 t.Run(kind, func(t *testing.T) {
73 parent := filepath.Join(d, kind)
74 if err := os.Mkdir(parent, 0700); err != nil {
75 t.Fatal(err)
76 }
77 target := filepath.Join(parent, "key")
78 switch kind {
79 case "symlink":
80 os.Symlink(p, target)
81 case "hardlink":
82 os.Link(p, target)
83 case "writable-parent":
84 os.Chmod(parent, 0777)
85 case "mode":
86 os.WriteFile(target, before, 0644)
87 }
88 if _, err := installActivationEvidenceKey(target, binding, false); err == nil {
89 t.Fatal("unsafe path accepted")
90 }
91 os.Remove(target)
92 os.Chmod(parent, 0700)
93 })
94 }
95}
96func TestRootActivationFenceCredentials(t *testing.T) {
97 d := activationRootFixture(t)
98 source := filepath.Join(d, "source.json")
99 target := filepath.Join(d, "credentials.json")
100 raw := []byte(`{"role":"arn:aws:iam::123456789012:role/fence","region":"eu-west-1","bucket":"bucket","access_key":"fixture","secret_key":"fixture-secret"}`)
101 if err := os.WriteFile(source, raw, 0600); err != nil {
102 t.Fatal(err)
103 }
104 scope := activationFenceScope{Bucket: "bucket", Region: "eu-west-1", Role: "arn:aws:iam::123456789012:role/fence"}
105 if err := installActivationFenceCredentials(source, target, scope, true); err != nil {
106 t.Fatal(err)
107 }
108 if _, err := os.Stat(target); !os.IsNotExist(err) {
109 t.Fatal("dry run wrote credentials")
110 }
111 if err := installActivationFenceCredentials(source, target, scope, false); err != nil {
112 t.Fatal(err)
113 }
114 st, _ := os.Stat(target)
115 if st.Mode().Perm() != 0600 {
116 t.Fatal(st.Mode())
117 }
118 if err := installActivationFenceCredentials(source, target, scope, false); err != nil {
119 t.Fatal(err)
120 }
121 scope.Bucket = "wrong"
122 if err := installActivationFenceCredentials(source, target, scope, false); err == nil {
123 t.Fatal("scope mismatch accepted")
124 }
125 scope.Bucket = "bucket"
126 os.WriteFile(source, bytes.ReplaceAll(raw, []byte("fixture-secret"), []byte("replacement")), 0600)
127 if err := installActivationFenceCredentials(source, target, scope, false); err == nil {
128 t.Fatal("rotation accepted")
129 }
130 got, _ := os.ReadFile(target)
131 if !bytes.Equal(got, raw) {
132 t.Fatal("existing credentials replaced")
133 }
134}
135func TestRootActivationControlInstaller(t *testing.T) {
136 d := activationRootFixture(t)
137 env := filepath.Join(d, "host.env")
138 unitDir := filepath.Join(d, "units")
139 os.Mkdir(unitDir, 0755)
140 os.WriteFile(env, []byte("CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM_ORIGINS=1\n"), 0600)
141 for _, name := range rootMigrationOfflineUnits {
142 if err := os.Symlink("/dev/null", filepath.Join(unitDir, name)); err != nil {
143 t.Fatal(err)
144 }
145 }
146 calls := []string{}
147 ctl := func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")); return "", nil }
148 if err := installActivationControl(env, unitDir, true, ctl); err != nil {
149 t.Fatal(err)
150 }
151 if len(calls) != 0 {
152 t.Fatal("dry run called systemd")
153 }
154 if _, err := os.Stat(filepath.Join(unitDir, claimControlUnit)); !os.IsNotExist(err) {
155 t.Fatal("dry run installed")
156 }
157 if err := installActivationControl(env, unitDir, false, ctl); err != nil {
158 t.Fatal(err)
159 }
160 got, err := os.ReadFile(filepath.Join(unitDir, claimControlUnit))
161 if err != nil {
162 t.Fatal(err)
163 }
164 for _, want := range []string{"StateDirectory=carlos-claims\n", "MemoryMax=256M\n", claimControlEnvironment} {
165 if !bytes.Contains(got, []byte(want)) {
166 t.Fatal("missing", want)
167 }
168 }
169 if strings.Join(calls, ",") != "daemon-reload" {
170 t.Fatal("unexpected startup", calls)
171 }
172 for _, name := range rootMigrationOfflineUnits {
173 target, err := os.Readlink(filepath.Join(unitDir, name))
174 if err != nil || target != "/dev/null" {
175 t.Fatal("mask changed", name, err)
176 }
177 }
178 os.WriteFile(env, []byte("CARLOS_HOST_CLAIM=1\nmalformed\n"), 0600)
179 if err := installActivationControl(env, unitDir, false, ctl); err == nil {
180 t.Fatal("malformed config accepted")
181 }
182 after, _ := os.ReadFile(filepath.Join(unitDir, claimControlUnit))
183 if !bytes.Equal(got, after) {
184 t.Fatal("unit changed after refusal")
185 }
186}
187
188func TestRootActivationConsoleInstaller(t *testing.T) {
189 d := activationRootFixture(t)
190 env := filepath.Join(d, "claim-console.env")
191 drop := filepath.Join(d, "console.service.d")
192 calls := []string{}
193 ctl := func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")); return "", nil }
194 raw := []byte("CARLOS_CONSOLE_ID=dev-console\nCONSOLE_HOME_REGION=ie\nCARLOS_HOST_CLAIM=\n")
195 if err := installActivationConsole(env, drop, raw, true, ctl); err != nil {
196 t.Fatal(err)
197 }
198 if _, err := os.Stat(env); !os.IsNotExist(err) {
199 t.Fatal("dry run changed config")
200 }
201 if err := installActivationConsole(env, drop, raw, false, ctl); err != nil {
202 t.Fatal(err)
203 }
204 got, _ := os.ReadFile(env)
205 if !bytes.Equal(got, raw) {
206 t.Fatal("config mismatch")
207 }
208 if strings.Join(calls, ",") != "daemon-reload" {
209 t.Fatal("started console", calls)
210 }
211 os.WriteFile(env, []byte("operator-change\n"), 0600)
212 if err := installActivationConsole(env, drop, raw, false, ctl); err == nil {
213 t.Fatal("overwrote operator config")
214 }
215}
216
217func TestRootActivationControlSystemd(t *testing.T) {
218 activationRootFixture(t)
219 paths := []string{"/etc/carlos", "/etc/systemd/system/" + claimControlUnit, "/etc/systemd/system/.claims-activation.lock"}
220 for _, name := range rootMigrationOfflineUnits {
221 paths = append(paths, "/etc/systemd/system/"+name)
222 }
223 for _, path := range paths {
224 if _, err := os.Lstat(path); !os.IsNotExist(err) {
225 t.Fatal("pre-existing fixture path", path, err)
226 }
227 }
228 t.Cleanup(func() {
229 for _, path := range paths {
230 if err := os.RemoveAll(path); err != nil {
231 t.Error(err)
232 }
233 }
234 if raw, err := claimSystemctl(context.Background(), "daemon-reload"); err != nil {
235 t.Error(string(raw), err)
236 }
237 })
238 if err := os.Mkdir("/etc/carlos", 0700); err != nil {
239 t.Fatal(err)
240 }
241 for _, name := range rootMigrationOfflineUnits {
242 if err := os.Symlink("/dev/null", "/etc/systemd/system/"+name); err != nil {
243 t.Fatal(err)
244 }
245 }
246 if raw, err := claimSystemctl(t.Context(), "daemon-reload"); err != nil {
247 t.Fatal(string(raw), err)
248 }
249 if err := os.WriteFile("/etc/carlos/host.env", []byte("CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM_ORIGINS=1\n"), 0600); err != nil {
250 t.Fatal(err)
251 }
252 var result bytes.Buffer
253 if err := runSystemClaims([]string{"install-control"}, &result); err != nil {
254 t.Fatal(err)
255 }
256 raw, err := claimSystemctl(t.Context(), "cat", claimControlUnit)
257 if err != nil || !bytes.Contains(raw, []byte("MemoryMax=256M")) {
258 t.Fatal(string(raw), err)
259 }
260 if err = verifyOfflineRootMigration(t.Context()); err != nil {
261 t.Fatal("migration masks changed", err)
262 }
263 raw, err = claimSystemctl(t.Context(), "show", claimControlUnit, "--property=ActiveState", "--value")
264 if err != nil || strings.TrimSpace(string(raw)) != "inactive" {
265 t.Fatal("control started", string(raw), err)
266 }
267}
268
269func TestRootActivationFenceStoreReceipt(t *testing.T) {
270 if _, err := os.Lstat("/etc/carlos"); !os.IsNotExist(err) {
271 t.Fatal("pre-existing config directory", err)
272 }
273 if err := os.Mkdir("/etc/carlos", 0700); err != nil {
274 t.Fatal(err)
275 }
276 defer os.RemoveAll("/etc/carlos")
277 source := "/etc/carlos/fence-source.json"
278 target := "/etc/carlos/fence-writer/credentials.json"
279 raw := []byte(`{"role":"arn:aws:iam::123456789012:role/fence","region":"eu-west-1","bucket":"bucket","access_key":"fixture","secret_key":"fixture-secret"}`)
280 if err := os.WriteFile(source, raw, 0600); err != nil {
281 t.Fatal(err)
282 }
283 scope := activationFenceScope{Bucket: "bucket", Region: "eu-west-1", Role: "arn:aws:iam::123456789012:role/fence"}
284 if err := installActivationFenceCredentials(source, target, scope, false); err != nil {
285 t.Fatal(err)
286 }
287 if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err != nil {
288 t.Fatal("installed credentials refused", err)
289 }
290 marker := "/etc/carlos/fence-writer/credentials.installation.json"
291 if err := os.Remove(marker); err != nil {
292 t.Fatal(err)
293 }
294 if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err == nil {
295 t.Fatal("uninstalled 0600 credentials accepted")
296 }
297 if err := installActivationFenceCredentials(source, target, scope, false); err != nil {
298 t.Fatal("interrupted receipt not resumed", err)
299 }
300 os.Remove(marker)
301 os.Chmod(target, 0400)
302 if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err != nil {
303 t.Fatal("legacy 0400 credentials changed", err)
304 }
305}
306
307func TestRootActivationEvidenceBoundKey(t *testing.T) {
308 d := activationRootFixture(t)
309 path := filepath.Join(d, "evidence.key")
310 binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: strings.Repeat("a", 32)}
311 proof, err := installActivationEvidenceKey(path, binding, false)
312 if err != nil {
313 t.Fatal(err)
314 }
315 bound := proof.Binding
316 if _, err = installActivationEvidenceKey(path, bound, false); err != nil {
317 t.Fatal("bound replay", err)
318 }
319 if _, err = installActivationEvidenceKey(filepath.Join(d, "missing.key"), bound, false); err == nil {
320 t.Fatal("replaced a lost bound key")
321 }
322 if _, err = os.Lstat(filepath.Join(d, "missing.key")); !os.IsNotExist(err) {
323 t.Fatal("created replacement key")
324 }
325 bound.PublicKey[0] ^= 1
326 if _, err = installActivationEvidenceKey(path, bound, false); err == nil {
327 t.Fatal("accepted another bound key")
328 }
329}
330