| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/sha256" |
| 9 | "encoding/hex" |
| 10 | "encoding/json" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostbox" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | "github.com/carlosframework/platform/internal/rootstate" |
| 19 | "golang.org/x/sys/unix" |
| 20 | ) |
| 21 | |
| 22 | const persistedRebootDir = "/var/lib/carlos-rootrunner-p3-persisted" |
| 23 | |
| 24 | type persistedReboot struct { |
| 25 | Entry hostbox.StopInventoryEntry |
| 26 | Artifact hostbox.Artifact |
| 27 | Unit proofUnitFile |
| 28 | Generation release.GenerationBinding |
| 29 | File hostbox.StopFileIdentity |
| 30 | } |
| 31 | |
| 32 | func TestRootRebootPersistedFamiliesPhase1(t *testing.T) { |
| 33 | if os.Geteuid() != 0 { |
| 34 | t.Fatal("root required") |
| 35 | } |
| 36 | if err := os.Mkdir(persistedRebootDir, 0755); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | for _, name := range []string{"data", "opt", "units", "data/state"} { |
| 40 | if err := os.Mkdir(filepath.Join(persistedRebootDir, name), 0755); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | } |
| 44 | path := filepath.Join(persistedRebootDir, "data/state") |
| 45 | f, err := hostbox.OpenInventoryFile(path) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | stable, err := rootstate.ObjectIdentity(f) |
| 50 | f.Close() |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | database := filepath.Join(path, "db.sqlite") |
| 55 | if err := os.WriteFile(database, []byte("db"), 0600); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | fileID, err := stopIdentity(database) |
| 59 | if err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | e := hostbox.StopInventoryEntry{Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: "state", Ownership: "owner", Materialization: "materialized", Device: stable.Device, Inode: stable.Inode, Type: stable.Mode & 0170000, Identity: stable} |
| 63 | unitPath := filepath.Join(persistedRebootDir, "units/app.service") |
| 64 | if err := os.WriteFile(unitPath, []byte("[Service]\nExecStart=/bin/true\n"), 0644); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | source := proofBindingSource{openFile: rootstate.OpenTrustedFile, persistent: rootstate.PersistentIdentity} |
| 68 | _, unit, err := source.trustedBytes(unitPath) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | versionPath := release.VersionPath(filepath.Join(persistedRebootDir, "opt"), "bab", "v1", "km") |
| 73 | if err := os.MkdirAll(filepath.Dir(versionPath), 0755); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if err := os.WriteFile(versionPath, []byte("executable"), 0555); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | g := release.GenerationBinding{Binding: release.HistoryBinding{Host: "h.example", Account: "bab", App: "km"}, Target: release.GenerationOwner{Host: "h.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "en", BootID: stable.Boot, ControllerID: "controller"}, Installation: "install", Attempt: "main", Inputs: "inputs", Invocation: "invocation", Record: "record", RecordETag: "etag", ControllerPID: os.Getpid(), ControllerStart: "start", Version: "v1", Digest: strings.Repeat("a", 64), Path: versionPath, Tree: filepath.Dir(versionPath)} |
| 80 | for path, dst := range map[string]*rootstate.FileIdentity{g.Path: &g.Executable, g.Tree: &g.TreeIdentity} { |
| 81 | opened, err := hostbox.OpenInventoryFile(path) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | *dst, err = rootstate.Identity(opened) |
| 86 | opened.Close() |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | } |
| 91 | for _, name := range []string{"old-pins", "stable-pins"} { |
| 92 | state, err := rootstate.Open(filepath.Join(persistedRebootDir, name), true, false) |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | ledger := &release.RootGenerations{Store: state} |
| 97 | binding := g |
| 98 | if name == "stable-pins" { |
| 99 | binding.ExecutableProof = testPersistentInventory(t, g.Path) |
| 100 | binding.TreeProof = testPersistentInventory(t, g.Tree) |
| 101 | } |
| 102 | if _, err = ledger.Reserve(binding); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | pins := release.RootGenerationPins{Ledger: ledger, Verify: func(context.Context, []release.GenerationReservation) error { return nil }} |
| 106 | if _, err = pins.StagedPins(t.Context(), filepath.Join(persistedRebootDir, "opt")); err != nil { |
| 107 | t.Fatal("initial pins", err) |
| 108 | } |
| 109 | state.Close() |
| 110 | } |
| 111 | parker := hostbox.TypedParker{CapturePersistent: true, Roots: map[string]string{"data": filepath.Join(persistedRebootDir, "data")}, ParkedRoot: filepath.Join(persistedRebootDir, "data"), Incarnation: "inc", Host: "h.example", Retirement: "retired"} |
| 112 | a, err := parker.CaptureArtifact("data", "state", "owner", hostbox.ArtifactPath) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | s, err := rootstate.Open(filepath.Join(persistedRebootDir, "authority"), true, false) |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | defer s.Close() |
| 121 | raw, _ := json.Marshal(persistedReboot{File: fileID, Entry: e, Artifact: a, Unit: unit, Generation: g}) |
| 122 | if err = s.Write("fixture.json", raw); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | t.Logf("boot=%s device=%d mount=%d", stable.Boot, stable.Device, stable.Mount) |
| 126 | } |
| 127 | func TestRootRebootPersistedFamiliesPhase2(t *testing.T) { |
| 128 | defer os.RemoveAll(persistedRebootDir) |
| 129 | s, err := rootstate.Open(filepath.Join(persistedRebootDir, "authority"), false, false) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | defer s.Close() |
| 134 | raw, err := s.Read("fixture.json") |
| 135 | if err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | var f persistedReboot |
| 139 | if err = json.Unmarshal(raw, &f); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| 143 | if err != nil || strings.TrimSpace(string(boot)) == f.Entry.Identity.Boot { |
| 144 | t.Fatal("actual reboot required", err) |
| 145 | } |
| 146 | roots := map[string]string{"data": filepath.Join(persistedRebootDir, "data"), "opt": filepath.Join(persistedRebootDir, "opt"), "units": filepath.Join(persistedRebootDir, "units")} |
| 147 | in := hostbox.StopInventory{Complete: true, Host: "h.example", Incarnation: "inc", InstallationStamp: 1, Entries: []hostbox.StopInventoryEntry{f.Entry}} |
| 148 | t.Run("installation", func(t *testing.T) { |
| 149 | if err := (ClaimRootHTTP{Roots: roots}).verifyInventoryPath(f.Entry); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | }) |
| 153 | t.Run("state-reuse", func(t *testing.T) { |
| 154 | retained := in |
| 155 | retained.Entries = append([]hostbox.StopInventoryEntry(nil), in.Entries...) |
| 156 | owner := sha256.Sum256([]byte("h.example\x00inc\x001\x00box")) |
| 157 | retained.Entries[0].Ownership = hex.EncodeToString(owner[:]) |
| 158 | a := ClaimRoleAssembly{ManagedRoots: roots} |
| 159 | changed, err := a.prepareStateInventory(ClaimLaunchInputs{DBPath: filepath.Join(roots["data"], "state", "db.sqlite")}, &retained, hostbox.Actor{Box: "box"}) |
| 160 | if err != nil || changed { |
| 161 | t.Fatal("retained state was not reused", changed, err) |
| 162 | } |
| 163 | }) |
| 164 | t.Run("payload", func(t *testing.T) { |
| 165 | if err := generationPayloadFiles(t.Context(), roots["data"], []hostbox.GenerationPayload{{Inventory: in}}); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | }) |
| 169 | t.Run("stopped-installation", func(t *testing.T) { |
| 170 | if _, _, err := installationCoverage(roots, in, true); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | }) |
| 174 | t.Run("migration", func(t *testing.T) { |
| 175 | version, err := filepath.Rel(roots["opt"], f.Generation.Path) |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | if err := hostbox.CheckMigrationRoots(t.Context(), roots, map[string][]string{"units": {"app.service"}, "opt": {version}}, []hostbox.StopInventory{in}); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | }) |
| 183 | t.Run("named-unit", func(t *testing.T) { |
| 184 | source := proofBindingSource{openFile: rootstate.OpenTrustedFile, persistent: rootstate.PersistentIdentity} |
| 185 | _, now, err := source.trustedBytes(f.Unit.Path) |
| 186 | if err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | if !sameProofFiles([]proofUnitFile{f.Unit}, []proofUnitFile{now}) || !bytes.Equal(proofFileSource(f.Unit), proofFileSource(now)) { |
| 190 | t.Fatal("unit source changed across boot") |
| 191 | } |
| 192 | bad := f.Unit |
| 193 | bad.Persistent.Filesystem = "foreign" |
| 194 | if sameProofFiles([]proofUnitFile{bad}, []proofUnitFile{now}) { |
| 195 | t.Fatal("foreign unit accepted") |
| 196 | } |
| 197 | }) |
| 198 | t.Run("old-generation", func(t *testing.T) { |
| 199 | if _, err := generationCurrentFiles(f.Generation, rootstate.OpenTrustedFile); err == nil { |
| 200 | t.Fatal("old record without persistent evidence crossed boot") |
| 201 | } |
| 202 | }) |
| 203 | t.Run("release-pins", func(t *testing.T) { |
| 204 | for _, name := range []string{"old-pins", "stable-pins"} { |
| 205 | state, err := rootstate.Open(filepath.Join(persistedRebootDir, name), false, false) |
| 206 | if err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | pins := release.RootGenerationPins{Ledger: &release.RootGenerations{Store: state}, Verify: func(context.Context, []release.GenerationReservation) error { return nil }} |
| 210 | _, err = pins.StagedPins(t.Context(), roots["opt"]) |
| 211 | state.Close() |
| 212 | if (err != nil) != (name == "old-pins") { |
| 213 | t.Fatal(name, err) |
| 214 | } |
| 215 | } |
| 216 | }) |
| 217 | t.Run("cgroup", func(t *testing.T) { |
| 218 | r := release.GenerationReservation{Binding: f.Generation} |
| 219 | r.Binding.Binding.Host = "h.example" |
| 220 | root := filepath.Join(persistedRebootDir, "cgroups") |
| 221 | group := filepath.Join(root, generationBindingGroup(r.Binding)) |
| 222 | if err := os.MkdirAll(group, 0755); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | r.Process.Group = generationBindingGroup(r.Binding) |
| 226 | r.Process.PID = 42 |
| 227 | if err := generationGroupGone(t.Context(), root, r); err == nil { |
| 228 | t.Fatal("reused cgroup from another boot accepted") |
| 229 | } |
| 230 | if err := os.Remove(group); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | if err := generationGroupGone(t.Context(), root, r); err != nil { |
| 234 | t.Fatal("absent old group refused", err) |
| 235 | } |
| 236 | }) |
| 237 | t.Run("substitution", func(t *testing.T) { |
| 238 | changed := f.Entry |
| 239 | changed.Device++ |
| 240 | changed.Identity.Device++ |
| 241 | changed.Identity.Mount++ |
| 242 | if err := hostbox.VerifyInventoryIdentity(roots["data"], changed); err != nil { |
| 243 | t.Fatal("boot-local numbers used", err) |
| 244 | } |
| 245 | changed.Identity.Filesystem = "foreign" |
| 246 | if err := hostbox.VerifyInventoryIdentity(roots["data"], changed); err == nil { |
| 247 | t.Fatal("foreign filesystem accepted") |
| 248 | } |
| 249 | path := filepath.Join(roots["data"], "state") |
| 250 | if err := unix.Mount(path, path, "", unix.MS_BIND, ""); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | err := hostbox.VerifyInventoryIdentity(roots["data"], f.Entry) |
| 254 | if e := unix.Unmount(path, 0); e != nil { |
| 255 | t.Fatal(e) |
| 256 | } |
| 257 | if err == nil { |
| 258 | t.Fatal("substituted mount accepted") |
| 259 | } |
| 260 | }) |
| 261 | t.Run("stop-descriptors", func(t *testing.T) { |
| 262 | k := LinuxStopKernel{ManagedRoots: roots} |
| 263 | files, err := k.rebootStopFiles(t.Context(), in, []hostbox.StopFileIdentity{f.File}) |
| 264 | if err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | database := filepath.Join(roots["data"], "state", "db.sqlite") |
| 268 | moved := filepath.Join(persistedRebootDir, "renamed.sqlite") |
| 269 | if err = os.Rename(database, moved); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | held, err := os.Open(moved) |
| 273 | if err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | err = scanStopDescriptors(t.Context(), "/proc", files, []string{database}) |
| 277 | held.Close() |
| 278 | if err == nil { |
| 279 | t.Fatal("renamed descriptor escaped reboot deny set") |
| 280 | } |
| 281 | if err = scanStopDescriptors(t.Context(), "/proc", files, []string{database}); err != nil { |
| 282 | t.Fatal("closed descriptor refused", err) |
| 283 | } |
| 284 | if err = os.Rename(moved, database); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | bad := in |
| 288 | bad.Entries = append([]hostbox.StopInventoryEntry(nil), in.Entries...) |
| 289 | bad.Entries[0].Identity.Filesystem = "foreign" |
| 290 | if _, err = k.rebootStopFiles(t.Context(), bad, []hostbox.StopFileIdentity{f.File}); err == nil { |
| 291 | t.Fatal("foreign filesystem rebased") |
| 292 | } |
| 293 | }) |
| 294 | t.Run("retirement", func(t *testing.T) { |
| 295 | p := hostbox.TypedParker{Roots: roots, ParkedRoot: roots["data"], Incarnation: "inc", Host: "h.example", Retirement: "retired"} |
| 296 | if err := p.Park(t.Context(), "state", f.Artifact); err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | if err := p.Park(t.Context(), "state", f.Artifact); err != nil { |
| 300 | t.Fatal("durable park replay", err) |
| 301 | } |
| 302 | entry := f.Entry |
| 303 | entry.RelativePath = filepath.Join(".parked", "inc", "h.example", "retired", "data", "state") |
| 304 | if err := generationPayloadFiles(t.Context(), roots["data"], []hostbox.GenerationPayload{{Inventory: hostbox.StopInventory{Entries: []hostbox.StopInventoryEntry{entry}}}}); err != nil { |
| 305 | t.Fatal("parked payload", err) |
| 306 | } |
| 307 | }) |
| 308 | t.Run("historic-migration", TestPlan14RootMigrationHistoricRootRunner) |
| 309 | t.Run("retry-delivery", TestPlan14RootRetryProjectionDelivery) |
| 310 | after, err := s.Read("fixture.json") |
| 311 | if err != nil || !bytes.Equal(raw, after) { |
| 312 | t.Fatal("reboot rewrote authority", err) |
| 313 | } |
| 314 | } |
| 315 | |