//go:build linux package instanceexec import ( "errors" "fmt" "net" "os" "runtime" "strings" "syscall" "unsafe" "golang.org/x/sys/unix" ) // landlockScopeABI is the first Landlock ABI with scopes (Linux 6.12). const landlockScopeABI = 6 // ScopeAbstractUnix confines the CALLING THREAD, and everything it execs, // to abstract unix sockets bound inside its own Landlock domain (#178). // Abstract names live in the network namespace, which every instance // shares with the box, and RestrictAddressFamilies= cannot tell them from // path sockets; LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET can. After this, a // connect to a name a sibling bound answers EPERM. The app's own // processes, all descended from the restricted thread, still reach each // other's. // // Landlock restricts a thread, not a process, so the caller must hold // runtime.LockOSThread and then exec, fork (the child inherits the // domain), or let the thread die locked. // Nothing here needs privilege: no_new_privs (which the unit already sets // with NoNewPrivileges=) is the only precondition. func ScopeAbstractUnix() error { abi, _, e := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, 0, 0, unix.LANDLOCK_CREATE_RULESET_VERSION) if e != 0 { return fmt.Errorf("landlock unavailable: %w", e) } if abi < landlockScopeABI { return fmt.Errorf("landlock ABI %d cannot scope abstract unix sockets (needs %d, Linux 6.12)", abi, landlockScopeABI) } attr := unix.LandlockRulesetAttr{Scoped: unix.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET} fd, _, e := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, uintptr(unsafe.Pointer(&attr)), unsafe.Sizeof(attr), 0) if e != 0 { return fmt.Errorf("landlock ruleset: %w", e) } defer unix.Close(int(fd)) if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { return fmt.Errorf("no_new_privs: %w", err) } if _, _, e := unix.Syscall(unix.SYS_LANDLOCK_RESTRICT_SELF, fd, 0, 0); e != 0 { return fmt.Errorf("landlock restrict: %w", e) } return nil } var errAbstractReached = errors.New("connected to an abstract socket bound outside the scope") // realAbstractCheck proves the scope on this kernel, end to end: it binds // an abstract name, connects to it once unscoped (the stack works), then // connects again from a scoped thread, which must answer EPERM. The scoped // thread is left locked so it dies with its restriction. func realAbstractCheck() error { name := "@carlos-probe-" + randomName() ln, err := net.Listen("unix", name) if err != nil { return fmt.Errorf("listen %s: %w", name, err) } defer ln.Close() go func() { for { c, err := ln.Accept() if err != nil { return } c.Close() } }() c, err := net.Dial("unix", name) if err != nil { return fmt.Errorf("unscoped connect: %w", err) } c.Close() res := make(chan error, 1) go func() { runtime.LockOSThread() if err := ScopeAbstractUnix(); err != nil { res <- err return } c, err := net.Dial("unix", name) if err == nil { c.Close() res <- errAbstractReached return } res <- unwrapErrno(err) }() if err := <-res; !errors.Is(err, syscall.EPERM) { return err } return nil } // initIPCInode is PROC_IPC_INIT_INO, the inode of the box's own IPC // namespace; any other value is a private one. const initIPCInode = 0xEFFFFFFF func ipcNamespaceInode() (uint64, error) { fi, err := os.Stat("/proc/self/ns/ipc") if err != nil { return 0, err } st, ok := fi.Sys().(*syscall.Stat_t) if !ok { return 0, errors.New("no inode") } return st.Ino, nil } // ioWeightEnforced reports whether io.weight has a controller behind it on // this box: io.cost enabled for some device (a line with enable=1 in the // root's io.cost.qos), or BFQ, which exposes io.bfq.weight in the unit's // own cgroup. func ioWeightEnforced(selfCgroup string) bool { if b, err := os.ReadFile("/sys/fs/cgroup/io.cost.qos"); err == nil { for _, line := range strings.Split(string(b), "\n") { if strings.Contains(" "+line+" ", " enable=1 ") { return true } } } _, err := os.Stat(selfCgroup + "/io.bfq.weight") return err == nil }