host, hostexec: root arming, pre-exec checks and update/controller exclusion (claim Plan 8b Task 3)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+1306
−22
internal/host/claimreport.go+39 −0internal/host/guardarm.go+181 −0internal/host/guardexec.go+80 −0internal/host/guardroster.go+13 −0internal/host/guardtransaction.go+315 −0internal/host/guardtransaction_test.go+366 −0internal/host/systemapply.go+51 −15internal/hostexec/controller.go+81 −6internal/hostexec/updateidentity_test.go+174 −0system.go+6 −1
diff --git a/internal/host/claimreport.go b/internal/host/claimreport.go| new file mode 100644 |
| index 000000000..b15b53cc6 |
| --- /dev/null |
| +++ b/internal/host/claimreport.go |
| @@ -0,0 +1,39 @@ |
| +package host |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| + "github.com/carlosframework/platform/internal/version" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| +) |
| + |
| +func claimReport(p *status.Probes) { |
| + d, err := floorDisk(release.OptDir()) |
| + if err != nil { |
| + return |
| + } |
| + e, err := hostfloor.ReadEnrollment("/etc/carlos/host-actor.json") |
| + if err != nil { |
| + return |
| + } |
| + r, err := d.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH) |
| + if err != nil { |
| + return |
| + } |
| + if completed, err := os.ReadFile(filepath.Join(d.Dir, "completed.json")); err == nil && len(completed) == 32 && hostfloor.UpdateIdle(d.Dir, filepath.Join(systemRootDir(release.OptDir()), "journal.json")) == nil { |
| + p.ActivationID = string(completed) |
| + p.ActivationComplete = true |
| + } |
| + p.Enrollment = e.Enrollment |
| + p.ReleaseTag = r.ReleaseTag |
| + p.BinarySHA256 = r.Hash |
| + p.MovesProtocol = r.Protocol |
| + p.BootID = r.BootID |
| + p.FloorSHA256 = r.FloorSHA256 |
| + p.FloorGuardReady = r.GuardReady |
| + p.HostClaimEnabled = strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" |
| +} |
diff --git a/internal/host/guardarm.go b/internal/host/guardarm.go| new file mode 100644 |
| index 000000000..84d00f15b |
| --- /dev/null |
| +++ b/internal/host/guardarm.go |
| @@ -0,0 +1,181 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "flag" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/version" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| +) |
| + |
| +func RunSystemGuardArm(args []string) { |
| + fs := flag.NewFlagSet("system guard-arm", flag.ExitOnError) |
| + consolePath := fs.String("console", "", "") |
| + policyPath := fs.String("policy", "", "") |
| + identityPath := fs.String("identity", "", "") |
| + enrollmentPath := fs.String("enrollment", "", "") |
| + _ = fs.Parse(args) |
| + if os.Geteuid() != 0 || *policyPath == "" || *identityPath == "" || *enrollmentPath == "" { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + lockDir := systemRootDir(release.OptDir()) |
| + if err := os.MkdirAll(lockDir, 0755); err != nil { |
| + log.Fatal(err) |
| + } |
| + lock, err := os.OpenFile(filepath.Join(lockDir, systemLockFile), os.O_CREATE|os.O_RDWR, 0600) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + defer lock.Close() |
| + if err = flockExclusiveNonblock(lock); err != nil { |
| + log.Fatal(err) |
| + } |
| + d, err := floorDisk(release.OptDir()) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + raw, err := os.ReadFile(*policyPath) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + var p hostfloor.Policy |
| + if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + f, err := p.Verify(d.PublicKey, d.Bucket) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + identity, err := os.ReadFile(*identityPath) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + hash, err := hostfloor.ExecutingHash() |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + platform := runtime.GOOS + "-" + runtime.GOARCH |
| + if err = hostfloor.CheckImage(d.PublicKey, f, identity, version.Build, platform, hash); err != nil { |
| + log.Fatal(err) |
| + } |
| + if _, err = os.Stat(filepath.Join(systemRootDir(release.OptDir()), "journal.json")); !os.IsNotExist(err) { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + for _, path := range []string{systemBinaryPath(release.OptDir()), systemPrevPath(release.OptDir())} { |
| + candidate, err := hostfloor.OpenProtected(path) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + b, err := os.ReadFile(candidate.Name()) |
| + candidate.Close() |
| + if err != nil || hostfloor.CheckImage(d.PublicKey, f, identity, version.Build, platform, hostfloor.Digest(b)) != nil { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + } |
| + // Enrollment input is reporting configuration, verified against the signed |
| + // directory before root persists it. It cannot grant roster membership. |
| + enrollment, err := hostfloor.ReadEnrollment(*enrollmentPath) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + store, pub, err := openSystemStore(context.Background()) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + roster, err := readGuardRoster(context.Background(), store, pub, d.Bucket) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + b, ok := roster.Boxes[enrollment.Box] |
| + if ok { |
| + if b.Enrollment != enrollment.Enrollment || b.Cloud.InstanceID != enrollment.InstanceID { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + } else { |
| + // A post-floor join must arm and report before it becomes an actor. |
| + // The invitation grants a reporting nonce, never execution authority. |
| + wire, _, e := store.Get(context.Background(), "control/host-actor-invitations/"+enrollment.Box+".json") |
| + if e != nil { |
| + log.Fatal(e) |
| + } |
| + var invitation hostactors.Invitation |
| + if release.VerifyRootEnvelope(pub, wire, &invitation) != nil || invitation.Bucket != d.Bucket || invitation.Reference != enrollment.Box || invitation.Enrollment != enrollment.Enrollment { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + } |
| + for _, dir := range []string{d.Dir, filepath.Join(d.Dir, "identities")} { |
| + if err = os.MkdirAll(dir, 0755); err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| + path := filepath.Join(d.Dir, "policy.json") |
| + if _, err = os.Stat(path); os.IsNotExist(err) { |
| + initial, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: d.Bucket, Mode: "unpublished"}) |
| + if err = hostfloor.Durable(path, initial, 0644); err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| + // The checker is outside system/router and outside every restore set. |
| + if err = copyFileDurable("/proc/self/exe", filepath.Join(d.Dir, "carlos"), 0755); err != nil { |
| + log.Fatal(err) |
| + } |
| + snapshot, err := release.VerifySystemIdentity(d.PublicKey, identity) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if err = hostfloor.Durable(filepath.Join(d.Dir, "identities", snapshot.Current.ReleaseTag+".json"), identity, 0644); err != nil { |
| + log.Fatal(err) |
| + } |
| + enrollmentRaw, _ := json.Marshal(enrollment) |
| + if err = hostfloor.Durable("/etc/carlos/host-actor.json", enrollmentRaw, 0644); err != nil { |
| + log.Fatal(err) |
| + } |
| + dropins := map[string]string{ |
| + "carlos-system-update.service": "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec apply\n", |
| + "carlos-system-recover.service": "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec recover\n", |
| + "carlos-edge.service": "[Unit]\nRequires=carlos-system-recover.service\nAfter=carlos-system-recover.service\n[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec edge\n", |
| + } |
| + if *consolePath != "" { |
| + if !filepath.IsAbs(*consolePath) || filepath.Clean(*consolePath) != *consolePath { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + for _, path := range []string{*consolePath, *consolePath + ".prev"} { |
| + file, e := hostfloor.OpenProtected(path) |
| + if e != nil { |
| + log.Fatal(e) |
| + } |
| + body, e := os.ReadFile(file.Name()) |
| + file.Close() |
| + if e != nil || hostfloor.CheckComponent(d.PublicKey, f, identity, version.Build, platform, hostfloor.Digest(body), "console") != nil { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + } |
| + dropins["console.service"] = "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec console " + *consolePath + "\n" |
| + } |
| + for unit, body := range dropins { |
| + dir := filepath.Join("/etc/systemd/system", unit+".d") |
| + if err = os.MkdirAll(dir, 0755); err != nil { |
| + log.Fatal(err) |
| + } |
| + if err = hostfloor.Durable(filepath.Join(dir, "90-host-floor.conf"), []byte(body), 0644); err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| + if _, err = productionSysOps().systemctl("daemon-reload"); err != nil { |
| + log.Fatal(err) |
| + } |
| + if *consolePath != "" { |
| + if err = hostfloor.Durable(filepath.Join(d.Dir, "console-ready.json"), []byte(*consolePath), 0644); err != nil { |
| + log.Fatal(err) |
| + } |
| + } |
| + if err = d.Persist(p, map[string][]byte{snapshot.Current.ReleaseTag: identity}); err != nil { |
| + log.Fatal(err) |
| + } |
| +} |
diff --git a/internal/host/guardexec.go b/internal/host/guardexec.go| new file mode 100644 |
| index 000000000..446a3a591 |
| --- /dev/null |
| +++ b/internal/host/guardexec.go |
| @@ -0,0 +1,80 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "time" |
| +) |
| + |
| +func floorDisk(optDir string) (hostfloor.Disk, error) { |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return hostfloor.Disk{}, err |
| + } |
| + bucket := os.Getenv("CARLOS_DEPLOYMENT_BUCKET") |
| + if bucket == "" { |
| + return hostfloor.Disk{}, hostfloor.ErrFloor |
| + } |
| + return hostfloor.Disk{Dir: filepath.Join(optDir, "system", "guard"), Bucket: bucket, PublicKey: pub}, nil |
| +} |
| +func RunSystemGuardExec(args []string) { |
| + if len(args) < 1 { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + d, err := floorDisk(release.OptDir()) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + policy, local, err := d.Load() |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if policy.Mode != "unpublished" { |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + store, pub, e := openSystemStore(ctx) |
| + if e == nil { |
| + e = hostfloor.CheckRemote(ctx, store, pub, d.Bucket, local) |
| + } |
| + cancel() |
| + if e != nil && !hostfloor.Offline(e) { |
| + log.Fatal(e) |
| + } |
| + } |
| + current := systemBinaryPath(release.OptDir()) |
| + candidate := current |
| + component := "router" |
| + switch args[0] { |
| + case "apply", "recover": |
| + _, err := os.Stat(filepath.Join(systemRootDir(release.OptDir()), "journal.json")) |
| + if err == nil { |
| + candidate = systemPrevPath(release.OptDir()) |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + log.Fatal(err) |
| + } |
| + mode := args[0] |
| + args = []string{"system", "apply"} |
| + if mode == "recover" { |
| + args = append(args, "--recover") |
| + } |
| + case "console": |
| + if len(args) < 2 { |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + component = "console" |
| + candidate = args[1] |
| + args = args[2:] |
| + case "edge": |
| + args = []string{"agent"} |
| + default: |
| + log.Fatal(hostfloor.ErrFloor) |
| + } |
| + if err = d.Exec(candidate, runtime.GOOS+"-"+runtime.GOARCH, args, os.Environ(), component); err != nil { |
| + log.Fatal(err) |
| + } |
| +} |
diff --git a/internal/host/guardroster.go b/internal/host/guardroster.go| new file mode 100644 |
| index 000000000..f0e34300b |
| --- /dev/null |
| +++ b/internal/host/guardroster.go |
| @@ -0,0 +1,13 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func readGuardRoster(ctx context.Context, s release.Store, pub ed25519.PublicKey, bucket string) (hostactors.Roster, error) { |
| + o, err := (hostactors.Directory{Store: s, PublicKey: pub, Bucket: bucket}).Read(ctx) |
| + return o.Roster, err |
| +} |
diff --git a/internal/host/guardtransaction.go b/internal/host/guardtransaction.go| new file mode 100644 |
| index 000000000..88a22eccd |
| --- /dev/null |
| +++ b/internal/host/guardtransaction.go |
| @@ -0,0 +1,315 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "time" |
| +) |
| + |
| +type guardTransaction struct { |
| + Roster json.RawMessage `json:"roster"` |
| + ID string `json:"id"` |
| + Actor string `json:"actor"` |
| + Enrollment string `json:"enrollment"` |
| +} |
| + |
| +func (a *systemApplier) guarded() bool { return a.floor != nil } |
| +func (a *systemApplier) guardImage(path string) error { |
| + if !a.guarded() { |
| + return nil |
| + } |
| + open := hostfloor.OpenProtected |
| + if a.guardOpen != nil { |
| + open = a.guardOpen |
| + } |
| + f, err := open(path) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + hash, err := a.floor.VerifyFile(f, a.platform) |
| + if err != nil { |
| + return err |
| + } |
| + p, _, err := a.floor.Load() |
| + if err != nil || p.Mode == "unpublished" { |
| + return err |
| + } |
| + tx, err := a.guardTransaction() |
| + if err != nil { |
| + if os.IsNotExist(err) { |
| + if j, e := readSystemJournal(a.optDir); e == nil && j == nil && path == systemBinaryPath(a.optDir) { |
| + return nil |
| + } |
| + } |
| + return err |
| + } |
| + var roster hostactors.Roster |
| + if release.VerifyRootEnvelope(a.floor.PublicKey, tx.Roster, &roster) != nil || roster.Validate(a.floor.Bucket) != nil { |
| + return hostfloor.ErrFloor |
| + } |
| + reservation, ok := roster.Activations[tx.ID] |
| + _, floor, err := a.floor.Load() |
| + if err != nil { |
| + return err |
| + } |
| + if !ok || reservation.FloorSHA256 != floor.Digest || reservation.Actors[tx.Actor] != tx.Enrollment || !slices.Contains(reservation.Hashes[tx.Actor], hash) { |
| + return hostfloor.ErrFloor |
| + } |
| + return nil |
| +} |
| +func (a *systemApplier) guardTransaction() (guardTransaction, error) { |
| + raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "activation.json")) |
| + if err != nil { |
| + return guardTransaction{}, err |
| + } |
| + var tx guardTransaction |
| + if json.Unmarshal(raw, &tx) != nil || tx.ID == "" || tx.Enrollment == "" || tx.Actor == "" { |
| + return guardTransaction{}, hostfloor.ErrFloor |
| + } |
| + return tx, nil |
| +} |
| +func (a *systemApplier) beginGuard(ctx context.Context, target *release.SystemPointer) error { |
| + if !a.guarded() { |
| + return nil |
| + } |
| + p, _, err := a.floor.Load() |
| + if err != nil { |
| + return err |
| + } |
| + if p.Mode == "unpublished" { |
| + return nil |
| + } |
| + if err := a.prepareGuard(ctx, target); err != nil { |
| + return err |
| + } |
| + tx, err := a.guardTransaction() |
| + if err != nil { |
| + return err |
| + } |
| + // The signed reservation is persisted by the root approval step, never by |
| + // the worker's request. A new transaction must still find it in the roster. |
| + store, pub, err := a.openStore(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + _, local, err := a.floor.Load() |
| + if err != nil { |
| + return err |
| + } |
| + remote, err := hostfloor.Read(ctx, store, pub, a.floor.Bucket) |
| + if err != nil || remote.Digest != local.Digest { |
| + return hostfloor.ErrFloor |
| + } |
| + d := hostactors.Directory{Store: store, PublicKey: pub, Bucket: a.floor.Bucket} |
| + live, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + h, ok := live.Roster.Activations[tx.ID] |
| + if !ok || h.Actors[tx.Actor] != tx.Enrollment { |
| + return hostfloor.ErrFloor |
| + } |
| + var saved hostactors.Roster |
| + if release.VerifyRootEnvelope(pub, tx.Roster, &saved) != nil { |
| + return hostfloor.ErrFloor |
| + } |
| + x, _ := json.Marshal(h) |
| + y, _ := json.Marshal(saved.Activations[tx.ID]) |
| + if string(x) != string(y) { |
| + return hostfloor.ErrFloor |
| + } |
| + return a.floor.Begin(tx.ID, a.dataDir) |
| +} |
| + |
| +// checkGuardRecovery runs with the apply lock held. No-journal markers can |
| +// only precede the first destructive step or follow durable journal removal. |
| +// Both are terminal after the selected current image has been verified. |
| +func (a *systemApplier) checkGuardRecovery() error { |
| + if !a.guarded() { |
| + return nil |
| + } |
| + p, _, err := a.floor.Load() |
| + if err != nil || p.Mode == "unpublished" { |
| + return err |
| + } |
| + j, err := readSystemJournal(a.optDir) |
| + if err != nil { |
| + return err |
| + } |
| + marker := filepath.Join(a.floor.Dir, "update-begun.json") |
| + raw, err := os.ReadFile(marker) |
| + if errors.Is(err, os.ErrNotExist) && j == nil { |
| + tx, txErr := a.guardTransaction() |
| + if errors.Is(txErr, os.ErrNotExist) { |
| + return nil |
| + } |
| + if txErr != nil { |
| + return txErr |
| + } |
| + completed, readErr := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")) |
| + if readErr == nil && string(completed) == tx.ID { |
| + return nil |
| + } |
| + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { |
| + return readErr |
| + } |
| + if err := hostfloor.CheckControllersIdle(a.dataDir); err != nil { |
| + return err |
| + } |
| + return a.endGuard() |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if err := hostfloor.CheckControllersIdle(a.dataDir); err != nil { |
| + return err |
| + } |
| + tx, err := a.guardTransaction() |
| + if err != nil { |
| + return err |
| + } |
| + var begun struct { |
| + ID string `json:"id"` |
| + } |
| + if json.Unmarshal(raw, &begun) != nil || begun.ID != tx.ID { |
| + return hostfloor.ErrFloor |
| + } |
| + if j == nil { |
| + return a.endGuard() |
| + } |
| + if j.Phase == systemPhaseBackingUp { |
| + return a.guardImage(systemBinaryPath(a.optDir)) |
| + } |
| + return a.guardImage(systemPrevPath(a.optDir)) |
| +} |
| +func (a *systemApplier) endGuard() error { |
| + if !a.guarded() { |
| + return nil |
| + } |
| + p, _, err := a.floor.Load() |
| + if err != nil || p.Mode == "unpublished" { |
| + return err |
| + } |
| + tx, err := a.guardTransaction() |
| + if err != nil { |
| + return err |
| + } |
| + if j, err := readSystemJournal(a.optDir); err != nil { |
| + return err |
| + } else if j != nil { |
| + return hostfloor.ErrFloor |
| + } |
| + if err = a.guardImage(systemBinaryPath(a.optDir)); err != nil { |
| + return err |
| + } |
| + // Completion is durable before the marker disappears. Replaying either |
| + // write is harmless; readers never report completion with a marker present. |
| + if err = hostfloor.Durable(filepath.Join(a.floor.Dir, "completed.json"), []byte(tx.ID), 0644); err != nil { |
| + return err |
| + } |
| + return hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "update-begun.json")) |
| +} |
| +func (a *systemApplier) closeGuardJournal() error { |
| + if !a.guarded() { |
| + a.removeJournal() |
| + return nil |
| + } |
| + if err := hostfloor.RemoveDurable(a.file("journal.json")); err != nil { |
| + return err |
| + } |
| + return a.endGuard() |
| +} |
| +func (a *systemApplier) guardRun(ctx context.Context, wait time.Duration, name string, args ...string) (string, error) { |
| + if !a.guarded() { |
| + return a.ops.run(ctx, wait, name, args...) |
| + } |
| + if err := a.guardImage(name); err != nil { |
| + return "", err |
| + } |
| + bounded, cancel := context.WithTimeout(ctx, wait) |
| + defer cancel() |
| + return a.floor.Run(bounded, name, a.platform, args...) |
| +} |
| +func installFloorHook(a *systemApplier) error { |
| + dir := filepath.Join(a.optDir, "system", "guard") |
| + if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } else if err != nil { |
| + return err |
| + } |
| + d, err := floorDisk(a.optDir) |
| + if err != nil { |
| + return err |
| + } |
| + a.floor = &d |
| + _, _, err = d.Load() |
| + return err |
| +} |
| + |
| +func (a *systemApplier) prepareGuard(ctx context.Context, target *release.SystemPointer) error { |
| + enrollment, err := hostfloor.ReadEnrollment(filepath.Join(a.etcDir, "carlos", "host-actor.json")) |
| + if err != nil { |
| + return err |
| + } |
| + store, pub, err := a.openStore(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + raw, _, err := store.Get(ctx, hostactors.Key) |
| + if err != nil { |
| + return err |
| + } |
| + var roster hostactors.Roster |
| + if release.VerifyRootEnvelope(pub, raw, &roster) != nil || roster.Validate(a.floor.Bucket) != nil { |
| + return hostfloor.ErrFloor |
| + } |
| + b, ok := roster.Boxes[enrollment.Box] |
| + if !ok || b.Enrollment != enrollment.Enrollment || b.Cloud.InstanceID != enrollment.InstanceID { |
| + return hostfloor.ErrFloor |
| + } |
| + actor := "box:" + enrollment.Box |
| + id := "" |
| + for name, h := range roster.Activations { |
| + if h.Publication != nil && target.ActivationID != name { |
| + continue |
| + } |
| + hashes := h.Hashes[actor] |
| + if h.Actors[actor] == enrollment.Enrollment && len(hashes) == 3 && hashes[1] == target.Current.Artifacts[a.platform].SHA256 { |
| + if id != "" { |
| + return hostfloor.ErrFloor |
| + } |
| + id = name |
| + } |
| + } |
| + if id == "" { |
| + return hostfloor.ErrFloor |
| + } |
| + identityKey, err := release.IdentityKey(target.Current.ReleaseTag) |
| + if err != nil { |
| + return err |
| + } |
| + identity, _, err := store.Get(ctx, identityKey) |
| + if err != nil { |
| + return err |
| + } |
| + _, floor, err := a.floor.Load() |
| + if err != nil { |
| + return err |
| + } |
| + if err = hostfloor.CheckImage(pub, floor, identity, target.Current.Version, a.platform, target.Current.Artifacts[a.platform].SHA256); err != nil { |
| + return err |
| + } |
| + if err = hostfloor.Durable(filepath.Join(a.floor.Dir, "identities", target.Current.ReleaseTag+".json"), identity, 0644); err != nil { |
| + return err |
| + } |
| + txRaw, _ := json.Marshal(guardTransaction{raw, id, actor, enrollment.Enrollment}) |
| + return hostfloor.Durable(filepath.Join(a.floor.Dir, "activation.json"), txRaw, 0644) |
| +} |
diff --git a/internal/host/guardtransaction_test.go b/internal/host/guardtransaction_test.go| new file mode 100644 |
| index 000000000..9382d621a |
| --- /dev/null |
| +++ b/internal/host/guardtransaction_test.go |
| @@ -0,0 +1,366 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestFloorRefusalKeepsRecoveryJournalAndExecutesNothing(t *testing.T) { |
| + for _, phase := range []string{systemPhaseBackingUp, systemPhaseInstalled, systemPhaseGated} { |
| + t.Run(phase, func(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "old-build") |
| + f.a.floor = &hostfloor.Disk{Dir: t.TempDir(), Bucket: "bucket", PublicKey: f.pub} |
| + _ = os.MkdirAll(f.a.rootDir(), 0700) |
| + if err := f.a.journal(systemJournal{Phase: phase, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.a.Run(context.Background(), true); err == nil { |
| + t.Fatal("missing policy accepted") |
| + } |
| + if _, err := os.Stat(filepath.Join(f.a.rootDir(), "journal.json")); err != nil { |
| + t.Fatal("journal discarded", err) |
| + } |
| + if len(f.calls) != 0 { |
| + t.Fatal("ran before guard", f.calls) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestUnpublishedCompletionAndRecovery(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "old-build") |
| + f.a.floor = &hostfloor.Disk{Dir: t.TempDir(), Bucket: "bucket", PublicKey: f.pub} |
| + raw, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "unpublished"}) |
| + if err := hostfloor.Durable(filepath.Join(f.a.floor.Dir, "policy.json"), raw, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.a.beginGuard(t.Context(), nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.a.endGuard(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.a.checkGuardRecovery(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(f.a.floor.Dir, "activation.json")); !os.IsNotExist(err) { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func guardRecoveryFixture(t *testing.T) (*applyFixture, string) { |
| + t.Helper() |
| + f := newApplyFixture(t, "stable", "old-build") |
| + a := f.a |
| + a.floor = &hostfloor.Disk{Dir: t.TempDir(), Bucket: "bucket", PublicKey: f.pub} |
| + a.guardOpen = os.Open // Isolate the state machine from root-owned path checks. |
| + sg := release.RootSigner(f.priv) |
| + floor, _ := json.Marshal(hostfloor.Floor{Min: 1, MinRelease: "v1.2.3"}) |
| + auth, err := release.SignRootEnvelope(sg, hostfloor.Authorization{Schema: 1, Purpose: "moves-protocol", DeploymentBucket: "bucket", Key: hostfloor.Key, SHA256: hostfloor.Digest(floor)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "armed", Floor: floor, Authorization: auth}) |
| + if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "policy.json"), policy, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := os.ReadFile(systemBinaryPath(a.optDir)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + hash := hostfloor.Digest(body) |
| + rel := release.SystemRelease{Version: "old-build", ReleaseTag: "v1.2.3", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{a.platform: {Key: release.SystemArtifactKey("old-build", a.platform), SHA256: hash}}} |
| + if _, err := release.PromoteSystem(t.Context(), f.store, sg, "router", a.channel, rel, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + identity, _, err := f.store.Get(t.Context(), release.SystemPointerKey("router", a.channel)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(filepath.Join(a.floor.Dir, "identities"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "identities", "v1.2.3.json"), identity, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + id := strings.Repeat("a", 32) |
| + enrollment := strings.Repeat("b", 32) |
| + box := hostactors.Box{Enrollment: enrollment, Namespace: "test", BoxID: "box", Label: "box", Region: "region", Pool: "pool", Cloud: hostactors.Cloud{Provider: "aws", Account: "account", Region: "region", InstanceID: "instance"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| + h := hostactors.Reservation{ID: id, FloorSHA256: hostfloor.Digest(floor), Actors: map[string]string{"box:box": enrollment}, Releases: map[string]hostactors.Report{"box:box": {Enrollment: enrollment}}, Hashes: map[string][]string{"box:box": {hash, hash, hash}}} |
| + roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]hostactors.Box{"box": box}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{id: h}} |
| + if err := roster.Validate("bucket"); err != nil { |
| + t.Fatal(err) |
| + } |
| + wire, err := release.SignRootEnvelope(sg, roster) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tx, _ := json.Marshal(guardTransaction{Roster: wire, ID: id, Actor: "box:box", Enrollment: enrollment}) |
| + if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "activation.json"), tx, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(a.dataDir, ".hostexec"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + return f, id |
| +} |
| +func TestGuardRecoveryEveryTerminalCrashState(t *testing.T) { |
| + for _, state := range []string{"prepared", "staging-failed", "journal-removed", "completion-written", "marker-removed"} { |
| + t.Run(state, func(t *testing.T) { |
| + f, id := guardRecoveryFixture(t) |
| + a := f.a |
| + marker := filepath.Join(a.floor.Dir, "update-begun.json") |
| + completed := filepath.Join(a.floor.Dir, "completed.json") |
| + if state != "prepared" { |
| + if err := a.floor.Begin(id, a.dataDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if state == "journal-removed" { |
| + if err := a.journal(systemJournal{Phase: systemPhaseGated, Target: "old-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := hostfloor.RemoveDurable(a.file("journal.json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if state == "completion-written" || state == "marker-removed" { |
| + if err := hostfloor.Durable(completed, []byte(id), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if state == "marker-removed" { |
| + if err := hostfloor.RemoveDurable(marker); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := a.Run(t.Context(), true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.Run(t.Context(), true); err != nil { |
| + t.Fatal("replay", err) |
| + } |
| + raw, err := os.ReadFile(completed) |
| + if err != nil || string(raw) != id { |
| + t.Fatal("completion absent", err, string(raw)) |
| + } |
| + if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatal("marker stranded", err) |
| + } |
| + if len(f.calls) != 0 { |
| + t.Fatal("recovery executed a binary", f.calls) |
| + } |
| + if err := a.floor.Begin("next", a.dataDir); err != nil { |
| + t.Fatal("subsequent update blocked", err) |
| + } |
| + }) |
| + } |
| +} |
| +func TestGuardRecoveryRetainsMarkerOnInvalidImageOrMarker(t *testing.T) { |
| + for _, bad := range []string{"image", "marker", "completion-io"} { |
| + t.Run(bad, func(t *testing.T) { |
| + f, id := guardRecoveryFixture(t) |
| + a := f.a |
| + if err := a.floor.Begin(id, a.dataDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + marker := filepath.Join(a.floor.Dir, "update-begun.json") |
| + switch bad { |
| + case "image": |
| + if err := os.WriteFile(systemBinaryPath(a.optDir), []byte("wrong executable"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "marker": |
| + if err := os.WriteFile(marker, []byte(`{"id":"other"}`), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "completion-io": |
| + if err := os.Mkdir(filepath.Join(a.floor.Dir, "completed.json"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := a.Run(t.Context(), true); err == nil { |
| + t.Fatal("unsafe recovery succeeded") |
| + } |
| + if _, err := os.Stat(marker); err != nil { |
| + t.Fatal("denial erased", err) |
| + } |
| + if bad == "completion-io" { |
| + if err := os.Remove(filepath.Join(a.floor.Dir, "completed.json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.Run(t.Context(), true); err != nil { |
| + t.Fatal("retry failed", err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestGuardRecoveryRefusesControllersAfterBegin(t *testing.T) { |
| + states := []struct { |
| + name string |
| + malformed bool |
| + unreadable bool |
| + }{ |
| + {name: "pending"}, |
| + {name: "active"}, |
| + {name: "lost"}, |
| + {name: "malformed", malformed: true}, |
| + {name: "unreadable", unreadable: true}, |
| + } |
| + paths := []struct { |
| + name string |
| + journal bool |
| + marker bool |
| + }{ |
| + {name: "journal-backing-up", journal: true, marker: true}, |
| + {name: "terminal-marker", marker: true}, |
| + {name: "terminal-no-marker"}, |
| + } |
| + for _, state := range states { |
| + for _, path := range paths { |
| + t.Run(state.name+"-"+path.name, func(t *testing.T) { |
| + f, id := guardRecoveryFixture(t) |
| + a := f.a |
| + if err := a.floor.Begin(id, a.dataDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + marker := filepath.Join(a.floor.Dir, "update-begun.json") |
| + binary := systemBinaryPath(a.optDir) |
| + before, err := os.ReadFile(binary) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if path.journal { |
| + if err := a.journal(systemJournal{Phase: systemPhaseBackingUp, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + controllers := filepath.Join(a.dataDir, ".hostexec") |
| + if state.unreadable { |
| + if err := os.RemoveAll(controllers); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(controllers, []byte("not a directory"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else if state.malformed { |
| + if err := os.WriteFile(filepath.Join(controllers, "host.json"), []byte("{"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + raw, err := json.Marshal(map[string]string{"state": state.name}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(controllers, "host.json"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if !path.marker { |
| + if err := hostfloor.RemoveDurable(marker); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := a.Run(t.Context(), true); err == nil { |
| + t.Fatal("unsafe recovery succeeded") |
| + } |
| + if path.marker { |
| + if _, err := os.Stat(marker); err != nil { |
| + t.Fatal("marker discarded", err) |
| + } |
| + } else if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatal("marker unexpectedly present", err) |
| + } |
| + if path.journal { |
| + if _, err := os.Stat(a.file("journal.json")); err != nil { |
| + t.Fatal("journal discarded", err) |
| + } |
| + } |
| + if _, err := os.Stat(filepath.Join(a.floor.Dir, "completed.json")); !os.IsNotExist(err) { |
| + t.Fatal("completion written", err) |
| + } |
| + after, err := os.ReadFile(binary) |
| + if err != nil || string(after) != string(before) { |
| + t.Fatal("binary changed", err) |
| + } |
| + if len(f.calls) != 0 { |
| + t.Fatal("recovery executed a binary", f.calls) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestGuardRejectsOldPointerForUnpublishedActivation(t *testing.T) { |
| + f, id := guardRecoveryFixture(t) |
| + a := f.a |
| + ctx := t.Context() |
| + sg := release.RootSigner(f.priv) |
| + old, _, err := release.GetSystemPointer(ctx, f.store, f.pub, "router", a.channel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tx, err := a.guardTransaction() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var roster hostactors.Roster |
| + if err := release.VerifyRootEnvelope(f.pub, tx.Roster, &roster); err != nil { |
| + t.Fatal(err) |
| + } |
| + publication, err := release.PrepareSystemPromotion(ctx, f.store, sg, "router", a.channel, old.Current, release.SystemPromoteOptions{}, id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h := roster.Activations[id] |
| + h.Publication = &publication |
| + h.RequestSHA256 = strings.Repeat("d", 64) |
| + h.UpdateMode = "promote" |
| + roster.Activations[id] = h |
| + raw, err := release.SignRootEnvelope(sg, roster) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.store.Put(ctx, hostactors.Key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + identity, _, err := f.store.Get(ctx, release.SystemPointerKey("router", a.channel)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.PublishSystemIdentity(ctx, f.store, f.pub, identity); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(a.etcDir, "carlos"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + enrollment, _ := json.Marshal(hostfloor.Enrollment{Box: "box", Enrollment: tx.Enrollment, InstanceID: "instance"}) |
| + if err := hostfloor.Durable(filepath.Join(a.etcDir, "carlos", "host-actor.json"), enrollment, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "activation.json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.prepareGuard(ctx, old); err == nil { |
| + t.Fatal("old same-target pointer consumed unpublished activation") |
| + } |
| + if _, err := os.Stat(filepath.Join(a.floor.Dir, "activation.json")); !os.IsNotExist(err) { |
| + t.Fatalf("refusal wrote transaction: %v", err) |
| + } |
| + current, err := release.PublishSystemPublication(ctx, f.store, f.pub, publication) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := a.prepareGuard(ctx, current); err != nil { |
| + t.Fatal("published activation refused", err) |
| + } |
| +} |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 198a3a074..db456b6a8 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -20,6 +20,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/cliflag" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/version" |
| ) |
| @@ -97,6 +98,9 @@ func RunSystemApply(args []string) { |
| optDir: release.OptDir(), dataDir: dataDir(), etcDir: "/etc", build: version.Build, |
| platform: runtime.GOOS + "-" + runtime.GOARCH, gateWait: systemGateWait, soak: systemGateSoak, |
| } |
| + if err := installFloorHook(a); err != nil { |
| + log.Fatal(err) |
| + } |
| if err := a.Run(ctx, *recover); err != nil { |
| log.Fatalf("%v", err) |
| } |
| @@ -175,7 +179,9 @@ func productionSysOps() sysOps { |
| } |
| type systemApplier struct { |
| - ops sysOps |
| + floor *hostfloor.Disk |
| + guardOpen func(string) (*os.File, error) |
| + ops sysOps |
| // openStore is called once, by verifyPointer, and only on a run that |
| // has a request to answer. --recover never calls it: see |
| // openSystemStore for why that matters at boot. |
| @@ -203,7 +209,7 @@ func (a *systemApplier) file(name string) string { return filepath.Join(a.rootDi |
| // inside install: the result and the heartbeat carry the truth, the |
| // journal is closed, and re-firing OnFailure= over a finished recovery |
| // would only start it again with nothing left to do. |
| -func (a *systemApplier) Run(ctx context.Context, recover bool) error { |
| +func (a *systemApplier) Run(ctx context.Context, recover bool) (retErr error) { |
| if err := os.MkdirAll(a.rootDir(), 0o755); err != nil { |
| return err |
| } |
| @@ -229,7 +235,19 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error { |
| a.ops.logf("system apply: another apply is running; leaving it to finish") |
| return nil |
| } |
| - defer os.RemoveAll(a.stagingDir()) |
| + // Rejected floor recovery must retain staging and the journal. |
| + if err := a.checkGuardRecovery(); err != nil { |
| + return err |
| + } |
| + defer func() { |
| + if j, e := readSystemJournal(a.optDir); e == nil && j == nil { |
| + if err := a.checkGuardRecovery(); err != nil { |
| + retErr = errors.Join(retErr, err) |
| + return |
| + } |
| + _ = os.RemoveAll(a.stagingDir()) |
| + } |
| + }() |
| recovered, err := a.recoverIfNeeded(ctx) |
| if err != nil { |
| @@ -285,6 +303,9 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error { |
| if req.NoOp || p.Current.Version == a.onDiskBuild(ctx) { |
| return a.writeResult(req, systemOutcomeNoted, "") |
| } |
| + if err := a.beginGuard(ctx, p); err != nil { |
| + return err |
| + } |
| staged, err := a.stageCandidate(ctx, p, req) |
| if err != nil { |
| return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err)) |
| @@ -328,6 +349,9 @@ func (a *systemApplier) waitForLock(lock *os.File) (bool, error) { |
| // failure from the quiesce on going to the rollback of step 13. |
| func (a *systemApplier) install(ctx context.Context, req systemRequest, p *release.SystemPointer, staged string) error { |
| target := p.Current.Version |
| + if err := a.guardImage(staged); err != nil { |
| + return err |
| + } |
| prevOK, err := a.decidePrev(ctx) |
| if err != nil { |
| return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err)) |
| @@ -416,8 +440,7 @@ func (a *systemApplier) finish(req systemRequest, target string, plan bootstrapP |
| if err := a.writeResult(req, systemOutcomeApplied, reason); err != nil { |
| return err |
| } |
| - a.removeJournal() |
| - return nil |
| + return a.closeGuardJournal() |
| } |
| // removeJournal closes the transaction, durably: an unsynced removal can |
| @@ -448,6 +471,9 @@ func (a *systemApplier) removeJournal() { |
| // the version that did write one outlive the upgrade to this one.) |
| func (a *systemApplier) decidePrev(ctx context.Context) (bool, error) { |
| prev := systemPrevPath(a.optDir) |
| + if err := a.guardImage(systemBinaryPath(a.optDir)); err != nil { |
| + return false, err |
| + } |
| _, prevErr := os.Stat(prev) |
| applied, err := readSystemApplied(a.optDir) |
| if err != nil { |
| @@ -456,6 +482,11 @@ func (a *systemApplier) decidePrev(ctx context.Context) (bool, error) { |
| disk := a.onDiskBuild(ctx) |
| known := (applied != nil && applied.Version == disk && disk != "") || (applied == nil && prevErr != nil) |
| if !known { |
| + if prevErr == nil { |
| + if err := a.guardImage(prev); err != nil { |
| + return false, err |
| + } |
| + } |
| return prevErr == nil, nil |
| } |
| if err := copyFileDurable(systemBinaryPath(a.optDir), prev, 0o755); err != nil { |
| @@ -465,7 +496,7 @@ func (a *systemApplier) decidePrev(ctx context.Context) (bool, error) { |
| } |
| func (a *systemApplier) prevBuild(ctx context.Context) string { |
| - out, err := a.ops.run(ctx, systemVersionWait, systemPrevPath(a.optDir), "version") |
| + out, err := a.guardRun(ctx, systemVersionWait, systemPrevPath(a.optDir), "version") |
| if err != nil { |
| return "" |
| } |
| @@ -481,7 +512,7 @@ func (a *systemApplier) stagePlan(ctx context.Context, binary, dir string) (boot |
| if err := os.MkdirAll(dir, 0o700); err != nil { |
| return bootstrapPlan{}, nil, err |
| } |
| - out, err := a.ops.run(ctx, systemBootstrapWait, binary, "bootstrap", "-root", dir, "-plan-json") |
| + out, err := a.guardRun(ctx, systemBootstrapWait, binary, "bootstrap", "-root", dir, "-plan-json") |
| if err != nil { |
| return bootstrapPlan{}, nil, err |
| } |
| @@ -785,7 +816,9 @@ func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason |
| a.restartRelay() |
| if err := a.gate(ctx, j.PrevBuild); err != nil { |
| err := failed("previous build failed its gate too", err) |
| - a.removeJournal() |
| + if closeErr := a.closeGuardJournal(); closeErr != nil { |
| + return errors.Join(err, closeErr) |
| + } |
| return err |
| } |
| if err := a.arm(plan); err != nil { |
| @@ -794,7 +827,9 @@ func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason |
| if err := a.writeResult(req, systemOutcomeRolledBack, reason); err != nil { |
| return err |
| } |
| - a.removeJournal() |
| + if err := a.closeGuardJournal(); err != nil { |
| + return err |
| + } |
| return fmt.Errorf("system apply: %s rolled back: %s", j.Target, reason) |
| } |
| @@ -806,6 +841,9 @@ func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason |
| // running edge is the caller's, and at boot the answer is nothing. |
| func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (bootstrapPlan, error) { |
| prev := systemPrevPath(a.optDir) |
| + if err := a.guardImage(prev); err != nil { |
| + return bootstrapPlan{}, err |
| + } |
| if _, err := os.Stat(prev); err != nil { |
| return bootstrapPlan{}, fmt.Errorf("no carlos.prev: %w", err) |
| } |
| @@ -921,8 +959,7 @@ func (a *systemApplier) recoverIfNeeded(ctx context.Context) (bool, error) { |
| // and it is not the file anything reads. The run carries on to the |
| // request it was started for. |
| os.Remove(systemPrevPath(a.optDir) + ".tmp") |
| - a.removeJournal() |
| - return false, nil |
| + return false, a.closeGuardJournal() |
| } |
| // Forward from gated — but only while the build that passed the gate is |
| // still the one on disk. A binary that has moved since means this is a |
| @@ -963,8 +1000,7 @@ func (a *systemApplier) recoverIfNeeded(ctx context.Context) (bool, error) { |
| if err := a.writeResult(req, systemOutcomeRolledBack, "interrupted at "+j.Phase); err != nil { |
| return false, err |
| } |
| - a.removeJournal() |
| - return true, nil |
| + return true, a.closeGuardJournal() |
| } |
| // record turns a refusal or deferral into result.json and a nil return. |
| @@ -1045,7 +1081,7 @@ func (a *systemApplier) verifyPointer(ctx context.Context) (*release.SystemPoint |
| // onDiskBuild asks the installed binary what it is, with a deadline. Not |
| // version.Build: after an interrupted apply the two can differ. |
| func (a *systemApplier) onDiskBuild(ctx context.Context) string { |
| - out, err := a.ops.run(ctx, systemVersionWait, systemBinaryPath(a.optDir), "version") |
| + out, err := a.guardRun(ctx, systemVersionWait, systemBinaryPath(a.optDir), "version") |
| if err != nil { |
| return "" |
| } |
| @@ -1110,7 +1146,7 @@ func (a *systemApplier) stageCandidate(ctx context.Context, p *release.SystemPoi |
| if err := os.Chmod(staged, 0o755); err != nil { |
| return "", err |
| } |
| - out, err := a.ops.run(ctx, systemVersionWait, staged, "version") |
| + out, err := a.guardRun(ctx, systemVersionWait, staged, "version") |
| if err != nil { |
| return "", fmt.Errorf("candidate: running its version: %w", err) |
| } |
diff --git a/internal/hostexec/controller.go b/internal/hostexec/controller.go| index 6f1bf820d..4dec2560f 100644 |
| --- a/internal/hostexec/controller.go |
| +++ b/internal/hostexec/controller.go |
| @@ -6,24 +6,31 @@ import ( |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| + "github.com/carlosframework/platform/internal/version" |
| "os" |
| + "runtime" |
| "slices" |
| "sync" |
| "time" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostquarantine" |
| "github.com/carlosframework/platform/internal/release" |
| + "path/filepath" |
| ) |
| type Config struct { |
| - Binding Binding |
| - Store release.Store |
| - Guard hostguard.Guard |
| - Directory hostquarantine.ActorDirectory |
| - Clock Clock |
| - LockDir string |
| + Binding Binding |
| + Store release.Store |
| + Guard hostguard.Guard |
| + Directory hostquarantine.ActorDirectory |
| + Clock Clock |
| + UpdateGuardDir string |
| + UpdateJournal string |
| + verifyRunning func(context.Context) error |
| + LockDir string |
| // These close/cancel hooks must be nonblocking and safe to call repeatedly. |
| CloseAdmission func() |
| CancelWork func() |
| @@ -62,6 +69,10 @@ func Open(ctx context.Context, cfg Config) (*Controller, error) { |
| cfg.Clock = SystemClock{} |
| } |
| if cfg.LockDir == "" { |
| + if cfg.UpdateGuardDir == "" { |
| + cfg.UpdateGuardDir = "/opt/carlos/system/guard" |
| + cfg.UpdateJournal = filepath.Join("/opt/carlos/system/router", "journal.json") |
| + } |
| cfg.LockDir = "/run/carlos/hostexec" |
| } |
| var nonce [16]byte |
| @@ -107,6 +118,11 @@ func Open(ctx context.Context, cfg Config) (*Controller, error) { |
| if writeErr := c.write(d, c.state); writeErr != nil { |
| return writeErr |
| } |
| + if cfg.UpdateGuardDir != "" { |
| + if err := cfg.checkUpdateIdentity(ctx); err != nil { |
| + return err |
| + } |
| + } |
| c.unlive, lockErr = tryAcquireLive(cfg.LockDir, cfg.Binding.Host) |
| return lockErr |
| }) |
| @@ -180,6 +196,12 @@ func (c *Controller) save(s State) error { |
| }) |
| } |
| func (c *Controller) authority(ctx context.Context) error { |
| + if err := c.cfg.checkUpdateIdentity(ctx); err != nil { |
| + return err |
| + } |
| + if err := c.cfg.checkRemoteUpdateIdentity(ctx); err != nil { |
| + return err |
| + } |
| b := c.cfg.Binding |
| if err := hostquarantine.Check(ctx, c.cfg.Directory, b.Box, b.Enrollment); err != nil { |
| return err |
| @@ -624,3 +646,56 @@ func ClearStale(ctx context.Context, cfg Config) error { |
| return d.remove(old.Host) |
| }) |
| } |
| + |
| +// checkUpdateIdentity reads the executing inode, never the replaceable current |
| +// path. Run it after pending is durable and before each acknowledgement. |
| +func (cfg Config) checkUpdateIdentity(ctx context.Context) error { |
| + if cfg.UpdateGuardDir == "" { |
| + return nil |
| + } |
| + if err := hostfloor.UpdateIdle(cfg.UpdateGuardDir, cfg.UpdateJournal); err != nil { |
| + return err |
| + } |
| + if cfg.verifyRunning != nil { |
| + return cfg.verifyRunning(ctx) |
| + } |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return err |
| + } |
| + d := hostfloor.Disk{Dir: cfg.UpdateGuardDir, Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), PublicKey: pub} |
| + running, err := d.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH) |
| + if err != nil || !running.GuardReady { |
| + if err != nil { |
| + return err |
| + } |
| + return hostfloor.ErrFloor |
| + } |
| + _, floor, err := d.Load() |
| + if err != nil || running.FloorSHA256 != floor.Digest { |
| + return hostfloor.ErrFloor |
| + } |
| + return hostfloor.UpdateIdle(cfg.UpdateGuardDir, cfg.UpdateJournal) |
| +} |
| + |
| +// authority calls this outside the controller mutex and host guard. Recheck |
| +// the executing identity against the exact local digest compared remotely. |
| +func (cfg Config) checkRemoteUpdateIdentity(ctx context.Context) error { |
| + if cfg.UpdateGuardDir == "" || cfg.verifyRunning != nil { |
| + return nil |
| + } |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return err |
| + } |
| + d := hostfloor.Disk{Dir: cfg.UpdateGuardDir, Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), PublicKey: pub} |
| + running, err := d.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH) |
| + if err != nil { |
| + return err |
| + } |
| + _, floor, err := d.Load() |
| + if err != nil || !running.GuardReady || running.FloorSHA256 != floor.Digest { |
| + return hostfloor.ErrFloor |
| + } |
| + return hostfloor.CheckRemote(ctx, cfg.Store, pub, d.Bucket, floor) |
| +} |
diff --git a/internal/hostexec/updateidentity_test.go b/internal/hostexec/updateidentity_test.go| new file mode 100644 |
| index 000000000..e2c84f4fa |
| --- /dev/null |
| +++ b/internal/hostexec/updateidentity_test.go |
| @@ -0,0 +1,174 @@ |
| +//go:build linux |
| + |
| +package hostexec |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/version" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "sync/atomic" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| +) |
| + |
| +func TestPendingRevalidatesRunningImageAfterCompletedUpdate(t *testing.T) { |
| + cfg, _, _, store, _ := fixture(t) |
| + cfg.UpdateGuardDir = t.TempDir() |
| + cfg.UpdateJournal = filepath.Join(t.TempDir(), "journal.json") |
| + var oldImage atomic.Bool |
| + oldImage.Store(true) |
| + var checked atomic.Int32 |
| + cfg.verifyRunning = func(context.Context) error { |
| + checked.Add(1) |
| + if oldImage.Load() { |
| + return hostfloor.ErrFloor |
| + } |
| + return nil |
| + } |
| + // Suspend precisely after pending persistence. Root finished its update by |
| + // the time the controller resumes, so marker-only checking would acknowledge. |
| + cfg.writeLocal = func(d *localDir, s State) error { |
| + if err := d.write(s); err != nil { |
| + return err |
| + } |
| + marker := filepath.Join(cfg.UpdateGuardDir, "update-begun.json") |
| + if err := hostfloor.Durable(marker, []byte(`{"id":"update"}`), 0644); err != nil { |
| + return err |
| + } |
| + if err := hostfloor.Durable(filepath.Join(cfg.UpdateGuardDir, "completed.json"), []byte("update"), 0644); err != nil { |
| + return err |
| + } |
| + return hostfloor.RemoveDurable(marker) |
| + } |
| + c, err := Open(t.Context(), cfg) |
| + if c != nil || !errors.Is(err, hostfloor.ErrFloor) { |
| + t.Fatal("old executing image acknowledged", err) |
| + } |
| + if checked.Load() == 0 { |
| + t.Fatal("running-image verifier skipped") |
| + } |
| + store.mu.Lock() |
| + calls := store.calls |
| + store.mu.Unlock() |
| + if calls != 0 { |
| + t.Fatal("acknowledged before identity check", calls) |
| + } |
| +} |
| + |
| +func TestUpdateIdentityRecheckedBeforeLeaseAcknowledgement(t *testing.T) { |
| + cfg, _, _, store, _ := fixture(t) |
| + cfg.UpdateGuardDir = t.TempDir() |
| + cfg.UpdateJournal = filepath.Join(t.TempDir(), "journal.json") |
| + var checked atomic.Int32 |
| + cfg.verifyRunning = func(context.Context) error { |
| + if checked.Add(1) > 1 { |
| + return hostfloor.ErrFloor |
| + } |
| + return nil |
| + } |
| + c, err := Open(t.Context(), cfg) |
| + if c != nil || !errors.Is(err, hostfloor.ErrFloor) { |
| + t.Fatal("identity change during cleanup accepted", err) |
| + } |
| + store.mu.Lock() |
| + calls := store.calls |
| + store.mu.Unlock() |
| + if calls != 0 { |
| + t.Fatal("lease written", calls) |
| + } |
| +} |
| +func TestUpdateMarkerBlocksBeforeIdentityCheck(t *testing.T) { |
| + cfg, _, _, _, _ := fixture(t) |
| + cfg.UpdateGuardDir = t.TempDir() |
| + cfg.UpdateJournal = filepath.Join(t.TempDir(), "journal.json") |
| + cfg.verifyRunning = func(context.Context) error { t.Fatal("verification ran despite update"); return nil } |
| + for _, path := range []string{filepath.Join(cfg.UpdateGuardDir, "update-begun.json"), cfg.UpdateJournal} { |
| + if err := os.WriteFile(path, []byte("present"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := cfg.checkUpdateIdentity(t.Context()); err == nil { |
| + t.Fatal("update accepted") |
| + } |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestRunningIdentityUsesExecutingImageAndCurrentFloor(t *testing.T) { |
| + oldBuild := version.Build |
| + version.Build = "v1.2.3" |
| + t.Cleanup(func() { version.Build = oldBuild }) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "bucket") |
| + cfg := Config{UpdateGuardDir: t.TempDir(), UpdateJournal: filepath.Join(t.TempDir(), "journal.json"), Store: release.NewDirStore(t.TempDir())} |
| + sg := release.RootSigner(priv) |
| + if err := os.Mkdir(filepath.Join(cfg.UpdateGuardDir, "identities"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(cfg.UpdateGuardDir, "carlos"), []byte("persistent checker"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + writeFloor := func(min string) { |
| + floor, _ := json.Marshal(hostfloor.Floor{Min: 1, MinRelease: min}) |
| + auth, err := release.SignRootEnvelope(sg, hostfloor.Authorization{Schema: 1, Purpose: "moves-protocol", DeploymentBucket: "bucket", Key: hostfloor.Key, SHA256: hostfloor.Digest(floor)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "armed", Floor: floor, Authorization: auth}) |
| + if err := hostfloor.Durable(filepath.Join(cfg.UpdateGuardDir, "policy.json"), policy, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + for key, wire := range map[string][]byte{hostfloor.Key: floor, hostfloor.AuthKey: auth} { |
| + _, etag, _ := cfg.Store.Get(t.Context(), key) |
| + if _, err := cfg.Store.Put(t.Context(), key, wire, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + writeIdentity := func(hash string) { |
| + platform := runtime.GOOS + "-" + runtime.GOARCH |
| + rel := release.SystemRelease{Version: version.Build, ReleaseTag: "v1.2.3", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey(version.Build, platform), SHA256: hash}}} |
| + store := release.NewDirStore(t.TempDir()) |
| + if _, err := release.PromoteSystem(t.Context(), store, sg, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + wire, _, err := store.Get(t.Context(), release.SystemPointerKey("router", "stable")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := hostfloor.Durable(filepath.Join(cfg.UpdateGuardDir, "identities", "v1.2.3.json"), wire, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + writeFloor("v1.2.3") |
| + hash, err := hostfloor.ExecutingHash() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + writeIdentity(hash) |
| + if err := cfg.checkUpdateIdentity(t.Context()); err != nil { |
| + t.Fatal("valid running image refused", err) |
| + } |
| + writeIdentity(hostfloor.Digest([]byte("replacement at current binary path"))) |
| + if err := cfg.checkUpdateIdentity(t.Context()); err == nil { |
| + t.Fatal("replacement identity authorized stale executing inode") |
| + } |
| + writeIdentity(hash) |
| + writeFloor("v1.2.4") |
| + if err := cfg.checkUpdateIdentity(t.Context()); err == nil { |
| + t.Fatal("resumed image below current floor authorized") |
| + } |
| +} |
diff --git a/system.go b/system.go| index 8253c34ab..b36302fcc 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -47,7 +47,7 @@ func systemVerb(args []string) (verb string, rest []string, component string) { |
| switch args[0] { |
| // antivirus is a FAMILY, not a leaf: its own sub-verbs come back |
| // untouched in rest for runSystemAntivirusVerb to read. |
| - case "update", "status", "rollback", "apply", "antivirus", "help", "-h", "--help": |
| + case "guard-arm", "guard-exec", "update", "status", "rollback", "apply", "antivirus", "help", "-h", "--help": |
| return args[0], args[1:], component |
| } |
| return "", args, component |
| @@ -56,6 +56,11 @@ func systemVerb(args []string) (verb string, rest []string, component string) { |
| func runSystem(args []string) { |
| verb, rest, _ := systemVerb(args) |
| switch verb { |
| + case "guard-arm": |
| + host.RunSystemGuardArm(rest) |
| + case "guard-exec": |
| + host.RunSystemGuardExec(rest) |
| + |
| case "apply": |
| host.RunSystemApply(rest) |
| case "update": |