Files
2026-08-21 16:00:13 +08:00

304 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 乾坤 · 魂镜安全沙箱服务(hunjing-sandbox / Sandbox Engine)
> 文化命名「乾坤」(Sandbox Engine),百工枢的隔离执行后端——为器用百工提供一个自成天地、越界即止的封闭执行世界。命名登记见 `docs/contracts/contract-glossary.md` §2.1。
Rust + axum 实现的 HTTP 微服务,为「百工 Tool Runtime」提供**本地隔离的命令执行层**。在系统中的定位:
```mermaid
flowchart LR
Backend["后端 FastAPI"] -->|POST /v1/exec| Sandbox["hunjing-sandbox (Rust)"]
Sandbox --> Child["受限子进程<br/>进程组 / 临时工作目录 / 超时"]
Child --> System["shell / python / 外部命令"]
Sandbox -->|SIGKILL 进程组| Cleanup["超时或取消时清理子进程"]
```
> 安全边界:沙箱只执行**已授权任务**,不做风险判定(由司命 Safety 负责);调用方应在发起前完成权限与风险审核。设计依据见 `docs/architecture/service-boundaries.md` 与 `docs/architecture/service-boundaries.md`。
## 司命授权
`/v1/exec` 受 HMAC 授权中间件保护(`/healthz` 不受保护)。调用方(后端百工客户端)需经司命审核后签发短期凭证,沙箱端验签通过才执行。
- 凭证格式:`<payload_b64url>.<sig_b64url>`,payload 含 `exp / trace_id / risk_level / tool / character_id / run_id / body_hash / rlimit_overrides / file_op_hash`,sig 为 `HMAC-SHA256(SANDBOX_AUTH_SECRET, payload_b64url)`。
- `rlimit_overrides`:司命按 tool policy 授予的资源配额上限(key: `fsize/as/cpu/nproc/nofile`),随 HMAC 不可伪造;sandbox 验签后 clamp 到全局天花板 `SANDBOX_RLIMIT_*_MAX`。属授权属性,不进 `body_hash`。
- 凭证轮换:`SANDBOX_AUTH_SECRET_PREVIOUS`(逗号分隔旧 secret)支持多 secret 并存的无中断轮换——sandbox 先加 previous 认新旧 → backend 切新 secret → TTL 后清 previous。metric `sandbox_auth_secret_used{slot=primary|previous}` 指示轮换进度。
- `body_hash` 为 `SHA256(canonical JSON {argv, stdin})`,把凭证绑死到具体执行内容——凭证在 TTL 内**无法被重放**去执行任意其他命令;两端字节级一致(共享固定向量回归测试守护,见 `sandbox/src/auth.rs` 与 `backend/.../sandbox/auth.py`)。
- `file_op_hash` 为 `SHA256(canonical JSON {op, path})`,把文件 IO 凭证绑死到具体操作(read/write/list/stat)与路径——TTL 内无法重放去读写任意其他文件;与 `body_hash` 互斥(exec 凭证此字段为 null),两端字节级一致(同样由固定向量回归守护)。
- HTTP 头:`Authorization: Bearer <token>`。
- 校验失败统一返回 `401 {"error":{"code":"sandbox_unauthorized", ...}}`。
- `SANDBOX_AUTH_ENABLED=false` 时跳过校验(仅本地联调,生产禁用)。
后端客户端实现见 `backend/app/integrations/sandbox/`(签发 + 调用 + 一炁事件回写,两端 HMAC 字节级一致,已通过跨语言闭环验证)。
## 隔离措施
| 维度 | 措施 |
| --- | --- |
| 进程 | 独立进程组(`process_group(0)`),超时/取消对整组 `SIGKILL`,清理派生子进程 |
| 环境 | 默认 `env_clear`,仅注入 `SANDBOX_ALLOWED_ENV` 白名单 + 请求指定变量 |
| 工作目录 | 默认每次执行用 base 下临时目录(执行后回收);可指定 base 内子目录,**拒绝路径逃逸**;文件类工具由百工 runtime 按 run/trace 隔离为 `workspace/<run_key>`(key 经 sanitize,防路径注入) |
| 超时 | `min(请求 timeout_ms, 全局 SANDBOX_MAX_TIMEOUT_MS)`,到点强制结束 |
| 客户端断开 | 流式执行(`/v1/exec/stream`)感知接收端关闭(rx drop / SSE 断连)后主动对进程组 `SIGKILL` 取消并收尸,避免已离开用户的长任务(如 `sleep 30`)占满并发闸;覆盖无输出命令(`capture_streaming` 的 send-err 检测对无输出命令失效) |
| 输出 | stdout / stderr 各自截断至 `max_output_bytes`,超出标记 `truncated` 并继续 drain 防管道阻塞 |
| 残留清理 | 启动时清理 base 下 `.tmp*` 临时目录(tempfile 异常退出残留),不触及 `workspace/` 持久目录 |
| 资源(进程) | fork 后、exec 前 `setrlimit` 限制单文件写入 / 虚拟内存 / CPU / 子进程数 / 打开文件数(软=硬),与容器限额形成纵深;配额分层(全局天花板 → 凭证授予 → 请求收紧)见下文 |
| 资源(容器) | Docker 侧 `deploy.resources.limits` 约束 memory / cpus / pids,`/tmp` 走 tmpfs 限大小 |
| 网络(容器) | sandbox 仅接入 internal 专属网络 `hunjing-sandbox-net`,**无公网出站**,防数据外泄;后端经该网络以服务名访问 |
| 权限(容器) | 只读根文件系统(仅 `/tmp` tmpfs 与 `sandbox-work` bind mount 可写)、`no-new-privileges`、`cap_drop: ALL`、Docker 默认 seccomp profile |
## HTTP API
### `GET /healthz`
```json
{ "status": "ok", "service": "hunjing-sandbox", "version": "0.1.0" }
```
### `GET /metrics`
Prometheus 指标(`text/plain; version=0.0.4`),不鉴权,供抓取:
| 指标 | 类型 | 含义 |
| --- | --- | --- |
| `sandbox_exec_total{outcome}` | counter | 同步执行次数(completed/timeout/cancelled/failed) |
| `sandbox_exec_duration_seconds` | histogram | 同步执行耗时分布(含 `_bucket/_sum/_count`) |
| `sandbox_concurrent` | gauge | 当前并发执行数 |
| `sandbox_concurrent_limit` | gauge | 并发上限(`SANDBOX_MAX_CONCURRENT`) |
| `sandbox_stream_total{outcome}` | counter | 流式执行次数 |
| `sandbox_auth_secret_used{slot}` | counter | 验签命中 secret 分布(primary/previous),指示轮换进度 |
| `sandbox_auth_enabled` | gauge | 鉴权是否开启(1/0);值为 0 时应告警(生产环境鉴权被关) |
### `POST /v1/exec`
请求体:
```json
{
"argv": ["python3", "-c", "print(1 + 1)"],
"stdin": "可选标准输入",
"timeout_ms": 5000,
"workdir": "可选,base 内的相对子目录",
"env": { "GREETING": "hi" },
"max_output_bytes": 1048576,
"trace_id": "可选,透传用"
}
```
响应体:
```json
{
"ok": true,
"exit_code": 0,
"stdout": "2\n",
"stderr": "",
"duration_ms": 42,
"timeout": false,
"cancelled": false,
"truncated": false,
"trace_id": "sbx_18f3a1...",
"isolation_mode": "process"
}
```
支持两种互斥的执行形态:
- **命令模式**:`argv`(首个元素为可执行文件)。
- **代码模式**:`code` + `language`(默认 `python`,P0 仅支持 python)。沙箱转译为 argv(写临时脚本文件 + interpreter)后走执行路径;凭证 `body_hash` 按 `SHA256(canonical JSON {language, code, stdin})` 绑定。
`argv` 与 `code` 同时或都不给出返回 400。响应体 `isolation_mode` 字段(`process` / `container`)回显实际生效的隔离模式:P0 期间凭证下发 `container` 一律降级 `process` 并 warn(L3-L4 真正走容器留待 P1)。
错误统一格式(见跨语言契约):
```json
{ "error": { "code": "exec_timeout", "message": "执行超时" } }
```
> 超时不计入错误码:响应 HTTP 200 且 `timeout: true`(执行被 SIGKILL 中止,`exit_code` 为 null),调用方据 `timeout` 字段判断。
| code | HTTP | 含义 |
| --- | --- | --- |
| `bad_request` | 400 | argv/code 互斥冲突、argv 为空、language 不支持等参数错误 |
| `sandbox_unauthorized` | 401 | 授权凭证缺失 / 过期 / 签名无效 / body_hash 不匹配 |
| `not_found` | 404 | cancel 的 trace_id 不存在或已完成 |
| `service_unavailable` | 503 | 并发闸耗尽 |
| `exec_failed` | 500 | spawn / 等待失败等 |
### `POST /v1/cancel`
按 `trace_id` 取消运行中的任务(对进程组 `SIGKILL`)。请求体 `{ "trace_id": "..." }`,命中返回 `{ "cancelled": true, "trace_id": "..." }`;任务不存在或已完成返回 404 `not_found`。被取消的 `/v1/exec` 调用会以 `cancelled: true`、`ok: false` 返回。
> 安全权衡:cancel 仅中止任务、不执行新命令,危害有限,故凭证验签 + 过期即可,不绑定 trace_id(与 exec 的 body_hash 强绑定不同)。调用方需自行记录 trace_id——exec 未传 trace_id 时由沙箱生成,客户端无从获知,无法取消。
### `POST /v1/exec/stream`
流式执行(SSE):请求体同 `/v1/exec`,响应为 `text/event-stream`,实时推送:
- `event: stdout` / `event: stderr`:data 为 base64(urlsafe-no-pad) 编码的原始字节增量(保真,前端解码为 UTF-8)
- `event: done`:data 为 JSON `{exit_code, duration_ms, timeout, cancelled, truncated, trace_id}`,标志执行结束
- 心跳:每 15s 一个注释行(KeepAlive),防代理空闲断开
后端 `POST /tools/{name}/invoke/stream`(sse-starlette)转发为 `tool.stdout` / `tool.stderr` / `tool.completed` / `tool.requires_confirmation` / `tool.failed` 事件;前端用 fetch+ReadableStream 消费(EventSource 不支持 POST body)。started/completed 落一炁事件存储,stdout/stderr 增量只透传不落库。
### 文件 IO(`/v1/file/read` · `/write` · `/list` · `/stat`)
不经 shell、二进制安全的 base 内文件读写(与 `/v1/exec` 的 shell 路径互补,适合需要精确字节控制或无 shell 语义的场景)。各端点独立校验 `file_op_hash`——凭证的 `{op, path}` 必须与请求一致,否则 401(防重放:read 凭证不能用于读写别的路径)。
| 端点 | 请求体 | 响应 |
| --- | --- | --- |
| `POST /v1/file/read` | `{path, offset?, limit?}` | `{content_b64, size, offset, returned, truncated}`(`content_b64` 为 base64url-no-pad,支持 offset/limit 分页) |
| `POST /v1/file/write` | `{path, content_b64}` | `{bytes_written}`(覆盖写) |
| `POST /v1/file/list` | `{path?}`(默认 `.`) | `{entries:[{name, kind, size}]}`(按名排序,`kind` 为 `file`/`dir`/`other`) |
| `POST /v1/file/stat` | `{path}` | `{kind, size, modified_unix}` |
- `path` 为 base 内相对路径;**词法预检查 + canonicalize + starts_with(base)** 三重校验拒绝路径逃逸(`..` 不得使深度为负,且 `create_dir_all` 前先拒,防越界副作用);符号链接覆盖由 canonicalize 兜底。
- 单次读写受 `SANDBOX_MAX_FILE_BYTES`(默认 1 MiB)约束,超出 read 标 `truncated`、write 直接 400。文件操作在主进程运行(非 exec 子进程),**不受 `RLIMIT_*` 约束**,故用此上限替代。
- 后端 `SandboxClient.file_read/write/list/stat` 自动签发 `file_op_hash` 凭证并回写 `tool.started/tool.completed/tool.failed` 一炁事件(argv 记 `[file:<op>, <path>]` 供审计 input_summary)。
- 无 `delete` 端点(设计上不开放删除,防误删;需清理时由运维直接操作 base 目录)。
## 配置(环境变量)
| 变量 | 默认 | 说明 |
| --- | --- | --- |
| `SANDBOX_HOST` | `0.0.0.0` | 监听地址 |
| `SANDBOX_PORT` | `8090` | 监听端口 |
| `SANDBOX_BASE_WORKDIR` | `../sandbox-work` | 沙箱工作根目录(相对二进制所在目录) |
| `SANDBOX_LOG_DIR` | `../logs` | tracing 文件日志目录(相对二进制所在目录,与后端对齐到项目根 `logs/`);容器内为 `/app/logs` |
| `SANDBOX_MAX_TIMEOUT_MS` | `30000` | 单次执行全局超时上限(ms) |
| `SANDBOX_MAX_CONCURRENT` | `8` | 最大并发执行数 |
| `SANDBOX_MAX_OUTPUT_BYTES` | `1048576` | 单流最大捕获字节数(1 MiB) |
| `SANDBOX_MAX_FILE_BYTES` | `1048576` | 单次文件读写最大字节数(1 MiB);文件 IO 在主进程,不受 `RLIMIT_*` 约束,故用此上限 |
| `SANDBOX_ALLOWED_ENV` | `PATH,HOME,TZ,LANG` | 默认注入子进程的环境变量白名单(逗号分隔) |
| `SANDBOX_AUTH_ENABLED` | `true` | 是否校验司命授权凭证;生产必须为 true |
| `SANDBOX_ISOLATION_DEFAULT` | `process` | 凭证未下发 `isolation_mode` 时的兜底隔离模式(process/container;P0 期间一律按 process) |
| `SANDBOX_AUTH_SECRET` | — | 授权 HMAC 共享密钥(与后端客户端一致),`AUTH_ENABLED=true` 时必填 |
| `SANDBOX_AUTH_TTL_SECONDS` | `30` | 授权凭证有效期(秒) |
| `SANDBOX_AUTH_SECRET_PREVIOUS` | —(空) | 旧 secret(逗号分隔),凭证轮换并存窗口;verify 时与主 secret 一并尝试 |
| `SANDBOX_RLIMIT_FSIZE_BYTES` | `67108864` | 单文件写入上限(64 MiB,普通工具默认) |
| `SANDBOX_RLIMIT_AS_BYTES` | `536870912` | 虚拟内存上限(512 MiB;**仅 Linux 生效**——macOS 的 `RLIMIT_AS` 实为 `RLIMIT_RSS` 别名,不真正限制虚拟地址空间,且 server 自身 AS 常超此值会使 `setrlimit` 返回 EINVAL 导致 spawn 失败,故 macOS 跳过) |
| `SANDBOX_RLIMIT_CPU_SECS` | `max_timeout` 秒 | CPU 秒数上限(对齐全局超时) |
| `SANDBOX_RLIMIT_NPROC` | `64` | 子进程数上限(防 fork bomb;root 运行时自动跳过) |
| `SANDBOX_RLIMIT_NOFILE` | `256` | 打开文件数上限 |
| `SANDBOX_RLIMIT_*_MAX` | = 对应 `*_BYTES/SECS` | 可选,分级场景的全局硬天花板;未设则不允许上探(默认部署等价单一限额) |
| `SANDBOX_RLIMIT_DISABLE` | —(空) | 显式禁用维度(逗号分隔,如 `nproc`) |
| `RUST_LOG` | `info` | tracing 日志级别;日志同时写 stdout(彩色)与 `<SANDBOX_LOG_DIR>/sandbox.log`(按日轮转,当天文件名 `sandbox.log.YYYY-MM-DD`) |
> 后端连接沙箱用 `.env` 中的 `RUST_SANDBOX_URL`(例如 `http://hunjing-sandbox:8090`);本服务的自身配置用上面的 `SANDBOX_*`。
## 本地开发
```bash
cd sandbox
cargo run # 监听 0.0.0.0:8090
# 或指定配置
SANDBOX_PORT=8090 SANDBOX_BASE_WORKDIR=./tmp-work cargo run
# 手动测试
curl -s http://127.0.0.1:8090/healthz
curl -s -X POST http://127.0.0.1:8090/v1/exec \
-H 'Content-Type: application/json' \
-d '{"argv":["python3","-c","print(6*7)"]}'
# 验证超时清理
curl -s -X POST http://127.0.0.1:8090/v1/exec \
-H 'Content-Type: application/json' \
-d '{"argv":["sh","-c","sleep 30"],"timeout_ms":300}'
# 验证工作目录隔离与逃逸拒绝
curl -s -X POST http://127.0.0.1:8090/v1/exec \
-H 'Content-Type: application/json' \
-d '{"argv":["sh","-c","pwd"],"workdir":"../../etc"}'
cargo test # 执行内核单元测试(echo / 超时 / 截断 / 路径逃逸 / stdin+env)
cargo build --release # 产出 release 二进制
```
## Docker
镜像构建见 `docker/sandbox/Dockerfile`,多阶段(`rust` 构建 → `debian-slim` 运行),监听 8090。通过 `docker-compose.app.yml` 的 `--profile sandbox` 启用。
容器级隔离:sandbox 仅接入 internal 网络 `hunjing-sandbox-net`(无公网出站,后端经该网络以服务名 `hunjing-sandbox` 访问),并受 `deploy.resources.limits`(memory / cpus / pids)与 `/tmp` tmpfs 约束。网络拓扑与资源定义见 `docker/docker-compose.app.yml`。容器日志既可经 `docker compose logs hunjing-sandbox`(stdout)查看,也持久化到挂载的项目根 `logs/sandbox.log.YYYY-MM-DD`(与后端 `app.log` 同目录,便于统一收集)。
```bash
docker compose -f docker/docker-compose.infra.yml up -d # 先建网络(如未建)
docker compose -f docker/docker-compose.app.yml --profile sandbox up -d --build hunjing-sandbox
docker compose -f docker/docker-compose.app.yml logs -f hunjing-sandbox
```
## 后续加固(按需,非当前阶段)
- 自定义 seccomp 白名单(当前用 Docker 默认 profile,已阻止约 50 个危险系统调用;自定义白名单维护成本高且易误伤正常命令)。
- workspace 容量配额与按 run 的自动 GC(当前已实现按 run 隔离 + 启动清理 `.tmp*` 残留)。
- 更强隔离形态:nsjail / gVisor / firecracker microVM(远期)。
---
## IoT / 边缘设备部署
乾坤沙箱可在 IoT 硬件上以进程模式运行(无需 Docker),单静态二进制即可部署。
### 交叉编译
```bash
# ARM64 静态编译(零运行时依赖)
rustup target add aarch64-unknown-linux-musl
cargo build --release --target aarch64-unknown-linux-musl
# 产物大小约 5-8 MB
ls -lh target/aarch64-unknown-linux-musl/release/hunjing-sandbox
# 部署
scp target/aarch64-unknown-linux-musl/release/hunjing-sandbox \
root@device:/usr/local/bin/
```
### 资源占用
| 模式 | RSS | 磁盘 |
| ------ | ----- | ------ |
| 进程模式闲置 | ~12 MB | ~5 MB |
| 进程模式执行中 | ~25 MB | - |
| 容器模式(需 Docker) | ~200 MB+ | 镜像额外 ~100 MB |
### 启动(进程模式)
```bash
ssh root@device \
'SANDBOX_DOCKER_ENABLED=false \
SANDBOX_PORT=8090 \
RUST_LOG=info \
/usr/local/bin/hunjing-sandbox'
```
### systemd 托管
```ini
[Unit]
Description=hunjing-sandbox (乾坤安全沙箱)
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/hunjing-sandbox
Environment=SANDBOX_DOCKER_ENABLED=false
Environment=SANDBOX_PORT=8090
Environment=SANDBOX_BASE_WORKDIR=/var/lib/hunjing-sandbox
Environment=SANDBOX_LOG_DIR=/var/log/hunjing-sandbox
Environment=RUST_LOG=info
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### 硬件兼容
| 硬件 | 架构 | 进程模式 | 容器模式 |
| ------ | ------ | --------- | --------- |
| Raspberry Pi 4/5 | ARM64 | ✅ | ✅(需 4GB+) |
| Jetson Orin | ARM64 | ✅ | ✅ |
| RK3588 / 全志 H6 | ARM64 | ✅ | ⚠️(需 4GB+) |
| x86 工控机 / NUC | x86-64 | ✅ | ✅ |
| OpenWRT 路由器 | MIPS/ARM | ⚠️(需确认内核支持) | ❌ |
| ESP32 / MCU | - | ❌ | ❌ |