//go:build linux package host import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net" "os" "os/exec" "path/filepath" "reflect" "strings" "sync" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/safefile" "golang.org/x/sys/unix" ) // Prelaunch prepares the session's empty parent group and journals it with // this controller's PID and process start before restore or materialization. // A failure or crash after it returns leaves the exact record root needs. func (a *ClaimRoleAssembly) Prelaunch(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) (*activator.RoleLaunch, error) { if a == nil || a.Manager == nil || a.Journal == nil || a.Inventory == nil || work == nil || o.Claim == nil || o.Claim.Holder == nil { return nil, hostwrite.ErrUnavailable } target, err := a.launchTarget(ctx, o) if err != nil { return nil, err } // The installation inventory is what root verifies a stop against. Without // it the record could never be settled, so refuse before any effect. inventory, err := a.Inventory.ReadExactStopInventory(ctx, target) if err != nil { return nil, err } if !inventory.Complete || inventory.Host != target.Host || inventory.Incarnation != target.Incarnation || inventory.InstallationStamp != target.Stamp || inventory.Box != target.Box || inventory.Enrollment != target.Enrollment || inventory.DirectoryRevision != target.DirectoryRevision { return nil, hostbox.ErrTypedInventory } start, err := claimConstructionProcessStart() if err != nil { return nil, err } parentName := claimContainmentName(target) var parent *cgroup.Prepared var group hostbox.LaunchGroup err = work.With(ctx, in.Identity, func() error { var e error prepare := a.prepareGroup if prepare == nil { prepare = a.preparePrelaunchGroup } parent, group, e = prepare(parentName, in.Limit) if e != nil { return e } if a.Journal.Remote() { return nil } return a.Journal.Prelaunch(ctx, hostbox.LaunchInventory{ControllerPID: os.Getpid(), ProcessStart: start, Target: target, Groups: []hostbox.LaunchGroup{group}, NoState: true}) }) if err == nil && a.Journal.Remote() { err = a.Journal.Prelaunch(ctx, hostbox.LaunchInventory{ControllerPID: os.Getpid(), ProcessStart: start, Target: target, Groups: []hostbox.LaunchGroup{group}, NoState: true}) if err == nil { err = work.With(ctx, in.Identity, func() error { if parent == nil { return hostbox.ErrChanged } id, e := parent.Identity() if e != nil { return e } if id.Device != group.Device || id.Inode != group.Inode { return hostbox.ErrChanged } return nil }) } } if err != nil { if parent != nil { err = errors.Join(err, parent.Close()) } return nil, err } return &activator.RoleLaunch{Parent: parent, ParentPath: group.RelativePath, CgroupRoot: "/sys/fs/cgroup", Target: target, Work: work, Identity: in.Identity, Journal: a.Journal}, nil } // Launch completes the pre-launch record after restore and materialization. // It reuses that exact parent group; it never creates a second record. func (a *ClaimRoleAssembly) Launch(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs, pre *activator.RoleLaunch) (*activator.RoleLaunch, error) { if a == nil || a.Manager == nil || a.Journal == nil || a.Inventory == nil || work == nil || pre == nil || pre.Parent == nil || pre.Journal != a.Journal || o.Claim == nil || o.Claim.Holder == nil || !filepath.IsAbs(in.DBPath) { return nil, hostwrite.ErrUnavailable } target, err := a.launchTarget(ctx, o) if err != nil { return nil, err } if !hostbox.SameLaunchOwner(pre.Target, target) { return nil, hostbox.ErrChanged } parentID, err := pre.Parent.Identity() if err != nil { return nil, err } recorded, err := a.Journal.Read(ctx, target) if err != nil { return nil, err } if recorded.ControllerPID != os.Getpid() || len(recorded.Groups) == 0 || recorded.Groups[0] != (hostbox.LaunchGroup{RelativePath: pre.ParentPath, Device: parentID.Device, Inode: parentID.Inode}) { return nil, hostbox.ErrChanged } inventory, err := a.Inventory.ReadExactStopInventory(ctx, target) if err != nil { return nil, err } if !inventory.Complete || inventory.Host != target.Host || inventory.Incarnation != target.Incarnation || inventory.InstallationStamp != target.Stamp || inventory.Box != target.Box || inventory.Enrollment != target.Enrollment || inventory.DirectoryRevision != target.DirectoryRevision { return nil, hostbox.ErrTypedInventory } expectedOwner := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", target.Host, target.Incarnation, target.Stamp, target.Box))) owner := hex.EncodeToString(expectedOwner[:]) stateDir := filepath.Clean(filepath.Dir(in.DBPath)) owned := false for _, entry := range inventory.Entries { root, ok := a.ManagedRoots[entry.ManagedRoot] if !ok || !filepath.IsAbs(root) || entry.Kind != "path" || entry.Materialization != "materialized" || entry.Type != unix.S_IFDIR || entry.Ownership != owner { continue } relative := filepath.Clean(entry.RelativePath) if filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { return nil, hostbox.ErrChanged } if filepath.Join(root, relative) != stateDir { continue } var stat unix.Stat_t if err = unix.Lstat(stateDir, &stat); err != nil { return nil, err } if stat.Mode&unix.S_IFMT != unix.S_IFDIR || hostbox.VerifyInventoryIdentity(root, entry) != nil { return nil, hostbox.ErrChanged } owned = true } if !owned { return nil, hostwrite.ErrUnavailable } launch := *pre launch.Unit = in.Unit launch.Spec = &hostbox.LaunchSpec{Identity: in.Identity, Executable: in.Executable, Digest: in.ExecutableSHA256, Args: append([]string(nil), in.Args...), Env: append([]string(nil), in.Env...)} launch.Target, launch.Work, launch.Identity = target, work, in.Identity var mu sync.Mutex initializeFiles := func(ctx context.Context) ([]hostbox.LaunchFile, error) { mu.Lock() defer mu.Unlock() files := []hostbox.LaunchFile{} for _, suffix := range []string{"", "-wal", "-shm"} { path := in.DBPath + suffix f, e := safefile.OpenFile(path, os.O_RDONLY, 0) if errors.Is(e, os.ErrNotExist) && suffix != "" { continue } if errors.Is(e, os.ErrNotExist) && suffix == "" && in.Empty { f, e = safefile.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) if e == nil { e = f.Sync() if e != nil { f.Close() } } } if e != nil { return nil, e } var stat unix.Stat_t e = unix.Fstat(int(f.Fd()), &stat) closeErr := f.Close() if e != nil || closeErr != nil { return nil, errors.Join(e, closeErr) } if stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Nlink != 1 { return nil, hostbox.ErrChanged } files = append(files, hostbox.LaunchFile{Device: uint64(stat.Dev), Inode: stat.Ino}) } if len(files) == 0 { return nil, hostwrite.ErrUnavailable } return files, nil } recordFiles := func(ctx context.Context, files []hostbox.LaunchFile) error { for _, f := range files { if err := a.Journal.RecordFile(ctx, target, f); err != nil { return err } } return nil } if a.Journal.Remote() { launch.InitializeUnlocked = func(ctx context.Context) error { var files []hostbox.LaunchFile if err := work.With(ctx, in.Identity, func() error { var e error; files, e = initializeFiles(ctx); return e }); err != nil { return err } if err := recordFiles(ctx, files); err != nil { return err } return work.With(ctx, in.Identity, func() error { again, err := initializeFiles(ctx) if err != nil { return err } if !reflect.DeepEqual(files, again) { return hostbox.ErrChanged } return nil }) } } else { launch.Initialize = func(ctx context.Context) error { files, err := initializeFiles(ctx) if err != nil { return err } return recordFiles(ctx, files) } } launch.Executable = func(ctx context.Context, cmd *exec.Cmd) (*os.File, error) { return verifiedClaimExecutable(ctx, cmd, in.Executable, in.ExecutableSHA256) } if a.Root != nil { paths, err := claimIsolatedPaths(a.ManagedRoots, target.Host, in) if err != nil { return nil, err } // Root runs the unit on the build behind the host's live link. The // agent pins that resolved file after verifying its digest; root // verifies the same identity again and binds exactly that path. resolved, err := filepath.EvalSymlinks(in.Executable) if err != nil { return nil, err } launch.Root, launch.LaunchDir, launch.RunDir, launch.LogSocket, launch.MemoryMax = a.Root, paths.LaunchDir, paths.RunDir, paths.LogSocket, paths.MemoryMax launch.Executable = func(ctx context.Context, cmd *exec.Cmd) (*os.File, error) { if cmd == nil || cmd.Path != in.Executable { return nil, hostwrite.ErrUnavailable } return verifiedClaimExecutable(ctx, exec.Command(resolved), resolved, in.ExecutableSHA256) } } return &launch, nil } func verifiedClaimExecutable(ctx context.Context, cmd *exec.Cmd, path, digest string) (*os.File, error) { if cmd == nil || cmd.Path != path || !filepath.IsAbs(path) || len(digest) != 64 { return nil, hostwrite.ErrUnavailable } expected, err := hex.DecodeString(digest) if err != nil || len(expected) != sha256.Size { return nil, hostwrite.ErrUnavailable } f, err := safefile.OpenFile(path, os.O_RDONLY, 0) if err != nil { return nil, err } fd := int(f.Fd()) refuse := func(e error) (*os.File, error) { f.Close(); return nil, e } var before unix.Stat_t if err = unix.Fstat(fd, &before); err != nil { return refuse(err) } if before.Uid != 0 || before.Mode&unix.S_IFMT != unix.S_IFREG || before.Nlink != 1 || before.Mode&0111 == 0 || before.Mode&0022 != 0 { return refuse(hostbox.ErrChanged) } hash := sha256.New() buffer := make([]byte, 128<<10) for { if err = ctx.Err(); err != nil { return refuse(err) } n, e := f.Read(buffer) if n > 0 { _, _ = hash.Write(buffer[:n]) } if e == io.EOF { break } if e != nil { return refuse(e) } } var after unix.Stat_t if err = unix.Fstat(fd, &after); err != nil { return refuse(err) } if before.Dev != after.Dev || before.Ino != after.Ino || before.Size != after.Size || before.Mtim != after.Mtim || before.Ctim != after.Ctim || hex.EncodeToString(hash.Sum(nil)) != strings.ToLower(digest) { return refuse(hostbox.ErrChanged) } if _, err = f.Seek(0, 0); err != nil { return refuse(err) } return claimExecutableName(f, path) } func claimExecutableName(f *os.File, path string) (*os.File, error) { namedFD, err := unix.FcntlInt(f.Fd(), unix.F_DUPFD_CLOEXEC, 0) if err != nil { f.Close() return nil, err } if err = f.Close(); err != nil { unix.Close(namedFD) return nil, err } return os.NewFile(uintptr(namedFD), path), nil } func claimReadyPeer(conn net.Conn, pid int) error { unixConn, ok := conn.(*net.UnixConn) if !ok || pid <= 0 { return hostwrite.ErrUnavailable } raw, err := unixConn.SyscallConn() if err != nil { return err } var peer *unix.Ucred var socketErr error if err = raw.Control(func(fd uintptr) { peer, socketErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) }); err != nil { return err } if socketErr != nil { return socketErr } if peer == nil || peer.Pid <= 0 { return hostwrite.ErrUnavailable } if int(peer.Pid) != pid { return hostbox.ErrChanged } return nil } func (a *ClaimRoleAssembly) PrepareState(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) error { if a == nil || a.Inventory == nil || work == nil || o.Claim == nil || o.Claim.Holder == nil || !filepath.IsAbs(in.DBPath) { return hostwrite.ErrUnavailable } actor, err := a.Identity.Verify(ctx) if err != nil { return err } if a.Inventory.Remote() { return a.prepareStateRemote(ctx, o, work, in, actor) } return work.With(ctx, in.Identity, func() error { h := o.Claim.Holder inventory, err := a.Inventory.ReadInstallation(ctx, o.Host, h.Incarnation, h.Stamp) if err != nil { return err } if !inventory.Complete || inventory.Host != o.Host || inventory.Incarnation != h.Incarnation || inventory.InstallationStamp != h.Stamp || inventory.Box != actor.Box || inventory.Enrollment != actor.Enrollment || inventory.DirectoryRevision != actor.Binding { return hostbox.ErrTypedInventory } root := a.ManagedRoots["data"] relative, err := filepath.Rel(root, filepath.Dir(in.DBPath)) if err != nil || !filepath.IsLocal(relative) || relative == "." { return hostbox.ErrChanged } ownerDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", o.Host, h.Incarnation, h.Stamp, actor.Box))) owner := hex.EncodeToString(ownerDigest[:]) index := -1 for i, e := range inventory.Entries { if e.Kind == "path" && e.ManagedRoot == "data" && e.RelativePath == relative { if index != -1 || e.Ownership != owner { return hostbox.ErrTypedInventory } index = i } } fd, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { return err } defer func() { unix.Close(fd) }() components := strings.Split(filepath.ToSlash(relative), "/") for _, part := range components[:len(components)-1] { next, e := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if e != nil { return e } unix.Close(fd) fd = next } leaf := components[len(components)-1] var stat unix.Stat_t err = unix.Fstatat(fd, leaf, &stat, unix.AT_SYMLINK_NOFOLLOW) if err == nil { if index < 0 { return hostbox.ErrTypedInventory } e := inventory.Entries[index] if e.Materialization != "materialized" || stat.Mode&unix.S_IFMT != unix.S_IFDIR || hostbox.VerifyInventoryIdentity(root, e) != nil { return hostbox.ErrTypedInventory } return nil } if !errors.Is(err, os.ErrNotExist) { return err } if index >= 0 && inventory.Entries[index].Materialization == "materialized" { return hostbox.ErrChanged } if index < 0 { sum := sha256.Sum256([]byte(owner + "\x00state-dir\x00" + relative)) inventory.Entries = append(inventory.Entries, hostbox.StopInventoryEntry{Kind: "path", ArtifactID: hex.EncodeToString(sum[:]), ManagedRoot: "data", RelativePath: relative, Ownership: owner, Type: unix.S_IFDIR, Materialization: "intended"}) index = len(inventory.Entries) - 1 if err = a.Inventory.WriteStopInventory(ctx, inventory); err != nil { return err } } if err = unix.Mkdirat(fd, leaf, 0700); err != nil { return err } if err = unix.Fstatat(fd, leaf, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { return err } if err = unix.Fsync(fd); err != nil { return err } entry := &inventory.Entries[index] entry.Identity, err = hostbox.CaptureInventoryIdentity(root, relative) if err != nil { return err } entry.Device = entry.Identity.Device entry.Inode = entry.Identity.Inode entry.Type = unix.S_IFDIR entry.Materialization = "materialized" return a.Inventory.WriteStopInventory(ctx, inventory) }) } func (a *ClaimRoleAssembly) preparePrelaunchGroup(name string, limit cgroup.Limit) (*cgroup.Prepared, hostbox.LaunchGroup, error) { path, err := filepath.Rel("/sys/fs/cgroup", filepath.Join(a.Manager.Root(), cgroup.ExecName(name))) if err != nil || !filepath.IsLocal(path) { return nil, hostbox.LaunchGroup{}, hostwrite.ErrUnavailable } parent, err := a.Manager.PrepareStrict(name, limit) if err != nil { return nil, hostbox.LaunchGroup{}, err } id, err := parent.Identity() if err != nil { parent.Close() return nil, hostbox.LaunchGroup{}, err } return parent, hostbox.LaunchGroup{RelativePath: path, Device: id.Device, Inode: id.Inode}, nil }