sandbox/sandbox_darwin.go

368 lines
11 KiB
Go
Raw Normal View History

2026-03-23 00:35:27 +08:00
//go:build darwin
// sandbox_darwin.go v1.6
package sandbox
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"apigo.cc/gojs"
"github.com/ssgo/log"
"github.com/ssgo/u"
)
var defaultVolumes = []Volume{
{Source: "/bin", ReadOnly: true},
{Source: "/usr/bin", ReadOnly: true},
{Source: "/usr/lib", ReadOnly: true},
{Source: "/usr/share", ReadOnly: true},
{Source: "/usr/local/bin", ReadOnly: true},
{Source: "/usr/local/lib", ReadOnly: true},
{Source: "/usr/local/share", ReadOnly: true},
{Source: "/usr/local/include", ReadOnly: true},
{Source: "/usr/local/Cellar", ReadOnly: true},
{Source: "/private/var/folders"}, // 必须存储临时模块缓存、Python .pyc 的地方
{Source: "/System/Library", ReadOnly: true}, // 必须:系统级 Frameworks (OpenSSL, Foundation)
{Source: "/dev/fd"}, // 很多 shell 操作和 Python 的 subprocess 重定向强依赖它。
{Source: "/dev/null"},
{Source: "/dev/zero"},
{Source: "/dev/stdout"},
{Source: "/dev/stderr"},
{Source: "/dev/random", ReadOnly: true},
{Source: "/dev/urandom", ReadOnly: true},
}
func Start(cfg *Config) (*Sandbox, error) {
s := &Sandbox{
config: cfg,
mountedList: []string{},
status: "created",
extra: map[string]any{},
}
s.lock.Lock()
defer s.lock.Unlock()
if s.status != "created" {
return s, gojs.Err("sandbox is already used")
}
s.id = getId()
s.status = "starting"
s.startTime = time.Now().Unix()
// 1. 物理环境准备
s.root = filepath.Join(pluginConfig.Root, s.id)
os.MkdirAll(s.root, 0755)
sandboxTmp := filepath.Join(os.TempDir(), s.id, ".tmp")
os.MkdirAll(sandboxTmp, 0777)
// 2. 优先级逻辑WorkDir 处理
if s.config.ProjectDir != "" {
s.workDir = u.GetAbsFilename(s.config.ProjectDir)
if s.config.WorkDir != "" {
log.DefaultLogger.Warning("[Sandbox] both HostWorkDir and WorkDir provided. Using HostWorkDir.")
}
} else if s.config.WorkDir != "" {
s.workDir = u.GetAbsFilename(s.config.WorkDir)
} else {
s.workDir = filepath.Join(s.root, ".workdir")
os.MkdirAll(s.workDir, 0755)
// log.DefaultLogger.Warning("[Sandbox] no WorkDir specified. Using isolated temporary directory as WorkDir.")
}
if !u.FileExists(s.workDir) {
os.MkdirAll(s.workDir, 0755)
}
vs := NewVolumes()
vs.Add(defaultVolumes...)
vs.Add(s.config.Volumes...)
vs.Add(Volume{Source: s.workDir}) // 添加工作目录
// 3. 不支持功能警告
if s.config.Limits.Cpu > 0 || s.config.Limits.Mem > 0 {
log.DefaultLogger.Warning("[Sandbox] resource 'Limits' are currently not enforced on Darwin.")
}
// 4. 环境变量
initEnv := map[string]string{
"PATH": "/usr/local/bin:/usr/bin:/bin",
"LANG": os.Getenv("LANG"),
"HOME": s.workDir,
"TMPDIR": sandboxTmp,
"PYTHONUNBUFFERED": "1",
}
addEnv := func(k, v string) {
initEnv[k] = os.Expand(v, func(varName string) string {
return initEnv[varName]
})
}
for k, v := range s.config.Envs {
addEnv(k, v)
}
// 处理 runtime
runtimeListLock.RLock()
rt := runtimeList[s.config.Runtime.Language]
runtimeListLock.RUnlock()
if rt != nil {
rtCfg := pluginConfig.Runtime[s.config.Runtime.Language]
if rtCfg == nil {
rtCfg = &RuntimeConfig{}
}
if rtCfg.Root == "" {
rtCfg.Root = filepath.Join(pluginConfig.Root, s.config.Runtime.Language)
}
runtimePath := filepath.Join(rtCfg.Root, "runtime", s.config.Runtime.Version)
venvPath := filepath.Join(rtCfg.Root, "venv", fmt.Sprintf("%s_%s", s.config.Runtime.Version, s.config.Runtime.Venv))
if rtsbCfg, err := rt.Check(runtimePath, venvPath, s.config.ProjectDir, s.uid, s.gid, rtCfg); err == nil {
if s.config.StartCmd == "" && rtsbCfg.StartCmd != "" {
s.config.StartCmd = rtsbCfg.StartCmd
}
for k, v := range rtsbCfg.Envs {
addEnv(k, v)
}
// 挂载运行时环境
if u.FileExists(runtimePath) {
vs.Add(Volume{Source: runtimePath, ReadOnly: true})
}
if u.FileExists(venvPath) {
vs.Add(Volume{Source: venvPath, ReadOnly: false})
}
} else {
s._cleanup()
return s, err
}
} else {
s._cleanup()
return s, gojs.Err("runtime not found for language " + s.config.Runtime.Language)
}
if s.config.StartCmd == "" {
s._cleanup()
return s, gojs.Err("start cmd is empty")
}
// 5. 生成 Seatbelt 策略
sbProfile := s.generateSeatbelt(vs)
sbPath := filepath.Join(s.root, "sandbox.sb")
os.WriteFile(sbPath, []byte(sbProfile), 0644)
// 6. 启动命令
s.cmd = exec.Command("sandbox-exec", append([]string{"-f", sbPath, s.config.StartCmd}, s.config.StartArgs...)...)
s.cmd.Dir = s.workDir
s.cmd.Env = make([]string, 0, len(initEnv))
for k, v := range initEnv {
s.cmd.Env = append(s.cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
if fdOut, err := os.OpenFile(filepath.Join(s.workDir, "stdout.log"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC|os.O_SYNC, 0644); err == nil {
s.lastLogFd = fdOut
s.cmd.Stdout = fdOut
} else {
s.cmd.Stdout = os.Stdout
}
if fdErr, err := os.OpenFile(filepath.Join(s.workDir, "stderr.log"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC|os.O_SYNC, 0644); err == nil {
s.errorLogFd = fdErr
s.cmd.Stderr = fdErr
} else {
s.cmd.Stderr = os.Stderr
}
if err := s.cmd.Start(); err != nil {
log.DefaultLogger.Error("[Sandbox] failed to start process", "err", err.Error(), "cmd", s.config.StartCmd, "args", s.config.StartArgs)
s.errorLogFd.WriteString(fmt.Sprintf("Failed to start process: %s\n", err.Error()))
s._cleanup()
return s, err
}
s.status = "running"
s.pid = s.cmd.Process.Pid
u.Save(filepath.Join(s.root, ".state.json"), State{
Id: s.id,
Pid: s.pid,
StartTime: s.startTime,
WorkDir: s.workDir,
})
RegisterSandbox(s)
return s, nil
}
func (s *Sandbox) generateSeatbelt(vs *Volumes) string {
var sb strings.Builder
// 默认拒绝所有,仅允许根路径读取(无其他路径访问权限)
sb.WriteString("(version 1)\n(deny default)\n")
sb.WriteString("(allow file-read* (literal \"/\"))\n")
sb.WriteString("(allow file-read-metadata (literal \"/\"))\n") // 没有它 Python 无法完成路径溯源
// 挂载卷授权
for _, v := range vs.Get() {
mode := "file-read*"
if !v.ReadOnly {
mode += " file-write*"
}
typ := "subpath"
if fi := u.GetFileInfo(v.Source); fi != nil && !fi.IsDir {
typ = "literal" // 非文件夹
}
sb.WriteString(fmt.Sprintf("(allow %s (%s \"%s\"))\n", mode, typ, v.Source))
s.assertLog("Mount "+v.Source, nil, "mode", mode, "id", s.id, "pid", s.pid, "name", s.config.Name)
}
// 系统能力
sb.WriteString("(allow file-read-metadata)\n")
sb.WriteString("(allow sysctl-read)\n") // 能看到 CPU 型号、内核版本。Python 的 platform, multiprocessing.cpu_count() 需要它
sb.WriteString("(allow process*)\n")
sb.WriteString("(allow signal (target self))\n") // 允许向自己发送信号,无法像其他进程发送信号。
sb.WriteString("(allow ipc-posix-shm*)\n")
if len(s.config.Gpu.Devices) > 0 {
sb.WriteString("(allow iokit-open)\n") // 开启GPU权限
sb.WriteString("(allow iokit-open (iokit-user-client-class \"AppleGraphicsControlClient\"))\n")
sb.WriteString("(allow iokit-open (iokit-user-client-class \"AGXDeviceUserClient\"))\n") // 针对 Apple Silicon
}
// 入站:按需开启监听
for _, port := range s.config.Network.AllowListen {
sb.WriteString(fmt.Sprintf("(allow network-bind (local ip \"*:%d\"))\n", port))
sb.WriteString(fmt.Sprintf("(allow network-inbound (local ip \"*:%d\"))\n", port))
}
// 出站:按需开窗
if s.config.Network.AllowInternet || s.config.Network.AllowLocalNetwork || len(s.config.Network.AllowList) > 0 {
// 允许 DNS 和系统网络配置查询
sb.WriteString("(allow mach-lookup (global-name \"com.apple.dnssd.service\"))\n")
sb.WriteString("(allow mach-lookup (global-name \"com.apple.SystemConfiguration.configd\"))\n")
if s.config.Network.AllowInternet {
sb.WriteString("(allow network-outbound (remote ip \"*:*\"))\n")
} else if s.config.Network.AllowLocalNetwork {
// 因为无法精细控制,本地网络指 localhost 而非局域网
sb.WriteString("(allow network* (remote ip \"localhost:*\"))\n")
}
}
// 允许白名单访问
for _, ipport := range s.config.Network.AllowList {
// 受 Seatbelt 限制不支持指定IP仅支持限制端口
if a := strings.Split(ipport, ":"); len(a) >= 2 {
sb.WriteString(fmt.Sprintf("(allow network-outbound (remote ip \"*:%s\"))\n", a[len(a)-1]))
}
}
// 拒绝白名单访问
for _, ipport := range s.config.Network.BlockList {
// 受 Seatbelt 限制不支持指定IP仅限制端口
if a := strings.Split(ipport, ":"); len(a) >= 2 {
sb.WriteString(fmt.Sprintf("(deny network-outbound (remote ip \"*:%s\"))\n", a[len(a)-1]))
}
}
// 以下为“按需放行”的可选能力,默认注释以保安全
// 例如传入 "(allow mach-per-user-lookup)" // 仅当涉及复杂系统 UI 服务时开启,能访问剪切板、用户偏好设置等系统服务因此风险较高,默认关闭
for _, o := range s.config.ExtraOptions {
if (strings.HasPrefix(o, "(allow ") || strings.HasPrefix(o, "(deny ")) && strings.HasSuffix(o, ")") {
sb.WriteString(o + "\n")
}
}
// fmt.Println(" ====Seatbelt:", sb.String())
return sb.String()
}
func (s *Sandbox) Kill() error {
s.lock.Lock()
defer s.lock.Unlock()
if s.status != "running" && s.status != "starting" {
return gojs.Err("sandbox not running or starting")
}
s.status = "stopping"
return s._kill(syscall.SIGTERM)
}
func (s *Sandbox) Wait(timeout int64) (any, error) {
s.lock.Lock()
defer s.lock.Unlock()
if s.status != "running" && s.status != "stopping" {
return nil, gojs.Err("sandbox not running or stopping")
}
if s._alive() {
ch := make(chan error, 1)
go func() {
state, err := s._waitProcess()
if err == nil && state != nil {
s.status = "exited"
}
ch <- err
}()
select {
case _ = <-ch:
break
case <-time.After(time.Duration(timeout) * time.Millisecond):
s._kill(syscall.SIGKILL)
break
}
}
return s._cleanup()
}
func (s *Sandbox) Cleanup() (any, error) {
s.lock.Lock()
defer s.lock.Unlock()
return s._cleanup()
}
func (s *Sandbox) _cleanup() (any, error) {
s.log("cleaning up sandbox", "id", s.id, "name", s.config.Name)
if s.lastLogFd != nil {
s.lastLogFd.Close()
s.lastLogFd = nil
}
if s.errorLogFd != nil {
s.errorLogFd.Close()
s.errorLogFd = nil
}
// 存储日志(按起始天合并)
_, err := copyLog(s.workDir, s.startTime)
s.assertLog("Copy log to logs directory", err, "id", s.id, "name", s.config.Name, "workDir", s.workDir)
outLog := u.ReadFileN(filepath.Join(s.workDir, "stdout.log"))
errLog := u.ReadFileN(filepath.Join(s.workDir, "stderr.log"))
err = os.RemoveAll(s.root)
s.assertLog(fmt.Sprintf("Remove Sandbox %s", s.root), err, "id", s.id, "name", s.config.Name)
releaseId(s.id)
ReleaseSandbox(s.id)
var outData any = outLog
if strings.HasPrefix(outLog, "{") && strings.HasSuffix(outLog, "}") || strings.HasPrefix(outLog, "[") && strings.HasSuffix(outLog, "]") {
var data map[string]any
if err := json.Unmarshal([]byte(outLog), &data); err == nil {
outData = data
}
}
if errLog != "" {
return outData, errors.New(errLog)
}
return outData, nil
}
func (s *Sandbox) Status() Status {
s.lock.Lock()
defer s.lock.Unlock()
st := Status{Id: s.id, Pid: s.pid, Status: s.status, StartTime: s.startTime, Uptime: time.Now().Unix() - s.startTime}
st.Alive = s._alive()
return st
}