//go:build linux && rootrunner package host import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "reflect" "testing" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostguard" "golang.org/x/sys/unix" ) const inventoryRebootRoot = "/var/lib/carlos-e53-inventory" type inventoryRebootFixture struct { Inventory hostbox.StopInventory File, Link, Retry hostbox.Artifact Journal hostbox.RetirementJournal } func inventoryRebootParker() hostbox.TypedParker { return hostbox.TypedParker{Roots: map[string]string{"data": inventoryRebootRoot}, ParkedRoot: inventoryRebootRoot, Incarnation: "inc", Host: "inventory.example", Retirement: "retire"} } func TestRootRebootInventoryIdentityPhase1(t *testing.T) { if os.Geteuid() != 0 { t.Fatal("requires root") } if err := os.Mkdir(inventoryRebootRoot, 0700); err != nil { t.Fatal(err) } for _, dir := range []string{"instances", "instances/inventory.example"} { if err := os.Mkdir(filepath.Join(inventoryRebootRoot, dir), 0700); err != nil { t.Fatal(err) } } for _, name := range []string{"db", "retry"} { if err := os.WriteFile(filepath.Join(inventoryRebootRoot, name), []byte(name), 0600); err != nil { t.Fatal(err) } } if err := os.Symlink("db", filepath.Join(inventoryRebootRoot, "live")); err != nil { t.Fatal(err) } p := inventoryRebootParker() f := inventoryRebootFixture{Inventory: hostbox.StopInventory{Complete: true, Host: "inventory.example", Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"}} for _, v := range []struct { name string kind hostbox.ArtifactKind out *hostbox.Artifact }{{"db", hostbox.ArtifactPath, &f.File}, {"live", hostbox.ArtifactLiveLink, &f.Link}, {"retry", hostbox.ArtifactPath, &f.Retry}} { var err error *v.out, err = p.CaptureArtifact("data", v.name, "owner", v.kind) if err != nil { t.Fatal(err) } if v.out.Identity.Handle == "" { t.Fatal("persistent handle required on runner") } } // A lost reply after rename must verify the destination after reboot. if err := p.Park(t.Context(), "retire", f.Retry); err != nil { t.Fatal(err) } id, err := hostbox.CaptureInventoryIdentity(inventoryRebootRoot, "instances/inventory.example") if err != nil { t.Fatal(err) } owner := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", f.Inventory.Host, f.Inventory.Incarnation, f.Inventory.InstallationStamp, "box"))) 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}} db, err := openRebootInventory(t, filepath.Join(inventoryRebootRoot, "inventory.db")) if err != nil { t.Fatal(err) } if err := db.WriteStopInventory(t.Context(), f.Inventory); err != nil { t.Fatal(err) } if err := db.Close(); err != nil { t.Fatal(err) } 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}}) if err != nil { t.Fatal(err) } target := hostguard.FenceTarget{Host: "inventory.example", Incarnation: "inc", Stamp: 1} retirement := inventoryRebootRetirement(p) retirement.Filesystem = inventoryRebootInterrupt{p} if err := retirement.Run(t.Context(), target, f.Journal); !errors.Is(err, os.ErrClosed) { t.Fatal("retirement interruption", err) } raw, err := json.Marshal(f) if err != nil { t.Fatal(err) } if err := writeFileDurable(filepath.Join(inventoryRebootRoot, "fixture.json"), raw, 0600); err != nil { t.Fatal(err) } t.Logf("saved boot=%s device=%d mount=%d fs=%s", id.Boot, id.Device, id.Mount, id.Filesystem) } func TestRootRebootInventoryIdentityPhase2(t *testing.T) { if os.Geteuid() != 0 { t.Fatal("requires root") } defer os.RemoveAll(inventoryRebootRoot) raw, err := os.ReadFile(filepath.Join(inventoryRebootRoot, "fixture.json")) var f inventoryRebootFixture if err != nil || json.Unmarshal(raw, &f) != nil { t.Fatal("load", err) } db, err := openRebootInventory(t, filepath.Join(inventoryRebootRoot, "inventory.db")) if err != nil { t.Fatal(err) } defer db.Close() snapshot, revision, err := db.Snapshot(t.Context(), f.Inventory.Host, hostguard.Captured{Incarnation: "inc", Stamp: 1}) if err != nil || revision != 1 || !reflect.DeepEqual(snapshot, f.Inventory) { t.Fatal("persistent inventory snapshot", err) } reader, err := hostbox.OpenGenerationLaunchReader(filepath.Join(inventoryRebootRoot, "inventory.db")) if err != nil { t.Fatal(err) } defer reader.Close() payloads, err := reader.GenerationPayloads(t.Context()) if err != nil || len(payloads) != 1 || !reflect.DeepEqual(payloads[0].Inventory, f.Inventory) { t.Fatal("persistent payload", err) } e := f.Inventory.Entries[0] now, err := hostbox.CaptureInventoryIdentity(inventoryRebootRoot, e.RelativePath) if err != nil || now.Boot == e.Identity.Boot { t.Fatal("requires actual reboot", err) } t.Logf("new boot=%s device=%d mount=%d fs=%s", now.Boot, now.Device, now.Mount, now.Filesystem) // Force renumbered values too, so a runner retaining its numbers cannot // accidentally make a raw-device comparison pass this acceptance. e.Device += 1000 e.Identity.Device = e.Device e.Identity.Mount += 1000 f.Inventory.Entries[0] = e h := ClaimRootHTTP{Roots: map[string]string{"data": inventoryRebootRoot}} a := ClaimRoleAssembly{ManagedRoots: h.Roots} check := func() error { if err := h.verifyInventoryPath(e); err != nil { return fmt.Errorf("root inventory: %w", err) } if _, _, err := installationCoverage(h.Roots, f.Inventory, false); err != nil { return fmt.Errorf("stop coverage: %w", err) } if err := generationPayloadFiles(t.Context(), inventoryRebootRoot, []hostbox.GenerationPayload{{Inventory: f.Inventory, Revision: 1}}); err != nil { return fmt.Errorf("payload: %w", err) } changed, err := a.prepareStateInventory(ClaimLaunchInputs{DBPath: filepath.Join(inventoryRebootRoot, e.RelativePath, "app.db")}, &f.Inventory, hostbox.Actor{Box: "box"}) if changed { return fmt.Errorf("rewrote retained inventory") } return err } if err := check(); err != nil { t.Fatal(err) } if err := h.verifyNewInventoryIdentities(hostbox.StopInventory{}, f.Inventory); err == nil { t.Error("old-boot evidence accepted as new inventory") } path := filepath.Join(inventoryRebootRoot, e.RelativePath) if err := unix.Mount(path, path, "", unix.MS_BIND, ""); err != nil { t.Fatal(err) } if err := check(); err == nil { t.Error("accepted substituted mount") } if err := unix.Unmount(path, 0); err != nil { t.Fatal(err) } if err := os.Chown(path, 65534, 65534); err != nil { t.Fatal(err) } if err := check(); err == nil { t.Error("accepted changed ownership") } if err := os.Chown(path, int(e.Identity.UID), int(e.Identity.GID)); err != nil { t.Fatal(err) } if err := unix.Mount("tmpfs", path, "tmpfs", 0, "mode=0700"); err != nil { t.Fatal(err) } if err := check(); err == nil { t.Error("accepted different filesystem") } if err := unix.Unmount(path, 0); err != nil { t.Fatal(err) } if err := check(); err != nil { t.Fatal("restored original", err) } if err := os.Chown(inventoryRebootRoot, 65534, 65534); err != nil { t.Fatal(err) } if err := check(); err == nil { t.Error("accepted untrusted root ancestry") } if err := hostbox.VerifyInventoryIdentityForOwner(inventoryRebootRoot, e, 65534); err != nil { t.Fatal("explicit managed-root owner refused after reboot", err) } if err := hostbox.VerifyInventoryIdentityForOwner(inventoryRebootRoot, e, 65533); err == nil { t.Fatal("wrong managed-root owner accepted") } if err := os.Chmod(inventoryRebootRoot, 0777); err != nil { t.Fatal(err) } if err := hostbox.VerifyInventoryIdentityForOwner(inventoryRebootRoot, e, 65534); err == nil { t.Fatal("writable managed-root accepted") } if err := os.Chmod(inventoryRebootRoot, 0700); err != nil { t.Fatal(err) } if err := os.Chown(inventoryRebootRoot, 0, 0); err != nil { t.Fatal(err) } p := inventoryRebootParker() for _, v := range []*hostbox.Artifact{&f.File, &f.Link, &f.Retry} { v.Dev += 1000 v.Identity.Device = v.Dev v.Identity.Mount += 1000 } // Existing-file replacement must survive refused retirement. if err := os.Rename(filepath.Join(inventoryRebootRoot, "db"), filepath.Join(inventoryRebootRoot, "saved")); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(inventoryRebootRoot, "db"), []byte("replacement"), 0600); err != nil { t.Fatal(err) } if err := p.Park(t.Context(), "retire", f.File); err == nil { t.Fatal("parked replacement") } if raw, err := os.ReadFile(filepath.Join(inventoryRebootRoot, "db")); err != nil || string(raw) != "replacement" { t.Fatal("replacement changed", err) } if err := os.Remove(filepath.Join(inventoryRebootRoot, "db")); err != nil { t.Fatal(err) } if err := os.Rename(filepath.Join(inventoryRebootRoot, "saved"), filepath.Join(inventoryRebootRoot, "db")); err != nil { t.Fatal(err) } for _, v := range []hostbox.Artifact{f.File, f.Link, f.Retry} { if err := p.Park(context.Background(), "retire", v); err != nil { t.Fatalf("park %s: %v", v.Name, err) } if err := p.Park(context.Background(), "retire", v); err != nil { t.Fatalf("replay %s: %v", v.Name, err) } } retirement := inventoryRebootRetirement(p) target := hostguard.FenceTarget{Host: "inventory.example", Incarnation: "inc", Stamp: 1} if err := retirement.Run(t.Context(), target, f.Journal); err != nil { t.Fatal("durable retirement recovery", err) } if err := retirement.Run(t.Context(), target, f.Journal); err != nil { t.Fatal("durable retirement replay", err) } if target, err := os.Readlink(filepath.Join(inventoryRebootRoot, ".parked/inc/inventory.example/retire/data/live")); err != nil || target != "db" { t.Fatal("link changed", err) } if retained, err := os.ReadFile(filepath.Join(inventoryRebootRoot, "fixture.json")); err != nil || string(retained) != string(raw) { t.Fatal("rewrote durable evidence", err) } } // Cleanup authority is a fixture; persistence, inode checks and journal replay // use the production implementations across the real reboot. type inventoryRebootAuthority struct{} func (inventoryRebootAuthority) WithCleanup(_ context.Context, _ hostguard.FenceTarget, fn func() error) error { return fn() } type inventoryRebootRows struct{} func (inventoryRebootRows) RetireRows(context.Context, string, []hostbox.Artifact) error { return os.ErrPermission } type inventoryRebootInterrupt struct{ parker hostbox.TypedParker } func (p inventoryRebootInterrupt) Park(ctx context.Context, id string, a hostbox.Artifact) error { if a.Name != "retry" { return os.ErrClosed } return p.parker.Park(ctx, id, a) } func inventoryRebootRetirement(p hostbox.TypedParker) hostbox.TypedRetirement { return hostbox.TypedRetirement{JournalDir: filepath.Join(inventoryRebootRoot, "retirements"), Authority: inventoryRebootAuthority{}, Filesystem: p, Rows: inventoryRebootRows{}, Ack: func(_ context.Context, j hostbox.RetirementJournal) error { if !j.Complete { return os.ErrPermission } return nil }} }