//go:build linux package hostexec import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "syscall" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/hostguard" "golang.org/x/sys/unix" ) func controllerProcessStart(pid int) (string, error) { if pid <= 0 { return "", ErrState } raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) if err != nil { return "", err } end := strings.LastIndexByte(string(raw), ')') if end < 0 { return "", ErrState } fields := strings.Fields(string(raw[end+1:])) if len(fields) < 20 { return "", ErrState } start := fields[19] if _, err = strconv.ParseUint(start, 10, 64); err != nil { return "", ErrState } return start, nil } func currentControllerProcess() (ControllerProcess, error) { boot, err := BootID() if err != nil { return ControllerProcess{}, err } start, err := controllerProcessStart(os.Getpid()) if err != nil { return ControllerProcess{}, err } return ControllerProcess{KernelBootID: boot, PID: os.Getpid(), Start: start}, nil } func proveControllerDead(p ControllerProcess, boot string, probe func(int) (string, error)) error { if !validID(p.KernelBootID) || p.PID <= 0 || p.Start == "" || !validID(boot) { return ErrState } if n, err := strconv.ParseUint(p.Start, 10, 64); err != nil || n == 0 || strconv.FormatUint(n, 10) != p.Start { return ErrState } if p.KernelBootID != boot { return nil } start, err := probe(p.PID) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if start == "" || start == p.Start { return ErrControllerBusy } return nil } // RecoverPending is root's offline update recovery. It never clears active // or lost state and never removes remote participation or claim authority. func RecoverPending(ctx context.Context, dataDir, guardDir string) error { if os.Geteuid() != 0 { return os.ErrPermission } return recoverPending(ctx, hostguard.Guard{DataDir: dataDir}, "/run/carlos/hostexec", filepath.Join(guardDir, "settled-controllers"), BootID, controllerProcessStart) } func recoverPending(ctx context.Context, g hostguard.Guard, lockDir, archive string, boot func() (string, error), probe func(int) (string, error)) error { d, err := openLocal(g.DataDir) if err != nil { return err } defer d.close() fd, err := unix.Dup(d.fd) if err != nil { return err } f := os.NewFile(uintptr(fd), "controllers") entries, err := f.ReadDir(-1) _ = f.Close() if err != nil { return err } if len(entries) == 0 { return nil } kernelBoot, err := boot() if err != nil { return err } for _, e := range entries { if err = ctx.Err(); err != nil { return err } if !strings.HasSuffix(e.Name(), ".json") { return ErrState } host := strings.TrimSuffix(e.Name(), ".json") err = g.WithLocal(ctx, host, func() error { unlock, err := tryExistingPendingLock(lockDir, host, ".lock") if err != nil { return err } defer unlock() unlive, err := tryExistingPendingLock(lockDir, host, ".live") if err != nil { return err } defer unlive() old, err := d.read(host) if err != nil { return err } if old.State != "pending" { return ErrState } if err = proveControllerDead(old.Process, kernelBoot, probe); err != nil { return err } // Keep the complete original tuple durably before removing its denial. // A crash after this write replays the same content-addressed evidence. raw, err := json.Marshal(old) if err != nil { return err } sum := sha256.Sum256(raw) if err = os.Mkdir(archive, 0700); err != nil && !errors.Is(err, os.ErrExist) { return err } info, err := os.Lstat(archive) if err != nil { return err } stat, ok := info.Sys().(*syscall.Stat_t) if !ok || stat.Uid != uint32(os.Geteuid()) || !info.IsDir() || info.Mode().Perm() != 0700 { return ErrState } parent, err := os.Open(filepath.Dir(archive)) if err != nil { return err } syncErr := parent.Sync() closeErr := parent.Close() if err = errors.Join(syncErr, closeErr); err != nil { return err } if err = hostfloor.Durable(filepath.Join(archive, hex.EncodeToString(sum[:])+".json"), raw, 0600); err != nil { return err } current, err := d.read(host) if err != nil { return err } currentRaw, err := json.Marshal(current) if err != nil { return err } if string(currentRaw) != string(raw) { return ErrState } return d.remove(host) }) if err != nil { return err } } return nil } // The enclosing host guard prevents a cooperating Open from creating these // names until recovery finishes. Root must never create agent lock files. func tryExistingPendingLock(dir, host, suffix string) (func(), error) { if _, err := Key(host, "check"); err != nil { return nil, err } fd, err := openDir(dir) if errors.Is(err, os.ErrNotExist) { return func() {}, nil } if err != nil { return nil, err } defer unix.Close(fd) lock, err := unix.Openat(fd, host+suffix, unix.O_RDWR|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) if errors.Is(err, os.ErrNotExist) { return func() {}, nil } if err != nil { return nil, err } fail := func(e error) (func(), error) { unix.Close(lock); return nil, e } var st unix.Stat_t if err = unix.Fstat(lock, &st); err != nil { return fail(err) } if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 { return fail(ErrState) } if err = unix.Flock(lock, unix.LOCK_EX|unix.LOCK_NB); err != nil { if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { return fail(ErrControllerBusy) } return fail(err) } return func() { _ = unix.Flock(lock, unix.LOCK_UN); _ = unix.Close(lock) }, nil }