package hostactors import ( "context" "crypto/ed25519" "encoding/json" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" "reflect" "slices" "time" ) type ComputeBinding struct { Box string `json:"box"` Enrollment string `json:"enrollment"` } type ConsoleProcess = status.ConsoleProcess type Report struct { Compute *ComputeBinding `json:"compute,omitempty"` ConsoleProcesses []ConsoleProcess `json:"console_processes,omitempty"` Component string `json:"component"` SystemChannel string `json:"system_channel,omitempty"` ActivationID string `json:"activation_id,omitempty"` ActivationComplete bool `json:"activation_complete,omitempty"` GeneratedAt time.Time `json:"generated_at"` Actor string `json:"actor"` Enrollment string `json:"enrollment"` InstanceID string `json:"instance_id"` ProcessID string `json:"process_id"` Release string `json:"release"` ReleaseTag string `json:"release_tag"` Platform string `json:"platform"` BinarySHA256 string `json:"binary_sha256"` MovesProtocol int `json:"moves_protocol"` BootID string `json:"boot_id"` FloorSHA256 string `json:"floor_sha256"` FloorGuardReady bool `json:"floor_guard_ready"` HostClaimEnabled bool `json:"host_claim_enabled"` RetirementReceiptSHA256 string `json:"retirement_receipt_sha256,omitempty"` } type Scope struct { // Only CensusBoxRelocation sets this private permission after eligibility checks. boxRelocation bool StarterBox string Namespace string Regions []string Pools []string Participants []string ConsoleID string } type Evidence interface { BoxReport(context.Context, string, Box) (Report, error) ConsoleReport(context.Context, string, Console) (Report, error) LocalConsole(context.Context) (Report, error) Identity(context.Context, string) ([]byte, error) Fence(context.Context, Observed, string, time.Time) (bool, error) } func VerifyReport(ctx context.Context, e Evidence, pub ed25519.PublicKey, f hostfloor.Verified, r Report, enrollment, instance string, now time.Time) error { return verifyReport(ctx, e, pub, f, r, enrollment, instance, now, false) } func verifyReport(ctx context.Context, e Evidence, pub ed25519.PublicKey, f hostfloor.Verified, r Report, enrollment, instance string, now time.Time, allowStale bool) error { age := now.Sub(r.GeneratedAt) if r.GeneratedAt.IsZero() || (!allowStale && age > 15*time.Minute) || age < -5*time.Second || r.Enrollment != enrollment || r.InstanceID != instance || r.BootID == "" || !r.FloorGuardReady || r.FloorSHA256 != f.Digest || r.MovesProtocol < f.Floor.Min { return ErrRoster } wire, err := e.Identity(ctx, r.ReleaseTag) if err != nil { return err } p, err := release.VerifySystemIdentity(pub, wire) if err != nil { return err } if p.Current.ReleaseTag != r.ReleaseTag || p.Current.MovesProtocol != r.MovesProtocol { return ErrRoster } return hostfloor.CheckComponent(pub, f, wire, r.Release, r.Platform, r.BinarySHA256, r.Component) } func Census(ctx context.Context, o Observed, e Evidence, pub ed25519.PublicKey, f hostfloor.Verified, scope Scope, now time.Time) (map[string]Report, error) { if o.Roster.Publication != "published" || o.Roster.FloorSHA256 != f.Digest { return nil, ErrRoster } if scope.Namespace == "" || len(scope.Regions) == 0 || len(scope.Pools) == 0 || (!scope.boxRelocation && scope.ConsoleID == "") || e == nil { return nil, ErrRoster } if err := validateScope(o.Roster, scope); err != nil { return nil, err } reports := map[string]Report{} participants := map[string]bool{} for _, p := range scope.Participants { participants[p] = true } for ref, b := range o.Roster.Boxes { required := participants[ref] || (b.Namespace == scope.Namespace && slices.Contains(scope.Regions, b.Region) && slices.Contains(scope.Pools, b.Pool)) if !required { continue } requiredParticipant := participants[ref] delete(participants, ref) fenced, err := e.Fence(ctx, o, ref, now) if err != nil { return nil, err } if fenced { if requiredParticipant { return nil, ErrRoster } continue } if b.State != "active" { if requiredParticipant { return nil, ErrRoster } if len(b.LossIDs) == 0 || b.QuarantineRevision == 0 { return nil, ErrRoster } exact, ok := e.(ExactEvidence) if !ok { return nil, ErrRoster } found := false for _, id := range b.LossIDs { loss, err := hostclaim.ReadLossRecord(ctx, exact.Store, pub, id) if err != nil { return nil, err } if loss.Record.Box != ref || loss.Record.Enrollment != b.Enrollment { return nil, ErrRoster } if loss.Record.QuarantineRevision == b.QuarantineRevision { found = true } } if !found { return nil, ErrRoster } continue } r, err := e.BoxReport(ctx, ref, b) if err != nil { return nil, err } if r.Component != "router" || r.Actor != ref || !r.HostClaimEnabled { return nil, ErrRoster } if err = VerifyReport(ctx, e, pub, f, r, b.Enrollment, b.Cloud.InstanceID, now); err != nil { return nil, err } reports["box:"+ref] = r } if len(participants) != 0 { return nil, ErrRoster } for id, c := range o.Roster.Consoles { if !slices.Contains(c.Namespaces, scope.Namespace) && id != scope.ConsoleID { continue } r, err := e.ConsoleReport(ctx, id, c) if err != nil { return nil, err } if r.Component != "console" || r.Actor != id || r.ProcessID == "" || !r.HostClaimEnabled { return nil, ErrRoster } bound, err := verifyCompute(ctx, o, e, id, c, r) if err != nil { return nil, err } if c.Compute != nil { fenced, err := e.Fence(ctx, o, c.Compute.Box, now) if err != nil { return nil, err } if fenced { if id == scope.ConsoleID { return nil, ErrRoster } continue } } allowStale := false if scope.boxRelocation && c.Compute != nil { // Re-read the one current private key. No caller-selected historical report. exact, ok := e.(ExactEvidence) if ok { newest, readErr := exact.ConsoleReport(ctx, id, c) if readErr != nil { return nil, readErr } if !reflect.DeepEqual(newest, r) { return nil, ErrRoster } box := o.Roster.Boxes[c.Compute.Box] if VerifyReport(ctx, e, pub, f, bound, box.Enrollment, box.Cloud.InstanceID, now) == nil { for _, p := range bound.ConsoleProcesses { if p.ConsoleID == id && p.GuardFloorSHA256 == f.Digest && p.AllLaunchPathsGuarded { allowStale = true } } } } } if err = verifyReport(ctx, e, pub, f, r, c.Enrollment, id, now, allowStale); err != nil { return nil, err } reports["console:"+id] = r } if scope.boxRelocation { local, err := e.LocalConsole(ctx) // box relocation supplies its actual running router here. published, ok := reports["box:"+scope.StarterBox] if err != nil || !ok || local.Component != "router" || !local.HostClaimEnabled || local.Actor != scope.StarterBox || local.BootID != published.BootID { return nil, ErrRoster } box := o.Roster.Boxes[scope.StarterBox] if err := VerifyReport(ctx, e, pub, f, local, box.Enrollment, box.Cloud.InstanceID, now); err != nil { return nil, err } return reports, nil } own, ok := o.Roster.Consoles[scope.ConsoleID] if !ok { return nil, ErrRoster } local, err := e.LocalConsole(ctx) if err != nil { return nil, err } published, ok := reports["console:"+scope.ConsoleID] if !ok || local.Component != "console" || !local.HostClaimEnabled || local.Actor != scope.ConsoleID || local.ProcessID == "" || local.ProcessID != published.ProcessID { return nil, ErrRoster } if _, err := verifyCompute(ctx, o, e, scope.ConsoleID, own, local); err != nil { return nil, err } if err = VerifyReport(ctx, e, pub, f, local, own.Enrollment, scope.ConsoleID, now); err != nil { return nil, err } return reports, nil } func (d Directory) Reserve(ctx context.Context, sg release.Signer, o Observed, h Reservation, activation bool) (Observed, error) { r := Clone(o.Roster) if h.ID == "" || len(h.Actors) == 0 || len(h.Releases) != len(h.Actors) { return Observed{}, ErrRoster } if _, exists := r.Holds[h.ID]; exists { return Observed{}, ErrRoster } if _, exists := r.Activations[h.ID]; exists { return Observed{}, ErrRoster } if activation { if len(h.Hashes) != len(h.Actors) { return Observed{}, ErrRoster } for key := range h.Actors { if len(h.Hashes[key]) != 3 { return Observed{}, ErrRoster } } for _, other := range r.Holds { if overlaps(h, other) { return Observed{}, ErrRoster } } for _, other := range r.Activations { if h.Publication != nil && other.Publication != nil && h.Publication.Component == other.Publication.Component && h.Publication.Channel == other.Publication.Channel { return Observed{}, ErrRoster } if overlaps(h, other) { return Observed{}, ErrRoster } } r.Activations[h.ID] = h } else { if h.Host == "" || h.OpID == "" { return Observed{}, ErrRoster } for _, other := range r.Activations { if h.Publication != nil && other.Publication != nil && h.Publication.Component == other.Publication.Component && h.Publication.Channel == other.Publication.Channel { return Observed{}, ErrRoster } if overlaps(h, other) { return Observed{}, ErrRoster } } r.Holds[h.ID] = h } return d.Replace(ctx, sg, o, r) } func (d Directory) CheckBoxStart(ctx context.Context, refs ...string) (uint64, error) { o, err := d.Read(ctx) if err != nil { return 0, err } if len(refs) == 0 { return 0, ErrRoster } for _, ref := range refs { b, ok := o.Roster.Boxes[ref] if !ok || b.State != "active" { return 0, ErrRoster } fenced, err := d.TerminationFence(ctx, o, ref, time.Now()) if err != nil || fenced { return 0, ErrRoster } for _, a := range o.Roster.Activations { for _, enrollment := range a.Actors { if enrollment == b.Enrollment { return 0, ErrRoster } } } } return o.Roster.Revision, nil } // ExactEvidence adapts the existing private status object without changing // the public status shape. The supplied readers must use exact private keys. type ExactEvidence struct { PublicKey ed25519.PublicKey Store release.Store Boxes func(context.Context, string, Box) (Report, error) Self func(context.Context) (Report, error) } func (e ExactEvidence) BoxReport(ctx context.Context, id string, b Box) (Report, error) { if e.Boxes == nil { return Report{}, ErrRoster } return e.Boxes(ctx, id, b) } func (e ExactEvidence) LocalConsole(ctx context.Context) (Report, error) { if e.Self == nil { return Report{}, ErrRoster } return e.Self(ctx) } func (e ExactEvidence) ConsoleReport(ctx context.Context, id string, c Console) (Report, error) { if !segment(id) { return Report{}, ErrRoster } raw, _, err := e.Store.Get(ctx, "status/consoles/"+id+".json") if err != nil { return Report{}, err } var r Report if err = json.Unmarshal(raw, &r); err != nil { return Report{}, err } return r, nil } func (e ExactEvidence) Identity(ctx context.Context, tag string) ([]byte, error) { key, err := release.IdentityKey(tag) if err != nil { return nil, err } raw, _, err := e.Store.Get(ctx, key) return raw, err } func (e ExactEvidence) Fence(ctx context.Context, o Observed, ref string, now time.Time) (bool, error) { return (Directory{Store: e.Store, PublicKey: e.PublicKey, Bucket: o.Roster.DeploymentBucket}).TerminationFence(ctx, o, ref, now) } func validateScope(r Roster, scope Scope) error { for _, name := range scope.Regions { region, ok := r.Regions[name] if !ok || region.Account == "" || region.AWSRegion == "" || !slices.Contains(region.Namespaces, scope.Namespace) { return ErrRoster } for _, pool := range scope.Pools { if !slices.Contains(region.Pools, pool) { return ErrRoster } } } return nil } func verifyCompute(ctx context.Context, o Observed, e Evidence, id string, c Console, r Report) (Report, error) { if r.Component != "console" || r.Actor != id || r.Enrollment != c.Enrollment || r.InstanceID != id || r.ProcessID == "" { return Report{}, ErrRoster } if c.Compute == nil { if r.Compute != nil { return Report{}, ErrRoster } return Report{}, nil } binding := *c.Compute box, ok := o.Roster.Boxes[binding.Box] if !ok || binding.Enrollment != box.Enrollment || r.Compute == nil || *r.Compute != binding { return Report{}, ErrRoster } br, err := e.BoxReport(ctx, binding.Box, box) if err != nil { return Report{}, err } if br.Component != "router" || br.Actor != binding.Box || br.Enrollment != box.Enrollment || br.InstanceID != box.Cloud.InstanceID || br.BootID == "" || br.BootID != r.BootID { return Report{}, ErrRoster } matches := 0 for _, p := range br.ConsoleProcesses { if p.ConsoleID != id { continue } matches++ if p.Enrollment != c.Enrollment || p.ProcessID == "" || p.ProcessID != r.ProcessID || p.BinarySHA256 != r.BinarySHA256 { return Report{}, ErrRoster } } if matches != 1 { return Report{}, ErrRoster } return br, nil } // CensusBoxRelocation is the only entry that grants L20's freshness exception. // The caller must be the local relocation controller; it cannot accept a member request. func (d Directory) CensusBoxRelocation(ctx context.Context, e Evidence, f hostfloor.Verified, scope Scope, now time.Time) (map[string]Report, error) { if scope.StarterBox == "" { return nil, ErrRoster } refs := append([]string{scope.StarterBox}, scope.Participants...) revision, err := d.CheckBoxStart(ctx, refs...) if err != nil { return nil, err } o, err := d.Read(ctx) if err != nil || o.Roster.Revision != revision { return nil, ErrRoster } scope.boxRelocation = true scope.ConsoleID = "" scope.Participants = refs return Census(ctx, o, e, d.PublicKey, f, scope, now) }