first version

supported macOS、linux
This commit is contained in:
Star 2026-03-23 00:35:27 +08:00
parent 798c4f5c08
commit f9dcf07ba4
23 changed files with 3186 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
.*
!.gitignore
go.sum
node_modules
package.json
env.yml
/data
release
build
pub
/c
/t
/tl
/linuxTestApp
/buildInit
/sandbox

214
README.md
View File

@ -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 机制控制网络访问
- **资源限制**:使用 cgroupsLinux或等效机制限制资源使用
- **进程隔离**:创建独立的进程环境
### 核心 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 文件。

117
base_test.js Normal file
View File

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

32
go.mod Normal file
View File

@ -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
)

BIN
init.gz Executable file

Binary file not shown.

342
init/init.c Normal file
View File

@ -0,0 +1,342 @@
// init.c v2.1
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdint.h>
#include <string.h>
#include <stddef.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/prctl.h>
#include <sys/ioctl.h>
#include <sys/uio.h>
#include <sys/syscall.h>
#include <sys/mount.h>
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#include <signal.h>
#include <poll.h> // 新增
// #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;
}

BIN
macTestApp Executable file

Binary file not shown.

167
master.go Normal file
View File

@ -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")
}

163
nodejsRuntime.go Normal file
View File

@ -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")
}

66
plugin.go Normal file
View File

@ -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()
}

67
plugin_test.go Normal file
View File

@ -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)
}

166
pythonRuntime.go Normal file
View File

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

66
runtime.go Normal file
View File

@ -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...)
}

17
runtime_linux.go Normal file
View File

@ -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),
},
}
}

10
runtime_other.go Normal file
View File

@ -0,0 +1,10 @@
//go:build !linux
package sandbox
import (
"os/exec"
)
func applyCredential(cmd *exec.Cmd, uid, gid int) {
}

101
sandbox.go Normal file
View File

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

367
sandbox_darwin.go Normal file
View File

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

652
sandbox_linux.go Normal file
View File

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

164
testcase/base_allow.js Normal file
View File

@ -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();

109
testcase/base_allow.py Normal file
View File

@ -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()

68
testcase/base_deny.py Normal file
View File

@ -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()

92
testcase/secret.py Normal file
View File

@ -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()

190
util.go Normal file
View File

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