carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 39f67594fbc634cede23de0b40e5843b34b0e4bc parent 12d011e
9 files changed, +491 −2
  • approved_region_ha_2b_copy_test.go +1 −1
  • internal/host/bootstrap.go +3 −0
  • internal/host/host_test.go +2 −0
  • internal/host/joinroot.go +210 −0
  • internal/host/joinroot_test.go +213 −0
  • internal/host/units/carlos-system-join.path +15 −0
  • internal/host/units/carlos-system-join.service +41 −0
  • internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv +5 −1
  • region_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