Files
Privatae 8822d5df85
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
bastion 0.55.3
2026-10-25 23:16:45 +00:00

201 lines
7.8 KiB
Go

//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 <spec>`: 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]
}