carlos / platform Public

Claim activation tools, run 1 (not for merge)

carlos system claims inspect/run over the existing signed-directory libraries (resumable, dry-run, readback, floor before BindCensusKey); protected evidence-key and fence-credential installers; persistent-mask transaction (real systemd, reboot and interruption tests); narrow instance-control unit installer; console actor enrolment; home mapping ie.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6863c3213bed1f118f13231fff09fc3bc2cf3cd1 parent ed1a3db
24 files changed, +2855 −5
  • docs/site/cli.md +6 −0
  • internal/claimactivation/runner.go +316 −0
  • internal/claimactivation/runner_test.go +321 −0
  • internal/host/activation_command.go +27 −0
  • internal/host/activation_command_linux.go +284 −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_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 +314 −0
  • internal/host/activation_install_test.go +17 −0
  • internal/host/activation_offline_linux.go +547 −0
  • internal/host/activation_offline_rootrunner_linux_test.go +367 −0
  • 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 +9 −0
  • internal/provision/plan14_p3_census_test.go +1 −1
  • internal/provision/plan14_root_reads_test.go +3 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-activation-tools.tsv +45 −0
  • system.go +3 −1

CI passed — 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	290.849s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.714s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.212s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.702s
ok  	github.com/carlosframework/platform/internal/activator	249.930s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
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.444s
ok  	github.com/carlosframework/platform/internal/apperr	0.072s
ok  	github.com/carlosframework/platform/internal/applog	0.143s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.640s
?   	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.014s
ok  	github.com/carlosframework/platform/internal/certwarm	0.005s
ok  	github.com/carlosframework/platform/internal/cgroup	0.699s
ok  	github.com/carlosframework/platform/internal/changelog	0.313s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.023s
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	387.830s
?   	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.325s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.006s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.004s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.342s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.620s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.132s
ok  	github.com/carlosframework/platform/internal/gate	1.654s
ok  	github.com/carlosframework/platform/internal/genselect	0.016s
ok  	github.com/carlosframework/platform/internal/geo	0.012s
ok  	github.com/carlosframework/platform/internal/geoip	0.031s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.153s
ok  	github.com/carlosframework/platform/internal/host	175.072s
ok  	github.com/carlosframework/platform/internal/hostactors	0.522s
ok  	github.com/carlosframework/platform/internal/hostbox	2.642s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.497s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.012s
ok  	github.com/carlosframework/platform/internal/hostexec	4.723s
ok  	github.com/carlosframework/platform/internal/hostfence	0.037s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.521s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	1.035s
ok  	github.com/carlosframework/platform/internal/hostops	1.557s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.013s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.012s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.462s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.136s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.012s
ok  	github.com/carlosframework/platform/internal/infraview	0.012s
ok  	github.com/carlosframework/platform/internal/instancectl	5.227s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.729s
ok  	github.com/carlosframework/platform/internal/instancefs	0.012s
ok  	github.com/carlosframework/platform/internal/instancekey	0.014s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.021s
ok  	github.com/carlosframework/platform/internal/layout	1.526s
ok  	github.com/carlosframework/platform/internal/lease	9.793s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.024s
ok  	github.com/carlosframework/platform/internal/ledger	0.023s
ok  	github.com/carlosframework/platform/internal/mail	0.173s
ok  	github.com/carlosframework/platform/internal/maintenance	0.074s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.542s
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	21.190s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.800s
ok  	github.com/carlosframework/platform/internal/objstore	0.171s
ok  	github.com/carlosframework/platform/internal/ops	0.177s
ok  	github.com/carlosframework/platform/internal/park	0.200s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	30.171s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.005s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.500s
ok  	github.com/carlosframework/platform/internal/relay	7.234s
ok  	github.com/carlosframework/platform/internal/release	21.945s
ok  	github.com/carlosframework/platform/internal/rollout	3.681s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
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.574s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.493s
ok  	github.com/carlosframework/platform/internal/seeds	0.011s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.296s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.891s
ok  	github.com/carlosframework/platform/internal/status	0.040s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.211s
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.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
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.002s
ok  	github.com/carlosframework/platform/internal/vet	1.527s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 48562338 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/16 14:44:35 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF09BB089465DA, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 250cdfff4..25f914b73 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2152,6 +2152,12 @@ 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.
+
+`offline-begin` persistently masks the migration services, their triggers and the edge. `offline-restore` restores the transaction's own masks and previous active states, with the edge last. `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.
+
#### 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..0bd8625a3
--- /dev/null
+++ b/internal/host/activation_command.go
@@ -0,0 +1,27 @@
+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]
+ install-control
+ offline-begin
+ offline-restore
+
+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..c6ba7b8a4
--- /dev/null
+++ b/internal/host/activation_command_linux.go
@@ -0,0 +1,284 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "flag"
+ "io"
+ "os"
+ "strconv"
+
+ "github.com/carlosframework/platform/internal/claimactivation"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "golang.org/x/sys/unix"
+)
+
+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-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})
+ case "offline-begin", "offline-restore":
+ tx := activationOffline{units: "/etc/systemd/system", state: "/etc/carlos/claim-activation/offline", systemctl: claimSystemctl, drained: activationProcessesDrained}
+ preview, err := tx.preview(ctx)
+ if err != nil {
+ return err
+ }
+ if args[0] == "offline-begin" {
+ err = tx.begin(ctx, *dry)
+ } else {
+ err = tx.restore(ctx, *dry)
+ }
+ if err != nil {
+ return err
+ }
+ return encode(map[string]any{"dry_run": *dry, "phase": args[0], "units": preview, "transaction": tx.state})
+ default:
+ return errActivation
+ }
+}
+func activationProcessesDrained(ctx context.Context) error {
+ if err := quiesceLegacyRootMutators(ctx); err != nil {
+ return err
+ }
+ uid, _, err := claimQuiesceOwner()
+ if err != nil {
+ return err
+ }
+ entries, err := os.ReadDir("/proc")
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if err = ctx.Err(); err != nil {
+ return err
+ }
+ if _, err = strconv.Atoi(entry.Name()); err != nil {
+ continue
+ }
+ var st unix.Stat_t
+ err = unix.Stat("/proc/"+entry.Name(), &st)
+ if errors.Is(err, unix.ENOENT) {
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ if int(st.Uid) == uid {
+ return errActivation
+ }
+ }
+ return nil
+}
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_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..6c24b128b
--- /dev/null
+++ b/internal/host/activation_install_rootrunner_linux_test.go
@@ -0,0 +1,314 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "bytes"
+ "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) {
+ tx := setupOfflineReal(t)
+ defer cleanupOfflineReal(t, tx)
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ 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")
+ unit := "/etc/systemd/system/" + claimControlUnit
+ if _, err := os.Lstat(unit); !os.IsNotExist(err) {
+ t.Fatal("pre-existing control unit", err)
+ }
+ defer func() { os.Remove(unit); offlineRealSystemctl(t.Context(), "daemon-reload") }()
+ 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 := offlineRealSystemctl(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 = offlineRealSystemctl(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/activation_offline_linux.go b/internal/host/activation_offline_linux.go
new file mode 100644
index 000000000..33e1fde30
--- /dev/null
+++ b/internal/host/activation_offline_linux.go
@@ -0,0 +1,547 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "syscall"
+
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/safefile"
+)
+
+var activationTriggers = []string{"carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path", "carlos-system-update.path"}
+
+func activationOfflineUnits() []string {
+ out := append([]string(nil), activationTriggers...)
+ out = append(out, rootMigrationOfflineUnits...)
+ return append(out, "carlos-edge.service")
+}
+
+type activationOffline struct {
+ units, state string
+ systemctl func(context.Context, ...string) ([]byte, error)
+ drained func(context.Context) error
+ checkpoint func(string) error
+}
+type activationOfflineItem struct {
+ LoadState string
+ ActiveState string
+ Name string
+ Masked, Active bool
+ Mode uint32
+ UID, GID uint32
+ Size, Modified int64
+ Digest string
+}
+type activationOfflineRecord struct {
+ V int
+ Units string
+ Phase string
+ Items []activationOfflineItem
+}
+
+func (t activationOffline) point(name string) error {
+ if t.checkpoint != nil {
+ return t.checkpoint(name)
+ }
+ return nil
+}
+func (t activationOffline) dirs(create bool) (*os.File, *os.File, error) {
+ if t.state == t.units || strings.HasPrefix(t.state, t.units+"/") {
+ return nil, nil, errActivation
+ }
+ units, err := activationDir(t.units, false)
+ if err != nil {
+ return nil, nil, err
+ }
+ state, err := activationDir(t.state, create)
+ if err != nil {
+ units.Close()
+ return nil, nil, err
+ }
+ st, err := state.Stat()
+ if err != nil || st.Mode().Perm() != 0700 {
+ units.Close()
+ state.Close()
+ return nil, nil, errActivation
+ }
+ return units, state, nil
+}
+func (t activationOffline) status(ctx context.Context, name string) (string, string, error) {
+ raw, err := t.systemctl(ctx, "show", name, "--property=LoadState,ActiveState", "--no-pager")
+ if err != nil {
+ return "", "", err
+ }
+ fields := map[string]string{}
+ for _, line := range strings.Split(string(raw), "\n") {
+ k, v, ok := strings.Cut(line, "=")
+ if ok {
+ if _, seen := fields[k]; seen {
+ return "", "", errActivation
+ }
+ fields[k] = v
+ }
+ }
+ load, active := fields["LoadState"], fields["ActiveState"]
+ if load != "loaded" && load != "masked" || active != "active" && active != "inactive" && active != "failed" {
+ return "", "", errActivation
+ }
+ return load, active, nil
+}
+func activationUnitSnapshot(path, name string) (activationOfflineItem, error) {
+ item := activationOfflineItem{Name: name}
+ st, err := os.Lstat(path)
+ if err != nil {
+ return item, err
+ }
+ stat, ok := st.Sys().(*syscall.Stat_t)
+ if !ok || stat.Uid != 0 || st.Mode().Perm()&0022 != 0 && st.Mode()&os.ModeSymlink == 0 {
+ return item, errActivation
+ }
+ item.Mode = uint32(st.Mode())
+ item.UID = stat.Uid
+ item.GID = stat.Gid
+ item.Size = st.Size()
+ item.Modified = st.ModTime().UnixNano()
+ if st.Mode()&os.ModeSymlink != 0 {
+ target, e := os.Readlink(path)
+ if e != nil || target != "/dev/null" {
+ return item, errActivation
+ }
+ item.Masked = true
+ item.Digest = hostfloor.Digest([]byte(target))
+ return item, nil
+ }
+ if !st.Mode().IsRegular() || st.Size() > 1<<20 {
+ return item, errActivation
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return item, err
+ }
+ item.Digest = hostfloor.Digest(raw)
+ return item, nil
+}
+func sameActivationFile(a, b string) bool {
+ x, e := os.Lstat(a)
+ if e != nil {
+ return false
+ }
+ y, e := os.Lstat(b)
+ return e == nil && os.SameFile(x, y)
+}
+func (t activationOffline) save(state *os.File, r activationOfflineRecord) error {
+ raw, err := json.Marshal(r)
+ if err != nil {
+ return err
+ }
+ if err = safefile.WriteAt(int(state.Fd()), "transaction.json", raw, 0600); err != nil {
+ return err
+ }
+ check, err := activationReadAt(state, "transaction.json", true)
+ if err != nil || !bytes.Equal(raw, check) {
+ return errActivation
+ }
+ return t.point("journal-" + r.Phase)
+}
+func (t activationOffline) read(state *os.File) (activationOfflineRecord, error) {
+ var r activationOfflineRecord
+ raw, err := activationReadAt(state, "transaction.json", true)
+ if err != nil {
+ return r, err
+ }
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if err = dec.Decode(&r); err != nil {
+ return r, err
+ }
+ names := activationOfflineUnits()
+ if r.V != 1 || r.Units != t.units || len(r.Items) != len(names) {
+ return r, errActivation
+ }
+ for i, item := range r.Items {
+ if item.Name != names[i] {
+ return r, errActivation
+ }
+ }
+ switch r.Phase {
+ case "prepared", "held", "restoring", "released", "done":
+ default:
+ return r, errActivation
+ }
+ return r, nil
+}
+func (t activationOffline) prepare(ctx context.Context, units, state *os.File) (activationOfflineRecord, error) {
+ r := activationOfflineRecord{V: 1, Units: t.units, Phase: "prepared"}
+ for _, name := range activationOfflineUnits() {
+ path := filepath.Join(t.units, name)
+ backup := filepath.Join(t.state, name+".original")
+ item, err := activationUnitSnapshot(path, name)
+ if err != nil {
+ return r, err
+ }
+ load, active, err := t.status(ctx, name)
+ if err != nil {
+ return r, err
+ }
+ item.Active = active == "active"
+ item.LoadState = load
+ item.ActiveState = active
+ if err = os.Link(path, backup); err != nil && !errors.Is(err, os.ErrExist) {
+ return r, err
+ }
+ if !sameActivationFile(path, backup) {
+ return r, errActivation
+ }
+ if !item.Masked {
+ mask := filepath.Join(t.state, name+".mask")
+ if err = os.Symlink("/dev/null", mask); err != nil && !errors.Is(err, os.ErrExist) {
+ return r, err
+ }
+ st, e := os.Lstat(mask)
+ target, e2 := os.Readlink(mask)
+ if e != nil || e2 != nil || target != "/dev/null" || st.Sys().(*syscall.Stat_t).Uid != 0 {
+ return r, errActivation
+ }
+ }
+ if err = state.Sync(); err != nil {
+ return r, err
+ }
+ r.Items = append(r.Items, item)
+ if err = t.point("snapshot-" + name); err != nil {
+ return r, err
+ }
+ }
+ return r, t.save(state, r)
+}
+func (t activationOffline) checkItem(item activationOfflineItem) error {
+ original := filepath.Join(t.state, item.Name+".original")
+ got, err := activationUnitSnapshot(original, item.Name)
+ if err != nil {
+ return err
+ }
+ got.Active = item.Active
+ got.LoadState = item.LoadState
+ got.ActiveState = item.ActiveState
+ if !reflect.DeepEqual(got, item) {
+ return errActivation
+ }
+ current := filepath.Join(t.units, item.Name)
+ if sameActivationFile(current, original) {
+ return nil
+ }
+ if !item.Masked && sameActivationFile(current, filepath.Join(t.state, item.Name+".mask")) {
+ return nil
+ }
+ return errActivation
+}
+func (t activationOffline) replace(units, state *os.File, item activationOfflineItem, restore bool) error {
+ if err := t.checkItem(item); err != nil {
+ return err
+ }
+ if item.Masked {
+ return nil
+ }
+ source := filepath.Join(t.state, item.Name+".mask")
+ if restore {
+ source = filepath.Join(t.state, item.Name+".original")
+ }
+ target := filepath.Join(t.units, item.Name)
+ if sameActivationFile(source, target) {
+ return nil
+ }
+ stage := filepath.Join(t.state, item.Name+".next")
+ if err := os.Link(source, stage); err != nil && !errors.Is(err, os.ErrExist) {
+ return err
+ }
+ if !sameActivationFile(source, stage) {
+ return errActivation
+ }
+ if err := state.Sync(); err != nil {
+ return err
+ }
+ if err := t.point("stage-" + item.Name); err != nil {
+ return err
+ }
+ if err := os.Rename(stage, target); err != nil {
+ return err
+ }
+ if err := units.Sync(); err != nil {
+ return err
+ }
+ if err := state.Sync(); err != nil {
+ return err
+ }
+ if err := t.point("replace-" + item.Name); err != nil {
+ return err
+ }
+ if !sameActivationFile(source, target) {
+ return errActivation
+ }
+ return nil
+}
+func (t activationOffline) begin(ctx context.Context, dry bool) error {
+ if dry {
+ state, err := activationDir(t.state, false)
+ if err == nil {
+ record, e := t.read(state)
+ state.Close()
+ if e == nil {
+ if record.Phase != "prepared" && record.Phase != "held" {
+ return errActivation
+ }
+ for _, item := range record.Items {
+ if e = t.checkItem(item); e != nil {
+ return e
+ }
+ }
+ } else if !errors.Is(e, os.ErrNotExist) {
+ return e
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+
+ units, err := activationDir(t.units, false)
+ if err != nil {
+ return err
+ }
+ defer units.Close()
+ for _, name := range activationOfflineUnits() {
+ if _, err := activationUnitSnapshot(filepath.Join(t.units, name), name); err != nil {
+ return err
+ }
+ if _, _, err := t.status(ctx, name); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ units, state, err := t.dirs(true)
+ if err != nil {
+ return err
+ }
+ defer units.Close()
+ defer state.Close()
+ lock, err := activationLock(state)
+ if err != nil {
+ return err
+ }
+ defer lock.Close()
+ r, err := t.read(state)
+ if errors.Is(err, os.ErrNotExist) {
+ r, err = t.prepare(ctx, units, state)
+ }
+ if err != nil {
+ return err
+ }
+ if r.Phase != "prepared" && r.Phase != "held" {
+ return errActivation
+ }
+ for _, item := range r.Items {
+ if err = t.checkItem(item); err != nil {
+ return err
+ }
+ }
+ for _, item := range r.Items {
+ if _, err = t.systemctl(ctx, "stop", item.Name); err != nil {
+ return err
+ }
+ if err = t.point("stop-" + item.Name); err != nil {
+ return err
+ }
+ }
+ for _, item := range r.Items {
+ if err = t.replace(units, state, item, false); err != nil {
+ return err
+ }
+ }
+ if _, err = t.systemctl(ctx, "daemon-reload"); err != nil {
+ return err
+ }
+ if err = t.point("reload"); err != nil {
+ return err
+ }
+ for _, item := range r.Items {
+ load, active, e := t.status(ctx, item.Name)
+ if e != nil {
+ return e
+ }
+ if load != "masked" || active != "inactive" && active != "failed" {
+ return errActivation
+ }
+ }
+ if t.drained == nil {
+ return errActivation
+ }
+ if err = t.drained(ctx); err != nil {
+ return err
+ }
+ if err = t.point("drained"); err != nil {
+ return err
+ }
+ r.Phase = "held"
+ return t.save(state, r)
+}
+func (t activationOffline) restore(ctx context.Context, dry bool) error {
+ units, state, err := t.dirs(false)
+ if err != nil {
+ return err
+ }
+ defer units.Close()
+ defer state.Close()
+ if !dry {
+ lock, e := activationLock(state)
+ if e != nil {
+ return e
+ }
+ defer lock.Close()
+ }
+ r, err := t.read(state)
+ if err != nil {
+ return err
+ }
+ if r.Phase == "prepared" {
+ return errActivation
+ }
+ if r.Phase == "done" {
+ return nil
+ }
+ pending := r.Items
+ if r.Phase == "released" {
+ pending = r.Items[len(r.Items)-1:]
+ }
+ for _, item := range pending {
+ if err = t.checkItem(item); err != nil {
+ return err
+ }
+ }
+ if dry {
+ return nil
+ }
+ if r.Phase != "released" {
+ r.Phase = "restoring"
+ if err = t.save(state, r); err != nil {
+ return err
+ }
+ for _, item := range r.Items[:len(r.Items)-1] {
+ if err = t.replace(units, state, item, true); err != nil {
+ return err
+ }
+ }
+ if _, err = t.systemctl(ctx, "daemon-reload"); err != nil {
+ return err
+ }
+ if err = t.point("restore-reload"); err != nil {
+ return err
+ }
+ // Once triggers run, adopt may replace a restored unit. Relinquish those
+ // originals durably before starting any trigger; only the edge remains ours.
+ r.Phase = "released"
+ if err = t.save(state, r); err != nil {
+ return err
+ }
+ }
+ start := func(item activationOfflineItem) error {
+ if !item.Active || item.Masked {
+ return nil
+ }
+ if _, err := t.systemctl(ctx, "start", item.Name); err != nil {
+ return err
+ }
+ if err := t.point("start-" + item.Name); err != nil {
+ return err
+ }
+ load, active, err := t.status(ctx, item.Name)
+ if err != nil {
+ return err
+ }
+ if load != "loaded" || active != "active" {
+ return errActivation
+ }
+ return nil
+ }
+ for _, item := range r.Items[:len(r.Items)-1] {
+ if err = start(item); err != nil {
+ return err
+ }
+ }
+ edge := r.Items[len(r.Items)-1]
+ if err = t.replace(units, state, edge, true); err != nil {
+ return err
+ }
+ if _, err = t.systemctl(ctx, "daemon-reload"); err != nil {
+ return err
+ }
+ if err = t.point("edge-reload"); err != nil {
+ return err
+ }
+ if err = start(edge); err != nil {
+ return err
+ }
+ r.Phase = "done"
+ return t.save(state, r)
+}
+
+type activationOfflinePreview struct {
+ Unit string `json:"unit"`
+ OriginalSHA256 string `json:"original_sha256"`
+ PreserveMask bool `json:"preserve_mask"`
+ RestoreActive bool `json:"restore_active"`
+ CurrentLoadState string `json:"current_load_state"`
+ CurrentActiveState string `json:"current_active_state"`
+}
+
+func (t activationOffline) preview(ctx context.Context) ([]activationOfflinePreview, error) {
+ var items []activationOfflineItem
+ state, err := activationDir(t.state, false)
+ if err == nil {
+ defer state.Close()
+ record, e := t.read(state)
+ if e == nil {
+ items = record.Items
+ } else if !errors.Is(e, os.ErrNotExist) {
+ return nil, e
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ if items == nil {
+ units, e := activationDir(t.units, false)
+ if e != nil {
+ return nil, e
+ }
+ defer units.Close()
+ for _, name := range activationOfflineUnits() {
+ item, e := activationUnitSnapshot(filepath.Join(t.units, name), name)
+ if e != nil {
+ return nil, e
+ }
+ load, active, e := t.status(ctx, name)
+ if e != nil {
+ return nil, e
+ }
+ item.LoadState = load
+ item.ActiveState = active
+ item.Active = active == "active"
+ items = append(items, item)
+ }
+ }
+ var out []activationOfflinePreview
+ for _, item := range items {
+ load, active, err := t.status(ctx, item.Name)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, activationOfflinePreview{Unit: item.Name, OriginalSHA256: item.Digest, PreserveMask: item.Masked, RestoreActive: item.Active, CurrentLoadState: load, CurrentActiveState: active})
+ }
+ return out, nil
+}
diff --git a/internal/host/activation_offline_rootrunner_linux_test.go b/internal/host/activation_offline_rootrunner_linux_test.go
new file mode 100644
index 000000000..58ade0d48
--- /dev/null
+++ b/internal/host/activation_offline_rootrunner_linux_test.go
@@ -0,0 +1,367 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+func offlineFixture(t *testing.T) (activationOffline, string) {
+ t.Helper()
+ d := activationRootFixture(t)
+ units := filepath.Join(d, "units")
+ os.Mkdir(units, 0755)
+ state := filepath.Join(d, "transaction")
+ for _, name := range activationOfflineUnits() {
+ os.WriteFile(filepath.Join(units, name), []byte("[Unit]\nDescription="+name+"\n"), 0644)
+ }
+ os.Remove(filepath.Join(units, "carlos-stage.service"))
+ os.Symlink("/dev/null", filepath.Join(units, "carlos-stage.service"))
+ ctl := func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "show" {
+ load := "loaded"
+ if target, _ := os.Readlink(filepath.Join(units, args[1])); target == "/dev/null" {
+ load = "masked"
+ }
+ return []byte("LoadState=" + load + "\nActiveState=inactive\n"), nil
+ }
+ return nil, nil
+ }
+ return activationOffline{units: units, state: state, systemctl: ctl, drained: func(context.Context) error { return nil }}, d
+}
+func TestRootActivationOfflineInterruptions(t *testing.T) {
+ for _, phase := range []string{"begin", "restore"} {
+ for cut := 1; cut < 100; cut++ {
+ t.Run(fmt.Sprintf("%s/%d", phase, cut), func(t *testing.T) {
+ tx, _ := offlineFixture(t)
+ active := map[string]bool{"carlos-adopt.timer": true, "carlos-edge.service": true}
+ base := tx.systemctl
+ tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "stop" {
+ active[args[1]] = false
+ }
+ if args[0] == "start" {
+ active[args[1]] = true
+ }
+ raw, err := base(ctx, args...)
+ if args[0] == "show" && active[args[1]] {
+ raw = []byte(strings.ReplaceAll(string(raw), "ActiveState=inactive", "ActiveState=active"))
+ }
+ return raw, err
+ }
+ if phase == "restore" {
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ }
+ n := 0
+ injected := errors.New("interrupted")
+ tx.checkpoint = func(string) error {
+ n++
+ if n == cut {
+ return injected
+ }
+ return nil
+ }
+ var err error
+ if phase == "begin" {
+ err = tx.begin(t.Context(), false)
+ } else {
+ err = tx.restore(t.Context(), false)
+ }
+ if err != nil && !errors.Is(err, injected) {
+ t.Fatal(err)
+ }
+ tx.checkpoint = nil
+ if phase == "begin" {
+ if err = tx.begin(t.Context(), false); err != nil {
+ t.Fatal("resume", err)
+ }
+ }
+ if err = tx.restore(t.Context(), false); err != nil {
+ t.Fatal("restore", err)
+ }
+ if err = tx.begin(t.Context(), true); err == nil {
+ t.Fatal("dry run reopened completed transaction")
+ }
+
+ for _, name := range activationOfflineUnits() {
+ path := filepath.Join(tx.units, name)
+ if name == "carlos-stage.service" {
+ target, e := os.Readlink(path)
+ if e != nil || target != "/dev/null" {
+ t.Fatal("operator mask lost")
+ }
+ continue
+ }
+ raw, e := os.ReadFile(path)
+ if e != nil || string(raw) != "[Unit]\nDescription="+name+"\n" {
+ t.Fatal("original lost", name, e)
+ }
+ }
+ if n < cut {
+ cut = 100
+ }
+ })
+ }
+ }
+}
+func TestRootActivationOfflineReplacementRefused(t *testing.T) {
+ tx, _ := offlineFixture(t)
+ if err := tx.begin(t.Context(), true); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(tx.state); !os.IsNotExist(err) {
+ t.Fatal("dry run wrote state")
+ }
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(tx.units, "carlos-edge.service")
+ os.Remove(p)
+ os.Symlink("/dev/null", p)
+ if err := tx.restore(t.Context(), false); err == nil {
+ t.Fatal("unowned replacement removed")
+ }
+ if target, _ := os.Readlink(p); target != "/dev/null" {
+ t.Fatal("replacement mask changed")
+ }
+}
+
+const offlineRebootState = "/etc/carlos-activation-rootrunner"
+
+func offlineRealSystemctl(ctx context.Context, args ...string) ([]byte, error) {
+ return exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
+}
+func setupOfflineReal(t *testing.T) activationOffline {
+ t.Helper()
+ if os.Geteuid() != 0 {
+ t.Fatal("root required")
+ }
+ if err := os.Mkdir(offlineRebootState, 0700); err != nil {
+ t.Fatal(err)
+ }
+ tx := activationOffline{units: "/etc/systemd/system", state: offlineRebootState + "/transaction", systemctl: offlineRealSystemctl, drained: quiesceLegacyRootMutators}
+ for _, name := range activationOfflineUnits() {
+ p := filepath.Join(tx.units, name)
+ if _, err := os.Lstat(p); !os.IsNotExist(err) {
+ t.Fatal("pre-existing unit", name, err)
+ }
+ raw := "[Unit]\nDescription=activation root runner\n[Service]\nType=simple\nExecStart=/usr/bin/sleep infinity\n[Install]\nWantedBy=multi-user.target\n"
+ if strings.HasSuffix(name, ".timer") {
+ raw = "[Unit]\nDescription=activation timer\n[Timer]\nOnActiveSec=1h\n[Install]\nWantedBy=timers.target\n"
+ }
+ if strings.HasSuffix(name, ".path") {
+ raw = "[Unit]\nDescription=activation path\n[Path]\nPathExists=" + offlineRebootState + "/never\n[Install]\nWantedBy=multi-user.target\n"
+ }
+ if err := os.WriteFile(p, []byte(raw), 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, args := range [][]string{{"daemon-reload"}, {"enable", "--now", "carlos-edge.service", "carlos-adopt.timer"}} {
+ if out, err := tx.systemctl(t.Context(), args...); err != nil {
+ t.Fatal(string(out), err)
+ }
+ }
+ return tx
+}
+func cleanupOfflineReal(t *testing.T, tx activationOffline) {
+ t.Helper()
+ for _, name := range activationOfflineUnits() {
+ tx.systemctl(t.Context(), "disable", "--now", name)
+ os.Remove(filepath.Join(tx.units, name))
+ }
+ tx.systemctl(t.Context(), "daemon-reload")
+ os.RemoveAll(offlineRebootState)
+}
+func TestRootActivationOfflineSystemd(t *testing.T) {
+ tx := setupOfflineReal(t)
+ defer cleanupOfflineReal(t, tx)
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifyOfflineRootMigration(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if err := tx.restore(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ out, err := tx.systemctl(t.Context(), "is-active", "carlos-edge.service")
+ if err != nil || strings.TrimSpace(string(out)) != "active" {
+ t.Fatal(string(out), err)
+ }
+}
+func TestRootRebootActivationOfflinePhase1(t *testing.T) {
+ tx := setupOfflineReal(t)
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(offlineRebootState+"/boot", boot, 0600); err != nil {
+ t.Fatal(err)
+ }
+}
+func TestRootRebootActivationOfflinePhase2(t *testing.T) {
+ tx := activationOffline{units: "/etc/systemd/system", state: offlineRebootState + "/transaction", systemctl: offlineRealSystemctl, drained: quiesceLegacyRootMutators}
+ defer cleanupOfflineReal(t, tx)
+ before, err := os.ReadFile(offlineRebootState + "/boot")
+ if err != nil {
+ t.Fatal(err)
+ }
+ after, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil || string(before) == string(after) {
+ t.Fatal("no actual reboot", err)
+ }
+ if err := verifyOfflineRootMigration(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if err := tx.begin(t.Context(), false); err != nil {
+ t.Fatal("resume after reboot", err)
+ }
+ if err := tx.restore(t.Context(), false); err != nil {
+ t.Fatal("restore after reboot", err)
+ }
+ out, err := tx.systemctl(t.Context(), "is-active", "carlos-edge.service")
+ if err != nil || strings.TrimSpace(string(out)) != "active" {
+ t.Fatal(string(out), err)
+ }
+}
+
+func TestRootActivationOfflineEdgeLast(t *testing.T) {
+ tx, _ := offlineFixture(t)
+ original := tx.systemctl
+ restoring := false
+ tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "show" && (args[1] == "carlos-edge.service" || args[1] == "carlos-adopt.timer") {
+ if !restoring {
+ return []byte("LoadState=loaded\nActiveState=active\n"), nil
+ }
+ }
+ return original(ctx, args...)
+ }
+ units, state, err := tx.dirs(true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = tx.prepare(t.Context(), units, state)
+ units.Close()
+ state.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ tx.systemctl = original
+ if err = tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ restoring = true
+ started := map[string]bool{}
+ tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "start" {
+ if args[1] != "carlos-edge.service" {
+ target, _ := os.Readlink(filepath.Join(tx.units, "carlos-edge.service"))
+ if target != "/dev/null" {
+ t.Fatal("edge unmasked before triggers restored")
+ }
+ }
+ started[args[1]] = true
+ }
+ if args[0] == "show" && started[args[1]] {
+ return []byte("LoadState=loaded\nActiveState=active\n"), nil
+ }
+ return original(ctx, args...)
+ }
+ if err = tx.restore(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ if !started["carlos-edge.service"] || !started["carlos-adopt.timer"] {
+ t.Fatal("active states not restored", started)
+ }
+}
+
+func TestRootActivationProcessDrain(t *testing.T) {
+ uid, gid := generationRunnerRegistryUser(t)
+ cmd := exec.Command("/usr/bin/sleep", "60")
+ cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}}
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { cmd.Process.Kill(); cmd.Wait() }()
+ if err := activationProcessesDrained(t.Context()); err == nil {
+ t.Fatal("live service process accepted")
+ }
+ if err := cmd.Process.Kill(); err != nil {
+ t.Fatal(err)
+ }
+ cmd.Wait()
+ if err := activationProcessesDrained(t.Context()); err != nil {
+ t.Fatal("drained service refused", err)
+ }
+}
+
+func TestRootActivationOfflineResumeAfterTriggerRepair(t *testing.T) {
+ tx, _ := offlineFixture(t)
+ original := tx.systemctl
+ tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "show" && args[1] == "carlos-adopt.timer" {
+ return []byte("LoadState=loaded\nActiveState=active\n"), nil
+ }
+ return original(ctx, args...)
+ }
+ units, state, err := tx.dirs(true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = tx.prepare(t.Context(), units, state)
+ units.Close()
+ state.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ tx.systemctl = original
+ if err = tx.begin(t.Context(), false); err != nil {
+ t.Fatal(err)
+ }
+ injected := errors.New("lost trigger start reply")
+ once := false
+ tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "start" && args[1] == "carlos-adopt.timer" {
+ if !once {
+ once = true
+ // An enabled adopt trigger can repair other units immediately.
+ path := filepath.Join(tx.units, "carlos-system-update.service")
+ if err := os.WriteFile(path+".repair", []byte("[Unit]\nDescription=repaired by adopt\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(path+".repair", path); err != nil {
+ t.Fatal(err)
+ }
+ return nil, injected
+ }
+ }
+ if args[0] == "show" && args[1] == "carlos-adopt.timer" {
+ return []byte("LoadState=loaded\nActiveState=active\n"), nil
+ }
+ return original(ctx, args...)
+ }
+ if err = tx.restore(t.Context(), false); !errors.Is(err, injected) {
+ t.Fatal("failure not reached", err)
+ }
+ if err = tx.restore(t.Context(), false); err != nil {
+ t.Fatal("released unit blocks restore", err)
+ }
+ raw, _ := os.ReadFile(filepath.Join(tx.units, "carlos-system-update.service"))
+ if !strings.Contains(string(raw), "repaired by adopt") {
+ t.Fatal("restored over new owner")
+ }
+}
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..b80c99784
--- /dev/null
+++ b/internal/provision/activation_tools_census_test.go
@@ -0,0 +1,9 @@
+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",
+ })
+}
diff --git a/internal/provision/plan14_p3_census_test.go b/internal/provision/plan14_p3_census_test.go
index 710c429a1..490691f76 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-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-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 1606437b6..3d7631bba 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -777,6 +777,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 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 20c283a57..cf4050202 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..3af5ef902
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv
@@ -0,0 +1,45 @@
+# Activation-tools run 1: proposals only; loaded after all existing overlays.
+@coverage . efeeda08e816fc2c7e3aa451b06cce95ab5778af79682290cf98a60d8b9a841a
+@coverage internal/claimactivation 625d82dce14bae92f619077ca92a8c9066a9446545dfcc96a6f818e89e20d4c2
+@coverage internal/host e159204d107057af3a44f7e4015dd969e5e5744d71e27172a0ccb69011671e81
+@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
+internal/host/activation_command_linux.go activationProcessesDrained 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:ReadDir@219,file:Stat@458 104e95618024c8f84acb7f18c39cdc18ac6834d34242a075f4652fdb6ed84e75
+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 ed3cfa4f82a5b8b918d8c52cd58c979b0c451e3e34a5b20076b1744ea695c55c
+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
+internal/host/activation_offline_linux.go activationOffline.begin 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 c043caa233ba91721b2a7a7c5792d847b2ebd129ac2975160e4af224bef349fe
+internal/host/activation_offline_linux.go activationOffline.checkItem 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 db9c3080a32a91bbc92b9d57b26807a87bff55950b9b5405f0917101f30a0186
+internal/host/activation_offline_linux.go activationOffline.dirs 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:Stat@384 cdbba773717e890be772460b0f8235b4f7f0e158a11977740a2c963aa3a1c923
+internal/host/activation_offline_linux.go activationOffline.point 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 34292b297a697dbb9fd4c1b067900a7cdcdd0b41dba0d7c7dc9764f633424e43
+internal/host/activation_offline_linux.go activationOffline.prepare 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:Lstat@947,file:Readlink@979 f3723f40487a5fe0fd43a8369be180b3238c808d9ce117612d744058a9fb0861
+internal/host/activation_offline_linux.go activationOffline.preview 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 a092bb6fb96f64e406a266cef59a4516145a7f58935ff8d1bbc42a96a653aab1
+internal/host/activation_offline_linux.go activationOffline.read 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 b6a9e2446fa1a163a2afdd039ca6dad27260ffbb099c91d4a9fcbc487d065470
+internal/host/activation_offline_linux.go activationOffline.replace 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 2a64167ac7053a5c73fe64aa9fea7e4f8db3b1409c7e34fa78c1b842328ccdbb
+internal/host/activation_offline_linux.go activationOffline.restore 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 db8fea8f8ec23ca29f763d6e8653e6b82f8abc0fba79e4a969b86198e3f8a5e5
+internal/host/activation_offline_linux.go activationOffline.save 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 3082a1585eb287687e05756f495aae7de7e11aab6e96bbee920c50b4807ab9b7
+internal/host/activation_offline_linux.go activationOffline.status 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 22467426d7d4cdb3d3f41dfab898f5a5ee49c34a58d4489ab53d6f04ab213587
+internal/host/activation_offline_linux.go activationOfflineUnits 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 946bc600ded790992d034479eb50b5363a3c9c50e3a3b47693e90318c357c8cc
+internal/host/activation_offline_linux.go activationUnitSnapshot 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:Lstat@135,file:ReadFile@819,file:Readlink@539 bcb4d45f273e849f03715cfb3a6b49b25b3e128d55e88b15a9528175e433ad04
+internal/host/activation_offline_linux.go sameActivationFile 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:Lstat@107,file:Lstat@53 72cf415a823352f1fcf5aba8452df074896aacc851da3dfea9a635b37c44669b
+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 840490df69e8afea3148a1bff69b98d8e0b047adab82a209a8d5554fd09c4a14
+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 eb6c83689a425682db92ef36b4012f42a10ebaa94cfe731daedf5e4cc8f64218
diff --git a/system.go b/system.go
index 761e5bc12..67b9d4933 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", "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", "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":