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
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
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