//go:build linux package host import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strconv" "strings" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" "golang.org/x/sys/unix" ) // This reader can prove absence only with launch exclusion held by its caller. // Uncontained service processes and foreign namespaces remain unavailable until // their reference-creation boundary can be established independently. type generationKernel struct { proc, cgroups, boundApp string owner uint32 contained []release.GenerationReservation service func(string, []byte) (bool, error) // parent, when set, is the pid of an offline root command (journal-migrate) // whose own direct children pass the root-task rule. They still share // its namespaces and pass every reference check below. parent int // recheck holds a task service trusted to the image it was trusted for, // at each use of that trust and, final, at the end of its inspection // (generationServiceEnv.recheck). A trusted task needs it. recheck func(path string, final bool) error } func generationRead(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, rootstate.MaxRecord+1)) if err != nil { return nil, err } if len(raw) > rootstate.MaxRecord { return nil, os.ErrPermission } return raw, nil } func generationStart(raw []byte) (string, error) { end := strings.LastIndexByte(string(raw), ')') if end < 0 { return "", os.ErrPermission } fields := strings.Fields(string(raw[end+1:])) if len(fields) < 20 { return "", os.ErrPermission } _, err := strconv.ParseUint(fields[19], 10, 64) if err != nil { return "", os.ErrPermission } return fields[19], nil } func (k generationKernel) boot() (string, error) { raw, err := generationRead(filepath.Join(k.proc, "sys/kernel/random/boot_id")) if err != nil { return "", err } boot := strings.TrimSpace(string(raw)) if boot == "" { return "", os.ErrPermission } return boot, nil } func (k generationKernel) controllerGone(ctx context.Context, b release.GenerationBinding) error { if err := ctx.Err(); err != nil { return err } boot, err := k.boot() if err != nil { return err } if b.ControllerPID <= 0 || b.ControllerStart == "" || b.Target.BootID == "" { return os.ErrPermission } if boot != b.Target.BootID { return nil } raw, err := generationRead(filepath.Join(k.proc, strconv.Itoa(b.ControllerPID), "stat")) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } start, err := generationStart(raw) if err != nil { return err } if start == b.ControllerStart { return os.ErrPermission } return nil } func generationNames(path string) ([]string, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() names, err := f.Readdirnames(8193) if err != nil && err != io.EOF { return nil, err } if len(names) > 8192 { return nil, os.ErrPermission } return names, nil } func generationPathRef(path string, b release.GenerationBinding) bool { path = strings.TrimSuffix(path, " (deleted)") return path == b.Path || path == b.Tree || strings.HasPrefix(path, b.Tree+"/") } func generationIDRef(id rootstate.FileIdentity, b release.GenerationBinding) bool { return id == b.Executable || id == b.TreeIdentity } func generationLinkRef(path string, b release.GenerationBinding, refs ...map[rootstate.FileIdentity]bool) error { name, err := os.Readlink(path) if err != nil { return err } // A retained namespace descriptor can keep mounts alive after its last task. if strings.HasPrefix(name, "mnt:[") || strings.HasPrefix(name, "pid:[") || generationPathRef(name, b) { return os.ErrPermission } var st unix.Stat_t if err := unix.Stat(path, &st); err != nil { return err } id := rootstate.FileIdentity{Device: uint64(st.Dev), Inode: st.Ino} for _, set := range refs { if set[id] { return os.ErrPermission } } if generationIDRef(id, b) { return os.ErrPermission } return nil } func generationMaps(raw []byte, b release.GenerationBinding, refs ...map[rootstate.FileIdentity]bool) error { for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { if line == "" { continue } fields := strings.Fields(line) if len(fields) < 5 { return os.ErrPermission } major, minor, ok := strings.Cut(fields[3], ":") if !ok { return os.ErrPermission } maj, e1 := strconv.ParseUint(major, 16, 32) min, e2 := strconv.ParseUint(minor, 16, 32) ino, e3 := strconv.ParseUint(fields[4], 10, 64) if e1 != nil || e2 != nil || e3 != nil { return os.ErrPermission } id := rootstate.FileIdentity{Device: unix.Mkdev(uint32(maj), uint32(min)), Inode: ino} for _, set := range refs { if ino != 0 && set[id] { return os.ErrPermission } } if ino != 0 && generationIDRef(rootstate.FileIdentity{Device: unix.Mkdev(uint32(maj), uint32(min)), Inode: ino}, b) { return os.ErrPermission } if len(fields) > 5 && generationPathRef(strings.Join(fields[5:], " "), b) { return os.ErrPermission } } return nil } func generationUnescape(s string) (string, error) { var out strings.Builder for i := 0; i < len(s); i++ { if s[i] != '\\' { out.WriteByte(s[i]) continue } if i+3 >= len(s) { return "", os.ErrPermission } switch s[i : i+4] { case "\\040": out.WriteByte(' ') case "\\011": out.WriteByte('\t') case "\\012": out.WriteByte('\n') case "\\134": out.WriteByte('\\') default: return "", os.ErrPermission } i += 3 } return out.String(), nil } type generationMount struct { id, root, point string device uint64 } func generationMounts(raw []byte, b release.GenerationBinding, trusted ...bool) error { var mounts []generationMount for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { f := strings.Fields(line) if len(f) < 10 { return os.ErrPermission } separator := -1 for i := 6; i < len(f); i++ { if f[i] == "-" { separator = i break } } if separator < 6 || len(f) != separator+4 { return os.ErrPermission } // A bind-mounted namespace can retain staged mounts after its last task // and descriptor disappear. Its nsfs device does not identify that tree. if f[separator+1] == "nsfs" { return os.ErrPermission } major, minor, ok := strings.Cut(f[2], ":") if !ok { return os.ErrPermission } maj, e1 := strconv.ParseUint(major, 10, 32) min, e2 := strconv.ParseUint(minor, 10, 32) root, e3 := generationUnescape(f[3]) point, e4 := generationUnescape(f[4]) if e1 != nil || e2 != nil || e3 != nil || e4 != nil || !filepath.IsAbs(root) || !filepath.IsAbs(point) { return os.ErrPermission } if generationPathRef(root, b) || generationPathRef(point, b) { return os.ErrPermission } mounts = append(mounts, generationMount{f[0], root, point, unix.Mkdev(uint32(maj), uint32(min))}) } // Translate the canonical tree into the containing filesystem's namespace. // Every additional mount of that tree, its contents or an ancestor keeps it. var base generationMount for _, m := range mounts { rel, err := filepath.Rel(m.point, b.Tree) if err == nil && filepath.IsLocal(rel) && len(m.point) > len(base.point) { base = m } } if base.id == "" || base.device != b.TreeIdentity.Device { return os.ErrPermission } rel, err := filepath.Rel(base.point, b.Tree) if err != nil { return err } tree := filepath.Join(base.root, rel) for _, m := range mounts { if m.id == base.id || m.device != base.device { continue } below, e1 := filepath.Rel(tree, m.root) above, e2 := filepath.Rel(m.root, tree) if (e1 == nil && filepath.IsLocal(below)) || (e2 == nil && filepath.IsLocal(above) && !(len(trusted) != 0 && trusted[0])) { return os.ErrPermission } } return nil } func generationRootTask(raw []byte) (bool, error) { uid, kernel := false, false for _, line := range strings.Split(string(raw), "\n") { f := strings.Fields(line) if len(f) == 0 { continue } switch f[0] { case "Uid:": if uid || len(f) != 5 { return false, os.ErrPermission } uid = true for _, id := range f[1:] { if id != "0" { return false, os.ErrPermission } } case "Kthread:": if len(f) != 2 || (f[1] != "0" && f[1] != "1") { return false, os.ErrPermission } kernel = f[1] == "1" } } if !uid { return false, os.ErrPermission } return kernel, nil } func generationTaskFDs(ctx context.Context, path string, b release.GenerationBinding, refs map[rootstate.FileIdentity]bool) error { f, err := os.Open(path) if err != nil { return err } // Our own descriptor appears when scanning this process. Keep it open // until inspection finishes, so it cannot manufacture a missing entry. defer f.Close() names, err := f.Readdirnames(8193) if err != nil && err != io.EOF { return err } if len(names) > 8192 { return os.ErrPermission } for _, name := range names { if err := ctx.Err(); err != nil { return err } if err := generationLinkRef(filepath.Join(path, name), b, refs); err != nil { return fmt.Errorf("%s: %w", name, err) } } return nil } func (k generationKernel) task(ctx context.Context, path string, b release.GenerationBinding, ns map[string]string, refs map[rootstate.FileIdentity]bool) (string, error) { if err := ctx.Err(); err != nil { return "", err } raw, err := generationRead(path + "/stat") if err != nil { return "", err } start, err := generationStart(raw) if err != nil { return "", err } status, err := generationRead(path + "/status") if err != nil { return "", err } if len(k.contained) != 0 { ok, err := k.containedTask(ctx, path, status) if err != nil { return "", err } if ok { again, err := generationRead(path + "/stat") if err != nil { return "", err } end, err := generationStart(again) if err != nil { return "", os.ErrPermission } if end != start { return "", errGenerationTaskReplaced } return start, nil } } kernel, rootErr := generationRootTask(status) if rootErr != nil && !kernel && k.parent != 0 { if ppid, err := procStatParent(raw); err == nil && ppid == k.parent { rootErr = nil } } trusted := false if k.service != nil && !kernel { trusted, err = k.service(path, status) if err != nil { trusted = false if rootErr != nil { return "", fmt.Errorf("service: %w", err) } } } if rootErr != nil && !trusted { return "", rootErr } bound := func(final bool) error { if !trusted { return nil } if k.recheck == nil { return fmt.Errorf("service: no executable binding: %w", os.ErrPermission) } if err := k.recheck(path, final); err != nil { return fmt.Errorf("service: %w", err) } return nil } if trusted && k.recheck != nil { defer k.recheck(path, true) } if err := bound(false); err != nil { return "", err } if !kernel { for name, want := range ns { got, err := os.Readlink(path + "/ns/" + name) if err != nil { return "", err } if got != want && !trusted { return "", os.ErrPermission } } for _, name := range []string{"exe", "cwd", "root"} { if err := generationLinkRef(path+"/"+name, b, refs); err != nil { return "", fmt.Errorf("%s: %w", name, err) } } if err := generationTaskFDs(ctx, path+"/fd", b, refs); err != nil { return "", fmt.Errorf("fd: %w", err) } maps, err := generationRead(path + "/maps") if err != nil { return "", err } if err := generationMaps(maps, b, refs); err != nil { return "", err } mounts, err := generationRead(path + "/mountinfo") if err != nil { return "", err } if err := bound(false); err != nil { return "", err } if err := generationMounts(mounts, b, trusted); err != nil { return "", fmt.Errorf("mounts: %w", err) } } raw, err = generationRead(path + "/stat") if err != nil { return "", err } again, err := generationStart(raw) if err != nil { return "", os.ErrPermission } if again != start { return "", errGenerationTaskReplaced } if err := bound(true); err != nil { return "", err } return start, nil } func (k generationKernel) absent(ctx context.Context, r release.GenerationReservation) (string, error) { if err := ctx.Err(); err != nil { return "", err } boot, err := k.boot() if err != nil { return "", err } ns := map[string]string{} for _, name := range []string{"mnt", "pid"} { ns[name], err = os.Readlink(filepath.Join(k.proc, "self/ns", name)) if err != nil || ns[name] == "" { return "", os.ErrPermission } } if r.Binding.TreeProof.Boot != "" { current, err := generationCurrentFiles(r.Binding, rootstate.OpenTrustedFile) if err != nil { return "", err } r.Binding = current } refs, err := generationTree(ctx, r.Binding) if err != nil { return "", err } names, err := generationNames(k.proc) if err != nil { return "", err } observations := map[string]string{} count := 0 for _, name := range names { pid, err := strconv.ParseUint(name, 10, 32) if err != nil { continue } if pid == 0 { return "", os.ErrPermission } tasks, err := generationNames(filepath.Join(k.proc, name, "task")) if err != nil { return "", &generationTaskRefusal{task: filepath.Join(k.proc, name), err: err} } if len(tasks) == 0 { return "", os.ErrPermission } for _, task := range tasks { if id, err := strconv.ParseUint(task, 10, 32); err != nil || id == 0 { return "", os.ErrPermission } count++ if count > 8192 { return "", os.ErrPermission } start, err := k.task(ctx, filepath.Join(k.proc, name, "task", task), r.Binding, ns, refs) if err != nil { return "", &generationTaskRefusal{task: filepath.Join(k.proc, name, "task", task), err: err} } observations[name+"/"+task] = start } } if count == 0 { return "", os.ErrPermission } again, err := k.boot() if err != nil || boot != again { return "", os.ErrPermission } raw, err := json.Marshal([]any{1, r, boot, ns, observations}) if err != nil { return "", err } sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:]), nil } func productionGenerationAbsence(ctx context.Context, r release.GenerationReservation) (string, error) { if os.Geteuid() != 0 { return "", os.ErrPermission } if err := verifyStopKernelMounts("/sys/fs/cgroup", "/proc"); err != nil { return "", err } if r.Binding.Path != release.VersionPath(release.OptDir(), r.Binding.Binding.Account, r.Binding.Version, r.Binding.Binding.App) { return "", os.ErrPermission } current, err := generationRecordedAbsence(ctx, r) if err != nil { return "", err } service, recheck := generationServiceReader(ctx) return (generationKernel{proc: "/proc", service: service, recheck: recheck}).absent(ctx, current) } func generationRecordedAbsence(ctx context.Context, r release.GenerationReservation) (release.GenerationReservation, error) { current, err := generationRecordedTargets(ctx, r) if err != nil { return r, err } return current, (generationKernel{proc: "/proc"}).controllerGone(ctx, current.Binding) } func generationRecordedTargets(ctx context.Context, r release.GenerationReservation) (release.GenerationReservation, error) { current, err := generationCurrentFiles(r.Binding, rootstate.OpenTrustedFile) if err != nil { return r, err } r.Binding = current if err := generationGroupGone(ctx, "/sys/fs/cgroup", r); err != nil { return r, err } k := generationKernel{proc: "/proc"} // Missing cgroups alone never discharge a reservation. With its launch // authority closed, both recorded process identities must also be gone. for _, p := range []struct { pid int start string }{{r.Process.PID, r.Process.Start}, {r.Process.TenantPID, r.Process.TenantStart}} { if p.pid == 0 { continue } b := r.Binding b.ControllerPID, b.ControllerStart = p.pid, p.start if err := k.controllerGone(ctx, b); err != nil { return r, err } } return r, nil } func generationGroupGone(ctx context.Context, root string, r release.GenerationReservation) error { group := generationBindingGroup(r.Binding) path := filepath.Join(root, group) if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { return nil } else if err != nil { return err } if r.Process == (release.GenerationProcess{}) || r.Process.Group != group { return os.ErrPermission } boot, err := (generationKernel{proc: "/proc"}).boot() if err != nil { return err } if r.Binding.Target.BootID == "" { return os.ErrPermission } records, _ := ctx.Value(generationCensusReservationsKey{}).([]release.GenerationReservation) k := generationKernel{proc: "/proc", cgroups: root, contained: records} if k.replacedGroup(ctx, r) == nil { return nil } if r.Binding.Target.BootID != boot { _, err := scanRebootCgroups(ctx, root, []hostbox.StopCgroupIdentity{{RelativePath: group, Device: r.Process.GroupIdentity.Device, Inode: r.Process.GroupIdentity.Inode}}, r.Binding.Target.BootID, boot) return err } _, err = scanStopCgroups(ctx, root, []hostbox.StopCgroupIdentity{{RelativePath: group, Device: r.Process.GroupIdentity.Device, Inode: r.Process.GroupIdentity.Inode}}) return err } func generationCurrentFiles(b release.GenerationBinding, open func(string) (*os.File, error)) (release.GenerationBinding, error) { f, err := open(b.Path) if err != nil { return b, err } defer f.Close() d, err := os.Open(b.Tree) if err != nil { return b, err } defer d.Close() for _, item := range []struct { file *os.File path string identity *rootstate.FileIdentity proof rootstate.FileIdentity }{ {f, b.Path, &b.Executable, b.ExecutableProof}, {d, b.Tree, &b.TreeIdentity, b.TreeProof}, } { current, err := rootstate.Identity(item.file) if err != nil { return b, err } if item.proof.Boot == "" { if current != *item.identity { return b, os.ErrPermission } { boot, err := (generationKernel{proc: "/proc"}).boot() if err != nil || boot != b.Target.BootID { return b, os.ErrPermission } } } else { observed, err := rootstate.PersistentIdentity(item.file) if err != nil || !generationFileProof(item.path, item.proof, observed) { return b, os.ErrPermission } } *item.identity = current } return b, nil } func generationTree(ctx context.Context, b release.GenerationBinding) (map[rootstate.FileIdentity]bool, error) { refs := map[rootstate.FileIdentity]bool{b.Executable: true, b.TreeIdentity: true} count := 0 err := filepath.WalkDir(b.Tree, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if err := ctx.Err(); err != nil { return err } count++ if count > 8192 || entry.Type()&os.ModeSymlink != 0 { return os.ErrPermission } var st unix.Stat_t if err := unix.Lstat(path, &st); err != nil { return err } if st.Mode&unix.S_IFMT != unix.S_IFDIR && st.Mode&unix.S_IFMT != unix.S_IFREG { return os.ErrPermission } id := rootstate.FileIdentity{Device: uint64(st.Dev), Inode: st.Ino} if path == b.Tree && id != b.TreeIdentity { return os.ErrPermission } refs[id] = true return nil }) return refs, err } func generationFileProof(path string, before, after rootstate.FileIdentity) bool { return release.GenerationFileProof(path, before, after) }