//go:build linux package main // ops_taskcap_linux.go — a root op's tasks are capped by a cgroup. // // A files op's tasks are capped at opSandboxTasksMax by RLIMIT_NPROC, and the // kernel never applies that limit to root: a task whose real user is root, // or that holds CAP_SYS_RESOURCE or CAP_SYS_ADMIN, forks past it. A user op // runs as an unprivileged user and the limit holds. A root op's runaway fork // was held by nothing but its time limit. // // A cgroup's pids.max counts every task in the cgroup, root's included. So a // root op runs in a scope of its own, in the ops' slice, with TasksMax set, // started by systemd-run as an action op's unit is. The scope's process is // this binary as `__op-sandbox-scope `: it reads its own cgroup's // pids.max, writes what it read beside the spec, and only then starts the // sandbox's pid 1 exactly as the bastion does without a scope. The report // says the tasks were capped only when that value is the cap. // // Where no scope can be made (no systemd, no pids controller, systemd-run // failing), the op runs as before, and its report says its tasks were not // capped and why. A scope that could not start ran nothing: the value is // written before the sandbox starts, so with no value the op never ran. import ( "context" "fmt" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "syscall" ) const ( // opSandboxTasksMax is the most tasks a files op's sandbox holds at once, // threads included: RLIMIT_NPROC for a user op, pids.max for a root op. opSandboxTasksMax = 64 // opSandboxScopeTasks is the scope's TasksMax: the op's own cap plus room // for the two bastion processes that start it inside the scope (this // binary, twice, each with its runtime's threads; 8 tasks between them // where measured, held there by opSandboxHelperEnv), so a root op may // start at least as many tasks as a user op. opSandboxScopeTasks = opSandboxTasksMax + 32 // opSandboxHelperEnv is in the environment of both bastion processes that // start the sandbox. The Go runtime starts threads by the CPUs it may use, // and in a scope each one counts against the cap: measured, the two held // 16 tasks with 12 CPUs, 44 with the runtime set for 64, and set for 256 // the sandbox's pid 1 could not start a thread and the op never ran. With // one, they hold 8 on a machine of any size. Neither process does more // than start its child and wait for it. The op never sees this: pid 1 // sets the op's environment itself. opSandboxHelperEnv = "GOMAXPROCS=1" opSandboxScopeSubcommand = "__op-sandbox-scope" // opScopeTasksFile, beside the spec on the run's tmpfs, holds the // pids.max the scope's process read in its own cgroup. opScopeTasksFile = "scope_pids_max" ) // opTasksScopeWhyNot says why this machine cannot hold an op's tasks in a // scope of their own, "" when it can. func opTasksScopeWhyNot() string { if _, err := exec.LookPath("systemd-run"); err != nil { return "systemd-run is not installed" } if st, err := os.Stat("/run/systemd/system"); err != nil || !st.IsDir() { return "systemd is not this machine's init" } raw, err := os.ReadFile("/sys/fs/cgroup/cgroup.controllers") if err != nil { return "this machine does not run the unified cgroup hierarchy" } for _, c := range strings.Fields(string(raw)) { if c == "pids" { return "" } } return "the kernel offers no pids cgroup controller" } // opTasksScopeArgs is the systemd-run command line for a root op's run. func opTasksScopeArgs(unit, self, specPath string) []string { return []string{"--scope", "--quiet", "--collect", "--unit=" + unit, "--slice=" + opActionSlice, "-p", "TasksMax=" + strconv.Itoa(opSandboxScopeTasks), "--", self, opSandboxScopeSubcommand, specPath} } // opScopeUnit names the scope of the run whose tmpfs is run. func opScopeUnit(run string) string { return opScopeUnitPrefix + strings.TrimPrefix(filepath.Base(run), "run-") } // opKillScope kills every task in a files op's scope through the kernel. func opKillScope(unit string) { _ = os.WriteFile(filepath.Join("/sys/fs/cgroup", opActionSlice, unit+".scope", "cgroup.kill"), []byte("1"), 0o600) } // opSweepScopes kills what a files op's scope still holds when the bastion // died under it. func opSweepScopes() { entries, _ := os.ReadDir(filepath.Join("/sys/fs/cgroup", opActionSlice)) for _, e := range entries { if n := e.Name(); e.IsDir() && strings.HasPrefix(n, opScopeUnitPrefix) && strings.HasSuffix(n, ".scope") { opKillScope(strings.TrimSuffix(n, ".scope")) } } } // opSandboxCmd is the command that starts the sandbox: the sandbox's pid 1 // directly, or systemd-run making the scope it runs in. func opSandboxCmd(ctx context.Context, self, specPath, unit string) *exec.Cmd { if unit == "" { cmd := exec.CommandContext(ctx, self, opSandboxSubcommand, specPath) cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC | syscall.CLONE_NEWUTS, Setpgid: true, } return cmd } cmd := exec.CommandContext(ctx, "systemd-run", opTasksScopeArgs(unit, self, specPath)...) // The scope is outside the bastion's own cgroup, so a bastion stopped or // restarted does not take it down: the scope's process dies with the // bastion instead (systemd-run execs it, which keeps the signal), and the // sandbox's pid 1 dies with that process. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL} return cmd } // opRunSandboxCmd runs cmd on a thread held for the whole run: a parent- // death signal is sent when the thread that started the child exits. func opRunSandboxCmd(cmd *exec.Cmd) error { runtime.LockOSThread() defer runtime.UnlockOSThread() return cmd.Run() } // opScopeTasks reads what the scope's process found its cgroup's pids.max to // be: ok is false when the scope's process never ran. func opScopeTasks(run string) (string, bool) { raw, err := os.ReadFile(filepath.Join(run, opScopeTasksFile)) if err != nil { return "", false } return strings.TrimSpace(string(raw)), true } // opOwnPidsMax reads pids.max of the cgroup this process is in. func opOwnPidsMax() string { raw, err := os.ReadFile("/proc/self/cgroup") if err != nil { return "unreadable: " + err.Error() } for _, line := range strings.Split(string(raw), "\n") { if p, ok := strings.CutPrefix(line, "0::"); ok { v, err := os.ReadFile(filepath.Join("/sys/fs/cgroup", filepath.Clean("/"+p), "pids.max")) if err != nil { return "unreadable: " + err.Error() } return strings.TrimSpace(string(v)) } } return "unreadable: no unified cgroup" } // opSandboxScopeMain is the scope's process: it records its cgroup's // pids.max, then starts the sandbox's pid 1 and returns its exit. func opSandboxScopeMain(specPath string) int { if err := os.WriteFile(filepath.Join(filepath.Dir(specPath), opScopeTasksFile), []byte(opOwnPidsMax()), 0o600); err != nil { fmt.Fprintln(os.Stderr, "sandbox: cannot record the scope's task cap:", err) return 125 } self, err := os.Executable() if err != nil { fmt.Fprintln(os.Stderr, "sandbox:", err) return 125 } cmd := exec.Command(self, opSandboxSubcommand, specPath) cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC | syscall.CLONE_NEWUTS, Pdeathsig: syscall.SIGKILL, } cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", opSandboxHelperEnv} return opExitOf(cmd.Run()) } // opTasksNotCapped is the report's note for a root op whose tasks no cgroup // held. func opTasksNotCapped(why string) string { return "this root op's tasks were not capped: " + why } // opLastLine is the last line s holds, "" for none. func opLastLine(s string) string { lines := strings.Split(strings.TrimSpace(s), "\n") return lines[len(lines)-1] }