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 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "os"
13 "path/filepath"
14 "reflect"
15 "testing"
16
17 "github.com/carlosframework/platform/internal/hostbox"
18 "github.com/carlosframework/platform/internal/hostguard"
19 "golang.org/x/sys/unix"
20)
21
22const inventoryRebootRoot = "/var/lib/carlos-e53-inventory"
23
24type inventoryRebootFixture struct {
25 Inventory hostbox.StopInventory
26 File, Link, Retry hostbox.Artifact
27 Journal hostbox.RetirementJournal
28}
29
30func inventoryRebootParker() hostbox.TypedParker {
31 return hostbox.TypedParker{Roots: map[string]string{"data": inventoryRebootRoot}, ParkedRoot: inventoryRebootRoot, Incarnation: "inc", Host: "inventory.example", Retirement: "retire"}
32}
33
34func TestRootRebootInventoryIdentityPhase1(t *testing.T) {
35 if os.Geteuid() != 0 {
36 t.Fatal("requires root")
37 }
38 if err := os.Mkdir(inventoryRebootRoot, 0700); err != nil {
39 t.Fatal(err)
40 }
41 for _, dir := range []string{"instances", "instances/inventory.example"} {
42 if err := os.Mkdir(filepath.Join(inventoryRebootRoot, dir), 0700); err != nil {
43 t.Fatal(err)
44 }
45 }
46 for _, name := range []string{"db", "retry"} {
47 if err := os.WriteFile(filepath.Join(inventoryRebootRoot, name), []byte(name), 0600); err != nil {
48 t.Fatal(err)
49 }
50 }
51 if err := os.Symlink("db", filepath.Join(inventoryRebootRoot, "live")); err != nil {
52 t.Fatal(err)
53 }
54 p := inventoryRebootParker()
55 f := inventoryRebootFixture{Inventory: hostbox.StopInventory{Complete: true, Host: "inventory.example", Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"}}
56 for _, v := range []struct {
57 name string
58 kind hostbox.ArtifactKind
59 out *hostbox.Artifact
60 }{{"db", hostbox.ArtifactPath, &f.File}, {"live", hostbox.ArtifactLiveLink, &f.Link}, {"retry", hostbox.ArtifactPath, &f.Retry}} {
61 var err error
62 *v.out, err = p.CaptureArtifact("data", v.name, "owner", v.kind)
63 if err != nil {
64 t.Fatal(err)
65 }
66 if v.out.Identity.Handle == "" {
67 t.Fatal("persistent handle required on runner")
68 }
69 }
70 // A lost reply after rename must verify the destination after reboot.
71 if err := p.Park(t.Context(), "retire", f.Retry); err != nil {
72 t.Fatal(err)
73 }
74 id, err := hostbox.CaptureInventoryIdentity(inventoryRebootRoot, "instances/inventory.example")
75 if err != nil {
76 t.Fatal(err)
77 }
78 owner := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", f.Inventory.Host, f.Inventory.Incarnation, f.Inventory.InstallationStamp, "box")))
79 f.Inventory.Entries = []hostbox.StopInventoryEntry{{Kind: "path", ManagedRoot: "data", RelativePath: "instances/inventory.example", ArtifactID: "state", Ownership: hex.EncodeToString(owner[:]), Materialization: "materialized", Device: id.Device, Inode: id.Inode, Type: unix.S_IFDIR, Identity: id}}
80 db, err := openRebootInventory(t, filepath.Join(inventoryRebootRoot, "inventory.db"))
81 if err != nil {
82 t.Fatal(err)
83 }
84 if err := db.WriteStopInventory(t.Context(), f.Inventory); err != nil {
85 t.Fatal(err)
86 }
87 if err := db.Close(); err != nil {
88 t.Fatal(err)
89 }
90 f.Journal, err = hostbox.NewRetirementJournal("retire", hostbox.RetirementInventory{Host: "inventory.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 1}, Ownership: "owner", Artifacts: []hostbox.Artifact{f.File, f.Link, f.Retry}})
91 if err != nil {
92 t.Fatal(err)
93 }
94 target := hostguard.FenceTarget{Host: "inventory.example", Incarnation: "inc", Stamp: 1}
95 retirement := inventoryRebootRetirement(p)
96 retirement.Filesystem = inventoryRebootInterrupt{p}
97 if err := retirement.Run(t.Context(), target, f.Journal); !errors.Is(err, os.ErrClosed) {
98 t.Fatal("retirement interruption", err)
99 }
100 raw, err := json.Marshal(f)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if err := writeFileDurable(filepath.Join(inventoryRebootRoot, "fixture.json"), raw, 0600); err != nil {
105 t.Fatal(err)
106 }
107 t.Logf("saved boot=%s device=%d mount=%d fs=%s", id.Boot, id.Device, id.Mount, id.Filesystem)
108}
109
110func TestRootRebootInventoryIdentityPhase2(t *testing.T) {
111 if os.Geteuid() != 0 {
112 t.Fatal("requires root")
113 }
114 defer os.RemoveAll(inventoryRebootRoot)
115 raw, err := os.ReadFile(filepath.Join(inventoryRebootRoot, "fixture.json"))
116 var f inventoryRebootFixture
117 if err != nil || json.Unmarshal(raw, &f) != nil {
118 t.Fatal("load", err)
119 }
120 db, err := openRebootInventory(t, filepath.Join(inventoryRebootRoot, "inventory.db"))
121 if err != nil {
122 t.Fatal(err)
123 }
124 defer db.Close()
125 snapshot, revision, err := db.Snapshot(t.Context(), f.Inventory.Host, hostguard.Captured{Incarnation: "inc", Stamp: 1})
126 if err != nil || revision != 1 || !reflect.DeepEqual(snapshot, f.Inventory) {
127 t.Fatal("persistent inventory snapshot", err)
128 }
129 reader, err := hostbox.OpenGenerationLaunchReader(filepath.Join(inventoryRebootRoot, "inventory.db"))
130 if err != nil {
131 t.Fatal(err)
132 }
133 defer reader.Close()
134 payloads, err := reader.GenerationPayloads(t.Context())
135 if err != nil || len(payloads) != 1 || !reflect.DeepEqual(payloads[0].Inventory, f.Inventory) {
136 t.Fatal("persistent payload", err)
137 }
138 e := f.Inventory.Entries[0]
139 now, err := hostbox.CaptureInventoryIdentity(inventoryRebootRoot, e.RelativePath)
140 if err != nil || now.Boot == e.Identity.Boot {
141 t.Fatal("requires actual reboot", err)
142 }
143 t.Logf("new boot=%s device=%d mount=%d fs=%s", now.Boot, now.Device, now.Mount, now.Filesystem)
144 // Force renumbered values too, so a runner retaining its numbers cannot
145 // accidentally make a raw-device comparison pass this acceptance.
146 e.Device += 1000
147 e.Identity.Device = e.Device
148 e.Identity.Mount += 1000
149 f.Inventory.Entries[0] = e
150 h := ClaimRootHTTP{Roots: map[string]string{"data": inventoryRebootRoot}}
151 a := ClaimRoleAssembly{ManagedRoots: h.Roots}
152 check := func() error {
153 if err := h.verifyInventoryPath(e); err != nil {
154 return fmt.Errorf("root inventory: %w", err)
155 }
156 if _, _, err := installationCoverage(h.Roots, f.Inventory, false); err != nil {
157 return fmt.Errorf("stop coverage: %w", err)
158 }
159 if err := generationPayloadFiles(t.Context(), inventoryRebootRoot, []hostbox.GenerationPayload{{Inventory: f.Inventory, Revision: 1}}); err != nil {
160 return fmt.Errorf("payload: %w", err)
161 }
162 changed, err := a.prepareStateInventory(ClaimLaunchInputs{DBPath: filepath.Join(inventoryRebootRoot, e.RelativePath, "app.db")}, &f.Inventory, hostbox.Actor{Box: "box"})
163 if changed {
164 return fmt.Errorf("rewrote retained inventory")
165 }
166 return err
167 }
168 if err := check(); err != nil {
169 t.Fatal(err)
170 }
171 if err := h.verifyNewInventoryIdentities(hostbox.StopInventory{}, f.Inventory); err == nil {
172 t.Error("old-boot evidence accepted as new inventory")
173 }
174 path := filepath.Join(inventoryRebootRoot, e.RelativePath)
175 if err := unix.Mount(path, path, "", unix.MS_BIND, ""); err != nil {
176 t.Fatal(err)
177 }
178 if err := check(); err == nil {
179 t.Error("accepted substituted mount")
180 }
181 if err := unix.Unmount(path, 0); err != nil {
182 t.Fatal(err)
183 }
184 if err := os.Chown(path, 65534, 65534); err != nil {
185 t.Fatal(err)
186 }
187 if err := check(); err == nil {
188 t.Error("accepted changed ownership")
189 }
190 if err := os.Chown(path, int(e.Identity.UID), int(e.Identity.GID)); err != nil {
191 t.Fatal(err)
192 }
193 if err := unix.Mount("tmpfs", path, "tmpfs", 0, "mode=0700"); err != nil {
194 t.Fatal(err)
195 }
196 if err := check(); err == nil {
197 t.Error("accepted different filesystem")
198 }
199 if err := unix.Unmount(path, 0); err != nil {
200 t.Fatal(err)
201 }
202 if err := check(); err != nil {
203 t.Fatal("restored original", err)
204 }
205 if err := os.Chown(inventoryRebootRoot, 65534, 65534); err != nil {
206 t.Fatal(err)
207 }
208 if err := check(); err == nil {
209 t.Error("accepted untrusted root ancestry")
210 }
211 if err := hostbox.VerifyInventoryIdentityForOwner(inventoryRebootRoot, e, 65534); err != nil {
212 t.Fatal("explicit managed-root owner refused after reboot", err)
213 }
214 if err := hostbox.VerifyInventoryIdentityForOwner(inventoryRebootRoot, e, 65533); err == nil {
215 t.Fatal("wrong managed-root owner accepted")
216 }
217 if err := os.Chmod(inventoryRebootRoot, 0777); err != nil {
218 t.Fatal(err)
219 }
220 if err := hostbox.VerifyInventoryIdentityForOwner(inventoryRebootRoot, e, 65534); err == nil {
221 t.Fatal("writable managed-root accepted")
222 }
223 if err := os.Chmod(inventoryRebootRoot, 0700); err != nil {
224 t.Fatal(err)
225 }
226 if err := os.Chown(inventoryRebootRoot, 0, 0); err != nil {
227 t.Fatal(err)
228 }
229
230 p := inventoryRebootParker()
231 for _, v := range []*hostbox.Artifact{&f.File, &f.Link, &f.Retry} {
232 v.Dev += 1000
233 v.Identity.Device = v.Dev
234 v.Identity.Mount += 1000
235 }
236 // Existing-file replacement must survive refused retirement.
237 if err := os.Rename(filepath.Join(inventoryRebootRoot, "db"), filepath.Join(inventoryRebootRoot, "saved")); err != nil {
238 t.Fatal(err)
239 }
240 if err := os.WriteFile(filepath.Join(inventoryRebootRoot, "db"), []byte("replacement"), 0600); err != nil {
241 t.Fatal(err)
242 }
243 if err := p.Park(t.Context(), "retire", f.File); err == nil {
244 t.Fatal("parked replacement")
245 }
246 if raw, err := os.ReadFile(filepath.Join(inventoryRebootRoot, "db")); err != nil || string(raw) != "replacement" {
247 t.Fatal("replacement changed", err)
248 }
249 if err := os.Remove(filepath.Join(inventoryRebootRoot, "db")); err != nil {
250 t.Fatal(err)
251 }
252 if err := os.Rename(filepath.Join(inventoryRebootRoot, "saved"), filepath.Join(inventoryRebootRoot, "db")); err != nil {
253 t.Fatal(err)
254 }
255 for _, v := range []hostbox.Artifact{f.File, f.Link, f.Retry} {
256 if err := p.Park(context.Background(), "retire", v); err != nil {
257 t.Fatalf("park %s: %v", v.Name, err)
258 }
259 if err := p.Park(context.Background(), "retire", v); err != nil {
260 t.Fatalf("replay %s: %v", v.Name, err)
261 }
262 }
263 retirement := inventoryRebootRetirement(p)
264 target := hostguard.FenceTarget{Host: "inventory.example", Incarnation: "inc", Stamp: 1}
265 if err := retirement.Run(t.Context(), target, f.Journal); err != nil {
266 t.Fatal("durable retirement recovery", err)
267 }
268 if err := retirement.Run(t.Context(), target, f.Journal); err != nil {
269 t.Fatal("durable retirement replay", err)
270 }
271 if target, err := os.Readlink(filepath.Join(inventoryRebootRoot, ".parked/inc/inventory.example/retire/data/live")); err != nil || target != "db" {
272 t.Fatal("link changed", err)
273 }
274 if retained, err := os.ReadFile(filepath.Join(inventoryRebootRoot, "fixture.json")); err != nil || string(retained) != string(raw) {
275 t.Fatal("rewrote durable evidence", err)
276 }
277}
278
279// Cleanup authority is a fixture; persistence, inode checks and journal replay
280// use the production implementations across the real reboot.
281type inventoryRebootAuthority struct{}
282
283func (inventoryRebootAuthority) WithCleanup(_ context.Context, _ hostguard.FenceTarget, fn func() error) error {
284 return fn()
285}
286
287type inventoryRebootRows struct{}
288
289func (inventoryRebootRows) RetireRows(context.Context, string, []hostbox.Artifact) error {
290 return os.ErrPermission
291}
292
293type inventoryRebootInterrupt struct{ parker hostbox.TypedParker }
294
295func (p inventoryRebootInterrupt) Park(ctx context.Context, id string, a hostbox.Artifact) error {
296 if a.Name != "retry" {
297 return os.ErrClosed
298 }
299 return p.parker.Park(ctx, id, a)
300}
301func inventoryRebootRetirement(p hostbox.TypedParker) hostbox.TypedRetirement {
302 return hostbox.TypedRetirement{JournalDir: filepath.Join(inventoryRebootRoot, "retirements"), Authority: inventoryRebootAuthority{}, Filesystem: p, Rows: inventoryRebootRows{}, Ack: func(_ context.Context, j hostbox.RetirementJournal) error {
303 if !j.Complete {
304 return os.ErrPermission
305 }
306 return nil
307 }}
308}
309