carlos / platform Public

cgroup, instancectl, hostguard, host, hostbox: root fences and exact typed inventory retirement (claim Plan 6 Task 8)

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 4b4692e602488ec7096d6f953b46cd06a9021ae7 parent 5b33faa
21 files changed, +2287 −21
  • internal/cgroup/role_linux.go +91 −0
  • internal/cgroup/role_linux_test.go +143 −0
  • internal/cgroup/role_other.go +10 −0
  • internal/cgroup/strict_linux.go +26 −3
  • internal/cgroup/strict_linux_test.go +1 −1
  • internal/host/bootstrap.go +2 −1
  • internal/host/claimdirs_test.go +13 −0
  • internal/host/claimstop.go +315 −0
  • internal/host/claimstop_test.go +225 −0
  • internal/hostbox/typedpark_linux.go +200 −0
  • internal/hostbox/typedpark_linux_test.go +139 −0
  • internal/hostbox/typedpark_other.go +18 −0
  • internal/hostbox/typedretire.go +293 −0
  • internal/hostbox/typedretire_test.go +173 −0
  • internal/hostguard/fence.go +245 −0
  • internal/hostguard/fence_test.go +229 −0
  • internal/hostguard/guard.go +32 −11
  • internal/hostguard/record.go +20 −5
  • internal/instancectl/peer_tuple_linux.go +64 −0
  • internal/instancectl/peer_tuple_linux_test.go +30 −0
  • internal/instancectl/peer_tuple_other.go +18 −0
