//go:build linux package cgroup import ( "bufio" "bytes" "encoding/binary" "errors" "fmt" "io" "math" "os" "os/exec" "os/signal" "runtime" "slices" "strconv" "strings" "syscall" "testing" "time" "golang.org/x/sys/unix" ) // shimAvailable reports that RunShimIfRequested ran in this process, so // /proc/self/exe will serve ShimVerb, or that FakePlacementForTest stands // in for the shim. func shimAvailable() bool { return shimHooked.Load() || shimForcedForTest.Load() } // startViaShim starts cmd so that its process is in dir before any tenant // code runs: the child starts as this binary (shimExe ShimVerb), writes its // own PID into dir's cgroup.procs, checks where it landed, answers placed, // and execs the tenant only once released. // // On a nil error the tenant is running in dir and cmd.Process is it. // refused means before said no: the shim was placed, never released, and // is reaped. Any other error means no tenant code ran and any shim process // is reaped: a *placementError is the placement's failure, anything else is // the command's own error, as exec.Cmd.Start would have returned it. A // failed start's cmd is spent and must not be reused. // // The classification rests on one rule: the shim writes R immediately // before execve, and never earlier. A start is reported failed only when // the status stream proves no R; a start that may have exec'd is reported // started, so its caller owns the process and nothing runs a tenant twice. func (m *Manager) startViaShim(dir string, cmd *exec.Cmd, before func() error) (refused bool, err error) { return m.startViaShimBy(dir, cmd, before, time.Time{}) } // startViaShimBy is startViaShim with neither of its two waits running past // by. The zero time caps nothing: each wait is shimDeadline, as for every // tenant start (Ruling 9). Only VerifyPlacement sets by, so the arm-time // check fits its budget whatever the shim does in user space; past by, a // start fails and is reaped exactly as one past shimDeadline is. The reap // of a SIGKILLed shim is outside both waits and assumes the kernel ends a // killed process promptly (reap). func (m *Manager) startViaShimBy(dir string, cmd *exec.Cmd, before func() error, by time.Time) (refused bool, err error) { // The hook itself, not shimAvailable: FakePlacementForTest's override // must never send a binary without the hook to start itself as a shim. if !shimHooked.Load() { return false, &placementError{errors.New("the placement shim is not available in this process")} } if cmd.Err != nil { return false, cmd.Err } fd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { return false, &placementError{fmt.Errorf("open %s: %w", dir, err)} } dirFile := os.NewFile(uintptr(fd), dir) var st unix.Statfs_t if err := unix.Fstatfs(fd, &st); err != nil { dirFile.Close() return false, &placementError{fmt.Errorf("statfs %s: %w", dir, err)} } if st.Type != unix.CGROUP2_SUPER_MAGIC { dirFile.Close() return false, &placementError{fmt.Errorf("%s is not a cgroup2 directory", dir)} } // The shim checks /proc/self/cgroup against this, and the path below // /sys/fs/cgroup against the fd, so dir must be named there. rel, ok := strings.CutPrefix(dir, "/sys/fs/cgroup/") if !ok || rel == "" { dirFile.Close() return false, &placementError{fmt.Errorf("%s is not a cgroup below /sys/fs/cgroup", dir)} } return m.startShimCore(dir, dirFile, "/"+rel, cmd, before, by) } // shimWait is one of the parent's waits on the shim: shimDeadline from now, // or by when that is sooner (and set). within is the wait it allows, for the // error that names it. func shimWait(by time.Time) (deadline time.Time, within time.Duration) { now := time.Now() deadline = now.Add(shimDeadline) if by.IsZero() || !by.Before(deadline) { return deadline, shimDeadline } return by, max(by.Sub(now), 0).Round(time.Millisecond) } // envHasNUL reports whether Start would refuse cmd's environment for a NUL. // With Env nil, Start uses os.Environ (which cannot hold a NUL) plus // PWD=Dir made absolute, so a NUL in Dir is the only way in. func envHasNUL(cmd *exec.Cmd) bool { if cmd.Env == nil { return cmd.Dir != "" && strings.IndexByte(cmd.Dir, 0) >= 0 } for _, kv := range cmd.Env { if strings.IndexByte(kv, 0) >= 0 { return true } } return false } // startShimCore is startViaShim after its checks: dirFile is dir opened, // and expected is dir as /proc/self/cgroup will show it. It closes dirFile. // by caps both waits, as startViaShimBy describes. func (m *Manager) startShimCore(dir string, dirFile *os.File, expected string, cmd *exec.Cmd, before func() error, by time.Time) (refused bool, err error) { // The tenant, captured before cmd is rewritten into the shim's start: // exactly what exec.Cmd.Start would have passed. path := cmd.Path argv := cmd.Args if len(argv) == 0 { argv = []string{cmd.Path} } wd := cmd.Dir keep := 3 + len(cmd.ExtraFiles) // cmd.Environ() silently drops an entry that contains a NUL, where // Start refuses the command (Go issue #56284). Refuse it the same way, // with Start's own error, before anything starts or is registered. if envHasNUL(cmd) { dirFile.Close() return false, errors.New("exec: environment variable contains NUL") } // The signals this process ignores now, which a direct start's child // would keep ignored (the shim restores them before its exec). Unknown // is a placement failure: nothing has started, and the flat start's // fallback is a direct start, which keeps them by itself. sigIgn, err := ignoredSignals() if err != nil { dirFile.Close() return false, &placementError{fmt.Errorf("read the ignored signals: %w", err)} } spec, err := encodeShimSpec(shimSpec{ Path: path, Dir: wd, Argv: argv, Env: cmd.Environ(), Expected: expected, KeepFDs: keep, TestHold: shimTestHold, SigIgn: sigIgn, }) if err != nil { // A NUL execve cannot carry: what execve answers for it. dirFile.Close() return false, &os.PathError{Op: "fork/exec", Path: path, Err: syscall.EINVAL} } if len(spec) > shimSpecMax { dirFile.Close() return false, &os.PathError{Op: "fork/exec", Path: path, Err: syscall.E2BIG} } specR, specW, err := os.Pipe() if err != nil { dirFile.Close() return false, &placementError{fmt.Errorf("spec pipe: %w", err)} } statusR, statusW, err := os.Pipe() if err != nil { dirFile.Close() specR.Close() specW.Close() return false, &placementError{fmt.Errorf("status pipe: %w", err)} } // Every return closes specW and statusR exactly once; specW may go // early, as the release or the refusal. specWOpen := true closeSpecW := func() { if specWOpen { specWOpen = false specW.Close() } } defer closeSpecW() defer statusR.Close() // The shim's own start: no tenant environment and no working directory // (the shim changes to wd itself, after placement, so a bad wd is the // command's error, never the placement's). Stdin, Stdout, Stderr, // SysProcAttr, Cancel and WaitDelay stay the caller's. cmd.Path = shimExe cmd.Args = []string{"carlos-cgexec", ShimVerb, strconv.Itoa(keep), strconv.Itoa(keep + 1), strconv.Itoa(keep + 2)} cmd.Env = []string{} cmd.Dir = "" cmd.ExtraFiles = append(slices.Clip(cmd.ExtraFiles), specR, dirFile, statusW) // One deadline covers the spec and the wait for placement. deadline, within := shimWait(by) done := m.beginPlacement(dir) startErr := startCmd(cmd) specR.Close() dirFile.Close() statusW.Close() if startErr != nil { // The carlos binary itself did not start; the tenant was not part // of this start. done() return false, &placementError{fmt.Errorf("start the shim: %w", startErr)} } msg := make([]byte, 8+len(spec)) binary.BigEndian.PutUint64(msg, uint64(len(spec))) copy(msg[8:], spec) _ = specW.SetWriteDeadline(deadline) if _, err := specW.Write(msg); err != nil { reap(cmd) done() if errors.Is(err, os.ErrDeadlineExceeded) { return false, &placementError{fmt.Errorf("no answer from the shim within %v", within)} } return false, &placementError{fmt.Errorf("the shim exited before placement (spec: %v)", err)} } _ = statusR.SetReadDeadline(deadline) br := bufio.NewReader(statusR) line, rerr := br.ReadBytes('\n') placed, perr := classifyPlacement(line, rerr != nil && !errors.Is(rerr, io.EOF), within) if !placed { // P, EOF or the deadline: no release byte was sent, so the shim // cannot have exec'd. reap's SIGKILL ends one still running. reap(cmd) done() return false, perr } // The process is in dir now, visible to every reader of the cgroup: the // registry covers only fork to placement, and no caller code runs // inside it. done() // The caller's last check, after placement and immediately before exec // (internal/activator/genbackend.go). A refusal closes specW without the // release byte: the shim reads EOF and exits without exec. if before != nil { if err := before(); err != nil { closeSpecW() reap(cmd) return true, err } } // Release. A failed write leaves the shim to read EOF; the status read // below then proves no R. _ = specW.SetWriteDeadline(time.Time{}) _, _ = specW.Write([]byte{'X'}) closeSpecW() deadline, within = shimWait(by) _ = statusR.SetReadDeadline(deadline) if shimAfterRelease != nil { shimAfterRelease() } rest, rerr := io.ReadAll(br) killed := false if rerr != nil { // The deadline passed before EOF. With R read, an execve is still // loading: started, and nothing is killed. An E line is the shim's // answer. Otherwise kill the shim, which was not released into an // exec it has announced, and read what it wrote before it died. started, err := classifyExec(path, wd, rest, false, within) if started { return false, nil } if !isPlacementError(err) { reap(cmd) return false, err } // EOF comes when the killed shim's status fd closes, which is when // the kernel has ended it: unbounded by the deadline, under reap's // assumption. _ = cmd.Process.Kill() _ = statusR.SetReadDeadline(time.Time{}) more, _ := io.ReadAll(br) rest = append(rest, more...) killed = true } started, err := classifyExec(path, wd, rest, killed, within) if started { return false, nil } reap(cmd) return false, err } // reap ends a failed start's shim: kill, then wait, which reaps the PID. // exec.Cmd's copying goroutines and pipes are released in the background, // so the caller's I/O never holds this call, and no tenant ran to hold the // child's pipes open. // // The wait is bounded only by the kernel finishing the SIGKILL. SIGKILL is // not proof of termination: a process in uninterruptible sleep (a stuck // cgroupfs write, a hung disk or network filesystem read of /proc/self/exe) // dies only when that sleep ends, and this call, and so the start's // answer, waits that long. Normally that is microseconds. Neither // shimDeadline nor VerifyPlacement's budget covers it: they bound the // protocol's waits, on the assumption that a killed process ends promptly // (whole-branch review, finding 4). Bounding the reap too would need the // start to return while still owning an unreaped shim and its in-flight // registration, and to report the target unproven until the reap is done. func reap(cmd *exec.Cmd) { _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() go func() { _ = cmd.Wait() }() } // classifyPlacement reads the stream up to placement (parent step 7). // placed is true only for "A". timedOut is the read deadline, which allowed // within, having passed. func classifyPlacement(b []byte, timedOut bool, within time.Duration) (placed bool, err error) { if i := bytes.IndexByte(b, '\n'); i >= 0 { line := string(b[:i]) switch { case line == "A": return true, nil case strings.HasPrefix(line, "P "): return false, &placementError{fmt.Errorf("the shim could not place the process: %s", line[2:])} default: return false, &placementError{fmt.Errorf("the shim answered %q before placement", line)} } } if timedOut { return false, &placementError{fmt.Errorf("no answer from the shim within %v", within)} } return false, &placementError{errors.New("the shim exited before placement")} } // classifyExec reads the stream after the release byte (parent step 9). // killed means the deadline (which allowed within) passed with no R, the // shim was SIGKILLed, and b is everything read until EOF after that. // // R, and no "E exec" after it, is started: the tenant may have run. An // "E chdir" before R, or an "E exec" after it, is the command's own error, // as exec.Cmd.Start reports it. No R is a placement error. func classifyExec(path, wd string, b []byte, killed bool, within time.Duration) (started bool, err error) { sawR := false for _, line := range strings.Split(string(b), "\n") { if line == "R" { sawR = true continue } op, n, ok := parseShimErr(line) switch { case !ok: case !sawR && op == "chdir": return false, &os.PathError{Op: "chdir", Path: wd, Err: syscall.Errno(n)} case sawR && op == "exec": return false, &os.PathError{Op: "fork/exec", Path: path, Err: syscall.Errno(n)} } } if sawR { return true, nil } if killed { return false, &placementError{fmt.Errorf("the shim did not exec within %v", within)} } return false, &placementError{errors.New("the shim exited before exec")} } // parseShimErr parses "E ". func parseShimErr(line string) (op string, errno int, ok bool) { f := strings.Fields(line) if len(f) != 3 || f[0] != "E" { return "", 0, false } n, err := strconv.Atoi(f[2]) if err != nil || n <= 0 { return "", 0, false } return f[1], n, true } // runShim is the shim process: args are the spec, directory and status // fds, which are keep, keep+1 and keep+2. It writes nothing to fds 0 to 2. // A failure before placement is confirmed writes "P : " and // returns 125; tenant code has not run. // // Plan 14 root-read census, census ruling zddr3: the shim, its probe and // the parent-side start read only // // the agent's own delegated cgroup subtree (including its scratch probe child), /proc/self, and pipes to and from its own parent, running as carlos // // On that basis their reads are accepted-legacy rows // (internal/provision/testdata/plan14-root-reads-zddr3.tsv). If the shim // ever runs as root, or reads anything outside that boundary, it stops // being legacy and needs the Plan 14 owner's ruling. func runShim(args []string) int { if len(args) != 3 { return 125 } var fds [3]int for i, a := range args { n, err := strconv.Atoi(a) if err != nil { return 125 } fds[i] = n } keep, specFD, dirFD, statusFD := fds[0], fds[0], fds[1], fds[2] if keep < 3 || dirFD != keep+1 || statusFD != keep+2 { return 125 } // First, so every later path, the exec included, closes it. if _, err := unix.FcntlInt(uintptr(statusFD), unix.F_SETFD, unix.FD_CLOEXEC); err != nil { return 125 } fail := func(step string, err error) int { shimWrite(statusFD, "P "+step+": "+err.Error()+"\n") return 125 } // The pipes may carry the parent's O_NONBLOCK; the shim's reads and // writes block. for _, fd := range []int{specFD, statusFD} { if err := unix.SetNonblock(fd, false); err != nil { return fail("fds", err) } } spec, err := readShimSpec(specFD) if err != nil { return fail("spec", err) } if spec.KeepFDs != keep { return fail("spec", fmt.Errorf("the spec keeps %d fds, the shim was given %d", spec.KeepFDs, keep)) } // Everything from keep up closes at exec: the shim's three fds and any // the agent leaked without close-on-exec. if err := cloexecFrom(keep); err != nil { return fail("cloexec", err) } shimHold(spec.TestHold, 1) var st unix.Statfs_t if err := unix.Fstatfs(dirFD, &st); err != nil { return fail("statfs", err) } if st.Type != unix.CGROUP2_SUPER_MAGIC { return fail("statfs", errors.New("not a cgroup2 directory")) } // Moves the whole process, every Go runtime thread, while it is still // carlos code. if err := joinCgroup(dirFD); err != nil { return fail("procs", err) } if err := verifyCgroup(dirFD, spec.Expected); err != nil { return fail("verify", err) } unix.Close(dirFD) if !shimWrite(statusFD, "A\n") { return 125 } // The release byte. EOF, an error or another byte: the parent refused // or is gone. var rel [1]byte if n, err := unix.Read(specFD, rel[:]); n != 1 || err != nil || rel[0] != 'X' { return 125 } shimHold(spec.TestHold, 2) if spec.Dir != "" { if err := os.Chdir(spec.Dir); err != nil { shimWrite(statusFD, fmt.Sprintf("E chdir %d\n", errnoOf(err))) return 126 } } // The exec state a direct start under systemd would have: an empty // signal mask on the thread that execs, and the agent's ignored signals // ignored. execve resets every handled signal to default and keeps // ignored ones ignored, and syscall.Exec restores the RLIMIT_NOFILE // this process started with. runtime.LockOSThread() if err := unix.PthreadSigmask(unix.SIG_SETMASK, &unix.Sigset_t{}, nil); err != nil { return 125 } // A failure here, before R, is "the shim exited before exec": a // placement failure, with no tenant code run. if err := ignoreSignals(spec.SigIgn); err != nil { return 125 } // R immediately before execve, and never earlier: without it the parent // may report the start failed, so no exec may follow a failed write. if !shimWrite(statusFD, "R\n") { return 125 } err = syscall.Exec(spec.Path, spec.Argv, spec.Env) shimWrite(statusFD, fmt.Sprintf("E exec %d\n", errnoOf(err))) return 126 } // ignoredSignals reads this process's ignored-signal set from // /proc/self/status (SigIgn, hexadecimal; bit n-1 is signal n). Signal // dispositions are process-wide, so any thread's view is the process's. func ignoredSignals() (uint64, error) { raw, err := os.ReadFile("/proc/self/status") if err != nil { return 0, err } for _, line := range strings.Split(string(raw), "\n") { if v, ok := strings.CutPrefix(line, "SigIgn:"); ok { return strconv.ParseUint(strings.TrimSpace(v), 16, 64) } } return 0, errors.New("no SigIgn line in /proc/self/status") } // ignoreSignals sets every signal in set (bit n-1 is signal n) to SIG_IGN // through os/signal, which keeps the shim's runtime in step, and then // checks the kernel's view: each one must read back ignored. // // os/signal will not ignore SIGPROF, nor signals 32 and 33, which have no // Notify slot. None of them can be in the set while it does nothing: // SIGPROF and (without cgo) 33 get the agent runtime's own handler at // start, whatever the agent inherited, so the agent never has them ignored // (a CPU profile does not leave SIGPROF ignored either: checked on Go 1.26, // Linux); 32, and 33 with cgo, the runtime leaves as inherited, in the // agent and in the shim alike, so an inherited ignore is still in place. // The read-back check turns anything this reasoning misses into a failed // start (a placement failure: a flat start falls back to a direct start, // a generation start is refused), never a tenant with a handled signal it // should have ignored. Signals the shim inherited ignored beyond set are // left as they are: they come from the same agent, so a direct start at // that moment would have kept them too. func ignoreSignals(set uint64) error { for n := 1; n <= 64; n++ { if set&(1<<(n-1)) != 0 { signal.Ignore(syscall.Signal(n)) } } got, err := ignoredSignals() if err != nil { return err } if missing := set &^ got; missing != 0 { return fmt.Errorf("signals %#x did not read back ignored", missing) } return nil } // runProbe prints this process's cgroup as /proc/self/cgroup shows it. func runProbe() int { raw, err := os.ReadFile("/proc/self/cgroup") if err != nil { return 1 } rel, ok := ParseSelfCgroup(string(raw)) if !ok { return 1 } if _, err := os.Stdout.WriteString("/" + rel + "\n"); err != nil { return 1 } return 0 } // readShimSpec reads the 8-byte big-endian length and then the spec. func readShimSpec(fd int) (shimSpec, error) { var hdr [8]byte if err := readFull(fd, hdr[:]); err != nil { return shimSpec{}, err } n := binary.BigEndian.Uint64(hdr[:]) if n > shimSpecMax { return shimSpec{}, fmt.Errorf("spec of %d bytes is over %d", n, shimSpecMax) } b := make([]byte, n) if err := readFull(fd, b); err != nil { return shimSpec{}, err } return decodeShimSpec(b) } func readFull(fd int, b []byte) error { for len(b) > 0 { n, err := unix.Read(fd, b) if err == unix.EINTR { continue } if err != nil { return err } if n == 0 { return io.ErrUnexpectedEOF } b = b[n:] } return nil } func shimWrite(fd int, s string) bool { b := []byte(s) for len(b) > 0 { n, err := unix.Write(fd, b) if err == unix.EINTR { continue } if err != nil { return false } b = b[n:] } return true } // cloexecFrom marks every fd >= keep close-on-exec. Without close_range // (ENOSYS on an old kernel, or a filter's EPERM or EINVAL) it walks // /proc/self/fd. func cloexecFrom(keep int) error { err := unix.CloseRange(uint(keep), math.MaxUint32, unix.CLOSE_RANGE_CLOEXEC) if err == nil { return nil } if err != unix.ENOSYS && err != unix.EPERM && err != unix.EINVAL { return err } entries, err := os.ReadDir("/proc/self/fd") if err != nil { return err } for _, e := range entries { n, err := strconv.Atoi(e.Name()) if err != nil || n < keep { continue } // EBADF is the listing's own fd, closed since. if _, err := unix.FcntlInt(uintptr(n), unix.F_SETFD, unix.FD_CLOEXEC); err != nil && err != unix.EBADF { return err } } return nil } // joinCgroup writes this process's PID into the cgroup.procs of the // directory dirFD names. func joinCgroup(dirFD int) error { fd, err := unix.Openat(dirFD, "cgroup.procs", unix.O_WRONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { return fmt.Errorf("open cgroup.procs: %w", err) } defer unix.Close(fd) if _, err := unix.Write(fd, []byte(strconv.Itoa(os.Getpid()))); err != nil { return fmt.Errorf("write cgroup.procs: %w", err) } return nil } // verifyCgroup checks that the kernel reports this process in expected, and // that expected is the directory dirFD names (same device and inode). func verifyCgroup(dirFD int, expected string) error { raw, err := os.ReadFile("/proc/self/cgroup") if err != nil { return err } rel, ok := ParseSelfCgroup(string(raw)) if !ok { return errors.New("no cgroup v2 path in /proc/self/cgroup") } if "/"+rel != expected { return fmt.Errorf("landed in /%s, want %s", rel, expected) } var fst, lst unix.Stat_t if err := unix.Fstat(dirFD, &fst); err != nil { return err } if err := unix.Lstat("/sys/fs/cgroup"+expected, &lst); err != nil { return err } if fst.Dev != lst.Dev || fst.Ino != lst.Ino { return fmt.Errorf("/sys/fs/cgroup%s is not the directory the parent opened", expected) } return nil } // shimHold sleeps for good at the point at, when a test binary's spec asks // for it, until a signal ends the process. Default dispositions: no // signal.Notify, so SIGTERM or SIGKILL kills it there. func shimHold(hold, at int) { if hold != at || !testing.Testing() { return } for { time.Sleep(time.Hour) } } func errnoOf(err error) int { var e syscall.Errno if errors.As(err, &e) { return int(e) } return int(syscall.EINVAL) }