carlos / platform Public

Merge claim activation tools into the integrated Plan 14 branch (not for merge)

carlos system claims runner and installers (automated offline window removed; copy approved). Known regressions under repair: three mount-substitution reboot pairs and the raw .instances boundary check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 8b9895c7085359543ad8b68201a4e4df46b0182e parent 1d12bc3parent c3a2121
29 files changed, +2912 −10
  • docs/site/cli.md +8 −0
  • internal/claimactivation/runner.go +316 −0
  • internal/claimactivation/runner_test.go +321 −0
  • internal/host/activation_command.go +26 −0
  • internal/host/activation_command_linux.go +257 −0
  • internal/host/activation_command_other.go +7 −0
  • internal/host/activation_command_test.go +14 −0
  • internal/host/activation_console.go +37 −0
  • internal/host/activation_console_binary_rootrunner_linux_test.go +16 −0
  • internal/host/activation_console_report_linux.go +336 −0
  • internal/host/activation_console_report_linux_test.go +21 −0
  • internal/host/activation_console_rootrunner_linux_test.go +494 −0
  • internal/host/activation_console_test.go +36 −0
  • internal/host/activation_install.go +68 −0
  • internal/host/activation_install_linux.go +345 −0
  • internal/host/activation_install_rootrunner_linux_test.go +329 −0
  • internal/host/activation_install_test.go +17 −0
  • internal/host/plan14_lifecycle_root_linux_test.go +1 −1
  • internal/hostfence/activation_credentials.go +52 −0
  • internal/hostfence/activation_credentials_test.go +26 −0
  • internal/hostfence/writer.go +9 −2
  • internal/provision/activation_tools_census_test.go +11 −0
  • internal/provision/plan14_p3_census_test.go +1 −1
  • internal/provision/plan14_root_reads_test.go +28 −2
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-activation-tools.tsv +85 −0
  • internal/provision/testdata/plan14-root-reads-p3.tsv +2 −2
  • scripts/activation-tools-root-runner.sh +45 −0
  • system.go +3 −1

