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 box's AWS identity could not be read, so it cannot join yet: %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 }