199 lines
6.4 KiB
Go
199 lines
6.4 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package tentacle
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
)
|
|
|
|
var ErrContainment = errors.New("tentacle resource containment failed")
|
|
|
|
// ResourceBudget includes the child and all its descendants. Tasks counts
|
|
// kernel threads as well as processes. CPUQuota is microseconds per 100 ms,
|
|
// so 100000 allows one CPU, independent of the machine's number of CPUs.
|
|
type ResourceBudget struct {
|
|
CPUQuota int64
|
|
MemoryBytes int64
|
|
Tasks int64
|
|
}
|
|
|
|
type ResourceLimits struct {
|
|
PerSession ResourceBudget
|
|
Total ResourceBudget
|
|
CleanupTimeout time.Duration
|
|
}
|
|
|
|
func DefaultResourceLimits() ResourceLimits {
|
|
return ResourceLimits{
|
|
PerSession: ResourceBudget{CPUQuota: 25000, MemoryBytes: 256 << 20, Tasks: 64},
|
|
Total: ResourceBudget{CPUQuota: 100000, MemoryBytes: 1 << 30, Tasks: 256},
|
|
CleanupTimeout: 2 * time.Second,
|
|
}
|
|
}
|
|
|
|
func (limits ResourceLimits) validate() error {
|
|
for _, budget := range []ResourceBudget{limits.PerSession, limits.Total} {
|
|
if budget.CPUQuota < 1000 || budget.MemoryBytes < 1<<20 || budget.Tasks < 1 {
|
|
return fmt.Errorf("%w: invalid resource budget", ErrContainment)
|
|
}
|
|
}
|
|
if limits.Total.CPUQuota < limits.PerSession.CPUQuota ||
|
|
limits.Total.MemoryBytes < limits.PerSession.MemoryBytes ||
|
|
limits.Total.Tasks < limits.PerSession.Tasks || limits.CleanupTimeout <= 0 {
|
|
return fmt.Errorf("%w: invalid aggregate budget or cleanup deadline", ErrContainment)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewContainedManager creates a private subtree in an already delegated cgroup
|
|
// v2 parent with cpu, memory and pids enabled in cgroup.subtree_control. It never
|
|
// moves the host or changes the caller's parent. Setup may perform filesystem
|
|
// I/O and belongs on a lifecycle worker, never an input callback.
|
|
//
|
|
// There is no fallback when delegation, controllers, cgroup.kill or atomic
|
|
// cgroup placement are unavailable. This is resource containment for trusted
|
|
// local code, not a security sandbox against same-user cgroup modification,
|
|
// migration, privileged scheduling, or external service launches.
|
|
func NewContainedManager(limits Limits, overrides []input.BindingOverride, parent string, resources ResourceLimits) (*Manager, error) {
|
|
if err := resources.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
manager, err := NewManager(limits, overrides)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !filepath.IsAbs(parent) {
|
|
return nil, fmt.Errorf("%w: cgroup parent must be absolute", ErrContainment)
|
|
}
|
|
var stat syscall.Statfs_t
|
|
if err := syscall.Statfs(parent, &stat); err != nil || stat.Type != 0x63677270 {
|
|
return nil, fmt.Errorf("%w: parent is not a cgroup v2 filesystem", ErrContainment)
|
|
}
|
|
var identity [16]byte
|
|
if _, err := rand.Read(identity[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
group, err := create_cgroup(filepath.Join(parent, fmt.Sprintf("cthulhu-tentacles-%x", identity)), resources.Total, limits.Processes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := write_control(group.path, "cgroup.subtree_control", "+cpu +memory +pids"); err != nil {
|
|
return nil, errors.Join(err, group.remove())
|
|
}
|
|
manager.containment = &containment{group: group, limits: resources}
|
|
return manager, nil
|
|
}
|
|
|
|
type containment struct {
|
|
group *cgroup
|
|
limits ResourceLimits
|
|
}
|
|
|
|
type cgroup struct {
|
|
path string
|
|
file *os.File
|
|
}
|
|
|
|
func write_control(path, name, value string) error {
|
|
// Do not create ordinary files if the kernel lacks a required controller.
|
|
file, err := os.OpenFile(filepath.Join(path, name), os.O_WRONLY, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: open %s: %w", ErrContainment, name, err)
|
|
}
|
|
_, writeError := file.WriteString(value)
|
|
return errors.Join(wrap_control_error(name, writeError), wrap_control_error(name, file.Close()))
|
|
}
|
|
|
|
func wrap_control_error(name string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%w: %s: %w", ErrContainment, name, err)
|
|
}
|
|
|
|
func create_cgroup(path string, budget ResourceBudget, children int) (*cgroup, error) {
|
|
if err := os.Mkdir(path, 0700); err != nil {
|
|
return nil, fmt.Errorf("%w: create group: %w", ErrContainment, err)
|
|
}
|
|
group := &cgroup{path: path}
|
|
settings := []struct{ name, value string }{
|
|
{"cpu.max", strconv.FormatInt(budget.CPUQuota, 10) + " 100000"},
|
|
{"memory.max", strconv.FormatInt(budget.MemoryBytes, 10)},
|
|
{"memory.swap.max", "0"},
|
|
{"pids.max", strconv.FormatInt(budget.Tasks, 10)},
|
|
{"cgroup.max.descendants", strconv.Itoa(children)},
|
|
}
|
|
if children == 0 {
|
|
// If any task exhausts memory, retire the entire session, not an
|
|
// arbitrary worker while leaving a partially broken child alive.
|
|
settings = append(settings, struct{ name, value string }{"memory.oom.group", "1"})
|
|
}
|
|
for _, setting := range settings {
|
|
if err := write_control(path, setting.name, setting.value); err != nil {
|
|
return nil, errors.Join(err, group.remove())
|
|
}
|
|
}
|
|
// Check availability without issuing a kill before launch. Some kernels
|
|
// SIGKILL CLONE_INTO_CGROUP children entering a previously killed group.
|
|
killFile, err := os.OpenFile(filepath.Join(path, "cgroup.kill"), os.O_WRONLY, 0)
|
|
if err != nil {
|
|
return nil, errors.Join(wrap_control_error("open cgroup.kill", err), group.remove())
|
|
}
|
|
if err := killFile.Close(); err != nil {
|
|
return nil, errors.Join(wrap_control_error("close cgroup.kill", err), group.remove())
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, errors.Join(wrap_control_error("open group", err), group.remove())
|
|
}
|
|
group.file = file
|
|
return group, nil
|
|
}
|
|
|
|
func (group *cgroup) remove() error {
|
|
if group.file != nil {
|
|
_ = group.file.Close()
|
|
group.file = nil
|
|
}
|
|
return wrap_control_error("remove group", os.Remove(group.path))
|
|
}
|
|
|
|
// kill_and_remove waits only during teardown. Successful removal proves the
|
|
// kernel no longer has live tasks in the group, including setsid descendants.
|
|
func (group *cgroup) kill_and_remove(timeout time.Duration) error {
|
|
defer func() {
|
|
if group.file != nil {
|
|
_ = group.file.Close()
|
|
group.file = nil
|
|
}
|
|
}()
|
|
if err := write_control(group.path, "cgroup.kill", "1"); err != nil {
|
|
return err
|
|
}
|
|
deadline := time.NewTimer(timeout)
|
|
defer deadline.Stop()
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
err := group.remove()
|
|
if err == nil || !errors.Is(err, syscall.EBUSY) {
|
|
return err
|
|
}
|
|
select {
|
|
case <-ticker.C:
|
|
case <-deadline.C:
|
|
return fmt.Errorf("%w: cleanup deadline expired", ErrContainment)
|
|
}
|
|
}
|
|
}
|