package hostactors import ( "context" "crypto/ed25519" "encoding/json" "errors" "math" "os" "reflect" "regexp" "slices" "strings" "time" "github.com/carlosframework/platform/internal/boxid" "github.com/carlosframework/platform/internal/hostquarantine" "github.com/carlosframework/platform/internal/regionname" "github.com/carlosframework/platform/internal/release" ) const Key = "control/host-actors.json" var ErrRoster = errors.New("hostactors: roster authority refused") var token = regexp.MustCompile(`^[0-9a-f]{32}$`) type Cloud = boxid.Cloud type Box struct { EnrolledAt time.Time `json:"enrolled_at,omitzero"` CensusPublicKey ed25519.PublicKey `json:"census_public_key,omitempty"` Enrollment string `json:"enrollment"` Namespace string `json:"namespace"` BoxID string `json:"box_id"` Label string `json:"label"` Region string `json:"region"` Pool string `json:"pool"` Cloud Cloud `json:"cloud"` EvidenceSHA256 string `json:"evidence_sha256"` State string `json:"state"` QuarantineRevision uint64 `json:"quarantine_revision"` LossIDs []string `json:"loss_ids"` // Group is the Auto Scaling group automatic enrollment admitted the box // from (the region's GroupName then); "" for a box an operator // enrolled. It never changes. Group string `json:"group,omitempty"` // Role is the box's placement role (roles.go): "" (default), legacy or // platform. It is the one field besides the state fields a roster // write may change on an enrolled box. Role string `json:"role,omitempty"` } type Console struct { EnrolledAt time.Time `json:"enrolled_at,omitzero"` Compute *ComputeBinding `json:"compute,omitempty"` Enrollment string `json:"enrollment"` InstanceID string `json:"instance_id"` Namespaces []string `json:"namespaces"` DeploymentBucket string `json:"deployment_bucket"` } type Reservation struct { Publication *release.SystemPublication `json:"publication,omitempty"` RequestSHA256 string `json:"request_sha256,omitempty"` UpdateMode string `json:"update_mode,omitempty"` ID string `json:"id"` Host string `json:"host"` OpID string `json:"op_id"` ExpectedClaimETag string `json:"expected_claim_etag"` FloorSHA256 string `json:"floor_sha256"` Actors map[string]string `json:"actors"` Releases map[string]Report `json:"releases"` Hashes map[string][]string `json:"hashes,omitempty"` } type Roster struct { HomeRegion string `json:"home_region,omitempty"` RequestLimitNS int64 `json:"request_limit_ns,omitempty"` EnrollmentAttempts map[string]EnrollmentAttempt `json:"enrollment_attempts,omitempty"` Publication string `json:"publication,omitempty"` FloorSHA256 string `json:"floor_sha256,omitempty"` Regions map[string]Region `json:"regions,omitempty"` V int `json:"v"` DeploymentBucket string `json:"deployment_bucket"` Revision uint64 `json:"revision"` Boxes map[string]Box `json:"boxes"` Consoles map[string]Console `json:"consoles"` Holds map[string]Reservation `json:"holds"` Activations map[string]Reservation `json:"activations"` } type Observed struct { Roster Roster ETag string } type Directory struct { Store release.Store PublicKey ed25519.PublicKey Bucket string } func segment(s string) bool { return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") } func (r Roster) Validate(bucket string) error { if r.V != 1 || bucket == "" || r.DeploymentBucket != bucket || r.Revision == 0 || r.Boxes == nil || r.Consoles == nil || r.Holds == nil || r.Activations == nil { return ErrRoster } if r.HomeRegion != "" && regionname.PlacementRegion(r.HomeRegion) != r.HomeRegion { return ErrRoster } if r.Publication != "" && r.Publication != "preparing" && r.Publication != "published" { return ErrRoster } if (r.Publication == "") != (r.FloorSHA256 == "") { return ErrRoster } ids := map[string]bool{} clouds := map[Cloud]bool{} for key, b := range r.Boxes { if len(b.CensusPublicKey) != 0 && len(b.CensusPublicKey) != ed25519.PublicKeySize { return ErrRoster } if !segment(key) || b.BoxID != key || !token.MatchString(b.Enrollment) || ids[b.Enrollment] || b.Namespace == "" || b.Label == "" || b.Region == "" || b.Pool == "" || b.Cloud.Provider != "aws" || b.Cloud.Account == "" || b.Cloud.Region == "" || b.Cloud.InstanceID == "" || len(b.EvidenceSHA256) != 64 || clouds[b.Cloud] { return ErrRoster } if b.State != "active" && b.State != "quarantined" && b.State != "retired" { return ErrRoster } if !validBoxRole(b.Role) { return ErrRoster } if b.State == "quarantined" && (b.QuarantineRevision == 0 || len(b.LossIDs) == 0) { return ErrRoster } ids[b.Enrollment] = true clouds[b.Cloud] = true } for _, g := range r.Regions { if !validGroupRole(g.Role) || (g.Role != RoleDefault && g.GroupName == "") { return ErrRoster } } for id, c := range r.Consoles { if !segment(id) || !token.MatchString(c.Enrollment) || ids[c.Enrollment] || c.InstanceID != id || c.DeploymentBucket != bucket || len(c.Namespaces) == 0 { return ErrRoster } if c.Compute != nil { b, ok := r.Boxes[c.Compute.Box] if !ok || b.Enrollment != c.Compute.Enrollment { return ErrRoster } } ids[c.Enrollment] = true } for _, reservations := range []map[string]Reservation{r.Holds, r.Activations} { for id, h := range reservations { if !segment(id) || h.ID != id || len(h.FloorSHA256) != 64 || len(h.Actors) == 0 || len(h.Releases) != len(h.Actors) { return ErrRoster } if h.Publication != nil && (h.Publication.ID != id || len(h.RequestSHA256) != 64 || (h.UpdateMode != "promote" && h.UpdateMode != "rollback")) { return ErrRoster } for key, enrollment := range h.Actors { expected := "" if ref, ok := strings.CutPrefix(key, "box:"); ok { expected = r.Boxes[ref].Enrollment } if ref, ok := strings.CutPrefix(key, "console:"); ok { expected = r.Consoles[ref].Enrollment } if expected != enrollment || !ids[enrollment] || h.Releases[key].Enrollment != enrollment { return ErrRoster } } } } for _, h := range r.Holds { for _, a := range r.Activations { if overlaps(h, a) { return ErrRoster } } } return nil } func (d Directory) Read(ctx context.Context) (Observed, error) { if d.Store == nil { return Observed{}, ErrRoster } raw, etag, err := d.Store.Get(ctx, Key) if err != nil { return Observed{}, err } var r Roster if etag == "" || release.VerifyRootEnvelope(d.PublicKey, raw, &r) != nil || r.Validate(d.Bucket) != nil { return Observed{}, ErrRoster } return Observed{r, etag}, nil } func (d Directory) Lookup(ctx context.Context, box string) (hostquarantine.Actor, error) { o, err := d.Read(ctx) if err != nil { return hostquarantine.Actor{}, err } b, ok := o.Roster.Boxes[box] if !ok { return hostquarantine.Actor{}, ErrRoster } fenced, err := d.TerminationFence(ctx, o, box, time.Now()) if err != nil || fenced { return hostquarantine.Actor{}, ErrRoster } return hostquarantine.Actor{Enrollment: b.Enrollment, State: b.State, QuarantineRevision: b.QuarantineRevision}, nil } func (d Directory) Replace(ctx context.Context, sg release.Signer, before Observed, next Roster) (Observed, error) { // The home designation is write-once only with host claims on: claim // operations bind it, so changing it mid-flight would re-point their // alias. With claims off the roster behaves exactly as it always did. claimsOn := strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" if before.ETag == "" || before.Roster.Revision == math.MaxUint64 || claimsOn && before.Roster.HomeRegion != "" && next.HomeRegion != before.Roster.HomeRegion { return Observed{}, ErrRoster } // Assign new enrollment times inside the signed roster CAS. Older entries // keep an unknown time; replacing it with today's time would fabricate evidence. next = Clone(next) var now time.Time if claimsOn { now = time.Now().UTC() } for ref, b := range next.Boxes { if _, exists := before.Roster.Boxes[ref]; !exists { if !b.EnrolledAt.IsZero() { return Observed{}, ErrRoster } b.EnrolledAt = now next.Boxes[ref] = b } } for id, c := range next.Consoles { if old, exists := before.Roster.Consoles[id]; exists && old.Enrollment == c.Enrollment { if !old.EnrolledAt.Equal(c.EnrolledAt) { return Observed{}, ErrRoster } } else { if !c.EnrolledAt.IsZero() { return Observed{}, ErrRoster } c.EnrolledAt = now next.Consoles[id] = c } } if err := d.checkMembership(ctx, before.Roster, next); err != nil { return Observed{}, err } next.Revision = before.Roster.Revision + 1 for ref, old := range before.Roster.Boxes { b, ok := next.Boxes[ref] if !ok { return Observed{}, ErrRoster } a, z := old, b a.State = "" z.State = "" a.QuarantineRevision = 0 z.QuarantineRevision = 0 a.LossIDs = nil z.LossIDs = nil a.Role = "" z.Role = "" if !reflect.DeepEqual(a, z) || b.QuarantineRevision < old.QuarantineRevision { return Observed{}, ErrRoster } if old.State == "retired" && b.State != "retired" { return Observed{}, ErrRoster } } if next.Validate(d.Bucket) != nil { return Observed{}, ErrRoster } wire, err := release.SignRootEnvelope(sg, next) if err != nil { return Observed{}, err } etag, err := d.Store.Put(ctx, Key, wire, before.ETag) if err != nil { return Observed{}, err } return Observed{next, etag}, nil } func Clone(r Roster) Roster { b, _ := json.Marshal(r) var out Roster _ = json.Unmarshal(b, &out) return out } func (d Directory) Quarantine(ctx context.Context, sg release.Signer, o Observed, box, enrollment, loss string) (Observed, error) { r := Clone(o.Roster) b, ok := r.Boxes[box] if !ok || b.Enrollment != enrollment || b.State == "retired" || loss == "" || b.QuarantineRevision == math.MaxUint64 { return Observed{}, ErrRoster } if slices.Contains(b.LossIDs, loss) { return o, nil } b.State = "quarantined" b.QuarantineRevision++ b.LossIDs = append(b.LossIDs, loss) r.Boxes[box] = b return d.Replace(ctx, sg, o, r) } func (d Directory) Resolve(ctx context.Context, name string) (string, Box, error) { o, err := d.Read(ctx) if err != nil { return "", Box{}, err } if b, ok := o.Roster.Boxes[name]; ok { return name, b, nil } var ref string var result Box for key, b := range o.Roster.Boxes { if b.Label == name && b.State != "retired" { if ref != "" { return "", Box{}, ErrRoster } ref = key result = b } } if ref == "" { return "", Box{}, ErrRoster } return ref, result, nil } func overlaps(a, b Reservation) bool { for _, x := range a.Actors { for _, y := range b.Actors { if x == y { return true } } } return false } func (d Directory) Initialize(ctx context.Context, sg release.Signer) error { r := Roster{V: 1, DeploymentBucket: d.Bucket, Revision: 1, Boxes: map[string]Box{}, Consoles: map[string]Console{}, Holds: map[string]Reservation{}, Activations: map[string]Reservation{}} if err := r.Validate(d.Bucket); err != nil { return err } wire, err := release.SignRootEnvelope(sg, r) if err != nil { return err } return release.CreateExact(ctx, d.Store, Key, wire) } func (r Roster) MatchesHome(home string) bool { return home == r.HomeRegion && (home == "" || regionname.PlacementRegion(home) == home) }