mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-17 23:22:27 +02:00
feat: manage task process lifetimes and preserve turn history
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
//go:build linux
|
||||
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCgroupContainsSetsidAndAppliesLimits(t *testing.T) {
|
||||
opts := testOptions()
|
||||
if opts.CgroupRoot == "" {
|
||||
t.Skip("set CSAI_TEST_CGROUP_ROOT to a delegated cgroup v2 root")
|
||||
}
|
||||
opts.Mode = "required"
|
||||
opts.CPUQuotaMicros = 50000
|
||||
id := testID()
|
||||
g, err := NewWithOptions(id, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeTestGroup(t, g)
|
||||
file := filepath.Join(t.TempDir(), "escaped")
|
||||
cmd, err := startTestCommand(g, fmt.Sprintf("setsid sh -c 'echo $$ > %s; exec sleep 300' </dev/null >/dev/null 2>&1 &", file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reaped := make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(reaped) }()
|
||||
pid := readPID(t, file)
|
||||
<-reaped // The launching shell is gone; the cgroup must still own setsid descendants.
|
||||
root := filepath.Join(opts.CgroupRoot, "task-"+id)
|
||||
for name, want := range map[string]string{"pids.max": "64", "memory.max": "268435456", "cpu.max": "50000 100000"} {
|
||||
data, err := os.ReadFile(filepath.Join(root, name))
|
||||
if err != nil || strings.TrimSpace(string(data)) != want {
|
||||
t.Fatalf("%s=%s err=%v", name, data, err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err = g.Close(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-reaped
|
||||
waitGone(t, pid)
|
||||
if _, err = os.Stat(root); !os.IsNotExist(err) {
|
||||
t.Fatalf("cgroup retained after cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgroupStartupDelegationAndRecovery(t *testing.T) {
|
||||
opts := testOptions()
|
||||
if opts.CgroupRoot == "" {
|
||||
t.Skip("requires delegated cgroup fixture")
|
||||
}
|
||||
before, err := os.ReadFile("/proc/self/cgroup")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := ""
|
||||
for _, line := range strings.Split(string(before), "\n") {
|
||||
if strings.HasPrefix(line, "0::") {
|
||||
original = filepath.Join("/sys/fs/cgroup", strings.TrimPrefix(line, "0::"))
|
||||
}
|
||||
}
|
||||
root := filepath.Join(opts.CgroupRoot, "startup-fixture")
|
||||
if err = os.Mkdir(root, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(root, "cgroup.procs"), []byte(fmt.Sprint(os.Getpid())), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.WriteFile(filepath.Join(original, "cgroup.procs"), []byte(fmt.Sprint(os.Getpid())), 0600)
|
||||
if rootLock != nil {
|
||||
_ = rootLock.Close()
|
||||
rootLock = nil
|
||||
}
|
||||
_ = removeCgroupTree(root)
|
||||
}()
|
||||
stale := filepath.Join(root, "task-"+testID())
|
||||
if err = os.Mkdir(stale, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := os.Open(stale)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dir.Close()
|
||||
cmd := exec.Command("sh", "-c", "exec sleep 300")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{UseCgroupFD: true, CgroupFD: int(dir.Fd()), Setsid: true}
|
||||
if err = cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reaped := make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(reaped) }()
|
||||
opts.CgroupRoot = root
|
||||
opts.Mode = "required"
|
||||
if err = configurePlatform(&opts); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
<-reaped
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-reaped
|
||||
waitGone(t, cmd.Process.Pid)
|
||||
if _, err = os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale task cgroup was not removed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "cgroup.subtree_control"))
|
||||
if err != nil || !strings.Contains(string(data), "memory") {
|
||||
t.Fatalf("delegation not enabled: %s %v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Check exercises the real creation path, including clone3/Job inheritance,
|
||||
// watchdog readiness, admission and cleanup. It does not start the HTTP server.
|
||||
func Check(ctx context.Context) (backend string, err error) {
|
||||
var id [16]byte
|
||||
if _, err = rand.Read(id[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
|
||||
g, err := New(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { err = errors.Join(err, g.Close(ctx)) }()
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, exe, "-h")
|
||||
configureGuardian(cmd)
|
||||
launch, err := g.Prepare(cmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer launch.Dispose()
|
||||
if err = cmd.Start(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = launch.Commit(); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return "", err
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = g.Release(cmd.Process.Pid); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return g.Name(), nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//go:build !windows
|
||||
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type unixGroup struct {
|
||||
mu sync.Mutex
|
||||
watcher *watchdog
|
||||
pids map[int]struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newUnixGroup() (*unixGroup, error) {
|
||||
g := &unixGroup{pids: make(map[int]struct{})}
|
||||
w, err := startWatchdog(watchRequest{Name: "process_group"}, func() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.closed = true
|
||||
for pid := range g.pids {
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.watcher = w
|
||||
return g, nil
|
||||
}
|
||||
func (g *unixGroup) Name() string { return "process_group_watchdog" }
|
||||
func configureGuardian(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} }
|
||||
|
||||
type childSpec struct {
|
||||
Path string
|
||||
Args []string
|
||||
}
|
||||
|
||||
func (g *unixGroup) Prepare(cmd *exec.Cmd) (*Launch, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return nil, fmt.Errorf("process group is closed")
|
||||
}
|
||||
// A dead guardian rejects subsequent launches before user code is executed.
|
||||
if _, err := g.watcher.send(watchRequest{Op: "ping"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
read, write, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec, _ := json.Marshal(childSpec{Path: cmd.Path, Args: cmd.Args})
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
read.Close()
|
||||
write.Close()
|
||||
return nil, err
|
||||
}
|
||||
fd := 3 + len(cmd.ExtraFiles)
|
||||
cmd.ExtraFiles = append(cmd.ExtraFiles, read)
|
||||
cmd.Path = exe
|
||||
cmd.Args = []string{exe, childArg, strconv.Itoa(fd), base64.RawStdEncoding.EncodeToString(spec)}
|
||||
return &Launch{Dispose: func() { read.Close(); write.Close() }, Commit: func() error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return fmt.Errorf("process group is closed")
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if _, err := g.watcher.send(watchRequest{Op: "add", PID: pid}); err != nil {
|
||||
return err
|
||||
}
|
||||
g.pids[pid] = struct{}{}
|
||||
_, err := write.Write([]byte{1})
|
||||
return err
|
||||
}}, nil
|
||||
}
|
||||
func (g *unixGroup) Release(pid int) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if _, ok := g.pids[pid]; !ok {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-g.watcher.done:
|
||||
delete(g.pids, pid)
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if _, err := g.watcher.send(watchRequest{Op: "release", PID: pid}); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(g.pids, pid)
|
||||
return nil
|
||||
}
|
||||
func (g *unixGroup) Close(ctx context.Context) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.closed = true
|
||||
for pid := range g.pids {
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
for {
|
||||
for pid := range g.pids {
|
||||
if syscall.Kill(-pid, 0) == syscall.ESRCH {
|
||||
delete(g.pids, pid)
|
||||
}
|
||||
}
|
||||
if len(g.pids) == 0 {
|
||||
return g.watcher.close()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
func gatedChildMain(args []string) error {
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("invalid internal launch")
|
||||
}
|
||||
fd, err := strconv.Atoi(args[0])
|
||||
if err != nil || fd < 3 {
|
||||
return fmt.Errorf("invalid launch gate")
|
||||
}
|
||||
gate := os.NewFile(uintptr(fd), "launch-gate")
|
||||
var token [1]byte
|
||||
if _, err = io.ReadFull(gate, token[:]); err != nil {
|
||||
return fmt.Errorf("owner exited before launch: %w", err)
|
||||
}
|
||||
gate.Close()
|
||||
if token[0] != 1 {
|
||||
return fmt.Errorf("invalid launch token")
|
||||
}
|
||||
b, err := base64.RawStdEncoding.DecodeString(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var spec childSpec
|
||||
if err = json.Unmarshal(b, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
return syscall.Exec(spec.Path, spec.Args, os.Environ())
|
||||
}
|
||||
func groupGuardian(dec *json.Decoder, enc *json.Encoder) error {
|
||||
pids := make(map[int]struct{})
|
||||
return serveGuardian(dec, enc, func(req watchRequest) error {
|
||||
switch req.Op {
|
||||
case "ping":
|
||||
case "add":
|
||||
if req.PID <= 1 {
|
||||
return fmt.Errorf("invalid PID")
|
||||
}
|
||||
pids[req.PID] = struct{}{}
|
||||
case "release":
|
||||
delete(pids, req.PID)
|
||||
default:
|
||||
return fmt.Errorf("unknown guardian command")
|
||||
}
|
||||
return nil
|
||||
}, func() error {
|
||||
for pid := range pids {
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package processguard provides OS containment and out-of-process crash cleanup.
|
||||
// It is intentionally independent of the Agent/MCP packages so it can be
|
||||
// cross-compiled and exercised without starting the application.
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Mode string `yaml:"mode" json:"mode"` // auto, required, process_group
|
||||
CgroupRoot string `yaml:"cgroup_root" json:"cgroup_root"`
|
||||
MaxProcesses int `yaml:"max_processes" json:"max_processes"`
|
||||
MemoryMaxBytes int64 `yaml:"memory_max_bytes" json:"memory_max_bytes"`
|
||||
CPUQuotaMicros int64 `yaml:"cpu_quota_micros" json:"cpu_quota_micros"` // per 100000 us
|
||||
}
|
||||
|
||||
// Prepared commands must call Commit after Start and always call Dispose.
|
||||
// Commit releases the Unix fallback launch gate only after watchdog ownership
|
||||
// is acknowledged. Strong backends assign containment atomically at creation.
|
||||
type Launch struct {
|
||||
Commit func() error
|
||||
Dispose func()
|
||||
}
|
||||
|
||||
type Group interface {
|
||||
Name() string
|
||||
Prepare(*exec.Cmd) (*Launch, error)
|
||||
Release(int) error
|
||||
Close(context.Context) error
|
||||
}
|
||||
|
||||
var configured = struct {
|
||||
sync.RWMutex
|
||||
opts Options
|
||||
}{opts: Options{Mode: "auto", MaxProcesses: 256, MemoryMaxBytes: 2 << 30}}
|
||||
|
||||
func normalize(o Options) (Options, error) {
|
||||
if o.Mode == "" {
|
||||
o.Mode = "auto"
|
||||
}
|
||||
if o.Mode != "auto" && o.Mode != "required" && o.Mode != "process_group" {
|
||||
return o, fmt.Errorf("invalid process isolation mode %q", o.Mode)
|
||||
}
|
||||
if o.MaxProcesses == 0 {
|
||||
o.MaxProcesses = 256
|
||||
}
|
||||
if o.MemoryMaxBytes == 0 {
|
||||
o.MemoryMaxBytes = 2 << 30
|
||||
}
|
||||
if o.MaxProcesses < 1 || o.MaxProcesses > 65535 || o.MemoryMaxBytes < 0 || o.CPUQuotaMicros < 0 {
|
||||
return o, fmt.Errorf("invalid process isolation resource limits")
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Configure validates deployment before accepting any tasks. An explicit root
|
||||
// or required mode fails closed; it never silently falls back after an error.
|
||||
func Configure(o Options) error {
|
||||
var err error
|
||||
o, err = normalize(o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = configurePlatform(&o); err != nil {
|
||||
return err
|
||||
}
|
||||
configured.Lock()
|
||||
configured.opts = o
|
||||
configured.Unlock()
|
||||
return nil
|
||||
}
|
||||
func New(id string) (Group, error) {
|
||||
configured.RLock()
|
||||
o := configured.opts
|
||||
configured.RUnlock()
|
||||
return NewWithOptions(id, o)
|
||||
}
|
||||
func NewWithOptions(id string, o Options) (Group, error) {
|
||||
var err error
|
||||
o, err = normalize(o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newPlatformGroup(id, o)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//go:build !windows
|
||||
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testID() string {
|
||||
return fmt.Sprintf("%08x-1111-4111-8111-%012x", os.Getpid(), uint64(time.Now().UnixNano())&0xffffffffffff)
|
||||
}
|
||||
func testOptions() Options {
|
||||
return Options{CgroupRoot: os.Getenv("CSAI_TEST_CGROUP_ROOT"), MaxProcesses: 64, MemoryMaxBytes: 256 << 20}
|
||||
}
|
||||
func closeTestGroup(t *testing.T, g Group) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := g.Close(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
func startTestCommand(g Group, command string) (*exec.Cmd, error) {
|
||||
cmd := exec.Command("sh", "-c", command)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
launch, err := g.Prepare(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer launch.Dispose()
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = launch.Commit(); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return nil, err
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
func readPID(t *testing.T, path string) int {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||
if err == nil && pid > 0 {
|
||||
return pid
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("no PID written to %s", path)
|
||||
return 0
|
||||
}
|
||||
func waitGone(t *testing.T, pid int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if syscall.Kill(pid, 0) == syscall.ESRCH {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("PID %d survived cleanup", pid)
|
||||
}
|
||||
func TestGuardianReapsAfterOwnerSIGKILL(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "pid")
|
||||
owner := exec.Command(os.Args[0], "-test.run=^TestGuardianOwnerHelper$")
|
||||
owner.Env = append(os.Environ(), "CSAI_GUARD_TEST_OWNER=1", "CSAI_GUARD_TEST_PID="+pidPath)
|
||||
if err := owner.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = owner.Process.Kill(); _ = owner.Wait() }()
|
||||
pid := readPID(t, pidPath)
|
||||
if err := owner.Process.Kill(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = owner.Wait()
|
||||
waitGone(t, pid)
|
||||
}
|
||||
func TestGuardianOwnerHelper(t *testing.T) {
|
||||
if os.Getenv("CSAI_GUARD_TEST_OWNER") != "1" {
|
||||
t.Skip("subprocess helper")
|
||||
}
|
||||
g, err := NewWithOptions(testID(), testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := fmt.Sprintf("echo $$ > %q; exec sleep 300", os.Getenv("CSAI_GUARD_TEST_PID"))
|
||||
cmd, err := startTestCommand(g, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go cmd.Wait()
|
||||
select {}
|
||||
}
|
||||
func TestGroupCloseAndAdmission(t *testing.T) {
|
||||
g, err := NewWithOptions(testID(), testOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeTestGroup(t, g)
|
||||
cmd, err := startTestCommand(g, "exec sleep 300")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reaped := make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(reaped) }()
|
||||
closeTestGroup(t, g)
|
||||
<-reaped
|
||||
waitGone(t, cmd.Process.Pid)
|
||||
if _, err = g.Prepare(exec.Command("sh", "-c", "true")); err == nil {
|
||||
t.Fatal("closed containment accepted a command")
|
||||
}
|
||||
}
|
||||
func TestLaunchGateOwnerDisappearsBeforeCommit(t *testing.T) {
|
||||
if runtime.GOOS == "linux" && testOptions().CgroupRoot != "" {
|
||||
t.Skip("cgroup assignment is atomic without a gate")
|
||||
}
|
||||
g, err := NewWithOptions(testID(), Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeTestGroup(t, g)
|
||||
file := filepath.Join(t.TempDir(), "should-not-exist")
|
||||
cmd := exec.Command("sh", "-c", fmt.Sprintf("echo escaped > %q", file))
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
launch, err := g.Prepare(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launch.Dispose() // simulate owner crashing before watchdog registration
|
||||
_ = cmd.Wait()
|
||||
if _, err = os.Stat(file); !os.IsNotExist(err) {
|
||||
t.Fatal("unregistered child executed user code")
|
||||
}
|
||||
}
|
||||
func TestRequiredIsolationFailsClosed(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows has Job Objects")
|
||||
}
|
||||
if _, err := NewWithOptions(testID(), Options{Mode: "required"}); err == nil {
|
||||
t.Fatal("required isolation silently downgraded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build windows
|
||||
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"golang.org/x/sys/windows"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWindowsJobOwnerHelper(t *testing.T) {
|
||||
if os.Getenv("CSAI_JOB_OWNER") != "1" {
|
||||
t.Skip("subprocess helper")
|
||||
}
|
||||
g, err := NewWithOptions(fmt.Sprintf("test-%d-%d", os.Getpid(), time.Now().UnixNano()), Options{Mode: "required"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestWindowsJobPayload$")
|
||||
cmd.Env = append(os.Environ(), "CSAI_JOB_PAYLOAD=1")
|
||||
launch, err := g.Prepare(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer launch.Dispose()
|
||||
if err = cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = launch.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go cmd.Wait()
|
||||
select {}
|
||||
}
|
||||
func TestWindowsJobPayload(t *testing.T) {
|
||||
if os.Getenv("CSAI_JOB_PAYLOAD") != "1" {
|
||||
t.Skip("subprocess helper")
|
||||
}
|
||||
if err := os.WriteFile(os.Getenv("CSAI_JOB_PIDFILE"), []byte(strconv.Itoa(os.Getpid())), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(300 * time.Second)
|
||||
}
|
||||
func TestWindowsJobReapsAfterOwnerKilled(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "pid")
|
||||
owner := exec.Command(os.Args[0], "-test.run=^TestWindowsJobOwnerHelper$")
|
||||
owner.Env = append(os.Environ(), "CSAI_JOB_OWNER=1", "CSAI_JOB_PIDFILE="+file)
|
||||
if err := owner.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = owner.Process.Kill(); _ = owner.Wait() }()
|
||||
var pid int
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
data, _ := os.ReadFile(file)
|
||||
pid, _ = strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if pid > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if pid == 0 {
|
||||
t.Fatal("job child did not start")
|
||||
}
|
||||
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
_ = owner.Process.Kill()
|
||||
_ = owner.Wait()
|
||||
event, err := windows.WaitForSingleObject(handle, 5000)
|
||||
if err != nil || event != windows.WAIT_OBJECT_0 {
|
||||
t.Fatalf("child survived owner death: %d %v", event, err)
|
||||
}
|
||||
}
|
||||
func TestWindowsJobClose(t *testing.T) {
|
||||
g, err := NewWithOptions(fmt.Sprintf("test-%d-%d", os.Getpid(), time.Now().UnixNano()), Options{Mode: "required", CPUQuotaMicros: 100000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err = g.Close(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = g.Prepare(exec.Command("cmd.exe", "/c", "exit")); err == nil {
|
||||
t.Fatal("closed job admitted a process")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//go:build linux
|
||||
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var rootLock *os.File // retained until server exit; never inherited by commands
|
||||
|
||||
func configurePlatform(o *Options) error {
|
||||
if o.CgroupRoot == "" {
|
||||
if o.Mode == "required" {
|
||||
return fmt.Errorf("required isolation needs security.process_isolation.cgroup_root")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if o.Mode == "process_group" {
|
||||
return fmt.Errorf("cgroup_root cannot be combined with process_group mode")
|
||||
}
|
||||
if o.CgroupRoot == "auto" {
|
||||
data, err := os.ReadFile("/proc/self/cgroup")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "0::") {
|
||||
o.CgroupRoot = filepath.Join("/sys/fs/cgroup", strings.TrimPrefix(line, "0::"))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
root, err := validateRoot(o.CgroupRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.CgroupRoot = root
|
||||
// An exclusive host-side lock prevents one server's recovery sweep from
|
||||
// killing tasks owned by another server using the same delegated root.
|
||||
hash := sha256.Sum256([]byte(root))
|
||||
lockPath := filepath.Join(os.TempDir(), fmt.Sprintf("cyberstrike-cgroup-%d-%x.lock", os.Getuid(), hash[:12]))
|
||||
fd, err := unix.Open(lockPath, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lock := os.NewFile(uintptr(fd), lockPath)
|
||||
if err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
lock.Close()
|
||||
return fmt.Errorf("cgroup root is already owned: %w", err)
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
lock.Close()
|
||||
}
|
||||
}()
|
||||
// cgroup v2 requires the delegated parent to have no processes before
|
||||
// domain controllers can be enabled. Move only this server, never outsiders.
|
||||
data, err := os.ReadFile(filepath.Join(root, "cgroup.procs"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pid := range strings.Fields(string(data)) {
|
||||
if pid != strconv.Itoa(os.Getpid()) {
|
||||
return fmt.Errorf("delegated root contains another process %s", pid)
|
||||
}
|
||||
}
|
||||
if len(strings.Fields(string(data))) > 0 {
|
||||
supervisor := filepath.Join(root, "supervisor")
|
||||
if err = os.Mkdir(supervisor, 0700); err != nil && !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(supervisor, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = os.WriteFile(filepath.Join(root, "cgroup.subtree_control"), []byte("+cpu +memory +pids"), 0600); err != nil {
|
||||
return fmt.Errorf("delegate cpu, memory and pids controllers: %w", err)
|
||||
}
|
||||
// Recover only our names under the exclusively owned root. No PID replay.
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && validTaskName(entry.Name()) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err = killAndRemoveCgroup(ctx, filepath.Join(root, entry.Name()))
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("recover %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
rootLock = lock
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoot(root string) (string, error) {
|
||||
if !filepath.IsAbs(root) {
|
||||
return "", fmt.Errorf("cgroup root must be absolute")
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
resolved, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if root != resolved || root == "/sys/fs/cgroup" || root == "/" {
|
||||
return "", fmt.Errorf("use a dedicated delegated cgroup, not the hierarchy root or a symlink")
|
||||
}
|
||||
var st unix.Statfs_t
|
||||
if err = unix.Statfs(root, &st); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if st.Type != unix.CGROUP2_SUPER_MAGIC {
|
||||
return "", fmt.Errorf("%s is not cgroup v2", root)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
func validTaskName(name string) bool {
|
||||
if !strings.HasPrefix(name, "task-") || len(name) != 41 {
|
||||
return false
|
||||
}
|
||||
for _, c := range name[5:] {
|
||||
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type cgroupGroup struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
dir *os.File
|
||||
watcher *watchdog
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newPlatformGroup(id string, o Options) (Group, error) {
|
||||
if o.CgroupRoot == "" {
|
||||
if o.Mode == "required" {
|
||||
return nil, fmt.Errorf("required isolation has no delegated cgroup root")
|
||||
}
|
||||
return newUnixGroup()
|
||||
}
|
||||
root, err := validateRoot(o.CgroupRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := "task-" + id
|
||||
if !validTaskName(name) {
|
||||
return nil, fmt.Errorf("invalid task run ID")
|
||||
}
|
||||
path := filepath.Join(root, name)
|
||||
if err = os.Mkdir(path, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
limits := map[string]string{"pids.max": strconv.Itoa(o.MaxProcesses), "memory.max": strconv.FormatInt(o.MemoryMaxBytes, 10), "memory.oom.group": "1"}
|
||||
if o.CPUQuotaMicros > 0 {
|
||||
limits["cpu.max"] = fmt.Sprintf("%d 100000", o.CPUQuotaMicros)
|
||||
}
|
||||
for file, value := range limits {
|
||||
if err = os.WriteFile(filepath.Join(path, file), []byte(value), 0600); err != nil {
|
||||
return nil, fmt.Errorf("set %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(path, "cgroup.kill")); err != nil {
|
||||
return nil, fmt.Errorf("cgroup.kill requires Linux 5.14+: %w", err)
|
||||
}
|
||||
dir, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := &cgroupGroup{path: path, dir: dir}
|
||||
w, err := startWatchdog(watchRequest{Name: "cgroup", Path: path}, func() {
|
||||
// A guardian crash is also fail-closed while the owner is still alive.
|
||||
_ = os.WriteFile(filepath.Join(path, "cgroup.kill"), []byte("1"), 0600)
|
||||
})
|
||||
if err != nil {
|
||||
dir.Close()
|
||||
return nil, err
|
||||
}
|
||||
g.watcher = w
|
||||
success = true
|
||||
return g, nil
|
||||
}
|
||||
func (g *cgroupGroup) Name() string { return "cgroup_v2" }
|
||||
func (g *cgroupGroup) Prepare(cmd *exec.Cmd) (*Launch, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return nil, fmt.Errorf("cgroup is closed")
|
||||
}
|
||||
if _, err := g.watcher.send(watchRequest{Op: "ping"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
// clone3(CLONE_INTO_CGROUP), not a racy write of a newly started PID.
|
||||
cmd.SysProcAttr.UseCgroupFD = true
|
||||
cmd.SysProcAttr.CgroupFD = int(g.dir.Fd())
|
||||
return &Launch{Commit: func() error { return nil }, Dispose: func() {}}, nil
|
||||
}
|
||||
func (g *cgroupGroup) Release(pid int) error { return nil }
|
||||
func (g *cgroupGroup) Close(ctx context.Context) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.closed = true
|
||||
if g.dir == nil {
|
||||
return nil
|
||||
}
|
||||
if err := killAndRemoveCgroup(ctx, g.path); err != nil {
|
||||
return err
|
||||
}
|
||||
watchErr := g.watcher.close()
|
||||
err := errors.Join(watchErr, g.dir.Close())
|
||||
g.dir = nil
|
||||
return err
|
||||
}
|
||||
func killAndRemoveCgroup(ctx context.Context, path string) error {
|
||||
if err := os.WriteFile(filepath.Join(path, "cgroup.kill"), []byte("1"), 0600); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for {
|
||||
data, err := os.ReadFile(filepath.Join(path, "cgroup.events"))
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(string(data), "populated 0") {
|
||||
return removeCgroupTree(path)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
func removeCgroupTree(path string) error {
|
||||
entries, err := os.ReadDir(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
if err = removeCgroupTree(filepath.Join(path, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
err = os.Remove(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
func guardianMain(dec *json.Decoder, enc *json.Encoder) error {
|
||||
var req watchRequest
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Name == "process_group" {
|
||||
return groupGuardian(dec, enc)
|
||||
}
|
||||
if req.Name != "cgroup" || !validTaskName(filepath.Base(req.Path)) {
|
||||
return fmt.Errorf("invalid cgroup guardian")
|
||||
}
|
||||
if _, err := validateRoot(req.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
return serveGuardian(dec, enc, func(r watchRequest) error {
|
||||
if r.Op != "ping" {
|
||||
return fmt.Errorf("unknown command")
|
||||
}
|
||||
return nil
|
||||
}, func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return killAndRemoveCgroup(ctx, req.Path)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func configurePlatform(o *Options) error {
|
||||
if o.Mode == "required" || o.CgroupRoot != "" {
|
||||
return fmt.Errorf("kernel task containment is unavailable on this OS; use a Linux cgroup deployment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func newPlatformGroup(id string, o Options) (Group, error) {
|
||||
if err := configurePlatform(&o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newUnixGroup()
|
||||
}
|
||||
func guardianMain(dec *json.Decoder, enc *json.Encoder) error {
|
||||
var req watchRequest
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Name != "process_group" {
|
||||
return fmt.Errorf("unsupported guardian")
|
||||
}
|
||||
return groupGuardian(dec, enc)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//go:build windows
|
||||
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func configurePlatform(o *Options) error {
|
||||
if o.CgroupRoot != "" {
|
||||
return fmt.Errorf("cgroups are Linux-only")
|
||||
}
|
||||
if o.Mode == "process_group" {
|
||||
return fmt.Errorf("Windows tasks require Job Object containment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func configureGuardian(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
func gatedChildMain(args []string) error {
|
||||
return fmt.Errorf("Unix launch gates are unavailable on Windows")
|
||||
}
|
||||
|
||||
type jobGroup struct {
|
||||
mu sync.Mutex
|
||||
job windows.Handle
|
||||
parent windows.Handle
|
||||
watcher *watchdog
|
||||
closed bool
|
||||
broken bool
|
||||
}
|
||||
|
||||
func newPlatformGroup(id string, o Options) (Group, error) {
|
||||
if err := configurePlatform(&o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := "Local\\CyberStrikeAI-" + id
|
||||
g := &jobGroup{}
|
||||
w, err := startWatchdog(watchRequest{Name: name, Options: o}, func() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.broken = true
|
||||
if !g.closed && g.job != 0 {
|
||||
_ = windows.TerminateJobObject(g.job, 1)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fail := func(err error) (Group, error) { w.close(); return nil, err }
|
||||
namePtr, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("OpenJobObjectW")
|
||||
h, _, callErr := proc.Call(0x0004|0x0008, 0, uintptr(unsafe.Pointer(namePtr)))
|
||||
if h == 0 {
|
||||
return fail(callErr)
|
||||
}
|
||||
parent, err := windows.OpenProcess(windows.PROCESS_CREATE_PROCESS|windows.PROCESS_DUP_HANDLE, false, uint32(w.cmd.Process.Pid))
|
||||
if err != nil {
|
||||
windows.CloseHandle(windows.Handle(h))
|
||||
return fail(err)
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.broken {
|
||||
windows.CloseHandle(parent)
|
||||
windows.CloseHandle(windows.Handle(h))
|
||||
return fail(fmt.Errorf("job guardian exited during setup"))
|
||||
}
|
||||
g.job = windows.Handle(h)
|
||||
g.parent = parent
|
||||
g.watcher = w
|
||||
return g, nil
|
||||
}
|
||||
func (g *jobGroup) Name() string { return "windows_job" }
|
||||
func (g *jobGroup) Prepare(cmd *exec.Cmd) (*Launch, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed || g.broken {
|
||||
return nil, fmt.Errorf("job is closed")
|
||||
}
|
||||
if _, err := g.watcher.send(watchRequest{Op: "ping"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
// Windows inherits the job at CreateProcess time from this parent. The
|
||||
// guardian joined the job BEFORE acknowledging readiness, closing the
|
||||
// Start-then-Assign race and its suspended-process crash window.
|
||||
cmd.SysProcAttr.ParentProcess = syscall.Handle(g.parent)
|
||||
return &Launch{Commit: func() error { return nil }, Dispose: func() {}}, nil
|
||||
}
|
||||
func (g *jobGroup) Release(pid int) error { return nil }
|
||||
func (g *jobGroup) Close(ctx context.Context) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.closed {
|
||||
return nil
|
||||
}
|
||||
if err := windows.TerminateJobObject(g.job, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
type accounting struct {
|
||||
TotalUser, TotalKernel, PeriodUser, PeriodKernel int64
|
||||
PageFaults, TotalProcesses, ActiveProcesses, Terminated uint32
|
||||
}
|
||||
for {
|
||||
var info accounting
|
||||
if err := windows.QueryInformationJobObject(g.job, windows.JobObjectBasicAccountingInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.ActiveProcesses == 0 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
g.closed = true
|
||||
return errors.Join(g.watcher.close(), windows.CloseHandle(g.parent), windows.CloseHandle(g.job))
|
||||
}
|
||||
func guardianMain(dec *json.Decoder, enc *json.Encoder) error {
|
||||
var req watchRequest
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := windows.UTF16PtrFromString(req.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
job, err := windows.CreateJobObject(nil, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(job)
|
||||
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_ACTIVE_PROCESS | windows.JOB_OBJECT_LIMIT_JOB_MEMORY
|
||||
limits.BasicLimitInformation.ActiveProcessLimit = uint32(req.Options.MaxProcesses + 1)
|
||||
limits.JobMemoryLimit = uintptr(req.Options.MemoryMaxBytes)
|
||||
if _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Options.CPUQuotaMicros > 0 {
|
||||
rate := req.Options.CPUQuotaMicros / 10 / int64(runtime.NumCPU())
|
||||
if rate < 1 {
|
||||
rate = 1
|
||||
}
|
||||
if rate > 10000 {
|
||||
rate = 10000
|
||||
}
|
||||
cpu := struct{ Flags, Rate uint32 }{Flags: 1 | 4, Rate: uint32(rate)}
|
||||
if _, err = windows.SetInformationJobObject(job, windows.JobObjectCpuRateControlInformation, uintptr(unsafe.Pointer(&cpu)), uint32(unsafe.Sizeof(cpu))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = windows.AssignProcessToJobObject(job, windows.CurrentProcess()); err != nil {
|
||||
return err
|
||||
}
|
||||
return serveGuardian(dec, enc, func(req watchRequest) error {
|
||||
if req.Op != "ping" {
|
||||
return fmt.Errorf("unknown guardian command")
|
||||
}
|
||||
return nil
|
||||
}, func() error { return windows.TerminateJobObject(job, uint32(os.Getpid())) })
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package processguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const guardianArg = "--cyberstrike-internal-process-guardian"
|
||||
const childArg = "--cyberstrike-internal-process-child"
|
||||
|
||||
type watchRequest struct {
|
||||
Op string
|
||||
PID int
|
||||
Path string
|
||||
Name string
|
||||
Options Options
|
||||
}
|
||||
type watchReply struct {
|
||||
PID int
|
||||
Error string
|
||||
}
|
||||
|
||||
type watchdog struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
input *os.File
|
||||
output *os.File
|
||||
encoder *json.Encoder
|
||||
decoder *json.Decoder
|
||||
done chan struct{}
|
||||
failed error
|
||||
}
|
||||
|
||||
// The re-exec modes run before application configuration, listeners or MCP
|
||||
// initialization. Stdin is a private pipe; no network control port is opened.
|
||||
func init() {
|
||||
if len(os.Args) < 2 {
|
||||
return
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case guardianArg:
|
||||
err := guardianMain(json.NewDecoder(os.Stdin), json.NewEncoder(os.Stdout))
|
||||
if err != nil {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(watchReply{Error: err.Error()})
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
case childArg:
|
||||
if err := gatedChildMain(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func startWatchdog(req watchRequest, onExit func()) (*watchdog, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(exe, guardianArg)
|
||||
configureGuardian(cmd)
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
in.Close()
|
||||
return nil, err
|
||||
}
|
||||
// No inherited stderr pipe that could keep a caller's output reader alive.
|
||||
if err = cmd.Start(); err != nil {
|
||||
in.Close()
|
||||
out.Close()
|
||||
return nil, err
|
||||
}
|
||||
w := &watchdog{cmd: cmd, input: in.(*os.File), output: out.(*os.File), done: make(chan struct{})}
|
||||
w.encoder = json.NewEncoder(w.input)
|
||||
w.decoder = json.NewDecoder(w.output)
|
||||
// The guardian only exits after EOF or failure; exit invalidates all RPCs.
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(w.done)
|
||||
if onExit != nil {
|
||||
onExit()
|
||||
}
|
||||
}()
|
||||
req.Op = "init"
|
||||
if _, err = w.send(req); err != nil {
|
||||
w.close()
|
||||
return nil, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *watchdog) send(req watchRequest) (watchReply, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.failed != nil {
|
||||
return watchReply{}, w.failed
|
||||
}
|
||||
type response struct {
|
||||
reply watchReply
|
||||
err error
|
||||
}
|
||||
result := make(chan response, 1)
|
||||
// Pipe deadlines are not supported by every Windows pipe implementation.
|
||||
// On timeout kill the helper and close both ends to release this goroutine.
|
||||
go func() {
|
||||
if err := w.encoder.Encode(req); err != nil {
|
||||
result <- response{err: err}
|
||||
return
|
||||
}
|
||||
var reply watchReply
|
||||
err := w.decoder.Decode(&reply)
|
||||
if err == nil && reply.Error != "" {
|
||||
err = fmt.Errorf("process guardian: %s", reply.Error)
|
||||
}
|
||||
result <- response{reply, err}
|
||||
}()
|
||||
select {
|
||||
case r := <-result:
|
||||
w.failed = r.err
|
||||
return r.reply, r.err
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = w.cmd.Process.Kill()
|
||||
_ = w.input.Close()
|
||||
_ = w.output.Close()
|
||||
w.failed = fmt.Errorf("process guardian acknowledgement timed out")
|
||||
return watchReply{}, w.failed
|
||||
}
|
||||
}
|
||||
|
||||
func (w *watchdog) close() error {
|
||||
w.mu.Lock()
|
||||
_ = w.input.Close()
|
||||
w.mu.Unlock()
|
||||
select {
|
||||
case <-w.done:
|
||||
case <-time.After(3 * time.Second):
|
||||
_ = w.cmd.Process.Kill()
|
||||
select {
|
||||
case <-w.done:
|
||||
case <-time.After(3 * time.Second):
|
||||
return fmt.Errorf("process guardian did not exit")
|
||||
}
|
||||
}
|
||||
_ = w.output.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveGuardian(dec *json.Decoder, enc *json.Encoder, apply func(watchRequest) error, cleanup func() error) error {
|
||||
defer cleanup()
|
||||
if err := enc.Encode(watchReply{PID: os.Getpid()}); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
var req watchRequest
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
err := apply(req)
|
||||
reply := watchReply{PID: os.Getpid()}
|
||||
if err != nil {
|
||||
reply.Error = err.Error()
|
||||
}
|
||||
if err := enc.Encode(reply); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user