sandbox/sandbox_darwin.go
Star f9dcf07ba4 first version
supported macOS、linux
2026-03-23 00:35:27 +08:00

368 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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
}