diff --git a/internal/cgroup/role_linux.go b/internal/cgroup/role_linux.go
new file mode 100644
index 000000000..708e5f931
--- /dev/null
+++ b/internal/cgroup/role_linux.go
@@ -0,0 +1,91 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "golang.org/x/sys/unix"
+)
+
+type ContainmentIdentity struct{ Device, Inode uint64 }
+
+func (p *Prepared) Identity() (ContainmentIdentity, error) {
+ if p == nil || p.strictPreparedState == nil {
+ return ContainmentIdentity{}, ErrStrictState
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.identityOK(); err != nil {
+ return ContainmentIdentity{}, err
+ }
+ return ContainmentIdentity{Device: p.identity.device, Inode: p.identity.inode}, nil
+}
+
+// PrepareRoleChild pins a newly empty role group below the host parent. It
+// never reuses the parent's populated group and inherits its aggregate limits.
+func (p *Prepared) PrepareRoleChild(role string) (out *Prepared, err error) {
+ if p == nil || p.strictPreparedState == nil || !controlname.ValidLabel(role) {
+ return nil, ErrStrictRefused
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.parent != nil || p.empty || p.draining {
+ return nil, ErrStrictState
+ }
+ if err = p.identityOK(); err != nil {
+ return nil, err
+ }
+ name := "role-" + role
+ if err = unix.Mkdirat(int(p.dir.Fd()), name, 0755); err != nil && !errors.Is(err, unix.EEXIST) {
+ return nil, ErrStrictRefused
+ }
+ child := &Prepared{strictPreparedState: &strictPreparedState{parent: p, host: p.host, limits: map[string]*os.File{}, expected: map[string]string{}, entryName: name, start: func(cmd *exec.Cmd) error { return cmd.Start() }}}
+ defer func() {
+ if err != nil {
+ child.closeFiles()
+ }
+ }()
+ fd, e := unix.FcntlInt(p.dir.Fd(), unix.F_DUPFD_CLOEXEC, 0)
+ if e != nil {
+ return nil, ErrStrictUnsupported
+ }
+ child.entryParent = os.NewFile(uintptr(fd), name+"-parent")
+ child.dir, e = strictOpen(p.dir, name, unix.O_RDONLY|unix.O_DIRECTORY)
+ if e != nil {
+ return nil, ErrStrictRefused
+ }
+ child.identity, e = strictID(child.dir)
+ if e != nil {
+ return nil, ErrStrictRefused
+ }
+ strictReservations.Lock()
+ if strictReservations.held[child.identity] {
+ strictReservations.Unlock()
+ return nil, ErrStrictState
+ }
+ strictReservations.held[child.identity] = true
+ strictReservations.Unlock()
+ defer func() {
+ if err != nil {
+ strictReservations.Lock()
+ delete(strictReservations.held, child.identity)
+ strictReservations.Unlock()
+ }
+ }()
+ child.events, e = strictOpen(child.dir, "cgroup.events", unix.O_RDONLY)
+ if e != nil {
+ return nil, ErrStrictUnsupported
+ }
+ child.kill, e = strictOpen(child.dir, "cgroup.kill", unix.O_WRONLY)
+ if e != nil {
+ return nil, ErrStrictUnsupported
+ }
+ if populated, e := strictPopulation(child.events); e != nil || populated {
+ return nil, ErrStrictRefused
+ }
+ return child, nil
+}
diff --git a/internal/cgroup/role_linux_test.go b/internal/cgroup/role_linux_test.go
new file mode 100644
index 000000000..dfecd0aa5
--- /dev/null
+++ b/internal/cgroup/role_linux_test.go
@@ -0,0 +1,143 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+func makeRoleFixture(t *testing.T, p *Prepared, role, events string) {
+ t.Helper()
+ d := filepath.Join(p.dir.Name(), "role-"+role)
+ if err := os.Mkdir(d, 0700); err != nil {
+ t.Fatal(err)
+ }
+ for n, v := range map[string]string{"cgroup.events": events, "cgroup.kill": ""} {
+ if err := os.WriteFile(filepath.Join(d, n), []byte(v), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestPrepareRoleChildEmptyAndIdentity(t *testing.T) {
+ p := strictFixture(t)
+ makeRoleFixture(t, p, "seed", "populated 0\n")
+ child, err := p.PrepareRoleChild("seed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ flags, err := unix.FcntlInt(child.entryParent.Fd(), unix.F_GETFD, 0)
+ if err != nil || flags&unix.FD_CLOEXEC == 0 {
+ t.Fatalf("parent descriptor may escape exec: flags=%d err=%v", flags, err)
+ }
+ id, err := child.Identity()
+ if err != nil || id.Device == 0 || id.Inode == 0 {
+ t.Fatalf("identity=%+v %v", id, err)
+ }
+ if err := child.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRoleChildCannotOutliveParentCleanup(t *testing.T) {
+ for _, action := range []string{"kill", "close", "failed-kill", "failed-close"} {
+ t.Run(action, func(t *testing.T) {
+ p := strictFixture(t)
+ makeRoleFixture(t, p, "seed", "populated 0\n")
+ child, err := p.PrepareRoleChild("seed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer child.Close()
+ starts := 0
+ child.start = func(*exec.Cmd) error { starts++; return nil }
+ if action == "failed-close" {
+ if err := strictWrite(p.events, "populated 1\n"); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Close(); !errors.Is(err, ErrStrictRefused) {
+ t.Fatalf("failed close=%v", err)
+ }
+ // Even once the parent's population drops, cleanup remains latched.
+ if err := strictWrite(p.events, "populated 0\n"); err != nil {
+ t.Fatal(err)
+ }
+ } else if action == "close" {
+ if err := p.Close(); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ if action == "failed-kill" {
+ p.kill.Close()
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ err := p.KillAndEmpty(ctx)
+ if (err != nil) != (action == "failed-kill") {
+ t.Fatalf("cleanup: %v", err)
+ }
+ }
+ if err := child.Start(exec.Command("unused")); !errors.Is(err, ErrStrictState) || starts != 0 {
+ t.Fatalf("child reopened drained parent: %v, starts=%d", err, starts)
+ }
+ if _, err := p.PrepareRoleChild("another"); !errors.Is(err, ErrStrictState) {
+ t.Fatalf("new role after cleanup: %v", err)
+ }
+ })
+ }
+}
+
+func TestRoleChildRechecksParentLimits(t *testing.T) {
+ p := strictFixture(t)
+ makeRoleFixture(t, p, "seed", "populated 0\n")
+ child, err := p.PrepareRoleChild("seed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer child.Close()
+ if err := strictWrite(p.limits["memory.max"], "0"); err != nil {
+ t.Fatal(err)
+ }
+ if err := child.Start(exec.Command("unused")); !errors.Is(err, ErrStrictRefused) {
+ t.Fatalf("changed aggregate limit: %v", err)
+ }
+}
+
+func TestPrepareRoleChildRefusals(t *testing.T) {
+ t.Run("nonempty", func(t *testing.T) {
+ p := strictFixture(t)
+ makeRoleFixture(t, p, "seed", "populated 1\n")
+ if _, err := p.PrepareRoleChild("seed"); !errors.Is(err, ErrStrictRefused) {
+ t.Fatalf("err=%v", err)
+ }
+ })
+ t.Run("symlink", func(t *testing.T) {
+ p := strictFixture(t)
+ if err := os.Symlink(t.TempDir(), filepath.Join(p.dir.Name(), "role-seed")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.PrepareRoleChild("seed"); err == nil {
+ t.Fatal("symlink accepted")
+ }
+ })
+ t.Run("no-delegation", func(t *testing.T) {
+ p := strictFixture(t)
+ if _, err := p.PrepareRoleChild("seed"); !errors.Is(err, ErrStrictUnsupported) {
+ t.Fatalf("err=%v", err)
+ }
+ })
+ t.Run("absent-role", func(t *testing.T) {
+ p := strictFixture(t)
+ if _, err := p.PrepareRoleChild(""); !errors.Is(err, ErrStrictRefused) {
+ t.Fatalf("err=%v", err)
+ }
+ })
+}
diff --git a/internal/cgroup/role_other.go b/internal/cgroup/role_other.go
new file mode 100644
index 000000000..6f1b17fbc
--- /dev/null
+++ b/internal/cgroup/role_other.go
@@ -0,0 +1,10 @@
+//go:build !linux
+
+package cgroup
+
+type ContainmentIdentity struct{ Device, Inode uint64 }
+
+func (p *Prepared) Identity() (ContainmentIdentity, error) {
+ return ContainmentIdentity{}, ErrStrictUnsupported
+}
+func (p *Prepared) PrepareRoleChild(string) (*Prepared, error) { return nil, ErrStrictUnsupported }
diff --git a/internal/cgroup/strict_linux.go b/internal/cgroup/strict_linux.go
index 35ace0591..3914e32e2 100644
--- a/internal/cgroup/strict_linux.go
+++ b/internal/cgroup/strict_linux.go
@@ -36,6 +36,10 @@ type strictPreparedState struct {
limits map[string]*os.File
expected map[string]string
identity strictIdentity
+ entryParent *os.File
+ entryName string
+ parent *Prepared
+ draining bool
host string
attempted, empty, closed bool
start func(*exec.Cmd) error
@@ -112,7 +116,11 @@ func (p *Prepared) identityOK() error {
return ErrStrictRefused
}
var st unix.Stat_t
- if e = unix.Fstatat(int(p.root.Fd()), ExecName(p.host), &st, unix.AT_SYMLINK_NOFOLLOW); e != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || (strictIdentity{uint64(st.Dev), st.Ino}) != p.identity {
+ parent, name := p.root, ExecName(p.host)
+ if p.entryParent != nil {
+ parent, name = p.entryParent, p.entryName
+ }
+ if e = unix.Fstatat(int(parent.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW); e != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || (strictIdentity{uint64(st.Dev), st.Ino}) != p.identity {
return ErrStrictRefused
}
return nil
@@ -130,7 +138,7 @@ func (p *Prepared) closeFiles() {
for _, f := range p.limits {
f.Close()
}
- for _, f := range []*os.File{p.events, p.kill, p.dir, p.root} {
+ for _, f := range []*os.File{p.events, p.kill, p.dir, p.entryParent, p.root} {
if f != nil {
f.Close()
}
@@ -234,9 +242,22 @@ func (p *Prepared) Start(cmd *exec.Cmd) error {
if p == nil || p.strictPreparedState == nil {
return ErrStrictState
}
+ if p.parent != nil {
+ p.parent.mu.Lock()
+ defer p.parent.mu.Unlock()
+ if p.parent.closed || p.parent.empty || p.parent.draining {
+ return ErrStrictState
+ }
+ if err := p.parent.identityOK(); err != nil {
+ return err
+ }
+ if err := p.parent.limitsOK(); err != nil {
+ return err
+ }
+ }
p.mu.Lock()
defer p.mu.Unlock()
- if p.closed || p.attempted || p.empty {
+ if p.closed || p.attempted || p.empty || p.draining {
return ErrStrictState
}
p.attempted = true
@@ -311,6 +332,7 @@ func (p *Prepared) KillAndEmpty(ctx context.Context) error {
if e := p.identityOK(); e != nil {
return e
}
+ p.draining = true
p.attempted = true
if e := strictWrite(p.kill, "1"); e != nil {
return ErrStrictRefused
@@ -351,6 +373,7 @@ func (p *Prepared) Close() error {
}
p.mu.Lock()
defer p.mu.Unlock()
+ p.draining = true
if p.closed {
return ErrStrictState
}
diff --git a/internal/cgroup/strict_linux_test.go b/internal/cgroup/strict_linux_test.go
index 03f687a55..d8d8f3908 100644
--- a/internal/cgroup/strict_linux_test.go
+++ b/internal/cgroup/strict_linux_test.go
@@ -210,7 +210,7 @@ func TestStrictStaleHandleCannotKillReplacement(t *testing.T) {
if e != nil || string(raw) != "replacement" {
t.Fatal("replacement group touched", e)
}
- if e := p.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictRefused) {
+ if e := p.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictState) {
t.Fatal("stale spawn accepted", e)
}
}
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index 5831faf10..db43a6bd7 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -58,7 +58,8 @@ const socketsGroup = "carlos-sockets"
// 0600), tenants never need path access (the fd is passed in) — it stays
// carlos:carlos, so consumer enrollment deliberately does not reach unit
// tenants. other=0 on both dirs keeps every unenrolled uid out.
-const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" +
+const tmpfilesConf = "d /data/.parked 2770 " + serviceUser + " " + serviceUser + " -\n" +
+ "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" +
"d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" +
"d /data/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" +
diff --git a/internal/host/claimdirs_test.go b/internal/host/claimdirs_test.go
new file mode 100644
index 000000000..f21ceed8d
--- /dev/null
+++ b/internal/host/claimdirs_test.go
@@ -0,0 +1,13 @@
+package host
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestHostClaimParkedTmpfiles(t *testing.T) {
+ line := "d /data/.parked 2770 " + serviceUser + " " + serviceUser + " -\n"
+ if strings.Count(tmpfilesConf, line) != 1 {
+ t.Fatal("parked directory missing or duplicated")
+ }
+}
diff --git a/internal/host/claimstop.go b/internal/host/claimstop.go
new file mode 100644
index 000000000..83100ede8
--- /dev/null
+++ b/internal/host/claimstop.go
@@ -0,0 +1,315 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var (
+ ErrClaimStopUnavailable = errors.New("claim stop: required root provider unavailable")
+ ErrClaimStopIdentity = errors.New("claim stop: authenticated identity mismatch")
+ ErrClaimStopInventory = errors.New("claim stop: inventory does not match request")
+ ErrClaimStopReceipt = errors.New("claim stop: immutable receipt conflict")
+)
+
+type StopPeer struct {
+ UID uint32
+ PID int
+ ProcessStart string
+ BootID string
+ ControllerID string
+}
+
+type StopPeerProvider interface {
+ AuthenticatedPeer(context.Context) (StopPeer, error)
+}
+
+type StopInventoryEntry struct {
+ Kind string
+ ArtifactID string
+ ManagedRoot string
+ RelativePath string
+ RowIdentity string
+ Ownership string
+ Materialization string
+}
+
+type StopInventory struct {
+ Host string
+ Incarnation string
+ InstallationStamp uint64
+ Box string
+ Enrollment string
+ DirectoryRevision string
+ Entries []StopInventoryEntry
+}
+
+type StopInventoryReader interface {
+ ReadStopInventory(context.Context, string) (StopInventory, error)
+}
+
+type StopRequest struct {
+ Host, Incarnation string
+ InstallationStamp, WriterSession uint64
+ OperationID, Phase, ObligationID string
+ Box, Enrollment string
+ BootID, ControllerID string
+ ExpectedClaimETag string
+ ExpectedDirectoryRevision string
+}
+
+type StopClaimReader interface {
+ Read(context.Context, string) (hostclaim.Observed, error)
+}
+
+type DrainAcknowledgement struct {
+ FenceID string
+ ControllerID string
+ ProcessStart string
+ Digest string
+}
+
+type StopDrainer interface {
+ DrainClaim(context.Context, StopRequest, string) (DrainAcknowledgement, error)
+}
+
+type StopKernelEvidence struct {
+ InventorySHA256 string
+ LocalJournalSHA256 string
+ CgroupIdentities []string
+ UnitInvocationIDs []string
+}
+
+type StopKernelVerifier interface {
+ VerifyStopped(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error)
+}
+
+type StopReceipt struct {
+ V int `json:"v"`
+ ID string `json:"id"`
+ Host string `json:"host"`
+ Incarnation string `json:"incarnation"`
+ InstallationStamp uint64 `json:"installation_stamp"`
+ WriterSession uint64 `json:"writer_session"`
+ OperationID string `json:"operation_id"`
+ Phase string `json:"phase"`
+ ObligationID string `json:"obligation_id"`
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ BootID string `json:"boot_id"`
+ ControllerID string `json:"controller_id"`
+ ClaimGen uint64 `json:"claim_gen"`
+ DirectoryRevision string `json:"directory_revision"`
+ Kind string `json:"kind"`
+ EvidenceAt time.Time `json:"evidence_at"`
+ FenceID string `json:"fence_id"`
+ InventorySHA256 string `json:"inventory_sha256"`
+ LocalJournalSHA256 string `json:"local_journal_sha256"`
+ ProducerDrainDigest string `json:"producer_drain_digest"`
+ CgroupIdentities []string `json:"cgroup_identities"`
+ UnitInvocationIDs []string `json:"unit_invocation_ids"`
+}
+
+type StopResult struct {
+ Key string
+ Digest string
+ Bytes []byte
+ EvidenceAt time.Time
+}
+
+type ClaimStopCoordinator struct {
+ Guard hostguard.Guard
+ Claims StopClaimReader
+ Peers StopPeerProvider
+ Inventory StopInventoryReader
+ Drainer StopDrainer
+ Kernel StopKernelVerifier
+ Evidence release.Store
+ CarlosUID uint32
+ NewID func() string
+ Now func() time.Time
+}
+
+func stopComponent(s string) bool {
+ return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00")
+}
+
+func stopDigest(s string) bool {
+ if len(s) != sha256.Size*2 {
+ return false
+ }
+ _, err := hex.DecodeString(s)
+ return err == nil
+}
+
+func (c ClaimStopCoordinator) validateRequest(r StopRequest) error {
+ if c.CarlosUID == 0 || c.Peers == nil || c.Claims == nil || c.Inventory == nil || c.Drainer == nil || c.Kernel == nil || c.Evidence == nil || c.NewID == nil || c.Now == nil ||
+ !stopComponent(r.Host) || !stopComponent(r.OperationID) || !stopComponent(r.Phase) || !stopComponent(r.ObligationID) || !stopComponent(r.Box) ||
+ !stopComponent(r.Enrollment) || r.Incarnation == "" || r.InstallationStamp == 0 || r.WriterSession == 0 ||
+ r.BootID == "" || r.ControllerID == "" || r.ExpectedClaimETag == "" || r.ExpectedDirectoryRevision == "" {
+ return ErrClaimStopUnavailable
+ }
+ return nil
+}
+
+func (c ClaimStopCoordinator) FenceAndStop(ctx context.Context, req StopRequest) (StopResult, error) {
+ if ctx == nil || c.validateRequest(req) != nil {
+ return StopResult{}, ErrClaimStopUnavailable
+ }
+ peer, err := c.Peers.AuthenticatedPeer(ctx)
+ if err != nil {
+ return StopResult{}, err
+ }
+ if peer.UID != c.CarlosUID || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID != req.BootID || peer.ControllerID != req.ControllerID {
+ return StopResult{}, ErrClaimStopIdentity
+ }
+ observed, err := c.Claims.Read(ctx, req.Host)
+ if err != nil {
+ return StopResult{}, err
+ }
+ if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil || observed.ETag != req.ExpectedClaimETag ||
+ observed.Claim.Host != req.Host || observed.Claim.Holder.Incarnation != req.Incarnation ||
+ observed.Claim.Holder.Stamp != req.InstallationStamp || observed.Claim.Holder.Session != req.WriterSession ||
+ observed.Claim.Op.ID != req.OperationID || observed.Claim.Op.Phase != req.Phase || observed.Claim.Op.Exec != req.Box {
+ return StopResult{}, ErrClaimStopIdentity
+ }
+ inv, err := c.Inventory.ReadStopInventory(ctx, req.Host)
+ if err != nil {
+ return StopResult{}, err
+ }
+ if inv.Host != req.Host || inv.Incarnation != req.Incarnation || inv.InstallationStamp != req.InstallationStamp ||
+ inv.Box != req.Box || inv.Enrollment != req.Enrollment || inv.DirectoryRevision != req.ExpectedDirectoryRevision {
+ return StopResult{}, ErrClaimStopInventory
+ }
+ fenceID, receiptID := c.NewID(), c.NewID()
+ if !stopComponent(fenceID) || !stopComponent(receiptID) {
+ return StopResult{}, ErrClaimStopUnavailable
+ }
+ target := hostguard.FenceTarget{FenceID: fenceID, Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp,
+ Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, Box: req.Box, Enrollment: req.Enrollment,
+ BootID: req.BootID, ControllerID: req.ControllerID, ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision}
+ target, err = c.Guard.BeginFence(ctx, target)
+ if err != nil {
+ return StopResult{}, err
+ }
+ fenceID = target.FenceID
+ if durable, err := c.Guard.FenceReceiptFor(ctx, target); err == nil {
+ result, err := stopResultFromDurable(req, durable)
+ if err != nil {
+ return StopResult{}, err
+ }
+ if err := c.Publish(ctx, result); err != nil {
+ return result, err
+ }
+ return result, nil
+ } else if !errors.Is(err, hostguard.ErrNoFenceReceipt) {
+ return StopResult{}, err
+ }
+ ack, err := c.Drainer.DrainClaim(ctx, req, fenceID)
+ if err != nil {
+ return StopResult{}, err
+ }
+ if ack.FenceID != fenceID || ack.ControllerID != req.ControllerID || ack.ProcessStart != peer.ProcessStart || !stopDigest(ack.Digest) {
+ return StopResult{}, ErrClaimStopIdentity
+ }
+ kernel, err := c.Kernel.VerifyStopped(ctx, target, inv, ack)
+ if err != nil {
+ return StopResult{}, err
+ }
+ evidenceAt := c.Now().UTC()
+ if evidenceAt.IsZero() || !stopDigest(kernel.InventorySHA256) || !stopDigest(kernel.LocalJournalSHA256) || len(kernel.CgroupIdentities) == 0 {
+ return StopResult{}, ErrClaimStopReceipt
+ }
+ receipt := StopReceipt{V: 1, ID: receiptID, Host: req.Host, Incarnation: req.Incarnation,
+ InstallationStamp: req.InstallationStamp, WriterSession: req.WriterSession, OperationID: req.OperationID,
+ Phase: req.Phase, ObligationID: req.ObligationID, Box: req.Box, Enrollment: req.Enrollment,
+ BootID: req.BootID, ControllerID: req.ControllerID, ClaimGen: observed.Claim.Gen,
+ DirectoryRevision: req.ExpectedDirectoryRevision, Kind: "stopped", EvidenceAt: evidenceAt, FenceID: fenceID,
+ InventorySHA256: kernel.InventorySHA256, LocalJournalSHA256: kernel.LocalJournalSHA256,
+ ProducerDrainDigest: ack.Digest, CgroupIdentities: append([]string(nil), kernel.CgroupIdentities...),
+ UnitInvocationIDs: append([]string(nil), kernel.UnitInvocationIDs...)}
+ body, err := json.Marshal(receipt)
+ if err != nil {
+ return StopResult{}, err
+ }
+ verify := func(_ context.Context, got hostguard.FenceTarget, exact []byte, at time.Time) error {
+ if got != target || !at.Equal(evidenceAt) || !bytes.Equal(exact, body) {
+ return ErrClaimStopReceipt
+ }
+ return nil
+ }
+ if err := c.Guard.CompleteFence(ctx, target, body, evidenceAt, verify); err != nil {
+ return StopResult{}, err
+ }
+ key := fmt.Sprintf("control/hosts/%s/evidence/%s/%s/%s.json", req.Host, req.OperationID, req.Box, receiptID)
+ sum := sha256.Sum256(body)
+ result := StopResult{Key: key, Digest: hex.EncodeToString(sum[:]), Bytes: append([]byte(nil), body...), EvidenceAt: evidenceAt}
+ if err := c.Publish(ctx, result); err != nil {
+ return result, err
+ }
+ return result, nil
+}
+
+func stopResultFromDurable(req StopRequest, durable hostguard.FenceReceipt) (StopResult, error) {
+ var receipt StopReceipt
+ if err := json.Unmarshal(durable.Bytes, &receipt); err != nil {
+ return StopResult{}, ErrClaimStopReceipt
+ }
+ if receipt.V != 1 || receipt.ID == "" || receipt.Host != req.Host || receipt.Incarnation != req.Incarnation ||
+ receipt.InstallationStamp != req.InstallationStamp || receipt.WriterSession != req.WriterSession ||
+ receipt.OperationID != req.OperationID || receipt.Phase != req.Phase || receipt.ObligationID != req.ObligationID ||
+ receipt.Box != req.Box || receipt.Enrollment != req.Enrollment || receipt.BootID != req.BootID ||
+ receipt.ControllerID != req.ControllerID || receipt.FenceID != durable.Target.FenceID ||
+ !receipt.EvidenceAt.Equal(durable.EvidenceAt) || !bytes.Equal(durable.Bytes, mustMarshalStopReceipt(receipt)) {
+ return StopResult{}, ErrClaimStopReceipt
+ }
+ key := fmt.Sprintf("control/hosts/%s/evidence/%s/%s/%s.json", req.Host, req.OperationID, req.Box, receipt.ID)
+ sum := sha256.Sum256(durable.Bytes)
+ return StopResult{Key: key, Digest: hex.EncodeToString(sum[:]), Bytes: append([]byte(nil), durable.Bytes...), EvidenceAt: durable.EvidenceAt}, nil
+}
+
+func mustMarshalStopReceipt(receipt StopReceipt) []byte {
+ b, _ := json.Marshal(receipt)
+ return b
+}
+
+// Publish retries only the exact bytes and key returned by FenceAndStop.
+func (c ClaimStopCoordinator) Publish(ctx context.Context, result StopResult) error {
+ if ctx == nil || c.Evidence == nil || result.Key == "" || len(result.Bytes) == 0 || result.EvidenceAt.IsZero() {
+ return ErrClaimStopUnavailable
+ }
+ var receipt StopReceipt
+ if err := json.Unmarshal(result.Bytes, &receipt); err != nil {
+ return ErrClaimStopReceipt
+ }
+ wantKey := fmt.Sprintf("control/hosts/%s/evidence/%s/%s/%s.json", receipt.Host, receipt.OperationID, receipt.Box, receipt.ID)
+ sum := sha256.Sum256(result.Bytes)
+ if result.Key != wantKey || result.Digest != hex.EncodeToString(sum[:]) || !result.EvidenceAt.Equal(receipt.EvidenceAt) ||
+ !bytes.Equal(result.Bytes, mustMarshalStopReceipt(receipt)) {
+ return ErrClaimStopReceipt
+ }
+ if _, err := c.Evidence.Put(ctx, result.Key, result.Bytes, ""); err == nil {
+ return nil
+ } else if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ body, _, err := c.Evidence.Get(ctx, result.Key)
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(body, result.Bytes) {
+ return ErrClaimStopReceipt
+ }
+ return nil
+}
diff --git a/internal/host/claimstop_test.go b/internal/host/claimstop_test.go
new file mode 100644
index 000000000..eab7c2189
--- /dev/null
+++ b/internal/host/claimstop_test.go
@@ -0,0 +1,225 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type stopPeerFunc func(context.Context) (StopPeer, error)
+
+func (f stopPeerFunc) AuthenticatedPeer(ctx context.Context) (StopPeer, error) { return f(ctx) }
+
+type stopInventoryFunc func(context.Context, string) (StopInventory, error)
+
+func (f stopInventoryFunc) ReadStopInventory(ctx context.Context, host string) (StopInventory, error) {
+ return f(ctx, host)
+}
+
+type stopDrainFunc func(context.Context, StopRequest, string) (DrainAcknowledgement, error)
+
+func (f stopDrainFunc) DrainClaim(ctx context.Context, req StopRequest, fence string) (DrainAcknowledgement, error) {
+ return f(ctx, req, fence)
+}
+
+type stopKernelFunc func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error)
+
+func (f stopKernelFunc) VerifyStopped(ctx context.Context, target hostguard.FenceTarget, inv StopInventory, ack DrainAcknowledgement) (StopKernelEvidence, error) {
+ return f(ctx, target, inv, ack)
+}
+
+type stopClaimReader struct{ obs hostclaim.Observed }
+
+func (r stopClaimReader) Read(context.Context, string) (hostclaim.Observed, error) { return r.obs, nil }
+
+type failPutStore struct {
+ release.Store
+ err error
+}
+
+func (s *failPutStore) Put(ctx context.Context, key string, body []byte, match string) (string, error) {
+ if s.err != nil {
+ err := s.err
+ s.err = nil
+ return "", err
+ }
+ return s.Store.Put(ctx, key, body, match)
+}
+
+func claimStopFixture(t *testing.T) (ClaimStopCoordinator, StopRequest, *failPutStore) {
+ t.Helper()
+ host := "shop.example"
+ data, locks := t.TempDir(), t.TempDir()
+ if err := os.Mkdir(filepath.Join(data, "hostguard"), 0770); err != nil {
+ t.Fatal(err)
+ }
+ claim := &hostclaim.Claim{V: 2, Host: host, Gen: 19,
+ Holder: &hostclaim.Holder{Incarnation: "inc-1", Stamp: 11, Session: 13},
+ Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: "operation-1", Phase: "stopping", Exec: "box-1"}}
+ obs := hostclaim.Observed{Host: host, Claim: claim, ETag: "etag-1"}
+ guard := hostguard.Guard{DataDir: data, LockDir: locks, Claims: stopClaimReader{obs}}
+ if err := guard.Activate(t.Context(), host, "box-1", nil); err != nil {
+ t.Fatal(err)
+ }
+ req := StopRequest{Host: host, Incarnation: "inc-1", InstallationStamp: 11, WriterSession: 13,
+ OperationID: "operation-1", Phase: "stopping", ObligationID: "obligation-1", Box: "box-1",
+ Enrollment: "enrollment-1", BootID: "boot-1", ControllerID: "controller-1",
+ ExpectedClaimETag: "etag-1", ExpectedDirectoryRevision: "directory-1"}
+ base := release.NewDirStore(t.TempDir())
+ store := &failPutStore{Store: base}
+ ids := []string{"fence-1", "receipt-1", "fence-retry", "receipt-retry"}
+ now := time.Date(2026, 10, 6, 17, 0, 0, 123, time.UTC)
+ c := ClaimStopCoordinator{Guard: guard, Claims: stopClaimReader{obs}, CarlosUID: 1001, Evidence: store,
+ Peers: stopPeerFunc(func(context.Context) (StopPeer, error) {
+ return StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"}, nil
+ }),
+ Inventory: stopInventoryFunc(func(_ context.Context, got string) (StopInventory, error) {
+ return StopInventory{Host: got, Incarnation: "inc-1", InstallationStamp: 11, Box: "box-1", Enrollment: "enrollment-1", DirectoryRevision: "directory-1", Entries: []StopInventoryEntry{{Kind: "path", ArtifactID: "artifact-1", ManagedRoot: "data", RelativePath: "derived/by/root"}}}, nil
+ }),
+ Drainer: stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) {
+ return DrainAcknowledgement{FenceID: fence, ControllerID: "controller-1", ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil
+ }),
+ Kernel: stopKernelFunc(func(_ context.Context, _ hostguard.FenceTarget, _ StopInventory, _ DrainAcknowledgement) (StopKernelEvidence, error) {
+ return StopKernelEvidence{InventorySHA256: strings.Repeat("b", 64), LocalJournalSHA256: strings.Repeat("c", 64), CgroupIdentities: []string{"cg-1"}, UnitInvocationIDs: []string{"unit-1"}}, nil
+ }),
+ NewID: func() string { id := ids[0]; ids = ids[1:]; return id }, Now: func() time.Time { return now }}
+ return c, req, store
+}
+
+func TestClaimStopCollectsAndPublishesExactReceipt(t *testing.T) {
+ c, req, _ := claimStopFixture(t)
+ result, err := c.FenceAndStop(t.Context(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantKey := "control/hosts/shop.example/evidence/operation-1/box-1/receipt-1.json"
+ if result.Key != wantKey || result.Digest == "" {
+ t.Fatalf("result = %+v", result)
+ }
+ stored, _, err := c.Evidence.Get(t.Context(), result.Key)
+ if err != nil || !reflect.DeepEqual(stored, result.Bytes) {
+ t.Fatalf("stored exact bytes = %v, %v", reflect.DeepEqual(stored, result.Bytes), err)
+ }
+ var receipt StopReceipt
+ if err := json.Unmarshal(stored, &receipt); err != nil {
+ t.Fatal(err)
+ }
+ if receipt.FenceID != "fence-1" || receipt.ID != "receipt-1" || receipt.EvidenceAt != result.EvidenceAt ||
+ receipt.ControllerID != req.ControllerID || receipt.Phase != req.Phase || receipt.ProducerDrainDigest != strings.Repeat("a", 64) ||
+ !reflect.DeepEqual(receipt.CgroupIdentities, []string{"cg-1"}) || !reflect.DeepEqual(receipt.UnitInvocationIDs, []string{"unit-1"}) {
+ t.Fatalf("receipt = %+v", receipt)
+ }
+}
+
+func TestClaimStopAuthenticatesBeforeFenceOrDrain(t *testing.T) {
+ c, req, _ := claimStopFixture(t)
+ drained := false
+ c.Peers = stopPeerFunc(func(context.Context) (StopPeer, error) {
+ return StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "wrong", ControllerID: "controller-1"}, nil
+ })
+ c.Drainer = stopDrainFunc(func(context.Context, StopRequest, string) (DrainAcknowledgement, error) {
+ drained = true
+ return DrainAcknowledgement{}, nil
+ })
+ if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopIdentity) || drained {
+ t.Fatalf("unauthenticated stop = %v drained=%v", err, drained)
+ }
+ called := false
+ err := c.Guard.With(t.Context(), req.Host, hostguard.Captured{Incarnation: req.Incarnation, Stamp: req.InstallationStamp}, func(*hostclaim.Claim) error { return nil }, func() error { called = true; return nil })
+ if err != nil || !called {
+ t.Fatalf("authentication installed fence: %v called=%v", err, called)
+ }
+}
+
+func TestClaimStopPublicationRetryKeepsBytesAndEvidenceTime(t *testing.T) {
+ c, req, store := claimStopFixture(t)
+ transport := errors.New("temporary publication failure")
+ store.err = transport
+ result, err := c.FenceAndStop(t.Context(), req)
+ if !errors.Is(err, transport) || len(result.Bytes) == 0 || result.EvidenceAt.IsZero() {
+ t.Fatalf("first publication = %+v, %v", result, err)
+ }
+ original := append([]byte(nil), result.Bytes...)
+ at := result.EvidenceAt
+ if err := c.Publish(t.Context(), result); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(result.Bytes, original) || result.EvidenceAt != at {
+ t.Fatal("retry changed durable receipt")
+ }
+ if err := c.Publish(t.Context(), result); err != nil {
+ t.Fatalf("create-only exact replay = %v", err)
+ }
+}
+
+func TestClaimStopCrashAfterBeginReusesDurableFenceID(t *testing.T) {
+ c, req, _ := claimStopFixture(t)
+ original := hostguard.FenceTarget{FenceID: "fence-before-crash", Host: req.Host, Incarnation: req.Incarnation,
+ Stamp: req.InstallationStamp, Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase,
+ Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID,
+ ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision}
+ if _, err := c.Guard.BeginFence(t.Context(), original); err != nil {
+ t.Fatal(err)
+ }
+ seen := ""
+ c.Drainer = stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) {
+ seen = fence
+ return DrainAcknowledgement{FenceID: fence, ControllerID: req.ControllerID, ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil
+ })
+ result, err := c.FenceAndStop(t.Context(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var receipt StopReceipt
+ if err := json.Unmarshal(result.Bytes, &receipt); err != nil {
+ t.Fatal(err)
+ }
+ if seen != original.FenceID || receipt.FenceID != original.FenceID {
+ t.Fatalf("retry used fence %q, receipt %q, want durable %q", seen, receipt.FenceID, original.FenceID)
+ }
+}
+
+func TestClaimStopCompletedRetryPublishesOriginalEvidence(t *testing.T) {
+ c, req, _ := claimStopFixture(t)
+ drains := 0
+ c.Drainer = stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) {
+ drains++
+ return DrainAcknowledgement{FenceID: fence, ControllerID: req.ControllerID, ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil
+ })
+ first, err := c.FenceAndStop(t.Context(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := c.FenceAndStop(t.Context(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if drains != 1 || !reflect.DeepEqual(first.Bytes, second.Bytes) || first.Key != second.Key || !first.EvidenceAt.Equal(second.EvidenceAt) {
+ t.Fatalf("completed retry recollected evidence: drains=%d first=%+v second=%+v", drains, first, second)
+ }
+}
+
+func TestClaimStopRefusesIncompleteProvidersAndEvidence(t *testing.T) {
+ c, req, _ := claimStopFixture(t)
+ c.Kernel = nil
+ if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopUnavailable) {
+ t.Fatalf("nil kernel = %v", err)
+ }
+ c, req, _ = claimStopFixture(t)
+ c.Kernel = stopKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) {
+ return StopKernelEvidence{}, nil
+ })
+ if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopReceipt) {
+ t.Fatalf("incomplete evidence = %v", err)
+ }
+}
diff --git a/internal/hostbox/typedpark_linux.go b/internal/hostbox/typedpark_linux.go
new file mode 100644
index 000000000..d545b3fa3
--- /dev/null
+++ b/internal/hostbox/typedpark_linux.go
@@ -0,0 +1,200 @@
+//go:build linux
+
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+var ErrTypedParkUnsupported = errors.New("hostbox: typed park unsupported")
+
+// TypedParker resolves inventory root IDs only from trusted configuration.
+// ParkedRoot is the provisioned filesystem root containing .parked.
+type TypedParker struct {
+ Roots map[string]string
+ ParkedRoots map[string]string
+ ParkedRoot, Incarnation, Host, Retirement string
+ syncParents func(int, int) error
+}
+
+func (p TypedParker) validate() error {
+ if (p.ParkedRoot == "" && len(p.ParkedRoots) == 0) || !component(p.Incarnation) || !component(p.Host) || !component(p.Retirement) || len(p.Roots) == 0 {
+ return ErrTypedInventory
+ }
+ return nil
+}
+
+// CaptureArtifact records the no-follow inode identity used at retirement.
+func (p TypedParker) CaptureArtifact(rootID, name, owner string, kind ArtifactKind) (Artifact, error) {
+ if err := p.validate(); err != nil {
+ return Artifact{}, err
+ }
+ rootPath, ok := p.Roots[rootID]
+ if !ok || !managedName(name) || owner == "" {
+ return Artifact{}, ErrTypedInventory
+ }
+ fd, err := openParent(rootPath, name, false)
+ if err != nil {
+ return Artifact{}, err
+ }
+ defer unix.Close(fd.dir)
+ var st unix.Stat_t
+ if err := unix.Fstatat(fd.dir, fd.base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
+ return Artifact{}, err
+ }
+ return Artifact{Kind: kind, Root: rootID, Name: name, Owner: owner, Dev: uint64(st.Dev), Ino: st.Ino, Type: st.Mode & unix.S_IFMT}, nil
+}
+
+func (p TypedParker) Park(ctx context.Context, id string, a Artifact) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if err := p.validate(); err != nil {
+ return err
+ }
+ if id == "" || !managedName(a.Name) || a.Dev == 0 || a.Ino == 0 || a.Type == 0 {
+ return ErrTypedInventory
+ }
+ srcRoot, ok := p.Roots[a.Root]
+ if !ok {
+ return ErrTypedInventory
+ }
+ src, err := openParent(srcRoot, a.Name, false)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(src.dir)
+ var st unix.Stat_t
+ if err := unix.Fstatat(src.dir, src.base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
+ if errors.Is(err, unix.ENOENT) {
+ return p.verifyDestination(a, src.dir)
+ }
+ return err
+ }
+ if !sameArtifact(a, &st) {
+ return ErrChanged
+ }
+ dstRel := filepath.Join(".parked", p.Incarnation, p.Host, p.Retirement, a.Root, a.Name)
+ parkedRoot := p.ParkedRoots[a.Root]
+ if parkedRoot == "" {
+ parkedRoot = p.ParkedRoot
+ }
+ if parkedRoot == "" {
+ return ErrTypedInventory
+ }
+ dst, err := openParent(parkedRoot, dstRel, true)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(dst.dir)
+ var srcFS, dstFS unix.Stat_t
+ if err := unix.Fstat(src.dir, &srcFS); err != nil {
+ return err
+ }
+ if err := unix.Fstat(dst.dir, &dstFS); err != nil {
+ return err
+ }
+ if srcFS.Dev != dstFS.Dev {
+ return unix.EXDEV
+ }
+ if err := unix.Renameat2(src.dir, src.base, dst.dir, dst.base, unix.RENAME_NOREPLACE); err != nil {
+ if errors.Is(err, unix.EEXIST) {
+ return ErrChanged
+ }
+ return err
+ }
+ return p.sync(src.dir, dst.dir)
+}
+
+func (p TypedParker) verifyDestination(a Artifact, srcParent int) error {
+ rel := filepath.Join(".parked", p.Incarnation, p.Host, p.Retirement, a.Root, a.Name)
+ parkedRoot := p.ParkedRoots[a.Root]
+ if parkedRoot == "" {
+ parkedRoot = p.ParkedRoot
+ }
+ if parkedRoot == "" {
+ return ErrTypedInventory
+ }
+ fd, err := openParent(parkedRoot, rel, false)
+ if err != nil {
+ return err
+ }
+ defer unix.Close(fd.dir)
+ var st unix.Stat_t
+ if err := unix.Fstatat(fd.dir, fd.base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
+ return err
+ }
+ if !sameArtifact(a, &st) {
+ return ErrChanged
+ }
+ return p.sync(srcParent, fd.dir)
+}
+
+func (p TypedParker) sync(a, b int) error {
+ if p.syncParents != nil {
+ return p.syncParents(a, b)
+ }
+ return syncFDs(a, b)
+}
+
+type parentFD struct {
+ dir int
+ base string
+}
+
+func openParent(rootPath, name string, create bool) (parentFD, error) {
+ root, err := unix.Open(rootPath, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
+ if err != nil {
+ return parentFD{}, err
+ }
+ parts := strings.Split(filepath.Clean(name), string(filepath.Separator))
+ cur := root
+ for _, part := range parts[:len(parts)-1] {
+ next, er := unix.Openat(cur, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
+ if er != nil && create && errors.Is(er, unix.ENOENT) {
+ if er = unix.Mkdirat(cur, part, 0o700); er == nil || errors.Is(er, unix.EEXIST) {
+ if er == nil {
+ er = unix.Fsync(cur)
+ }
+ if er != nil {
+ if cur != root {
+ unix.Close(cur)
+ }
+ unix.Close(root)
+ return parentFD{}, er
+ }
+ next, er = unix.Openat(cur, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
+ }
+ }
+ if cur != root {
+ unix.Close(cur)
+ }
+ if er != nil {
+ unix.Close(root)
+ return parentFD{}, er
+ }
+ cur = next
+ }
+ if cur != root {
+ unix.Close(root)
+ }
+ return parentFD{cur, parts[len(parts)-1]}, nil
+}
+func sameArtifact(a Artifact, st *unix.Stat_t) bool {
+ return a.Dev == uint64(st.Dev) && a.Ino == st.Ino && a.Type == st.Mode&unix.S_IFMT
+}
+func syncFDs(a, b int) error {
+ if err := unix.Fsync(a); err != nil {
+ return fmt.Errorf("sync source: %w", err)
+ }
+ if err := unix.Fsync(b); err != nil {
+ return fmt.Errorf("sync destination: %w", err)
+ }
+ return nil
+}
diff --git a/internal/hostbox/typedpark_linux_test.go b/internal/hostbox/typedpark_linux_test.go
new file mode 100644
index 000000000..a40c1a110
--- /dev/null
+++ b/internal/hostbox/typedpark_linux_test.go
@@ -0,0 +1,139 @@
+//go:build linux
+
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestTypedParkerMovesExactFileAndSymlinkWithoutFollowing(t *testing.T) {
+ root := t.TempDir()
+ data := filepath.Join(root, "data")
+ if err := os.Mkdir(data, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(data, "db"), []byte("db"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("db", filepath.Join(data, "live")); err != nil {
+ t.Fatal(err)
+ }
+ p := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: root, Incarnation: "inc", Host: "shop.example", Retirement: "ret"}
+ file, err := p.CaptureArtifact("data", "db", "owner", ArtifactPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ link, err := p.CaptureArtifact("data", "live", "owner", ArtifactLiveLink)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if file.Ino == link.Ino || file.Type == link.Type {
+ t.Fatal("capture followed symlink")
+ }
+ if err := p.Park(context.Background(), "ignored", file); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Park(context.Background(), "ignored", link); err != nil {
+ t.Fatal(err)
+ }
+ parked := filepath.Join(root, ".parked", "inc", "shop.example", "ret", "data")
+ if got, err := os.Readlink(filepath.Join(parked, "live")); err != nil || got != "db" {
+ t.Fatalf("parked symlink=%q err=%v", got, err)
+ }
+ // Missing source replay accepts only the exact recorded destination inode.
+ if err := p.Park(context.Background(), "ignored", file); err != nil {
+ t.Fatalf("idempotent replay: %v", err)
+ }
+}
+
+func TestTypedParkerRefusesSuccessorAndSymlinkIntermediate(t *testing.T) {
+ root := t.TempDir()
+ data := filepath.Join(root, "data")
+ other := filepath.Join(root, "other")
+ os.Mkdir(data, 0o700)
+ os.Mkdir(other, 0o700)
+ if err := os.WriteFile(filepath.Join(data, "db"), []byte("old"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ p := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: root, Incarnation: "inc", Host: "shop.example", Retirement: "ret"}
+ a, err := p.CaptureArtifact("data", "db", "owner", ArtifactPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ held, err := os.Open(filepath.Join(data, "db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer held.Close()
+ if err := os.Remove(filepath.Join(data, "db")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(data, "db"), []byte("successor"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Park(context.Background(), "ignored", a); !errors.Is(err, ErrChanged) {
+ t.Fatalf("successor error=%v", err)
+ }
+ if err := os.Symlink(other, filepath.Join(data, "escape")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.CaptureArtifact("data", "escape/file", "owner", ArtifactPath); err == nil {
+ t.Fatal("symlink intermediate accepted")
+ }
+}
+
+func TestTypedParkerNoReplacePreservesExistingDestination(t *testing.T) {
+ root := t.TempDir()
+ data := filepath.Join(root, "data")
+ dest := filepath.Join(root, ".parked", "inc", "shop.example", "ret", "data")
+ os.Mkdir(data, 0o700)
+ os.MkdirAll(dest, 0o700)
+ os.WriteFile(filepath.Join(data, "db"), []byte("old"), 0o600)
+ os.WriteFile(filepath.Join(dest, "db"), []byte("occupied"), 0o600)
+ p := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: root, Incarnation: "inc", Host: "shop.example", Retirement: "ret"}
+ a, err := p.CaptureArtifact("data", "db", "owner", ArtifactPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Park(context.Background(), "ignored", a); !errors.Is(err, ErrChanged) {
+ t.Fatalf("error=%v", err)
+ }
+ if string(mustReadTyped(t, filepath.Join(data, "db"))) != "old" || string(mustReadTyped(t, filepath.Join(dest, "db"))) != "occupied" {
+ t.Fatal("no-replace modified source or destination")
+ }
+}
+
+func TestTypedParkerRetryAfterRenameFsyncFailureSyncsBothParents(t *testing.T) {
+ root := t.TempDir()
+ data := filepath.Join(root, "data")
+ os.Mkdir(data, 0o700)
+ os.WriteFile(filepath.Join(data, "db"), []byte("old"), 0o600)
+ calls := 0
+ p := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: root, Incarnation: "inc", Host: "shop.example", Retirement: "ret", syncParents: func(a, b int) error {
+ calls++
+ if calls == 1 {
+ return errors.New("injected fsync failure")
+ }
+ return syncFDs(a, b)
+ }}
+ a, err := p.CaptureArtifact("data", "db", "owner", ArtifactPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Park(context.Background(), "ignored", a); err == nil {
+ t.Fatal("first park did not report fsync failure")
+ }
+ if _, err := os.Stat(filepath.Join(data, "db")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("rename did not happen: %v", err)
+ }
+ if err := p.Park(context.Background(), "ignored", a); err != nil {
+ t.Fatalf("retry: %v", err)
+ }
+ if calls != 2 {
+ t.Fatalf("sync calls=%d, want initial and replay", calls)
+ }
+}
diff --git a/internal/hostbox/typedpark_other.go b/internal/hostbox/typedpark_other.go
new file mode 100644
index 000000000..635032cd3
--- /dev/null
+++ b/internal/hostbox/typedpark_other.go
@@ -0,0 +1,18 @@
+//go:build !linux
+
+package hostbox
+
+import "context"
+
+var ErrTypedParkUnsupported = ErrUnsupported
+
+type TypedParker struct {
+ Roots map[string]string
+ ParkedRoots map[string]string
+ ParkedRoot, Incarnation, Host, Retirement string
+}
+
+func (TypedParker) CaptureArtifact(string, string, string, ArtifactKind) (Artifact, error) {
+ return Artifact{}, ErrTypedParkUnsupported
+}
+func (TypedParker) Park(context.Context, string, Artifact) error { return ErrTypedParkUnsupported }
diff --git a/internal/hostbox/typedretire.go b/internal/hostbox/typedretire.go
new file mode 100644
index 000000000..ce0b3b199
--- /dev/null
+++ b/internal/hostbox/typedretire.go
@@ -0,0 +1,293 @@
+package hostbox
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostguard"
+)
+
+var (
+ ErrTypedInventory = errors.New("hostbox: invalid typed retirement inventory")
+ ErrTypedJournal = errors.New("hostbox: invalid typed retirement journal")
+)
+
+type ArtifactKind string
+
+const (
+ ArtifactPath ArtifactKind = "path"
+ ArtifactLiveLink ArtifactKind = "live-link"
+ ArtifactUnitState ArtifactKind = "unit-state"
+ ArtifactRoute ArtifactKind = "route"
+ ArtifactTimer ArtifactKind = "timer"
+)
+
+// Artifact binds a managed name to the installation identity which created it.
+// Root is a configured root identifier, never an arbitrary absolute pathname.
+type Artifact struct {
+ Kind ArtifactKind `json:"kind"`
+ Root string `json:"root"`
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+ RowID string `json:"row_id,omitempty"`
+ Dev uint64 `json:"dev,omitempty"`
+ Ino uint64 `json:"ino,omitempty"`
+ Type uint32 `json:"type,omitempty"`
+}
+
+type RetirementInventory struct {
+ Host string `json:"host"`
+ Captured hostguard.Captured `json:"captured"`
+ Ownership string `json:"ownership"`
+ Artifacts []Artifact `json:"artifacts"`
+}
+
+type RetirementStep struct {
+ Artifact Artifact `json:"artifact"`
+ Done bool `json:"done"`
+}
+type RetirementJournal struct {
+ V int `json:"v"`
+ ID string `json:"id"`
+ Inventory RetirementInventory `json:"inventory"`
+ Steps []RetirementStep `json:"steps"`
+ Complete bool `json:"complete"`
+}
+
+type FilesystemParker interface {
+ Park(context.Context, string, Artifact) error
+}
+type ConditionalRows interface {
+ RetireRows(context.Context, string, []Artifact) error
+}
+type RetirementAuthority interface {
+ WithCleanup(context.Context, hostguard.FenceTarget, func() error) error
+}
+
+type TypedRetirement struct {
+ JournalDir string
+ Authority RetirementAuthority
+ Filesystem FilesystemParker
+ Rows ConditionalRows
+ Ack func(context.Context, RetirementJournal) error
+}
+
+func component(s string) bool {
+ return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00")
+}
+func managedName(s string) bool {
+ if s == "" || filepath.IsAbs(s) || filepath.Clean(s) != s || strings.ContainsAny(s, "\\\x00") {
+ return false
+ }
+ for _, p := range strings.Split(s, "/") {
+ if !component(p) {
+ return false
+ }
+ }
+ return true
+}
+
+func validateInventory(in RetirementInventory) error {
+ if !component(in.Host) || in.Captured.Incarnation == "" || in.Captured.Stamp == 0 || !component(in.Ownership) || len(in.Artifacts) == 0 {
+ return ErrTypedInventory
+ }
+ seen := map[string]bool{}
+ for _, a := range in.Artifacts {
+ if !component(a.Root) || !managedName(a.Name) || a.Owner != in.Ownership {
+ return ErrTypedInventory
+ }
+ switch a.Kind {
+ case ArtifactPath, ArtifactLiveLink, ArtifactUnitState:
+ if a.RowID != "" || a.Dev == 0 || a.Ino == 0 || a.Type == 0 {
+ return ErrTypedInventory
+ }
+ case ArtifactRoute, ArtifactTimer:
+ if !component(a.RowID) || a.Dev != 0 || a.Ino != 0 || a.Type != 0 {
+ return ErrTypedInventory
+ }
+ default:
+ return ErrTypedInventory
+ }
+ key := string(a.Kind) + "\x00" + a.Root + "\x00" + a.Name + "\x00" + a.RowID
+ if seen[key] {
+ return ErrTypedInventory
+ }
+ seen[key] = true
+ }
+ return nil
+}
+
+func NewRetirementJournal(id string, in RetirementInventory) (RetirementJournal, error) {
+ if !component(id) || validateInventory(in) != nil {
+ return RetirementJournal{}, ErrTypedInventory
+ }
+ a := append([]Artifact(nil), in.Artifacts...)
+ sort.Slice(a, func(i, j int) bool { return fmt.Sprint(a[i]) < fmt.Sprint(a[j]) })
+ in.Artifacts = a
+ j := RetirementJournal{V: 1, ID: id, Inventory: in, Steps: make([]RetirementStep, len(a))}
+ for i := range a {
+ j.Steps[i].Artifact = a[i]
+ }
+ return j, nil
+}
+
+func (r TypedRetirement) Run(ctx context.Context, target hostguard.FenceTarget, intent RetirementJournal) error {
+ if ctx == nil || r.JournalDir == "" || r.Authority == nil || r.Filesystem == nil || r.Rows == nil || r.Ack == nil || intent.V != 1 || !component(intent.ID) || validateInventory(intent.Inventory) != nil || target.Host != intent.Inventory.Host || target.Incarnation != intent.Inventory.Captured.Incarnation || target.Stamp != intent.Inventory.Captured.Stamp {
+ return ErrTypedInventory
+ }
+ canonical, err := NewRetirementJournal(intent.ID, intent.Inventory)
+ if err != nil {
+ return err
+ }
+ // Callers provide immutable intent only. Progress is trusted exclusively
+ // from the durable journal this controller wrote itself.
+ if intent.Complete || len(intent.Steps) != len(canonical.Steps) {
+ return ErrTypedJournal
+ }
+ for i := range intent.Steps {
+ if intent.Steps[i].Done || intent.Steps[i].Artifact != canonical.Steps[i].Artifact {
+ return ErrTypedJournal
+ }
+ }
+ intent = canonical
+ path := filepath.Join(r.JournalDir, intent.ID+".json")
+ j, err := r.loadOrCreate(path, intent)
+ if err != nil {
+ return err
+ }
+ if !j.Complete {
+ err = r.Authority.WithCleanup(ctx, target, func() error {
+ var rows []Artifact
+ for i := range j.Steps {
+ if j.Steps[i].Done {
+ continue
+ }
+ a := j.Steps[i].Artifact
+ switch a.Kind {
+ case ArtifactRoute, ArtifactTimer:
+ rows = append(rows, a)
+ default:
+ if err := r.Filesystem.Park(ctx, j.ID, a); err != nil {
+ return err
+ }
+ j.Steps[i].Done = true
+ if err := writeJournal(path, j); err != nil {
+ return err
+ }
+ }
+ }
+ if len(rows) > 0 {
+ if err := r.Rows.RetireRows(ctx, j.Inventory.Ownership, rows); err != nil {
+ return err
+ }
+ for i := range j.Steps {
+ if j.Steps[i].Artifact.Kind == ArtifactRoute || j.Steps[i].Artifact.Kind == ArtifactTimer {
+ j.Steps[i].Done = true
+ }
+ }
+ if err := writeJournal(path, j); err != nil {
+ return err
+ }
+ }
+ for _, s := range j.Steps {
+ if !s.Done {
+ return ErrTypedJournal
+ }
+ }
+ j.Complete = true
+ return writeJournal(path, j)
+ })
+ if err != nil {
+ return err
+ }
+ }
+ // Acknowledgement is deliberately after the complete journal and outside
+ // the cleanup guard. A crash here replays only this idempotent call.
+ return r.Ack(ctx, j)
+}
+
+func (r TypedRetirement) loadOrCreate(path string, want RetirementJournal) (RetirementJournal, error) {
+ b, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ if err := os.MkdirAll(r.JournalDir, 0o700); err != nil {
+ return RetirementJournal{}, err
+ }
+ if err := writeJournal(path, want); err != nil {
+ return RetirementJournal{}, err
+ }
+ return want, nil
+ }
+ if err != nil {
+ return RetirementJournal{}, err
+ }
+ var got RetirementJournal
+ if err := decodeStrict(b, &got); err != nil {
+ return RetirementJournal{}, ErrTypedJournal
+ }
+ a, _ := json.Marshal(want.Inventory)
+ c, _ := json.Marshal(got.Inventory)
+ if got.V != 1 || got.ID != want.ID || !bytes.Equal(a, c) || len(got.Steps) != len(want.Steps) {
+ return RetirementJournal{}, ErrTypedJournal
+ }
+ allDone := true
+ for i := range got.Steps {
+ if got.Steps[i].Artifact != want.Steps[i].Artifact {
+ return RetirementJournal{}, ErrTypedJournal
+ }
+ allDone = allDone && got.Steps[i].Done
+ }
+ if got.Complete && !allDone {
+ return RetirementJournal{}, ErrTypedJournal
+ }
+ return got, nil
+}
+
+func decodeStrict(b []byte, v any) error {
+ d := json.NewDecoder(bytes.NewReader(b))
+ d.DisallowUnknownFields()
+ if err := d.Decode(v); err != nil {
+ return err
+ }
+ if err := d.Decode(new(any)); err != io.EOF {
+ return ErrTypedJournal
+ }
+ return nil
+}
+func writeJournal(path string, j RetirementJournal) error {
+ b, err := json.Marshal(j)
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".retire-*")
+ if err != nil {
+ return err
+ }
+ name := tmp.Name()
+ defer os.Remove(name)
+ if _, err = tmp.Write(b); err == nil {
+ err = tmp.Sync()
+ }
+ if ce := tmp.Close(); err == nil {
+ err = ce
+ }
+ if err != nil {
+ return err
+ }
+ if err = os.Rename(name, path); err != nil {
+ return err
+ }
+ d, err := os.Open(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ return d.Sync()
+}
diff --git a/internal/hostbox/typedretire_test.go b/internal/hostbox/typedretire_test.go
new file mode 100644
index 000000000..2ca4253a5
--- /dev/null
+++ b/internal/hostbox/typedretire_test.go
@@ -0,0 +1,173 @@
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostguard"
+)
+
+type cleanupAuthority struct{ calls int }
+
+func (a *cleanupAuthority) WithCleanup(_ context.Context, _ hostguard.FenceTarget, fn func() error) error {
+ a.calls++
+ return fn()
+}
+
+// rootParker uses descriptor-relative operations and link-before-remove. Link
+// is the no-replace claim: an existing destination refuses without touching
+// the source. Retirement directories are provisioned before the run.
+type rootParker struct {
+ root *os.Root
+ calls int
+ crashAfter int
+}
+
+func (p *rootParker) Park(_ context.Context, id string, a Artifact) error {
+ p.calls++
+ dst := filepath.Join("parked", id, a.Root, a.Name)
+ src := filepath.Join(a.Root, a.Name)
+ if err := p.root.Link(src, dst); err != nil {
+ srcInfo, srcErr := p.root.Lstat(src)
+ dstInfo, dstErr := p.root.Lstat(dst)
+ if srcErr != nil || dstErr != nil || !os.SameFile(srcInfo, dstInfo) {
+ return err
+ }
+ }
+ if p.crashAfter == p.calls {
+ return errors.New("crash after durable destination")
+ }
+ return p.root.Remove(src)
+}
+
+type rowRetirer struct {
+ calls int
+ rows map[string]bool
+}
+
+func (r *rowRetirer) RetireRows(_ context.Context, owner string, a []Artifact) error {
+ r.calls++
+ for _, x := range a {
+ key := string(x.Kind) + ":" + x.RowID
+ if r.rows[key] != true {
+ return errors.New("conditional row identity changed")
+ }
+ delete(r.rows, key)
+ }
+ return nil
+}
+
+func TestTypedRetirementJournalsBeforeEffectsAndRetriesWithoutTouchingSuccessor(t *testing.T) {
+ rootPath := t.TempDir()
+ mustMkdir(t, filepath.Join(rootPath, "data"))
+ mustMkdir(t, filepath.Join(rootPath, "live"))
+ mustMkdir(t, filepath.Join(rootPath, "parked", "ret-1", "data"))
+ mustMkdir(t, filepath.Join(rootPath, "parked", "ret-1", "live"))
+ mustWrite(t, filepath.Join(rootPath, "data", "app.db"), "old-db")
+ mustWrite(t, filepath.Join(rootPath, "live", "app"), "old-link")
+ root, err := os.OpenRoot(rootPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+ p := &rootParker{root: root, crashAfter: 1}
+ rows := &rowRetirer{rows: map[string]bool{"route:route-7": true, "timer:timer-9": true}}
+ auth := &cleanupAuthority{}
+ acks := 0
+ in := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 7}, Ownership: "install-7", Artifacts: []Artifact{{Kind: ArtifactPath, Root: "data", Name: "app.db", Owner: "install-7", Dev: 1, Ino: 1, Type: 1}, {Kind: ArtifactLiveLink, Root: "live", Name: "app", Owner: "install-7", Dev: 1, Ino: 2, Type: 1}, {Kind: ArtifactRoute, Root: "registry", Name: "shop.example", Owner: "install-7", RowID: "route-7"}, {Kind: ArtifactTimer, Root: "registry", Name: "shop.example/daily", Owner: "install-7", RowID: "timer-9"}}}
+ j, err := NewRetirementJournal("ret-1", in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := hostguard.FenceTarget{Host: in.Host, Incarnation: "inc", Stamp: 7}
+ r := TypedRetirement{JournalDir: filepath.Join(rootPath, "journals"), Authority: auth, Filesystem: p, Rows: rows, Ack: func(context.Context, RetirementJournal) error { acks++; return nil }}
+ if err := r.Run(context.Background(), target, j); err == nil {
+ t.Fatal("first run survived injected crash")
+ }
+ if _, err := os.Stat(filepath.Join(rootPath, "journals", "ret-1.json")); err != nil {
+ t.Fatalf("intent was not durable before effect: %v", err)
+ }
+ // Retry sees the no-replace destination. The parker accepts it only when it
+ // is the same inode, removes the old source name, then continues the journal.
+ p.crashAfter = 0
+ if err := r.Run(context.Background(), target, j); err != nil {
+ t.Fatal(err)
+ }
+ if acks != 1 || rows.calls != 1 {
+ t.Fatalf("acks=%d row txns=%d", acks, rows.calls)
+ }
+ // A successor at the old path appears after completion. Ack retry performs
+ // no filesystem or SQL effect and therefore preserves it.
+ mustWrite(t, filepath.Join(rootPath, "data", "app.db"), "successor")
+ calls := p.calls
+ if err := r.Run(context.Background(), target, j); err != nil {
+ t.Fatal(err)
+ }
+ if p.calls != calls || string(mustReadTyped(t, filepath.Join(rootPath, "data", "app.db"))) != "successor" {
+ t.Fatal("completed retry touched successor")
+ }
+}
+
+func TestTypedRetirementRefusesIncompleteOrUntypedInventory(t *testing.T) {
+ bad := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 1}, Ownership: "own", Artifacts: []Artifact{{Kind: ArtifactRoute, Root: "registry", Name: "shop.example", Owner: "own"}}}
+ if _, err := NewRetirementJournal("r", bad); !errors.Is(err, ErrTypedInventory) {
+ t.Fatalf("error=%v", err)
+ }
+}
+
+func TestTypedRetirementRejectsForgedCallerProgress(t *testing.T) {
+ dir := t.TempDir()
+ in := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 1}, Ownership: "own", Artifacts: []Artifact{{Kind: ArtifactPath, Root: "data", Name: "db", Owner: "own", Dev: 1, Ino: 2, Type: 3}}}
+ canonical, err := NewRetirementJournal("ret", in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ acks := 0
+ r := TypedRetirement{JournalDir: dir, Authority: &cleanupAuthority{}, Filesystem: &rootParker{}, Rows: &rowRetirer{}, Ack: func(context.Context, RetirementJournal) error { acks++; return nil }}
+ for name, forged := range map[string]RetirementJournal{"complete": canonical, "empty-steps": canonical, "done": canonical} {
+ switch name {
+ case "complete":
+ forged.Complete = true
+ for i := range forged.Steps {
+ forged.Steps[i].Done = true
+ }
+ case "empty-steps":
+ forged.Steps = nil
+ case "done":
+ forged.Steps[0].Done = true
+ }
+ if err := r.Run(context.Background(), hostguard.FenceTarget{Host: in.Host, Incarnation: "inc", Stamp: 1}, forged); !errors.Is(err, ErrTypedJournal) {
+ t.Fatalf("%s error=%v", name, err)
+ }
+ }
+ if acks != 0 {
+ t.Fatalf("forged progress acked %d times", acks)
+ }
+ if entries, err := os.ReadDir(dir); err != nil || len(entries) != 0 {
+ t.Fatalf("forgery poisoned journal dir: entries=%v err=%v", entries, err)
+ }
+}
+
+func mustMkdir(t *testing.T, p string) {
+ t.Helper()
+ if err := os.MkdirAll(p, 0o700); err != nil {
+ t.Fatal(err)
+ }
+}
+func mustWrite(t *testing.T, p, s string) {
+ t.Helper()
+ if err := os.WriteFile(p, []byte(s), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+func mustReadTyped(t *testing.T, p string) []byte {
+ t.Helper()
+ b, e := os.ReadFile(p)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return b
+}
diff --git a/internal/hostguard/fence.go b/internal/hostguard/fence.go
new file mode 100644
index 000000000..bf6752107
--- /dev/null
+++ b/internal/hostguard/fence.go
@@ -0,0 +1,245 @@
+package hostguard
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+var (
+ ErrFenceActive = errors.New("hostguard: another fence is active")
+ ErrFenceTarget = errors.New("hostguard: fence target does not match authority")
+ ErrNoFenceReceipt = errors.New("hostguard: no fence receipt for target")
+ ErrReceiptChanged = errors.New("hostguard: fence receipt bytes changed")
+)
+
+// FenceTarget contains authority identities only. Filesystem paths, PIDs and
+// unit names are discovered and authenticated by the trusted root verifier.
+type FenceTarget struct {
+ FenceID string `json:"fence_id"`
+ Host string `json:"host"`
+ Incarnation string `json:"incarnation"`
+ Stamp uint64 `json:"stamp"`
+ Session uint64 `json:"session"`
+ Op string `json:"op,omitempty"`
+ Phase string `json:"phase,omitempty"`
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ BootID string `json:"boot_id"`
+ ControllerID string `json:"controller_id"`
+ ClaimETag string `json:"claim_etag"`
+ DirectoryRevision string `json:"directory_revision"`
+}
+
+func (t FenceTarget) validate(host string) error {
+ if validateFileHost(host) != nil || t.FenceID == "" || t.Host != host || t.Incarnation == "" || t.Stamp == 0 || t.Session == 0 ||
+ t.Box == "" || t.Enrollment == "" || t.BootID == "" || t.ControllerID == "" || t.ClaimETag == "" || t.DirectoryRevision == "" {
+ return ErrMalformedRecord
+ }
+ return nil
+}
+
+func (t FenceTarget) sameAuthority(other FenceTarget) bool {
+ t.FenceID, other.FenceID = "", ""
+ // The fresh request has already revalidated this session and operation.
+ // A bookkeeping CAS must not replace the persisted fence or its evidence.
+ t.ClaimETag, other.ClaimETag = "", ""
+ return t == other
+}
+
+func (t FenceTarget) matchesSession(c *hostclaim.Claim, captured Captured) bool {
+ return c != nil && c.Holder != nil && t.Host == c.Host && t.Incarnation == captured.Incarnation &&
+ t.Incarnation == c.Holder.Incarnation && t.Stamp == captured.Stamp && t.Stamp == c.Holder.Stamp &&
+ t.Session == c.Holder.Session
+}
+
+func (t FenceTarget) matchesAuthority(c *hostclaim.Claim, captured Captured) bool {
+ return t.matchesSession(c, captured) && t.Op == c.Op.ID && t.Phase == c.Op.Phase
+}
+
+type FenceReceipt struct {
+ Target FenceTarget `json:"target"`
+ Bytes []byte `json:"bytes"`
+ EvidenceAt time.Time `json:"evidence_at"`
+}
+
+func (r FenceReceipt) validate(host string) error {
+ if err := r.Target.validate(host); err != nil || len(r.Bytes) == 0 || r.EvidenceAt.IsZero() {
+ return ErrMalformedRecord
+ }
+ return nil
+}
+
+// BeginFence checks fresh claim authority while holding the host lock, then
+// durably installs the deny before any privileged stop work begins.
+func (g Guard) BeginFence(ctx context.Context, target FenceTarget) (FenceTarget, error) {
+ var authoritative FenceTarget
+ err := g.locked(ctx, target.Host, func(d *recordDir) error {
+ if err := target.validate(target.Host); err != nil {
+ return ErrInvalidInput
+ }
+ obs, err := g.freshObserved(ctx, target.Host)
+ if err != nil {
+ return err
+ }
+ c := obs.Claim
+ if obs.ETag != target.ClaimETag || !target.matchesAuthority(c, Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}) || c.Op.Exec != target.Box {
+ return ErrFenceTarget
+ }
+ r, err := d.read(target.Host)
+ if err != nil {
+ return err
+ }
+ if r.Incarnation != target.Incarnation || r.ActiveStamp != target.Stamp {
+ return ErrFenceTarget
+ }
+ if r.ActiveFence != nil {
+ if *r.ActiveFence == target {
+ authoritative = *r.ActiveFence
+ return d.sync(target.Host)
+ }
+ if r.ActiveFence.sameAuthority(target) {
+ authoritative = *r.ActiveFence
+ return d.sync(target.Host)
+ }
+ // A completed predecessor remains independently cleanable from its
+ // immutable journal entry. An unsettled predecessor keeps the deny
+ // slot and cannot be overwritten by a successor request.
+ completed := false
+ for _, receipt := range r.FenceJournal {
+ if receipt.Target == *r.ActiveFence {
+ completed = true
+ break
+ }
+ }
+ if !completed {
+ return ErrFenceActive
+ }
+ }
+ r.ActiveFence = &target
+ authoritative = target
+ return d.write(r)
+ })
+ return authoritative, err
+}
+
+// FenceReceiptFor returns the immutable completed evidence for exactly target.
+// It does not consult the claim and is safe for transport recovery.
+func (g Guard) FenceReceiptFor(ctx context.Context, target FenceTarget) (FenceReceipt, error) {
+ var found FenceReceipt
+ err := g.locked(ctx, target.Host, func(d *recordDir) error {
+ r, err := d.read(target.Host)
+ if err != nil {
+ return err
+ }
+ for _, receipt := range r.FenceJournal {
+ if receipt.Target == target {
+ found = receipt
+ return nil
+ }
+ }
+ return ErrNoFenceReceipt
+ })
+ return found, err
+}
+
+type FenceVerify func(context.Context, FenceTarget, []byte, time.Time) error
+
+// CompleteFence verifies root evidence without holding the host lock, then
+// journals its exact bytes. Replaying the same receipt is idempotent.
+func (g Guard) CompleteFence(ctx context.Context, target FenceTarget, receipt []byte, evidenceAt time.Time, verify FenceVerify) error {
+ if ctx == nil || verify == nil || len(receipt) == 0 || evidenceAt.IsZero() {
+ return ErrInvalidInput
+ }
+ if err := verify(ctx, target, receipt, evidenceAt); err != nil {
+ return err
+ }
+ return g.locked(ctx, target.Host, func(d *recordDir) error {
+ r, err := d.read(target.Host)
+ if err != nil {
+ return err
+ }
+ for _, old := range r.FenceJournal {
+ if old.Target == target {
+ if old.EvidenceAt.Equal(evidenceAt) && bytes.Equal(old.Bytes, receipt) {
+ return d.sync(target.Host)
+ }
+ return ErrReceiptChanged
+ }
+ }
+ if r.ActiveFence == nil || *r.ActiveFence != target {
+ return ErrFenceTarget
+ }
+ r.FenceJournal = append(r.FenceJournal, FenceReceipt{Target: target, Bytes: append([]byte(nil), receipt...), EvidenceAt: evidenceAt})
+ return d.write(r)
+ })
+}
+
+type CleanupVerify func(context.Context, FenceReceipt) error
+
+// CleanupWith authorizes from the persisted fence journal, so claim loss,
+// control-store outage, a free claim, or a successor cannot widen its target.
+func (g Guard) CleanupWith(ctx context.Context, target FenceTarget, verify CleanupVerify, fn func() error) error {
+ return g.cleanupWith(ctx, target, verify, fn, false)
+}
+
+// CleanupRetireInstallationWith retires only an exact still-current target
+// before cleanup effects. A successor record is preserved byte-for-byte.
+func (g Guard) CleanupRetireInstallationWith(ctx context.Context, target FenceTarget, verify CleanupVerify, fn func() error) error {
+ return g.cleanupWith(ctx, target, verify, fn, true)
+}
+
+func (g Guard) cleanupWith(ctx context.Context, target FenceTarget, verify CleanupVerify, fn func() error, retire bool) error {
+ if ctx == nil || verify == nil || fn == nil {
+ return ErrInvalidInput
+ }
+ var receipt FenceReceipt
+ err := g.locked(ctx, target.Host, func(d *recordDir) error {
+ r, err := d.read(target.Host)
+ if err != nil {
+ return err
+ }
+ for _, candidate := range r.FenceJournal {
+ if candidate.Target == target {
+ receipt = candidate
+ return nil
+ }
+ }
+ return ErrNoFenceReceipt
+ })
+ if err != nil {
+ return err
+ }
+ if err := verify(ctx, receipt); err != nil {
+ return err
+ }
+ return g.locked(ctx, target.Host, func(d *recordDir) error {
+ r, err := d.read(target.Host)
+ if err != nil {
+ return err
+ }
+ found := false
+ for _, candidate := range r.FenceJournal {
+ if candidate.Target == target && candidate.EvidenceAt.Equal(receipt.EvidenceAt) && bytes.Equal(candidate.Bytes, receipt.Bytes) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return ErrNoFenceReceipt
+ }
+ if retire && r.Incarnation == target.Incarnation && r.ActiveStamp == target.Stamp {
+ r.retire()
+ if err := d.write(r); err != nil {
+ return err
+ }
+ }
+ if err := fn(); err != nil {
+ return err
+ }
+ return d.sync(target.Host)
+ })
+}
diff --git a/internal/hostguard/fence_test.go b/internal/hostguard/fence_test.go
new file mode 100644
index 000000000..dc429ac64
--- /dev/null
+++ b/internal/hostguard/fence_test.go
@@ -0,0 +1,229 @@
+//go:build unix
+
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+func fenceFixture(t *testing.T) (*guardFixture, FenceTarget, Captured) {
+ t.Helper()
+ f := newGuardFixture(t)
+ f.obs.Claim.Holder.Session = 7
+ f.obs.Claim.Op.ID = "operation-1"
+ f.obs.Claim.Op.Phase = "serving"
+ if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ captured, err := Capture(f.g.DataDir, f.obs.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := FenceTarget{FenceID: "fence-1", Host: f.obs.Host, Incarnation: "inc", Stamp: 1, Session: 7,
+ Op: "operation-1", Phase: "serving", Box: "uk", Enrollment: "enrollment-1",
+ BootID: "boot-1", ControllerID: "controller-1", ClaimETag: "one", DirectoryRevision: "directory-9"}
+ return f, target, captured
+}
+
+func TestBeginFenceDeniesOnlyExactCurrentAuthority(t *testing.T) {
+ f, target, captured := fenceFixture(t)
+ bad := target
+ bad.ClaimETag = "stale"
+ if _, err := f.g.BeginFence(t.Context(), bad); !errors.Is(err, ErrFenceTarget) {
+ t.Fatalf("stale begin = %v", err)
+ }
+ if _, err := f.g.BeginFence(t.Context(), target); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.g.BeginFence(t.Context(), target); err != nil { // exact replay
+ t.Fatal(err)
+ }
+ retry := target
+ retry.FenceID = "new-id-must-not-replace-durable-id"
+ got, err := f.g.BeginFence(t.Context(), retry)
+ if err != nil || got != target {
+ t.Fatalf("begin retry = %+v, %v; want original %+v", got, err, target)
+ }
+ called := false
+ err = f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil })
+ if !errors.Is(err, ErrFenced) || called {
+ t.Fatalf("guard under fence = %v, called=%v", err, called)
+ }
+ f.obs.Claim.Op.Phase = "next-phase"
+ if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }); !errors.Is(err, ErrFenced) {
+ t.Fatalf("phase change bypassed fence: %v", err)
+ }
+
+ // A later writer session is a different authority tuple. The old deny
+ // remains durable for cleanup but cannot deny the successor session.
+ f.obs.Claim.Holder.Session = 8
+ if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }); err != nil || !called {
+ t.Fatalf("new session guard = %v, called=%v", err, called)
+ }
+}
+
+func TestBeginFenceCannotOverwriteUnsettledTarget(t *testing.T) {
+ f, first, _ := fenceFixture(t)
+ if _, err := f.g.BeginFence(t.Context(), first); err != nil {
+ t.Fatal(err)
+ }
+ second := first
+ second.FenceID = "fence-2"
+ second.Phase = "next-phase"
+ second.ClaimETag = "two"
+ second.DirectoryRevision = "directory-10"
+ f.obs.ETag = "two"
+ f.obs.Claim.Op.Phase = "next-phase"
+ if _, err := f.g.BeginFence(t.Context(), second); !errors.Is(err, ErrFenceActive) {
+ t.Fatalf("unsettled overwrite = %v", err)
+ }
+ at := time.Date(2026, 10, 6, 14, 30, 0, 0, time.UTC)
+ if err := f.g.CompleteFence(t.Context(), first, []byte("first receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.g.BeginFence(t.Context(), second); err != nil {
+ t.Fatalf("completed predecessor blocked successor fence: %v", err)
+ }
+ if err := f.g.CleanupWith(t.Context(), first, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ r, err := readRecord(f.g.DataDir, first.Host)
+ if err != nil || r.ActiveFence == nil || *r.ActiveFence != second || len(r.FenceJournal) != 1 {
+ t.Fatalf("predecessor cleanup damaged successor fence: %+v, %v", r, err)
+ }
+}
+
+func TestCompleteFenceVerificationFailureAndImmutableReplay(t *testing.T) {
+ f, target, captured := fenceFixture(t)
+ if _, err := f.g.BeginFence(t.Context(), target); err != nil {
+ t.Fatal(err)
+ }
+ failure := errors.New("root rejected evidence")
+ at := time.Date(2026, 10, 6, 14, 0, 0, 0, time.UTC)
+ if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return failure }); !errors.Is(err, failure) {
+ t.Fatalf("failed verification = %v", err)
+ }
+ r, err := readRecord(f.g.DataDir, target.Host)
+ if err != nil || len(r.FenceJournal) != 0 || r.ActiveFence == nil {
+ t.Fatalf("failed verification persisted evidence: %+v, %v", r, err)
+ }
+ verify := func(ctx context.Context, got FenceTarget, _ []byte, _ time.Time) error {
+ // Verification is outside the flock: reacquiring it completes rather
+ // than deadlocking. The installed deny is visible to the nested guard.
+ return f.g.With(ctx, got.Host, captured, allow, func() error { return nil })
+ }
+ if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); !errors.Is(err, ErrFenced) {
+ t.Fatalf("nested verifier = %v", err)
+ }
+ verify = func(context.Context, FenceTarget, []byte, time.Time) error { return nil }
+ if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); err != nil {
+ t.Fatalf("exact replay = %v", err)
+ }
+ if err := f.g.CompleteFence(t.Context(), target, []byte("changed"), at, verify); !errors.Is(err, ErrReceiptChanged) {
+ t.Fatalf("changed replay = %v", err)
+ }
+}
+
+func TestCleanupUsesJournalDuringControlOutage(t *testing.T) {
+ f, target, _ := fenceFixture(t)
+ if _, err := f.g.BeginFence(t.Context(), target); err != nil {
+ t.Fatal(err)
+ }
+ at := time.Date(2026, 10, 6, 15, 0, 0, 0, time.UTC)
+ if err := f.g.CompleteFence(t.Context(), target, []byte{0, 1, 2, 255}, at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ f.err = errors.New("control store unavailable")
+ verified, cleaned := false, false
+ err := f.g.CleanupWith(t.Context(), target, func(_ context.Context, receipt FenceReceipt) error {
+ verified = string(receipt.Bytes) == string([]byte{0, 1, 2, 255}) && receipt.EvidenceAt.Equal(at)
+ return nil
+ }, func() error { cleaned = true; return nil })
+ if err != nil || !verified || !cleaned {
+ t.Fatalf("cleanup = %v verified=%v cleaned=%v", err, verified, cleaned)
+ }
+ r, err := readRecord(f.g.DataDir, target.Host)
+ if err != nil || r.ActiveFence == nil || len(r.FenceJournal) != 1 {
+ t.Fatalf("cleaned record = %+v, %v", r, err)
+ }
+ f.err = nil
+ called := false
+ if err := f.g.With(t.Context(), target.Host, hostguardCaptured(target), allow, func() error { called = true; return nil }); !errors.Is(err, ErrFenced) || called {
+ t.Fatalf("cleanup reopened fenced session: %v called=%v", err, called)
+ }
+}
+
+func TestCleanupOldFencePreservesSuccessor(t *testing.T) {
+ f, target, _ := fenceFixture(t)
+ if _, err := f.g.BeginFence(t.Context(), target); err != nil {
+ t.Fatal(err)
+ }
+ at := time.Date(2026, 10, 6, 16, 0, 0, 0, time.UTC)
+ if err := f.g.CompleteFence(t.Context(), target, []byte("root receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ f.obs.ETag = "two"
+ f.obs.Claim.Holder.Incarnation = "successor"
+ f.obs.Claim.Holder.Stamp = 2
+ f.obs.Claim.Holder.Session = 8
+ f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, ID: "operation-2", Phase: "serving", Exec: "uk"}
+ if err := f.g.Activate(t.Context(), target.Host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.g.CleanupWith(t.Context(), target, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ r, err := readRecord(f.g.DataDir, target.Host)
+ if err != nil || r.Incarnation != "successor" || r.ActiveStamp != 2 || r.ActiveFence == nil || len(r.FenceJournal) != 1 {
+ t.Fatalf("successor after cleanup = %+v, %v", r, err)
+ }
+}
+
+func hostguardCaptured(target FenceTarget) Captured {
+ return Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}
+}
+
+func TestBeginFenceResumesAfterUnrelatedClaimCAS(t *testing.T) {
+ f, target, _ := fenceFixture(t)
+ if _, err := f.g.BeginFence(t.Context(), target); err != nil {
+ t.Fatal(err)
+ }
+ f.obs.ETag = "bookkeeping"
+ if _, err := f.g.BeginFence(t.Context(), target); !errors.Is(err, ErrFenceTarget) {
+ t.Fatalf("stale request: %v", err)
+ }
+ fresh := target
+ fresh.ClaimETag = f.obs.ETag
+ fresh.FenceID = "retry"
+ got, err := f.g.BeginFence(t.Context(), fresh)
+ if err != nil || got != target {
+ t.Fatalf("resume=%+v %v", got, err)
+ }
+ at := time.Unix(1, 0).UTC()
+ if err := f.g.CompleteFence(t.Context(), got, []byte("original receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ receipt, err := f.g.FenceReceiptFor(t.Context(), target)
+ if err != nil || string(receipt.Bytes) != "original receipt" || receipt.Target != target {
+ t.Fatalf("receipt=%+v %v", receipt, err)
+ }
+ for _, change := range []func(*FenceTarget){func(v *FenceTarget) { v.Session++ }, func(v *FenceTarget) { v.Op = "another" }, func(v *FenceTarget) { v.ControllerID = "another" }} {
+ g, old, _ := fenceFixture(t)
+ if _, err := g.g.BeginFence(t.Context(), old); err != nil {
+ t.Fatal(err)
+ }
+ fresh := old
+ change(&fresh)
+ if _, err := g.g.BeginFence(t.Context(), fresh); err == nil {
+ t.Fatal("changed authority resumed")
+ }
+ }
+}
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go
index 09aac8f0b..04b4be015 100644
--- a/internal/hostguard/guard.go
+++ b/internal/hostguard/guard.go
@@ -22,6 +22,7 @@ var (
ErrNotParticipant = errors.New("hostguard: box is not authorized to activate")
ErrRetiredStamp = errors.New("hostguard: installation stamp retired")
ErrOldStamp = errors.New("hostguard: installation stamp predates local history")
+ ErrFenced = errors.New("hostguard: execution denied by durable fence")
)
type ClaimReader interface {
@@ -73,35 +74,43 @@ func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) erro
}
func (g Guard) fresh(ctx context.Context, host string) (*hostclaim.Claim, error) {
- if err := ctx.Err(); err != nil {
+ obs, err := g.freshObserved(ctx, host)
+ if err != nil {
return nil, err
}
+ return obs.Claim, nil
+}
+
+func (g Guard) freshObserved(ctx context.Context, host string) (hostclaim.Observed, error) {
+ if err := ctx.Err(); err != nil {
+ return hostclaim.Observed{}, err
+ }
if g.Claims == nil {
- return nil, ErrNoClaims
+ return hostclaim.Observed{}, ErrNoClaims
}
obs, err := g.Claims.Read(ctx, host)
if err != nil {
- return nil, err
+ return hostclaim.Observed{}, err
}
if err := ctx.Err(); err != nil {
- return nil, err
+ return hostclaim.Observed{}, err
}
if obs.Host != host {
- return nil, ErrMalformedClaim
+ return hostclaim.Observed{}, ErrMalformedClaim
}
if obs.Absent {
- return nil, ErrNoClaim
+ return hostclaim.Observed{}, ErrNoClaim
}
if obs.Claim == nil || obs.Claim.Host != host {
- return nil, ErrMalformedClaim
+ return hostclaim.Observed{}, ErrMalformedClaim
}
if obs.Claim.Holder == nil {
- return nil, ErrNoHolder
+ return hostclaim.Observed{}, ErrNoHolder
}
if obs.Claim.Holder.Incarnation == "" {
- return nil, ErrMalformedClaim
+ return hostclaim.Observed{}, ErrMalformedClaim
}
- return obs.Claim, nil
+ return obs, nil
}
func (g Guard) With(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error) error {
@@ -132,6 +141,14 @@ func (g Guard) With(ctx context.Context, host string, captured Captured, check f
if captured.Stamp != r.ActiveStamp {
return ErrStampMismatch
}
+ if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) {
+ return ErrFenced
+ }
+ for _, receipt := range r.FenceJournal {
+ if receipt.Target.matchesSession(c, captured) {
+ return ErrFenced
+ }
+ }
if fn == nil {
return ErrInvalidInput
}
@@ -170,8 +187,12 @@ func (g Guard) Activate(ctx context.Context, host, box string, participant func(
if err != nil && !errors.Is(err, ErrNoRecord) {
return err
}
- if errors.Is(err, ErrNoRecord) || r.Incarnation != c.Holder.Incarnation {
+ if errors.Is(err, ErrNoRecord) {
r = record{V: 1, Host: host, Incarnation: c.Holder.Incarnation, RetiredStamps: []uint64{}}
+ } else if r.Incarnation != c.Holder.Incarnation {
+ // Fence evidence belongs to the machine, not the successor holder.
+ // Preserve it so delayed cleanup can prove and retire only its target.
+ r = record{V: 1, Host: host, Incarnation: c.Holder.Incarnation, RetiredStamps: []uint64{}, ActiveFence: r.ActiveFence, FenceJournal: r.FenceJournal}
} else {
for _, s := range r.RetiredStamps {
if stamp == s {
diff --git a/internal/hostguard/record.go b/internal/hostguard/record.go
index bee23a619..7de7237f8 100644
--- a/internal/hostguard/record.go
+++ b/internal/hostguard/record.go
@@ -26,11 +26,13 @@ type Captured struct {
}
type record struct {
- V int `json:"v"`
- Host string `json:"host"`
- Incarnation string `json:"incarnation"`
- ActiveStamp uint64 `json:"active_stamp"`
- RetiredStamps []uint64 `json:"retired_stamps"`
+ V int `json:"v"`
+ Host string `json:"host"`
+ Incarnation string `json:"incarnation"`
+ ActiveStamp uint64 `json:"active_stamp"`
+ RetiredStamps []uint64 `json:"retired_stamps"`
+ ActiveFence *FenceTarget `json:"active_fence,omitempty"`
+ FenceJournal []FenceReceipt `json:"fence_journal,omitempty"`
}
func (r record) validate(host string) error {
@@ -44,6 +46,19 @@ func (r record) validate(host string) error {
}
previous = s
}
+ if r.ActiveFence != nil {
+ if err := r.ActiveFence.validate(host); err != nil {
+ return err
+ }
+ }
+ for i := range r.FenceJournal {
+ if err := r.FenceJournal[i].validate(host); err != nil {
+ return err
+ }
+ if i > 0 && r.FenceJournal[i-1].Target == r.FenceJournal[i].Target {
+ return ErrMalformedRecord
+ }
+ }
return nil
}
diff --git a/internal/instancectl/peer_tuple_linux.go b/internal/instancectl/peer_tuple_linux.go
new file mode 100644
index 000000000..7015e8e96
--- /dev/null
+++ b/internal/instancectl/peer_tuple_linux.go
@@ -0,0 +1,64 @@
+//go:build linux
+
+package instancectl
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+)
+
+type AuthenticatedPeer struct {
+ UID uint32
+ PID int
+ ProcessStart, BootID, ControllerID string
+}
+
+func procStart(pid int) (string, error) {
+ b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
+ if err != nil {
+ return "", err
+ }
+ i := strings.LastIndexByte(string(b), ')')
+ if i < 0 {
+ return "", fmt.Errorf("malformed proc stat")
+ }
+ f := strings.Fields(string(b[i+1:]))
+ if len(f) <= 19 {
+ return "", fmt.Errorf("malformed proc stat")
+ }
+ if _, err := strconv.ParseUint(f[19], 10, 64); err != nil {
+ return "", err
+ }
+ return f[19], nil
+}
+
+// AuthenticatePeerTuple binds the accepted Unix peer to kernel identity and
+// rereads starttime to reject PID reuse during collection.
+func AuthenticatePeerTuple(conn net.Conn, expectedUID uint32, expectedPID int, controllerID string) (AuthenticatedPeer, error) {
+ if conn == nil || expectedPID <= 0 || controllerID == "" {
+ return AuthenticatedPeer{}, fmt.Errorf("invalid peer expectation")
+ }
+ u, err := peerCred(conn)
+ if err != nil {
+ return AuthenticatedPeer{}, err
+ }
+ if u.Uid != expectedUID || int(u.Pid) != expectedPID {
+ return AuthenticatedPeer{}, fmt.Errorf("peer identity mismatch")
+ }
+ start, err := procStart(expectedPID)
+ if err != nil {
+ return AuthenticatedPeer{}, err
+ }
+ boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil {
+ return AuthenticatedPeer{}, err
+ }
+ start2, err := procStart(expectedPID)
+ if err != nil || start2 != start {
+ return AuthenticatedPeer{}, fmt.Errorf("peer process changed during authentication")
+ }
+ return AuthenticatedPeer{UID: u.Uid, PID: int(u.Pid), ProcessStart: start, BootID: strings.TrimSpace(string(boot)), ControllerID: controllerID}, nil
+}
diff --git a/internal/instancectl/peer_tuple_linux_test.go b/internal/instancectl/peer_tuple_linux_test.go
new file mode 100644
index 000000000..9600a6795
--- /dev/null
+++ b/internal/instancectl/peer_tuple_linux_test.go
@@ -0,0 +1,30 @@
+//go:build linux
+
+package instancectl
+
+import (
+ "os"
+ "strconv"
+ "testing"
+)
+
+func TestProcStartReadsStableIdentity(t *testing.T) {
+ pid := os.Getpid()
+ a, err := procStart(pid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := procStart(pid)
+ if err != nil || a == "" || a != b {
+ t.Fatalf("start %q %q %v", a, b, err)
+ }
+ if _, err := strconv.ParseUint(a, 10, 64); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProcStartRefusesMissingProcess(t *testing.T) {
+ if _, err := procStart(1 << 30); err == nil {
+ t.Fatal("missing pid accepted")
+ }
+}
diff --git a/internal/instancectl/peer_tuple_other.go b/internal/instancectl/peer_tuple_other.go
new file mode 100644
index 000000000..6d9a12a57
--- /dev/null
+++ b/internal/instancectl/peer_tuple_other.go
@@ -0,0 +1,18 @@
+//go:build !linux
+
+package instancectl
+
+import (
+ "errors"
+ "net"
+)
+
+type AuthenticatedPeer struct {
+ UID uint32
+ PID int
+ ProcessStart, BootID, ControllerID string
+}
+
+func AuthenticatePeerTuple(net.Conn, uint32, int, string) (AuthenticatedPeer, error) {
+ return AuthenticatedPeer{}, errors.ErrUnsupported
+}