CI failed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	300.418s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.620s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.183s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.702s
ok  	github.com/carlosframework/platform/internal/activator	249.861s
ok  	github.com/carlosframework/platform/internal/agentsock	0.016s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.454s
ok  	github.com/carlosframework/platform/internal/apperr	0.083s
ok  	github.com/carlosframework/platform/internal/applog	0.139s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.744s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.015s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.697s
ok  	github.com/carlosframework/platform/internal/changelog	0.307s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.022s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	403.241s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.380s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.021s
ok  	github.com/carlosframework/platform/internal/dbverify	0.011s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.012s
ok  	github.com/carlosframework/platform/internal/docsite	0.011s
ok  	github.com/carlosframework/platform/internal/domains	2.449s
ok  	github.com/carlosframework/platform/internal/doorkey	0.010s
ok  	github.com/carlosframework/platform/internal/economics	0.008s
ok  	github.com/carlosframework/platform/internal/edge	44.974s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.016s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.147s
ok  	github.com/carlosframework/platform/internal/gate	1.691s
ok  	github.com/carlosframework/platform/internal/genselect	0.025s
ok  	github.com/carlosframework/platform/internal/geo	0.033s
ok  	github.com/carlosframework/platform/internal/geoip	0.071s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.185s
ok  	github.com/carlosframework/platform/internal/host	180.809s
ok  	github.com/carlosframework/platform/internal/hostactors	0.564s
ok  	github.com/carlosframework/platform/internal/hostbox	2.619s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.541s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.005s
ok  	github.com/carlosframework/platform/internal/hostexec	4.711s
ok  	github.com/carlosframework/platform/internal/hostfence	0.031s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.534s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.013s
ok  	github.com/carlosframework/platform/internal/hostguard	0.999s
ok  	github.com/carlosframework/platform/internal/hostops	1.590s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.443s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.155s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	5.222s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.719s
ok  	github.com/carlosframework/platform/internal/instancefs	0.005s
ok  	github.com/carlosframework/platform/internal/instancekey	0.017s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.023s
2026/10/17 07:49:49 activator: h.example.com hibernated
--- FAIL: TestRawInstancesDirectoryIsNamedOnlyHere (0.35s)
    rawdir_test.go:58: ../../internal/host/rootinventory_sibling.go:23 names the raw .instances directory; only internal/layout (and bootstrap's tmpfiles lines) may
FAIL
FAIL	github.com/carlosframework/platform/internal/layout	1.552s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.018s
ok  	github.com/carlosframework/platform/internal/lease	9.781s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.006s
ok  	github.com/carlosframework/platform/internal/ledger	0.015s
ok  	github.com/carlosframework/platform/internal/mail	0.174s
ok  	github.com/carlosframework/platform/internal/maintenance	0.076s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.485s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.075s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	20.986s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.759s
ok  	github.com/carlosframework/platform/internal/objstore	0.173s
ok  	github.com/carlosframework/platform/internal/ops	0.190s
ok  	github.com/carlosframework/platform/internal/park	0.200s
ok  	github.com/carlosframework/platform/internal/privatefile	0.005s
ok  	github.com/carlosframework/platform/internal/provision	31.039s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.008s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.490s
ok  	github.com/carlosframework/platform/internal/relay	7.220s
ok  	github.com/carlosframework/platform/internal/release	21.530s
ok  	github.com/carlosframework/platform/internal/rollout	3.644s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.007s
ok  	github.com/carlosframework/platform/internal/rootaws	0.006s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.058s
ok  	github.com/carlosframework/platform/internal/rootstate	0.027s
ok  	github.com/carlosframework/platform/internal/runs	0.572s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.487s
ok  	github.com/carlosframework/platform/internal/seeds	0.010s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.182s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.209s
ok  	github.com/carlosframework/platform/internal/status	0.037s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.224s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.003s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.560s
FAIL
make: *** [Makefile:18: test] Error 1

amadan: step gate failed: exit status 2

amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 3b68e6742..2fdaf4234 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2152,6 +2152,14 @@ image before starting `apply`, `recover`, `edge` or `console`. Once armed,
the local floor remains enforced independently of `CARLOS_HOST_CLAIM`.
The member command remains `carlos ops host resolve`.
+#### carlos system claims
+
+`carlos system claims` prepares the dev claim mechanics trial. Run `help` for the commands and required inputs. `run` executes one revision-checked phase from a protected plan on the trusted signing host; `inspect` returns the verified roster and its signed envelope. Box installers run as root. Add `--dry-run` to preview an operation.
+
+`install-control` installs only instance-control and reloads systemd. `console-config` uses a separate console environment file and leaves host execution claims off. Neither installer starts its service.
+
+After starting the guarded console, run `console-report` on its box as root. It checks the signed roster, guard, systemd process and running executable, then records that process in the existing private box report. It refuses stale box evidence and leaves the report’s timestamp unchanged. Run it again after a console restart and immediately before floor publication; the next agent report may replace the process entry.
+
#### carlos system journal-migrate
Before starting claim-enabled services on a box with legacy journals, stop the edge and agent. Run `carlos system journal-migrate` as root with `CARLOS_HOST_CLAIM=1` and the deployment environment loaded. The command imports journals only when signed root stop evidence verifies them. It leaves the original files in place. After migration, root ignores changed legacy files and logs an alert. Keep the migration marker.
diff --git a/internal/claimactivation/runner.go b/internal/claimactivation/runner.go
new file mode 100644
index 000000000..1a7e8d04d
--- /dev/null
+++ b/internal/claimactivation/runner.go
@@ -0,0 +1,316 @@
+package claimactivation
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "os"
+ "reflect"
+ "regexp"
+ "slices"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var ErrActivation = errors.New("claim activation: phase, revision, or actor inputs refused")
+
+type Plan struct {
+ Phase string `json:"phase"`
+ Bucket string `json:"bucket"`
+ ExpectedRevision uint64 `json:"expected_revision"`
+ Regions map[string]hostactors.Region `json:"regions,omitempty"`
+ HomeRegion string `json:"home_region,omitempty"`
+ Box *BoxInput `json:"box,omitempty"`
+ Console *hostactors.Console `json:"console,omitempty"`
+ Floor *hostfloor.Floor `json:"floor,omitempty"`
+ Binding *hostactors.CensusKeyBinding `json:"binding,omitempty"`
+ Proof []byte `json:"proof,omitempty"`
+}
+type BoxInput struct {
+ Reference string `json:"reference"`
+ Namespace string `json:"namespace"`
+ Label string `json:"label"`
+ Region string `json:"region"`
+ Pool string `json:"pool"`
+ AWSRegion string `json:"aws_region"`
+ InstanceID string `json:"instance_id"`
+ Operator string `json:"operator"`
+}
+
+type Write struct {
+ Key string `json:"key"`
+ BeforeETag string `json:"before_etag"`
+ SHA256 string `json:"sha256"`
+ Payload json.RawMessage `json:"payload"`
+}
+type Result struct {
+ DryRun bool `json:"dry_run"`
+ Revision uint64 `json:"revision"`
+ Writes []Write `json:"writes"`
+}
+type Runner struct {
+ Directory hostactors.Directory
+ Signer release.Signer
+}
+
+func (r Runner) Run(ctx context.Context, p Plan, dry bool) (Result, error) {
+ result := Result{DryRun: dry, Writes: []Write{}}
+ if p.Bucket == "" || p.Bucket != r.Directory.Bucket || !bytes.Equal(r.Signer.RootPublicKey(), r.Directory.PublicKey) {
+ return result, ErrActivation
+ }
+ d := r.Directory
+ o, err := d.Read(ctx)
+ if p.Phase == "initialize" {
+ if p.ExpectedRevision != 0 {
+ return result, ErrActivation
+ }
+ if err == nil {
+ if o.Roster.Revision != 1 || o.Roster.HomeRegion != "" || o.Roster.RequestLimitNS != 0 || o.Roster.Publication != "" || len(o.Roster.Boxes) != 0 || len(o.Roster.Consoles) != 0 || len(o.Roster.Regions) != 0 || len(o.Roster.Holds) != 0 || len(o.Roster.Activations) != 0 {
+ return result, ErrActivation
+ }
+ result.Revision = 1
+ return result, nil
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ return result, err
+ }
+ } else if err != nil {
+ return result, err
+ } else if p.ExpectedRevision == 0 || o.Roster.Revision != p.ExpectedRevision {
+ return result, ErrActivation
+ }
+ allowed := map[string]bool{}
+ switch p.Phase {
+ case "initialize", "configure", "home", "enroll-box", "enroll-console", "bind-key":
+ allowed[hostactors.Key] = true
+ case "invite-box":
+ if p.Box != nil {
+ allowed["control/host-actor-invitations/"+p.Box.Reference+".json"] = true
+ }
+ case "invite-console":
+ if p.Console != nil {
+ allowed["control/host-console-invitations/"+p.Console.InstanceID+".json"] = true
+ }
+ case "publish-floor":
+ allowed[hostactors.Key] = true
+ allowed[hostfloor.Key] = true
+ allowed[hostfloor.AuthKey] = true
+ }
+ store := &checkedStore{allowed: allowed, Store: d.Store, pub: d.PublicKey, bucket: d.Bucket, expected: o.ETag, dry: dry, overlay: map[string]overlayObject{}, writes: &result.Writes}
+ d.Store = store
+ if p.Phase != "initialize" && p.Phase != "configure" && o.Roster.RequestLimitNS != int64(hostbox.RequestLimit) {
+ return result, ErrActivation
+ }
+ switch p.Phase {
+ case "initialize":
+ err = d.Initialize(ctx, r.Signer)
+ case "configure":
+ if len(p.Regions) == 0 || o.Roster.Publication != "" || len(o.Roster.Boxes) != 0 || len(o.Roster.Consoles) != 0 {
+ return result, ErrActivation
+ }
+ for name, region := range p.Regions {
+ if name != "ie" || region.AWSRegion != "eu-west-1" || !regexp.MustCompile(`^[0-9]{12}$`).MatchString(region.Account) || len(region.Namespaces) == 0 || len(region.Pools) == 0 {
+ return result, ErrActivation
+ }
+ }
+ next := hostactors.Clone(o.Roster)
+ next.Regions = p.Regions
+ next.RequestLimitNS = int64(hostbox.RequestLimit)
+ if !reflect.DeepEqual(next, o.Roster) {
+ _, err = d.Replace(ctx, r.Signer, o, next)
+ }
+ case "home":
+ if p.HomeRegion != "ie" || o.Roster.Regions[p.HomeRegion].AWSRegion != "eu-west-1" || o.Roster.HomeRegion != "" && o.Roster.HomeRegion != p.HomeRegion {
+ return result, ErrActivation
+ }
+ if o.Roster.HomeRegion != p.HomeRegion {
+ next := hostactors.Clone(o.Roster)
+ next.HomeRegion = p.HomeRegion
+ _, err = d.Replace(ctx, r.Signer, o, next)
+ }
+ case "invite-box", "enroll-box":
+ if p.Box == nil {
+ return result, ErrActivation
+ }
+ in := hostactors.EnrollmentRequest{Reference: p.Box.Reference, Namespace: p.Box.Namespace, Label: p.Box.Label, Region: p.Box.Region, Pool: p.Box.Pool, AWSRegion: p.Box.AWSRegion, TofuInstanceID: p.Box.InstanceID, Operator: p.Box.Operator, ExistingReference: true}
+ if !boxid.ValidBox(in.Reference) || in.TofuInstanceID == "" || in.Operator == "" || in.Label == "" || in.Region != "ie" || in.Namespace == "" || in.Pool == "" {
+ return result, ErrActivation
+ }
+ region, ok := o.Roster.Regions[in.Region]
+ if !ok || region.AWSRegion != "eu-west-1" || in.AWSRegion != "" && in.AWSRegion != region.AWSRegion {
+ return result, ErrActivation
+ }
+ if !slices.Contains(region.Namespaces, in.Namespace) || !slices.Contains(region.Pools, in.Pool) {
+ return result, ErrActivation
+ }
+ if old, exists := o.Roster.Boxes[in.Reference]; exists && (old.Label != in.Label || old.State != "active" || old.Namespace != in.Namespace || old.Region != in.Region || old.Pool != in.Pool || old.Cloud.InstanceID != in.TofuInstanceID) {
+ return result, ErrActivation
+ }
+ raw, e := boxid.Load(ctx, d.Store, in.Reference)
+ if e != nil {
+ return result, e
+ }
+ certs, e := boxid.EmbeddedCerts()
+ if e != nil {
+ return result, e
+ }
+ expect := boxid.Expect{Box: in.Reference, AWSRegion: region.AWSRegion, TofuInstanceID: in.TofuInstanceID}
+ for ref, b := range o.Roster.Boxes {
+ expect.Roster = append(expect.Roster, boxid.Enrolled{Box: ref, Cloud: b.Cloud})
+ }
+ if _, e = (boxid.Verifier{Account: region.Account, Certs: certs}).Enroll(raw, expect); e != nil {
+ return result, e
+ }
+ if p.Phase == "invite-box" {
+ _, err = d.Invite(ctx, r.Signer, in.Reference, raw)
+ } else {
+ _, err = d.EnrollAWS(ctx, r.Signer, o, in, raw)
+ }
+ case "invite-console", "enroll-console":
+ if p.Console == nil || p.Console.Compute == nil {
+ return result, ErrActivation
+ }
+ c := *p.Console
+ b, ok := o.Roster.Boxes[c.Compute.Box]
+ if !ok || b.State != "active" || b.Enrollment != c.Compute.Enrollment || b.Region != "ie" || c.DeploymentBucket != d.Bucket {
+ return result, ErrActivation
+ }
+ if old, ok := o.Roster.Consoles[c.InstanceID]; ok {
+ old.EnrolledAt = c.EnrolledAt
+ if !reflect.DeepEqual(old, c) {
+ return result, ErrActivation
+ }
+ break
+ }
+ if p.Phase == "invite-console" {
+ err = d.InviteConsole(ctx, r.Signer, c)
+ } else {
+ invited, e := d.InvitedConsole(ctx, o, c.InstanceID)
+ if e != nil || !reflect.DeepEqual(invited, c) {
+ return result, ErrActivation
+ }
+ next := hostactors.Clone(o.Roster)
+ next.Consoles[c.InstanceID] = c
+ _, err = d.Replace(ctx, r.Signer, o, next)
+ }
+ case "publish-floor":
+ if p.Floor == nil {
+ return result, ErrActivation
+ }
+ e := hostactors.ExactEvidence{Store: d.Store, PublicKey: d.PublicKey, Boxes: d.PrivateBoxReport}
+ err = d.PublishFloor(ctx, r.Signer, *p.Floor, e, time.Now())
+ if err == nil {
+ _, err = hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket)
+ }
+ case "bind-key":
+ if p.Binding == nil || p.Binding.Bucket != p.Bucket {
+ return result, ErrActivation
+ }
+ _, err = d.BindCensusKey(ctx, r.Signer, o, *p.Binding, p.Proof)
+ default:
+ return result, ErrActivation
+ }
+ if err != nil {
+ return result, err
+ }
+ after, err := d.Read(ctx)
+ if err != nil {
+ return result, err
+ }
+ if after.ETag != store.expected {
+ return result, ErrActivation
+ }
+ result.Revision = after.Roster.Revision
+ if p.Phase != "initialize" && after.Roster.RequestLimitNS != int64(hostbox.RequestLimit) {
+ return result, ErrActivation
+ }
+ return result, nil
+}
+
+type overlayObject struct {
+ raw []byte
+ etag string
+}
+type checkedStore struct {
+ allowed map[string]bool
+ release.Store
+ pub ed25519.PublicKey
+ bucket, expected string
+ dry bool
+ overlay map[string]overlayObject
+ writes *[]Write
+}
+
+func (s *checkedStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if v, ok := s.overlay[key]; ok {
+ return append([]byte(nil), v.raw...), v.etag, nil
+ }
+ return s.Store.Get(ctx, key)
+}
+func (s *checkedStore) Delete(context.Context, string) error { return ErrActivation }
+func (s *checkedStore) Put(ctx context.Context, key string, raw []byte, before string) (string, error) {
+ if !s.allowed[key] {
+ return "", ErrActivation
+ }
+ // Recheck the roster before every write, including non-roster objects.
+ _, current, e := s.Get(ctx, hostactors.Key)
+ if e != nil && !errors.Is(e, os.ErrNotExist) {
+ return "", e
+ }
+ if current != s.expected || key == hostactors.Key && before != s.expected {
+ return "", ErrActivation
+ }
+ var payload json.RawMessage
+ if key == hostfloor.Key {
+ payload = append([]byte(nil), raw...)
+ } else if release.VerifyRootEnvelope(s.pub, raw, &payload) != nil {
+ return "", ErrActivation
+ }
+ var tag string
+ if s.dry {
+ _, old, e := s.Get(ctx, key)
+ if e != nil && !errors.Is(e, os.ErrNotExist) {
+ return "", e
+ }
+ if old != before {
+ return "", release.ErrCASConflict
+ }
+ tag = hostfloor.Digest(raw)
+ s.overlay[key] = overlayObject{append([]byte(nil), raw...), tag}
+ } else {
+ tag, e = s.Store.Put(ctx, key, raw, before)
+ if e != nil {
+ got, readTag, readErr := s.Get(ctx, key)
+ if readErr != nil || readTag == "" || !bytes.Equal(got, raw) {
+ return "", errors.Join(e, readErr)
+ }
+ tag = readTag
+ }
+ }
+ got, readTag, e := s.Get(ctx, key)
+ if e != nil || tag == "" || tag != readTag || !bytes.Equal(got, raw) {
+ return "", ErrActivation
+ }
+ if key == hostfloor.Key {
+ if _, e = hostfloor.Read(ctx, s, s.pub, s.bucket); e != nil {
+ return "", e
+ }
+ } else {
+ var verified json.RawMessage
+ if release.VerifyRootEnvelope(s.pub, got, &verified) != nil {
+ return "", ErrActivation
+ }
+ }
+ if key == hostactors.Key {
+ s.expected = tag
+ }
+ *s.writes = append(*s.writes, Write{Key: key, BeforeETag: before, SHA256: hostfloor.Digest(raw), Payload: payload})
+ return tag, nil
+}
diff --git a/internal/claimactivation/runner_test.go b/internal/claimactivation/runner_test.go
new file mode 100644
index 000000000..c05b53f21
--- /dev/null
+++ b/internal/claimactivation/runner_test.go
@@ -0,0 +1,321 @@
+package claimactivation
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/status"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func runnerFixture(t *testing.T) Runner {
+ t.Helper()
+ pub, key, _ := ed25519.GenerateKey(rand.Reader)
+ return Runner{Directory: hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key)}
+}
+func TestActivationInitializeConfigureAndHome(t *testing.T) {
+ r := runnerFixture(t)
+ ctx := t.Context()
+ p := Plan{Phase: "initialize", Bucket: "bucket"}
+ result, err := r.Run(ctx, p, true)
+ if err != nil || len(result.Writes) != 1 {
+ t.Fatal(result, err)
+ }
+ if _, err = r.Directory.Read(ctx); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("dry run wrote roster", err)
+ }
+ if _, err = r.Run(ctx, p, false); err != nil {
+ t.Fatal(err)
+ }
+ p = Plan{Phase: "configure", Bucket: "bucket", ExpectedRevision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"cohort1"}}}}
+ if _, err = r.Run(ctx, p, false); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := r.Directory.Read(ctx)
+ if got.Roster.RequestLimitNS != int64(hostbox.RequestLimit) {
+ t.Fatal("wrong request limit")
+ }
+ if _, err = r.Run(ctx, p, false); err == nil {
+ t.Fatal("stale revision accepted")
+ }
+ p = Plan{Phase: "home", Bucket: "bucket", ExpectedRevision: 2, HomeRegion: "ie"}
+ if _, err = r.Run(ctx, p, false); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = r.Directory.Read(ctx)
+ if got.Roster.HomeRegion != "ie" {
+ t.Fatal("home absent")
+ }
+ p.ExpectedRevision = 3
+ if result, err = r.Run(ctx, p, false); err != nil || len(result.Writes) != 0 {
+ t.Fatal("home replay", result, err)
+ }
+ p.HomeRegion = "uk"
+ if _, err = r.Run(ctx, p, false); err == nil {
+ t.Fatal("home changed")
+ }
+}
+
+type corruptReadback struct {
+ release.Store
+ corrupt bool
+}
+
+func (s *corruptReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
+ tag, err := s.Store.Put(ctx, key, raw, etag)
+ s.corrupt = true
+ return tag, err
+}
+func (s *corruptReadback) Get(ctx context.Context, key string) ([]byte, string, error) {
+ raw, tag, err := s.Store.Get(ctx, key)
+ if s.corrupt && key == hostactors.Key {
+ raw = []byte(`{"untrusted":true}`)
+ }
+ return raw, tag, err
+}
+func TestActivationRequiresAuthenticatedReadback(t *testing.T) {
+ r := runnerFixture(t)
+ r.Directory.Store = &corruptReadback{Store: r.Directory.Store}
+ if _, err := r.Run(t.Context(), Plan{Phase: "initialize", Bucket: "bucket"}, false); err == nil {
+ t.Fatal("unverified readback accepted")
+ }
+}
+func TestActivationRefusesUnscopedInputs(t *testing.T) {
+ r := runnerFixture(t)
+ for _, p := range []Plan{{Phase: "initialize", Bucket: "other"}, {Phase: "unknown", Bucket: "bucket"}, {Phase: "enroll-box", Bucket: "bucket"}, {Phase: "bind-key", Bucket: "bucket"}} {
+ if _, err := r.Run(t.Context(), p, false); err == nil {
+ t.Fatal("accepted", p)
+ }
+ }
+}
+
+type lostWriteReply struct {
+ release.Store
+ once bool
+}
+
+func (s *lostWriteReply) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
+ tag, err := s.Store.Put(ctx, key, raw, etag)
+ if err == nil && !s.once {
+ s.once = true
+ return "", errors.New("lost reply")
+ }
+ return tag, err
+}
+func TestActivationResumesAfterLostWriteReply(t *testing.T) {
+ r := runnerFixture(t)
+ s := &lostWriteReply{Store: r.Directory.Store}
+ r.Directory.Store = s
+ p := Plan{Phase: "initialize", Bucket: "bucket"}
+ if result, err := r.Run(t.Context(), p, false); err != nil || len(result.Writes) != 1 {
+ t.Fatal("authenticated lost-reply recovery failed", result, err)
+ }
+ if result, err := r.Run(t.Context(), p, false); err != nil || result.Revision != 1 {
+ t.Fatal(result, err)
+ }
+}
+func TestActivationBindRequiresPublishedFloorAndPossession(t *testing.T) {
+ r := runnerFixture(t)
+ ctx := t.Context()
+ if err := r.Directory.Initialize(ctx, r.Signer); err != nil {
+ t.Fatal(err)
+ }
+ o, _ := r.Directory.Read(ctx)
+ next := hostactors.Clone(o.Roster)
+ next.RequestLimitNS = int64(hostbox.RequestLimit)
+ next.Boxes["leo"] = hostactors.Box{Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "leo", Label: "leo", Region: "ie", Pool: "cohort1", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-leo"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}
+ o, err := r.Directory.Replace(ctx, r.Signer, o, next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, key, _ := ed25519.GenerateKey(rand.Reader)
+ binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: next.Boxes["leo"].Enrollment, PublicKey: pub}
+ raw, _ := json.Marshal(binding)
+ p := Plan{Phase: "bind-key", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Binding: &binding, Proof: ed25519.Sign(key, raw)}
+ if _, err = r.Run(ctx, p, false); err == nil {
+ t.Fatal("bound before floor")
+ }
+ floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}
+ if err = hostfloor.Publish(ctx, r.Directory.Store, r.Signer, "bucket", floor); err != nil {
+ t.Fatal(err)
+ }
+ f, err := hostfloor.Read(ctx, r.Directory.Store, r.Directory.PublicKey, "bucket")
+ if err != nil {
+ t.Fatal(err)
+ }
+ next = hostactors.Clone(o.Roster)
+ next.Publication = "published"
+ next.FloorSHA256 = f.Digest
+ o, err = r.Directory.Replace(ctx, r.Signer, o, next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.ExpectedRevision = o.Roster.Revision
+ valid := append([]byte(nil), p.Proof...)
+ p.Proof[0] ^= 1
+ if _, err = r.Run(ctx, p, false); err == nil {
+ t.Fatal("bad possession proof accepted")
+ }
+ p.Proof = valid
+ result, err := r.Run(ctx, p, true)
+ if err != nil || len(result.Writes) != 1 {
+ t.Fatal(result, err)
+ }
+ current, _ := r.Directory.Read(ctx)
+ if len(current.Roster.Boxes["leo"].CensusPublicKey) != 0 {
+ t.Fatal("dry run bound key")
+ }
+ result, err = r.Run(ctx, p, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.ExpectedRevision = result.Revision
+ result, err = r.Run(ctx, p, false)
+ if err != nil || len(result.Writes) != 0 {
+ t.Fatal("binding not resumable", result, err)
+ }
+}
+func TestActivationConsoleInvitationEnrollment(t *testing.T) {
+ r := runnerFixture(t)
+ ctx := t.Context()
+ r.Directory.Initialize(ctx, r.Signer)
+ o, _ := r.Directory.Read(ctx)
+ next := hostactors.Clone(o.Roster)
+ next.RequestLimitNS = int64(hostbox.RequestLimit)
+ next.Boxes["sophie"] = hostactors.Box{Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "sophie", Label: "sophie", Region: "ie", Pool: "console", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-sophie"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}
+ o, err := r.Directory.Replace(ctx, r.Signer, o, next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), Namespaces: []string{"platform"}, DeploymentBucket: "bucket", Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: next.Boxes["sophie"].Enrollment}}
+ p := Plan{Phase: "enroll-console", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Console: &c}
+ if _, err = r.Run(ctx, p, false); err == nil {
+ t.Fatal("enrollment without invitation")
+ }
+ p.Phase = "invite-console"
+ if _, err = r.Run(ctx, p, false); err != nil {
+ t.Fatal(err)
+ }
+ p.Phase = "enroll-console"
+ result, err := r.Run(ctx, p, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p.ExpectedRevision = result.Revision
+ if result, err = r.Run(ctx, p, false); err != nil || len(result.Writes) != 0 {
+ t.Fatal("replay", result, err)
+ }
+ c.Compute.Box = "leo"
+ if _, err = r.Run(ctx, p, false); err == nil {
+ t.Fatal("wrong compute accepted")
+ }
+}
+
+func TestActivationFloorWaitsForGuardAndComputeReport(t *testing.T) {
+ r := runnerFixture(t)
+ ctx := t.Context()
+ store := r.Directory.Store
+ if err := r.Directory.Initialize(ctx, r.Signer); err != nil {
+ t.Fatal(err)
+ }
+ o, _ := r.Directory.Read(ctx)
+ next := hostactors.Clone(o.Roster)
+ next.RequestLimitNS = int64(hostbox.RequestLimit)
+ en := strings.Repeat("a", 32)
+ hash := strings.Repeat("f", 64)
+ tag := "v1.0.0"
+ next.Boxes["sophie"] = hostactors.Box{Enrollment: en, Namespace: "platform", BoxID: "sophie", Label: "sophie", Region: "ie", Pool: "console", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-sophie"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}
+ c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), Namespaces: []string{"platform"}, DeploymentBucket: "bucket", Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: en}}
+ next.Consoles[c.InstanceID] = c
+ o, err := r.Directory.Replace(ctx, r.Signer, o, next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ floor := hostfloor.Floor{Min: 1, MinRelease: tag}
+ body, _ := json.Marshal(floor)
+ digest := hostfloor.Digest(body)
+ rel := release.SystemRelease{Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey(tag, "linux-amd64"), SHA256: hash}}, ConsoleArtifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: "bootstrap/console/" + tag + "/carlos-linux-amd64", SHA256: hash}}}
+ if _, err = release.PromoteSystem(ctx, store, r.Signer, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ wire, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = release.PublishSystemIdentity(ctx, store, r.Directory.PublicKey, wire); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC()
+ report := status.Box{GeneratedAt: now, Metrics: &status.BoxMetrics{Enrollment: en, InstanceID: "i-sophie", Release: tag, ReleaseTag: tag, Platform: "linux-amd64", BinarySHA256: hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: digest, FloorGuardReady: true}}
+ put := func(key string, value any) {
+ t.Helper()
+ raw, _ := json.Marshal(value)
+ _, etag, _ := store.Get(ctx, key)
+ if _, err := store.Put(ctx, key, raw, etag); err != nil {
+ t.Fatal(err)
+ }
+ }
+ put("status/boxes/sophie.json", report)
+ cr := hostactors.Report{Compute: c.Compute, Component: "console", GeneratedAt: now, Actor: c.InstanceID, Enrollment: c.Enrollment, InstanceID: c.InstanceID, ProcessID: "console-process", Release: tag, ReleaseTag: tag, Platform: "linux-amd64", BinarySHA256: hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: digest, FloorGuardReady: true}
+ put("status/consoles/dev-console.json", cr)
+ p := Plan{Phase: "publish-floor", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Floor: &floor}
+ if _, err = r.Run(ctx, p, false); err == nil {
+ t.Fatal("floor published without box process report")
+ }
+ o, _ = r.Directory.Read(ctx)
+ if o.Roster.Publication != "preparing" {
+ t.Fatal("missing resumable preparation")
+ }
+ if _, _, err = store.Get(ctx, hostfloor.Key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("floor written before readiness")
+ }
+ report.Metrics.ConsoleProcesses = []status.ConsoleProcess{{ConsoleID: c.InstanceID, Enrollment: c.Enrollment, ProcessID: cr.ProcessID, BinarySHA256: hash}}
+ put("status/boxes/sophie.json", report)
+ p.ExpectedRevision = o.Roster.Revision
+ if result, err := r.Run(ctx, p, true); err != nil || len(result.Writes) != 3 {
+ t.Fatal("floor preview", result, err)
+ }
+ o, _ = r.Directory.Read(ctx)
+ if o.Roster.Publication != "preparing" {
+ t.Fatal("preview published")
+ }
+ if _, err = r.Run(ctx, p, false); err != nil {
+ t.Fatal(err)
+ }
+ o, _ = r.Directory.Read(ctx)
+ if o.Roster.Publication != "published" {
+ t.Fatal("floor not published")
+ }
+ if _, err = hostfloor.Read(ctx, store, r.Directory.PublicKey, "bucket"); err != nil {
+ t.Fatal("floor readback", err)
+ }
+}
+
+func TestActivationStoreRefusesUnlistedWrites(t *testing.T) {
+ r := runnerFixture(t)
+ writes := []Write{}
+ store := &checkedStore{Store: r.Directory.Store, pub: r.Directory.PublicKey, bucket: "bucket", overlay: map[string]overlayObject{}, writes: &writes}
+ raw, err := release.SignRootEnvelope(r.Signer, struct {
+ OK bool `json:"ok"`
+ }{true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := "apps/bab/app/instances/test.example.json"
+ if _, err = store.Put(t.Context(), key, raw, ""); err == nil {
+ t.Fatal("activation transport wrote an unlisted resource")
+ }
+ if _, _, err = store.Get(t.Context(), key); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("unlisted resource changed", err)
+ }
+}
diff --git a/internal/host/activation_command.go b/internal/host/activation_command.go
new file mode 100644
index 000000000..1c3b6ebe1
--- /dev/null
+++ b/internal/host/activation_command.go
@@ -0,0 +1,26 @@
+package host
+
+import (
+ "log"
+ "os"
+)
+
+func RunSystemClaims(args []string) {
+ if err := runSystemClaims(args, os.Stdout); err != nil {
+ log.Fatal(err)
+ }
+}
+
+const activationClaimsUsage = `usage: carlos system claims <command> [flags]
+
+ inspect --bucket B --aws-region R --public-key K
+ run --plan FILE --signing-key FILE --public-key K --aws-region R
+ evidence-key --roster FILE --expect-revision N --bucket B --box BOX --enrollment E --public-key K
+ fence-credentials --source FILE --bucket B --aws-region R --role ARN
+ console-config --roster FILE --expect-revision N --bucket B --console-id ID --public-key K [--enable-console-runtime]
+ console-report --bucket B --aws-region R --expect-revision N --console-id ID --public-key K
+ install-control
+
+For commands that write, add --dry-run to validate and print the planned operation without writing.
+Run signing phases on the trusted operator host. Run box installers as root.
+`
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go
new file mode 100644
index 000000000..4293bea40
--- /dev/null
+++ b/internal/host/activation_command_linux.go
@@ -0,0 +1,257 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "flag"
+ "io"
+
+ "github.com/carlosframework/platform/internal/claimactivation"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func runSystemClaims(args []string, out io.Writer) error {
+ if len(args) == 1 && (args[0] == "help" || args[0] == "--help" || args[0] == "-h") {
+ _, err := io.WriteString(out, activationClaimsUsage)
+ return err
+ }
+ if len(args) == 0 {
+ return errActivation
+ }
+ fs := flag.NewFlagSet("system claims "+args[0], flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ consoleID := fs.String("console-id", "", "")
+ enableRuntime := fs.Bool("enable-console-runtime", false, "")
+ dry := fs.Bool("dry-run", false, "")
+ planPath := fs.String("plan", "", "")
+ keyPath := fs.String("signing-key", "", "")
+ pubValue := fs.String("public-key", "", "")
+ region := fs.String("aws-region", "", "")
+ rosterPath := fs.String("roster", "", "")
+ revision := fs.Uint64("expect-revision", 0, "")
+ box := fs.String("box", "", "")
+ enrollment := fs.String("enrollment", "", "")
+ source := fs.String("source", "", "")
+ bucket := fs.String("bucket", "", "")
+ role := fs.String("role", "", "")
+ if err := fs.Parse(args[1:]); err != nil || len(fs.Args()) != 0 {
+ return errActivation
+ }
+ ctx := context.Background()
+ encode := func(value any) error { e := json.NewEncoder(out); e.SetIndent("", " "); return e.Encode(value) }
+ switch args[0] {
+ case "inspect":
+ if *bucket == "" || *region == "" {
+ return errActivation
+ }
+ pub, err := release.ParsePublicKey(*pubValue, "public-key")
+ if err != nil {
+ return errActivation
+ }
+ store, err := release.NewS3StoreIn(ctx, *bucket, *region)
+ if err != nil {
+ return err
+ }
+ roster, err := (hostactors.Directory{Store: store, PublicKey: pub, Bucket: *bucket}).Read(ctx)
+ if err != nil {
+ return err
+ }
+ wire, tag, err := store.Get(ctx, hostactors.Key)
+ if err != nil || tag != roster.ETag {
+ return errActivation
+ }
+ return encode(struct {
+ Roster hostactors.Roster `json:"roster"`
+ ETag string `json:"etag"`
+ SignedRoster json.RawMessage `json:"signed_roster"`
+ }{roster.Roster, roster.ETag, wire})
+ case "console-report":
+ if *consoleID == "" || *bucket == "" || *region == "" || *revision == 0 {
+ return errActivation
+ }
+ pub, err := release.ParsePublicKey(*pubValue, "public-key")
+ if err != nil {
+ return errActivation
+ }
+ store, err := release.NewS3StoreIn(ctx, *bucket, *region)
+ if err != nil {
+ return err
+ }
+ report, err := publishActivationConsoleReport(ctx, store, pub, *bucket, *consoleID, *revision, *dry)
+ if err != nil {
+ return err
+ }
+ localRaw, err := activationRead("/etc/carlos/host-actor.json", false)
+ if err != nil {
+ return err
+ }
+ var local hostfloor.Enrollment
+ if json.Unmarshal(localRaw, &local) != nil {
+ return errActivation
+ }
+ return encode(map[string]any{"dry_run": *dry, "bucket": *bucket, "key": "status/boxes/" + local.Box + ".json", "expected_roster_revision": *revision, "console_process": report})
+ case "console-config":
+ if *consoleID == "" || *bucket == "" || *revision == 0 || *rosterPath == "" {
+ return errActivation
+ }
+ pub, err := release.ParsePublicKey(*pubValue, "public-key")
+ if err != nil {
+ return errActivation
+ }
+ raw, err := activationRead(*rosterPath, false)
+ if err != nil {
+ return err
+ }
+ var roster hostactors.Roster
+ if release.VerifyRootEnvelope(pub, raw, &roster) != nil || roster.Validate(*bucket) != nil || roster.Revision != *revision {
+ return errActivation
+ }
+ localRaw, err := activationRead("/etc/carlos/host-actor.json", false)
+ if err != nil {
+ return err
+ }
+ var local hostfloor.Enrollment
+ if json.Unmarshal(localRaw, &local) != nil {
+ return errActivation
+ }
+ env, err := activationRead("/etc/carlos/host.env", false)
+ if err != nil {
+ return err
+ }
+ content, err := activationConsoleEnvironment(roster, local, env, *consoleID, *enableRuntime)
+ if err != nil {
+ return err
+ }
+ policyRaw, err := activationRead("/opt/carlos/system/guard/policy.json", false)
+ if err != nil {
+ return err
+ }
+ var policy hostfloor.Policy
+ if json.Unmarshal(policyRaw, &policy) != nil || policy.Mode == "unpublished" {
+ return errActivation
+ }
+ floor, err := policy.Verify(pub, *bucket)
+ if err != nil {
+ return err
+ }
+ if *enableRuntime && floor.Digest != roster.FloorSHA256 {
+ return errActivation
+ }
+ consolePath, err := activationRead("/opt/carlos/system/guard/console-ready.json", false)
+ if err != nil {
+ return err
+ }
+ guard, err := activationRead("/etc/systemd/system/console.service.d/90-host-floor.conf", false)
+ if err != nil {
+ return err
+ }
+ if string(guard) != hostfloor.ConsoleGuardDropIn(string(consolePath)) {
+ return errActivation
+ }
+ if err = installActivationConsole("/etc/carlos/claim-console.env", "/etc/systemd/system/console.service.d", content, *dry, productionSysOps().systemctl); err != nil {
+ return err
+ }
+ return encode(map[string]any{"dry_run": *dry, "console_id": *consoleID, "environment": string(content), "path": "/etc/carlos/claim-console.env", "drop_in": "/etc/systemd/system/console.service.d/91-claim-runtime.conf", "reload": true, "start": false})
+ case "run":
+ if *planPath == "" || *keyPath == "" || *pubValue == "" || *region == "" {
+ return errActivation
+ }
+ raw, err := activationRead(*planPath, false)
+ if err != nil {
+ return err
+ }
+ var plan claimactivation.Plan
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if dec.Decode(&plan) != nil || dec.Decode(new(any)) != io.EOF {
+ return errActivation
+ }
+ keyRaw, err := activationRead(*keyPath, true)
+ if err != nil {
+ return err
+ }
+ key, err := release.ParseSigningKey(string(keyRaw), *keyPath)
+ if err != nil {
+ return errActivation
+ }
+ pub, err := release.ParsePublicKey(*pubValue, "public-key")
+ if err != nil {
+ return errActivation
+ }
+ if !bytes.Equal(key.Public().(ed25519.PublicKey), pub) {
+ return errActivation
+ }
+ store, err := release.NewS3StoreIn(ctx, plan.Bucket, *region)
+ if err != nil {
+ return err
+ }
+ runner := claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: plan.Bucket}, Signer: release.RootSigner(key)}
+ result, err := runner.Run(ctx, plan, *dry)
+ if err != nil {
+ return err
+ }
+ return encode(result)
+ case "evidence-key":
+ if *rosterPath == "" || *revision == 0 || *bucket == "" || *box == "" || *enrollment == "" {
+ return errActivation
+ }
+ pub, err := release.ParsePublicKey(*pubValue, "public-key")
+ if err != nil {
+ return errActivation
+ }
+ raw, err := activationRead(*rosterPath, false)
+ if err != nil {
+ return err
+ }
+ var roster hostactors.Roster
+ if release.VerifyRootEnvelope(pub, raw, &roster) != nil || roster.Validate(*bucket) != nil || roster.Revision != *revision {
+ return errActivation
+ }
+ actor, ok := roster.Boxes[*box]
+ if !ok || actor.Enrollment != *enrollment || actor.State != "active" {
+ return errActivation
+ }
+ localRaw, err := activationRead("/etc/carlos/host-actor.json", false)
+ if err != nil {
+ return err
+ }
+ var local hostfloor.Enrollment
+ if json.Unmarshal(localRaw, &local) != nil || local.Box != *box || local.Enrollment != *enrollment || local.InstanceID != actor.Cloud.InstanceID {
+ return errActivation
+ }
+ proof, err := installActivationEvidenceKey("/etc/carlos/host-evidence.key", hostactors.CensusKeyBinding{Bucket: *bucket, Box: *box, Enrollment: *enrollment, PublicKey: actor.CensusPublicKey}, *dry)
+ if err != nil {
+ return err
+ }
+ return encode(struct {
+ DryRun bool `json:"dry_run"`
+ Path string `json:"path"`
+ Proof activationKeyProof `json:"key_proof"`
+ }{*dry, "/etc/carlos/host-evidence.key", proof})
+ case "fence-credentials":
+ if *source == "" || *bucket == "" || *region == "" || *role == "" {
+ return errActivation
+ }
+ target := "/etc/carlos/fence-writer/credentials.json"
+ if err := installActivationFenceCredentials(*source, target, activationFenceScope{Bucket: *bucket, Region: *region, Role: *role}, *dry); err != nil {
+ return err
+ }
+ return encode(map[string]any{"dry_run": *dry, "source": *source, "target": target, "mode": "0600", "bucket": *bucket, "aws_region": *region, "role": *role})
+ case "install-control":
+ if err := verifyOfflineRootMigration(ctx); err != nil {
+ return err
+ }
+ if err := installActivationControl("/etc/carlos/host.env", "/etc/systemd/system", *dry, productionSysOps().systemctl); err != nil {
+ return err
+ }
+ return encode(map[string]any{"dry_run": *dry, "source": "/etc/carlos/host.env", "target": "/etc/systemd/system/" + claimControlUnit, "reload": true, "start": false})
+ default:
+ return errActivation
+ }
+}
diff --git a/internal/host/activation_command_other.go b/internal/host/activation_command_other.go
new file mode 100644
index 000000000..dd92a63f9
--- /dev/null
+++ b/internal/host/activation_command_other.go
@@ -0,0 +1,7 @@
+//go:build !linux
+
+package host
+
+import "io"
+
+func runSystemClaims([]string, io.Writer) error { return errActivation }
diff --git a/internal/host/activation_command_test.go b/internal/host/activation_command_test.go
new file mode 100644
index 000000000..a4c3c044b
--- /dev/null
+++ b/internal/host/activation_command_test.go
@@ -0,0 +1,14 @@
+package host
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestActivationCommandsRefuseMissingScope(t *testing.T) {
+ for _, args := range [][]string{nil, {"unknown"}, {"run"}, {"evidence-key"}, {"fence-credentials"}, {"install-control", "unexpected"}} {
+ if err := runSystemClaims(args, &bytes.Buffer{}); err == nil {
+ t.Fatal("accepted", args)
+ }
+ }
+}
diff --git a/internal/host/activation_console.go b/internal/host/activation_console.go
new file mode 100644
index 000000000..caee5f863
--- /dev/null
+++ b/internal/host/activation_console.go
@@ -0,0 +1,37 @@
+package host
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostfloor"
+)
+
+func activationConsoleEnvironment(roster hostactors.Roster, local hostfloor.Enrollment, hostEnv []byte, id string, runtime bool) ([]byte, error) {
+ env, err := activationEnvironment(hostEnv)
+ if err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(env["CARLOS_HOST_CLAIM"]) != "" || env["CARLOS_REGION"] != "" || !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(id) {
+ return nil, errActivation
+ }
+ c, ok := roster.Consoles[id]
+ if !ok || len(c.Namespaces) != 1 || c.Compute == nil || c.Compute.Box != local.Box || c.Compute.Enrollment != local.Enrollment {
+ return nil, errActivation
+ }
+ b, ok := roster.Boxes[local.Box]
+ if !ok || b.State != "active" || b.Enrollment != local.Enrollment || b.Cloud.InstanceID != local.InstanceID || b.Region != "ie" || b.Cloud.Region != "eu-west-1" || roster.RequestLimitNS != int64(hostbox.RequestLimit) {
+ return nil, errActivation
+ }
+ if runtime && (roster.HomeRegion != "ie" || roster.Publication != "published") {
+ return nil, errActivation
+ }
+ flag := ""
+ if runtime {
+ flag = "1"
+ }
+ return []byte(fmt.Sprintf("CARLOS_CONSOLE_ID=%s\nCONSOLE_HOME_REGION=ie\nCARLOS_HOST_CLAIM=%s\n", id, flag)), nil
+}
diff --git a/internal/host/activation_console_binary_rootrunner_linux_test.go b/internal/host/activation_console_binary_rootrunner_linux_test.go
new file mode 100644
index 000000000..84d0b8407
--- /dev/null
+++ b/internal/host/activation_console_binary_rootrunner_linux_test.go
@@ -0,0 +1,16 @@
+//go:build linux && rootrunner && activationconsole
+
+package host
+
+import _ "embed"
+
+//go:embed activation-console-runner.bin
+var activationConsoleBinary []byte
+
+//go:embed activation-carlos-runner.bin
+var activationCarlosBinary []byte
+
+func init() {
+ activationConsoleImage = activationConsoleBinary
+ activationCarlosImage = activationCarlosBinary
+}
diff --git a/internal/host/activation_console_report_linux.go b/internal/host/activation_console_report_linux.go
new file mode 100644
index 000000000..2bc2be57c
--- /dev/null
+++ b/internal/host/activation_console_report_linux.go
@@ -0,0 +1,336 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+ "golang.org/x/sys/unix"
+)
+
+func activationConsolePID(r hostactors.Report) (int, string, error) {
+ p := strings.Split(r.ProcessID, ":")
+ if len(p) != 4 || p[0] != r.BootID {
+ return 0, "", errActivation
+ }
+ for _, field := range p[1:] {
+ n, e := strconv.ParseUint(field, 10, 64)
+ if e != nil || n == 0 {
+ return 0, "", errActivation
+ }
+ }
+ pid, err := strconv.Atoi(p[2])
+ if err != nil || pid <= 0 {
+ return 0, "", errActivation
+ }
+ return pid, p[3], nil
+}
+
+// This observation is deliberately on demand. It never refreshes an old box
+// report or asserts that every possible console launch path is guarded.
+func activationConsoleProcess(ctx context.Context, pub ed25519.PublicKey, bucket string, o hostactors.Observed, id string, r hostactors.Report) (status.ConsoleProcess, error) {
+ var zero status.ConsoleProcess
+ c, ok := o.Roster.Consoles[id]
+ if !ok || c.Compute == nil || r.Actor != id || r.Component != "console" || r.Compute == nil || *r.Compute != *c.Compute {
+ return zero, errActivation
+ }
+ localRaw, err := activationRead("/etc/carlos/host-actor.json", false)
+ if err != nil {
+ return zero, err
+ }
+ var local hostfloor.Enrollment
+ if json.Unmarshal(localRaw, &local) != nil || local.Box != c.Compute.Box || local.Enrollment != c.Compute.Enrollment {
+ return zero, errActivation
+ }
+ hostEnv, err := activationRead("/etc/carlos/host.env", false)
+ if err != nil {
+ return zero, err
+ }
+ expected, err := activationConsoleEnvironment(o.Roster, local, hostEnv, id, r.HostClaimEnabled)
+ if err != nil {
+ return zero, err
+ }
+ actual, err := activationRead("/etc/carlos/claim-console.env", true)
+ if err != nil || !bytes.Equal(actual, expected) {
+ return zero, errActivation
+ }
+ d := hostfloor.Disk{Dir: "/opt/carlos/system/guard", Bucket: bucket, PublicKey: pub}
+ policyRaw, err := activationRead(filepath.Join(d.Dir, "policy.json"), false)
+ if err != nil {
+ return zero, err
+ }
+ var policy hostfloor.Policy
+ if json.Unmarshal(policyRaw, &policy) != nil || policy.Mode == "unpublished" {
+ return zero, errActivation
+ }
+ floor, err := policy.Verify(pub, bucket)
+ if err != nil || floor.Digest != r.FloorSHA256 {
+ return zero, errActivation
+ }
+ path, err := activationRead(filepath.Join(d.Dir, "console-ready.json"), false)
+ if err != nil {
+ return zero, err
+ }
+ guard, err := activationRead("/etc/systemd/system/console.service.d/90-host-floor.conf", false)
+ if err != nil || string(guard) != hostfloor.ConsoleGuardDropIn(string(path)) {
+ return zero, errActivation
+ }
+ if !filepath.IsAbs(string(path)) || filepath.Clean(string(path)) != string(path) || strings.ContainsAny(string(path), " \t\r\n;") {
+ return zero, errActivation
+ }
+ snapshot := func() (map[string]string, error) {
+ raw, e := claimSystemctl(ctx, "show", "console.service", "--property=MainPID,ActiveState,SubState,ControlGroup,ExecStart,FragmentPath,DropInPaths")
+ if e != nil {
+ return nil, e
+ }
+ m := map[string]string{}
+ for _, line := range strings.Split(string(raw), "\n") {
+ k, v, ok := strings.Cut(line, "=")
+ if ok {
+ m[k] = v
+ }
+ }
+ return m, nil
+ }
+ unit, err := snapshot()
+ if err != nil {
+ return zero, err
+ }
+ pid, start, err := activationConsolePID(r)
+ if err != nil {
+ return zero, err
+ }
+ wantExec := "{ path=/opt/carlos/system/guard/carlos ; argv[]=/opt/carlos/system/guard/carlos system guard-exec console " + string(path) + " serve ;"
+ if unit["ActiveState"] != "active" || unit["SubState"] != "running" || unit["MainPID"] != strconv.Itoa(pid) || !strings.HasPrefix(unit["ExecStart"], wantExec) || strings.Count(unit["ExecStart"], "argv[]=") != 1 || unit["ControlGroup"] != "/system.slice/console.service" {
+ return zero, errActivation
+ }
+ for _, p := range append([]string{unit["FragmentPath"]}, strings.Fields(unit["DropInPaths"])...) {
+ if _, err := activationRead(p, false); err != nil {
+ return zero, err
+ }
+ }
+ if !strings.Contains(unit["DropInPaths"], "/etc/systemd/system/console.service.d/90-host-floor.conf") || !strings.Contains(unit["DropInPaths"], "/etc/systemd/system/console.service.d/91-claim-runtime.conf") {
+ return zero, errActivation
+ }
+ proc := fmt.Sprintf("/proc/%d", pid)
+ check := func() error {
+ boot, e := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if e != nil || strings.TrimSpace(string(boot)) != r.BootID {
+ return errActivation
+ }
+ st, e := os.ReadFile(proc + "/stat")
+ if e != nil {
+ return e
+ }
+ end := strings.LastIndexByte(string(st), ')')
+ if end < 0 {
+ return errActivation
+ }
+ fields := strings.Fields(string(st[end+1:]))
+ if len(fields) < 20 || fields[19] != start {
+ return errActivation
+ }
+ var ns unix.Stat_t
+ if unix.Stat(proc+"/ns/pid", &ns) != nil || strconv.FormatUint(ns.Ino, 10) != strings.Split(r.ProcessID, ":")[1] {
+ return errActivation
+ }
+ group, e := os.ReadFile(proc + "/cgroup")
+ if e != nil || string(group) != "0::"+unit["ControlGroup"]+"\n" {
+ return errActivation
+ }
+ return nil
+ }
+ if err = check(); err != nil {
+ return zero, err
+ }
+ command, err := os.ReadFile(proc + "/cmdline")
+ if err != nil || string(command) != string(path)+"\x00serve\x00" {
+ return zero, errActivation
+ }
+ processEnv, err := os.ReadFile(proc + "/environ")
+ if err != nil {
+ return zero, err
+ }
+ wantedEnv, err := activationEnvironment(expected)
+ if err != nil {
+ return zero, err
+ }
+ for _, name := range []string{"CARLOS_CONSOLE_ID", "CONSOLE_HOME_REGION", "CARLOS_HOST_CLAIM"} {
+ count := 0
+ for _, entry := range strings.Split(string(processEnv), "\x00") {
+ key, value, ok := strings.Cut(entry, "=")
+ if ok && key == name {
+ count++
+ if value != wantedEnv[name] {
+ return zero, errActivation
+ }
+ }
+ }
+ if count != 1 {
+ return zero, errActivation
+ }
+ }
+ checker, err := hostfloor.OpenProtected(filepath.Join(d.Dir, "carlos"))
+ if err != nil {
+ return zero, err
+ }
+ _, err = d.VerifyFile(checker, runtime.GOOS+"-"+runtime.GOARCH)
+ checker.Close()
+ if err != nil {
+ return zero, err
+ }
+ candidate, err := hostfloor.OpenProtected(string(path))
+ if err != nil {
+ return zero, err
+ }
+ defer candidate.Close()
+ f, err := os.Open(proc + "/exe")
+ if err != nil {
+ return zero, err
+ }
+ defer f.Close()
+ currentInfo, err := f.Stat()
+ if err != nil {
+ return zero, err
+ }
+ candidateInfo, err := candidate.Stat()
+ if err != nil || !os.SameFile(currentInfo, candidateInfo) {
+ return zero, errActivation
+ }
+ var st unix.Stat_t
+ if unix.Fstat(int(f.Fd()), &st) != nil || st.Uid != 0 || st.Mode&0022 != 0 {
+ return zero, errActivation
+ }
+ hash, err := d.VerifyFile(f, runtime.GOOS+"-"+runtime.GOARCH, "console")
+ if err != nil || hash != r.BinarySHA256 {
+ return zero, errActivation
+ }
+ if err = check(); err != nil {
+ return zero, err
+ }
+ again, err := snapshot()
+ if err != nil || !reflect.DeepEqual(again, unit) {
+ return zero, errActivation
+ }
+ return status.ConsoleProcess{ConsoleID: id, Enrollment: c.Enrollment, ProcessID: r.ProcessID, BinarySHA256: hash, GuardFloorSHA256: floor.Digest}, nil
+}
+
+func publishActivationConsoleReport(ctx context.Context, store release.Store, pub ed25519.PublicKey, bucket, id string, revision uint64, dry bool) (status.ConsoleProcess, error) {
+ var zero status.ConsoleProcess
+ d := hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket}
+ o, err := d.Read(ctx)
+ if err != nil {
+ return zero, err
+ }
+ if revision == 0 || o.Roster.Revision != revision {
+ return zero, errActivation
+ }
+ c, ok := o.Roster.Consoles[id]
+ if !ok || c.Compute == nil {
+ return zero, errActivation
+ }
+ e := hostactors.ExactEvidence{Store: store, PublicKey: pub, Boxes: d.PrivateBoxReport}
+ r, err := e.ConsoleReport(ctx, id, c)
+ if err != nil {
+ return zero, err
+ }
+ p, err := activationConsoleProcess(ctx, pub, bucket, o, id, r)
+ if err != nil {
+ return zero, err
+ }
+ raw, err := activationRead("/opt/carlos/system/guard/policy.json", false)
+ if err != nil {
+ return zero, err
+ }
+ var policy hostfloor.Policy
+ if json.Unmarshal(raw, &policy) != nil {
+ return zero, errActivation
+ }
+ floor, err := policy.Verify(pub, bucket)
+ if err != nil {
+ return zero, err
+ }
+ if err = hostactors.VerifyReport(ctx, e, pub, floor, r, c.Enrollment, id, time.Now()); err != nil {
+ return zero, err
+ }
+ b := o.Roster.Boxes[c.Compute.Box]
+ key := "status/boxes/" + c.Compute.Box + ".json"
+ body, tag, err := store.Get(ctx, key)
+ if err != nil {
+ return zero, err
+ }
+ br, err := d.PrivateBoxReport(ctx, c.Compute.Box, b)
+ if err != nil {
+ return zero, err
+ }
+ if err = hostactors.VerifyReport(ctx, e, pub, floor, br, b.Enrollment, b.Cloud.InstanceID, time.Now()); err != nil {
+ return zero, err
+ }
+ unchanged, unchangedTag, err := store.Get(ctx, key)
+ if err != nil || unchangedTag != tag || !bytes.Equal(unchanged, body) {
+ return zero, errActivation
+ }
+ if br.BootID != r.BootID {
+ return zero, errActivation
+ }
+ // Keep fields unknown to this binary, including future box metrics.
+ var box map[string]json.RawMessage
+ if json.Unmarshal(body, &box) != nil {
+ return zero, errActivation
+ }
+ var metrics map[string]json.RawMessage
+ if json.Unmarshal(box["metrics"], &metrics) != nil {
+ return zero, errActivation
+ }
+ var processes []status.ConsoleProcess
+ if v := metrics["console_processes"]; len(v) > 0 && json.Unmarshal(v, &processes) != nil {
+ return zero, errActivation
+ }
+ next := []status.ConsoleProcess{}
+ for _, old := range processes {
+ if old.ConsoleID != id {
+ next = append(next, old)
+ }
+ }
+ next = append(next, p)
+ metrics["console_processes"], _ = json.Marshal(next)
+ box["metrics"], _ = json.Marshal(metrics)
+ body, _ = json.Marshal(box)
+ fresh, err := d.Read(ctx)
+ if err != nil || fresh.ETag != o.ETag {
+ return zero, errActivation
+ }
+ if _, err = activationConsoleProcess(ctx, pub, bucket, o, id, r); err != nil {
+ return zero, err
+ }
+ if dry {
+ return p, nil
+ }
+ written, err := store.Put(ctx, key, body, tag)
+ if err != nil {
+ return zero, err
+ }
+ got, readTag, err := store.Get(ctx, key)
+ if err != nil || readTag != written || !bytes.Equal(got, body) {
+ return zero, errActivation
+ }
+ if _, err = activationConsoleProcess(ctx, pub, bucket, o, id, r); err != nil {
+ return zero, err
+ }
+ return p, nil
+}
diff --git a/internal/host/activation_console_report_linux_test.go b/internal/host/activation_console_report_linux_test.go
new file mode 100644
index 000000000..4aa9653f2
--- /dev/null
+++ b/internal/host/activation_console_report_linux_test.go
@@ -0,0 +1,21 @@
+//go:build linux
+
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostactors"
+ "testing"
+)
+
+func TestActivationConsoleProcessIdentity(t *testing.T) {
+ r := hostactors.Report{ProcessID: "boot:4026531836:123:456", BootID: "boot"}
+ if pid, start, err := activationConsolePID(r); err != nil || pid != 123 || start != "456" {
+ t.Fatal(pid, start, err)
+ }
+ for _, id := range []string{"boot:123", "boot:4026531836:0:456", "other:4026531836:123:456", "boot:4026531836:123:0", "boot:bad:123:456"} {
+ r.ProcessID = id
+ if _, _, err := activationConsolePID(r); err == nil {
+ t.Fatal("accepted", id)
+ }
+ }
+}
diff --git a/internal/host/activation_console_rootrunner_linux_test.go b/internal/host/activation_console_rootrunner_linux_test.go
new file mode 100644
index 000000000..7b0d5e17e
--- /dev/null
+++ b/internal/host/activation_console_rootrunner_linux_test.go
@@ -0,0 +1,494 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/claimactivation"
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+var activationConsoleImage, activationCarlosImage []byte
+
+// This test allows only loopback HTTP to its S3 and console fixtures. The
+// executable, guard, systemd, proc observation, HTTP auth and reset are real.
+// Sophie uses the runner’s real AWS identity; Leo, box telemetry and initial
+// app/claim state are fixtures. No deployment bucket or tenant is touched.
+func TestRootActivationConsoleRuntime(t *testing.T) {
+ if os.Geteuid() != 0 || len(activationConsoleImage) == 0 || len(activationCarlosImage) == 0 {
+ t.Fatal("requires root and embedded activation binaries")
+ }
+ uid, gid := generationRunnerRegistryUser(t)
+ instance := status.InstanceID()
+ if instance == "" {
+ t.Fatal("runner instance identity unavailable")
+ }
+ for _, p := range []string{"/opt/carlos", "/etc/carlos", "/etc/systemd/system/console.service", "/etc/systemd/system/console.service.d", "/etc/systemd/system/carlos-edge.service.d", "/etc/systemd/system/carlos-system-update.service.d", "/etc/systemd/system/carlos-system-recover.service.d"} {
+ if _, err := os.Lstat(p); !os.IsNotExist(err) {
+ t.Fatal("pre-existing fixture path", p, err)
+ }
+ }
+ d := activationRootFixture(t)
+ check := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ write := func(path string, b []byte, mode os.FileMode) {
+ t.Helper()
+ check(os.MkdirAll(filepath.Dir(path), 0755))
+ check(os.WriteFile(path, b, mode))
+ }
+ t.Cleanup(func() {
+ out, _ := exec.Command("journalctl", "-u", "console.service", "--no-pager", "-n", "45").CombinedOutput()
+ t.Log(string(out))
+ exec.Command("systemctl", "stop", "console.service").Run()
+ for _, p := range []string{"/opt/carlos", "/etc/carlos", "/etc/systemd/system/console.service", "/etc/systemd/system/console.service.d", "/etc/systemd/system/carlos-edge.service.d", "/etc/systemd/system/carlos-system-update.service.d", "/etc/systemd/system/carlos-system-recover.service.d"} {
+ os.RemoveAll(p)
+ }
+ exec.Command("systemctl", "daemon-reload").Run()
+ exec.Command("systemctl", "reset-failed", "console.service").Run()
+ })
+ store := release.NewDirStore(filepath.Join(d, "bucket"))
+ endpoint := activationS3Fixture(t, store)
+ pub, key, _ := ed25519.GenerateKey(nil)
+ signer := release.RootSigner(key)
+ const bucket = "activation-fixture"
+ ctx := t.Context()
+ directory := hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket}
+ runner := claimactivation.Runner{Directory: directory, Signer: signer}
+ phase := func(p claimactivation.Plan) {
+ t.Helper()
+ p.Bucket = bucket
+ if o, e := directory.Read(ctx); e == nil {
+ p.ExpectedRevision = o.Roster.Revision
+ }
+ before := activationStoreSnapshot(t, store)
+ preview, e := runner.Run(ctx, p, true)
+ check(e)
+ if !reflect.DeepEqual(before, activationStoreSnapshot(t, store)) {
+ t.Fatal("dry run mutated bucket", p.Phase)
+ }
+ result, e := runner.Run(ctx, p, false)
+ check(e)
+ if preview.Revision != result.Revision || len(preview.Writes) != len(result.Writes) {
+ t.Fatal("preview/apply differ", p.Phase, preview, result)
+ }
+ t.Log("phase", p.Phase, "revision", result.Revision, "writes", len(result.Writes))
+ }
+ phase(claimactivation.Plan{Phase: "initialize"})
+ phase(claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "640012953448", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console", "cohort1"}}}})
+ identityRecord, err := readIdentityRecord(&http.Client{Timeout: 3 * time.Second}, imdsBase, "sophie", time.Now())
+ check(err)
+ check(boxid.Publish(ctx, store, identityRecord))
+ boxPlan := claimactivation.BoxInput{Reference: "sophie", Namespace: "platform", Label: "sophie", Region: "ie", Pool: "console", AWSRegion: "eu-west-1", InstanceID: instance, Operator: "root-runner"}
+ phase(claimactivation.Plan{Phase: "invite-box", Box: &boxPlan})
+ phase(claimactivation.Plan{Phase: "enroll-box", Box: &boxPlan})
+ o, err := directory.Read(ctx)
+ check(err)
+ next := hostactors.Clone(o.Roster)
+ next.Boxes["leo"] = hostactors.Box{Enrollment: strings.Repeat("2", 32), Namespace: "platform", BoxID: "leo", Label: "leo", Region: "ie", Pool: "cohort1", Cloud: hostactors.Cloud{Provider: "aws", Account: "640012953448", Region: "eu-west-1", InstanceID: "i-leo"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}
+ _, err = directory.Replace(ctx, signer, o, next)
+ check(err)
+ c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), DeploymentBucket: bucket, Namespaces: []string{"platform"}, Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: next.Boxes["sophie"].Enrollment}}
+ phase(claimactivation.Plan{Phase: "invite-console", Console: &c})
+ phase(claimactivation.Plan{Phase: "enroll-console", Console: &c})
+ rel := release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey("v1.0.0", "linux-arm64"), SHA256: hostfloor.Digest(activationCarlosImage)}}, ConsoleArtifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: "bootstrap/console/v1.0.0/carlos-linux-arm64", SHA256: hostfloor.Digest(activationConsoleImage)}}}
+ _, err = release.PromoteSystem(ctx, store, signer, "router", "stable", rel, release.SystemPromoteOptions{})
+ check(err)
+ identity, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable"))
+ check(err)
+ check(release.PublishSystemIdentity(ctx, store, pub, identity))
+ floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}
+ floorRaw, _ := json.Marshal(floor)
+ auth, err := release.SignRootEnvelope(signer, hostfloor.Authorization{Schema: 1, Purpose: "moves-protocol", DeploymentBucket: bucket, Key: hostfloor.Key, SHA256: hostfloor.Digest(floorRaw)})
+ check(err)
+ policy := hostfloor.Policy{Schema: 1, DeploymentBucket: bucket, Mode: "armed", Floor: floorRaw, Authorization: auth}
+ policyRaw, _ := json.Marshal(policy)
+ local := hostfloor.Enrollment{Box: "sophie", Enrollment: c.Compute.Enrollment, InstanceID: instance}
+ localRaw, _ := json.Marshal(local)
+ write("/opt/carlos/carlos", activationCarlosImage, 0755)
+ write("/opt/carlos/carlos.prev", activationCarlosImage, 0755)
+ write("/opt/carlos/console", activationConsoleImage, 0755)
+ write("/opt/carlos/console.prev", activationConsoleImage, 0755)
+ write(d+"/policy.json", policyRaw, 0600)
+ write(d+"/identity.json", identity, 0600)
+ write(d+"/enrollment.json", localRaw, 0600)
+ check(os.MkdirAll(d+"/data", 0755))
+ check(os.Chown(d+"/data", uid, gid))
+ check(os.Chmod(d, 0755))
+ environment := []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "CARLOS_DEPLOYMENT_BUCKET=" + bucket, "AWS_REGION=eu-west-1", "AWS_DEFAULT_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=fixture", "AWS_SECRET_ACCESS_KEY=fixture", "AWS_EC2_METADATA_DISABLED=true", "AWS_CONFIG_FILE=/nonexistent", "AWS_SHARED_CREDENTIALS_FILE=/nonexistent", "CARLOS_S3_ENDPOINT=" + endpoint, "CARLOS_RELEASE_PUBKEY=" + base64.StdEncoding.EncodeToString(pub), "CARLOS_RELEASE_KEY=" + base64.StdEncoding.EncodeToString(key.Seed()), "CARLOS_DATA_DIR=" + d + "/data"}
+ write("/etc/carlos/host.env", []byte(strings.Join(environment[1:], "\n")+"\n"), 0600)
+ cmd := exec.CommandContext(ctx, "/opt/carlos/carlos", "system", "guard-arm", "--policy", d+"/policy.json", "--identity", d+"/identity.json", "--enrollment", d+"/enrollment.json", "--console", "/opt/carlos/console")
+ cmd.Env = environment
+ if output, err := cmd.CombinedOutput(); err != nil {
+ t.Fatal("guard arm", string(output), err)
+ }
+ dbPath := d + "/data/console.db"
+ st, err := console.Open(dbPath)
+ check(err)
+ now := time.Now().UTC()
+ check(st.CreateAccount(123, "bab", "fixture", "operator@example.test", now))
+ _, err = st.CreateAppIn(123, "trial", "operator@example.test", now)
+ check(err)
+ // OpenGated's schema migration is also used on the fixture before serving.
+ check(st.Close())
+ st, err = console.OpenGated(dbPath)
+ check(err)
+ check(st.Close())
+ st, err = console.Open(dbPath)
+ check(err)
+ check(st.BindClaimAppIdentity(123, "trial", "trial-app-id"))
+ tokenFor := func(address string) string {
+ user, device, e := st.NewCLIAuth("fixture", "127.0.0.1", now)
+ check(e)
+ check(st.ApproveCLIAuth(user, address, now))
+ _, token, _, e := st.PollCLIAuth(device, now)
+ check(e)
+ return token
+ }
+ token := tokenFor("operator@example.test")
+ other := tokenFor("member@example.test")
+ check(st.SetOperator("operator@example.test", true))
+ check(st.Close())
+ for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
+ if _, e := os.Stat(p); e == nil {
+ check(os.Chown(p, uid, gid))
+ }
+ }
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ check(err)
+ addr := ln.Addr().String()
+ check(ln.Close())
+ unit := "[Service]\nType=notify\nUser=carlos\nGroup=carlos\nEnvironmentFile=/etc/carlos/host.env\nEnvironment=CONSOLE_ADDR=" + addr + " CONSOLE_ORIGIN=http://" + addr + " CONSOLE_KEYMAIL=http://127.0.0.1:1 CONSOLE_DB=" + dbPath + "\nExecStart=/opt/carlos/console serve\nRestart=no\nTimeoutStartSec=30s\nNoNewPrivileges=yes\nProtectSystem=strict\nReadWritePaths=" + d + "/data\nPrivateTmp=yes\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n"
+ write("/etc/systemd/system/console.service", []byte(unit), 0644)
+ config := func(enabled bool) {
+ t.Helper()
+ o, e := directory.Read(ctx)
+ check(e)
+ wire, _, e := store.Get(ctx, hostactors.Key)
+ check(e)
+ write(d+"/roster.json", wire, 0600)
+ args := []string{"console-config", "--roster", d + "/roster.json", "--expect-revision", fmt.Sprint(o.Roster.Revision), "--bucket", bucket, "--console-id", c.InstanceID, "--public-key", base64.StdEncoding.EncodeToString(pub)}
+ if enabled {
+ args = append(args, "--enable-console-runtime")
+ }
+ var output bytes.Buffer
+ check(runSystemClaims(append(append([]string{}, args...), "--dry-run"), &output))
+ check(runSystemClaims(args, &output))
+ }
+ config(false)
+ ctl := func(args ...string) {
+ t.Helper()
+ out, e := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
+ if e != nil {
+ t.Fatal(args, string(out), e)
+ }
+ }
+ ctl("start", "console.service")
+ report := func(enabled bool, previous string) hostactors.Report {
+ t.Helper()
+ deadline := time.Now().Add(20 * time.Second)
+ for time.Now().Before(deadline) {
+ raw, _, e := store.Get(ctx, "status/consoles/dev-console.json")
+ var r hostactors.Report
+ if e == nil && json.Unmarshal(raw, &r) == nil && r.HostClaimEnabled == enabled && r.ProcessID != previous {
+ return r
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ t.Fatal("console did not report", enabled)
+ return hostactors.Report{}
+ }
+ first := report(false, "")
+ // Box reports are fixture telemetry. The console process entry is produced
+ // by the real root observer below, never copied from the console report.
+ put := func(k string, v any) {
+ t.Helper()
+ raw, _ := json.Marshal(v)
+ _, tag, _ := store.Get(ctx, k)
+ _, e := store.Put(ctx, k, raw, tag)
+ check(e)
+ }
+ for ref, b := range next.Boxes {
+ put("status/boxes/"+ref+".json", status.Box{GeneratedAt: time.Now().UTC(), Metrics: &status.BoxMetrics{Enrollment: b.Enrollment, InstanceID: b.Cloud.InstanceID, Release: "v1.0.0", ReleaseTag: "v1.0.0", Platform: "linux-arm64", BinarySHA256: hostfloor.Digest(activationCarlosImage), MovesProtocol: 1, BootID: first.BootID, FloorSHA256: hostfloor.Digest(floorRaw), FloorGuardReady: true}})
+ }
+ o, err = directory.Read(ctx)
+ check(err)
+ if _, err = runner.Run(ctx, claimactivation.Plan{Phase: "publish-floor", Bucket: bucket, ExpectedRevision: o.Roster.Revision, Floor: &floor}, true); err == nil {
+ t.Fatal("published without independent process observation")
+ }
+ publish := func() {
+ t.Helper()
+ o, e := directory.Read(ctx)
+ check(e)
+ before := activationStoreSnapshot(t, store)
+ p, e := publishActivationConsoleReport(ctx, store, pub, bucket, c.InstanceID, o.Roster.Revision, true)
+ check(e)
+ if !reflect.DeepEqual(before, activationStoreSnapshot(t, store)) {
+ t.Fatal("report dry run wrote")
+ }
+ cmd := exec.CommandContext(ctx, "/opt/carlos/carlos", "system", "claims", "console-report", "--bucket", bucket, "--aws-region", "eu-west-1", "--expect-revision", fmt.Sprint(o.Roster.Revision), "--console-id", c.InstanceID, "--public-key", base64.StdEncoding.EncodeToString(pub))
+ cmd.Env = environment
+ if output, e := cmd.CombinedOutput(); e != nil {
+ t.Fatal("report CLI", string(output), e)
+ }
+ t.Log("authenticated process", p.ProcessID, p.BinarySHA256)
+ }
+ publish()
+ phase(claimactivation.Plan{Phase: "publish-floor", Floor: &floor})
+ phase(claimactivation.Plan{Phase: "home", HomeRegion: "ie"})
+ binding := hostactors.CensusKeyBinding{Bucket: bucket, Box: "leo", Enrollment: next.Boxes["leo"].Enrollment}
+ proof, err := installActivationEvidenceKey(d+"/leo-evidence.key", binding, false)
+ check(err)
+ phase(claimactivation.Plan{Phase: "bind-key", Binding: &proof.Binding, Proof: proof.Proof})
+ config(true)
+ ctl("restart", "console.service")
+ second := report(true, first.ProcessID)
+ publish()
+ raw, err := os.ReadFile("/etc/carlos/host.env")
+ check(err)
+ if bytes.Contains(raw, []byte("CARLOS_HOST_CLAIM=")) {
+ t.Fatal("enabled host execution")
+ }
+ o, err = directory.Read(ctx)
+ check(err)
+ forged := second
+ forged.ProcessID = first.ProcessID
+ if _, err = activationConsoleProcess(ctx, pub, bucket, o, c.InstanceID, forged); err == nil {
+ t.Fatal("accepted old process")
+ }
+ forged = second
+ forged.BinarySHA256 = strings.Repeat("a", 64)
+ if _, err = activationConsoleProcess(ctx, pub, bucket, o, c.InstanceID, forged); err == nil {
+ t.Fatal("accepted false process digest")
+ }
+ activationResetFixture(t, store, "http://"+addr, token, other)
+ ctl("stop", "console.service")
+ if _, err = publishActivationConsoleReport(ctx, store, pub, bucket, c.InstanceID, o.Roster.Revision, false); err == nil {
+ t.Fatal("reported stopped console")
+ }
+ t.Log("PASS real guard-arm, guarded console, claims-off/on reports, floor/home/key sequence, authenticated reset and refusals")
+}
+
+func activationStoreSnapshot(t *testing.T, s release.Store) map[string]string {
+ t.Helper()
+ keys, err := s.List(t.Context(), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ result := map[string]string{}
+ for _, k := range keys {
+ raw, tag, e := s.Get(t.Context(), k)
+ if e != nil {
+ t.Fatal(e)
+ }
+ result[k] = tag + ":" + string(raw)
+ }
+ return result
+}
+
+func activationS3Fixture(t *testing.T, s release.Store) string {
+ t.Helper()
+ var mu sync.Mutex
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+ _, key, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
+ fail := func(code int, name string) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(code)
+ fmt.Fprintf(w, "<Error><Code>%s</Code></Error>", name)
+ }
+ switch r.Method {
+ case "GET", "HEAD":
+ if key == "" {
+ prefix := r.URL.Query().Get("prefix")
+ keys, e := s.List(r.Context(), prefix)
+ if e != nil {
+ fail(500, "InternalError")
+ return
+ }
+ type item struct{ Key string }
+ type common struct{ Prefix string }
+ result := struct {
+ XMLName xml.Name `xml:"ListBucketResult"`
+ IsTruncated bool
+ Contents []item
+ CommonPrefixes []common
+ }{}
+ seen := map[string]bool{}
+ for _, k := range keys {
+ if delim := r.URL.Query().Get("delimiter"); delim != "" {
+ rest := strings.TrimPrefix(k, prefix)
+ if at := strings.Index(rest, delim); at >= 0 {
+ p := prefix + rest[:at+len(delim)]
+ if !seen[p] {
+ result.CommonPrefixes = append(result.CommonPrefixes, common{p})
+ seen[p] = true
+ }
+ continue
+ }
+ }
+ result.Contents = append(result.Contents, item{k})
+ }
+ w.Header().Set("Content-Type", "application/xml")
+ xml.NewEncoder(w).Encode(result)
+ return
+ }
+ raw, tag, e := s.Get(r.Context(), key)
+ if e != nil {
+ fail(404, "NoSuchKey")
+ return
+ }
+ w.Header().Set("ETag", `"`+tag+`"`)
+ w.Header().Set("Content-Length", fmt.Sprint(len(raw)))
+ if r.Method == "GET" {
+ w.Write(raw)
+ }
+ case "PUT":
+ raw, e := io.ReadAll(r.Body)
+ if e != nil {
+ fail(400, "IncompleteBody")
+ return
+ }
+ _, old, e := s.Get(r.Context(), key)
+ if e != nil && !errors.Is(e, os.ErrNotExist) {
+ fail(500, "InternalError")
+ return
+ }
+ before := strings.Trim(r.Header.Get("If-Match"), `"`)
+ if r.Header.Get("If-None-Match") == "*" && old != "" || before != "" && before != old {
+ fail(412, "PreconditionFailed")
+ return
+ }
+ tag, e := s.Put(r.Context(), key, raw, old)
+ if e != nil {
+ fail(412, "PreconditionFailed")
+ return
+ }
+ w.Header().Set("ETag", `"`+tag+`"`)
+ case "DELETE":
+ if e := s.Delete(r.Context(), key); e != nil && !errors.Is(e, os.ErrNotExist) {
+ fail(500, "InternalError")
+ return
+ }
+ w.WriteHeader(204)
+ default:
+ fail(400, "InvalidRequest")
+ }
+ }))
+ t.Cleanup(server.Close)
+ return server.URL
+}
+
+func activationResetFixture(t *testing.T, s release.Store, origin, token, other string) {
+ t.Helper()
+ ctx := t.Context()
+ check := func(e error) {
+ t.Helper()
+ if e != nil {
+ t.Fatal(e)
+ }
+ }
+ now := time.Now().UTC().Add(-time.Minute)
+ const host = "trial.bab.example.test"
+ record := []byte(`{"schema":1,"host":"trial.bab.example.test","account":"bab","app":"trial","region":"ie","created_at":"` + now.Format(time.RFC3339Nano) + `"}`)
+ o, err := hostclaim.Read(ctx, s, host)
+ check(err)
+ o, err = hostclaim.Update(ctx, s, o, hostclaim.BeginCreate(hostclaim.NewHolder{Family: "instance", Key: "apps/bab/trial/instances/" + host + ".json", Account: "bab", App: "trial", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: now}, record, "create", "dev-console", now))
+ check(err)
+ o, err = hostclaim.Update(ctx, s, o, hostclaim.CompleteCreate("create", "leo"))
+ check(err)
+ for i := 0; i < hostclaim.WakeRetryLimit; i++ {
+ a := hostclaim.WakeRetryAttempt{ID: fmt.Sprint(i), Box: "leo", Enrollment: strings.Repeat("2", 32), BootID: "fixture", ControllerID: "fixture", InvocationID: "fixture", LaunchInputs: "fixture"}
+ o, err = hostclaim.Update(ctx, s, o, hostclaim.ReserveWakeRetryAttempt(hostclaim.WakeRetryReservation{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "exhausted", Attempt: a, Now: now}))
+ check(err)
+ o, err = hostclaim.Update(ctx, s, o, hostclaim.FailWakeRetryAttempt(hostclaim.WakeRetryResult{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "exhausted", Attempt: a, Now: now}))
+ check(err)
+ now = now.Add(20 * time.Second)
+ }
+ admission := hostops.AppAdmission{V: 1, AppID: "trial-app-id", Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}}
+ raw, _ := json.Marshal(admission)
+ key, _ := hostops.AdmissionKey("bab", "trial")
+ _, err = s.Put(ctx, key, raw, "")
+ check(err)
+ payload := `{"action":"reset-wake-retry","incarnation":"` + o.Claim.Holder.Incarnation + `","episode":"exhausted","request_id":"fixture-reset"}`
+ call := func(auth, body string) int {
+ t.Helper()
+ req, _ := http.NewRequestWithContext(ctx, "POST", origin+"/api/cli/ops/hosts/"+host+"/resolve", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ if auth != "" {
+ req.Header.Set("Authorization", "Bearer "+auth)
+ }
+ client := http.Client{Timeout: 10 * time.Second}
+ resp, e := client.Do(req)
+ check(e)
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ t.Log("reset response", resp.StatusCode, string(b))
+ return resp.StatusCode
+ }
+ for _, test := range []struct {
+ token string
+ status int
+ }{{"", 401}, {other, 404}} {
+ if code := call(test.token, payload); code != test.status {
+ t.Fatal(code, test.status)
+ }
+ }
+ before, _ := hostclaim.Read(ctx, s, host)
+ if before.ETag != o.ETag {
+ t.Fatal("unauthorized reset changed claim")
+ }
+ if code := call(token, payload); code != 200 {
+ t.Fatal("reset failed", code)
+ }
+ after, err := hostclaim.Read(ctx, s, host)
+ check(err)
+ retry := after.Claim.Holder.WakeRetry
+ if retry.EpisodeID == "exhausted" || retry.AttemptsReserved != 0 || retry.State != "open" || retry.ResetRequest != `{"Actor":"operator@example.test","RequestID":"fixture-reset"}` {
+ t.Fatal("reset result", retry)
+ }
+ previous := *o.Claim.Holder
+ previous.WakeRetry = retry
+ if !reflect.DeepEqual(&previous, after.Claim.Holder) || !reflect.DeepEqual(o.Claim.Op, after.Claim.Op) {
+ t.Fatal("reset changed execution authority")
+ }
+ if code := call(token, payload); code != 409 {
+ t.Fatal("stale reset accepted", code)
+ }
+ final, err := hostclaim.Read(ctx, s, host)
+ check(err)
+ if final.ETag != after.ETag {
+ t.Fatal("stale reset mutated claim")
+ }
+ t.Log("exhausted retry reset through production runtime; holder/session/stamp preserved")
+}
diff --git a/internal/host/activation_console_test.go b/internal/host/activation_console_test.go
new file mode 100644
index 000000000..3b5abcca1
--- /dev/null
+++ b/internal/host/activation_console_test.go
@@ -0,0 +1,36 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "strings"
+ "testing"
+)
+
+func activationConsoleRoster() (hostactors.Roster, hostfloor.Enrollment) {
+ en := strings.Repeat("a", 32)
+ local := hostfloor.Enrollment{Box: "sophie", Enrollment: en, InstanceID: "i-sophie"}
+ return hostactors.Roster{HomeRegion: "ie", RequestLimitNS: int64(hostbox.RequestLimit), Publication: "published", Boxes: map[string]hostactors.Box{"sophie": {Enrollment: en, State: "active", Region: "ie", Cloud: hostactors.Cloud{Region: "eu-west-1", InstanceID: "i-sophie"}}}, Consoles: map[string]hostactors.Console{"dev-console": {InstanceID: "dev-console", Enrollment: strings.Repeat("b", 32), Namespaces: []string{"platform"}, Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: en}}}}, local
+}
+func TestActivationConsoleSeparatesHostExecution(t *testing.T) {
+ r, local := activationConsoleRoster()
+ for _, runtime := range []bool{false, true} {
+ got, err := activationConsoleEnvironment(r, local, []byte("CARLOS_REGION=\n"), "dev-console", runtime)
+ if err != nil || !strings.Contains(string(got), "CARLOS_CONSOLE_ID=dev-console\nCONSOLE_HOME_REGION=ie\n") {
+ t.Fatal(string(got), err)
+ }
+ if strings.Contains(string(got), "CARLOS_HOST_CLAIM=1\n") != runtime {
+ t.Fatal("wrong runtime flag", string(got))
+ }
+ }
+ for _, env := range []string{"CARLOS_HOST_CLAIM=1\n", "CARLOS_HOST_CLAIM=0\n", "CARLOS_REGION=ie\n", "malformed"} {
+ if _, err := activationConsoleEnvironment(r, local, []byte(env), "dev-console", true); err == nil {
+ t.Fatal("host execution accepted", env)
+ }
+ }
+ local.InstanceID = "i-leo"
+ if _, err := activationConsoleEnvironment(r, local, nil, "dev-console", true); err == nil {
+ t.Fatal("wrong local box accepted")
+ }
+}
diff --git a/internal/host/activation_install.go b/internal/host/activation_install.go
new file mode 100644
index 000000000..a9fe3636b
--- /dev/null
+++ b/internal/host/activation_install.go
@@ -0,0 +1,68 @@
+package host
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+var errActivation = errors.New("claim activation: input or protected state refused")
+
+func activationEnvironment(raw []byte) (map[string]string, error) {
+ values := map[string]string{}
+ scan := bufio.NewScanner(bytes.NewReader(raw))
+ for scan.Scan() {
+ line := strings.TrimSpace(scan.Text())
+ if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
+ continue
+ }
+ key, value, ok := strings.Cut(line, "=")
+ key = strings.TrimSpace(key)
+ value = strings.TrimSpace(value)
+ if !ok || !regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`).MatchString(key) {
+ return nil, errActivation
+ }
+ if _, exists := values[key]; exists {
+ return nil, errActivation
+ }
+ if len(value) >= 2 && (value[0] == '"' && value[len(value)-1] == '"' || value[0] == '\'' && value[len(value)-1] == '\'') {
+ value = value[1 : len(value)-1]
+ }
+ if strings.ContainsAny(value, "\\\"'\x00\r\n") {
+ return nil, errActivation
+ }
+ values[key] = value
+ }
+ if scan.Err() != nil {
+ return nil, scan.Err()
+ }
+ return values, nil
+}
+
+func activationUnitSettings(raw []byte) (claimUnitSettings, error) {
+ v, err := activationEnvironment(raw)
+ if err != nil {
+ return claimUnitSettings{}, err
+ }
+ cfg := claimUnitSettings{enabled: strings.TrimSpace(v["CARLOS_HOST_CLAIM"]) != "", data: v["CARLOS_DATA_DIR"], opt: v["CARLOS_OPT_DIR"]}
+ if !cfg.enabled || v["CARLOS_REGION"] != "" {
+ return claimUnitSettings{}, errActivation
+ }
+ if cfg.data == "" {
+ cfg.data = "/data"
+ }
+ if cfg.opt == "" {
+ cfg.opt = "/opt/carlos"
+ }
+ for _, p := range []string{cfg.data, cfg.opt} {
+ if !filepath.IsAbs(p) || filepath.Clean(p) != p || p == "/" || strings.ContainsAny(p, "\x00\r\n") {
+ return claimUnitSettings{}, errActivation
+ }
+ }
+ return cfg, nil
+}
+
+type activationFenceScope struct{ Bucket, Region, Role string }
diff --git a/internal/host/activation_install_linux.go b/internal/host/activation_install_linux.go
new file mode 100644
index 000000000..6e09a2db9
--- /dev/null
+++ b/internal/host/activation_install_linux.go
@@ -0,0 +1,345 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfence"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/safefile"
+ "golang.org/x/sys/unix"
+)
+
+// Each component is opened relative to the checked parent, before any create.
+func activationDir(path string, create bool) (*os.File, error) {
+ if os.Geteuid() != 0 || !filepath.IsAbs(path) || filepath.Clean(path) != path {
+ return nil, errActivation
+ }
+ fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, err
+ }
+ for _, name := range strings.Split(strings.TrimPrefix(path, "/"), "/") {
+ var st unix.Stat_t
+ if err = unix.Fstat(fd, &st); err != nil || st.Uid != 0 || st.Mode&0022 != 0 {
+ unix.Close(fd)
+ return nil, errActivation
+ }
+ if name == "" {
+ continue
+ }
+ next, e := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if e == unix.ENOENT && create {
+ e = unix.Mkdirat(fd, name, 0700)
+ if e == nil || e == unix.EEXIST {
+ if e = unix.Fsync(fd); e == nil {
+ next, e = unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ }
+ }
+ }
+ unix.Close(fd)
+ if e != nil {
+ return nil, e
+ }
+ fd = next
+ }
+ var st unix.Stat_t
+ if err = unix.Fstat(fd, &st); err != nil || st.Uid != 0 || st.Mode&0022 != 0 {
+ unix.Close(fd)
+ return nil, errActivation
+ }
+ return os.NewFile(uintptr(fd), path), nil
+}
+
+func activationReadAt(dir *os.File, name string, private bool) ([]byte, error) {
+ f, err := safefile.OpenFileAt(int(dir.Fd()), name, os.O_RDONLY, 0)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ var st unix.Stat_t
+ if err = unix.Fstat(int(f.Fd()), &st); err != nil {
+ return nil, err
+ }
+ if st.Uid != 0 || st.Mode&0022 != 0 || private && st.Mode&0777 != 0600 && st.Mode&0777 != 0400 {
+ return nil, errActivation
+ }
+ raw, err := io.ReadAll(io.LimitReader(f, 65537))
+ if err != nil {
+ return nil, err
+ }
+ if len(raw) > 65536 {
+ return nil, errActivation
+ }
+ return raw, nil
+}
+func activationRead(path string, private bool) ([]byte, error) {
+ dir, err := activationDir(filepath.Dir(path), false)
+ if err != nil {
+ return nil, err
+ }
+ defer dir.Close()
+ return activationReadAt(dir, filepath.Base(path), private)
+}
+func activationLock(dir *os.File) (*os.File, error) {
+ f, err := safefile.OpenFileAt(int(dir.Fd()), ".claims-activation.lock", os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return nil, err
+ }
+ var st unix.Stat_t
+ if err = unix.Fstat(int(f.Fd()), &st); err != nil || st.Uid != 0 || st.Mode&0777 != 0600 {
+ f.Close()
+ return nil, errActivation
+ }
+ if err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ f.Close()
+ return nil, err
+ }
+ return f, nil
+}
+
+type activationKeyProof struct {
+ Binding hostactors.CensusKeyBinding `json:"binding"`
+ Proof []byte `json:"proof"`
+}
+
+func installActivationEvidenceKey(path string, binding hostactors.CensusKeyBinding, dry bool) (activationKeyProof, error) {
+ var result activationKeyProof
+ if binding.Bucket == "" || binding.Box == "" || len(binding.Enrollment) != 32 || len(binding.PublicKey) != 0 && len(binding.PublicKey) != ed25519.PublicKeySize {
+ return result, errActivation
+ }
+ dir, err := activationDir(filepath.Dir(path), false)
+ if err != nil {
+ return result, err
+ }
+ defer dir.Close()
+ if !dry {
+ lock, e := activationLock(dir)
+ if e != nil {
+ return result, e
+ }
+ defer lock.Close()
+ }
+ raw, err := activationReadAt(dir, filepath.Base(path), true)
+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ENOENT) {
+ if len(binding.PublicKey) != 0 {
+ return result, errActivation
+ }
+ if dry {
+ return result, nil
+ }
+ _, key, e := ed25519.GenerateKey(rand.Reader)
+ if e != nil {
+ return result, e
+ }
+ raw = []byte(base64.StdEncoding.EncodeToString(key.Seed()) + "\n")
+ if err = safefile.WriteAt(int(dir.Fd()), filepath.Base(path), raw, 0600); err != nil {
+ return result, err
+ }
+ verified, e := activationReadAt(dir, filepath.Base(path), true)
+ if e != nil || !bytes.Equal(raw, verified) {
+ return result, errActivation
+ }
+ } else if err != nil {
+ return result, err
+ }
+ key, err := release.ParseSigningKey(strings.TrimSpace(string(raw)), path)
+ if err != nil {
+ return result, errActivation
+ }
+ if len(binding.PublicKey) != 0 && !bytes.Equal(binding.PublicKey, key.Public().(ed25519.PublicKey)) {
+ return result, errActivation
+ }
+ binding.PublicKey = key.Public().(ed25519.PublicKey)
+ wire, err := json.Marshal(binding)
+ if err != nil {
+ return result, err
+ }
+ result = activationKeyProof{Binding: binding, Proof: ed25519.Sign(key, wire)}
+ if !ed25519.Verify(binding.PublicKey, wire, result.Proof) {
+ return activationKeyProof{}, errActivation
+ }
+ return result, nil
+}
+func installActivationFenceCredentials(source, target string, scope activationFenceScope, dry bool) error {
+ raw, err := activationRead(source, true)
+ if err != nil {
+ return err
+ }
+ if err = hostfence.ValidateActivationCredentials(raw, scope.Bucket, scope.Region, scope.Role); err != nil {
+ return errActivation
+ }
+ dir, err := activationDir(filepath.Dir(target), !dry)
+ if dry && (errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ENOENT)) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+ if !dry {
+ lock, e := activationLock(dir)
+ if e != nil {
+ return e
+ }
+ defer lock.Close()
+ }
+ existing, err := activationReadAt(dir, filepath.Base(target), true)
+ if err == nil {
+ if !bytes.Equal(existing, raw) {
+ return errActivation
+ }
+ } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, unix.ENOENT) {
+ return err
+ }
+ receipt := hostfence.ActivationCredentialReceipt(raw)
+ prior, receiptErr := activationReadAt(dir, "credentials.installation.json", true)
+ if receiptErr == nil && !bytes.Equal(prior, receipt) {
+ return errActivation
+ }
+ if receiptErr != nil && !errors.Is(receiptErr, os.ErrNotExist) {
+ return receiptErr
+ }
+ if dry {
+ return nil
+ }
+ if err != nil {
+ if err = safefile.WriteAt(int(dir.Fd()), filepath.Base(target), raw, 0600); err != nil {
+ return err
+ }
+ }
+ after, err := activationReadAt(dir, filepath.Base(target), true)
+ if err != nil || !bytes.Equal(raw, after) {
+ return errActivation
+ }
+ if receiptErr != nil {
+ if err = safefile.WriteAt(int(dir.Fd()), "credentials.installation.json", receipt, 0600); err != nil {
+ return err
+ }
+ }
+ after, err = activationReadAt(dir, "credentials.installation.json", true)
+ if err != nil || !bytes.Equal(receipt, after) {
+ return errActivation
+ }
+ return nil
+}
+
+func installActivationControl(envPath, unitPath string, dry bool, systemctl func(...string) (string, error)) error {
+ raw, err := activationRead(envPath, false)
+ if err != nil {
+ return err
+ }
+ cfg, err := activationUnitSettings(raw)
+ if err != nil {
+ return err
+ }
+ dir, err := activationDir(unitPath, false)
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+ // Refuse a masked/symlinked control unit, including an operator's own mask.
+ if _, err = activationReadAt(dir, claimControlUnit, false); err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, unix.ENOENT) {
+ return err
+ }
+ content, err := unitsFS.ReadFile("units/" + claimControlUnit)
+ if err != nil {
+ return err
+ }
+ if dry {
+ return nil
+ }
+ lock, err := activationLock(dir)
+ if err != nil {
+ return err
+ }
+ defer lock.Close()
+ cfg.rootRetry, err = prepareRootRetryUnitDirectory()
+ if err != nil {
+ return err
+ }
+ content = claimUnitContent(claimControlUnit, content, cfg)
+ if err = safefile.WriteAt(int(dir.Fd()), claimControlUnit, content, 0644); err != nil {
+ return err
+ }
+ after, err := activationReadAt(dir, claimControlUnit, false)
+ if err != nil || !bytes.Equal(after, content) {
+ return errActivation
+ }
+ _, err = systemctl("daemon-reload")
+ return err
+}
+
+func installActivationConsole(envPath, dropPath string, raw []byte, dry bool, systemctl func(...string) (string, error)) error {
+ parent, err := activationDir(filepath.Dir(envPath), false)
+ if err != nil {
+ return err
+ }
+ defer parent.Close()
+ unitParent, err := activationDir(filepath.Dir(dropPath), false)
+ if err != nil {
+ return err
+ }
+ defer unitParent.Close()
+ if !dry {
+ lock, e := activationLock(parent)
+ if e != nil {
+ return e
+ }
+ defer lock.Close()
+ }
+ existing, err := activationReadAt(parent, filepath.Base(envPath), true)
+ off := bytes.Replace(raw, []byte("CARLOS_HOST_CLAIM=1\n"), []byte("CARLOS_HOST_CLAIM=\n"), 1)
+ if err == nil && !bytes.Equal(existing, raw) && !bytes.Equal(existing, off) {
+ return errActivation
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ drop, err := activationDir(dropPath, !dry)
+ if dry && errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ defer drop.Close()
+ content := []byte("[Service]\nEnvironmentFile=" + envPath + "\n")
+ old, err := activationReadAt(drop, "91-claim-runtime.conf", false)
+ if err == nil && !bytes.Equal(old, content) {
+ return errActivation
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if dry {
+ return nil
+ }
+ if err = safefile.WriteAt(int(parent.Fd()), filepath.Base(envPath), raw, 0600); err != nil {
+ return err
+ }
+ if err = safefile.WriteAt(int(drop.Fd()), "91-claim-runtime.conf", content, 0644); err != nil {
+ return err
+ }
+ got, err := activationReadAt(parent, filepath.Base(envPath), true)
+ if err != nil || !bytes.Equal(got, raw) {
+ return errActivation
+ }
+ got, err = activationReadAt(drop, "91-claim-runtime.conf", false)
+ if err != nil || !bytes.Equal(got, content) {
+ return errActivation
+ }
+ _, err = systemctl("daemon-reload")
+ return err
+}
diff --git a/internal/host/activation_install_rootrunner_linux_test.go b/internal/host/activation_install_rootrunner_linux_test.go
new file mode 100644
index 000000000..c90890990
--- /dev/null
+++ b/internal/host/activation_install_rootrunner_linux_test.go
@@ -0,0 +1,329 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfence"
+)
+
+func activationRootFixture(t *testing.T) string {
+ t.Helper()
+ if os.Geteuid() != 0 {
+ t.Fatal("real root required")
+ }
+ d, err := os.MkdirTemp("/var/lib", "carlos-activation-test-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(d) })
+ return d
+}
+func TestRootActivationSecrets(t *testing.T) {
+ d := activationRootFixture(t)
+ binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: strings.Repeat("a", 32)}
+ p := filepath.Join(d, "host-evidence.key")
+ result, err := installActivationEvidenceKey(p, binding, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := readClaimEvidenceKey(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wire, _ := json.Marshal(result.Binding)
+ if !ed25519.Verify(key.Public().(ed25519.PublicKey), wire, result.Proof) {
+ t.Fatal("proof does not bind installed key")
+ }
+ again, err := installActivationEvidenceKey(p, binding, false)
+ if err != nil || !bytes.Equal(again.Binding.PublicKey, result.Binding.PublicKey) {
+ t.Fatal("replay rotated key", err)
+ }
+ st, _ := os.Stat(p)
+ if st.Mode().Perm() != 0600 {
+ t.Fatal(st.Mode())
+ }
+ before, _ := os.ReadFile(p)
+ _, err = installActivationEvidenceKey(p, binding, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ after, _ := os.ReadFile(p)
+ if !bytes.Equal(before, after) {
+ t.Fatal("dry run changed key")
+ }
+ dry := filepath.Join(d, "absent.key")
+ if _, err = installActivationEvidenceKey(dry, binding, true); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = os.Lstat(dry); !os.IsNotExist(err) {
+ t.Fatal("dry run created key")
+ }
+ for _, kind := range []string{"symlink", "hardlink", "writable-parent", "mode"} {
+ t.Run(kind, func(t *testing.T) {
+ parent := filepath.Join(d, kind)
+ if err := os.Mkdir(parent, 0700); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(parent, "key")
+ switch kind {
+ case "symlink":
+ os.Symlink(p, target)
+ case "hardlink":
+ os.Link(p, target)
+ case "writable-parent":
+ os.Chmod(parent, 0777)
+ case "mode":
+ os.WriteFile(target, before, 0644)
+ }
+ if _, err := installActivationEvidenceKey(target, binding, false); err == nil {
+ t.Fatal("unsafe path accepted")
+ }
+ os.Remove(target)
+ os.Chmod(parent, 0700)
+ })
+ }
+}
+func TestRootActivationFenceCredentials(t *testing.T) {
+ d := activationRootFixture(t)
+ source := filepath.Join(d, "source.json")
+ target := filepath.Join(d, "credentials.json")
+ raw := []byte(`{"role":"arn:aws:iam::123456789012:role/fence","region":"eu-west-1","bucket":"bucket","access_key":"fixture","secret_key":"fixture-secret"}`)
+ if err := os.WriteFile(source, raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ scope := activationFenceScope{Bucket: "bucket", Region: "eu-west-1", Role: "arn:aws:iam::123456789012:role/fence"}
+ if err := installActivationFenceCredentials(source, target, scope, true); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(target); !os.IsNotExist(err) {
+ t.Fatal("dry run wrote credentials")
+ }
+ if err := installActivationFenceCredentials(source, target, scope, false); err != nil {
+ t.Fatal(err)
+ }
+ st, _ := os.Stat(target)
+ if st.Mode().Perm() != 0600 {
+ t.Fatal(st.Mode())
+ }
+ if err := installActivationFenceCredentials(source, target, scope, false); err != nil {
+ t.Fatal(err)
+ }
+ scope.Bucket = "wrong"
+ if err := installActivationFenceCredentials(source, target, scope, false); err == nil {
+ t.Fatal("scope mismatch accepted")
+ }
+ scope.Bucket = "bucket"
+ os.WriteFile(source, bytes.ReplaceAll(raw, []byte("fixture-secret"), []byte("replacement")), 0600)
+ if err := installActivationFenceCredentials(source, target, scope, false); err == nil {
+ t.Fatal("rotation accepted")
+ }
+ got, _ := os.ReadFile(target)
+ if !bytes.Equal(got, raw) {
+ t.Fatal("existing credentials replaced")
+ }
+}
+func TestRootActivationControlInstaller(t *testing.T) {
+ d := activationRootFixture(t)
+ env := filepath.Join(d, "host.env")
+ unitDir := filepath.Join(d, "units")
+ os.Mkdir(unitDir, 0755)
+ os.WriteFile(env, []byte("CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM_ORIGINS=1\n"), 0600)
+ for _, name := range rootMigrationOfflineUnits {
+ if err := os.Symlink("/dev/null", filepath.Join(unitDir, name)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ calls := []string{}
+ ctl := func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")); return "", nil }
+ if err := installActivationControl(env, unitDir, true, ctl); err != nil {
+ t.Fatal(err)
+ }
+ if len(calls) != 0 {
+ t.Fatal("dry run called systemd")
+ }
+ if _, err := os.Stat(filepath.Join(unitDir, claimControlUnit)); !os.IsNotExist(err) {
+ t.Fatal("dry run installed")
+ }
+ if err := installActivationControl(env, unitDir, false, ctl); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(filepath.Join(unitDir, claimControlUnit))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"StateDirectory=carlos-claims\n", "MemoryMax=256M\n", claimControlEnvironment} {
+ if !bytes.Contains(got, []byte(want)) {
+ t.Fatal("missing", want)
+ }
+ }
+ if strings.Join(calls, ",") != "daemon-reload" {
+ t.Fatal("unexpected startup", calls)
+ }
+ for _, name := range rootMigrationOfflineUnits {
+ target, err := os.Readlink(filepath.Join(unitDir, name))
+ if err != nil || target != "/dev/null" {
+ t.Fatal("mask changed", name, err)
+ }
+ }
+ os.WriteFile(env, []byte("CARLOS_HOST_CLAIM=1\nmalformed\n"), 0600)
+ if err := installActivationControl(env, unitDir, false, ctl); err == nil {
+ t.Fatal("malformed config accepted")
+ }
+ after, _ := os.ReadFile(filepath.Join(unitDir, claimControlUnit))
+ if !bytes.Equal(got, after) {
+ t.Fatal("unit changed after refusal")
+ }
+}
+
+func TestRootActivationConsoleInstaller(t *testing.T) {
+ d := activationRootFixture(t)
+ env := filepath.Join(d, "claim-console.env")
+ drop := filepath.Join(d, "console.service.d")
+ calls := []string{}
+ ctl := func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")); return "", nil }
+ raw := []byte("CARLOS_CONSOLE_ID=dev-console\nCONSOLE_HOME_REGION=ie\nCARLOS_HOST_CLAIM=\n")
+ if err := installActivationConsole(env, drop, raw, true, ctl); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(env); !os.IsNotExist(err) {
+ t.Fatal("dry run changed config")
+ }
+ if err := installActivationConsole(env, drop, raw, false, ctl); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := os.ReadFile(env)
+ if !bytes.Equal(got, raw) {
+ t.Fatal("config mismatch")
+ }
+ if strings.Join(calls, ",") != "daemon-reload" {
+ t.Fatal("started console", calls)
+ }
+ os.WriteFile(env, []byte("operator-change\n"), 0600)
+ if err := installActivationConsole(env, drop, raw, false, ctl); err == nil {
+ t.Fatal("overwrote operator config")
+ }
+}
+
+func TestRootActivationControlSystemd(t *testing.T) {
+ activationRootFixture(t)
+ paths := []string{"/etc/carlos", "/etc/systemd/system/" + claimControlUnit, "/etc/systemd/system/.claims-activation.lock"}
+ for _, name := range rootMigrationOfflineUnits {
+ paths = append(paths, "/etc/systemd/system/"+name)
+ }
+ for _, path := range paths {
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Fatal("pre-existing fixture path", path, err)
+ }
+ }
+ t.Cleanup(func() {
+ for _, path := range paths {
+ if err := os.RemoveAll(path); err != nil {
+ t.Error(err)
+ }
+ }
+ if raw, err := claimSystemctl(context.Background(), "daemon-reload"); err != nil {
+ t.Error(string(raw), err)
+ }
+ })
+ if err := os.Mkdir("/etc/carlos", 0700); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range rootMigrationOfflineUnits {
+ if err := os.Symlink("/dev/null", "/etc/systemd/system/"+name); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if raw, err := claimSystemctl(t.Context(), "daemon-reload"); err != nil {
+ t.Fatal(string(raw), err)
+ }
+ if err := os.WriteFile("/etc/carlos/host.env", []byte("CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM_ORIGINS=1\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ var result bytes.Buffer
+ if err := runSystemClaims([]string{"install-control"}, &result); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := claimSystemctl(t.Context(), "cat", claimControlUnit)
+ if err != nil || !bytes.Contains(raw, []byte("MemoryMax=256M")) {
+ t.Fatal(string(raw), err)
+ }
+ if err = verifyOfflineRootMigration(t.Context()); err != nil {
+ t.Fatal("migration masks changed", err)
+ }
+ raw, err = claimSystemctl(t.Context(), "show", claimControlUnit, "--property=ActiveState", "--value")
+ if err != nil || strings.TrimSpace(string(raw)) != "inactive" {
+ t.Fatal("control started", string(raw), err)
+ }
+}
+
+func TestRootActivationFenceStoreReceipt(t *testing.T) {
+ if _, err := os.Lstat("/etc/carlos"); !os.IsNotExist(err) {
+ t.Fatal("pre-existing config directory", err)
+ }
+ if err := os.Mkdir("/etc/carlos", 0700); err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll("/etc/carlos")
+ source := "/etc/carlos/fence-source.json"
+ target := "/etc/carlos/fence-writer/credentials.json"
+ raw := []byte(`{"role":"arn:aws:iam::123456789012:role/fence","region":"eu-west-1","bucket":"bucket","access_key":"fixture","secret_key":"fixture-secret"}`)
+ if err := os.WriteFile(source, raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ scope := activationFenceScope{Bucket: "bucket", Region: "eu-west-1", Role: "arn:aws:iam::123456789012:role/fence"}
+ if err := installActivationFenceCredentials(source, target, scope, false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err != nil {
+ t.Fatal("installed credentials refused", err)
+ }
+ marker := "/etc/carlos/fence-writer/credentials.installation.json"
+ if err := os.Remove(marker); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err == nil {
+ t.Fatal("uninstalled 0600 credentials accepted")
+ }
+ if err := installActivationFenceCredentials(source, target, scope, false); err != nil {
+ t.Fatal("interrupted receipt not resumed", err)
+ }
+ os.Remove(marker)
+ os.Chmod(target, 0400)
+ if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err != nil {
+ t.Fatal("legacy 0400 credentials changed", err)
+ }
+}
+
+func TestRootActivationEvidenceBoundKey(t *testing.T) {
+ d := activationRootFixture(t)
+ path := filepath.Join(d, "evidence.key")
+ binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: strings.Repeat("a", 32)}
+ proof, err := installActivationEvidenceKey(path, binding, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bound := proof.Binding
+ if _, err = installActivationEvidenceKey(path, bound, false); err != nil {
+ t.Fatal("bound replay", err)
+ }
+ if _, err = installActivationEvidenceKey(filepath.Join(d, "missing.key"), bound, false); err == nil {
+ t.Fatal("replaced a lost bound key")
+ }
+ if _, err = os.Lstat(filepath.Join(d, "missing.key")); !os.IsNotExist(err) {
+ t.Fatal("created replacement key")
+ }
+ bound.PublicKey[0] ^= 1
+ if _, err = installActivationEvidenceKey(path, bound, false); err == nil {
+ t.Fatal("accepted another bound key")
+ }
+}
diff --git a/internal/host/activation_install_test.go b/internal/host/activation_install_test.go
new file mode 100644
index 000000000..dfadbb090
--- /dev/null
+++ b/internal/host/activation_install_test.go
@@ -0,0 +1,17 @@
+package host
+
+import "testing"
+
+func TestActivationUnitSettings(t *testing.T) {
+ for _, raw := range []string{"CARLOS_HOST_CLAIM=1\n", "CARLOS_HOST_CLAIM='0'\nCARLOS_DATA_DIR=/data\n"} {
+ cfg, err := activationUnitSettings([]byte(raw))
+ if err != nil || !cfg.enabled || cfg.data != "/data" {
+ t.Fatalf("%q: %+v %v", raw, cfg, err)
+ }
+ }
+ for _, raw := range []string{"", "CARLOS_HOST_CLAIM=\n", "CARLOS_HOST_CLAIM=1\nBROKEN", "CARLOS_HOST_CLAIM=1\nCARLOS_DATA_DIR=relative\n", "CARLOS_HOST_CLAIM=1\nCARLOS_DATA_DIR=/data\nCARLOS_DATA_DIR=/elsewhere\n", "CARLOS_HOST_CLAIM=1\nCARLOS_REGION=ie\n", "CARLOS_HOST_CLAIM=1\nCARLOS_OPT_DIR=\"/opt\n", "export CARLOS_HOST_CLAIM=1\n"} {
+ if _, err := activationUnitSettings([]byte(raw)); err == nil {
+ t.Fatalf("accepted %q", raw)
+ }
+ }
+}
diff --git a/internal/host/plan14_lifecycle_root_linux_test.go b/internal/host/plan14_lifecycle_root_linux_test.go
index db4bc8cb4..86e11c9d2 100644
--- a/internal/host/plan14_lifecycle_root_linux_test.go
+++ b/internal/host/plan14_lifecycle_root_linux_test.go
@@ -609,7 +609,7 @@ func lifecycleAgent(t *testing.T) {
t.Fatal("route disappeared before wake")
}
finish := lifecycleLaunch(t, ctx, providers.Wake, assembly.Wake, *route)
- if wake == 0 && os.Getenv("PLAN14_LIFECYCLE_PHASE") != "2" && os.Getenv("PLAN14_LIFECYCLE_PHASE") != "" {
+ if wake == 0 && (os.Getenv("PLAN14_LIFECYCLE_PHASE") == "1" || os.Getenv("PLAN14_LIFECYCLE_PHASE") == "crash") {
lifecycleCheck(t, "request populated-box create", os.WriteFile("/run/carlos/lifecycle-create-sibling", []byte("ready"), 0644))
until := time.Now().Add(time.Minute)
for {
diff --git a/internal/hostfence/activation_credentials.go b/internal/hostfence/activation_credentials.go
new file mode 100644
index 000000000..ac2bdc0ad
--- /dev/null
+++ b/internal/hostfence/activation_credentials.go
@@ -0,0 +1,52 @@
+package hostfence
+
+import (
+ "bytes"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+func ValidateActivationCredentials(raw []byte, bucket, region, role string) error {
+ c, err := decodeFenceCredentials(bytes.NewReader(raw), bucket)
+ if err != nil {
+ return err
+ }
+ if region == "" || role == "" || c.Region != region || c.Role != role {
+ return os.ErrPermission
+ }
+ return nil
+}
+
+func ActivationCredentialReceipt(raw []byte) []byte {
+ receipt, _ := json.Marshal(struct {
+ V int `json:"v"`
+ SHA256 string `json:"sha256"`
+ }{1, hostfloor.Digest(raw)})
+ return receipt
+}
+func VerifyActivationCredentialReceipt(raw, receipt []byte) bool {
+ return bytes.Equal(receipt, ActivationCredentialReceipt(raw))
+}
+func activationCredentialReader(f *os.File) (io.Reader, error) {
+ raw, err := io.ReadAll(io.LimitReader(f, 16385))
+ if err != nil || len(raw) > 16384 {
+ return nil, os.ErrPermission
+ }
+ receipt, err := hostfloor.OpenProtected(filepath.Join(filepath.Dir(f.Name()), "credentials.installation.json"))
+ if err != nil {
+ return nil, err
+ }
+ defer receipt.Close()
+ st, err := receipt.Stat()
+ if err != nil || st.Mode().Perm() != 0600 {
+ return nil, os.ErrPermission
+ }
+ wire, err := io.ReadAll(io.LimitReader(receipt, 1025))
+ if err != nil || !VerifyActivationCredentialReceipt(raw, wire) {
+ return nil, os.ErrPermission
+ }
+ return bytes.NewReader(raw), nil
+}
diff --git a/internal/hostfence/activation_credentials_test.go b/internal/hostfence/activation_credentials_test.go
new file mode 100644
index 000000000..8be686803
--- /dev/null
+++ b/internal/hostfence/activation_credentials_test.go
@@ -0,0 +1,26 @@
+package hostfence
+
+import "testing"
+
+func TestActivationCredentialsScope(t *testing.T) {
+ raw := []byte(`{"role":"arn:aws:iam::123456789012:role/fence","region":"eu-west-1","bucket":"bucket","access_key":"test","secret_key":"test"}`)
+ if err := ValidateActivationCredentials(raw, "bucket", "eu-west-1", "arn:aws:iam::123456789012:role/fence"); err != nil {
+ t.Fatal(err)
+ }
+ for _, scope := range [][3]string{{"other", "eu-west-1", "arn:aws:iam::123456789012:role/fence"}, {"bucket", "eu-west-2", "arn:aws:iam::123456789012:role/fence"}, {"bucket", "eu-west-1", "arn:aws:iam::123456789012:role/other"}} {
+ if err := ValidateActivationCredentials(raw, scope[0], scope[1], scope[2]); err == nil {
+ t.Fatal("scope mismatch accepted")
+ }
+ }
+}
+
+func TestActivationReceiptBindsExactCredentialBytes(t *testing.T) {
+ raw := []byte(`{"role":"arn:aws:iam::123456789012:role/fence","region":"eu-west-1","bucket":"bucket","access_key":"test","secret_key":"test"}`)
+ receipt := ActivationCredentialReceipt(raw)
+ if !VerifyActivationCredentialReceipt(raw, receipt) {
+ t.Fatal("receipt rejected")
+ }
+ if VerifyActivationCredentialReceipt(append(raw, ' '), receipt) || VerifyActivationCredentialReceipt(raw, nil) {
+ t.Fatal("uninstalled credentials accepted")
+ }
+}
diff --git a/internal/hostfence/writer.go b/internal/hostfence/writer.go
index fa1548d96..1b2372998 100644
--- a/internal/hostfence/writer.go
+++ b/internal/hostfence/writer.go
@@ -43,10 +43,17 @@ func OpenRootFenceStore(ctx context.Context, bucket string) (release.Store, erro
if err != nil {
return nil, err
}
- if st.Mode().Perm() != 0400 {
+ if st.Mode().Perm() != 0400 && st.Mode().Perm() != 0600 {
return nil, os.ErrPermission
}
- c, err := decodeFenceCredentials(f, bucket)
+ var reader io.Reader = f
+ if st.Mode().Perm() == 0600 {
+ reader, err = activationCredentialReader(f)
+ if err != nil {
+ return nil, err
+ }
+ }
+ c, err := decodeFenceCredentials(reader, bucket)
if err != nil {
return nil, err
}
diff --git a/internal/provision/activation_tools_census_test.go b/internal/provision/activation_tools_census_test.go
new file mode 100644
index 000000000..8c90ebd5b
--- /dev/null
+++ b/internal/provision/activation_tools_census_test.go
@@ -0,0 +1,11 @@
+package provision
+
+// The activation transport accepts only its phase's exact control keys.
+// It rejects tenant instance keys even when their payload is root-signed.
+func plan14ActivationToolsWriteCensusInventory() []censusEntry {
+ return append(plan14P3WriteCensusInventory(), censusEntry{
+ "internal/claimactivation/runner.go", "checkedStore.Put", "nonrecord", "def:Put,sel:Put", "26c6e1f7cb2a057ef8b19deb30bc18fd62fe08f81ff48fd4022bdfc3dd22d6b0",
+ }, censusEntry{
+ "internal/host/activation_console_report_linux.go", "publishActivationConsoleReport", "nonrecord", "sel:Put", "f33733cab3dfbeede384906fdfb4649305dbaa27f756964df2abccdab8408cbb",
+ })
+}
diff --git a/internal/provision/plan14_p3_census_test.go b/internal/provision/plan14_p3_census_test.go
index 85cce5b77..f18deb211 100644
--- a/internal/provision/plan14_p3_census_test.go
+++ b/internal/provision/plan14_p3_census_test.go
@@ -5,7 +5,7 @@ import "testing"
func TestPlan14P3OverlayPreservesLegacySeal(t *testing.T) {
root := censusRoot(t)
inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}}
- for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv", "plan14-root-reads-zddr4.tsv", "plan14-root-reads-reserved285.tsv", "plan14-root-reads-292.tsv", "plan14-root-reads-census-ruling-275.tsv", "plan14-root-reads-census-ruling-owed.tsv", "plan14-root-reads-carlos-run.tsv", "plan14-root-reads-seeded-instances.tsv", "plan14-root-reads-hostfiles-cap-236.tsv", "plan14-root-reads-moves-overcap-cut-236.tsv", "plan14-root-reads-seed.tsv", "plan14-root-reads-home-part2.tsv", "plan14-root-reads-fix-empty-app-key.tsv", "plan14-root-reads-region-ha-2a.tsv", "plan14-root-reads-e.tsv", "plan14-root-reads-p3.tsv"} {
+ for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv", "plan14-root-reads-fix5.tsv", "plan14-root-reads-fix6.tsv", "plan14-root-reads-zddr3.tsv", "plan14-root-reads-hotfix281.tsv", "plan14-root-reads-zddr4.tsv", "plan14-root-reads-reserved285.tsv", "plan14-root-reads-292.tsv", "plan14-root-reads-census-ruling-275.tsv", "plan14-root-reads-census-ruling-owed.tsv", "plan14-root-reads-carlos-run.tsv", "plan14-root-reads-seeded-instances.tsv", "plan14-root-reads-hostfiles-cap-236.tsv", "plan14-root-reads-moves-overcap-cut-236.tsv", "plan14-root-reads-seed.tsv", "plan14-root-reads-home-part2.tsv", "plan14-root-reads-fix-empty-app-key.tsv", "plan14-root-reads-region-ha-2a.tsv", "plan14-root-reads-e.tsv", "plan14-root-reads-p3.tsv", "plan14-root-reads-activation-tools.tsv"} {
if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil {
t.Fatal(err)
}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 31c9a0960..049748c9c 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -816,6 +816,9 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-p3.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-activation-tools.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
@@ -1017,9 +1020,9 @@ func checkRootReadDeclarations(scan rootReadScan, inv rootReadInventory) []strin
}
}
for key, removal := range inv.Removed {
- if strings.HasSuffix(removal, ";census-ruling-J4") {
+ if strings.HasSuffix(removal, ";census-ruling-J4") || strings.HasSuffix(removal, ";census-ruling-activation-tools-removal") {
if _, exists := scan.Declarations[key]; exists || inv.Entries[key].Class != "" {
- errs = append(errs, "removed J4 declaration still present "+key)
+ errs = append(errs, "removed declaration still present "+key)
}
continue
}
@@ -1063,3 +1066,26 @@ func TestPlan14J4RemovalRequiresAbsentDeclaration(t *testing.T) {
t.Fatal("unruled removal accepted")
}
}
+
+func TestPlan14ActivationToolsRemovalRequiresAbsentDeclaration(t *testing.T) {
+ key := "root.go:dead"
+ scan := rootReadScan{Declarations: map[string]string{}}
+ inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Removed: map[string]string{key: "G,J4;census-ruling-activation-tools-removal"}}
+ if errs := checkRootReadDeclarations(scan, inv); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ scan.Declarations[key] = "digest"
+ if len(checkRootReadDeclarations(scan, inv)) == 0 {
+ t.Fatal("live removed declaration accepted")
+ }
+ delete(scan.Declarations, key)
+ inv.Entries[key] = classifiedRootRead{Class: "authority"}
+ if len(checkRootReadDeclarations(scan, inv)) == 0 {
+ t.Fatal("retained removed row accepted")
+ }
+ delete(inv.Entries, key)
+ inv.Removed[key] = "E2;unruled"
+ if len(checkRootReadDeclarations(scan, inv)) == 0 {
+ t.Fatal("unruled removal accepted")
+ }
+}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 693ca6579..73396224e 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) {
if len(got) < 100 {
t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root)
}
- inventory := plan14P3WriteCensusInventory()
+ inventory := plan14ActivationToolsWriteCensusInventory()
problems := append(censusDiff(inventory, got), censusPolicy(inventory)...)
if len(problems) > 0 {
t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n "))
diff --git a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
new file mode 100644
index 000000000..4e6c3cc1d
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
@@ -0,0 +1,85 @@
+# Activation-tools runs 1–10: proposals only; loaded after all existing overlays.
+@coverage . 2145f21807743eb683387a61738d6bb16d9c41d7d69a9071dbac765ccd9ec2d6
+@coverage internal/claimactivation 625d82dce14bae92f619077ca92a8c9066a9446545dfcc96a6f818e89e20d4c2
+@coverage internal/host 3ff77942ddde8a5cd7c4d172eb797509f1384b2f1df4c503f16d170b48ca9ec8
+@coverage internal/hostfence 552570510fb62d2a8822a3f24fa76aeb1437c0a9516966dc5ce15ef58e3fb4c6
+internal/claimactivation/runner.go Runner.Run authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put file:Read@310,file:Read@5986,file:Read@6318 683059aedf27656d4b49c88102089b2bb2f2ce7fcc108ff8545e52a86a0f8385
+internal/claimactivation/runner.go checkedStore.Delete authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put 6bc9103a30cfd53cea59d611ccacb7dcc0ca0ea620b403497ef543f5bdaa0217
+internal/claimactivation/runner.go checkedStore.Get authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put 366fec104118e7bce2b71ede13e7c8c72314d99fd32f773967e31018b0f0fbd6
+internal/claimactivation/runner.go checkedStore.Put authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put file:Read@1357 26c6e1f7cb2a057ef8b19deb30bc18fd62fe08f81ff48fd4022bdfc3dd22d6b0
+internal/host/activation_command.go RunSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call cc8289fbcb3f34d8d43cd912a6a43ace727ed7289c6c32c5d35c8d00e6194cf3
+@removed internal/host/activation_command_linux.go activationProcessesDrained G,J4 census-ruling-activation-tools-removal
+internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Read@1518 b71131cace86703746375d4525b91e69c35a4702021e8a003545cb2d78eefd0b
+internal/host/activation_command_other.go runSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call fcf45d665275c2ac99ff9bfc37302217bb6134b34ff74c3b17f50d1348760151
+internal/host/activation_console.go activationConsoleEnvironment authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 9d1144ac45e5849f43872d12ac6389c620ad07f0b2e69575a1353bfaf218b9fd
+internal/host/activation_install.go activationEnvironment authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 289ff3c1267e613afd102f83f68592bfd99dafc3d6b7eaba05e630e6b2e7be3e
+internal/host/activation_install.go activationUnitSettings authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call data-literal@253,data-literal@427 5e540dae93fb146cc116d647677b7ca4d6fc6bc1ae0efb609b05fa907eba1fce
+internal/host/activation_install_linux.go activationDir authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Fstat@1016,file:Fstat@398,file:Open@189,file:Openat@566,file:Openat@814 78d11f56b448ba6e5ba8dfb021cae1c286dcbaceb379dbfdd4bf6a0236efdd46
+internal/host/activation_install_linux.go activationLock authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Fstat@224 f51423c16e9f9ca1226a02178155cedfae7e38cfb450524899841295e01d219f
+internal/host/activation_install_linux.go activationRead authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call fee48092f7a26b0de5409052d63e6aa48ff830b66edf01a035be514a0470f22f
+internal/host/activation_install_linux.go activationReadAt authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Fstat@234,file:ReadAll@440 5caf00466afd942308bdcb33b469b212f97bfcdcde3e1fc4847824b1859d29af
+internal/host/activation_install_linux.go installActivationConsole authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 3a6bdc41ae970453395ae516ad5183b70af7ec191279ba0431c2a12b913dcad9
+internal/host/activation_install_linux.go installActivationControl authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:ReadFile@616 16e3c76c1d1d06fc429725cfeaee21b17d6a37620da287b40482b12e2f6b20a5
+internal/host/activation_install_linux.go installActivationEvidenceKey authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 74f311fcb31aec95f5760ae9781186cc44a6f650858349c379e82f3a1fc6fd10
+internal/host/activation_install_linux.go installActivationFenceCredentials authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 9e18de6df5fb9d3e6898967c9ed91e72d19faf5fd6bff7331ee654d76dfe7075
+@removed internal/host/activation_offline_linux.go activationOffline.begin G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.checkItem G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.dirs G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.point G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.prepare G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.preview G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.read G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.replace G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.restore G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.save G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.status G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOfflineUnits G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationUnitSnapshot G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go sameActivationFile G,J4 census-ruling-activation-tools-removal
+internal/hostfence/activation_credentials.go ActivationCredentialReceipt authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal 63ffd51457e632687bc44f776fd5c0335748a899e623ff67a0dea31be4ffa7f2
+internal/hostfence/activation_credentials.go ValidateActivationCredentials authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal 9a08dd0349bd4bd43ef44d6e621876d43e8c151dcb355ef94c9f134776f0f4d9
+internal/hostfence/activation_credentials.go VerifyActivationCredentialReceipt authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal bd06fbd520d6808a635f8e3a4a8a5b8a389beb1d1374d56848853b1ad6855d84
+internal/hostfence/activation_credentials.go activationCredentialReader authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal file:ReadAll@480,file:ReadAll@78,file:Stat@372 126e54d8062b988cfd43130bf4bfe74ae753ae6ec507f10a83901690337218aa
+internal/hostfence/writer.go OpenRootFenceStore authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal file:Stat@346 ec25dedc946b9be45336b89962a9b55f940cb24e747c4f3284d8fc3458e27482
+system.go runSystem authority B,C,D,F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=adds explicit system claims operator dispatch; every existing command branch unchanged cc1e9869168e2032eb13f5c4c8c26e56c0f5c1f4066dc65c42314fbc117d10a7
+system.go systemVerb authority B,C,D,F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=adds explicit system claims operator dispatch; every existing command branch unchanged 67c544800333987eda7d7f0ee1f97b9a50da2619b7d4c6f34fc87c23a06ff289
+internal/host/activation_console_report_linux.go activationConsolePID authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=signed roster and identity, protected guard and configuration, live systemd MainPID plus proc boot/namespace/start/cgroup/executable; exact private box report CAS and readback; no automatic call e8ebb8a006f4b8407e377ddb01e39162e0bbc5d88f656bfc07cc4220828281c2
+internal/host/activation_console_report_linux.go activationConsoleProcess authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=signed roster and identity, protected guard and configuration, live systemd MainPID plus proc boot/namespace/start/cgroup/executable; exact private box report CAS and readback; no automatic call file:Fstat@5748,file:Open@5443,file:ReadFile@3554,file:ReadFile@3703,file:ReadFile@4156,file:ReadFile@4369,file:ReadFile@4520,file:Stat@4002,file:Stat@5543,file:Stat@5614 29235a700ba8fece0602b97a216a4db9d0d9471e3d59a2da4e913f443fbfa923
+internal/host/activation_console_report_linux.go publishActivationConsoleReport authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=signed roster and identity, protected guard and configuration, live systemd MainPID plus proc boot/namespace/start/cgroup/executable; exact private box report CAS and readback; no automatic call file:Read@2593,file:Read@296 f33733cab3dfbeede384906fdfb4649305dbaa27f756964df2abccdab8408cbb
+@removed internal/host/activation_seed_linux.go activationOffline.seed G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go activationSeedDropIns G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go activationSeedPreflight G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go const activationSeedDropIn G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go const activationSeedUnit G,J4 census-ruling-activation-tools-removal
+@coverage scripts 3ce4672ec33aaabdbadf5dd30e28ff6dda9d012bd55936093f57faf9fcabecab
+scripts/activation-tools-root-runner.sh external diagnostic G,J4 rev3=PROPOSED-activation-tools;identity=authorized root-runner operator;basis=builds real console and CLI into test-only embedded images; serial designated runner invocation; deletes only owned build artifacts file:external@0 17400986085d3a406062f45c9669f88c833502f7bbde138f300db9d5e8a86a51
+@removed internal/host/activation_seed_linux.go activationOffline.seedGuard G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go activationOffline.seedGuardCheck G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go activationOffline.seedGuardReplace G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go activationOffline.seedRemask G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go const activationSeedGuardOriginal G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go const activationSeedGuardReplacement G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_seed_linux.go activationSeedReadAt G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_adoption_linux.go activationAdoptionAllowed G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_adoption_other.go activationAdoptionAllowed G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go var activationEdgeUnit G,J4 census-ruling-activation-tools-removal
+internal/host/agent.go runAdoptUnderRootRelease authority B3,E1,E2,E3 rev3=PROPOSED-activation-tools;run=27;basis=automated offline transaction removed; retains preceding integrated classification: rev3=PROPOSED-p3;run=24;identity=root claim/adopt/update controller uid 0;basis=selects census participation before adding root history or named recovery; absent census uses main pass options with no recovery pre-list or per-host lookup; trace covers either flag and empty, non-adoptable and ordinary routes whole:Route@11104,whole:Route@18400,whole:Route@18436,whole:Route@8406 3031d8ce5a8d23390dee282dd299856f2c837b7bf1fb15599e4cd74692535294
+internal/host/systemunits.go ensureSystemUnits authority F,J4 rev3=PROPOSED-activation-tools;run=27;basis=automated offline transaction removed; retains preceding integrated classification: root-input;kept=F;scope=protected-decision-only file:OpenFile@265,file:ReadFile@2908,file:ReadFile@3041,file:ReadFile@3955,file:Stat@2817 984a691e04c28368d7015285d14952add40ce02ce24aab8d3786e80a99af4053
+internal/host/systemapply.go systemApplier.Run authority D,F,J4 rev3=PROPOSED-activation-tools;run=27;basis=automated offline transaction removed; retains preceding integrated classification: root-input;kept=D,F;scope=protected-decision-only PROPOSED E43: root; release-held context and D3 reservation/census verification under release-before-participation exclusion; no-census effects stay on the legacy path. TestPlan14LaunchVerifierToken, TestPlan14LaunchVerifierCallerOrder, TestPlan14LaunchVerifierNoCensus, TestPlan14LaunchVerifierRootRunner. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. file:OpenFile@483 6a5704be83e35979db7f2811b8ea7fd3d4c2c74c9b532f9f94f1d5482cbeb827
+@removed internal/host/activation_adoption_linux.go activationTransactionAllowed G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_adoption_other.go activationTransactionAllowed G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_settled_linux.go activationOffline.settled G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_settled_linux.go activationOffline.updateSettled G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.closeDifferences G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.reconcile G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_settled_linux.go activationOffline.updateDir G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_settled_linux.go activationOffline.updateUnitsSettled G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationRecordedState G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_settled_linux.go activationConfiguredUpdateRoot G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationSupportedBaseline G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.baseline G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.hold G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go activationOffline.heldDifferences G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_adoption_linux.go var errActivationRecoveryHeld G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_adoption_other.go var errActivationRecoveryHeld G,J4 census-ruling-activation-tools-removal
+@removed internal/host/activation_offline_linux.go var activationCanonicalBaseline G,J4 census-ruling-activation-tools-removal
diff --git a/internal/provision/testdata/plan14-root-reads-p3.tsv b/internal/provision/testdata/plan14-root-reads-p3.tsv
index c4f16a6af..c0b7b59c2 100644
--- a/internal/provision/testdata/plan14-root-reads-p3.tsv
+++ b/internal/provision/testdata/plan14-root-reads-p3.tsv
@@ -197,7 +197,7 @@ internal/host/rootmigration_timers.go rootMigrationTimers.owner authority G,J4 r
internal/host/rootmigration_timers.go type rootMigrationTimerRecord authority G,J4 rev3=PROPOSED-p3;identity=offline migration root uid 0;basis=retains original stopped inventory, exact observed timer contents and registrations, successor inventory and root revision through interrupted migration 653d1a8f608c1bb1965881928b652dffe419db0697bd2b3ce09ac40c7f59af34
internal/host/rootmigration_timers.go type rootMigrationTimers authority G,J4 rev3=PROPOSED-p3;identity=offline migration root uid 0;basis=holds root-private migration state, root inventory, root fence authority and isolated registry interface under offline exclusion dc83f25eaae718327601d7d19c2c93f56e589cc176405c6249e9ea4e3c864c34
internal/hostguard/rootmigration.go RootAuthority.CompleteMigrationInventory authority G,J4 rev3=PROPOSED-p3;identity=offline migration root uid 0;basis=root fence completion binds the migration inventory revision; timer adoption permits only its exact previous revision plus one and recommits visible failed writes 2cae20b131f8095abb56be863ed457b0cb22c42d28d486488edc83ba9d519986
-@coverage . 9037148b0e15fd58e3378d2d7fc28b85ec978d675575f5e00e9a721c4e33ca62
+@coverage . 2145f21807743eb683387a61738d6bb16d9c41d7d69a9071dbac765ccd9ec2d6
@coverage internal/activator 0b9179cf42fbbe917aa7e3dd5b1b03eb67a5f77689bc99dc47e8183b735174c1
@coverage internal/console 7702e7033dee36caa5129394955cb70d09ed5b3fd81dbf752ff37fdf2ed4edef
@coverage internal/edgesfile e0a7dd2795beedde4d17389b96b562f3f9438ad8505ea19a62d1e9c4b921aefb
@@ -365,7 +365,7 @@ internal/hostguard/recoveryfence.go Guard.PendingStopFences authority C,J4 rev3=
internal/hostguard/recoveryfence.go Guard.pendingFences authority C,J2,J4,J1 rev3=PROPOSED-p3;run=19;identity=root;basis=reads root-owned records under host lock and keeps exact barrier classification separate from ordinary stop recovery; legacy behavior retained 20be9535fd54587a54e3027f5388581ae5a64e5fcfced5a6031ffc9a4fc76a3b
internal/host/claimroleassembly_linux.go verifiedClaimExecutable authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=pins root-owned executable bytes and keeps the verified descriptor while supplying its absolute path to the root launcher file:Fstat@1122,file:Fstat@579,file:OpenFile@376,file:Read@949 7a7e9af520e4157e60ae93d12c3268a629a9b0bd1e83b263cdd27103cf2e227c
internal/host/claimroleassembly_linux.go claimExecutableName authority C,G,J4 rev3=PROPOSED-p3;run=19;identity=agent uid carlos;basis=duplicates the existing descriptor with CLOEXEC and its validated absolute name; never reopens a potentially replaced path e887880731a16c9e7934aef33a08169b34e7236f6d3b85a195602d35f8b69107
-@coverage internal/host 2b0767bf084d05d2baf485436fbe58e3d418c3b51c21679b5a6d5ea86d27850e
+@coverage internal/host 3ff77942ddde8a5cd7c4d172eb797509f1384b2f1df4c503f16d170b48ca9ec8
@coverage internal/hostbox 0b6ddb07bc7ef62f23e402757155825e8d098eb47ec9288c09b8754bb1632e56
@coverage internal/hostguard ca664497969680d692172abda403b6a5d6664f8d7c8bc16a6440f02dd8426be6
internal/host/claimstop.go ClaimStopCoordinator.FenceAndStop authority C,J4 rev3=PROPOSED-p3;run=20;identity=root instance-control uid 0;basis=authenticated session stop retains root fence before independent unjournaled absence check; proves no journaled installation, containment or open files before bypassing nonexistent writer closure; normal drain remains for recorded launches; no-census unchanged file:Read@1610 58147f3abbf278037fc660392aeec0b6e76b667e3fe8435845ebaedc62d2ead1
diff --git a/scripts/activation-tools-root-runner.sh b/scripts/activation-tools-root-runner.sh
new file mode 100644
index 000000000..9fc51f2ee
--- /dev/null
+++ b/scripts/activation-tools-root-runner.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+set -euo pipefail
+runner=$(realpath "$1")
+shift
+cd "$(dirname "$0")/.."
+worktree=$(pwd)
+scratch=$(mktemp -d)
+artifacts=()
+trap 'rm -f -- "${artifacts[@]}"; rm -rf -- "$scratch"' EXIT
+export GOFLAGS=-buildvcs=false
+unset GOCACHE
+listener="$scratch/listener.go"
+cat > "$listener" <<'GO'
+//go:build activationloopback
+
+package main
+
+import "os"
+
+func init() {
+ if len(os.Args) == 2 && os.Args[1] == "agent" {
+ os.Args = append(os.Args, "--http", "127.0.0.1:18080", "--https", "127.0.0.1:18443")
+ }
+}
+GO
+python3 - "$worktree" "$listener" "$scratch/overlay.json" <<'PYJSON'
+import json,sys
+with open(sys.argv[3], 'w') as f:
+ json.dump({"Replace": {sys.argv[1]+"/activation_rootrunner_listener.go":sys.argv[2]}}, f)
+PYJSON
+for component in console carlos; do
+ artifact=internal/host/activation-$component-runner.bin
+ test ! -e "$artifact"
+ package=.
+ if [ "$component" = console ]; then package=./cmd/console; fi
+ GOOS=linux GOARCH=arm64 CGO_ENABLED=0 nice -n 10 go build -overlay "$scratch/overlay.json" -tags activationloopback -ldflags '-X github.com/carlosframework/platform/internal/version.Build=v1.0.0' -o "$scratch/$component" "$package"
+ python3 - "$scratch/$component" "$artifact" <<'PY'
+import shutil,sys
+with open(sys.argv[1], 'rb') as src, open(sys.argv[2], 'xb') as dst:
+ shutil.copyfileobj(src,dst)
+PY
+ artifacts+=("$artifact")
+ sha256sum "$artifact"
+done
+nice -n 10 python3 -u "$runner" --worktree "$worktree" --pkg ./internal/host --tags rootrunner,activationconsole "$@"
diff --git a/system.go b/system.go
index 4a52915ca..d540ae29c 100644
--- a/system.go
+++ b/system.go
@@ -49,7 +49,7 @@ func systemVerb(args []string) (verb string, rest []string, component string) {
// untouched in rest for runSystemAntivirusVerb to read.
// layout is a family too: migrate|revert|status stay in rest for
// runSystemLayout.
- case "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "help", "-h", "--help":
+ case "claims", "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "help", "-h", "--help":
return args[0], args[1:], component
}
return "", args, component
@@ -58,6 +58,8 @@ func systemVerb(args []string) (verb string, rest []string, component string) {
func runSystem(args []string) {
verb, rest, _ := systemVerb(args)
switch verb {
+ case "claims":
+ host.RunSystemClaims(rest)
case "files":
host.RunSystemFiles(rest)
case "guard-arm":