初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+303
View File
@@ -0,0 +1,303 @@
# 乾坤 · 魂镜安全沙箱服务(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 | - | ❌ | ❌ |