carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 1f7151ccabbf589d68eb4133c37074de20364fa5 parent 8e0153d
10 files changed, +1306 −22
  • internal/host/claimreport.go +39 −0
  • internal/host/guardarm.go +181 −0
  • internal/host/guardexec.go +80 −0
  • internal/host/guardroster.go +13 −0
  • internal/host/guardtransaction.go +315 −0
  • internal/host/guardtransaction_test.go +366 −0
  • internal/host/systemapply.go +51 −15
  • internal/hostexec/controller.go +81 −6
  • internal/hostexec/updateidentity_test.go +174 −0
  • system.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":