region HA 2b: root's half of joining
carlos system join: root verifies this box's signed invitation, its published identity record and instance, builds the armed policy from the verified host floor, and runs guard-arm. The path unit's file is only a trigger; root reads nothing from it. A group box whose reference is not yet known fails with IMDS's reason rather than reading the invitation of an empty name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+491
−2
approved_region_ha_2b_copy_test.go+1 −1internal/host/bootstrap.go+3 −0internal/host/host_test.go+2 −0internal/host/joinroot.go+210 −0internal/host/joinroot_test.go+213 −0internal/host/units/carlos-system-join.path+15 −0internal/host/units/carlos-system-join.service+41 −0internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv+5 −1region_ha_boundary_test.go+1 −0
diff --git a/approved_region_ha_2b_copy_test.go b/approved_region_ha_2b_copy_test.go| index 5b2cf1a14..ed48d8c79 100644 |
| --- a/approved_region_ha_2b_copy_test.go |
| +++ b/approved_region_ha_2b_copy_test.go |
| @@ -38,7 +38,7 @@ var regionHA2bCopyIDs = func() []string { |
| // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| // task that writes a row's text adds its ids here in the same commit, once |
| // Paul has approved the rows. |
| -var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C69"} |
| +var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63", "RHB-C69"} |
| func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| t.Helper() |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 86045311d..9b9427a49 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -166,6 +166,9 @@ const relayUnitName = "carlos-relay.service" |
| var enabledUnits = []string{"carlos-edge.service", relayUnitName, "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path", |
| "carlos-system-update.path", "carlos-system-recover.service", |
| + // Region HA joining (spec 2 §5 step 5.3): root's half, on the agent's |
| + // request. Idle on every box that is not joining. |
| + "carlos-system-join.path", |
| // The isolated-exec mounts (design §3.2): the edge Requires= them. |
| "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", |
| // The root instance-control channel (design §5.2); socket-activated, so |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 47df3530d..6d1c4e688 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -57,6 +57,8 @@ var expectedUnits = []string{ |
| "carlos-restore-verify.timer", |
| "carlos-stage.path", |
| "carlos-stage.service", |
| + "carlos-system-join.path", |
| + "carlos-system-join.service", |
| "carlos-system-recover.service", |
| "carlos-system-update.path", |
| "carlos-system-update.service", |
diff --git a/internal/host/joinroot.go b/internal/host/joinroot.go| new file mode 100644 |
| index 000000000..b139ea5b7 |
| --- /dev/null |
| +++ b/internal/host/joinroot.go |
| @@ -0,0 +1,210 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/cliflag" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/version" |
| +) |
| + |
| +// carlos system join (region HA spec 2 §5 step 5.3): root's half of |
| +// joining, run by carlos-system-join.service when the joining agent asks. |
| +// |
| +// The invitation grants no membership: it is the console's signed nonce |
| +// for this box's identity record, with which the box installs its guard |
| +// and reports. Root reads it itself and checks it: signed by the release |
| +// key, for this deployment and this box, for the identity record this box |
| +// published, which must be this instance's. Then it writes the enrollment |
| +// the invitation names, builds the armed policy from the published host |
| +// floor, and runs guard-arm with the release identity install-channel |
| +// kept (channelIdentityPath). guard-arm checks both protected slots against |
| +// that identity and writes /etc/carlos/host-actor.json. |
| +// |
| +// A box already armed, a box with no invitation yet, and a box with claim |
| +// mode or region HA off have nothing to do. |
| + |
| +const joinUsage = "usage: carlos system join\n\n" + |
| + "Arm the host lifecycle guard of a joining box, as root, once the console has invited it.\n" |
| + |
| +const joinNotRoot = "carlos system join runs as root: it arms the host lifecycle guard" |
| + |
| +// joinDir holds what root hands guard-arm: the enrollment and the policy. |
| +func joinDir(optDir string) string { return filepath.Join(optDir, "system", "join") } |
| + |
| +// rootJoin is one run; production's is RunSystemJoin's. |
| +type rootJoin struct { |
| + box string |
| + optDir string |
| + bucket string |
| + store release.Store |
| + pub ed25519.PublicKey |
| + instance func() string |
| + // armed reports whether the guard is armed for host-actor.json. |
| + armed func() bool |
| + // arm is guard-arm, given the three files. |
| + arm func(policy, identity, enrollment string) error |
| +} |
| + |
| +// joinOutcome is what a run did. |
| +type joinOutcome string |
| + |
| +const ( |
| + joinArmedAlready joinOutcome = "armed already" |
| + joinNotInvited joinOutcome = "not invited" |
| + joinArmed joinOutcome = "armed" |
| +) |
| + |
| +// RunSystemJoin is `carlos system join`. |
| +func RunSystemJoin(args []string) { |
| + fs := flag.NewFlagSet("system join", flag.ExitOnError) |
| + fs.Usage = cliflag.Usage(fs, joinUsage) |
| + _ = fs.Parse(args) |
| + if fs.NArg() != 0 { |
| + fs.Usage() |
| + os.Exit(2) |
| + } |
| + if os.Geteuid() != 0 { |
| + fmt.Fprintln(os.Stderr, joinNotRoot) |
| + os.Exit(1) |
| + } |
| + if !hostwrite.Enabled(os.Getenv) || !regionHAEnabled(os.Getenv) { |
| + fmt.Println("CARLOS_HOST_CLAIM or CARLOS_REGION_HA is off, so this box does not join") |
| + return |
| + } |
| + ctx := context.Background() |
| + store, pub, err := openSystemStore(ctx) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + optDir := release.OptDir() |
| + box, err := joinBoxLabel(os.Getenv, readMachineID()) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + j := rootJoin{ |
| + box: box, optDir: optDir, bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| + store: store, pub: pub, instance: cachedInstanceID, |
| + armed: func() bool { |
| + if _, err := hostfloor.ReadEnrollment(hostActorPath); err != nil { |
| + return false |
| + } |
| + d, err := floorDisk(optDir) |
| + if err != nil { |
| + return false |
| + } |
| + r, err := d.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH) |
| + return err == nil && r.GuardReady |
| + }, |
| + arm: func(policy, identity, enrollment string) error { |
| + // guard-arm exits the process on any refusal, with its reason. |
| + RunSystemGuardArm([]string{"-policy", policy, "-identity", identity, "-enrollment", enrollment}) |
| + return nil |
| + }, |
| + } |
| + got, err := j.run(ctx) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + log.Printf("system join: %s: %s", j.box, got) |
| +} |
| + |
| +// joinBoxLabel is the name this box joins under: metricsBoxLabel's, except |
| +// that a group box whose reference IMDS has not given yet is an error with |
| +// IMDS's reason. Its label would be "", and "" names no invitation, so the |
| +// run would end as "not invited" on a box that may well be invited. |
| +func joinBoxLabel(getenv func(string) string, machineID string) (string, error) { |
| + if groupReferenced(getenv) { |
| + ref, err := resolveGroupBoxReference() |
| + if err != nil { |
| + return "", fmt.Errorf("this group box's reference could not be read, so it cannot join: %w", err) |
| + } |
| + return ref, nil |
| + } |
| + return metricsBoxLabel(getenv, machineID), nil |
| +} |
| + |
| +func (j rootJoin) run(ctx context.Context) (joinOutcome, error) { |
| + if j.box == "" { |
| + return "", errors.New("this box has no name to join under") |
| + } |
| + if j.armed() { |
| + return joinArmedAlready, nil |
| + } |
| + wire, _, err := j.store.Get(ctx, invitationKey(j.box)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return joinNotInvited, nil |
| + } |
| + if err != nil { |
| + return "", fmt.Errorf("the invitation for %s could not be read: %w", j.box, err) |
| + } |
| + var inv hostactors.Invitation |
| + if release.VerifyRootEnvelope(j.pub, wire, &inv) != nil || inv.V != 1 || inv.Bucket != j.bucket || inv.Reference != j.box || inv.Enrollment == "" { |
| + return "", fmt.Errorf("the invitation for %s does not verify, so the guard was not armed", j.box) |
| + } |
| + raw, err := boxid.Load(ctx, j.store, j.box) |
| + if err != nil { |
| + return "", fmt.Errorf("the identity record for %s could not be read: %w", j.box, err) |
| + } |
| + var rec boxid.Record |
| + if hostfloor.Digest(raw) != inv.EvidenceSHA256 || json.Unmarshal(raw, &rec) != nil { |
| + return "", fmt.Errorf("the invitation for %s is for another identity record, so the guard was not armed", j.box) |
| + } |
| + instance, err := recordInstanceID(rec) |
| + if err != nil || instance == "" || instance != j.instance() { |
| + return "", fmt.Errorf("the identity record for %s is not this instance's, so the guard was not armed", j.box) |
| + } |
| + // guard-arm refuses, naming the file, when install-channel has not |
| + // kept the release identity. |
| + identity := channelIdentityPath(j.optDir) |
| + floorRaw, _, err := j.store.Get(ctx, hostfloor.Key) |
| + if err != nil { |
| + return "", fmt.Errorf("the host floor could not be read: %w", err) |
| + } |
| + auth, _, err := j.store.Get(ctx, hostfloor.AuthKey) |
| + if err != nil { |
| + return "", fmt.Errorf("the host floor could not be read: %w", err) |
| + } |
| + if _, err := hostfloor.Verify(j.pub, j.bucket, floorRaw, auth); err != nil { |
| + return "", fmt.Errorf("the host floor does not verify, so the guard was not armed: %w", err) |
| + } |
| + policy, err := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: j.bucket, Mode: "armed", Floor: floorRaw, Authorization: auth}) |
| + if err != nil { |
| + return "", err |
| + } |
| + enrollment, err := json.Marshal(hostfloor.Enrollment{Box: j.box, Enrollment: inv.Enrollment, InstanceID: instance}) |
| + if err != nil { |
| + return "", err |
| + } |
| + dir := joinDir(j.optDir) |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + return "", err |
| + } |
| + policyPath, enrollmentPath := filepath.Join(dir, "policy.json"), filepath.Join(dir, "enrollment.json") |
| + if err := hostfloor.Durable(policyPath, policy, 0o600); err != nil { |
| + return "", err |
| + } |
| + if err := hostfloor.Durable(enrollmentPath, enrollment, 0o600); err != nil { |
| + return "", err |
| + } |
| + if err := j.arm(policyPath, identity, enrollmentPath); err != nil { |
| + return "", err |
| + } |
| + return joinArmed, nil |
| +} |
diff --git a/internal/host/joinroot_test.go b/internal/host/joinroot_test.go| new file mode 100644 |
| index 000000000..c6597fcc0 |
| --- /dev/null |
| +++ b/internal/host/joinroot_test.go |
| @@ -0,0 +1,213 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/boxid/boxidtest" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostenv" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// rootJoinWorld is a deployment that has invited box "ada-hiking", whose |
| +// identity record says it is instance i-ada. |
| +type rootJoinWorld struct { |
| + j rootJoin |
| + store *release.DirStore |
| + sg release.Signer |
| + armed []string |
| + invite hostactors.Invitation |
| +} |
| + |
| +func newRootJoinWorld(t *testing.T, invite bool) *rootJoinWorld { |
| + t.Helper() |
| + ctx := context.Background() |
| + pub, key, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + w := &rootJoinWorld{store: release.NewDirStore(t.TempDir()), sg: release.RootSigner(key)} |
| + _, raw := boxidtest.NewSigner(t).Record(t, "ada-hiking", "123", "eu-west-1", "i-ada") |
| + if _, err := w.store.Put(ctx, boxid.Key("ada-hiking"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := hostfloor.Publish(ctx, w.store, w.sg, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if invite { |
| + if w.invite, err = (hostactors.Directory{Store: w.store, PublicKey: pub, Bucket: "bucket"}).Invite(ctx, w.sg, "ada-hiking", raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + w.j = rootJoin{box: "ada-hiking", optDir: t.TempDir(), bucket: "bucket", store: w.store, pub: pub, |
| + instance: func() string { return "i-ada" }, armed: func() bool { return false }, |
| + arm: func(policy, identity, enrollment string) error { |
| + w.armed = []string{policy, identity, enrollment} |
| + return nil |
| + }} |
| + return w |
| +} |
| + |
| +func TestRootJoinArmsWithTheInvitationsEnrollment(t *testing.T) { |
| + w := newRootJoinWorld(t, true) |
| + got, err := w.j.run(context.Background()) |
| + if err != nil || got != joinArmed || len(w.armed) != 3 { |
| + t.Fatalf("%s %v, armed %v", got, err, w.armed) |
| + } |
| + raw, err := os.ReadFile(w.armed[0]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var p hostfloor.Policy |
| + if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| + t.Fatalf("policy %s", raw) |
| + } |
| + if _, err := p.Verify(w.j.pub, "bucket"); err != nil { |
| + t.Fatalf("the policy does not verify: %v", err) |
| + } |
| + if w.armed[1] != channelIdentityPath(w.j.optDir) { |
| + t.Fatalf("identity %s", w.armed[1]) |
| + } |
| + e, err := hostfloor.ReadEnrollment(w.armed[2]) |
| + if err != nil || e != (hostfloor.Enrollment{Box: "ada-hiking", Enrollment: w.invite.Enrollment, InstanceID: "i-ada"}) { |
| + t.Fatalf("enrollment %+v %v", e, err) |
| + } |
| + for _, p := range []string{w.armed[0], w.armed[2]} { |
| + if fi, err := os.Stat(p); err != nil || fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("%s: %v %v", p, fi, err) |
| + } |
| + } |
| +} |
| + |
| +func TestRootJoinDoesNothingUntilInvitedOrOnceArmed(t *testing.T) { |
| + w := newRootJoinWorld(t, false) |
| + if got, err := w.j.run(context.Background()); err != nil || got != joinNotInvited || w.armed != nil { |
| + t.Fatalf("not invited: %s %v %v", got, err, w.armed) |
| + } |
| + v := newRootJoinWorld(t, true) |
| + v.j.armed = func() bool { return true } |
| + v.j.store = nil // an armed box reads nothing |
| + if got, err := v.j.run(context.Background()); err != nil || got != joinArmedAlready || v.armed != nil { |
| + t.Fatalf("armed: %s %v %v", got, err, v.armed) |
| + } |
| +} |
| + |
| +func TestRootJoinRefusesAnInvitationThatIsNotThisBoxs(t *testing.T) { |
| + for name, spoil := range map[string]func(*rootJoinWorld){ |
| + "another instance": func(w *rootJoinWorld) { w.j.instance = func() string { return "i-other" } }, |
| + "another record": func(w *rootJoinWorld) { |
| + _, raw := boxidtest.NewSigner(t).Record(t, "ada-hiking", "123", "eu-west-1", "i-ada") |
| + _, etag, _ := w.store.Get(context.Background(), boxid.Key("ada-hiking")) |
| + w.store.Put(context.Background(), boxid.Key("ada-hiking"), raw, etag) |
| + }, |
| + "another release key": func(w *rootJoinWorld) { |
| + other, _, _ := ed25519.GenerateKey(nil) |
| + w.j.pub = other |
| + }, |
| + "another deployment": func(w *rootJoinWorld) { w.j.bucket = "other-bucket" }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + w := newRootJoinWorld(t, true) |
| + spoil(w) |
| + if got, err := w.j.run(context.Background()); err == nil || w.armed != nil { |
| + t.Fatalf("%s %v, armed %v", got, err, w.armed) |
| + } |
| + }) |
| + } |
| + // A store that cannot be read is an error, never "not invited". |
| + w := newRootJoinWorld(t, true) |
| + w.j.store = joinOutageStore{} |
| + if got, err := w.j.run(context.Background()); err == nil || got == joinNotInvited { |
| + t.Fatalf("unreadable store: %s %v", got, err) |
| + } |
| +} |
| + |
| +type joinOutageStore struct{ release.Store } |
| + |
| +func (joinOutageStore) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", errors.New("connection reset") |
| +} |
| + |
| +// The join units' census condition (platform-1e, region-ha-2b): the path |
| +// unit's file is only a trigger. Whatever the agent, or anything else, |
| +// writes into it, root's join reads none of it, takes no parameter from |
| +// it, and decides from the signed invitation, the published identity |
| +// record, the signed host floor and IMDS alone. |
| +func TestRootJoinTakesNothingFromItsTrigger(t *testing.T) { |
| + data := t.TempDir() |
| + t.Setenv("CARLOS_DATA_DIR", data) |
| + clean := newRootJoinWorld(t, true) |
| + want, err := clean.j.run(context.Background()) |
| + if err != nil || want != joinArmed { |
| + t.Fatalf("baseline: %s %v", want, err) |
| + } |
| + for name, body := range map[string]string{ |
| + "another box": `{"box":"grace-hiking","instance":"i-grace","enrollment":"ffffffffffffffffffffffffffffffff"}`, |
| + "a path": "../../etc/carlos/host-actor.json\n", |
| + "a policy": `{"schema":1,"mode":"armed","deployment_bucket":"other-bucket"}`, |
| + "nul and newlines": "a\x00b\n\n--policy=/tmp/x\n", |
| + "a megabyte of junk": strings.Repeat("x", 1<<20), |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if err := os.WriteFile(filepath.Join(data, "system-join-requested"), []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + w := newRootJoinWorld(t, true) |
| + got, err := w.j.run(context.Background()) |
| + if err != nil || got != want || len(w.armed) != 3 || w.armed[1] != channelIdentityPath(w.j.optDir) { |
| + t.Fatalf("%s %v, armed %v", got, err, w.armed) |
| + } |
| + e, err := hostfloor.ReadEnrollment(w.armed[2]) |
| + if err != nil || e != (hostfloor.Enrollment{Box: "ada-hiking", Enrollment: w.invite.Enrollment, InstanceID: "i-ada"}) { |
| + t.Fatalf("enrollment %+v %v", e, err) |
| + } |
| + }) |
| + } |
| + // And by construction: root's join names neither the trigger nor its |
| + // path anywhere in its source. |
| + src, err := os.ReadFile("joinroot.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(src, []byte("joinRequestMarker")) || bytes.Contains(src, []byte("system-join-requested")) { |
| + t.Fatal("joinroot.go reads its trigger") |
| + } |
| +} |
| + |
| +// A group box names its invitation by its group reference. While IMDS has |
| +// not given it, there is no name, and the join says so; it never reads the |
| +// invitation of "" and reports "not invited". |
| +func TestRootJoinNamesAGroupBoxByItsReferenceOrFails(t *testing.T) { |
| + group := groupEnv(map[string]string{hostenv.BoxReference: hostenv.GroupReference}) |
| + withGroupIdentity(t, "", "", errors.New("IMDS answered 500")) |
| + if got, err := joinBoxLabel(group, "machine"); err == nil || got != "" || !strings.Contains(err.Error(), "IMDS answered 500") { |
| + t.Fatalf("no identity: %q %v", got, err) |
| + } |
| + withGroupIdentity(t, "123456789012", "i-0a61f580df1acfa65", nil) |
| + want := hostactors.GroupBoxReference("123456789012", "i-0a61f580df1acfa65") |
| + if got, err := joinBoxLabel(group, "machine"); err != nil || got != want { |
| + t.Fatalf("group box: %q %v, want %q", got, err, want) |
| + } |
| + // Any other box keeps metricsBoxLabel's name. |
| + other := groupEnv(nil) |
| + if got, err := joinBoxLabel(other, "machine"); err != nil || got != metricsBoxLabel(other, "machine") || got == "" { |
| + t.Fatalf("other box: %q %v", got, err) |
| + } |
| + // And a run with no name reads nothing. |
| + w := newRootJoinWorld(t, true) |
| + w.j.box = "" |
| + w.j.store = nil |
| + if got, err := w.j.run(context.Background()); err == nil || got == joinNotInvited || w.armed != nil { |
| + t.Fatalf("nameless: %s %v %v", got, err, w.armed) |
| + } |
| +} |
diff --git a/internal/host/units/carlos-system-join.path b/internal/host/units/carlos-system-join.path| new file mode 100644 |
| index 000000000..4f4f4810b |
| --- /dev/null |
| +++ b/internal/host/units/carlos-system-join.path |
| @@ -0,0 +1,15 @@ |
| +# Region HA spec 2 §5 step 5.3: the relay from a joining box's sandboxed |
| +# agent to root's half of joining. The agent (User=carlos, |
| +# ProtectSystem=strict) sees the console's invitation but cannot write /etc |
| +# or arm the host lifecycle guard; it touches this marker and root does the |
| +# rest, as carlos-system-update.path relays a self-update. Root takes |
| +# nothing from the marker: it reads and verifies the invitation itself. |
| +[Unit] |
| +Description=carlos joining: arm the host lifecycle guard on agent request |
| + |
| +[Path] |
| +PathChanged=/data/system-join-requested |
| +Unit=carlos-system-join.service |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/internal/host/units/carlos-system-join.service b/internal/host/units/carlos-system-join.service| new file mode 100644 |
| index 000000000..9b60032df |
| --- /dev/null |
| +++ b/internal/host/units/carlos-system-join.service |
| @@ -0,0 +1,41 @@ |
| +# Root's half of joining (region HA spec 2 §5 step 5.3): `carlos system |
| +# join` verifies this box's signed invitation, writes the enrollment it |
| +# names, and arms the host lifecycle guard (guard-arm) with the release |
| +# identity `carlos system install-channel` kept. On a box that is armed |
| +# already, has no invitation yet, or has CARLOS_HOST_CLAIM or |
| +# CARLOS_REGION_HA off, it does nothing. Started only by |
| +# carlos-system-join.path; no [Install] section, because nothing else |
| +# should want it. |
| +# |
| +# Root, with no filesystem sandboxing, like carlos-system-update.service: |
| +# guard-arm writes /opt/carlos/system/guard, /etc/carlos/host-actor.json and |
| +# the guarded units' drop-ins under /etc/systemd/system. |
| +# StartLimitIntervalSec=0 because the path unit can fire in bursts. |
| +[Unit] |
| +Description=carlos joining (root: arm the host lifecycle guard) |
| +After=network-online.target |
| +Wants=network-online.target |
| +StartLimitIntervalSec=0 |
| + |
| +[Service] |
| +Type=oneshot |
| +TimeoutStartSec=5min |
| +EnvironmentFile=-/etc/carlos/host.env |
| +ExecStart=/opt/carlos/carlos system join |
| + |
| +# --- hardening (the carlos-system-update.service set) --- |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +RestrictNamespaces=yes |
| +RestrictRealtime=yes |
| +LockPersonality=yes |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=256M |
| +TasksMax=64 |
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv| index e544aa204..90223c1c9 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| @@ -4,12 +4,13 @@ |
| # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite (only on the bound store, after a membership read), enrollmentNonce (the read-only invitation check), BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. Fix round 1: CheckConfirmed is unexported (enrollment_confirmation.go), the pass binds the invitation to the confirmation checkConfirmed returns, and an invited box is found with enrollmentNonce, never Invite. The seal moves. |
| # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves. |
| +# Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs, ruled authority F,J4 on the condition that the path unit's file is only a trigger: root's join reads nothing from it. The legacy seal stands. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| @coverage internal/hostenv 45d9d4b90a5d441020f5ba4573821ca92701246c80b32d6f1823665fb0f65208 |
| internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| -@coverage internal/host 3e02ff558f27445db10bf04624dc3eac63f77d106cd1fee31265c76465a42e38 |
| +@coverage internal/host b9ceedace2daef757acc72df1eec6440c1377b2317212040d9c3f7211366c50b |
| internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| internal/host/installchannel_linux.go slotState authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, Linux): opens one protected slot with O_NOFOLLOW (a symlink is never followed), fstats the descriptor and hashes from it, to compare with the sha256 of the signed, verified artifact; a slot holding anything else, a symlink or a non-regular file is refused and left as it is, and nothing in it is executed file:Open@66,file:Stat@388 bc533a29f92ae95d31123b551354ab9f8960576e5ffef1bd6a74a06df0b57a24 |
| internal/host/installchannel_linux.go var openat2 authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, Linux): unix.Openat2 held in a variable so a test can make it fail; removeBeneath is its only user and has no path-based fallback when it fails file:Openat2@14 6081e86f758f4ae3948ec2d4f4c037888e1362f122a410c110e31290ba914816 |
| @@ -22,3 +23,6 @@ internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy acc |
| @coverage internal/status a1eee66fc906d6a7275017fd30c346b64aaafe4f88a501538248e99e9d95c33d |
| @legacy 30a3ee6b3365c202bf93b26087888a582c6b8d6df1a9f1fc6ba12e639ddd8684 internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with its tick through groupStatusTick, so a box whose host.env carries the group marker (CARLOS_BOX_REFERENCE=group) publishes its status report only under its group reference, resolved afresh each tick, and nothing until it resolves, never under status.Label; every other box, fleet boxes included, gets statusTick with today's label and key; read sites unchanged whole:Route@6154,whole:Route@6181,whole:Route@7423,whole:Route@9437 666ddbc4779442db4c868253cf5062c690e20ad643197688305e0742f6de9283 |
| internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with its tick through groupStatusTick, so a box whose host.env carries the group marker (CARLOS_BOX_REFERENCE=group) publishes its status report only under its group reference, resolved afresh each tick, and nothing until it resolves, never under status.Label; every other box, fleet boxes included, gets statusTick with today's label and key; read sites unchanged whole:Route@6154,whole:Route@6181,whole:Route@7423,whole:Route@9437 666ddbc4779442db4c868253cf5062c690e20ad643197688305e0742f6de9283 |
| +@coverage internal/host/units 18a7bb1fe57b2044517e7acd3f97780beae35fd55e695c593f87a4ae7f3af27d |
| +internal/host/units/carlos-system-join.path external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) starts carlos-system-join.service when the joining agent touches /data/system-join-requested; the file is only a trigger: root reads nothing from it and takes no parameter from it file:external@0 8af1bea945a0ec5a475b766e6b55d7168bf6eff1bf963bd5c7d46f90130f4c5c |
| +internal/host/units/carlos-system-join.service external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) runs carlos system join as root: it verifies the signed invitation and this box's identity record itself, then runs guard-arm with the identity install-channel kept file:external@0 98c405ad69c7fd3e265ddfcaf176b0a5b98f0e61e35c4ae29a553211f63d230b |
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go| index d6b5f3f2a..45153ac23 100644 |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| @@ -42,6 +42,7 @@ var regionHACoreFiles = []string{ |
| "internal/host/joinmode.go", |
| "internal/host/boxreference.go", |
| "internal/status/fleet.go", |
| + "internal/host/joinroot.go", |
| } |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |