//go:build linux package host import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "log" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/safefile" "golang.org/x/sys/unix" ) var claimQuiesceOwner = claimStateOwner // claimEdgeMaskOwner records the identity of the runtime-mask object that the // migration put in place. It lives in root's own journal directory, which the // agent cannot write. var claimEdgeMaskOwner = "/var/lib/carlos-claims/edge-mask" var claimEdgeRuntimeMask = "/run/systemd/system/carlos-edge.service" type claimEdgeMaskRecord struct { Version int `json:"v"` Boot string `json:"boot"` Token string `json:"token"` Device uint64 `json:"device"` Inode uint64 `json:"inode"` } var claimSystemctl = func(ctx context.Context, args ...string) ([]byte, error) { return exec.CommandContext(ctx, "systemctl", args...).Output() } func claimEdgeMaskAnchor(token string) string { return filepath.Join(filepath.Dir(claimEdgeRuntimeMask), ".carlos-edge.service.mask-"+token) } func currentClaimEdgeMask(path, boot, token string) (claimEdgeMaskRecord, error) { dir, err := safefile.OpenDir(filepath.Dir(path)) if err != nil { return claimEdgeMaskRecord{}, err } defer dir.Close() return currentClaimEdgeMaskAt(dir, filepath.Base(path), boot, token) } func currentClaimEdgeMaskAt(dir *os.File, name, boot, token string) (claimEdgeMaskRecord, error) { var st unix.Stat_t if err := unix.Fstatat(int(dir.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { return claimEdgeMaskRecord{}, err } return claimEdgeMaskRecord{ Version: 1, Boot: boot, Token: token, Device: uint64(st.Dev), Inode: st.Ino, }, nil } func decodeClaimEdgeMaskRecord(raw []byte, boot string) (claimEdgeMaskRecord, bool) { var record claimEdgeMaskRecord if json.Unmarshal(raw, &record) != nil { return claimEdgeMaskRecord{}, false } token, tokenErr := hex.DecodeString(record.Token) if tokenErr != nil || len(token) != 16 || record.Version != 1 || record.Boot != boot || record.Inode == 0 { return claimEdgeMaskRecord{}, false } return record, true } func claimEdgeMaskStillOwned(record claimEdgeMaskRecord) (bool, error) { dir, err := safefile.OpenDir(filepath.Dir(claimEdgeRuntimeMask)) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, err } defer dir.Close() anchor, err := currentClaimEdgeMaskAt(dir, filepath.Base(claimEdgeMaskAnchor(record.Token)), record.Boot, record.Token) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil || anchor != record { return false, err } current, err := currentClaimEdgeMaskAt(dir, filepath.Base(claimEdgeRuntimeMask), record.Boot, record.Token) if errors.Is(err, os.ErrNotExist) { return false, nil } return current == record, err } func writeClaimEdgeMaskRecord(record claimEdgeMaskRecord) error { raw, err := json.Marshal(record) if err != nil { return err } return writeFileDurable(claimEdgeMaskOwner, append(raw, '\n'), 0600) } func discardClaimEdgeMaskRecord(record claimEdgeMaskRecord, recorded bool) error { var anchorErr error if recorded { dir, err := safefile.OpenDir(filepath.Dir(claimEdgeRuntimeMask)) if err == nil { defer dir.Close() name := filepath.Base(claimEdgeMaskAnchor(record.Token)) var current claimEdgeMaskRecord current, err = currentClaimEdgeMaskAt(dir, name, record.Boot, record.Token) if err == nil && current == record { if err = unix.Unlinkat(int(dir.Fd()), name, 0); err == nil { err = dir.Sync() } } } if err != nil && !errors.Is(err, os.ErrNotExist) { anchorErr = err } } return errors.Join(anchorErr, removeClaimEdgeMaskRecord()) } func removeClaimEdgeMaskRecord() error { dir, err := safefile.OpenDir(filepath.Dir(claimEdgeMaskOwner)) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } defer dir.Close() err = unix.Unlinkat(int(dir.Fd()), filepath.Base(claimEdgeMaskOwner), 0) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } return dir.Sync() } func createClaimEdgeMask(ctx context.Context, boot string) (claimEdgeMaskRecord, error) { tokenBytes := make([]byte, 16) if _, err := rand.Read(tokenBytes); err != nil { return claimEdgeMaskRecord{}, err } token := hex.EncodeToString(tokenBytes) dir, err := safefile.OpenDir(filepath.Dir(claimEdgeRuntimeMask)) if err != nil { return claimEdgeMaskRecord{}, err } defer dir.Close() anchor := filepath.Base(claimEdgeMaskAnchor(token)) if err := unix.Symlinkat("/dev/null", int(dir.Fd()), anchor); err != nil { return claimEdgeMaskRecord{}, err } record, err := currentClaimEdgeMaskAt(dir, anchor, boot, token) if err != nil { removeErr := unix.Unlinkat(int(dir.Fd()), anchor, 0) if removeErr == nil { removeErr = dir.Sync() } return claimEdgeMaskRecord{}, errors.Join(err, removeErr) } if err == nil { err = writeClaimEdgeMaskRecord(record) } if err == nil { err = unix.Linkat(int(dir.Fd()), anchor, int(dir.Fd()), filepath.Base(claimEdgeRuntimeMask), 0) } if err == nil { err = dir.Sync() } if err == nil { _, err = claimSystemctl(ctx, "daemon-reload") } if err == nil { var ours bool ours, err = claimEdgeMaskStillOwned(record) if err == nil && !ours { err = errors.New("claim journal migration: runtime mask identity changed") } } if err != nil { // If linking did not put our exact object at the unit name, cleanup // removes only the private anchor and record. An object at the unit // name is preserved. ours, identityErr := claimEdgeMaskStillOwned(record) if identityErr == nil && ours { identityErr = releaseClaimEdgeMask(context.Background(), record) } else { identityErr = errors.Join(identityErr, discardClaimEdgeMaskRecord(record, record.Inode != 0)) } return claimEdgeMaskRecord{}, errors.Join(err, identityErr) } return record, nil } // releaseClaimEdgeMask checks the object again immediately before asking // systemd to remove it. A missing or replaced object makes the ownership // record stale; it never grants authority over the object now at the path. func releaseClaimEdgeMask(ctx context.Context, record claimEdgeMaskRecord) error { ours, err := claimEdgeMaskStillOwned(record) if err != nil { return err } if !ours { return discardClaimEdgeMaskRecord(record, true) } // systemd has no compare-and-unlink operation. This check protects every // replacement visible before the unmask call; a root operator concurrently // replacing the name during this call is outside the migration protocol. if _, err = claimSystemctl(ctx, "unmask", "--runtime", "carlos-edge.service"); err != nil { return err } return discardClaimEdgeMaskRecord(record, true) } func claimEdgeStopped(ctx context.Context) error { state, err := claimSystemctl(ctx, "show", "-p", "ActiveState", "--value", "carlos-edge.service") if err != nil || (strings.TrimSpace(string(state)) != "inactive" && strings.TrimSpace(string(state)) != "failed") { return errors.Join(err, hostbox.ErrTypedJournal) } return nil } // Migration is an offline upgrade. A running edge refuses before any mask is // written; otherwise runtime-mask the stopped edge, check again, and retain // the mask through the import. A live controller or same-UID child refuses // migration; the operator stops them before retrying. func quiesceLegacyClaimJournals(ctx context.Context) (func(), error) { uid, _, err := claimQuiesceOwner() if err != nil { return nil, err } if err = claimEdgeStopped(ctx); err != nil { return nil, err } boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") if err != nil { return nil, err } record, err := safefile.ReadFile(claimEdgeMaskOwner) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } before, err := claimSystemctl(ctx, "is-enabled", "carlos-edge.service") state := strings.TrimSpace(string(before)) masked := state == "masked" || state == "masked-runtime" if err != nil && !masked { return nil, err } // Only the identity of the concrete runtime-mask object grants authority // to lift it. A same-boot intent, malformed record, or replaced object is // ambiguous and therefore leaves the mask in place. owned, recorded := decodeClaimEdgeMaskRecord(record, strings.TrimSpace(string(boot))) ours := false if state == "masked-runtime" && recorded { ours, err = claimEdgeMaskStillOwned(owned) if err != nil { return nil, err } } if !ours && record != nil { if err = discardClaimEdgeMaskRecord(owned, recorded); err != nil { return nil, err } } if !masked { owned, err = createClaimEdgeMask(ctx, strings.TrimSpace(string(boot))) if err != nil { return nil, err } ours = true } release := func() { if !ours { return } if err := releaseClaimEdgeMask(context.Background(), owned); err != nil { log.Printf("claim journal migration: edge runtime mask remains: %v", err) } } // The edge may have started between the first check and the mask. if err = claimEdgeStopped(ctx); err != nil { release() return nil, err } if err = scanQuiesceProcs("/proc", uid, os.Getpid()); err != nil { release() return nil, err } return release, nil } // scanQuiesceProcs refuses while any process runs as the service uid, except // this migration's own direct children: journal-migrate holds the registry // through a child it runs as the service user (openServiceRegistry), and // counting that child would make the migration refuse itself every time. The // refusal names the process it found. func scanQuiesceProcs(procDir string, uid, self int) error { entries, err := os.ReadDir(procDir) if err != nil { return err } for _, entry := range entries { pid, err := strconv.Atoi(entry.Name()) if err != nil { continue } var st unix.Stat_t if err = unix.Stat(filepath.Join(procDir, entry.Name()), &st); errors.Is(err, unix.ENOENT) { continue } if err != nil { return errors.Join(err, hostbox.ErrTypedJournal) } if int(st.Uid) != uid { continue } ppid, perr := procParent(filepath.Join(procDir, entry.Name(), "stat")) if errors.Is(perr, os.ErrNotExist) { continue } if perr == nil && ppid == self { continue } return errors.Join(errors.New("service-user process "+strconv.Itoa(pid)+" is still running"), perr, hostbox.ErrTypedJournal) } return nil } // procParent reads the parent pid from a /proc//stat file. The command // name is parenthesised and may contain spaces, so the fields after the last // ')' are read: state, then ppid. func procParent(statPath string) (int, error) { raw, err := os.ReadFile(statPath) if err != nil { return 0, err } ppid, err := procStatParent(raw) if err != nil { return 0, fmt.Errorf("%s: %w", statPath, err) } return ppid, nil } // procStatParent parses the ppid from a /proc stat line. The quiesce scan // and the generation census share it, so their own-child rule is one rule. func procStatParent(raw []byte) (int, error) { s := string(raw) i := strings.LastIndexByte(s, ')') if i < 0 { return 0, errors.New("malformed stat") } fields := strings.Fields(s[i+1:]) if len(fields) < 2 { return 0, errors.New("malformed stat") } return strconv.Atoi(fields[1]) } func recoverLegacyClaimEdgeMask(ctx context.Context) error { record, err := safefile.ReadFile(claimEdgeMaskOwner) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") if err != nil { return err } ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() before, err := claimSystemctl(ctx, "is-enabled", "carlos-edge.service") state := strings.TrimSpace(string(before)) if err != nil && state != "masked" && state != "masked-runtime" { return err } owned, recorded := decodeClaimEdgeMaskRecord(record, strings.TrimSpace(string(boot))) if state == "masked-runtime" && recorded { return releaseClaimEdgeMask(ctx, owned) } return discardClaimEdgeMaskRecord(owned, recorded) }