package host import ( "context" "crypto/ed25519" "encoding/json" "errors" "flag" "fmt" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/version" "log" "os" "os/user" "path/filepath" "runtime" "strconv" "strings" ) func RunSystemGuardArm(args []string) { fs := flag.NewFlagSet("system guard-arm", flag.ExitOnError) consolePath := fs.String("console", "", "") policyPath := fs.String("policy", "", "") identityPath := fs.String("identity", "", "") enrollmentPath := fs.String("enrollment", "", "") _ = fs.Parse(args) if os.Geteuid() != 0 || *policyPath == "" || *identityPath == "" || *enrollmentPath == "" { log.Fatal(hostfloor.ErrFloor) } lockDir := systemRootDir(release.OptDir()) if err := prepareGuardArmLockDir(release.OptDir(), lockDir); err != nil { log.Fatal(err) } lock, err := os.OpenFile(filepath.Join(lockDir, systemLockFile), os.O_CREATE|os.O_RDWR, 0600) if err != nil { log.Fatal(err) } defer lock.Close() if err = flockExclusiveNonblock(lock); err != nil { log.Fatal(err) } d, err := floorDisk(release.OptDir()) if err != nil { log.Fatal(err) } raw, err := os.ReadFile(*policyPath) if err != nil { log.Fatal(err) } identity, err := os.ReadFile(*identityPath) if err != nil { log.Fatal(err) } hash, err := hostfloor.ExecutingHash() if err != nil { log.Fatal(err) } platform := runtime.GOOS + "-" + runtime.GOARCH p, f, err := guardArmInputs(raw, identity, d.PublicKey, d.Bucket, version.Build, platform, hash) if err != nil { log.Fatal(err) } if err = guardArmIdle(d.Dir, systemRootDir(release.OptDir())); err != nil { log.Fatal(err) } for _, path := range []string{systemBinaryPath(release.OptDir()), systemPrevPath(release.OptDir())} { candidate, err := hostfloor.OpenProtected(path) if err != nil { log.Fatal(err) } b, err := os.ReadFile(candidate.Name()) candidate.Close() if err != nil || hostfloor.CheckImage(d.PublicKey, f, identity, version.Build, platform, hostfloor.Digest(b)) != nil { log.Fatal(hostfloor.ErrFloor) } } // Enrollment input is reporting configuration, verified against the signed // directory before root persists it. It cannot grant roster membership. enrollment, err := hostfloor.ReadEnrollment(*enrollmentPath) if err != nil { log.Fatal(err) } store, pub, err := openSystemStore(context.Background()) if err != nil { log.Fatal(err) } roster, err := readGuardRoster(context.Background(), store, pub, d.Bucket) if err != nil { log.Fatal(err) } b, ok := roster.Boxes[enrollment.Box] if ok { if b.Enrollment != enrollment.Enrollment || b.Cloud.InstanceID != enrollment.InstanceID { log.Fatal(hostfloor.ErrFloor) } } else { // A post-floor join must arm and report before it becomes an actor. // The invitation grants a reporting nonce, never execution authority. wire, _, e := store.Get(context.Background(), "control/host-actor-invitations/"+enrollment.Box+".json") if e != nil { log.Fatal(e) } var invitation hostactors.Invitation if release.VerifyRootEnvelope(pub, wire, &invitation) != nil || invitation.Bucket != d.Bucket || invitation.Reference != enrollment.Box || invitation.Enrollment != enrollment.Enrollment { log.Fatal(hostfloor.ErrFloor) } } if err = guardArmControllers(dataDir()); err != nil { log.Fatal(err) } // The guard directories are traversed by non-root units (the console // and the edge exec the guard binary through them), so their mode // cannot depend on the caller's umask: a umask 077 shell once left // them 0700 and the guarded console failed with exit 203. for _, dir := range []string{d.Dir, filepath.Join(d.Dir, "identities")} { if err = mkdirAllExact(dir, 0755); err != nil { log.Fatal(err) } } path := filepath.Join(d.Dir, "policy.json") if _, err = os.Stat(path); os.IsNotExist(err) { initial, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: d.Bucket, Mode: "unpublished"}) if err = hostfloor.Durable(path, initial, 0644); err != nil { log.Fatal(err) } } // A migrated box retains its recovery checker until the root floor and // selector are durable. No-census arming keeps its original ordering. if id, required, e := rootMovesFloorIdentity(); e != nil { log.Fatal(e) } else if required { projection, e := readRootFloorProjection(release.OptDir()) if e != nil || projection.Identity != id { log.Fatal(errRootFloorHistory) } signed, e := release.VerifySystemIdentity(d.PublicKey, identity) if e != nil { log.Fatal(e) } image := rootFloorBinary{Version: signed.Current.ReleaseTag, Protocol: signed.Current.MovesProtocol, Digest: hash} if !image.supports(projection.Floor) { log.Fatal(errRootFloorHistory) } } // The checker is outside system/router and outside every restore set. if err = copyFileDurable("/proc/self/exe", filepath.Join(d.Dir, "carlos"), 0755); err != nil { log.Fatal(err) } snapshot, err := release.VerifySystemIdentity(d.PublicKey, identity) if err != nil { log.Fatal(err) } if err = hostfloor.Durable(filepath.Join(d.Dir, "identities", snapshot.Current.ReleaseTag+".json"), identity, 0644); err != nil { log.Fatal(err) } enrollmentRaw, _ := json.Marshal(enrollment) if err = hostfloor.Durable("/etc/carlos/host-actor.json", enrollmentRaw, 0644); err != nil { log.Fatal(err) } dropins := map[string]string{ "carlos-system-update.service": "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec apply\n", "carlos-system-recover.service": "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec recover\n", "carlos-edge.service": "[Unit]\nRequires=carlos-system-recover.service\nAfter=carlos-system-recover.service\n[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec edge\n", } if *consolePath != "" { if !filepath.IsAbs(*consolePath) || filepath.Clean(*consolePath) != *consolePath { log.Fatal(hostfloor.ErrFloor) } for _, path := range []string{*consolePath, *consolePath + ".prev"} { file, e := hostfloor.OpenProtected(path) if e != nil { log.Fatal(e) } body, e := os.ReadFile(file.Name()) file.Close() if e != nil || hostfloor.CheckComponent(d.PublicKey, f, identity, version.Build, platform, hostfloor.Digest(body), "console") != nil { log.Fatal(hostfloor.ErrFloor) } } dropins["console.service"] = hostfloor.ConsoleGuardDropIn(*consolePath) } for unit, body := range dropins { dir := filepath.Join("/etc/systemd/system", unit+".d") if err = os.MkdirAll(dir, 0755); err != nil { log.Fatal(err) } if err = hostfloor.Durable(filepath.Join(dir, "90-host-floor.conf"), []byte(body), 0644); err != nil { log.Fatal(err) } } if _, err = productionSysOps().systemctl("daemon-reload"); err != nil { log.Fatal(err) } if *consolePath != "" { if err = hostfloor.Durable(filepath.Join(d.Dir, "console-ready.json"), []byte(*consolePath), 0644); err != nil { log.Fatal(err) } } if err = d.Persist(p, map[string][]byte{snapshot.Current.ReleaseTag: identity}); err != nil { log.Fatal(err) } } // guardArmInputs is guard-arm's parse of its --policy and --identity files, // byte for byte as read: an armed policy for this deployment, and an identity // that names the running build (build, platform and the executing image's // hash) at or above the policy's floor. The claims phases sign-policy and // publish-identity write exactly these files (--out). func guardArmInputs(policy, identity []byte, pub ed25519.PublicKey, bucket, build, platform, hash string) (hostfloor.Policy, hostfloor.Verified, error) { var p hostfloor.Policy if json.Unmarshal(policy, &p) != nil || p.Mode != "armed" { return hostfloor.Policy{}, hostfloor.Verified{}, hostfloor.ErrFloor } f, err := p.Verify(pub, bucket) if err != nil { return hostfloor.Policy{}, hostfloor.Verified{}, err } if err = hostfloor.CheckImage(pub, f, identity, build, platform, hash); err != nil { return hostfloor.Policy{}, hostfloor.Verified{}, err } return p, f, nil } // guardArmIdle refuses to arm while root's update state is on the box: an // open journal, or any update-begun.json* in the guard directory. Main's // region marker, its temporary file or a stale copy beside an armed policy // would wedge every guarded update and recovery (Opus re-review M1-3). // guard-arm calls it holding the apply lock, so no update writes either // while it looks. func guardArmIdle(guardDir, rootDir string) error { journal := filepath.Join(rootDir, "journal.json") if _, err := os.Lstat(journal); err == nil { return fmt.Errorf("%w: %s is open; let the update finish or recover first", hostfloor.ErrFloor, journal) } else if !errors.Is(err, os.ErrNotExist) { return err } entries, err := os.ReadDir(guardDir) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } for _, e := range entries { if strings.HasPrefix(e.Name(), updateBegunFile) { return fmt.Errorf("%w: %s is present; let the update finish or recover first", hostfloor.ErrFloor, filepath.Join(guardDir, e.Name())) } } return nil } // lookupServiceUser resolves the controllers' account; a test seam. var lookupServiceUser = user.Lookup // guardArmControllers creates /.hostexec for the service account // before any armed state is written. The armed update path refuses while it // is missing (Opus re-review M1-2). func guardArmControllers(dataDir string) error { u, err := lookupServiceUser(serviceUser) if err != nil { return err } uid, err := strconv.Atoi(u.Uid) if err != nil { return fmt.Errorf("%w: %s has uid %q", hostfloor.ErrFloor, serviceUser, u.Uid) } gid, err := strconv.Atoi(u.Gid) if err != nil { return fmt.Errorf("%w: %s has gid %q", hostfloor.ErrFloor, serviceUser, u.Gid) } return hostfloor.EnsureControllerDir(dataDir, uid, gid) } // mkdirAllExact is os.MkdirAll with the caller's umask taken out: every // directory it creates, and dir itself whether or not it was created, ends // with exactly mode. Existing parents keep their modes. func mkdirAllExact(dir string, mode os.FileMode) error { var created []string for p := filepath.Clean(dir); ; p = filepath.Dir(p) { if _, err := os.Lstat(p); err == nil { break } else if !os.IsNotExist(err) { return err } created = append(created, p) if parent := filepath.Dir(p); parent == p { break } } if err := os.MkdirAll(dir, mode); err != nil { return err } for _, p := range append(created, filepath.Clean(dir)) { if err := os.Chmod(p, mode); err != nil { return err } } return nil } // prepareGuardArmLockDir makes guard-arm's lock directory under // /system without the caller's umask, and repairs /system itself // to 0755: the guard directories sit under the same parent and non-root // units exec the guard binary through it, so a umask 077 run must not // leave it closed. func prepareGuardArmLockDir(optDir, lockDir string) error { if err := mkdirAllExact(filepath.Join(optDir, "system"), 0755); err != nil { return err } return mkdirAllExact(lockDir, 0755) }