package hostguard import ( "context" "errors" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/release" ) var ( ErrInvalidInput = errors.New("hostguard: invalid input") ErrNoClaims = errors.New("hostguard: no claim reader") ErrNoClaim = errors.New("hostguard: claim absent") ErrMalformedClaim = errors.New("hostguard: malformed claim observation") ErrNoHolder = errors.New("hostguard: claim has no holder") ErrClaimIncarnation = errors.New("hostguard: captured incarnation differs from claim") ErrRecordIncarnation = errors.New("hostguard: local incarnation differs") ErrInactive = errors.New("hostguard: installation inactive") ErrStampMismatch = errors.New("hostguard: captured stamp is not active") ErrCheckRequired = errors.New("hostguard: identity check required") 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 { Read(context.Context, string) (hostclaim.Observed, error) } type StoreReader struct{ Store release.Store } func (r StoreReader) Read(ctx context.Context, host string) (hostclaim.Observed, error) { if r.Store == nil { return hostclaim.Observed{}, ErrNoClaims } return hostclaim.Read(ctx, r.Store, host) } type Guard struct { Root *RootAuthority FenceView FenceViewProvider DataDir string LockDir string Claims ClaimReader openRecords func(string) (*recordDir, error) } func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) error) error { if err := validateFileHost(host); err != nil { return err } if g.DataDir == "" || ctx == nil { return ErrInvalidInput } if g.Root != nil { if g.Root.store == nil { return ErrInvalidInput } unlock, err := g.Root.store.Lock(ctx, host+".lock") if err != nil { return err } defer unlock() } dir := g.LockDir if dir == "" { dir = "/run/carlos/hostguard" } lockCtx := ctx cancel := func() {} if g.Root != nil { lockCtx, cancel = context.WithTimeout(ctx, 250*time.Millisecond) } defer cancel() unlock, err := acquire(lockCtx, dir, host, g.Root != nil) if err != nil { // Contention and I/O errors are retryable, not hostile evidence. // Only a positively unsafe lock can quarantine an installation. if g.Root != nil && errors.Is(err, ErrUnsafeFile) { r, readErr := g.Root.read(host) if readErr == nil { r.quarantined, r.settled = true, false return errors.Join(err, g.Root.write(r)) } if !errors.Is(readErr, ErrNoRecord) { return errors.Join(err, readErr) } } return err } defer unlock() if g.Root != nil { return fn(&recordDir{root: g.Root}) } open := g.openRecords if open == nil { open = openRecordDir } d, err := open(g.DataDir) if err != nil { return err } defer d.close() return fn(d) } func (g Guard) fresh(ctx context.Context, host string) (*hostclaim.Claim, error) { 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 hostclaim.Observed{}, ErrNoClaims } obs, err := g.Claims.Read(ctx, host) if err != nil { return hostclaim.Observed{}, err } if err := ctx.Err(); err != nil { return hostclaim.Observed{}, err } if obs.Host != host { return hostclaim.Observed{}, ErrMalformedClaim } if obs.Absent { return hostclaim.Observed{}, ErrNoClaim } if obs.Claim == nil || obs.Claim.Host != host { return hostclaim.Observed{}, ErrMalformedClaim } if obs.Claim.Holder == nil { return hostclaim.Observed{}, ErrNoHolder } if obs.Claim.Holder.Incarnation == "" { return hostclaim.Observed{}, ErrMalformedClaim } return obs, nil } func (g Guard) With(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error) error { return g.locked(ctx, host, func(d *recordDir) error { c, err := g.fresh(ctx, host) if err != nil { return err } if (g.Root != nil || g.FenceView != nil) && c.Holder.Stamp != captured.Stamp { return ErrStampMismatch } if g.FenceView != nil { v, err := g.FenceView(ctx, host, captured) if err != nil { return err } if err := v.admit(host, captured, c.Holder.Session); err != nil { return err } } if c.Holder.Incarnation != captured.Incarnation { return ErrClaimIncarnation } if check == nil { return ErrCheckRequired } if err := check(c); err != nil { return err } r, err := d.read(host) if err != nil { return err } if err := r.admits(c, captured); err != nil { return err } if fn == nil { return ErrInvalidInput } if err := ctx.Err(); err != nil { return err } if g.FenceView != nil { v, err := g.FenceView(ctx, host, captured) if err != nil { return err } if err := v.admit(host, captured, c.Holder.Session); err != nil { return err } } return fn() }) } // admits is With's reading of the local record: nil only when the record // lets the captured installation run claim c's session. AdmissionFenced // reads the same, so the two cannot disagree. func (r record) admits(c *hostclaim.Claim, captured Captured) error { if r.quarantined || r.pendingInstallation != nil || r.barrier != nil { return ErrFenced } if r.root && (r.installation.Account != c.Holder.Account || r.installation.App != c.Holder.App) { return ErrRecordIncarnation } if r.Incarnation != captured.Incarnation { return ErrRecordIncarnation } if r.ActiveStamp == 0 { return ErrInactive } if captured.Stamp != r.ActiveStamp { return ErrStampMismatch } if migrationAdmissionDenied(r.migrationDenies, r.FenceJournal, captured, c.Holder.Session) { return ErrFenced } if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) { return ErrFenced } for _, receipt := range r.FenceJournal { if receipt.Target.matchesSession(c, captured) { return ErrFenced } } return nil } func (r *record) retire() { if r.ActiveStamp == 0 { return } if r.root { r.rootRetired = append(r.rootRetired, Captured{Incarnation: r.Incarnation, Stamp: r.ActiveStamp}) } r.RetiredStamps = append(r.RetiredStamps, r.ActiveStamp) if !r.root && len(r.RetiredStamps) > 32 { r.RetiredStamps = r.RetiredStamps[len(r.RetiredStamps)-32:] } r.ActiveStamp = 0 } func (g Guard) Activate(ctx context.Context, host, box string, participant func(*hostclaim.Claim) bool) error { if g.Root != nil { return g.locked(ctx, host, func(d *recordDir) error { c, err := g.fresh(ctx, host) if err != nil { return err } r, err := d.read(host) if err != nil { return err } if r.quarantined || r.pendingInstallation != nil || r.barrier != nil || r.Incarnation != c.Holder.Incarnation || r.ActiveStamp != c.Holder.Stamp || r.ActiveStamp == 0 || r.installation.Box != box { return ErrFenced } return d.sync(host) }) } return g.locked(ctx, host, func(d *recordDir) error { c, err := g.fresh(ctx, host) if err != nil { return err } if box == "" || (c.Op.Exec != box && (participant == nil || !participant(c))) { return ErrNotParticipant } stamp := c.Holder.Stamp if stamp == 0 { return ErrMalformedClaim } r, err := d.read(host) if err != nil && !errors.Is(err, ErrNoRecord) { return err } 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 { return ErrRetiredStamp } } if stamp < r.ActiveStamp { return ErrOldStamp } // Keep refusing trimmed stamps even after retirement sets active to zero. if n := len(r.RetiredStamps); n != 0 && stamp < r.RetiredStamps[n-1] { return ErrOldStamp } if stamp == r.ActiveStamp { return d.sync(host) } r.retire() } r.ActiveStamp = stamp if err := ctx.Err(); err != nil { return err } return d.write(r) }) } func (g Guard) RetireInstallation(ctx context.Context, host string, check func(*hostclaim.Claim) error) error { return g.locked(ctx, host, func(d *recordDir) error { c, err := g.fresh(ctx, host) if err != nil { return err } if check == nil { return ErrCheckRequired } if err := check(c); err != nil { return err } r, err := d.read(host) if err != nil { return err } if r.Incarnation != c.Holder.Incarnation { return ErrRecordIncarnation } if r.ActiveStamp == 0 { return d.sync(host) } r.retire() if err := ctx.Err(); err != nil { return err } return d.write(r) }) } func (g Guard) readLocked(ctx context.Context, host string, fn func(*recordDir) error) error { if g.Root == nil { return g.locked(ctx, host, fn) } if validateFileHost(host) != nil || g.Root.store == nil { return ErrInvalidInput } unlock, err := g.Root.store.Lock(ctx, host+".lock") if err != nil { return err } defer unlock() return fn(&recordDir{root: g.Root}) }