hostactors, status: the signed roster, verified enrollment, census and reservations (claim Plan 8b Task 2)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
21 files changed,
+2407
−0
internal/hostactors/activation_complete.go+50 −0internal/hostactors/activation_publication.go+51 −0internal/hostactors/attempt_regression_test.go+103 −0internal/hostactors/authority_regression_test.go+285 −0internal/hostactors/census.go+442 −0internal/hostactors/census_test.go+103 −0internal/hostactors/enroll.go+106 −0internal/hostactors/enroll_test.go+126 −0internal/hostactors/enrollment_confirmation.go+157 −0internal/hostactors/fence_authority.go+51 −0internal/hostactors/invitation.go+94 −0internal/hostactors/lifecycle.go+56 −0internal/hostactors/membership.go+64 −0internal/hostactors/private.go+29 −0internal/hostactors/publication.go+79 −0internal/hostactors/references.go+68 −0internal/hostactors/reservations.go+124 −0internal/hostactors/roster.go+291 −0internal/hostactors/roster_test.go+78 −0internal/status/console_process.go+12 −0internal/status/snapshot.go+38 −0
CI passed — run details
diff --git a/internal/hostactors/activation_complete.go b/internal/hostactors/activation_complete.go| new file mode 100644 |
| index 000000000..5955eaa1f |
| --- /dev/null |
| +++ b/internal/hostactors/activation_complete.go |
| @@ -0,0 +1,50 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "slices" |
| + "strings" |
| + "time" |
| +) |
| + |
| +func (d Directory) ReleaseActivation(ctx context.Context, sg release.Signer, id string, now time.Time) error { |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + h, ok := o.Roster.Activations[id] |
| + if !ok { |
| + return nil |
| + } |
| + floor, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) |
| + if err != nil { |
| + return err |
| + } |
| + evidence := ExactEvidence{PublicKey: d.PublicKey, Store: d.Store, Boxes: d.PrivateBoxReport} |
| + for actor, enrollment := range h.Actors { |
| + ref, ok := strings.CutPrefix(actor, "box:") |
| + if !ok { |
| + return ErrRoster |
| + } |
| + box, ok := o.Roster.Boxes[ref] |
| + if !ok || box.Enrollment != enrollment { |
| + return ErrRoster |
| + } |
| + r, err := d.PrivateBoxReport(ctx, ref, box) |
| + if err != nil { |
| + return err |
| + } |
| + if err = VerifyReport(ctx, evidence, d.PublicKey, floor, r, enrollment, box.Cloud.InstanceID, now); err != nil { |
| + return err |
| + } |
| + if r.ActivationID != id || !r.ActivationComplete || !slices.Contains(h.Hashes[actor], r.BinarySHA256) { |
| + return ErrRoster |
| + } |
| + } |
| + next := Clone(o.Roster) |
| + delete(next.Activations, id) |
| + _, err = d.Replace(ctx, sg, o, next) |
| + return err |
| +} |
diff --git a/internal/hostactors/activation_publication.go b/internal/hostactors/activation_publication.go| new file mode 100644 |
| index 000000000..dd563375a |
| --- /dev/null |
| +++ b/internal/hostactors/activation_publication.go |
| @@ -0,0 +1,51 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "reflect" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func (d Directory) PublishActivation(ctx context.Context, id string) (*release.SystemPointer, error) { |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + h, ok := o.Roster.Activations[id] |
| + if !ok || h.Publication == nil || h.Publication.ID != id { |
| + return nil, ErrRoster |
| + } |
| + return release.PublishSystemPublication(ctx, d.Store, d.PublicKey, *h.Publication) |
| +} |
| + |
| +func (d Directory) CancelActivation(ctx context.Context, sg release.Signer, id string) error { |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + h, ok := o.Roster.Activations[id] |
| + if !ok { |
| + return nil |
| + } |
| + if h.Publication == nil || h.Publication.ID != id { |
| + return ErrRoster |
| + } |
| + if err := release.CancelSystemPublication(ctx, d.Store, sg, *h.Publication); err != nil { |
| + return err |
| + } |
| + fresh, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if _, ok := fresh.Roster.Activations[id]; !ok { |
| + return nil |
| + } |
| + if !reflect.DeepEqual(h, fresh.Roster.Activations[id]) { |
| + return ErrRoster |
| + } |
| + next := Clone(fresh.Roster) |
| + delete(next.Activations, id) |
| + _, err = d.Replace(ctx, sg, fresh, next) |
| + return err |
| +} |
diff --git a/internal/hostactors/attempt_regression_test.go b/internal/hostactors/attempt_regression_test.go| new file mode 100644 |
| index 000000000..cd6c8c364 |
| --- /dev/null |
| +++ b/internal/hostactors/attempt_regression_test.go |
| @@ -0,0 +1,103 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/release" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestEnrollmentRefreshRejectsExpiredSupersededAndConsumedEvidence(t *testing.T) { |
| + ctx := context.Background() |
| + d, sg, o := fixture(t) |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc := []byte(`{"accountId":"123","region":"eu-west-1","instanceId":"i-new"}`) |
| + sum := sha256.Sum256(doc) |
| + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)} |
| + raw, _ := json.Marshal(rec) |
| + v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}} |
| + in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| + next := Clone(o.Roster) |
| + next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}}} |
| + o, err = d.Replace(ctx, sg, o, next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = d.Invite(ctx, sg, in.Reference, raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + o, first, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now().UTC() |
| + confirmation := func(a EnrollmentAttempt, at time.Time) MembershipConfirmation { |
| + c := MembershipConfirmation{PassID: "pass-1", AttemptID: a.AttemptID, ConfirmationID: a.ConfirmationID, EvidenceSHA256: a.EvidenceSHA256, TargetPlacement: "ie", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", InstanceID: "i-new", Account: "123", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", ObservedAt: at, RequestIDs: []string{"asg", "ec2"}} |
| + c.Confirmer.Box = "box" |
| + c.Confirmer.Enrollment = o.Roster.Boxes["box"].Enrollment |
| + c.Confirmer.Region = "ie" |
| + return c |
| + } |
| + save := func(a EnrollmentAttempt, c MembershipConfirmation) { |
| + t.Helper() |
| + wire, _ := json.Marshal(c) |
| + if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + save(first, confirmation(first, now.Add(-2*time.Minute))) |
| + in.AttemptID = first.AttemptID |
| + in.ConfirmationID = first.ConfirmationID |
| + if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil { |
| + t.Fatal("expired evidence admitted") |
| + } |
| + staleRoster := o |
| + o, second, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if first.AttemptID == second.AttemptID || first.ConfirmationID == second.ConfirmationID { |
| + t.Fatal("immutable identity reused") |
| + } |
| + save(second, confirmation(second, now)) |
| + if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil { |
| + t.Fatal("superseded request admitted") |
| + } |
| + if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil { |
| + t.Fatal("superseded IDs admitted while old evidence was still fresh") |
| + } |
| + // Even a still-fresh old confirmation cannot win the stale roster CAS. |
| + if _, err = d.EnrollAutomatic(ctx, sg, staleRoster, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil { |
| + t.Fatal("superseded snapshot admitted") |
| + } |
| + in.AttemptID = second.AttemptID |
| + in.ConfirmationID = second.ConfirmationID |
| + admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !admitted.Roster.EnrollmentAttempts[in.Reference].Consumed { |
| + t.Fatal("attempt not consumed with roster admission") |
| + } |
| + if _, err = d.EnrollAutomatic(ctx, sg, admitted, v, in, raw, "i-new", now); err == nil { |
| + t.Fatal("consumed confirmation replayed") |
| + } |
| + if _, _, err = d.Store.Get(ctx, ConfirmationKey(in.Reference, first.AttemptID, first.ConfirmationID)); err != nil { |
| + t.Fatal("old immutable evidence removed", err) |
| + } |
| +} |
diff --git a/internal/hostactors/authority_regression_test.go b/internal/hostactors/authority_regression_test.go| new file mode 100644 |
| index 000000000..e612cfeca |
| --- /dev/null |
| +++ b/internal/hostactors/authority_regression_test.go |
| @@ -0,0 +1,285 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func writeFence(t *testing.T, d Directory, o Observed, ref string, now time.Time, invalid bool) { |
| + t.Helper() |
| + b := o.Roster.Boxes[ref] |
| + state := "terminated" |
| + if invalid { |
| + state = "stopped" |
| + } |
| + f := map[string]any{"v": 1, "box": ref, "enrollment": b.Enrollment, "cloud": b.Cloud, "state": state, "observed_at": now, "via": "describe", "request_id": "request", "written_by": map[string]string{"box": ref, "enrollment": b.Enrollment}} |
| + raw, _ := json.Marshal(f) |
| + if err := release.CreateExact(context.Background(), d.Store, "control/box-fences/"+b.Enrollment+".json", raw); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestTerminationFenceExcludesActiveBeforeRosterUpdate(t *testing.T) { |
| + for _, invalid := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "valid", true: "invalid"}[invalid], func(t *testing.T) { |
| + d, o, e, f, scope, now := censusFixture(t) |
| + writeFence(t, d, o, "box", now, invalid) |
| + reports, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now) |
| + if invalid { |
| + if err == nil { |
| + t.Fatal("invalid fence treated as absence or exclusion") |
| + } |
| + } else if err != nil || len(reports) != 1 { |
| + t.Fatal(reports, err) |
| + } |
| + if _, err := d.Lookup(context.Background(), "box"); err == nil { |
| + t.Fatal("fenced or invalid actor eligible") |
| + } |
| + if _, err := d.CheckBoxStart(context.Background(), "box"); err == nil { |
| + t.Fatal("fenced or invalid start allowed") |
| + } |
| + scope.Participants = []string{"box"} |
| + if _, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now); err == nil { |
| + t.Fatal("fenced participant allowed") |
| + } |
| + }) |
| + } |
| +} |
| +func boundConsole(o *Observed, e *reportsFixture) { |
| + c := o.Roster.Consoles["console-1"] |
| + c.Compute = &ComputeBinding{Box: "box", Enrollment: e.box.Enrollment} |
| + o.Roster.Consoles["console-1"] = c |
| + e.console.Compute = c.Compute |
| + e.box.ConsoleProcesses = []ConsoleProcess{{ConsoleID: "console-1", Enrollment: c.Enrollment, ProcessID: e.console.ProcessID, BinarySHA256: e.console.BinarySHA256, GuardFloorSHA256: e.box.FloorSHA256, AllLaunchPathsGuarded: true}} |
| +} |
| +func TestConsoleFenceRequiresExactProcessMachineAndBoot(t *testing.T) { |
| + for _, fault := range []string{"good", "missing", "boot", "enrollment", "process", "ambiguous", "console-enrollment", "console-instance"} { |
| + t.Run(fault, func(t *testing.T) { |
| + d, o, e, f, scope, now := censusFixture(t) |
| + boundConsole(&o, &e) |
| + // The starter lives elsewhere; only the bound console can be excluded. |
| + scope.ConsoleID = "starter" |
| + c := o.Roster.Consoles["console-1"] |
| + c.Compute = nil |
| + c.InstanceID = "starter" |
| + c.Enrollment = "dddddddddddddddddddddddddddddddd" |
| + o.Roster.Consoles["starter"] = c |
| + switch fault { |
| + case "missing": |
| + c = o.Roster.Consoles["console-1"] |
| + c.Compute = nil |
| + o.Roster.Consoles["console-1"] = c |
| + case "console-enrollment": |
| + e.console.Enrollment = "wrong" |
| + case "console-instance": |
| + e.console.InstanceID = "wrong" |
| + case "boot": |
| + e.console.BootID = "other" |
| + case "enrollment": |
| + e.console.Compute = &ComputeBinding{Box: "box", Enrollment: "other"} |
| + case "process": |
| + e.box.ConsoleProcesses[0].ProcessID = "other" |
| + case "ambiguous": |
| + e.box.ConsoleProcesses = append(e.box.ConsoleProcesses, e.box.ConsoleProcesses[0]) |
| + } |
| + // Validate the binding independently, then exercise full census below with exact objects. |
| + _, err := verifyCompute(context.Background(), o, e, "console-1", o.Roster.Consoles["console-1"], e.console) |
| + if (err == nil) != (fault == "good") { |
| + t.Fatal(fault, err) |
| + } |
| + if fault != "good" { |
| + return |
| + } |
| + writeFence(t, d, o, "box", now, false) |
| + for id, cc := range o.Roster.Consoles { |
| + r := e.console |
| + if id == "starter" { |
| + r.Actor = id |
| + r.Enrollment = cc.Enrollment |
| + r.InstanceID = id |
| + r.Compute = nil |
| + } |
| + raw, _ := json.Marshal(r) |
| + if err := release.CreateExact(context.Background(), d.Store, "status/consoles/"+id+".json", raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + local := e.console |
| + local.Actor = "starter" |
| + local.Enrollment = c.Enrollment |
| + local.InstanceID = "starter" |
| + local.Compute = nil |
| + exact := ExactEvidence{Store: d.Store, PublicKey: d.PublicKey, Boxes: e.BoxReport, Self: func(context.Context) (Report, error) { return local, nil }} |
| + reports, err := Census(context.Background(), o, exact, d.PublicKey, f, scope, now) |
| + if err != nil || len(reports) != 1 || reports["console:starter"].Actor != "starter" { |
| + t.Fatal(reports, err) |
| + } |
| + }) |
| + } |
| +} |
| +func TestRelocationOnlyNewestGuardedConsoleMayBeStale(t *testing.T) { |
| + for _, fault := range []string{"good", "ordinary", "guard", "floor", "binding", "future", "newer-malformed", "newer-image", "other-operation"} { |
| + t.Run(fault, func(t *testing.T) { |
| + d, o, e, f, scope, now := censusFixture(t) |
| + boundConsole(&o, &e) |
| + e.console.GeneratedAt = now.Add(-time.Hour) |
| + scope.boxRelocation = true |
| + scope.StarterBox = "box" |
| + scope.ConsoleID = "" |
| + switch fault { |
| + case "ordinary", "other-operation": |
| + scope.boxRelocation = false |
| + scope.ConsoleID = "console-1" |
| + case "guard": |
| + e.box.ConsoleProcesses[0].AllLaunchPathsGuarded = false |
| + case "floor": |
| + e.box.ConsoleProcesses[0].GuardFloorSHA256 = "wrong" |
| + case "binding": |
| + e.console.Compute = nil |
| + case "future": |
| + e.console.GeneratedAt = now.Add(time.Minute) |
| + case "newer-image": |
| + e.console.BinarySHA256 = "wrong" |
| + } |
| + raw, _ := json.Marshal(e.console) |
| + if fault == "newer-malformed" { |
| + raw = []byte("{") |
| + } |
| + if err := release.CreateExact(context.Background(), d.Store, "status/consoles/console-1.json", raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + exact := ExactEvidence{Store: d.Store, PublicKey: d.PublicKey, Boxes: e.BoxReport, Self: func(context.Context) (Report, error) { return e.box, nil }} |
| + _, err := Census(context.Background(), o, exact, d.PublicKey, f, scope, now) |
| + if (err == nil) != (fault == "good") { |
| + t.Fatal(fault, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +type brokenFenceStore struct{ release.Store } |
| + |
| +func (s brokenFenceStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasPrefix(key, "control/box-fences/") { |
| + return nil, "", errors.New("fence read unavailable") |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| +func TestFenceAuthorityErrorsAndMismatchesRefuse(t *testing.T) { |
| + for _, fault := range []string{"read", "malformed", "enrollment", "cloud", "writer", "future"} { |
| + t.Run(fault, func(t *testing.T) { |
| + d, o, e, f, scope, now := censusFixture(t) |
| + if fault == "read" { |
| + d.Store = brokenFenceStore{d.Store} |
| + e.d = d |
| + } else { |
| + writeFence(t, d, o, "box", now, false) |
| + key := "control/box-fences/" + o.Roster.Boxes["box"].Enrollment + ".json" |
| + raw, etag, err := d.Store.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var record map[string]any |
| + if err := json.Unmarshal(raw, &record); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch fault { |
| + case "enrollment": |
| + record["enrollment"] = "wrong" |
| + case "cloud": |
| + record["cloud"] = Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "wrong"} |
| + case "writer": |
| + record["written_by"] = map[string]string{"box": "unknown", "enrollment": "wrong"} |
| + case "future": |
| + record["observed_at"] = now.Add(time.Minute) |
| + } |
| + raw, _ = json.Marshal(record) |
| + if fault == "malformed" { |
| + raw = []byte("{") |
| + } |
| + // Test-only mutation creates corrupt authority without allowing production overwrite. |
| + if _, err = d.Store.Put(context.Background(), key, raw, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now); err == nil { |
| + t.Fatal("bad authority admitted census") |
| + } |
| + if _, err := d.Lookup(context.Background(), "box"); err == nil { |
| + t.Fatal("bad authority granted eligibility") |
| + } |
| + if _, err := d.CheckBoxStart(context.Background(), "box"); err == nil { |
| + t.Fatal("bad authority granted start") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestStartRevalidatesReservedActorEligibility(t *testing.T) { |
| + for _, fault := range []string{"good", "quarantined", "retired", "fenced", "malformed", "unreadable", "console-quarantined", "console-fenced"} { |
| + t.Run(fault, func(t *testing.T) { |
| + ctx := t.Context() |
| + d, sg, o := fixture(t) |
| + floor := hostfloor.Floor{Min: 1, MinRelease: "v1.2.3"} |
| + if err := hostfloor.Publish(ctx, d.Store, sg, d.Bucket, floor); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r := Clone(o.Roster) |
| + r.Publication, r.FloorSHA256 = "published", f.Digest |
| + actor, enrollment := "box:box", r.Boxes["box"].Enrollment |
| + if strings.HasPrefix(fault, "console-") { |
| + actor, enrollment = "console:console-1", strings.Repeat("c", 32) |
| + r.Consoles["console-1"] = Console{Enrollment: enrollment, InstanceID: "console-1", Namespaces: []string{"platform"}, DeploymentBucket: d.Bucket, Compute: &ComputeBinding{Box: "box", Enrollment: r.Boxes["box"].Enrollment}} |
| + } |
| + o, err = d.Replace(ctx, sg, o, r) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := hostclaim.Read(ctx, d.Store, "shop.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h := Reservation{ID: "hold", Host: before.Host, OpID: "operation", ExpectedClaimETag: before.ETag, FloorSHA256: f.Digest, Actors: map[string]string{actor: enrollment}, Releases: map[string]Report{actor: {Enrollment: enrollment, GeneratedAt: time.Now()}}} |
| + o, err = d.Reserve(ctx, sg, o, h, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + switch fault { |
| + case "quarantined", "console-quarantined": |
| + _, err = d.Quarantine(ctx, sg, o, "box", o.Roster.Boxes["box"].Enrollment, "loss") |
| + case "retired": |
| + r = Clone(o.Roster) |
| + b := r.Boxes["box"] |
| + b.State = "retired" |
| + r.Boxes["box"] = b |
| + _, err = d.Replace(ctx, sg, o, r) |
| + case "fenced", "console-fenced", "malformed": |
| + writeFence(t, d, o, "box", time.Now(), fault == "malformed") |
| + case "unreadable": |
| + d.Store = brokenFenceStore{d.Store} |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + called := false |
| + _, err = d.Start(ctx, before, h.ID, func(*hostclaim.Claim) error { called = true; return errors.New("stop before writing") }) |
| + if err == nil || called != (fault == "good") { |
| + t.Fatalf("transition called=%v, error=%v", called, err) |
| + } |
| + after, err := hostclaim.Read(ctx, d.Store, before.Host) |
| + if err != nil || before.ETag != after.ETag || before.Absent != after.Absent { |
| + t.Fatal("refusal changed claim", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostactors/census.go b/internal/hostactors/census.go| new file mode 100644 |
| index 000000000..83d870cee |
| --- /dev/null |
| +++ b/internal/hostactors/census.go |
| @@ -0,0 +1,442 @@ |
| +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) |
| +} |
diff --git a/internal/hostactors/census_test.go b/internal/hostactors/census_test.go| new file mode 100644 |
| index 000000000..dfa98acb8 |
| --- /dev/null |
| +++ b/internal/hostactors/census_test.go |
| @@ -0,0 +1,103 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type reportsFixture struct { |
| + d Directory |
| + box, console Report |
| + wire []byte |
| +} |
| + |
| +func (e reportsFixture) BoxReport(context.Context, string, Box) (Report, error) { return e.box, nil } |
| +func (e reportsFixture) ConsoleReport(context.Context, string, Console) (Report, error) { |
| + return e.console, nil |
| +} |
| +func (e reportsFixture) LocalConsole(context.Context) (Report, error) { return e.console, nil } |
| +func (e reportsFixture) Identity(context.Context, string) ([]byte, error) { return e.wire, nil } |
| +func (e reportsFixture) Fence(ctx context.Context, o Observed, ref string, now time.Time) (bool, error) { |
| + return e.d.TerminationFence(ctx, o, ref, now) |
| +} |
| +func censusFixture(t *testing.T) (Directory, Observed, reportsFixture, hostfloor.Verified, Scope, time.Time) { |
| + t.Helper() |
| + ctx := context.Background() |
| + d, sg, o := fixture(t) |
| + now := time.Now().UTC() |
| + tag := "v1.2.3" |
| + hash := strings.Repeat("a", 64) |
| + rel := release.SystemRelease{ConsoleArtifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: "bootstrap/console/build123/carlos-linux-amd64", SHA256: hash}}, Version: "build123", ReleaseTag: tag, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("build123", "linux-amd64"), SHA256: hash}}} |
| + if _, err := release.PromoteSystem(ctx, d.Store, sg, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + wire, _, err := d.Store.Get(ctx, release.SystemPointerKey("router", "stable")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := release.IdentityKey(tag) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.CreateExact(ctx, d.Store, key, wire); err != nil { |
| + t.Fatal(err) |
| + } |
| + floor := hostfloor.Floor{Min: 1, MinRelease: tag} |
| + raw, _ := json.Marshal(floor) |
| + f := hostfloor.Verified{Floor: floor, Digest: hostfloor.Digest(raw)} |
| + o.Roster.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"default"}}} |
| + o.Roster.Publication = "published" |
| + o.Roster.FloorSHA256 = f.Digest |
| + c := Console{Enrollment: strings.Repeat("c", 32), InstanceID: "console-1", Namespaces: []string{"platform"}, DeploymentBucket: d.Bucket} |
| + o.Roster.Consoles["console-1"] = c |
| + box := Report{Component: "router", GeneratedAt: now, Actor: "box", Enrollment: o.Roster.Boxes["box"].Enrollment, InstanceID: "i-box", Release: "build123", ReleaseTag: tag, Platform: "linux-amd64", BinarySHA256: hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: f.Digest, FloorGuardReady: true, HostClaimEnabled: true} |
| + console := box |
| + console.Component = "console" |
| + console.Actor = "console-1" |
| + console.Enrollment = c.Enrollment |
| + console.InstanceID = "console-1" |
| + console.ProcessID = "process-1" |
| + return d, o, reportsFixture{d: d, box: box, console: console, wire: wire}, f, Scope{Namespace: "platform", Regions: []string{"ie"}, Pools: []string{"default"}, ConsoleID: "console-1"}, now |
| +} |
| +func TestCensusRejectsMissingStaleAndWrongImages(t *testing.T) { |
| + for _, field := range []string{"good", "old-box", "old-console", "future", "image", "floor", "flag", "enrollment", "scope", "unknown-participant", "unknown-region", "unknown-pool", "unknown-namespace"} { |
| + t.Run(field, func(t *testing.T) { |
| + d, o, e, f, scope, now := censusFixture(t) |
| + switch field { |
| + case "old-box": |
| + e.box.GeneratedAt = now.Add(-16 * time.Minute) |
| + case "old-console": |
| + e.console.GeneratedAt = now.Add(-16 * time.Minute) |
| + case "future": |
| + e.box.GeneratedAt = now.Add(6 * time.Second) |
| + case "image": |
| + e.box.BinarySHA256 = strings.Repeat("b", 64) |
| + case "floor": |
| + e.console.FloorSHA256 = "" |
| + case "flag": |
| + e.box.HostClaimEnabled = false |
| + case "enrollment": |
| + e.box.Enrollment = "another" |
| + case "scope": |
| + scope.Regions = nil |
| + case "unknown-region": |
| + scope.Regions = []string{"nowhere"} |
| + case "unknown-pool": |
| + scope.Pools = []string{"nowhere"} |
| + case "unknown-namespace": |
| + scope.Namespace = "unknown" |
| + case "unknown-participant": |
| + scope.Participants = []string{"unknown"} |
| + } |
| + _, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now) |
| + if (err == nil) != (field == "good") { |
| + t.Fatal(field, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostactors/enroll.go b/internal/hostactors/enroll.go| new file mode 100644 |
| index 000000000..8db7fd244 |
| --- /dev/null |
| +++ b/internal/hostactors/enroll.go |
| @@ -0,0 +1,106 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "encoding/base64" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type EnrollmentRequest struct { |
| + AttemptID string |
| + ConfirmationID string |
| + Reference string |
| + Namespace string |
| + Label string |
| + Region string |
| + Pool string |
| + AWSRegion string |
| + TofuInstanceID string |
| + ExistingReference bool |
| + Operator string |
| +} |
| + |
| +// Enroll uses operator-approved configuration, not values in the identity |
| +// request. H16/H17's worker confirmation producer belongs to region HA spec 4. |
| +func (d Directory) Enroll(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte) (Observed, error) { |
| + return d.enroll(ctx, sg, o, v, in, raw, false) |
| +} |
| +func (d Directory) enroll(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, automatic bool) (Observed, error) { |
| + if (!automatic && in.Operator == "") || in.AWSRegion == "" || in.Reference == "" { |
| + return Observed{}, ErrRoster |
| + } |
| + referenced, err := d.Referenced(ctx, in.Reference) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + in.ExistingReference = in.ExistingReference || referenced |
| + if automatic && referenced { |
| + return Observed{}, ErrRoster |
| + } |
| + var rec boxid.Record |
| + if json.Unmarshal(raw, &rec) != nil || rec.V != 1 || rec.Box != in.Reference { |
| + return Observed{}, ErrRoster |
| + } |
| + // Verify the IMDS signature without asking the legacy bind object to grant |
| + // enrollment. The label is bound by this root-signed roster write. |
| + var doc struct { |
| + InstanceID string `json:"instanceId"` |
| + } |
| + decoded, err := base64.StdEncoding.DecodeString(rec.IdentityDocument) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + if json.Unmarshal(decoded, &doc) != nil || doc.InstanceID == "" { |
| + return Observed{}, ErrRoster |
| + } |
| + proof := rec |
| + proof.Box = doc.InstanceID |
| + identity, err := v.Verify(proof, raw, nil) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + if identity.Region != in.AWSRegion || (in.ExistingReference && in.TofuInstanceID != identity.InstanceID) { |
| + return Observed{}, ErrRoster |
| + } |
| + cloud := Cloud{"aws", identity.AccountID, identity.Region, identity.InstanceID} |
| + if old, exists := o.Roster.Boxes[in.Reference]; exists { |
| + if old.Cloud == cloud && old.EvidenceSHA256 == hostfloor.Digest(raw) && old.Namespace == in.Namespace && old.Region == in.Region && old.Pool == in.Pool { |
| + return o, nil |
| + } |
| + return Observed{}, ErrRoster |
| + } |
| + enrollment, err := d.enrollmentNonce(ctx, in, raw) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + r := Clone(o.Roster) |
| + if automatic { |
| + a, ok := r.EnrollmentAttempts[in.Reference] |
| + if !ok || a.Consumed || a.AttemptID != in.AttemptID || a.ConfirmationID != in.ConfirmationID { |
| + return Observed{}, ErrRoster |
| + } |
| + a.Consumed = true |
| + r.EnrollmentAttempts[in.Reference] = a |
| + } |
| + r.Boxes[in.Reference] = Box{Enrollment: enrollment, Namespace: in.Namespace, BoxID: in.Reference, Label: in.Label, Region: in.Region, Pool: in.Pool, Cloud: cloud, EvidenceSHA256: hostfloor.Digest(raw), State: "active", LossIDs: []string{}} |
| + return d.Replace(ctx, sg, o, r) |
| +} |
| + |
| +func (d Directory) EnrollAWS(ctx context.Context, sg release.Signer, o Observed, in EnrollmentRequest, raw []byte) (Observed, error) { |
| + region, ok := o.Roster.Regions[in.Region] |
| + if !ok || region.Account == "" || region.AWSRegion == "" { |
| + return Observed{}, ErrRoster |
| + } |
| + if err := validateScope(o.Roster, Scope{Namespace: in.Namespace, Regions: []string{in.Region}, Pools: []string{in.Pool}}); err != nil { |
| + return Observed{}, err |
| + } |
| + certs, err := boxid.EmbeddedCerts() |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + in.AWSRegion = region.AWSRegion |
| + return d.Enroll(ctx, sg, o, boxid.Verifier{Account: region.Account, Certs: certs}, in, raw) |
| +} |
diff --git a/internal/hostactors/enroll_test.go b/internal/hostactors/enroll_test.go| new file mode 100644 |
| index 000000000..6e16251ae |
| --- /dev/null |
| +++ b/internal/hostactors/enroll_test.go |
| @@ -0,0 +1,126 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "testing" |
| +) |
| + |
| +func TestEnrollmentUsesSignedCloudAndRefusesRebinding(t *testing.T) { |
| + d, sg, o := fixture(t) |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cert := &x509.Certificate{PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA} |
| + document := []byte(`{"accountId":"123","region":"eu-west-1","instanceId":"i-new"}`) |
| + sum := sha256.Sum256(document) |
| + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := boxid.Record{V: 1, Box: "new-label", IdentityDocument: base64.StdEncoding.EncodeToString(document), IdentitySignature: base64.StdEncoding.EncodeToString(signature)} |
| + raw, _ := json.Marshal(rec) |
| + v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": cert}} |
| + in := EnrollmentRequest{Reference: rec.Box, Namespace: "platform", Label: rec.Box, Region: "ie", Pool: "default", AWSRegion: "eu-west-1", Operator: "operator", ExistingReference: true, TofuInstanceID: "i-new"} |
| + for _, wrong := range []string{"region", "tofu", "operator", "signature"} { |
| + request := in |
| + body := raw |
| + switch wrong { |
| + case "region": |
| + request.AWSRegion = "us-east-1" |
| + case "tofu": |
| + request.TofuInstanceID = "i-other" |
| + case "operator": |
| + request.Operator = "" |
| + case "signature": |
| + bad := rec |
| + bad.IdentitySignature = base64.StdEncoding.EncodeToString([]byte("wrong")) |
| + body, _ = json.Marshal(bad) |
| + } |
| + if _, err = d.Enroll(context.Background(), sg, o, v, request, body); err == nil { |
| + t.Fatal(wrong) |
| + } |
| + } |
| + if _, err = d.Invite(context.Background(), sg, in.Reference, raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + next, err := d.Enroll(context.Background(), sg, o, v, in, raw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got := next.Roster.Boxes[rec.Box] |
| + if got.Cloud.InstanceID != "i-new" || got.BoxID != rec.Box || got.Enrollment == "" { |
| + t.Fatal(got) |
| + } |
| + in.Reference = "box" |
| + rec.Box = "box" |
| + raw, _ = json.Marshal(rec) |
| + if _, err = d.Enroll(context.Background(), sg, next, v, in, raw); err == nil { |
| + t.Fatal("reference rebound") |
| + } |
| +} |
| + |
| +func TestFleetInvitationBindsReferenceToEnrollment(t *testing.T) { |
| + d, sg, o := fixture(t) |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cert := &x509.Certificate{PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA} |
| + document := []byte(`{"accountId":"123","region":"eu-west-1","instanceId":"i-fleet"}`) |
| + sum := sha256.Sum256(document) |
| + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := boxid.Record{V: 1, IdentityDocument: base64.StdEncoding.EncodeToString(document), IdentitySignature: base64.StdEncoding.EncodeToString(signature)} |
| + raw, _ := json.Marshal(rec) |
| + invite, err := d.Invite(context.Background(), sg, "", raw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if invite.Reference != "fleet-"+invite.Enrollment { |
| + t.Fatalf("reference = %q, enrollment = %q", invite.Reference, invite.Enrollment) |
| + } |
| + rec.Box = invite.Reference |
| + bound, _ := json.Marshal(rec) |
| + retry, err := d.Invite(context.Background(), sg, invite.Reference, bound) |
| + if err != nil || retry != invite { |
| + t.Fatalf("retry = %+v, %v", retry, err) |
| + } |
| + if _, err := d.Invite(context.Background(), sg, "fleet-00000000000000000000000000000000", bound); err == nil { |
| + t.Fatal("caller-chosen fleet reference accepted") |
| + } |
| + v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": cert}} |
| + ordinary := rec |
| + ordinary.Box = "ordinary-label" |
| + ordinaryRaw, _ := json.Marshal(ordinary) |
| + if _, err := d.Invite(context.Background(), sg, ordinary.Box, ordinaryRaw); err != nil { |
| + t.Fatal(err) |
| + } |
| + badFleet := EnrollmentRequest{Reference: ordinary.Box, Namespace: "fleet:workers", Label: "fleet", Region: "ie", Pool: "default", AWSRegion: "eu-west-1", Operator: "operator"} |
| + if _, err := d.Enroll(context.Background(), sg, o, v, badFleet, ordinaryRaw); err == nil { |
| + t.Fatal("fleet namespace accepted arbitrary reference") |
| + } |
| + in := EnrollmentRequest{Reference: invite.Reference, Namespace: "fleet:workers", Label: "fleet", Region: "ie", Pool: "default", AWSRegion: "eu-west-1", Operator: "operator"} |
| + next, err := d.Enroll(context.Background(), sg, o, v, in, bound) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := next.Roster.Boxes[invite.Reference]; got.Enrollment != invite.Enrollment || got.BoxID != invite.Reference { |
| + t.Fatal(got) |
| + } |
| + rec.Box = "fleet-ffffffffffffffffffffffffffffffff" |
| + mismatched, _ := json.Marshal(rec) |
| + if _, err := d.Enroll(context.Background(), sg, o, v, in, mismatched); err == nil { |
| + t.Fatal("mismatched fleet evidence accepted") |
| + } |
| +} |
diff --git a/internal/hostactors/enrollment_confirmation.go b/internal/hostactors/enrollment_confirmation.go| new file mode 100644 |
| index 000000000..326d0bb66 |
| --- /dev/null |
| +++ b/internal/hostactors/enrollment_confirmation.go |
| @@ -0,0 +1,157 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "time" |
| +) |
| + |
| +type Region struct { |
| + Namespaces []string `json:"namespaces"` |
| + Pools []string `json:"pools"` |
| + Account string `json:"account"` |
| + AWSRegion string `json:"aws_region"` |
| + GroupName string `json:"group_name"` |
| + GroupARN string `json:"group_arn"` |
| +} |
| +type TerminatedEnrollment struct { |
| + PassID string `json:"pass_id"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + InstanceID string `json:"instance_id"` |
| + State string `json:"state"` |
| + ObservedAt time.Time `json:"observed_at"` |
| + RequestID string `json:"request_id"` |
| +} |
| +type MembershipConfirmation struct { |
| + PassID string `json:"pass_id"` |
| + AttemptID string `json:"attempt_id"` |
| + ConfirmationID string `json:"confirmation_id"` |
| + EvidenceSHA256 string `json:"evidence_sha256"` |
| + |
| + TargetPlacement string `json:"target_placement"` |
| + AWSRegion string `json:"aws_region"` |
| + GroupName string `json:"group_name"` |
| + GroupARN string `json:"group_arn"` |
| + InstanceID string `json:"instance_id"` |
| + Account string `json:"account"` |
| + AvailabilityZoneRegion string `json:"availability_zone_region"` |
| + LifecycleState string `json:"lifecycle_state"` |
| + InstanceState string `json:"instance_state"` |
| + ObservedAt time.Time `json:"observed_at"` |
| + RequestIDs []string `json:"request_ids"` |
| + Confirmer struct { |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + Region string `json:"region"` |
| + } `json:"confirmer"` |
| + TerminatedEnrollments []TerminatedEnrollment `json:"terminated_enrollments"` |
| +} |
| + |
| +// Confirmers write this create-only evidence with read-only ASG/EC2 results. |
| +// This consumer gives H16/H17 their admission boundary; spec 4 owns producing it. |
| +func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, now time.Time) (Observed, error) { |
| + if err := validateScope(o.Roster, Scope{Namespace: in.Namespace, Regions: []string{in.Region}, Pools: []string{in.Pool}}); err != nil { |
| + return Observed{}, err |
| + } |
| + region, ok := o.Roster.Regions[in.Region] |
| + if !ok || region.Account != v.Account || region.AWSRegion != in.AWSRegion || region.GroupARN == "" || region.GroupName == "" || !segment(instance) || !segment(in.Reference) || in.ExistingReference { |
| + return Observed{}, ErrRoster |
| + } |
| + attempt, ok := o.Roster.EnrollmentAttempts[in.Reference] |
| + if !ok || attempt.Consumed || !token.MatchString(in.AttemptID) || !token.MatchString(in.ConfirmationID) || in.AttemptID == in.ConfirmationID || attempt.AttemptID != in.AttemptID || attempt.ConfirmationID != in.ConfirmationID || attempt.InstanceID != instance || attempt.EvidenceSHA256 != hostfloor.Digest(raw) || attempt.Region != in.Region || attempt.Account != region.Account || attempt.AWSRegion != region.AWSRegion || attempt.GroupARN != region.GroupARN || attempt.GroupName != region.GroupName { |
| + return Observed{}, ErrRoster |
| + } |
| + body, _, err := d.Store.Get(ctx, ConfirmationKey(in.Reference, in.AttemptID, in.ConfirmationID)) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + var c MembershipConfirmation |
| + if json.Unmarshal(body, &c) != nil || !segment(c.PassID) || c.AttemptID != attempt.AttemptID || c.ConfirmationID != attempt.ConfirmationID || c.EvidenceSHA256 != attempt.EvidenceSHA256 || c.TargetPlacement != in.Region || c.AWSRegion != region.AWSRegion || c.Account != region.Account || c.GroupARN != region.GroupARN || c.GroupName != region.GroupName || c.InstanceID != instance || c.AvailabilityZoneRegion != region.AWSRegion || (c.LifecycleState != "Pending" && c.LifecycleState != "InService") || (c.InstanceState != "pending" && c.InstanceState != "running") || !freshConfirmation(c.ObservedAt, now) || len(c.RequestIDs) < 2 { |
| + return Observed{}, ErrRoster |
| + } |
| + for _, id := range c.RequestIDs { |
| + if id == "" { |
| + return Observed{}, ErrRoster |
| + } |
| + } |
| + confirmer, ok := o.Roster.Boxes[c.Confirmer.Box] |
| + if !ok || confirmer.State != "active" || confirmer.Enrollment != c.Confirmer.Enrollment || confirmer.Region != c.Confirmer.Region { |
| + return Observed{}, ErrRoster |
| + } |
| + fenced, err := d.TerminationFence(ctx, o, c.Confirmer.Box, now) |
| + if err != nil || fenced { |
| + return Observed{}, ErrRoster |
| + } |
| + if confirmer.Region != in.Region { |
| + seen := map[string]bool{} |
| + for _, read := range c.TerminatedEnrollments { |
| + b, ok := o.Roster.Boxes[read.Box] |
| + if !ok || read.PassID != c.PassID || seen[read.Box] || b.Region != in.Region || b.Enrollment != read.Enrollment || b.Cloud.InstanceID != read.InstanceID || read.State != "terminated" || read.RequestID == "" || !freshConfirmation(read.ObservedAt, now) || read.ObservedAt.Before(c.ObservedAt.Add(-time.Minute)) || read.ObservedAt.After(c.ObservedAt) { |
| + return Observed{}, ErrRoster |
| + } |
| + seen[read.Box] = true |
| + } |
| + for ref, b := range o.Roster.Boxes { |
| + if b.Region == in.Region && (b.State == "active" || b.State == "quarantined") && !seen[ref] { |
| + return Observed{}, ErrRoster |
| + } |
| + } |
| + } |
| + // Pin the verified IMDS identity to the instance that membership proved. |
| + in.TofuInstanceID = instance |
| + in.ExistingReference = true |
| + return d.enroll(ctx, sg, o, v, in, raw, true) |
| +} |
| +func freshConfirmation(at, now time.Time) bool { |
| + return !at.IsZero() && now.Sub(at) <= time.Minute && !at.After(now.Add(5*time.Second)) |
| +} |
| + |
| +type EnrollmentAttempt struct { |
| + AttemptID string `json:"attempt_id"` |
| + ConfirmationID string `json:"confirmation_id"` |
| + InstanceID string `json:"instance_id"` |
| + EvidenceSHA256 string `json:"evidence_sha256"` |
| + Region string `json:"region"` |
| + Account string `json:"account"` |
| + AWSRegion string `json:"aws_region"` |
| + GroupName string `json:"group_name"` |
| + GroupARN string `json:"group_arn"` |
| + Consumed bool `json:"consumed"` |
| +} |
| + |
| +func ConfirmationKey(ref, attempt, confirmation string) string { |
| + return "control/box-enrollment/" + ref + "/" + attempt + "/" + confirmation + ".json" |
| +} |
| + |
| +// BeginEnrollmentAttempt supersedes evidence by CAS in the signed authority. |
| +// A retry after expiry obtains new IDs; old immutable confirmations remain. |
| +func (d Directory) BeginEnrollmentAttempt(ctx context.Context, sg release.Signer, o Observed, in EnrollmentRequest, evidence []byte, instance string) (Observed, EnrollmentAttempt, error) { |
| + region, ok := o.Roster.Regions[in.Region] |
| + if !ok || !segment(in.Reference) || !segment(instance) || region.Account == "" || region.AWSRegion == "" || region.GroupName == "" || region.GroupARN == "" { |
| + return Observed{}, EnrollmentAttempt{}, ErrRoster |
| + } |
| + if _, exists := o.Roster.Boxes[in.Reference]; exists { |
| + return Observed{}, EnrollmentAttempt{}, ErrRoster |
| + } |
| + var nonce [32]byte |
| + if _, err := rand.Read(nonce[:]); err != nil { |
| + return Observed{}, EnrollmentAttempt{}, err |
| + } |
| + a := EnrollmentAttempt{AttemptID: hex.EncodeToString(nonce[:16]), ConfirmationID: hex.EncodeToString(nonce[16:]), InstanceID: instance, EvidenceSHA256: hostfloor.Digest(evidence), Region: in.Region, Account: region.Account, AWSRegion: region.AWSRegion, GroupName: region.GroupName, GroupARN: region.GroupARN} |
| + if a.AttemptID == a.ConfirmationID { |
| + return Observed{}, EnrollmentAttempt{}, ErrRoster |
| + } |
| + next := Clone(o.Roster) |
| + if next.EnrollmentAttempts == nil { |
| + next.EnrollmentAttempts = map[string]EnrollmentAttempt{} |
| + } |
| + next.EnrollmentAttempts[in.Reference] = a |
| + updated, err := d.Replace(ctx, sg, o, next) |
| + return updated, a, err |
| +} |
diff --git a/internal/hostactors/fence_authority.go b/internal/hostactors/fence_authority.go| new file mode 100644 |
| index 000000000..685d0d218 |
| --- /dev/null |
| +++ b/internal/hostactors/fence_authority.go |
| @@ -0,0 +1,51 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "time" |
| +) |
| + |
| +// TerminationFence reads authenticated worker-owned, create-only evidence. |
| +// Object access failures and invalid bindings are errors, never exclusions. |
| +func (d Directory) TerminationFence(ctx context.Context, o Observed, ref string, now time.Time) (bool, error) { |
| + b, ok := o.Roster.Boxes[ref] |
| + if !ok || !token.MatchString(b.Enrollment) || d.Store == nil { |
| + return false, ErrRoster |
| + } |
| + raw, _, err := d.Store.Get(ctx, "control/box-fences/"+b.Enrollment+".json") |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + var f struct { |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + Cloud Cloud `json:"cloud"` |
| + State string `json:"state"` |
| + ObservedAt time.Time `json:"observed_at"` |
| + Via string `json:"via"` |
| + RequestID string `json:"request_id"` |
| + EventID string `json:"event_id"` |
| + WrittenBy struct { |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + } `json:"written_by"` |
| + } |
| + if json.Unmarshal(raw, &f) != nil || f.V != 1 || f.Box != ref || f.Enrollment != b.Enrollment || f.Cloud != b.Cloud || f.State != "terminated" || f.ObservedAt.IsZero() || f.ObservedAt.After(now.Add(5*time.Second)) { |
| + return false, ErrRoster |
| + } |
| + if (f.Via != "describe" || f.RequestID == "") && (f.Via != "event" || f.EventID == "") { |
| + return false, ErrRoster |
| + } |
| + writer, ok := o.Roster.Boxes[f.WrittenBy.Box] |
| + if !ok || writer.Enrollment != f.WrittenBy.Enrollment || writer.Region != b.Region || writer.Cloud.Account != b.Cloud.Account || writer.Cloud.Region != b.Cloud.Region { |
| + return false, ErrRoster |
| + } |
| + return true, nil |
| +} |
diff --git a/internal/hostactors/invitation.go b/internal/hostactors/invitation.go| new file mode 100644 |
| index 000000000..90aa21d6f |
| --- /dev/null |
| +++ b/internal/hostactors/invitation.go |
| @@ -0,0 +1,94 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type Invitation struct { |
| + V int `json:"v"` |
| + Bucket string `json:"bucket"` |
| + Reference string `json:"reference"` |
| + Enrollment string `json:"enrollment"` |
| + EvidenceSHA256 string `json:"evidence_sha256"` |
| +} |
| + |
| +// Invite grants no membership. It gives an enrolled-identity candidate a |
| +// stable nonce with which to install its root guard and publish readiness. |
| +// A fleet caller passes an empty reference, then changes only boxid.Record.Box |
| +// to the returned Reference and marshals it again before publishing or Enroll. |
| +// That field is outside the AWS-signed identity document and signature. |
| +func (d Directory) Invite(ctx context.Context, sg release.Signer, reference string, evidence []byte) (Invitation, error) { |
| + fleet := reference == "" |
| + if !fleet && !segment(reference) { |
| + return Invitation{}, ErrRoster |
| + } |
| + if !fleet { |
| + key := "control/host-actor-invitations/" + reference + ".json" |
| + raw, _, err := d.Store.Get(ctx, key) |
| + if err == nil { |
| + var invite Invitation |
| + if release.VerifyRootEnvelope(d.PublicKey, raw, &invite) != nil || invite.V != 1 || invite.Bucket != d.Bucket || invite.Reference != reference || !token.MatchString(invite.Enrollment) || invite.EvidenceSHA256 != hostfloor.Digest(evidence) || (strings.HasPrefix(reference, "fleet-") && reference != "fleet-"+invite.Enrollment) { |
| + return Invitation{}, ErrRoster |
| + } |
| + return invite, nil |
| + } |
| + if !errors.Is(err, os.ErrNotExist) { |
| + return Invitation{}, err |
| + } |
| + // Fleet references are minted from their enrollment nonce. A caller may |
| + // retry one returned by this function, but cannot choose a fresh one. |
| + if strings.HasPrefix(reference, "fleet-") { |
| + return Invitation{}, ErrRoster |
| + } |
| + } |
| + var id [16]byte |
| + if _, err := rand.Read(id[:]); err != nil { |
| + return Invitation{}, err |
| + } |
| + enrollment := hex.EncodeToString(id[:]) |
| + if fleet { |
| + reference = "fleet-" + enrollment |
| + var rec boxid.Record |
| + if json.Unmarshal(evidence, &rec) != nil || rec.V != 1 { |
| + return Invitation{}, ErrRoster |
| + } |
| + rec.Box = reference |
| + var err error |
| + evidence, err = json.Marshal(rec) |
| + if err != nil { |
| + return Invitation{}, err |
| + } |
| + } |
| + invite := Invitation{1, d.Bucket, reference, enrollment, hostfloor.Digest(evidence)} |
| + raw, err := release.SignRootEnvelope(sg, invite) |
| + if err != nil { |
| + return Invitation{}, err |
| + } |
| + key := "control/host-actor-invitations/" + reference + ".json" |
| + if _, err = d.Store.Put(ctx, key, raw, ""); err != nil { |
| + return Invitation{}, err |
| + } |
| + return invite, nil |
| +} |
| +func (d Directory) enrollmentNonce(ctx context.Context, in EnrollmentRequest, evidence []byte) (string, error) { |
| + key := "control/host-actor-invitations/" + in.Reference + ".json" |
| + raw, _, err := d.Store.Get(ctx, key) |
| + if err != nil { |
| + return "", err |
| + } |
| + var invite Invitation |
| + if release.VerifyRootEnvelope(d.PublicKey, raw, &invite) != nil || invite.V != 1 || invite.Bucket != d.Bucket || invite.Reference != in.Reference || !token.MatchString(invite.Enrollment) || ((strings.HasPrefix(in.Namespace, "fleet:") || strings.HasPrefix(in.Reference, "fleet-")) && in.Reference != "fleet-"+invite.Enrollment) || invite.EvidenceSHA256 != hostfloor.Digest(evidence) { |
| + return "", ErrRoster |
| + } |
| + return invite.Enrollment, nil |
| +} |
diff --git a/internal/hostactors/lifecycle.go b/internal/hostactors/lifecycle.go| new file mode 100644 |
| index 000000000..7bf5c49ce |
| --- /dev/null |
| +++ b/internal/hostactors/lifecycle.go |
| @@ -0,0 +1,56 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "time" |
| +) |
| + |
| +func (d Directory) ReserveLifecycle(ctx context.Context, sg release.Signer, e Evidence, scope Scope, claim hostclaim.Observed, id, op string, now time.Time) (Observed, error) { |
| + if scope.boxRelocation || scope.StarterBox != "" { |
| + return Observed{}, ErrRoster |
| + } |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + floor, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + reports, err := Census(ctx, o, e, d.PublicKey, floor, scope, now) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + actors := map[string]string{} |
| + for key, r := range reports { |
| + actors[key] = r.Enrollment |
| + } |
| + return d.Reserve(ctx, sg, o, Reservation{ID: id, Host: claim.Host, OpID: op, ExpectedClaimETag: claim.ETag, FloorSHA256: floor.Digest, Actors: actors, Releases: reports}, false) |
| +} |
| +func (d Directory) ReleaseFinishedHold(ctx context.Context, sg release.Signer, id string) error { |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + h, ok := o.Roster.Holds[id] |
| + if !ok { |
| + return nil |
| + } |
| + claim, err := hostclaim.Read(ctx, d.Store, h.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if claim.Absent || claim.ETag == h.ExpectedClaimETag { |
| + return ErrRoster |
| + } |
| + if !claim.Claim.Free() && claim.Claim.Op.Kind != hostclaim.KindServing { |
| + return ErrRoster |
| + } |
| + next := Clone(o.Roster) |
| + delete(next.Holds, id) |
| + _, err = d.Replace(ctx, sg, o, next) |
| + return err |
| +} |
diff --git a/internal/hostactors/membership.go b/internal/hostactors/membership.go| new file mode 100644 |
| index 000000000..8867ed132 |
| --- /dev/null |
| +++ b/internal/hostactors/membership.go |
| @@ -0,0 +1,64 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "reflect" |
| + "time" |
| +) |
| + |
| +func (d Directory) checkMembership(ctx context.Context, old, next Roster) error { |
| + if old.Publication == "" { |
| + return nil |
| + } |
| + if next.FloorSHA256 != old.FloorSHA256 || next.Publication == "" { |
| + return ErrRoster |
| + } |
| + changed := !reflect.DeepEqual(old.Boxes, next.Boxes) || !reflect.DeepEqual(old.Consoles, next.Consoles) || !reflect.DeepEqual(old.Regions, next.Regions) |
| + if old.Publication == "preparing" { |
| + if changed { |
| + return ErrRoster |
| + } |
| + return nil |
| + } |
| + if old.Publication != "published" || next.Publication != "published" { |
| + return ErrRoster |
| + } |
| + if !changed { |
| + return nil |
| + } |
| + f, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) |
| + if err != nil || f.Digest != old.FloorSHA256 { |
| + return ErrRoster |
| + } |
| + e := ExactEvidence{PublicKey: d.PublicKey, Store: d.Store, Boxes: d.PrivateBoxReport} |
| + for ref, b := range next.Boxes { |
| + previous, exists := old.Boxes[ref] |
| + if exists && !(previous.State != "active" && b.State == "active") { |
| + continue |
| + } |
| + report, err := d.PrivateBoxReport(ctx, ref, b) |
| + if err != nil { |
| + return err |
| + } |
| + if err = VerifyReport(ctx, e, d.PublicKey, f, report, b.Enrollment, b.Cloud.InstanceID, time.Now()); err != nil { |
| + return err |
| + } |
| + } |
| + for id, c := range next.Consoles { |
| + if previous, exists := old.Consoles[id]; exists && reflect.DeepEqual(previous, c) { |
| + continue |
| + } |
| + report, err := e.ConsoleReport(ctx, id, c) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := verifyCompute(ctx, Observed{Roster: next}, e, id, c, report); err != nil { |
| + return err |
| + } |
| + if err = VerifyReport(ctx, e, d.PublicKey, f, report, c.Enrollment, id, time.Now()); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostactors/private.go b/internal/hostactors/private.go| new file mode 100644 |
| index 000000000..ed2e11ec5 |
| --- /dev/null |
| +++ b/internal/hostactors/private.go |
| @@ -0,0 +1,29 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/status" |
| + "strings" |
| +) |
| + |
| +func (d Directory) PrivateBoxReport(ctx context.Context, ref string, b Box) (Report, error) { |
| + key := "status/boxes/" + ref + ".json" |
| + if strings.HasPrefix(b.Namespace, "fleet:") { |
| + fleet := strings.TrimPrefix(b.Namespace, "fleet:") |
| + if !segment(fleet) || !segment(b.Label) { |
| + return Report{}, ErrRoster |
| + } |
| + key = "fleets/" + fleet + "/status/boxes/" + b.Label + ".json" |
| + } |
| + raw, _, err := d.Store.Get(ctx, key) |
| + if err != nil { |
| + return Report{}, err |
| + } |
| + var box status.Box |
| + if json.Unmarshal(raw, &box) != nil || box.Metrics == nil { |
| + return Report{}, ErrRoster |
| + } |
| + m := box.Metrics |
| + return Report{ConsoleProcesses: m.ConsoleProcesses, Component: "router", SystemChannel: m.SystemChannel, ActivationID: m.ActivationID, ActivationComplete: m.ActivationComplete, GeneratedAt: box.GeneratedAt, Actor: ref, Enrollment: m.Enrollment, InstanceID: m.InstanceID, Release: m.Release, ReleaseTag: m.ReleaseTag, Platform: m.Platform, BinarySHA256: m.BinarySHA256, MovesProtocol: m.MovesProtocol, BootID: m.BootID, FloorSHA256: m.FloorSHA256, FloorGuardReady: m.FloorGuardReady, HostClaimEnabled: m.HostClaimEnabled, RetirementReceiptSHA256: m.RetirementReceiptSHA256}, nil |
| +} |
diff --git a/internal/hostactors/publication.go b/internal/hostactors/publication.go| new file mode 100644 |
| index 000000000..0592c003e |
| --- /dev/null |
| +++ b/internal/hostactors/publication.go |
| @@ -0,0 +1,79 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "time" |
| +) |
| + |
| +// PublishFloor is called by the approved release workflow after the initial |
| +// deployment inventory has been reconciled and every guarded launch path |
| +// installed. It never discovers membership from reports. |
| +func (d Directory) PublishFloor(ctx context.Context, sg release.Signer, floor hostfloor.Floor, e Evidence, now time.Time) error { |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if len(o.Roster.Boxes) == 0 || len(o.Roster.Consoles) == 0 || len(o.Roster.Holds) != 0 || len(o.Roster.Activations) != 0 { |
| + return ErrRoster |
| + } |
| + raw, err := json.Marshal(floor) |
| + if err != nil { |
| + return err |
| + } |
| + prospective := hostfloor.Verified{Floor: floor, Digest: hostfloor.Digest(raw)} |
| + if o.Roster.Publication == "" { |
| + next := Clone(o.Roster) |
| + next.Publication = "preparing" |
| + next.FloorSHA256 = prospective.Digest |
| + o, err = d.Replace(ctx, sg, o, next) |
| + if err != nil { |
| + return err |
| + } |
| + } else if o.Roster.FloorSHA256 != prospective.Digest { |
| + return ErrRoster |
| + } |
| + for ref, b := range o.Roster.Boxes { |
| + // Initial publication requires every enrollment. A retained quarantine |
| + // is resolved by the activation runbook rather than skipped here. |
| + if b.State != "active" { |
| + return ErrRoster |
| + } |
| + r, err := e.BoxReport(ctx, ref, b) |
| + if err != nil { |
| + return err |
| + } |
| + if err = VerifyReport(ctx, e, d.PublicKey, prospective, r, b.Enrollment, b.Cloud.InstanceID, now); err != nil { |
| + return err |
| + } |
| + } |
| + for id, c := range o.Roster.Consoles { |
| + r, err := e.ConsoleReport(ctx, id, c) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := verifyCompute(ctx, o, e, id, c, r); err != nil { |
| + return err |
| + } |
| + if err = VerifyReport(ctx, e, d.PublicKey, prospective, r, c.Enrollment, id, now); err != nil { |
| + return err |
| + } |
| + } |
| + // An inventory move during the scan is a failed publication attempt. |
| + fresh, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.ETag != o.ETag { |
| + return ErrRoster |
| + } |
| + if err = hostfloor.Publish(ctx, d.Store, sg, d.Bucket, floor); err != nil { |
| + return err |
| + } |
| + next := Clone(fresh.Roster) |
| + next.Publication = "published" |
| + _, err = d.Replace(ctx, sg, fresh, next) |
| + return err |
| +} |
diff --git a/internal/hostactors/references.go b/internal/hostactors/references.go| new file mode 100644 |
| index 000000000..2bd440064 |
| --- /dev/null |
| +++ b/internal/hostactors/references.go |
| @@ -0,0 +1,68 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "slices" |
| + "strings" |
| +) |
| + |
| +func (d Directory) Referenced(ctx context.Context, reference string) (bool, error) { |
| + keys, err := d.Store.List(ctx, "control/hosts/") |
| + if err != nil { |
| + return false, err |
| + } |
| + for _, key := range keys { |
| + name := strings.TrimPrefix(key, "control/hosts/") |
| + if strings.Contains(name, "/") { |
| + continue |
| + } |
| + host, ok := strings.CutSuffix(name, ".json") |
| + if !ok { |
| + return false, ErrRoster |
| + } |
| + observed, err := hostclaim.Read(ctx, d.Store, host) |
| + if err != nil { |
| + return false, err |
| + } |
| + if observed.Absent { |
| + return false, ErrRoster |
| + } |
| + c := observed.Claim |
| + _, inventory := c.Inventory[reference] |
| + if inventory || c.Op.Exec == reference || slices.Contains(c.Op.Participants, reference) || slices.Contains(c.Touched.Boxes, reference) || slices.Contains(c.Lost, reference) { |
| + return true, nil |
| + } |
| + if c.Holder != nil { |
| + if c.Holder.WriterBox == reference { |
| + return true, nil |
| + } |
| + for _, a := range c.Holder.Admissions { |
| + if a.Box == reference { |
| + return true, nil |
| + } |
| + } |
| + } |
| + } |
| + keys, err = d.Store.List(ctx, "control/edges/") |
| + if err != nil { |
| + return false, err |
| + } |
| + for _, key := range keys { |
| + raw, _, err := d.Store.Get(ctx, key) |
| + if err != nil { |
| + return false, err |
| + } |
| + var edge struct { |
| + BoxID string `json:"box_id"` |
| + } |
| + if json.Unmarshal(raw, &edge) != nil { |
| + return false, ErrRoster |
| + } |
| + if edge.BoxID == reference || key == "control/edges/"+reference+".json" { |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
diff --git a/internal/hostactors/reservations.go b/internal/hostactors/reservations.go| new file mode 100644 |
| index 000000000..44b98e368 |
| --- /dev/null |
| +++ b/internal/hostactors/reservations.go |
| @@ -0,0 +1,124 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "reflect" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Start attaches the reservation in the same claim CAS as the start intent. |
| +func (d Directory) Start(ctx context.Context, o hostclaim.Observed, id string, tr func(*hostclaim.Claim) error) (hostclaim.Observed, error) { |
| + roster, err := d.Read(ctx) |
| + if err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + h, ok := roster.Roster.Holds[id] |
| + if !ok || h.Host != o.Host || h.ExpectedClaimETag != o.ETag || roster.Roster.Publication != "published" || roster.Roster.FloorSHA256 != h.FloorSHA256 { |
| + return hostclaim.Observed{}, ErrRoster |
| + } |
| + floor, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) |
| + if err != nil || floor.Digest != h.FloorSHA256 { |
| + return hostclaim.Observed{}, ErrRoster |
| + } |
| + for _, report := range h.Releases { |
| + if report.GeneratedAt.IsZero() || time.Since(report.GeneratedAt) > 15*time.Minute || report.GeneratedAt.After(time.Now().Add(5*time.Second)) { |
| + return hostclaim.Observed{}, ErrRoster |
| + } |
| + } |
| + // Reservations pin identity, but never preserve an actor's eligibility. |
| + for actor, enrollment := range h.Actors { |
| + ref, boxActor := strings.CutPrefix(actor, "box:") |
| + if !boxActor { |
| + id, consoleActor := strings.CutPrefix(actor, "console:") |
| + console, exists := roster.Roster.Consoles[id] |
| + if !consoleActor || !exists || console.Enrollment != enrollment { |
| + return hostclaim.Observed{}, ErrRoster |
| + } |
| + if console.Compute == nil { |
| + continue |
| + } |
| + ref, enrollment = console.Compute.Box, console.Compute.Enrollment |
| + } |
| + box, exists := roster.Roster.Boxes[ref] |
| + if !exists || box.Enrollment != enrollment || box.State != "active" { |
| + return hostclaim.Observed{}, ErrRoster |
| + } |
| + fenced, err := d.TerminationFence(ctx, roster, ref, time.Now()) |
| + if err != nil || fenced { |
| + return hostclaim.Observed{}, ErrRoster |
| + } |
| + } |
| + return hostclaim.Update(ctx, d.Store, o, func(c *hostclaim.Claim) error { |
| + if err := tr(c); err != nil { |
| + return err |
| + } |
| + if c.Op.ID != h.OpID || c.Op.Intent == nil { |
| + return ErrRoster |
| + } |
| + c.Op.Intent["actors_hold"] = id |
| + return nil |
| + }) |
| +} |
| + |
| +// CancelHold invalidates the claim ETag before releasing a never-started hold. |
| +// If a delayed start won its CAS, the reread leaves its reservation held. |
| +func (d Directory) CancelHold(ctx context.Context, sg release.Signer, id string) error { |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + h, ok := o.Roster.Holds[id] |
| + if !ok { |
| + return nil |
| + } |
| + claim, err := hostclaim.Read(ctx, d.Store, h.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if !claim.Absent && claim.Claim.Op.Intent["actors_hold"] == id { |
| + return ErrRoster |
| + } |
| + if claim.ETag == h.ExpectedClaimETag { |
| + _, err = hostclaim.Update(ctx, d.Store, claim, func(*hostclaim.Claim) error { return nil }) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| + // Reread both records after the invalidating CAS. No timeout releases a hold. |
| + fresh, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + if !reflect.DeepEqual(fresh.Roster.Holds[id], h) { |
| + return ErrRoster |
| + } |
| + claim, err = hostclaim.Read(ctx, d.Store, h.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if !claim.Absent && claim.Claim.Op.Intent["actors_hold"] == id { |
| + return ErrRoster |
| + } |
| + r := Clone(fresh.Roster) |
| + delete(r.Holds, id) |
| + _, err = d.Replace(ctx, sg, fresh, r) |
| + return err |
| +} |
| +func (d Directory) CheckHold(ctx context.Context, c *hostclaim.Claim, box, enrollment string) error { |
| + if c == nil { |
| + return ErrRoster |
| + } |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + h, ok := o.Roster.Holds[c.Op.Intent["actors_hold"]] |
| + if !ok || h.Host != c.Host || h.OpID != c.Op.ID || h.Actors["box:"+box] != enrollment { |
| + return ErrRoster |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go| new file mode 100644 |
| index 000000000..e81f069ef |
| --- /dev/null |
| +++ b/internal/hostactors/roster.go |
| @@ -0,0 +1,291 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostquarantine" |
| + "github.com/carlosframework/platform/internal/release" |
| + "math" |
| + "reflect" |
| + "regexp" |
| + "slices" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const Key = "control/host-actors.json" |
| + |
| +var ErrRoster = errors.New("hostactors: roster authority refused") |
| +var token = regexp.MustCompile(`^[0-9a-f]{32}$`) |
| + |
| +type Cloud struct { |
| + Provider string `json:"provider"` |
| + Account string `json:"account"` |
| + Region string `json:"region"` |
| + InstanceID string `json:"instance_id"` |
| +} |
| +type Box struct { |
| + 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"` |
| +} |
| +type Console struct { |
| + 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 { |
| + 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.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 !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 b.State == "quarantined" && (b.QuarantineRevision == 0 || len(b.LossIDs) == 0) { |
| + return ErrRoster |
| + } |
| + ids[b.Enrollment] = true |
| + clouds[b.Cloud] = true |
| + } |
| + 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) { |
| + if before.ETag == "" || before.Roster.Revision == math.MaxUint64 { |
| + return Observed{}, ErrRoster |
| + } |
| + 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 |
| + 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) |
| +} |
diff --git a/internal/hostactors/roster_test.go b/internal/hostactors/roster_test.go| new file mode 100644 |
| index 000000000..b772f61a1 |
| --- /dev/null |
| +++ b/internal/hostactors/roster_test.go |
| @@ -0,0 +1,78 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "github.com/carlosframework/platform/internal/release" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func fixture(t *testing.T) (Directory, release.Signer, Observed) { |
| + t.Helper() |
| + pub, key, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s := release.NewDirStore(t.TempDir()) |
| + sg := release.RootSigner(key) |
| + r := Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]Box{"box": {Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "box", Label: "label", Region: "ie", Pool: "default", Cloud: Cloud{"aws", "123", "eu-west-1", "i-box"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}}, Consoles: map[string]Console{}, Holds: map[string]Reservation{}, Activations: map[string]Reservation{}} |
| + raw, err := release.SignRootEnvelope(sg, r) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + etag, err := s.Put(context.Background(), Key, raw, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return Directory{s, pub, "bucket"}, sg, Observed{r, etag} |
| +} |
| +func TestDirectoryFreshQuarantineAndCAS(t *testing.T) { |
| + ctx := context.Background() |
| + d, sg, o := fixture(t) |
| + first, err := d.Lookup(ctx, "box") |
| + if err != nil || first.State != "active" { |
| + t.Fatal(first, err) |
| + } |
| + changed, err := d.Quarantine(ctx, sg, o, "box", first.Enrollment, "loss-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := d.Lookup(ctx, "box") |
| + if err != nil || got.State != "quarantined" || got.QuarantineRevision != 1 { |
| + t.Fatal(got, err) |
| + } |
| + if _, err = d.Quarantine(ctx, sg, o, "box", first.Enrollment, "loss-2"); err == nil { |
| + t.Fatal("stale CAS accepted") |
| + } |
| + next := Clone(changed.Roster) |
| + b := next.Boxes["box"] |
| + b.Cloud.InstanceID = "i-other" |
| + next.Boxes["box"] = b |
| + if _, err = d.Replace(ctx, sg, changed, next); err == nil { |
| + t.Fatal("cloud binding changed") |
| + } |
| +} |
| +func TestReservationsExcludeBothOrders(t *testing.T) { |
| + ctx := context.Background() |
| + for _, activationFirst := range []bool{true, false} { |
| + d, sg, o := fixture(t) |
| + en := o.Roster.Boxes["box"].Enrollment |
| + h := Reservation{ID: "hold", Host: "app.example", OpID: "op", ExpectedClaimETag: "etag", FloorSHA256: strings.Repeat("f", 64), Actors: map[string]string{"box:box": en}, Releases: map[string]Report{"box:box": {Enrollment: en}}} |
| + a := h |
| + a.ID = "activation" |
| + a.Hashes = map[string][]string{"box:box": {strings.Repeat("a", 64), strings.Repeat("b", 64), strings.Repeat("c", 64)}} |
| + first, second := h, a |
| + if activationFirst { |
| + first, second = a, h |
| + } |
| + next, err := d.Reserve(ctx, sg, o, first, activationFirst) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = d.Reserve(ctx, sg, next, second, !activationFirst); err == nil { |
| + t.Fatal("overlap accepted") |
| + } |
| + } |
| +} |
diff --git a/internal/status/console_process.go b/internal/status/console_process.go| new file mode 100644 |
| index 000000000..5950af0f6 |
| --- /dev/null |
| +++ b/internal/status/console_process.go |
| @@ -0,0 +1,12 @@ |
| +package status |
| + |
| +// ConsoleProcess is emitted by the trusted local box reporter after measuring |
| +// the running process and checking all root-owned launch and replacement paths. |
| +type ConsoleProcess struct { |
| + ConsoleID string `json:"console_id"` |
| + Enrollment string `json:"enrollment"` |
| + ProcessID string `json:"process_id"` |
| + BinarySHA256 string `json:"binary_sha256"` |
| + GuardFloorSHA256 string `json:"guard_floor_sha256"` |
| + AllLaunchPathsGuarded bool `json:"all_launch_paths_guarded"` |
| +} |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index e1b76eaeb..021ebafe8 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -39,6 +39,19 @@ import ( |
| // Probes carries everything CollectBox may not read from the registry — |
| // each nil means "unknown", and unknown publishes as null, never a guess. |
| type Probes struct { |
| + ConsoleProcesses []ConsoleProcess |
| + ActivationID string |
| + ActivationComplete bool |
| + Enrollment string |
| + MovesProtocol int |
| + ReleaseTag string |
| + BinarySHA256 string |
| + BootID string |
| + FloorSHA256 string |
| + FloorGuardReady bool |
| + HostClaimEnabled bool |
| + RetirementReceiptSHA256 string |
| + |
| MachineID string // hashed into the label; never serialized |
| // InstanceID is the cloud machine this box runs on (status.InstanceID), |
| // or "" for hardware no cloud owns. It is the console architecture |
| @@ -211,6 +224,19 @@ type SystemState struct { |
| // publicly served fleet.json keeps the banded-words-only commitment. See the |
| // package doc comment for why this block is safe to exist at all. |
| type BoxMetrics struct { |
| + ConsoleProcesses []ConsoleProcess `json:"console_processes,omitempty"` |
| + ActivationID string `json:"activation_id,omitempty"` |
| + ActivationComplete bool `json:"activation_complete,omitempty"` |
| + Enrollment string `json:"enrollment,omitempty"` |
| + MovesProtocol int `json:"moves_protocol,omitempty"` |
| + ReleaseTag string `json:"release_tag,omitempty"` |
| + BinarySHA256 string `json:"binary_sha256,omitempty"` |
| + BootID string `json:"boot_id,omitempty"` |
| + FloorSHA256 string `json:"floor_sha256,omitempty"` |
| + FloorGuardReady bool `json:"floor_guard_ready,omitempty"` |
| + HostClaimEnabled bool `json:"host_claim_enabled,omitempty"` |
| + RetirementReceiptSHA256 string `json:"retirement_receipt_sha256,omitempty"` |
| + |
| MemTotalBytes *int64 `json:"mem_total_bytes,omitempty"` |
| MemAvailBytes *int64 `json:"mem_avail_bytes,omitempty"` |
| Load1 *float64 `json:"load1,omitempty"` |
| @@ -700,6 +726,18 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| len(routeDBBytes) > 0 || len(p.ConfigRefusals) > 0 || p.ConfigGuard != "" || |
| p.Alerts != nil { |
| box.Metrics = &BoxMetrics{ |
| + ConsoleProcesses: p.ConsoleProcesses, |
| + ActivationID: p.ActivationID, ActivationComplete: p.ActivationComplete, |
| + Enrollment: p.Enrollment, |
| + MovesProtocol: p.MovesProtocol, |
| + ReleaseTag: p.ReleaseTag, |
| + BinarySHA256: p.BinarySHA256, |
| + BootID: p.BootID, |
| + FloorSHA256: p.FloorSHA256, |
| + FloorGuardReady: p.FloorGuardReady, |
| + HostClaimEnabled: p.HostClaimEnabled, |
| + RetirementReceiptSHA256: p.RetirementReceiptSHA256, |
| + |
| MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes, |
| Load1: p.Load1, DiskFreeBytes: p.DiskFreeBytes, |
| Release: p.Release, Tunnel: p.Tunnel, |