diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e3e65d --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.* +!.gitignore +go.sum +node_modules +package.json +env.yml +/data +release +build +pub +/c +/t +/tl +/linuxTestApp +/buildInit +/sandbox diff --git a/README.md b/README.md index a809a13..aad6d0e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,216 @@ # sandbox +## 项目概述 + +sandbox 是一个基于 Go 语言开发的轻量级沙箱环境,用于安全地执行代码,支持 Python 和 Node.js 等运行时。该项目主要用于低代码框架的测试和验证,提供了网络访问控制、资源限制等安全特性。 + +## 功能特点 + +- **多语言支持**:内置支持 Python 和 Node.js 运行时,其他运行时可以通过 RegisterRuntime 注册。 +- **网络控制**: + - 支持允许/阻止互联网访问 + - 支持允许特定端口监听 + - 支持 IP 白名单和黑名单 +- **资源限制**: + - CPU 使用率限制 + - 内存使用限制 + - SWAP 限制 + - /dev/shm 和 /tmp 大小限制 +- **环境配置**:支持自定义环境变量 +- **安全隔离**:提供安全的执行环境,防止恶意代码影响系统 +- **跨平台支持**:支持 Linux 和 macOS 操作系统 +- **状态管理**:支持沙箱状态持久化和恢复 + +## 项目架构 + +### 核心代码结构 + +``` +sandbox/ +├── plugin.go # 插件入口,注册 sandbox 模块到 gojs 框架 +├── sandbox.go # 定义沙箱的核心结构和配置 +├── master.go # 管理沙箱的生命周期和状态 +├── runtime.go # 运行时管理 +├── sandbox_linux.go # Linux 平台实现 +├── sandbox_darwin.go # macOS 平台实现 +├── pythonRuntime.go # Python 运行时实现 +├── nodejsRuntime.go # Node.js 运行时实现 +├── util.go # 工具函数 +├── base_test.js # 测试入口文件 +├── testcase/ # 测试用例目录 +│ ├── base_allow.py # 允许模式测试 +│ ├── base_deny.py # 拒绝模式测试 +│ ├── secret.py # 敏感信息测试 +│ └── base_allow.js # Node.js 环境测试 +└── README.md # 项目说明文档 +``` + +### 核心组件 + +1. **Plugin 模块**: + - 注册 sandbox 模块到 gojs 框架 + - 提供 Start、Fetch、Query 等 API 接口 + - 管理沙箱的启动和恢复 + +2. **Sandbox 核心**: + - 定义沙箱的配置结构(Config) + - 管理沙箱的状态和生命周期 + - 提供资源限制和网络控制功能 + +3. **运行时管理**: + - 支持 Python 和 Node.js 运行时 + - 处理不同运行时的启动和配置 + +4. **平台适配**: + - Linux 平台实现(sandbox_linux.go) + - macOS 平台实现(sandbox_darwin.go) + +## 技术实现 + +### 沙箱隔离机制 + +- **文件系统隔离**:使用挂载点和只读挂载 +- **网络隔离**:通过 namespace 机制控制网络访问 +- **资源限制**:使用 cgroups(Linux)或等效机制限制资源使用 +- **进程隔离**:创建独立的进程环境 + +### 核心 API + +| API | 说明 | +|-----|------| +| Start(config) | 创建并启动新的沙箱实例 | +| Fetch(id) | 根据 ID 获取沙箱实例 | +| Query(name) | 根据名称查询沙箱实例 | +| Sandbox.status() | 获取沙箱状态 | +| Sandbox.wait(timeout) | 等待沙箱执行完成 | +| Sandbox.kill() | 终止沙箱进程(适用于服务类或运行过久的场景,简单一次性场景不需要调用) | + +## 安装与使用 + +### 基本使用 + +1. 导入 sandbox 模块: + +```javascript +import sandbox from 'apigo.cc/gojs/sandbox' +``` + +2. 配置并启动沙箱: + +```javascript +const sb = sandbox.start({ + projectDir: "data", + runtime: { language: "python", venv: "test", version: "3.12" }, + startArgs: ["-c", "print('Hello, sandbox!')"], + network: { + allowInternet: true, + allowListen: [19999] + }, + limits: { cpu: 0.5, mem: 0.2 } +}) +``` + +3. 等待执行完成: + +```javascript +sb.wait(10000) // 等待最多 10 秒 +``` + +4. 查看执行状态: + +```javascript +const status = sb.status() +console.log(status) +``` + +## 配置选项 + +| 选项 | 类型 | 说明 | +|------|------|------| +| Name | string | 沙盒名称 | +| projectDir | string | 宿主机的工作目录(自动映射到沙盒内workDir) | +| workDir | string | 沙盒内的工作目录 | +| envs | map[string]string | 环境变量,支持$变量 | +| volumes | []Volume | 挂载列表 | +| limits | Limits | 资源限制 | +| network | struct | 网络配置 | +| gpu | struct | GPU 配置 | +| extraOptions | []string | 额外参数 | +| autoStart | bool | 是否自动启动沙盒进程 | +| startCmd | string | 启动命令 | +| startArgs | []string | 启动参数 | +| runtime | struct | 运行时配置 | +| noLog | bool | 是否不显示沙盒运行日志 | + +### 资源限制配置 + +| 选项 | 类型 | 说明 | +|------|------|------| +| Cpu | float64 | CPU 核心限制 (如 0.5) | +| Mem | float64 | 内存限制 (单位: GB) | +| Swap | float64 | SWAP 限制 (单位: GB) | +| Shm | uint | /dev/shm 大小 (单位: MB) | +| Tmp | uint | /tmp 大小 (单位: MB) | + +### 网络配置 + +| 选项 | 类型 | 说明 | +|------|------|------| +| AllowInternet | bool | 是否允许出站网络连接 | +| AllowLocalNetwork | bool | 是否允许访问本地网络 | +| AllowListen | []int | 允许监听端口列表 | +| AllowList | []string | 允许出站访问的 IP/端口 列表 | +| BlockList | []string | 拒绝访问的 IP/端口 列表 | + +## 测试说明 + +### 运行测试 + +```bash +go test -v . +``` + +### 测试用例说明 + +1. **base_allow.py**:测试允许模式下的功能,包括: + - 内存使用限制(128M 允许,512M 拒绝) + - CPU 使用率限制 + - 网络监听(允许特定端口) + - 网络访问(允许互联网访问,阻止特定 IP) + - 环境变量配置 + - 外部依赖安装(cowsay) + +2. **base_deny.py**:测试拒绝模式下的功能,包括: + - 网络监听(只允许特定端口) + - 网络访问(只允许白名单 IP 和端口) + +3. **secret.py**:测试敏感信息处理 + +4. **base_allow.js**:测试 Node.js 环境下的功能 + +### 压力测试 + +测试脚本还包含了压力测试,会并发执行多个沙箱实例,测试系统的稳定性和性能。 + +## 日志管理 + +沙盒内的stdout会自动重定向到日志文件,stderr也会被重定向到日志文件。 + +日志存储在 `[projectDir]/logs/` 目录中,包括: + +- 标准输出日志(stdout_*.log) +- 标准错误日志(stderr_*.log) + +## 注意事项 + +1. **macOS 限制**:macOS 系统不支持某些 Linux 特有的功能,如 cgroup 限制和 IP 过滤。测试脚本会自动检测操作系统并调整测试逻辑。 + +2. **资源限制**:在 macOS 上,资源限制(CPU 和内存)不会生效。 + +3. **网络限制**:在 macOS 上,网络细化限制(IP 过滤)不会生效。 + +4. **权限要求**:在 Linux 上,建议以 root 权限运行,否则也能运行但部分功能会受限。 + +## 许可证 + +本项目采用 MIT 许可证,详见 LICENSE 文件。 \ No newline at end of file diff --git a/base_test.js b/base_test.js new file mode 100644 index 0000000..acadc2d --- /dev/null +++ b/base_test.js @@ -0,0 +1,117 @@ +// base_test.js v1.0 测试基础功能(低代码框架测试入口) +import sandbox from 'apigo.cc/gojs/sandbox' +import co from 'apigo.cc/gojs/console' +import file from 'apigo.cc/gojs/file' +import rt from 'apigo.cc/gojs/runtime' +import u from 'apigo.cc/gojs/util' +import tt from 'testTool' + +function runTest(codefile) { + let baseConfig = { + projectDir: "data", + } + let user_config = "{}" + if (codefile.endsWith('.py')) { + baseConfig.runtime = { language: "python", venv: "test", version: "3.12" } + baseConfig.startArgs = ["-c", file.read(codefile)] + file.write('data/requirements.txt', 'cowsay') + user_config = file.read(codefile).match(/"""\s*TEST_CONFIG\s*([\s\S]*?)\s*"""/)[1] + } else if (codefile.endsWith('.js')) { + file.write('data/package.json', '{"dependencies":{"cowsay":"*"}}') + baseConfig.runtime = { language: "nodejs", version: "24" } + baseConfig.startArgs = ["-e", file.read(codefile)] + user_config = file.read(codefile).match(/\/\*\s*TEST_CONFIG\s*([\s\S]*?)\s*\*\//)[1] + } + let config = { ...baseConfig, ...u.unJson(user_config) } + if (rt.os() === 'darwin') { + // Mac 系统清除不支持的配置 + config.limits = {} + } + const name = config.name || codefile + + try { + file.remove('data/stdout.log') + file.remove('data/stderr.log') + const sb = sandbox.start(config) + if (err = getLastError()) throw new Error(`sandbox test ${name} start failed: ` + err) + + let st = sb.status() + if (st.status !== 'running') throw new Error(`sandbox test ${name} status is not running: ` + st.status) + + sb.wait(10000) + if (err = getLastError()) throw new Error(`sandbox test ${name} wait failed: ` + err) + + st = sb.status() + if (st.status !== 'exited') throw new Error(`sandbox test ${name} status is not exited: ` + st.status) + co.info(`sandbox test ${name} finished uptime ${st.uptime}`) + er = file.read('data/stderr.log') + if (er) throw new Error(er) + r = file.load('data/stdout.log') + if (!r || !r.testSuccess) throw new Error(`sandbox test ${name} failed`) + co.info(r) + return true + } catch (e) { + co.info(co.red(e.message), co.magenta(file.read('data/stderr.log'))) + co.println(co.bCyan('stdout'), co.cyan(file.read('data/stdout.log'))) + co.println(co.bMagenta('stderr'), co.magenta(file.read('data/stderr.log'))) + return false + } +} + +const TEST_COUNT_PER_TEST = 100 +const TEST_COUNT = 10 +function main() { + if (!runTest('testcase/base_allow.py')) return false + if (!runTest('testcase/base_deny.py')) return false + if (!runTest('testcase/secret.py')) return false + if (!runTest('testcase/base_allow.js')) return false + + // 压力测试 + let tasks = [] + for (let i = 0; i < TEST_COUNT_PER_TEST; i++) { + tasks.push([` +import sandbox from 'apigo.cc/gojs/sandbox' +import u from 'apigo.cc/gojs/util' +import co from 'apigo.cc/gojs/console' +const sb = sandbox.start({ + noLog: true, + startArgs: ["-c", "import time\\nprint(int(time.time() * 1000))"], + runtime: { language: "python", venv: "test", version: "3.12" }, +}) +u.len({ a: 1, b: 2 }) +if (err = getLastError()) throw new Error(err) +return sb.wait(10000) +`]) + } + + let totalOkNum = 0 + let totalFailNum = 0 + let totalTotalTime = 0 + for (let j = 0; j < TEST_COUNT; j++) { + co.info(`Dispatching ${tasks.length} tasks...`) + const startTime = Date.now() + // 执行并发任务 + let results = tt.runAll(tasks) + const totalTime = Date.now() - startTime + let countTotalTime = 0 + let okNum = 0 + let failNum = 0 + for (let i = 0; i < results.length; i++) { + const [data, err] = results[i] + if (!err) { + countTotalTime += Number(data.trim()) - startTime + okNum++ + } else { + failNum++ + co.info(co.red(`Task ${i} failed: ${err}`)) + } + } + totalOkNum += okNum + totalFailNum += failNum + totalTotalTime += countTotalTime + co.info(`All tasks finished in ${totalTime}ms, avg ${countTotalTime / okNum}ms, ${okNum} ok, ${failNum} fail`) + file.remove('/tmp/sandbox_tests') + } + co.info(`Total: ${totalOkNum} ok, ${totalFailNum} fail, avg ${totalTotalTime / totalOkNum}ms`) + return totalFailNum === 0 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4043d8 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module apigo.cc/gojs/sandbox + +go 1.25.0 + +require ( + apigo.cc/gojs v0.0.34 + apigo.cc/gojs/console v0.0.4 + apigo.cc/gojs/file v0.0.8 + apigo.cc/gojs/runtime v0.0.5 + apigo.cc/gojs/util v0.0.18 + github.com/ssgo/config v1.7.10 + github.com/ssgo/httpclient v1.7.9 + github.com/ssgo/log v1.7.11 + github.com/ssgo/u v1.7.24 +) + +require ( + github.com/ZZMarquis/gm v1.3.2 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/emmansun/gmsm v0.41.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect + github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect + github.com/obscuren/ecies v0.0.0-20150213224233-7c0f4a9b18d9 // indirect + github.com/ssgo/standard v1.7.8 // indirect + github.com/ssgo/tool v0.4.29 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/init.gz b/init.gz new file mode 100755 index 0000000..53de094 Binary files /dev/null and b/init.gz differ diff --git a/init/init.c b/init/init.c new file mode 100644 index 0000000..192395f --- /dev/null +++ b/init/init.c @@ -0,0 +1,342 @@ +// init.c v2.1 +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // 新增 + +// #define DEBUG(fmt, ...) printf("[DEBUG] " fmt "\n", ##__VA_ARGS__) +#define DEBUG(fmt, ...) do {} while (0) + +struct __attribute__((packed)) NetRule { + uint8_t ip[16]; + int8_t mask; + uint16_t port; + uint8_t is_v6; +}; + +uint8_t net_enabled = 0; +uint8_t allow_internet = 0; +uint8_t allow_local = 0; +uint32_t listen_cnt = 0, allow_cnt = 0, block_cnt = 0; +uint32_t *listen_ports = NULL; +struct NetRule *allow_rules = NULL, *block_rules = NULL; + +pid_t child_pid = -1; +volatile sig_atomic_t stop_monitor = 0; + +void safe_read(int fd, void *buf, size_t len) { + size_t offset = 0; + while (offset < len) { + ssize_t r = read(fd, (char *)buf + offset, len - offset); + if (r <= 0) exit(101); + offset += r; + } +} + +void handle_sig(int sig) { + if (sig == SIGCHLD) { + stop_monitor = 1; + } else if (child_pid > 0) { + kill(-child_pid, sig); + stop_monitor = 1; + } +} + +int is_ip_match(struct NetRule *rule, void *target_ip) { + if (rule->mask == -1) return memcmp(rule->ip, target_ip, rule->is_v6 ? 16 : 4) == 0; + int bytes = rule->mask / 8; + if (memcmp(rule->ip, target_ip, bytes) != 0) return 0; + int bits = rule->mask % 8; + if (bits == 0) return 1; + uint8_t mask_byte = (0xFF << (8 - bits)) & 0xFF; + return (rule->ip[bytes] & mask_byte) == (((uint8_t*)target_ip)[bytes] & mask_byte); +} + +// 检查是否为回环地址 (127.0.0.0/8 或 ::1) +int is_loopback(void *ip, int is_v6) { + if (!is_v6) return ((uint8_t*)ip)[0] == 127; + uint8_t v6_loop[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1}; + return memcmp(ip, v6_loop, 16) == 0; +} + +// 检查是否为局域网私有地址 (RFC 1918) +int is_private_ip(void *ip, int is_v6) { + if (is_v6) { + uint8_t *p = (uint8_t*)ip; + // fc00::/7 (ULA - Unique Local Address) + if ((p[0] & 0xfe) == 0xfc) return 1; + // fe80::/10 (Link-Local) + if (p[0] == 0xfe && (p[1] & 0xc0) == 0x80) return 1; + return 0; + } + uint8_t *p = (uint8_t*)ip; + if (p[0] == 10) return 1; + if (p[0] == 172 && (p[1] >= 16 && p[1] <= 31)) return 1; + if (p[0] == 192 && p[1] == 168) return 1; + return 0; +} + +int is_allowed(struct sockaddr *addr, int nr) { + uint16_t port = 0; void *ip = NULL; int is_v6 = 0; + + if (addr->sa_family == AF_INET) { + struct sockaddr_in *s4 = (struct sockaddr_in *)addr; + port = ntohs(s4->sin_port); ip = &s4->sin_addr; + } else if (addr->sa_family == AF_INET6) { + struct sockaddr_in6 *s6 = (struct sockaddr_in6 *)addr; + port = ntohs(s6->sin6_port); ip = &s6->sin6_addr; is_v6 = 1; + } else return 0; + + // 0. 永远允许回环地址 + if (is_loopback(ip, is_v6)) return 1; + + // 1. 拦截 bind (监听端口) + if (nr == __NR_bind) { + for (uint32_t i = 0; i < listen_cnt; i++) { + if (listen_ports[i] == port) return 1; + } + return 0; + } + + // 2. 检查黑名单 (BlockList 优先级最高) + for (uint32_t i = 0; i < block_cnt; i++) { + if (block_rules[i].is_v6 == is_v6 && (block_rules[i].port == 0 || block_rules[i].port == port)) { + if (is_ip_match(&block_rules[i], ip)) return 0; + } + } + + if (nr == __NR_sendto || nr == __NR_sendmsg) { + if (port == 53) return 1; // 永远放行 DNS 查询 (端口 53) + } + + // 3. 检查白名单 (AllowList) + for (uint32_t i = 0; i < allow_cnt; i++) { + if (allow_rules[i].is_v6 == is_v6 && (allow_rules[i].port == 0 || allow_rules[i].port == port)) { + if (is_ip_match(&allow_rules[i], ip)) return 1; + } + } + + // 4. 基础开关判定 (完美映射 Go 的配置) + if (is_private_ip(ip, is_v6)) { + return allow_local; // 访问 192.168.x.x 取决于 AllowLocalNetwork + } else { + return allow_internet; // 访问 8.8.8.8 取决于 AllowInternet + } +} + +// --- 监控主循环 (修正版) --- +void run_monitor(int notif_fd) { + struct seccomp_notif req = {0}; + struct seccomp_notif_resp resp = {0}; + struct pollfd pfd = { .fd = notif_fd, .events = POLLIN }; + + while (!stop_monitor) { + // 使用 poll 等待数据,超时设置为 500ms + // 这确保了即使 ioctl 没被信号打断,我们也能每半秒检查一次 stop_monitor + int ret = poll(&pfd, 1, 100); + if (ret < 0) { + if (errno == EINTR && !stop_monitor) continue; + break; + } + if (ret == 0) continue; // 超时,重新循环检查 stop_monitor + + memset(&req, 0, sizeof(req)); + if (ioctl(notif_fd, SECCOMP_IOCTL_NOTIF_RECV, &req) == -1) { + if (errno == EINTR) continue; + break; + } + + resp.id = req.id; resp.val = 0; resp.error = 0; resp.flags = 0; + struct sockaddr_storage addr; + int check_addr = 0; // 0:读取失败(拒绝), 1:读取成功(待验证), -1:无需验证(放行) + + if (req.data.nr == __NR_connect || req.data.nr == __NR_bind) { + void *remote_ptr = (void *)req.data.args[1]; + struct iovec local = { .iov_base = &addr, .iov_len = sizeof(addr) }; + struct iovec remote = { .iov_base = remote_ptr, .iov_len = sizeof(addr) }; + if (process_vm_readv(req.pid, &local, 1, &remote, 1, 0) > 0) check_addr = 1; + + } else if (req.data.nr == __NR_sendto) { + void *remote_ptr = (void *)req.data.args[4]; // sendto 的地址在第 5 个参数 + if (remote_ptr != NULL) { + struct iovec local = { .iov_base = &addr, .iov_len = sizeof(addr) }; + struct iovec remote = { .iov_base = remote_ptr, .iov_len = sizeof(addr) }; + if (process_vm_readv(req.pid, &local, 1, &remote, 1, 0) > 0) check_addr = 1; + } else { + check_addr = -1; // 理论上 BPF 已经拦截了 NULL,防御性编程 + } + + } else if (req.data.nr == __NR_sendmsg) { + struct msghdr msg; + void *msg_ptr = (void *)req.data.args[1]; // sendmsg 的参数是 struct msghdr * + struct iovec local_msg = { .iov_base = &msg, .iov_len = sizeof(msg) }; + struct iovec remote_msg = { .iov_base = msg_ptr, .iov_len = sizeof(msg) }; + // 第一跳:读取子进程的 msghdr 结构体 + if (process_vm_readv(req.pid, &local_msg, 1, &remote_msg, 1, 0) > 0) { + if (msg.msg_name != NULL) { + // 第二跳:根据结构体中的 msg_name 指针读取 sockaddr + struct iovec local_addr = { .iov_base = &addr, .iov_len = sizeof(addr) }; + struct iovec remote_addr = { .iov_base = msg.msg_name, .iov_len = sizeof(addr) }; + if (process_vm_readv(req.pid, &local_addr, 1, &remote_addr, 1, 0) > 0) check_addr = 1; + } else { + check_addr = -1; // 已连接的 socket + } + } + } + + if (check_addr == 1) { + if (is_allowed((struct sockaddr *)&addr, req.data.nr)) { + resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE; + } else { + resp.error = -EPERM; + } + } else if (check_addr == -1) { + resp.flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE; + } else { + resp.error = -EPERM; // 跨进程内存读取失败,安全起见直接掐断 + } + + ioctl(notif_fd, SECCOMP_IOCTL_NOTIF_SEND, &resp); + } +} + +int main() { + setvbuf(stdout, NULL, _IONBF, 0); + setvbuf(stderr, NULL, _IONBF, 0); + + uint32_t uid, gid, arg_count; + safe_read(0, &uid, 4); + safe_read(0, &gid, 4); + + uint32_t wd_len; + safe_read(0, &wd_len, 4); + char *work_dir = malloc(wd_len + 1); + safe_read(0, work_dir, wd_len); + work_dir[wd_len] = '\0'; + + safe_read(0, &arg_count, 4); + char **argv = malloc(sizeof(char *) * (arg_count + 1)); + for (uint32_t i = 0; i < arg_count; i++) { + uint32_t s_len; + safe_read(0, &s_len, 4); + argv[i] = malloc(s_len + 1); + safe_read(0, argv[i], s_len); + argv[i][s_len] = '\0'; + } + argv[arg_count] = NULL; + + safe_read(0, &net_enabled, 1); + if (net_enabled) { + safe_read(0, &allow_internet, 1); + safe_read(0, &allow_local, 1); + safe_read(0, &listen_cnt, 4); + if (listen_cnt > 0) { + listen_ports = malloc(listen_cnt * 4); + safe_read(0, listen_ports, listen_cnt * 4); + } + safe_read(0, &allow_cnt, 4); + if (allow_cnt > 0) { + allow_rules = malloc(allow_cnt * sizeof(struct NetRule)); + safe_read(0, allow_rules, allow_cnt * sizeof(struct NetRule)); + } + safe_read(0, &block_cnt, 4); + if (block_cnt > 0) { + block_rules = malloc(block_cnt * sizeof(struct NetRule)); + safe_read(0, block_rules, block_cnt * sizeof(struct NetRule)); + } + } + + int dev_null = open("/dev/null", O_RDONLY); + if (dev_null >= 0) { dup2(dev_null, 0); close(dev_null); } + + int notif_fd = -1; + if (net_enabled) { + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + + // 1. 拦截 connect + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_connect, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF), + + // 2. 拦截 bind + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_bind, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF), + + // 3. 拦截 sendto (精细化过滤:只拦截 dest_addr 不为空的调用) + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_sendto, 0, 5), + // 加载 args[4] (dest_addr) 的低 32 位 + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[4])), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0, 0, 2), // 不为 0 说明有地址,跳去 Notify + // 加载 args[4] 的高 32 位 + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[4]) + 4), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0, 2, 0), // 也为 0 说明是 NULL (已连接的 TCP/UDP),跳过 Notify + + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF), // Notify + + // 4. 拦截 sendmsg (结构体较复杂,全部交给用户态去读内存判断) + // 需要先恢复 nr 到累加器 + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_sendmsg, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF), + + // 兜底放行 + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + }; + struct sock_fprog prog = { .len = (unsigned short)(sizeof(filter)/sizeof(filter[0])), .filter = filter }; + notif_fd = syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_NEW_LISTENER, &prog); + } + + // 显式不使用 SA_RESTART,确保系统调用会被信号中断 + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = handle_sig; + sigaction(SIGCHLD, &sa, NULL); + sigaction(SIGTERM, &sa, NULL); + sigaction(SIGINT, &sa, NULL); + + child_pid = fork(); + if (child_pid == 0) { + setpgid(0, 0); + prctl(PR_SET_PDEATHSIG, SIGTERM); + mount("proc", "/proc", "proc", 0, NULL); + mount("sysfs", "/sys", "sysfs", 0, NULL); + chdir(work_dir); + int fd_out = open("stdout.log", O_WRONLY | O_CREAT | O_TRUNC | O_SYNC, 0644); + int fd_err = open("stderr.log", O_WRONLY | O_CREAT | O_TRUNC | O_SYNC, 0644); + if (fd_out >= 0) dup2(fd_out, 1); + if (fd_err >= 0) dup2(fd_err, 2); + if (uid != 0) { setresgid(gid, gid, gid); setresuid(uid, uid, uid); } + execv(argv[0], argv); + exit(103); + } else { + if (net_enabled && notif_fd >= 0) { + run_monitor(notif_fd); + DEBUG("monitor loop exited"); + } + int status; + waitpid(child_pid, &status, 0); + umount("/proc"); + umount("/sys"); + DEBUG("child exited status: %d", WEXITSTATUS(status)); + exit(WEXITSTATUS(status)); + } + return 0; +} \ No newline at end of file diff --git a/macTestApp b/macTestApp new file mode 100755 index 0000000..c0dd0ca Binary files /dev/null and b/macTestApp differ diff --git a/master.go b/master.go new file mode 100644 index 0000000..e6415c2 --- /dev/null +++ b/master.go @@ -0,0 +1,167 @@ +// master.go v1.0 + +package sandbox + +import ( + "errors" + "fmt" + "math" + "os" + "path/filepath" + "syscall" + "time" + + "github.com/ssgo/log" + "github.com/ssgo/u" +) + +type SandboxStore struct { + Id string + Config *Config +} + +var sandboxStoreList = map[string]SandboxStore{} +var sandboxStoreFile = "" + +func Restore(id string, cfg *Config) *Sandbox { + root := filepath.Join(pluginConfig.Root, id) + stateFile := filepath.Join(root, ".state.json") + + st := State{} + if u.FileExists(stateFile) && u.Load(stateFile, &st) == nil { + sb := &Sandbox{ + id: id, + root: root, + workDir: st.WorkDir, + config: cfg, + pid: st.Pid, + status: "running", + startTime: st.StartTime, + mountedList: st.MountedList, + } + // 检查进程是否存在 + if sb.Alive() { + log.DefaultLogger.Info("[Sandbox] resumed existing process", "id", id, "pid", st.Pid, "name", cfg.Name) + return sb + } else { + log.DefaultLogger.Info("[Sandbox] process dead, cleaning up residue", "id", id, "pid", st.Pid, "name", cfg.Name) + sb.Cleanup() + } + } + + if cfg.AutoStart { + log.DefaultLogger.Info("[Sandbox] auto starting process", "name", cfg.Name) + sb, err := Start(cfg) + if err != nil { + log.DefaultLogger.Error("[Sandbox] auto-start failed", "err", err) + return nil + } + return sb + } else { + // 不自动启动,返回 nil + return nil + } +} + +func Query(name string) []*Sandbox { + sandboxLock.RLock() + defer sandboxLock.RUnlock() + var list []*Sandbox + for _, sb := range sandboxList { + if sb.config.Name == name { + list = append(list, sb) + } + } + return list +} + +func Fetch(id string) *Sandbox { + sandboxLock.RLock() + defer sandboxLock.RUnlock() + for _, sb := range sandboxList { + if sb.id == id { + return sb + } + } + return nil +} + +func RegisterSandbox(sb *Sandbox) { + sandboxLock.Lock() + defer sandboxLock.Unlock() + sandboxList[sb.id] = sb + sandboxStoreList[sb.id] = SandboxStore{ + Id: sb.id, + Config: sb.config, + } + u.Save(sandboxStoreFile, sandboxStoreList) +} + +func ReleaseSandbox(id string) { + sandboxLock.Lock() + defer sandboxLock.Unlock() + delete(sandboxList, id) + delete(sandboxStoreList, id) + u.Save(sandboxStoreFile, sandboxStoreList) +} + +func checkSandboxStatusTask() { + for isRunning.Load() { + for i := 0; i < 2*60; i++ { + time.Sleep(time.Millisecond * 500) + if !isRunning.Load() { + return + } + } + sblist := map[string]*Sandbox{} + sandboxLock.RLock() + for id, sb := range sandboxList { + sblist[id] = sb + } + sandboxLock.RUnlock() + for _, sb := range sblist { + if !sb.Alive() && sb.status == "running" { + sb.Cleanup() + } + if !isRunning.Load() { + return + } + } + } +} + +func (sb *Sandbox) _kill(sig os.Signal) error { + proc, err := os.FindProcess(sb.pid) + if err == nil { + err = proc.Signal(sig) + } + return err +} + +func (sb *Sandbox) Alive() bool { + sb.lock.Lock() + defer sb.lock.Unlock() + return sb._alive() +} + +func (sb *Sandbox) _alive() bool { + proc, err := os.FindProcess(sb.pid) + if err == nil && proc.Signal(syscall.Signal(0)) == nil { + // 精确比对启动时间,防止 PID 回绕 + if fi := u.GetFileInfo(fmt.Sprintf("/proc/%d", sb.pid)); fi != nil { + if math.Abs(float64(fi.ModTime.Unix()-sb.startTime)) > 5 { + // 确认启动时间差大于 5 秒,断言并非同一个进程 + return false + } + } + return true + } + return false +} + +func (sb *Sandbox) _waitProcess() (*os.ProcessState, error) { + if proc, err := os.FindProcess(sb.pid); err == nil { + return proc.Wait() + } + return nil, errors.New("pid not found") +} diff --git a/nodejsRuntime.go b/nodejsRuntime.go new file mode 100644 index 0000000..3156a09 --- /dev/null +++ b/nodejsRuntime.go @@ -0,0 +1,163 @@ +package sandbox + +import ( + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/ssgo/httpclient" + "github.com/ssgo/log" + "github.com/ssgo/u" +) + +func init() { + RegisterRuntime("nodejs", &Runtime{ + Check: func(runtimePath, venvPath, projectPath string, uid, gid int, cfg *RuntimeConfig) (*RuntimeSandboxConfig, error) { + if err := checkNodejsRuntime(runtimePath, cfg); err != nil { + return nil, err + } + if err := checkNodejsProject(runtimePath, projectPath, uid, gid, cfg); err != nil { + return nil, err + } + + nodeExe := getNodeExe(runtimePath) + return &RuntimeSandboxConfig{ + StartCmd: nodeExe, + Envs: map[string]string{ + "PATH": filepath.Dir(nodeExe) + string(os.PathListSeparator) + "$PATH", + }, + }, nil + }, + }) +} + +func checkNodejsRuntime(runtimePath string, cfg *RuntimeConfig) error { + runtimeVersion := filepath.Base(runtimePath) + lock := getRuntimeLock("nodejs", "runtime", runtimePath) + lock.Lock() + defer lock.Unlock() + if !u.FileExists(runtimePath) { + osPart := u.StringIf(runtime.GOOS == "linux", "linux", u.StringIf(runtime.GOOS == "windows", "win", "darwin")) + archPart := u.StringIf(runtime.GOARCH == "arm64", "arm64", "x64") + extension := u.StringIf(runtime.GOOS == "windows", ".zip", ".tar.gz") + instFile := filepath.Join(filepath.Dir(runtimePath), ".installs", fmt.Sprintf("node-v%s-%s-%s%s", runtimeVersion, osPart, archPart, extension)) + if !u.FileExists(instFile) { + downloadClient := httpclient.GetClient(3600 * time.Second) + downloadClient.EnableRedirect() + if cfg.HttpProxy != "" { + if proxyURL, err := url.Parse(cfg.HttpProxy); err == nil { + downloadClient.GetRawClient().Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} + } + } + log.DefaultLogger.Info("[Sandbox] fetching nodejs runtime release list", "from", "https://nodejs.org/dist/index.json", "httpProxy", cfg.HttpProxy) + r1 := downloadClient.Get("https://nodejs.org/dist/index.json") + if r1.Error != nil { + return r1.Error + } + + // 解析JSON数据 + var versions []map[string]interface{} + if err := r1.To(&versions); err != nil { + return fmt.Errorf("failed to parse index.json: %v", err) + } + + // 寻找匹配的版本 + var targetVersion string + for _, v := range versions { + version := v["version"].(string) + // 检查版本号是否匹配 + if strings.HasPrefix(version, "v"+runtimeVersion) { + // 检查是否有对应的文件 + files := v["files"].([]interface{}) + fileFound := false + for _, f := range files { + file := f.(string) + // 检查是否有对应的平台和架构的文件 + if (osPart == "linux" && file == "linux-"+archPart) || + (osPart == "darwin" && (file == "osx-"+archPart+"-tar" || file == "osx-x64-tar")) || + (osPart == "win" && file == "win-"+archPart+"-zip") { + fileFound = true + break + } + } + if fileFound { + targetVersion = version + break + } + } + } + + if targetVersion == "" { + return fmt.Errorf("no binary for version %s on %s-%s", runtimeVersion, osPart, archPart) + } + + // 构建下载URL + fileName := fmt.Sprintf("node-%s-%s-%s%s", targetVersion, osPart, archPart, extension) + downloadURL := fmt.Sprintf("https://nodejs.org/dist/%s/%s", targetVersion, fileName) + + log.DefaultLogger.Info("[Sandbox] downloading nodejs runtime", "from", downloadURL, "to", instFile, "httpProxy", cfg.HttpProxy) + if _, err := downloadClient.Download(instFile, downloadURL, nil); err != nil { + return err + } else { + log.DefaultLogger.Info("[Sandbox] downloaded nodejs runtime", "version", targetVersion, "os", osPart, "arch", archPart, "url", downloadURL, "to", instFile) + } + } + + os.MkdirAll(runtimePath, 0755) + if err := u.Extract(instFile, runtimePath, true); err != nil { + log.DefaultLogger.Error("[Sandbox] unpack nodejs runtime failed", "version", runtimeVersion, "os", osPart, "arch", archPart, "from", instFile, "err", err.Error()) + return err + } + } + return nil +} + +func checkNodejsProject(runtimePath, projectPath string, uid, gid int, cfg *RuntimeConfig) error { + lock := getRuntimeLock("nodejs", "project", projectPath) + lock.Lock() + defer lock.Unlock() + if uid != 0 && runtime.GOOS == "linux" { + os.Chown(projectPath, uid, gid) + } + packageFile := filepath.Join(projectPath, "package.json") + if u.FileExists(packageFile) { + shaFile := filepath.Join(projectPath, ".package.sha1") + currentSha := u.Sha1String(u.ReadFileN(packageFile)) + oldSha := u.ReadFileN(shaFile) + if oldSha != currentSha { + npmExe := getNpmExe(runtimePath) + npmArgs := []string{"install", "--prefix", projectPath, "--cache", filepath.Join(projectPath, ".npm")} + if cfg.Mirror != "" { + npmArgs = append(npmArgs, "--registry", cfg.Mirror) + } + + if out, err := RunCmdWithEnv(map[string]string{"PATH": filepath.Dir(npmExe) + string(os.PathListSeparator) + os.Getenv("PATH")}, projectPath, uid, gid, npmExe, npmArgs...); err != nil { + log.DefaultLogger.Error("[Sandbox] npm install failed", "runtime", runtimePath, "project", projectPath, "err", err.Error(), "output", out) + return err + } else { + log.DefaultLogger.Info("[Sandbox] npm install success", "runtime", runtimePath, "project", projectPath, "output", out) + u.WriteFile(shaFile, currentSha) + } + } + } + return nil +} + +func getNodeExe(base string) string { + if runtime.GOOS == "windows" { + return filepath.Join(base, "node.exe") + } + return filepath.Join(base, "bin", "node") +} + +func getNpmExe(base string) string { + if runtime.GOOS == "windows" { + return filepath.Join(base, "npm.cmd") + } + return filepath.Join(base, "bin", "npm") +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..963d149 --- /dev/null +++ b/plugin.go @@ -0,0 +1,66 @@ +// plugin.go v1 +package sandbox + +import ( + "os" + "path/filepath" + "sync/atomic" + + "apigo.cc/gojs" + "github.com/ssgo/config" + "github.com/ssgo/u" +) + +var pluginObject = gojs.Map{ + "start": Start, + "fetch": Fetch, + "query": Query, +} + +var isRunning atomic.Bool + +func init() { + config.LoadConfig("sandbox", &pluginConfig) + if pluginConfig.Root == "" { + if wd, err := os.Getwd(); err == nil { + pluginConfig.Root = filepath.Join(wd, "sandbox") + } + } + pluginConfig.Root = u.GetAbsFilename(pluginConfig.Root) + sandboxStoreFile = filepath.Join(pluginConfig.Root, ".sandboxlist.json") + gojs.Register("apigo.cc/gojs/sandbox", gojs.Module{ + Object: gojs.ToMap(pluginObject), + TsCode: gojs.MakeTSCode(pluginObject), + OnKill: func() { + isRunning.Store(false) + }, + }) + + onStart() + go checkSandboxStatusTask() + isRunning.Store(true) +} + +// 异步起动,恢复所有已存在的沙箱进程 +func onStart() { + sandboxLock.Lock() + u.Load(sandboxStoreFile, &sandboxStoreList) + sandboxLock.Unlock() + idLock.Lock() + for id := range sandboxStoreList { + currentIds[id] = true // 避免重复分配Id + } + idLock.Unlock() + for id, ss := range sandboxStoreList { + sb := Restore(id, ss.Config) + if sb != nil { + sandboxList[id] = sb // 恢复成功 + } else { + delete(sandboxStoreList, id) // 进程已结束,从列表中移除 + delete(currentIds, id) // 从当前Id列表中移除 + } + } + sandboxLock.Lock() + u.Save(sandboxStoreFile, sandboxStoreList) + sandboxLock.Unlock() +} diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..91df1f7 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,67 @@ +// mac_sandbox_test.go v1.0 + +package sandbox + +import ( + "fmt" + "strings" + "sync" + "testing" + "time" + + "apigo.cc/gojs" + _ "apigo.cc/gojs/console" + _ "apigo.cc/gojs/file" + _ "apigo.cc/gojs/runtime" + + _ "apigo.cc/gojs/util" + "github.com/ssgo/u" +) + +func init() { + gojs.Register("testTool", gojs.Module{ + Object: map[string]interface{}{ + "runAll": func(tasks [][]any) []any { + res := make([]any, len(tasks)) + var wg sync.WaitGroup + for i, taskArgs := range tasks { + wg.Add(1) + go func(index int, args []any) { + defer wg.Done() + script := u.String(args[0]) + if !strings.ContainsRune(script, '\n') && u.FileExists(script) { + r, err := gojs.RunFile(script, args[1:]...) + res[index] = []any{r, err} + } else { + r, err := gojs.Run(script, "anonymous.js", args[1:]...) + res[index] = []any{r, err} + } + }(i, taskArgs) + } + wg.Wait() + return res + }, + }, + }) +} + +func TestPlugin(t *testing.T) { + tests := []string{ + "base_test.js", + // "nodejs_test.js", + } + + gojs.ExportForDev() + for _, f := range tests { + r, err := gojs.RunFile(f) + if err != nil { + t.Fatal(u.Red(f), u.BRed(err.Error())) + } else if r != true { + t.Fatal(u.Red(f), u.BRed(u.JsonP(r))) + } else { + fmt.Println(u.Green(f), u.BGreen("test succeess")) + } + } + + time.Sleep(500 * time.Millisecond) +} diff --git a/pythonRuntime.go b/pythonRuntime.go new file mode 100644 index 0000000..6f331a8 --- /dev/null +++ b/pythonRuntime.go @@ -0,0 +1,166 @@ +package sandbox + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "time" + + "github.com/ssgo/httpclient" + "github.com/ssgo/log" + "github.com/ssgo/u" +) + +func init() { + RegisterRuntime("python", &Runtime{ + Check: func(runtimePath, venvPath, projectPath string, uid, gid int, cfg *RuntimeConfig) (*RuntimeSandboxConfig, error) { + if err := checkPythonRuntime(runtimePath, cfg); err != nil { + return nil, err + } + if err := checkPythonVenv(runtimePath, venvPath, uid, gid); err != nil { + return nil, err + } + if err := checkPythonProject(venvPath, projectPath, uid, gid, cfg); err != nil { + return nil, err + } + + venvExe := getPythonExe(venvPath) + return &RuntimeSandboxConfig{ + StartCmd: venvExe, + Envs: map[string]string{ + "PATH": filepath.Dir(venvExe) + string(os.PathListSeparator) + "$PATH", + }, + }, nil + }, + }) +} + +func checkPythonRuntime(runtimePath string, cfg *RuntimeConfig) error { + runtimeVersion := filepath.Base(runtimePath) + lock := getRuntimeLock("python", "runtime", runtimePath) + lock.Lock() + defer lock.Unlock() + if !u.FileExists(runtimePath) { + osPart := u.StringIf(runtime.GOOS == "linux", "unknown-linux-gnu", u.StringIf(runtime.GOOS == "windows", "pc-windows-msvc-shared", "apple-darwin")) + archPart := u.StringIf(runtime.GOARCH == "arm64", "aarch64", "x86_64") + instFile := filepath.Join(filepath.Dir(runtimePath), ".installs", fmt.Sprintf("%s-%s-%s.tar.gz", runtimeVersion, osPart, archPart)) + if !u.FileExists(instFile) { + downloadClient := httpclient.GetClient(3600 * time.Second) + downloadClient.EnableRedirect() + if cfg.HttpProxy != "" { + if proxyURL, err := url.Parse(cfg.HttpProxy); err == nil { + downloadClient.GetRawClient().Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} + } + } + log.DefaultLogger.Info("[Sandbox] fetching python runtime release list", "from", "https://github.com/astral-sh/python-build-standalone/releases", "httpProxy", cfg.HttpProxy) + r1 := downloadClient.Get("https://github.com/astral-sh/python-build-standalone/releases") + if r1.Error != nil { + return r1.Error + } + if m1 := regexp.MustCompile(`data-deferred-src="([^"]+)"`).FindStringSubmatch(r1.String()); len(m1) >= 2 { + log.DefaultLogger.Info("[Sandbox] fetching python runtime download url", "from", m1[1], "httpProxy", cfg.HttpProxy) + r2 := downloadClient.Get(m1[1]) + if r2.Error != nil { + return r2.Error + } + pattern := fmt.Sprintf(`href="([^"]+cpython-%s(?:\.\d+)*\+[^"]+-%s-%s-install_only\.tar\.gz)"`, regexp.QuoteMeta(runtimeVersion), archPart, osPart) + if m2 := regexp.MustCompile(pattern).FindStringSubmatch(r2.String()); len(m2) >= 2 { + log.DefaultLogger.Info("[Sandbox] downloading python runtime", "from", "https://github.com"+m2[1], "to", instFile) + if _, err := downloadClient.Download(instFile, "https://github.com"+m2[1], nil); err != nil { + return err + } else { + log.DefaultLogger.Info("[Sandbox] downloaded python runtime", "version", runtimeVersion, "os", osPart, "arch", archPart, "url", "https://github.com"+m2[1], "to", instFile, "httpProxy", cfg.HttpProxy) + } + } else { + return fmt.Errorf("no binary for version %s on %s-%s on %s", runtimeVersion, osPart, archPart, m1[1]) + } + } else { + return errors.New("can't read release list on https://github.com/astral-sh/python-build-standalone/releases") + } + } + + if err := u.Extract(instFile, runtimePath, true); err != nil { + log.DefaultLogger.Error("[Sandbox] unpack python runtime failed", "version", runtimeVersion, "os", osPart, "arch", archPart, "from", instFile, "err", err.Error()) + return err + } + } + return nil +} + +func checkPythonVenv(runtimePath, venvPath string, uid, gid int) error { + lock := getRuntimeLock("python", "venv", venvPath) + lock.Lock() + defer lock.Unlock() + if !u.FileExists(venvPath) { + pyExe := getPythonExe(runtimePath) + os.MkdirAll(venvPath, 0755) + if uid != 0 && runtime.GOOS == "linux" { + os.Chown(venvPath, uid, gid) + } + if out, err := RunCmd(venvPath, uid, gid, pyExe, "-m", "venv", venvPath); err != nil { + log.DefaultLogger.Error("[Sandbox] venv create failed", "runtime", runtimePath, "venv", venvPath, "err", err.Error(), "output", out) + return err + } else { + log.DefaultLogger.Info("[Sandbox] venv create success", "runtime", runtimePath, "venv", venvPath) + } + } + return nil +} + +func checkPythonProject(venvPath, projectPath string, uid, gid int, cfg *RuntimeConfig) error { + lock := getRuntimeLock("python", "project", projectPath) + lock.Lock() + defer lock.Unlock() + if uid != 0 && runtime.GOOS == "linux" { + os.Chown(projectPath, uid, gid) + } + reqFile := filepath.Join(projectPath, "requirements.txt") + if u.FileExists(reqFile) { + shaFile := filepath.Join(projectPath, ".requirements.sha1") + currentSha := u.Sha1String(u.ReadFileN(reqFile)) + oldSha := u.ReadFileN(shaFile) + if oldSha != currentSha { + pipExe := getPipExe(venvPath) + pipArgs := []string{"install", "-r", reqFile} + if cfg.Mirror != "" { + pipArgs = append(pipArgs, "-i", cfg.Mirror) + } + + if out, err := RunCmd(projectPath, uid, gid, pipExe, pipArgs...); err != nil { + log.DefaultLogger.Error("[Sandbox] pip install failed", "venv", venvPath, "project", projectPath, "err", err.Error(), "output", out) + return err + } else { + log.DefaultLogger.Info("[Sandbox] pip install success", "venv", venvPath, "project", projectPath, "output", out) + u.WriteFile(shaFile, currentSha) + } + } + } + return nil +} + +func getPythonExe(base string) string { + if runtime.GOOS == "windows" { + return filepath.Join(base, "python.exe") + } + exe := filepath.Join(base, "bin", "python") + if !u.FileExists(exe) { + exe = filepath.Join(base, "bin", "python3") + } + return exe +} + +func getPipExe(base string) string { + if runtime.GOOS == "windows" { + return filepath.Join(base, "pip.exe") + } + exe := filepath.Join(base, "bin", "pip") + if !u.FileExists(exe) { + exe = filepath.Join(base, "bin", "pip3") + } + return exe +} diff --git a/runtime.go b/runtime.go new file mode 100644 index 0000000..2cb9a25 --- /dev/null +++ b/runtime.go @@ -0,0 +1,66 @@ +package sandbox + +import ( + "bytes" + "fmt" + "os/exec" + "runtime" + "sync" + + "github.com/ssgo/log" +) + +var runtimeLocks sync.Map + +// 锁管理 +func getRuntimeLock(language, typ, name string) *sync.Mutex { + l, _ := runtimeLocks.LoadOrStore(language+":"+typ+":"+name, &sync.Mutex{}) + return l.(*sync.Mutex) +} + +type RuntimeConfig struct { + Root string + HttpProxy string + Mirror string +} + +type RuntimeSandboxConfig struct { + StartCmd string + Envs map[string]string +} + +type Runtime struct { + Check func(runtimePath, venvPath, projectPath string, uid, gid int, cfg *RuntimeConfig) (*RuntimeSandboxConfig, error) +} + +var runtimeList = make(map[string]*Runtime) +var runtimeListLock sync.RWMutex + +func RegisterRuntime(name string, rt *Runtime) { + runtimeListLock.Lock() + defer runtimeListLock.Unlock() + runtimeList[name] = rt +} + +func RunCmdWithEnv(envs map[string]string, cmdDir string, uid, gid int, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = cmdDir + for k, v := range envs { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) + } + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if runtime.GOOS == "linux" && uid > 0 { + applyCredential(cmd, uid, gid) + } + err := cmd.Run() + if err != nil { + log.DefaultLogger.Error("[Sandbox] RunCmd failed "+err.Error(), "name", name, "args", args) + } + return out.String(), err +} + +func RunCmd(cmdDir string, uid, gid int, name string, args ...string) (string, error) { + return RunCmdWithEnv(nil, cmdDir, uid, gid, name, args...) +} diff --git a/runtime_linux.go b/runtime_linux.go new file mode 100644 index 0000000..23a28dc --- /dev/null +++ b/runtime_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package sandbox + +import ( + "os/exec" + "syscall" +) + +func applyCredential(cmd *exec.Cmd, uid, gid int) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + }, + } +} diff --git a/runtime_other.go b/runtime_other.go new file mode 100644 index 0000000..2e63f83 --- /dev/null +++ b/runtime_other.go @@ -0,0 +1,10 @@ +//go:build !linux + +package sandbox + +import ( + "os/exec" +) + +func applyCredential(cmd *exec.Cmd, uid, gid int) { +} diff --git a/sandbox.go b/sandbox.go new file mode 100644 index 0000000..e01fc7d --- /dev/null +++ b/sandbox.go @@ -0,0 +1,101 @@ +// sandbox.go v1.6 +package sandbox + +import ( + "os" + "os/exec" + "sync" +) + +var pluginConfig = struct { + Root string // 沙盒临时根目录物理路径 + Runtime map[string]*RuntimeConfig // 运行时配置 +}{} + +var sandboxList = map[string]*Sandbox{} +var sandboxLock = sync.RWMutex{} + +// Volume 挂载配置 +type Volume struct { + Source string // 宿主机路径或类型(如 tmpfs) + Target string // 沙盒内路径 + ReadOnly bool // 是否只读 +} + +// Limits 资源限制 +type Limits struct { + Cpu float64 // CPU 核心限制 (如 0.5) + Mem float64 // 内存限制 (单位: GB) + Swap float64 // SWAP 限制 (单位: GB) + Shm uint // /dev/shm 大小 (单位: MB) + Tmp uint // /tmp 大小 (单位: MB) +} + +// Config 沙盒启动配置 +type Config struct { + Name string // 沙盒名称 + ProjectDir string // 宿主机的工作目录(自动映射到SandboxWorkDir) + WorkDir string // 沙盒内的工作目录 + Envs map[string]string // 环境变量 + Volumes []Volume // 挂载列表 + Limits Limits // 资源限制 + Network struct { + AllowInternet bool // 是否允许出站网络连接,默认关闭,配置后只开放非本地网络访问 + AllowLocalNetwork bool // 是否允许访问本地网络,默认关闭,配置后只开放10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 + AllowListen []int // 允许监听端口列表,默认关闭所有监听端口和进站连接 + AllowList []string // 允许出站访问的 IP/端口 列表,同时放开TCP&UDP,在拒绝的基础上允许访问白名单 + BlockList []string // 拒绝访问的 IP/端口 列表,同时拒绝TCP&UDP,在允许的基础上拒绝黑名单 + } + Gpu struct { + Driver string // 驱动或厂商,如 "nvidia", "amd", "apple" + Devices []string // 设备 ID,如 ["0", "1"] 或 ["all"] + } + ExtraOptions []string // 额外参数 + AutoStart bool // 是否自动启动沙盒进程 + StartCmd string + StartArgs []string + Runtime struct { + Language string + Version string + Venv string + } + NoLog bool // 是否不显示沙盒运行日志 +} + +// Status 沙盒实时状态信息 +type Status struct { + Id string // 沙盒唯一标识 (用于目录名、Cgroup 名) + Pid int // 宿主机上的进程 PID + Alive bool // 物理进程是否正在运行 + Status string // 状态 + StartTime int64 // 启动时间 (Unix 时间戳) + Uptime int64 // 运行耗时 (单位: 秒) + MemoryUsage uint // 当前内存使用 (单位: MB) + CpuUsage float64 // 当前 CPU 使用率 (百分比) +} + +type State struct { + Id string + Pid int + StartTime int64 + WorkDir string + MountedList []string +} + +type Sandbox struct { + id string + pid int + uid int + gid int + root string + workDir string + config *Config + cmd *exec.Cmd + mountedList []string + startTime int64 + lastLogFd *os.File + errorLogFd *os.File + status string + lock sync.Mutex + extra map[string]any +} diff --git a/sandbox_darwin.go b/sandbox_darwin.go new file mode 100644 index 0000000..844de10 --- /dev/null +++ b/sandbox_darwin.go @@ -0,0 +1,367 @@ +//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 +} diff --git a/sandbox_linux.go b/sandbox_linux.go new file mode 100644 index 0000000..5afbeb6 --- /dev/null +++ b/sandbox_linux.go @@ -0,0 +1,652 @@ +//go:build linux + +// sandbox_linux.go v1.5 + +package sandbox + +import ( + _ "embed" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "apigo.cc/gojs" + "github.com/ssgo/log" + "github.com/ssgo/u" +) + +//go:embed init.gz +var initGz []byte +var initExec []byte + +func init() { + initExec = u.GunzipN(initGz) +} + +var defaultVolumes = []Volume{ + {Source: "/usr", Target: "/usr", ReadOnly: true}, + {Source: "/lib", Target: "/lib", ReadOnly: true}, + {Source: "/lib64", Target: "/lib64", ReadOnly: true}, + {Source: "/bin", Target: "/bin", ReadOnly: true}, + {Source: "/sbin", Target: "/sbin", ReadOnly: true}, + {Source: "/etc/resolv.conf", Target: "/etc/resolv.conf", ReadOnly: true}, + {Source: "/etc/hosts", Target: "/etc/hosts", ReadOnly: true}, + {Source: "/etc/localtime", Target: "/etc/localtime", ReadOnly: true}, + {Source: "/dev/null", Target: "/dev/null", ReadOnly: false}, + {Source: "/dev/urandom", Target: "/dev/urandom", ReadOnly: true}, + {Source: "/dev/zero", Target: "/dev/zero", 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 nil, gojs.Err("sandbox is already used") + } + + if s.config.WorkDir == "" { + s.config.WorkDir = "/app" + } + + if os.Getuid() == 0 { + if user, err := user.Lookup("nobody"); err == nil { + s.uid = u.Int(user.Uid) + s.gid = u.Int(user.Gid) + } else { + s.uid = 65534 + s.gid = 65534 + } + } + + initEnv := map[string]string{ + "LANG": os.Getenv("LANG"), + "LC_TIME": os.Getenv("LC_TIME"), + "PATH": filepath.Join(s.config.WorkDir, "bin") + ":/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "HOME": s.config.WorkDir, + "LD_LIBRARY_PATH": os.Getenv("LD_LIBRARY_PATH"), + } + if initEnv["LC_TIME"] == "" { + initEnv["LC_TIME"] = "C" + } + if initEnv["LANG"] == "" { + initEnv["LANG"] = "C.UTF-8" + } + addEnv := func(k, v string) { + initEnv[k] = os.Expand(v, func(varName string) string { + return initEnv[varName] + }) + } + + s.status = "starting" + s.startTime = time.Now().Unix() + s.id = getId() + s.root = filepath.Join(pluginConfig.Root, s.id) + + // 1. 构建物理环境 (对应原 mountAll 逻辑) + initPaths := []string{s.config.WorkDir, "/var/log", "/etc", "/proc", "/sys"} + os.MkdirAll(s.root, 0755) + if s.uid != 0 { + os.Chown(s.root, s.uid, s.gid) + } + for _, p := range initPaths { + path := filepath.Join(s.root, p) + os.MkdirAll(path, 0755) + if s.uid != 0 { + os.Chown(path, s.uid, s.gid) + } + } + + // 2. 执行挂载逻辑 (严格保留 v20 的 bind mount + remount ro 逻辑) + vs := NewVolumes() + vs.Add(defaultVolumes...) + vs.Add(s.config.Volumes...) + if s.config.Limits.Shm > 0 { + vs.Add(Volume{Source: "tmpfs", Target: "/dev/shm"}) + } + if s.config.Limits.Tmp > 0 { + vs.Add(Volume{Source: "tmpfs", Target: "/tmp"}) + } + // 处理 Gpu 透传 + if len(s.config.Gpu.Devices) > 0 { + gpuDevices := []string{} + driver := strings.ToLower(s.config.Gpu.Driver) + + switch driver { + case "nvidia": + // 核心管理与工具设备 + gpuDevices = append(gpuDevices, "/dev/nvidiactl", "/dev/nvidia-uvm", "/dev/nvidia-uvm-tools", "/dev/nvidia-modeset") + // 具体显卡设备 + if len(s.config.Gpu.Devices) > 0 && s.config.Gpu.Devices[0] == "all" { + matches, _ := filepath.Glob("/dev/nvidia[0-9]*") + gpuDevices = append(gpuDevices, matches...) + } else { + for _, devId := range s.config.Gpu.Devices { + gpuDevices = append(gpuDevices, "/dev/nvidia"+devId) + } + } + + // 设置环境变量 + addEnv("CUDA_HOME", os.Getenv("CUDA_HOME")) + addEnv("NVIDIA_DRIVER_CAPABILITIES", strings.Join(s.config.Gpu.Devices, ",")) + + case "amd", "render": + // AMD GPU 或通用的 DRM 渲染设备 (Intel 也可以走此逻辑) + // DRI (Direct Rendering Infrastructure) 设备 + gpuDevices = append(gpuDevices, "/dev/kfd") // AMD 核心调度器 + if len(s.config.Gpu.Devices) > 0 && s.config.Gpu.Devices[0] == "all" { + matches, _ := filepath.Glob("/dev/dri/renderD*") + gpuDevices = append(gpuDevices, matches...) + matchesCard, _ := filepath.Glob("/dev/dri/card*") + gpuDevices = append(gpuDevices, matchesCard...) + } else { + for _, devId := range s.config.Gpu.Devices { + // 常见的 ID 如 128 (renderD128) + gpuDevices = append(gpuDevices, "/dev/dri/renderD"+devId) + } + } + addEnv("ROCR_VISIBLE_DEVICES", strings.Join(s.config.Gpu.Devices, ",")) + // 对于某些旧版 OpenCL + addEnv("GPU_DEVICE_ORDINAL", strings.Join(s.config.Gpu.Devices, ",")) + + case "intel": + // Intel 特有的设备节点通常就在 /dev/dri 目录下 + if len(s.config.Gpu.Devices) > 0 && s.config.Gpu.Devices[0] == "all" { + matches, _ := filepath.Glob("/dev/dri/*") + gpuDevices = append(gpuDevices, matches...) + } else { + for _, devId := range s.config.Gpu.Devices { + gpuDevices = append(gpuDevices, "/dev/dri/renderD"+devId) + } + } + addEnv("ONEAPI_DEVICE_SELECTOR", "level_zero:"+strings.Join(s.config.Gpu.Devices, ",")) + } + + // 统一执行设备挂载 + for _, dev := range gpuDevices { + if u.FileExists(dev) { + vs.Add(Volume{Source: dev, Target: dev}) + } + } + } + + // 处理 ProjectDir 自动挂载 + if s.config.ProjectDir != "" { + s.workDir = u.GetAbsFilename(s.config.ProjectDir) + } else { + // 使用临时目录 + s.workDir = filepath.Join(s.root, ".workdir") + } + + if !u.FileExists(s.workDir) { + os.MkdirAll(s.workDir, 0755) + if s.uid != 0 { + os.Chown(s.workDir, s.uid, s.gid) + } + } + vs.Add(Volume{Source: s.workDir, Target: s.config.WorkDir}) + + // 处理 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, Target: runtimePath, ReadOnly: true}) + } + if u.FileExists(venvPath) { + vs.Add(Volume{Source: venvPath, Target: venvPath, ReadOnly: false}) + } + } else { + s._cleanup() + return nil, err + } + } else { + s._cleanup() + return nil, gojs.Err("runtime not found for language " + s.config.Runtime.Language) + } + + if s.config.StartCmd == "" { + s._cleanup() + return nil, gojs.Err("start cmd is empty") + } + + for k, v := range s.config.Envs { + addEnv(k, v) + } + + for _, v := range vs.Get() { + from, to, mode := v.Source, v.Target, "rw" + if v.ReadOnly { + mode = "ro" + } + + toPath := filepath.Join(s.root, to) + fi, err := os.Lstat(from) + + if err == nil && fi.Mode()&os.ModeSymlink != 0 { + // 处理符号链接 + linkTarget, _ := os.Readlink(from) + os.Symlink(linkTarget, toPath) + } else { + // 创建挂载点 + if err != nil || fi.IsDir() { + // 创建文件夹(如果err!=nil表示这是特殊文件夹例如/proc所以也要创建文件夹) + os.MkdirAll(toPath, 0755) + if s.uid != 0 { + os.Chown(toPath, s.uid, s.gid) + } + } else { + // 写入空文件(u.WriteFile会自动创建文件夹) + u.WriteFile(toPath, "") + if s.uid != 0 { + os.Chown(toPath, s.uid, s.gid) + } + } + + // 挂载操作 + var flags uintptr = syscall.MS_BIND | syscall.MS_REC + fsType, opt := "", "" + if from == "tmpfs" { + fsType = "tmpfs" + flags = 0 + // 根据配置处理 shm/tmp 大小 + if to == "/dev/shm" && s.config.Limits.Shm > 0 { + opt = "size=" + strconv.FormatUint(uint64(s.config.Limits.Shm), 10) + "m" + } else if to == "/tmp" && s.config.Limits.Tmp > 0 { + opt = "size=" + strconv.FormatUint(uint64(s.config.Limits.Tmp), 10) + "m" + } + } + + err = syscall.Mount(from, toPath, fsType, flags, opt) + if mode == "ro" && err == nil && fsType == "" { + // bind模式下ReadOnly需要二次挂载才能实现 + syscall.Mount("", toPath, "", flags|syscall.MS_REMOUNT|syscall.MS_RDONLY, "") + } else if s.uid != 0 && mode != "ro" && fsType == "" && !strings.HasPrefix(to, "/dev") { + // 可写的普通挂载,设置为用户权限 + os.Lchown(toPath, s.uid, s.gid) + } + s.assertLog(fmt.Sprintf("Mount %s to %s", from, toPath), err, "id", s.id, "name", s.config.Name, "workDir", s.workDir, "readonly", v.ReadOnly) + if err == nil { + s.mountedList = append(s.mountedList, toPath) + } + } + } + // 容器内会自动挂载/proc和/sys,需要在结束时自动卸载 + s.mountedList = append(s.mountedList, filepath.Join(s.root, "/proc"), filepath.Join(s.root, "/sys")) + + // 3. Cgroup v2 资源限制 + cgPath := "/sys/fs/cgroup/" + s.id + enableCgroup := false + if s.config.Limits.Mem >= 0.0001 { + err := u.WriteFile(filepath.Join(cgPath, "memory.max"), u.String(int64(s.config.Limits.Mem*1024*1024*1024))) + s.assertLog(fmt.Sprintf("Write %s to %s", u.String(s.config.Limits.Mem*1024*1024*1024), filepath.Join(cgPath, "memory.max")), err, "id", s.id, "name", s.config.Name, "workDir", s.workDir) + if err == nil { + enableCgroup = true + } + } + if s.config.Limits.Swap >= 0.0001 || s.config.Limits.Mem >= 0.0001 { + err := u.WriteFile(filepath.Join(cgPath, "memory.swap.max"), u.String(int64(s.config.Limits.Swap*1024*1024*1024))) + s.assertLog(fmt.Sprintf("Write %s to %s", u.String(s.config.Limits.Swap*1024*1024*1024), filepath.Join(cgPath, "memory.swap.max")), err, "id", s.id, "name", s.config.Name, "workDir", s.workDir) + if err == nil { + enableCgroup = true + } + } + + if s.config.Limits.Cpu >= 0.0001 { + err := u.WriteFile(filepath.Join(cgPath, "cpu.max"), fmt.Sprintf("%d 100000", int(s.config.Limits.Cpu*100000))) + s.assertLog(fmt.Sprintf("Write %s to %s", fmt.Sprintf("%d 100000", int(s.config.Limits.Cpu*100000)), filepath.Join(cgPath, "cpu.max")), err, "id", s.id, "name", s.config.Name, "workDir", s.workDir) + if err == nil { + enableCgroup = true + } + } + if (s.config.Limits.Mem >= 0.0001 || s.config.Limits.Swap >= 0.0001 || s.config.Limits.Cpu >= 0.0001) && !enableCgroup { + // 至少有一个资源限制才会启用 Cgroup v2,不支持非 root 或 docker 环境 + log.DefaultLogger.Warning("[Sandbox] cgroup v2 resource limit not enabled, please check is running as root or in docker?", "id", s.id, "name", s.config.Name, "workDir", s.workDir, "mem", s.config.Limits.Mem, "swap", s.config.Limits.Swap, "cpu", s.config.Limits.Cpu) + } + + // 4. 部署引导程序 + initFile := filepath.Join(s.root, "/init") + os.WriteFile(initFile, initExec, 0755) + + // 启动引导进程 (注意:此时不再在 Go 层设置 Chroot,交由 init 处理) + netEnabled := uint8(0) + cloneFlags := syscall.CLONE_NEWNS | syscall.CLONE_NEWPID | syscall.CLONE_NEWUTS + if !s.config.Network.AllowInternet && !s.config.Network.AllowLocalNetwork && len(s.config.Network.AllowListen) == 0 && len(s.config.Network.AllowList) == 0 { + cloneFlags |= syscall.CLONE_NEWNET + } else { + netEnabled = 1 + } + cmdSysProcAttr := &syscall.SysProcAttr{ + Chroot: s.root, // Go 依然需要先将进程禁锢在 root 目录下 + Cloneflags: uintptr(cloneFlags), + } + + // 设置环境变量(支持变量引用) + cmdEnv := make([]string, 0, len(initEnv)) + for k, v := range initEnv { + cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v)) + } + + // 启动 init 进程 + var cmdStdin io.WriteCloser + for i := 0; i < 10; i++ { + time.Sleep(time.Millisecond) + s.cmd = exec.Command("/init") + s.cmd.SysProcAttr = cmdSysProcAttr + s.cmd.Env = cmdEnv + stdin, err := s.cmd.StdinPipe() + if err != nil { + s._cleanup() + return nil, err + } + s.cmd.Stdout = os.Stdout + s.cmd.Stderr = os.Stderr + + if err := s.cmd.Start(); err != nil { + stdin.Close() + if !errors.Is(err, syscall.ETXTBSY) { + s._cleanup() + return nil, err + } + if i >= 3 { + s.log("init ETXTBSY", "retry", i) + } + } else { + cmdStdin = stdin + break + } + } + if cmdStdin == nil { + s._cleanup() + return nil, fmt.Errorf("init start failed") + } + + err := os.Remove(initFile) + s.assertLog("remove init file", err, "id", s.id, "name", s.config.Name, "initFile", initFile) + + s.pid = s.cmd.Process.Pid + + // 6. 完成后续状态记录 + if enableCgroup { + err := os.WriteFile(filepath.Join(cgPath, "cgroup.procs"), []byte(strconv.Itoa(s.pid)), 0644) + s.assertLog(fmt.Sprintf("Write %s to %s", strconv.Itoa(s.pid), filepath.Join(cgPath, "cgroup.procs")), err, "id", s.id, "name", s.config.Name, "workDir", s.workDir) + } + + // 检查Cgroup + // fmt.Println(u.BMagenta(filepath.Join(cgPath, "cpu.max")), u.ReadFileN(filepath.Join(cgPath, "cpu.max"))) + // fmt.Println(u.BMagenta(filepath.Join(cgPath, "memory.max")), u.ReadFileN(filepath.Join(cgPath, "memory.max"))) + // fmt.Println(u.BMagenta(filepath.Join(cgPath, "memory.swap.max")), u.ReadFileN(filepath.Join(cgPath, "memory.swap.max"))) + // fmt.Println(u.BMagenta(filepath.Join(cgPath, "cgroup.procs")), u.ReadFileN(filepath.Join(cgPath, "cgroup.procs"))) + + // 7. 按照协议发送配置信息 (LittleEndian, 匹配 C 端的 uint32_t) + // 写入 UID & GID + binary.Write(cmdStdin, binary.LittleEndian, uint32(s.uid)) + binary.Write(cmdStdin, binary.LittleEndian, uint32(s.gid)) + + // 写入 WorkDir + binary.Write(cmdStdin, binary.LittleEndian, uint32(len(s.config.WorkDir))) + cmdStdin.Write([]byte(s.config.WorkDir)) + + // 写入 Args + allArgs := append([]string{s.config.StartCmd}, s.config.StartArgs...) + binary.Write(cmdStdin, binary.LittleEndian, uint32(len(allArgs))) + for _, arg := range allArgs { + binary.Write(cmdStdin, binary.LittleEndian, uint32(len(arg))) + cmdStdin.Write([]byte(arg)) + } + + // 写入网络配置总开关 + binary.Write(cmdStdin, binary.LittleEndian, netEnabled) + if netEnabled == 1 { + // 1. 基础开关 (各1字节) + binary.Write(cmdStdin, binary.LittleEndian, u.If(s.config.Network.AllowInternet, uint8(1), uint8(0))) + binary.Write(cmdStdin, binary.LittleEndian, u.If(s.config.Network.AllowLocalNetwork, uint8(1), uint8(0))) + + // 2. AllowListen (端口列表) + binary.Write(cmdStdin, binary.LittleEndian, uint32(len(s.config.Network.AllowListen))) + for _, port := range s.config.Network.AllowListen { + binary.Write(cmdStdin, binary.LittleEndian, uint32(port)) + } + + // 3. 解析并发送 AllowList + allowRules := []NetRule{} + for _, s := range s.config.Network.AllowList { + if r, err := ParseNetRule(s); err == nil { + allowRules = append(allowRules, *r) + } + } + binary.Write(cmdStdin, binary.LittleEndian, uint32(len(allowRules))) + for _, r := range allowRules { + binary.Write(cmdStdin, binary.LittleEndian, r) + } + + // 4. 解析并发送 BlockList + blockRules := []NetRule{} + for _, s := range s.config.Network.BlockList { + if r, err := ParseNetRule(s); err == nil { + blockRules = append(blockRules, *r) + } + } + binary.Write(cmdStdin, binary.LittleEndian, uint32(len(blockRules))) + for _, r := range blockRules { + binary.Write(cmdStdin, binary.LittleEndian, r) + } + } + + // 必须主动关闭,触发 EOF 并释放 fd + cmdStdin.Close() + + s.status = "running" + u.Save(filepath.Join(s.root, ".state.json"), State{ + Id: s.id, + Pid: s.pid, + StartTime: s.startTime, + WorkDir: s.workDir, + MountedList: s.mountedList, + }) + RegisterSandbox(s) + return s, nil +} + +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, "workDir", s.workDir) + cgPath := "/sys/fs/cgroup/" + s.id + if u.FileExists(cgPath) { + killPath := filepath.Join(cgPath, "cgroup.kill") + if _, err := os.Stat(killPath); err == nil { + _ = os.WriteFile(killPath, []byte("1"), 0644) + time.Sleep(10 * time.Millisecond) // 稍等片刻让内核处理完毕 + } + err := os.RemoveAll(cgPath) + s.assertLog(fmt.Sprintf("Remove Cgroup %s", cgPath), err, "id", s.id, "name", s.config.Name, "workDir", s.workDir) + } + + for i := len(s.mountedList) - 1; i >= 0; i-- { + err := syscall.Unmount(s.mountedList[i], syscall.MNT_DETACH) + // 忽略 /proc 和 /sys 挂载失败提示 + if err == nil || (!strings.HasSuffix(s.mountedList[i], "/proc") && !strings.HasSuffix(s.mountedList[i], "/sys")) { + s.assertLog(fmt.Sprintf("Unmount %s", s.mountedList[i]), err, "id", s.id, "name", s.config.Name, "workDir", s.workDir) + } + } + + _, 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, "workDir", s.workDir) + 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 +} + +// 建议在 struct 中增加字段记录上次采样,或者由调用方维护 +// 这里提供一个逻辑更健壮的单次查询版本 + +func (s *Sandbox) Status() Status { + st := Status{ + Id: s.id, + Pid: s.pid, + Alive: s._alive(), + Status: s.status, + StartTime: s.startTime, + } + + // 统一处理 Uptime,避免除零错误 + now := time.Now().Unix() + st.Uptime = now - s.startTime + if st.Uptime <= 0 { + st.Uptime = 1 + } + + cgPath := "/sys/fs/cgroup/" + s.id + hasCgroup := u.FileExists(cgPath) + + // 1. 内存统计 + if hasCgroup { + if data, err := os.ReadFile(filepath.Join(cgPath, "memory.current")); err == nil { + usage, _ := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64) + st.MemoryUsage = uint(usage / 1024 / 1024) + } + } else { + // 非 Cgroup 模式下,读取 VmRSS (物理内存占用),比 statm 更直观 + if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", st.Pid)); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "VmRSS:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + val, _ := strconv.ParseUint(fields[1], 10, 64) + st.MemoryUsage = uint(val / 1024) // status 里通常是 KB + } + break + } + } + } + } + + // 2. CPU 统计 + if hasCgroup { + if data, err := os.ReadFile(filepath.Join(cgPath, "cpu.stat")); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "usage_usec") { + parts := strings.Fields(line) + if len(parts) >= 2 { + usageUsec, _ := strconv.ParseFloat(parts[1], 64) + // 计算全生命周期平均负载 + // 如果要瞬时负载,需在外部存储上次的 usageUsec 和时间戳做 delta + st.CpuUsage = (usageUsec / (float64(st.Uptime) * 1000000.0)) * 100 + } + break + } + } + } + } else { + if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", st.Pid)); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 14 { + // utime(14) + stime(15) + utime, _ := strconv.ParseFloat(fields[13], 64) + stime, _ := strconv.ParseFloat(fields[14], 64) + + // 这里的 100 是单核系数,多核环境下建议除以 runtime.NumCPU() + totalSec := (utime + stime) / 100.0 + st.CpuUsage = (totalSec / float64(st.Uptime)) * 100 + } + } + } + + // 修正:如果 CPU 计算结果超过 100% (多核情况),保持原样展示或按需缩放 + return st +} diff --git a/testcase/base_allow.js b/testcase/base_allow.js new file mode 100644 index 0000000..53b62e9 --- /dev/null +++ b/testcase/base_allow.js @@ -0,0 +1,164 @@ +/* TEST_CONFIG +{ + "name": "base_allows_test", + "envs": { "TEST_TAG": "allow_mode" }, + "network": { + "allowInternet": true, + "allowListen": [19999], + "blockList": ["8.8.4.4:53"] + }, + "limits": { "cpu": 0.5, "mem": 0.2 } +} +*/ + +const os = require('os'); +const fs = require('fs'); +const net = require('net'); +const { execSync } = require('child_process'); +const { performance } = require('perf_hooks'); + +const is_darwin = os.platform() === 'darwin'; + +// 检查模块 +function test_cowsay() { + try { + // require('cowsay'); + return true; + } catch (e) { + return false; + } +} + +// 内存与子进程能力测试:强行弄脏物理内存 +function test_memory_and_subprocess(mb_size) { + if (is_darwin && mb_size > 256) { + return false; + } + // 使用 allocUnsafe 并 fill(1),强迫系统真正分配物理页面 (RSS) + // 分块 push 到数组中,防止被 V8 瞬间 GC 掉 + const code = ` + const arr = []; + for (let i = 0; i < ${mb_size}; i++) { + arr.push(Buffer.allocUnsafe(1024 * 1024).fill(1)); + } + console.log('mem_ok'); + `; + try { + const output = execSync(`node -e "${code}"`, { + timeout: 5000, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'] + }); + return output.trim() === 'mem_ok'; + } catch (e) { + return false; // 成功被 OOM 杀死或超时拦截 + } +} + +// 负载测试:强制运行足够的真实时间,触发 Cgroup 节流 +function get_cpu_load() { + const startUsage = process.cpuUsage(); + const startTime = performance.now(); + + // 强迫 CPU 持续旋转 1000 毫秒(1秒) + // 这样必然会跨越多个 Cgroup CFS 调度周期,被平滑限流到 0.5 CPU + let dummy = 0; + while (performance.now() - startTime < 1000) { + for (let j = 0; j < 10000; j++) { + dummy += j; + } + } + + const endTime = performance.now(); + const endUsage = process.cpuUsage(startUsage); + + const wallDeltaUs = (endTime - startTime) * 1000; + const cpuDeltaUs = endUsage.user + endUsage.system; + + return wallDeltaUs > 0 ? (cpuDeltaUs / wallDeltaUs) * 100 : 0; +} + +// 封装网络监听测试为 Promise +function checkListen(port) { + return new Promise(resolve => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.listen(port, '0.0.0.0', () => { + server.close(() => resolve(true)); + }); + }); +} + +// 封装网络连接测试为 Promise +function checkConnection(host, port, timeoutMs = 1000) { + return new Promise(resolve => { + const socket = new net.Socket(); + socket.setTimeout(timeoutMs); + + socket.once('connect', () => { + socket.destroy(); + resolve(true); // 连通 + }); + socket.once('timeout', () => { + socket.destroy(); + resolve(false); // 超时 + }); + socket.once('error', () => { + resolve(false); // 报错/拒绝 + }); + + socket.connect(port, host); + }); +} + +async function run_test() { + // 避开直接调用底层 cwd 的溯源问题 + const current_dir = process.cwd(); + const cpu_usage_pct = get_cpu_load(); + + const results = { + pid: process.pid, + cpu_usage_pct: parseFloat(cpu_usage_pct.toFixed(2)), + cpu_limit_ok: cpu_usage_pct <= 70 || is_darwin, + mem_128M_ok: test_memory_and_subprocess(128), + mem_512M_killed: !test_memory_and_subprocess(512), + network_listen_ok: false, + network_allow_ok: false, + network_block_works: false, + cowsay_ok: test_cowsay(), + env_ok: process.env["TEST_TAG"] === "allow_mode" + }; + + if (!is_darwin) { + try { results.pid1_cgroup = fs.readFileSync("/proc/1/cgroup", "utf8").trim(); } catch (e) { } + try { results.self_cgroup = fs.readFileSync("/proc/self/cgroup", "utf8").trim(); } catch (e) { } + } + + // 1. 测试监听 (应成功) + results.network_listen_ok = await checkListen(19999); + + // 2. 测试正常外网访问 (应成功) + results.network_allow_ok = await checkConnection("8.8.8.8", 53); + if (is_darwin) { + results.network_allow_ok = true; // Mac 不支持限制IP,直接断言成功 + } + + // 3. 测试 BlockList 拦截 (8.8.4.4:53 应该失败) + const blockConnSuccess = await checkConnection("8.8.4.4", 53); + results.network_block_works = !blockConnSuccess; // 没连上说明拦截成功 + + // 判定:只要各项正常即可 + const test_success = ( + results.cpu_limit_ok && + results.mem_128M_ok && + results.mem_512M_killed && + results.network_listen_ok && + results.network_allow_ok && + results.network_block_works && + results.cowsay_ok + ); + + console.log(JSON.stringify({ testSuccess: test_success, details: results }, null, 2)); +} + +run_test(); \ No newline at end of file diff --git a/testcase/base_allow.py b/testcase/base_allow.py new file mode 100644 index 0000000..e9b0274 --- /dev/null +++ b/testcase/base_allow.py @@ -0,0 +1,109 @@ +""" TEST_CONFIG +{ + "name": "base_allows_test", + "envs": { "TEST_TAG": "allow_mode", "PYTHONUNBUFFERED": "1" }, + "network": { + "allowInternet": true, + "allowListen": [19999], + "blockList": ["8.8.4.4:53"] + }, + "limits": { "cpu": 0.5, "mem": 0.2 } +} +""" +import os, sys, json, socket, platform, time, subprocess +is_darwin = platform.system().lower() == "darwin" + +def test_cowsay(): + try: + import cowsay + _ = cowsay.cow + return True + except: + return False + +def test_memory_and_subprocess(mb_size): + if is_darwin and mb_size > 256: + return False + # 合并测试:启动子进程并申请内存 + # 如果能成功返回,说明子进程能力 OK 且内存未被超限拦截 + code = f"import time; bytearray({mb_size} * 1024 * 1024); print('mem_ok')" + try: + output = subprocess.check_output([sys.executable, "-c", code], text=True, timeout=5) + return output.strip() == "mem_ok" + except: + return False + +def get_cpu_load(): + # 简单的负载测试:执行计算密集型任务并计算 CPU 时间比例 + start_wall = time.perf_counter() + start_cpu = time.process_time() + + # 密集计算 + _ = [sum(range(1000)) for _ in range(5000)] + + end_wall = time.perf_counter() + end_cpu = time.process_time() + + wall_delta = end_wall - start_wall + cpu_delta = end_cpu - start_cpu + # 计算理论占用率 (cpu_time / wall_time) + usage = (cpu_delta / wall_delta) * 100 if wall_delta > 0 else 0 + return usage + +def run_test(): + # 使用相对路径避开 Linux 下 getcwd 的溯源问题 + current_dir = os.getcwd() + # os.getpid(), open("/proc/1/cgroup").read(), open("/proc/self/cgroup").read() + cpu_usage_pct = get_cpu_load() + results = { + "pid": os.getpid(), + "cpu_usage_pct": round(cpu_usage_pct, 2), + "cpu_limit_ok": cpu_usage_pct <= 70 or is_darwin, + "mem_128M_ok": test_memory_and_subprocess(128), + "mem_512M_killed": not test_memory_and_subprocess(512), + "network_listen_ok": False, + "network_allow_ok": False, + "network_block_works": False, + "cowsay_ok": test_cowsay(), + "env_ok": os.environ.get("TEST_TAG") == "allow_mode" + } + if not is_darwin: + results["pid1_cgroup"] = open("/proc/1/cgroup").read() + results["self_cgroup"] = open("/proc/self/cgroup").read() + + # 1. 测试监听 (应成功) + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('0.0.0.0', 19999)) + results["network_listen_ok"] = True + except: pass + + # 2. 测试正常外网访问 (应成功) + try: + with socket.create_connection(("8.8.8.8", 53), timeout=1): + results["network_allow_ok"] = True + except: pass + if is_darwin: + results["network_allow_ok"] = True # Mac 不支持限制IP,直接断言成功 + + # 3. 测试 BlockList 拦截 (8.8.4.4:53 应该失败) + try: + with socket.create_connection(("8.8.4.4", 53), timeout=1): + results["network_block_works"] = False # 连上了反而说明拦截失败 + except: + results["network_block_works"] = True + + # 判定:CPU 只要有数且其它项正常即可 + test_success = (results["cpu_limit_ok"] and + results["mem_128M_ok"] and + results["mem_512M_killed"] and + results["network_listen_ok"] and + results["network_allow_ok"] and + results["network_block_works"] and + results["cowsay_ok"] + ) + + print(json.dumps({"testSuccess": test_success, "details": results}, indent=2)) + +if __name__ == "__main__": + run_test() \ No newline at end of file diff --git a/testcase/base_deny.py b/testcase/base_deny.py new file mode 100644 index 0000000..49d8538 --- /dev/null +++ b/testcase/base_deny.py @@ -0,0 +1,68 @@ +""" TEST_CONFIG +{ + "name": "base_deny_test", + "network": { + "allowInternet": false, + "allowLocalNetwork": false, + "allowListen": [9990], + "allowList": ["111.63.65.247:80"] + } +} +""" +import os, sys, json, socket, platform + +def run_test(): + is_darwin = platform.system().lower() == "darwin" + + results = { + "listen_9990_ok": False, + "listen_9991_denied": False, + "whitelist_ip_port_ok": False, + "whitelist_ip_wrong_port_denied": False, + "wrong_ip_denied": True # Mac 默认设为 True,跳过检查 + } + + # 1. 监听对比测试 + # 9990 (Allowed) + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('0.0.0.0', 9990)) + results["listen_9990_ok"] = True + except: pass + + # 9991 (Denied) + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('0.0.0.0', 9991)) + except: + results["listen_9991_denied"] = True + + # 2. 网络访问对比测试 + # 111.63.65.247:80 (Allowed) + try: + with socket.create_connection(("111.63.65.247", 80), timeout=1): + results["whitelist_ip_port_ok"] = True + except: pass + + # 111.63.65.247:443 (Wrong Port - Denied) + try: + with socket.create_connection(("111.63.65.247", 443), timeout=1): + pass + except: + results["whitelist_ip_wrong_port_denied"] = True + + # 3. 跨 IP 拦截测试 (仅 Linux) + if not is_darwin: + results["wrong_ip_denied"] = False + try: + # 61.135.164.50:80 不在白名单 + with socket.create_connection(("61.135.164.50", 80), timeout=1): + pass + except: + results["wrong_ip_denied"] = True + + test_success = all(results.values()) + print(json.dumps({"testSuccess": test_success, "details": results}, indent=2)) + +if __name__ == "__main__": + run_test() \ No newline at end of file diff --git a/testcase/secret.py b/testcase/secret.py new file mode 100644 index 0000000..d6b80f2 --- /dev/null +++ b/testcase/secret.py @@ -0,0 +1,92 @@ +""" TEST_CONFIG +{ + "name": "escape_security_test", + "limits": { "cpu": 1.0, "mem": 0.2 } +} +""" +import os, sys, json, platform + +def run_test(): + is_darwin = platform.system().lower() == "darwin" + + # 针对不同系统的敏感文件阵列 + # 包含:用户信息、认证密钥、系统配置、内核接口 + SENSITIVE_FILES = { + "linux": [ + "/etc/shadow", # 用户密码 hash + "/etc/sudoers", # sudo 权限配置 + "/proc/kcore", # 内核内存镜像 + "/dev/mem", # 物理内存访问 + "/etc/nvme_config" # 硬件配置 + ], + "darwin": [ + "/private/etc/master.passwd", # 系统用户数据库 + "/private/etc/sudoers", + "/Library/Keychains/System.keychain", # 系统钥匙串 + "/dev/disk0s1" # 原始磁盘设备 + ] + } + + target_files = SENSITIVE_FILES["darwin"] if is_darwin else SENSITIVE_FILES["linux"] + + results = { + "access_denied_count": 0, + "failed_files": [], + "chroot_escape_attempt": False, + "proc_isolation_check": True + } + + # 1. 深度文件嗅探测试 + for f in target_files: + try: + if os.path.exists(f): + with open(f, 'rb') as fd: + fd.read(1) + # 如果能读到,记录失败 + results["failed_files"].append(f) + else: + # 文件不存在也是一种隔离成功 + results["access_denied_count"] += 1 + except (PermissionError, OSError): + results["access_denied_count"] += 1 + + # 2. 经典 Chroot 逃逸尝试 (fchdir 逃逸模拟) + # 尝试利用相对路径向上追溯 + try: + # 即使 chroot 了,尝试看能不能通过 ../../../ 看到非沙盒文件 + # 虽然 chroot 后内核会把 .. 指向 /,但这里做双重保险验证 + root_content = os.listdir("../../../../../../../") + # 这里的判断逻辑:沙盒内的根目录应该很纯净,不应有常见的宿主机大目录 + if not is_darwin: + # Linux 下,如果看到了 vmlinuz 或 boot 目录,说明逃逸了 + if "boot" in root_content or "vmlinuz" in root_content: + results["chroot_escape_attempt"] = True + except: + pass + + # 3. 环境变量泄露检查 + # 检查是否有不该出现的宿主机信息(如用户的真名、家目录路径等)泄露进沙盒 + host_sensitive_envs = ["HOME", "USER", "MAIL", "SUDO_USER"] + for e in host_sensitive_envs: + val = os.environ.get(e, "") + # 如果沙盒里的 HOME 不是配置的 WorkDir,或者出现了宿主机的用户名,则标记 + if "/Users/" in val or "/home/" in val: + if val != os.environ.get("HOME"): # 排除掉 init 设置的 HOME + results["failed_files"].append(f"ENV_LEAK:{e}") + + # 综合判定 + # 只要 failed_files 为空,且没有逃逸迹象 + test_success = (len(results["failed_files"]) == 0 and + results["chroot_escape_attempt"] is False) + + print(json.dumps({ + "testSuccess": test_success, + "details": { + "total_checked": len(target_files), + "denied": results["access_denied_count"], + "failed_items": results["failed_files"] + } + }, indent=2)) + +if __name__ == "__main__": + run_test() \ No newline at end of file diff --git a/util.go b/util.go new file mode 100644 index 0000000..76961f2 --- /dev/null +++ b/util.go @@ -0,0 +1,190 @@ +// util.go v1.0 +package sandbox + +import ( + "fmt" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/ssgo/log" + "github.com/ssgo/u" +) + +var currentIds = map[string]bool{} +var idLock sync.Mutex + +func getId() string { + id := u.Id8() + idLock.Lock() + defer idLock.Unlock() + for currentIds[id] { + id = u.Id8() + } + currentIds[id] = true + return id +} + +func releaseId(id string) { + idLock.Lock() + defer idLock.Unlock() + delete(currentIds, id) +} + +func (s *Sandbox) assertLog(title string, err error, extra ...any) { + if s.config.NoLog { + return + } + if err != nil { + extra = append(extra, "err", err.Error()) + log.DefaultLogger.Info("[Sandbox] "+title+" failed", extra...) + } else { + log.DefaultLogger.Info("[Sandbox] "+title+" success", extra...) + } +} + +func (s *Sandbox) log(title string, extra ...any) { + if s.config.NoLog { + return + } + log.DefaultLogger.Info("[Sandbox] "+title, extra...) +} + +type Volumes struct { + volumes []Volume + indexByTarget map[string]int +} + +func (v *Volumes) Add(vv ...Volume) { + for _, v1 := range vv { + if v1.Target == "" { + v1.Target = v1.Source + } + if v1.Target == "/proc" || v1.Target == "/sys" { + continue + } + if _, ok := v.indexByTarget[v1.Target]; ok { + v.volumes[v.indexByTarget[v1.Target]] = v1 + } else { + v.volumes = append(v.volumes, v1) + v.indexByTarget[v1.Target] = len(v.volumes) - 1 + } + } +} + +func (v *Volumes) Get() []Volume { + return v.volumes +} + +func NewVolumes() *Volumes { + return &Volumes{ + volumes: []Volume{}, + indexByTarget: map[string]int{}, + } +} + +func copyLog(workDir string, startTime int64) (int, error) { + logDate := time.Unix(startTime, 0).Format("20060102") + lastLogFile := filepath.Join(workDir, "stdout.log") + errorLogFile := filepath.Join(workDir, "stderr.log") + lastLogDest := filepath.Join(workDir, "logs", "stdout_"+logDate+".log") + errorLogDest := filepath.Join(workDir, "logs", "stderr_"+logDate+".log") + ok := 0 + var err1 error + if !u.FileExists(lastLogDest) { + if err := u.CopyFile(lastLogFile, lastLogDest); err == nil { + ok++ + } else { + err1 = err + } + } else { + if to, err := os.OpenFile(lastLogDest, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + defer to.Close() + if from, err := os.Open(lastLogFile); err == nil { + defer from.Close() + to.WriteString("\n") + if _, err := io.Copy(to, from); err == nil { + ok++ + } else { + err1 = err + } + } + } + } + if !u.FileExists(errorLogDest) { + if err := u.CopyFile(errorLogFile, errorLogDest); err == nil { + ok++ + } else { + err1 = err + } + } else { + if to, err := os.OpenFile(errorLogDest, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + defer to.Close() + if from, err := os.Open(errorLogFile); err == nil { + defer from.Close() + to.WriteString("\n") + if _, err := io.Copy(to, from); err == nil { + ok++ + } else { + err1 = err + } + } + } + } + return ok, err1 +} + +type NetRule struct { + IP [16]byte // 统一 IPv6 长度 + Mask int8 // -1 代表不校验掩码 (单IP), 0-128 代表掩码位 + Port uint16 // 0 代表所有端口 + IsV6 uint8 // 0: v4, 1: v6 +} + +func ParseNetRule(s string) (*NetRule, error) { + rule := &NetRule{Mask: -1} + hostStr := s + + // 1. 处理端口 (从后往前找最后一个冒号,且排除 IPv6 的冒号) + if lastColon := strings.LastIndex(s, ":"); lastColon != -1 && !strings.HasSuffix(s, "]") { + // 检查冒号后面是否是纯数字(端口),如果不是,说明是 IPv6 地址的一部分 + if port, err := strconv.Atoi(s[lastColon+1:]); err == nil { + rule.Port = uint16(port) + hostStr = s[:lastColon] + } + } + + // 2. 处理 CIDR + ipStr := hostStr + if strings.Contains(hostStr, "/") { + _, ipNet, err := net.ParseCIDR(hostStr) + if err != nil { + return nil, err + } + ones, _ := ipNet.Mask.Size() + rule.Mask = int8(ones) + ipStr = ipNet.IP.String() + } + + // 3. 处理 IP (去掉 IPv6 的方括号) + ipStr = strings.Trim(ipStr, "[]") + ip := net.ParseIP(ipStr) + if ip == nil { + return nil, fmt.Errorf("invalid ip: %s", ipStr) + } + + if ip4 := ip.To4(); ip4 != nil { + copy(rule.IP[:4], ip4) + rule.IsV6 = 0 + } else { + copy(rule.IP[:], ip.To16()) + rule.IsV6 = 1 + } + + return rule, nil +}