368 lines
11 KiB
Go
368 lines
11 KiB
Go
//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
|
||
}
|