first version
supported macOS、linux
This commit is contained in:
parent
798c4f5c08
commit
f9dcf07ba4
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
214
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 文件。
|
||||
117
base_test.js
Normal file
117
base_test.js
Normal 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
32
go.mod
Normal 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
|
||||
)
|
||||
342
init/init.c
Normal file
342
init/init.c
Normal 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
BIN
macTestApp
Executable file
Binary file not shown.
167
master.go
Normal file
167
master.go
Normal 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
163
nodejsRuntime.go
Normal 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
66
plugin.go
Normal 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
67
plugin_test.go
Normal 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
166
pythonRuntime.go
Normal 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
66
runtime.go
Normal 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
17
runtime_linux.go
Normal 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
10
runtime_other.go
Normal 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
101
sandbox.go
Normal 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
367
sandbox_darwin.go
Normal 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
652
sandbox_linux.go
Normal 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
164
testcase/base_allow.js
Normal 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
109
testcase/base_allow.py
Normal 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
68
testcase/base_deny.py
Normal 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
92
testcase/secret.py
Normal 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
190
util.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user