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 { Region string `json:"region,omitempty"` ClaimGen uint64 `json:"claim_gen,omitempty"` ObligationID string `json:"obligation_id,omitempty"` 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"` // RosterRevision is the roster write counter the stop's requester read, // persisted with the fence before any stop effect. It is the one source of // a stop receipt's directory_revision numeral (roster binding N1); the // installation's binding is DirectoryRevision. It is receipt data only: // retry authority ignores it (sameAuthority) and the journal v1 tuple // never hashes it. RosterRevision uint64 `json:"roster_revision,omitempty"` } 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 = "", "" t.ClaimGen, other.ClaimGen = 0, 0 // The receipt numeral is the roster write counter at the request, which // unrelated roster writes move between retries of one stop. The persisted // target keeps the original numeral; a retry never replaces it (B2). t.RosterRevision, other.RosterRevision = 0, 0 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 || !StopAuthorityMatches(c, target) { 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.fenceLocked(ctx, target.Host, Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}, 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) }) } func StopAuthorityMatches(c *hostclaim.Claim, t FenceTarget) bool { if !t.matchesAuthority(c, Captured{Incarnation: t.Incarnation, Stamp: t.Stamp}) { return false } if t.ObligationID == "relocation-release" { if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != "releasing" || c.Op.Exec != t.Box || c.Op.Intent["source_box"] != t.Box || c.Op.Intent["region"] != t.Region { return false } for _, ob := range c.Obligations { if ob.ID == t.ObligationID && ob.Op == t.Op && ob.Kind == "relocation-release" && ob.Park == nil && ob.Box == t.Box && ob.Region == t.Region && ob.Owner == "region:"+t.Region { return true } } return false } if c.Op.Kind != hostclaim.KindServing { // A writer-session stop names no chore, so it is only ever the // landing's own current writer on this box (claim Plan 9 D4). if writerStop(t.ObligationID) { id, ok := LandingWriterStop(c, t.Box, t.Region) if c.Op.Kind == hostclaim.KindRestoring || c.Op.Kind == hostclaim.KindReverting { id, ok = RecoveryWriterStop(c, t.Box, t.Region) } if c.Op.Kind == hostclaim.KindRelocating { id, ok = RelocationWriterStop(c, t.Box, t.Region) } if !ok || id != t.ObligationID { return false } } return c.Op.Exec == t.Box && t.Op != "" && t.Phase != "" } if t.Op != "" || t.Phase != "" || t.ObligationID != "" || t.Region == "" || c.Holder.WriterBox != t.Box || (c.Op.Exec != "" && c.Op.Exec != t.Box) { return false } matched := false for _, a := range c.Holder.Admissions { if a.Session == t.Session { if matched || a.Box != t.Box || a.Region != t.Region || a.Generation != c.Holder.Generation { return false } matched = true } } return matched }