Stage C Step 2: Docker per-user 容器池 + iptables blocklist 基底
- deploy/sandbox/Dockerfile: python:3.11-slim + tini + iptables + non-root uid via HOST_UID build-arg + 全套 requirements - deploy/sandbox/init.sh: 6 段红线 IPv4 + ::1 + ZCBOT_PG_IPS 环境注入,任一规则失败 fail-fast - core/sandbox/network.py: ensure_network() 创 --internal zcbot-sandbox-net - core/sandbox/pool.py: SandboxPool 容器命名 zcbot-sandbox-<user_id>,per-user asyncio.Lock, in-memory _last_active dict(Docker 23+ 移除 docker update --label-add),hardening flags 全套(read-only / tmpfs / cap-drop ALL + NET_ADMIN / no-new-privileges / pids/mem/cpu limit / bind mount user_root → /workspace) - core/sandbox/__init__.py: 公开 SandboxPool / container_name / setup_pool / NETWORK_NAME Step 2 范围明确不含 AgentLoop 集成 / shell-run_python 切容器 / egress proxy / reaper task —— pool 孤立 commit,Step 3 接入。本地 Windows 无 docker 走不动,Ubuntu 上 5 条 smoke 命令 (build / iptables 段 / non-root uid / read-only / 销毁)写进 RUN.md "Sandbox(Stage C,Ubuntu)" 段。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
48f99cf66d
commit
160e801ab0
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-05-26(Stage C Step 1:Executor 接口骨架 + HostExecutor in-process backend,行为零变化)
|
最后更新:2026-05-26(Stage C Step 2:Docker per-user 容器池 + Dockerfile / init.sh / network ensure,代码就绪未集成 AgentLoop)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-05-26
|
### 2026-05-26
|
||||||
|
|
||||||
|
- **Stage C Step 2:Docker per-user 容器 + iptables blocklist(§7.5 #1 + #3 落地基底,未接入 AgentLoop)**:`deploy/sandbox/Dockerfile`(python:3.11-slim + tini PID 1 + iptables/iproute2/netbase + non-root user uid `HOST_UID` build-arg + 全套 requirements.txt 装到容器内)+ `deploy/sandbox/init.sh`(`set -euo pipefail`,任一 iptables 规则失败 fail-fast → 容器终止,符合 §7.5 #1"任一缺失视为 Stage C 未完成"硬协议;6 段 IPv4 红线 + ::1 IPv6 loopback 降级容忍 + `ZCBOT_PG_IPS` env 逐 IP DROP;`exec sleep infinity` 等 `docker exec` 进来)。`core/sandbox/network.py` 单函数 `ensure_network()`,`docker network create --internal zcbot-sandbox-net`(默认无 outbound + 跨容器隔离,Step 4 加 proxy 时 proxy 同接此网络);`core/sandbox/pool.py` `SandboxPool` 类持 per-user `asyncio.Lock` + in-memory `_last_active` dict —— ensure 路径 inspect 探测 → running 直接返 / exists-but-stopped `rm -f` 重起(保 iptables 重新 apply)/ 不存在 `docker run` 装齐 hardening flags(`--read-only --tmpfs /tmp:exec --cap-drop=ALL --cap-add=NET_ADMIN --security-opt=no-new-privileges --pids-limit=256 --memory=2g --cpus=1.0` + bind mount user_root → `/workspace` + label `zcbot.product=sandbox` 给批量清扫用 + `--restart=no`);`mark_active` 更新 dict / `reap_idle` 按 ttl 杀 / `shutdown_all` 杀 label 全集(app 启动清前驱孤儿用)。容器命名 `zcbot-sandbox-<user_id>`(UUID 标准串带 dash,与 mount 路径 `<workspace>/users/<user_id>/` 视觉对齐 ── `docker ps | grep zcbot-sandbox-` 直接看活跃 user)。**关键决策**:(a) **docker CLI via subprocess 而非 docker-py SDK** ── §7.5 #5 "接口形状不泄漏 Docker 假设"对应到实现层,subprocess 行为透明、零新依赖、`docker ps` 实地对账;(b) **`docker update --label-add` 不可用 → 用 in-memory dict** ── Docker 23+ 移除 runtime label 修改,所以 last_active 落 Python dict;app 重启 dict 空 → 历史孤儿由 `shutdown_all` 兜底清(lifespan 启动钩子里调);(c) **`--internal` 网络从 Step 2 即生效** ── iptables OUTPUT 规则作为 defense-in-depth(网络层已堵死 outbound,iptables 仍按协议加规则);Step 4 加 proxy 时 proxy 容器同接 `zcbot-sandbox-net`,加 iptables ACCEPT 例外 + 改默认 DROP 实现"默认 deny + 仅经 proxy";(d) **NET_ADMIN cap 留给 PID 1 root 跑 iptables** ── 容器整生命周期持 NET_ADMIN,但 PID 1 `sleep infinity` 不接外部输入,`docker exec` 进来由 `--user 1000:1000` 锁 non-root + 空 cap_effective,等同于无 NET_ADMIN。Step 3 DockerExecutor 必须硬编 --user 1000 不让 root 路径打开(代码 review 守住)。**Step 2 范围明确不包含**:① AgentLoop 集成(`agent_builder.py` 不动 ── pool 是孤立模块,Step 3 才插)② shell/run_python 切到容器 ③ egress proxy(Step 4)④ reaper 后台 task(Step 3 接入 web lifespan 时一起加)。**验证**:`from core.sandbox import ...` 全套导入 + ctor 通过;`SandboxPool(user_root_base=Path(...), pg_ips='10.x,172.x')` 字段正确;`unittest discover` 1/1 PASS。docker 真起容器验证在 Ubuntu 上跑(RUN.md "Sandbox(Stage C,Ubuntu)" 段写了 5 条 smoke 命令:build / iptables 段 / non-root uid / read-only / 销毁)。`DESIGN.md` 不动(纯按 §7.5 #1 #3 既有协议实施);`RUN.md` 加 "Sandbox(Stage C,Ubuntu)" 部署段(镜像构建 / sandbox env / 5 条验证命令 / xfs project quota 升级时点)+ 故障兜底加 2 条(uid 错配 EACCES / NET_ADMIN 缺失)。否决:(a) 容器名用 sha256(uid)[:12] + label 反查 —— 每次 exec 多一次 `docker ps --filter` round-trip,可读性损失,隐私收益 0;(b) per-task 容器 —— DESIGN §7.5 已锁 per-user 共享心智模型(同 user 多 task 共享素材),不重开;(c) 用 docker `init container` 范式做 iptables —— Docker 没原生支持(那是 k8s),compose v2 同步又增复杂度,NET_ADMIN + 非 root exec 范式更直接;(d) Step 2 立即接入 AgentLoop —— 接了不能 dogfood(本地 Windows 无 docker),反而污染 host 路径;pool 孤立 commit 留 Step 3 一起接。
|
||||||
- **Stage C Step 1:Executor 接口骨架 + HostExecutor in-process backend(§7.5 #5 落地)**:`core/executor.py` 加 `Executor` ABC + `ExecCtx`(user_id/task_id/working_dir/cancel_check)+ `ToolResult`(content/exit_code);`core/executor_host.py` 加 `HostExecutor` 包原 tools dict,`call_tool` 内部分流到对应 `Tool.execute` 并把三种错误(unknown / TypeError / 抛异常)统一收成 `[Error] ...` content + exit_code 区分。`AgentLoop.__init__` 改接 `executor` 而非 `tools` dict、加 `working_dir` 形参;`_stream_llm` 用 `executor.schemas()` 拼 LLM tools 字段;`_execute_tool_call` 改单条 `executor.call_tool(name, args, ctx)`,删原三段错误 emit(unknown/TypeError/Exception 已被 executor 收编为 ToolResult,只剩一处 emit)。`agent_builder.py` 装完 tools dict 后 `HostExecutor(tools)` 包一层,传给 `AgentLoop`。**接口形状刻意 backend 无关**——不暴露 `docker exec` / `docker cp` 等 Docker 假设,Step 3 切 docker backend 时 `AgentLoop` 零改动,只换 `agent_builder.py` 里 `HostExecutor` → `DockerExecutor(host_tools=..., docker_tools={shell, run_python})`。**行为零变化** —— sanity import 通过,`unittest discover -s tests` 1/1 PASS。`DESIGN.md` 不动(纯按 §7.5 #5 既有协议实施,无架构漂移);`RUN.md` 不动(无新 env / CLI 变化,`ZCBOT_SANDBOX_BACKEND` env 留到 Step 3 docker backend 引入时一起加)。否决:(a) 不抽 Executor 直接在 `shell.py/run_python.py` 里 `if backend=='docker'` —— 违反 §7.5 #5,未来切 gVisor/Firecracker 时改动散到工具层;(b) Executor 用 `exec(cmd, ctx)` primitive 而非 `call_tool(name, args, ctx)` dispatcher —— 不匹配 DESIGN 签名,且 host 工具(read/web_*/seedream)不是 "命令" 语义;(c) 用 `cancel_check` callable 替代 ExecCtx 重建 —— 当前 cancel_check 是 build 后 setter 赋值,ctx 缓存会指向 stale,per-call 构 ExecCtx 是 dataclass 廉价。
|
- **Stage C Step 1:Executor 接口骨架 + HostExecutor in-process backend(§7.5 #5 落地)**:`core/executor.py` 加 `Executor` ABC + `ExecCtx`(user_id/task_id/working_dir/cancel_check)+ `ToolResult`(content/exit_code);`core/executor_host.py` 加 `HostExecutor` 包原 tools dict,`call_tool` 内部分流到对应 `Tool.execute` 并把三种错误(unknown / TypeError / 抛异常)统一收成 `[Error] ...` content + exit_code 区分。`AgentLoop.__init__` 改接 `executor` 而非 `tools` dict、加 `working_dir` 形参;`_stream_llm` 用 `executor.schemas()` 拼 LLM tools 字段;`_execute_tool_call` 改单条 `executor.call_tool(name, args, ctx)`,删原三段错误 emit(unknown/TypeError/Exception 已被 executor 收编为 ToolResult,只剩一处 emit)。`agent_builder.py` 装完 tools dict 后 `HostExecutor(tools)` 包一层,传给 `AgentLoop`。**接口形状刻意 backend 无关**——不暴露 `docker exec` / `docker cp` 等 Docker 假设,Step 3 切 docker backend 时 `AgentLoop` 零改动,只换 `agent_builder.py` 里 `HostExecutor` → `DockerExecutor(host_tools=..., docker_tools={shell, run_python})`。**行为零变化** —— sanity import 通过,`unittest discover -s tests` 1/1 PASS。`DESIGN.md` 不动(纯按 §7.5 #5 既有协议实施,无架构漂移);`RUN.md` 不动(无新 env / CLI 变化,`ZCBOT_SANDBOX_BACKEND` env 留到 Step 3 docker backend 引入时一起加)。否决:(a) 不抽 Executor 直接在 `shell.py/run_python.py` 里 `if backend=='docker'` —— 违反 §7.5 #5,未来切 gVisor/Firecracker 时改动散到工具层;(b) Executor 用 `exec(cmd, ctx)` primitive 而非 `call_tool(name, args, ctx)` dispatcher —— 不匹配 DESIGN 签名,且 host 工具(read/web_*/seedream)不是 "命令" 语义;(c) 用 `cancel_check` callable 替代 ExecCtx 重建 —— 当前 cancel_check 是 build 后 setter 赋值,ctx 缓存会指向 stale,per-call 构 ExecCtx 是 dataclass 廉价。
|
||||||
- **REVISIONS.md 修订日志机制(覆盖 proposal/patent/ppt 三个产物型 skill)**:`<task_dir>/REVISIONS.md` 作为产物迭代过程的紧凑 changelog —— task 对话历史是粗流水(50 条消息找上周改动靠翻),REVISIONS 是用户与 LLM 共同沉淀的实质决策列表(5 行就能复盘"上周这章为啥这么写"),与 spec 定位互补:**spec = 宪法(定调一次),REVISIONS = 实施日志(每次卡点累加)**。三个 SKILL.md 各加 (a) 起草步骤里加一步"用户确认实质改动后追加一行" + (b) "## 修订日志" 独立小节(何时记/何时不记表 + 格式约定 + 实例 + 操作)。三类 skill 的"实质改动"判据按各自领域定制:proposal = 技术路线/考核指标/创新点/课题分解/关键引文/预算结构;patent = 区别技术特征/关键参数/公式/实施例/章节;ppt = 版式/主色/页/图标/文案要点。统一原则:首次起草不记 / 错别字微调不记 / 模型自己改改撤撤不记 — 拿不准倾向不记,避免变流水账。格式选**单行 bullet 倒序追加**(时间在前、文件:章节定位、改了什么 — 为什么),用 edit 在头注释后插入新一行(不 append 到末尾,倒序读秒看最新)。否决:(a) 走 system prompt 软约束 — 对 coding/research/documents/imagegen/videogen 等非产物型 skill 强加无关约束;(b) 新建 `record_revision` tool — 开发期内 LLM 直接 edit 追加足够,加 tool 增加每次小改的调用开销,后期发现 LLM 漏记多再升 tool 化;(c) 按产物拆多文件(`<topic>.revisions.md`)— 单文件好读、跨产物时间线统一。`DESIGN.md` 不动(无架构变化);`RUN.md` 不动(无 CLI/env 变化)。
|
- **REVISIONS.md 修订日志机制(覆盖 proposal/patent/ppt 三个产物型 skill)**:`<task_dir>/REVISIONS.md` 作为产物迭代过程的紧凑 changelog —— task 对话历史是粗流水(50 条消息找上周改动靠翻),REVISIONS 是用户与 LLM 共同沉淀的实质决策列表(5 行就能复盘"上周这章为啥这么写"),与 spec 定位互补:**spec = 宪法(定调一次),REVISIONS = 实施日志(每次卡点累加)**。三个 SKILL.md 各加 (a) 起草步骤里加一步"用户确认实质改动后追加一行" + (b) "## 修订日志" 独立小节(何时记/何时不记表 + 格式约定 + 实例 + 操作)。三类 skill 的"实质改动"判据按各自领域定制:proposal = 技术路线/考核指标/创新点/课题分解/关键引文/预算结构;patent = 区别技术特征/关键参数/公式/实施例/章节;ppt = 版式/主色/页/图标/文案要点。统一原则:首次起草不记 / 错别字微调不记 / 模型自己改改撤撤不记 — 拿不准倾向不记,避免变流水账。格式选**单行 bullet 倒序追加**(时间在前、文件:章节定位、改了什么 — 为什么),用 edit 在头注释后插入新一行(不 append 到末尾,倒序读秒看最新)。否决:(a) 走 system prompt 软约束 — 对 coding/research/documents/imagegen/videogen 等非产物型 skill 强加无关约束;(b) 新建 `record_revision` tool — 开发期内 LLM 直接 edit 追加足够,加 tool 增加每次小改的调用开销,后期发现 LLM 漏记多再升 tool 化;(c) 按产物拆多文件(`<topic>.revisions.md`)— 单文件好读、跨产物时间线统一。`DESIGN.md` 不动(无架构变化);`RUN.md` 不动(无 CLI/env 变化)。
|
||||||
- **新增 patent skill(中国发明专利技术交底书)**:`skills/patent/` 完整 6 文件 — `SKILL.md` 主入口(五阶段 workflow:摄取 → 挖点 → 检索 → spec → 逐章起草 → 自查渲染,跟 proposal 同款 BLOCKING 节奏)+ `references/{disclosure_structure,patent_point_taxonomy,prior_art_search,self_check}.md` 4 份指南 + `templates/{spec,disclosure}.md` 2 份模板。**关键复用避免重复造**:① 素材摄取用 `markitdown` CLI(不内置 docx/pptx→md);② mermaid + docx 渲染直接复用 `skills/proposal/scripts/{render_diagrams,render_docx}.py`(参数兼容,patent 不另写);③ 现有技术检索走现成的 `web_search`/`web_fetch`(Bocha)+ `documents` + `research`,不实现 CNIPA Playwright 爬虫(反爬重、维护成本高,正式可作 IDS 提交的检索建议走线下专业渠道);④ 不实现修订日志(zcbot task 对话历史已有)。源 repo `github.com/handsomestWei/patent-disclosure-skill` 的 11 prompts 文件折叠进单份 SKILL.md(跟 proposal/ppt 风格一致)+ 8 Python tools 减到 0(全靠复用)。skill 内特有内容:7 章交底书骨架(技术领域 / 背景 / 发明内容 / 附图 / 实施方式 / 有益效果 / 权利要求建议)+ 三性自检(新颖/创造/实用)+ 9 类客体排除清单 + 6 类自查清单 + 脱敏边界(商业敏感词中性化、技术参数不脱敏)。`SkillRegistry` 自动发现验证通过。`DESIGN.md` 不动(无架构变化,纯新 skill);`RUN.md` 不动(无 CLI/env 变化)。
|
- **新增 patent skill(中国发明专利技术交底书)**:`skills/patent/` 完整 6 文件 — `SKILL.md` 主入口(五阶段 workflow:摄取 → 挖点 → 检索 → spec → 逐章起草 → 自查渲染,跟 proposal 同款 BLOCKING 节奏)+ `references/{disclosure_structure,patent_point_taxonomy,prior_art_search,self_check}.md` 4 份指南 + `templates/{spec,disclosure}.md` 2 份模板。**关键复用避免重复造**:① 素材摄取用 `markitdown` CLI(不内置 docx/pptx→md);② mermaid + docx 渲染直接复用 `skills/proposal/scripts/{render_diagrams,render_docx}.py`(参数兼容,patent 不另写);③ 现有技术检索走现成的 `web_search`/`web_fetch`(Bocha)+ `documents` + `research`,不实现 CNIPA Playwright 爬虫(反爬重、维护成本高,正式可作 IDS 提交的检索建议走线下专业渠道);④ 不实现修订日志(zcbot task 对话历史已有)。源 repo `github.com/handsomestWei/patent-disclosure-skill` 的 11 prompts 文件折叠进单份 SKILL.md(跟 proposal/ppt 风格一致)+ 8 Python tools 减到 0(全靠复用)。skill 内特有内容:7 章交底书骨架(技术领域 / 背景 / 发明内容 / 附图 / 实施方式 / 有益效果 / 权利要求建议)+ 三性自检(新颖/创造/实用)+ 9 类客体排除清单 + 6 类自查清单 + 脱敏边界(商业敏感词中性化、技术参数不脱敏)。`SkillRegistry` 自动发现验证通过。`DESIGN.md` 不动(无架构变化,纯新 skill);`RUN.md` 不动(无 CLI/env 变化)。
|
||||||
|
|
|
||||||
96
RUN.md
96
RUN.md
|
|
@ -253,6 +253,100 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Sandbox(Stage C,Ubuntu)
|
||||||
|
|
||||||
|
> 为外部用户开放前必须完成。当前 dogfood + 信任同事白名单阶段可跳过 ── 默 backend = host,
|
||||||
|
> `shell` / `run_python` 仍走 subprocess(未隔离)。Step 3 接入 DockerExecutor 后切
|
||||||
|
> `ZCBOT_SANDBOX_BACKEND=docker` 启用。
|
||||||
|
|
||||||
|
### 镜像构建
|
||||||
|
|
||||||
|
容器内 uid/gid 与 host `zcbot` 用户必须对齐(bind mount 保 host owner;错配导致 EACCES):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 确保 zcbot 用户存在,uid 拿出来
|
||||||
|
id -u zcbot # 期望:整数,后面用作 HOST_UID
|
||||||
|
id -g zcbot # 期望:整数
|
||||||
|
|
||||||
|
# 2) 构建镜像(build context = repo 根)
|
||||||
|
cd /opt/zcbot
|
||||||
|
sudo -u zcbot docker build \
|
||||||
|
-f deploy/sandbox/Dockerfile \
|
||||||
|
--build-arg HOST_UID=$(id -u zcbot) \
|
||||||
|
--build-arg HOST_GID=$(id -g zcbot) \
|
||||||
|
-t zcbot-sandbox:latest .
|
||||||
|
|
||||||
|
# 3) 创建 sandbox 网络(--internal,默认无 outbound)
|
||||||
|
sudo -u zcbot docker network create --internal zcbot-sandbox-net
|
||||||
|
# 或 SandboxPool.setup_pool() 自动 ensure
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sandbox 相关 env(.env 加)
|
||||||
|
|
||||||
|
```
|
||||||
|
# 容器镜像 tag(默 zcbot-sandbox:latest)
|
||||||
|
# ZCBOT_SANDBOX_IMAGE=zcbot-sandbox:latest
|
||||||
|
# 容器 runtime(切 gVisor 用 runsc,Firecracker 用 kata;默 runc)
|
||||||
|
# ZCBOT_SANDBOX_RUNTIME=
|
||||||
|
# 空闲多少秒回收(默 300)
|
||||||
|
# ZCBOT_SANDBOX_IDLE_TTL=300
|
||||||
|
# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1),
|
||||||
|
# init.sh 再加一遍 DROP 规则。生产部署必填。
|
||||||
|
ZCBOT_PG_IPS=10.1.2.3,10.1.2.4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证(Step 2 部分能验)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 起一个测试容器(直接 docker run,不走 pool ── pool 在 Step 3 接入后才用)
|
||||||
|
USER_ID=00000000-0000-0000-0000-000000000001
|
||||||
|
sudo -u zcbot docker run -d \
|
||||||
|
--name zcbot-sandbox-$USER_ID \
|
||||||
|
--label zcbot.product=sandbox \
|
||||||
|
--label zcbot.user_id=$USER_ID \
|
||||||
|
--network zcbot-sandbox-net \
|
||||||
|
--read-only --tmpfs /tmp:exec,size=512m,mode=1777 \
|
||||||
|
--cap-drop=ALL --cap-add=NET_ADMIN \
|
||||||
|
--security-opt=no-new-privileges \
|
||||||
|
--pids-limit=256 --memory=2g --cpus=1.0 \
|
||||||
|
-v /opt/zcbot/workspace/users/$USER_ID:/workspace \
|
||||||
|
-e ZCBOT_PG_IPS=10.1.2.3 \
|
||||||
|
zcbot-sandbox:latest
|
||||||
|
|
||||||
|
# 看 iptables 规则确实 apply 了(应 6+1 条 DROP)
|
||||||
|
sudo -u zcbot docker exec zcbot-sandbox-$USER_ID iptables -L OUTPUT -n --line-numbers
|
||||||
|
|
||||||
|
# 看 non-root 用户(uid 应 = host zcbot uid)
|
||||||
|
sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \
|
||||||
|
zcbot-sandbox-$USER_ID id
|
||||||
|
|
||||||
|
# 看 rootfs read-only(应报 Read-only file system)
|
||||||
|
sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \
|
||||||
|
zcbot-sandbox-$USER_ID touch /badtest
|
||||||
|
|
||||||
|
# 销毁
|
||||||
|
sudo -u zcbot docker rm -f zcbot-sandbox-$USER_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 4 引入 egress proxy 后,完整 5 条红队用例(metadata / loopback / 跨 user / nohup
|
||||||
|
残留 / allowlist 外 403)进 `tests/test_sandbox_redteam.py` 自动化跑。
|
||||||
|
|
||||||
|
### 配额硬化(§7.5 #4,外部开放前必做)
|
||||||
|
|
||||||
|
应用层磁盘配额(Step 5 引入)能挡常规超额,**但扫描间隙打满共享 fs 拖死同节点**这条
|
||||||
|
硬要 **xfs / ext4 project quota 或 zfs dataset quota**。部署到独立服务器 + 多租户开放前:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 示例(xfs project quota):
|
||||||
|
sudo mount -o remount,prjquota /opt
|
||||||
|
sudo xfs_quota -x -c "project -s -p /opt/zcbot/workspace/users/<uid> <pid>" /opt
|
||||||
|
sudo xfs_quota -x -c "limit -p bhard=10g <pid>" /opt
|
||||||
|
```
|
||||||
|
|
||||||
|
具体方案视部署 fs 选择(xfs 推荐)── 不做这步等于"软配额 + 信任用户不写满"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 故障兜底
|
## 故障兜底
|
||||||
|
|
||||||
| 现象 | 原因 / 处理 |
|
| 现象 | 原因 / 处理 |
|
||||||
|
|
@ -263,6 +357,8 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干
|
||||||
| `db upgrade` 报 `column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
|
| `db upgrade` 报 `column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
|
||||||
| Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 |
|
| Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 |
|
||||||
| `--working-dir` 指定后 task 删了目录还在 | 两种情况:① 目录非空(有用户文件) — 设计如此,绝不 rmtree,手动 `rm -rf <dir>` 清;② 外部 `--working-dir`(DB 存绝对路径)— 不自动清,避免误删用户外部项目。ROOT 内 + 同 working_dir 无其他 task 引用 + FS 空 → DELETE task 时已自动 rmdir |
|
| `--working-dir` 指定后 task 删了目录还在 | 两种情况:① 目录非空(有用户文件) — 设计如此,绝不 rmtree,手动 `rm -rf <dir>` 清;② 外部 `--working-dir`(DB 存绝对路径)— 不自动清,避免误删用户外部项目。ROOT 内 + 同 working_dir 无其他 task 引用 + FS 空 → DELETE task 时已自动 rmdir |
|
||||||
|
| Sandbox 容器内 `touch /workspace/x` 报 `Permission denied` | 容器 uid 1000 与 host `zcbot` 用户 uid 不一致(bind mount 保 host owner)。`docker build --build-arg HOST_UID=$(id -u zcbot)` 重建镜像 |
|
||||||
|
| Sandbox 容器 build 完起不来,`docker logs` 显示 iptables 报错 | 缺 NET_ADMIN cap(`--cap-add=NET_ADMIN` 漏了)或 kernel 不支持(WSL2 / OpenVZ 环境不能跑)。Ubuntu 物理 / KVM 正常。验:`docker exec ... iptables -V` |
|
||||||
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export |
|
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export |
|
||||||
| `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) |
|
| `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) |
|
||||||
| `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` |
|
| `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Sandbox 容器管理(DESIGN §7.5)。
|
||||||
|
|
||||||
|
模块边界:
|
||||||
|
- `network.py`:Docker network ensure(`zcbot-sandbox-net`,`--internal` 隔离 outbound + cross-container)
|
||||||
|
- `pool.py`:per-user 容器生命周期(ensure / mark_active / reap_idle / shutdown_all)
|
||||||
|
|
||||||
|
不在本目录:`shell` / `run_python` 工具的 docker exec 调用 ── 那是 Step 3 的
|
||||||
|
`core/executor_docker.py`,调用本模块的 `pool.ensure(user_id)` 拿到容器名后再 exec。
|
||||||
|
"""
|
||||||
|
from .pool import SandboxPool, container_name, setup_pool
|
||||||
|
from .network import NETWORK_NAME, ensure_network
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SandboxPool",
|
||||||
|
"container_name",
|
||||||
|
"setup_pool",
|
||||||
|
"NETWORK_NAME",
|
||||||
|
"ensure_network",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Sandbox Docker network 管理。
|
||||||
|
|
||||||
|
`zcbot-sandbox-net` 是 `--internal` bridge:
|
||||||
|
- 默认无 outbound(Docker bridge 移除 host NAT 路由)
|
||||||
|
- 同网络下容器之间默认隔离(Docker bridge 默认行为,internal 也成立)
|
||||||
|
|
||||||
|
Step 2 起即用 `--internal`,iptables OUTPUT blocklist(init.sh 里的)作为 defense-in-depth
|
||||||
|
(网络层已堵死,iptables 仍按 §7.5 #1 协议加规则,任一缺失视为部署未完成)。
|
||||||
|
|
||||||
|
Step 4 引入 egress proxy 时:proxy 容器同接 `zcbot-sandbox-net`(从内部网到 proxy 容器
|
||||||
|
保持联通),proxy 容器再走 host 默认网出网。sandbox 容器 env `HTTP_PROXY` 指向
|
||||||
|
proxy 容器名 + iptables 加 ACCEPT 例外,实现"默认 deny + 仅经 proxy"。
|
||||||
|
|
||||||
|
操作幂等:create 前 inspect 探测,已存在直接返。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
NETWORK_NAME = "zcbot-sandbox-net"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_network() -> None:
|
||||||
|
"""创建 `zcbot-sandbox-net`(若不存在)。失败 raise。"""
|
||||||
|
inspect = subprocess.run(
|
||||||
|
["docker", "network", "inspect", NETWORK_NAME],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if inspect.returncode == 0:
|
||||||
|
return
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "network", "create", "--internal", NETWORK_NAME],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"docker network create {NETWORK_NAME} failed: {r.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""Per-user sandbox 容器池(DESIGN §7.5)。
|
||||||
|
|
||||||
|
命名:`zcbot-sandbox-<user_id>`(user_id = UUID 标准串带 dash,与 bind mount
|
||||||
|
源路径 `<workspace>/users/<user_id>/` 对齐 ── `docker ps` 看到容器名能直接 grep
|
||||||
|
出 workspace 目录)。
|
||||||
|
|
||||||
|
生命周期:
|
||||||
|
- `ensure(user_id)`:per-user `asyncio.Lock` 串行化 → `docker inspect` 探测 → 已 running
|
||||||
|
直接返;exists-but-stopped 先 `rm -f` 重起(保证 iptables 重新 apply);不存在 `docker run`
|
||||||
|
- `mark_active(user_id)`:exec 完更新 in-memory `_last_active[uid]=now`(docker labels
|
||||||
|
不可运行时修改 ── Docker 23+ 移除 `docker update --label-add` 支持)
|
||||||
|
- `reap_idle()`:周期任务,扫 `_last_active` dict,>`idle_ttl` 的 `docker rm -f`
|
||||||
|
- `shutdown_all()`:app 启动时清前驱孤儿(`docker ps --filter label=zcbot.product=sandbox`)
|
||||||
|
|
||||||
|
幂等性:
|
||||||
|
- ensure 在重复调用时跨 daemon round-trip < 100ms(纯 `docker inspect`);per-user lock
|
||||||
|
防同 user 两并发 `docker run --name` 撞 "Conflict"(虽然 docker 本身会 reject,提前
|
||||||
|
锁更干净)
|
||||||
|
- reaper 只杀 dict 里有记录的容器 ── 重启后 dict 空 → 不杀历史孤儿(这条由 startup
|
||||||
|
`shutdown_all` 兜底)
|
||||||
|
|
||||||
|
Step 2 范围:仅 pool / lifecycle。Tools(shell / run_python)在 Step 3 接入。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from .network import NETWORK_NAME, ensure_network
|
||||||
|
|
||||||
|
|
||||||
|
CONTAINER_NAME_PREFIX = "zcbot-sandbox-"
|
||||||
|
LABEL_PRODUCT_KEY = "zcbot.product"
|
||||||
|
LABEL_PRODUCT_VALUE = "sandbox"
|
||||||
|
LABEL_USER_ID_KEY = "zcbot.user_id"
|
||||||
|
|
||||||
|
DEFAULT_IMAGE = "zcbot-sandbox:latest"
|
||||||
|
DEFAULT_IDLE_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def container_name(user_id: UUID) -> str:
|
||||||
|
return f"{CONTAINER_NAME_PREFIX}{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> int:
|
||||||
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _container_exists(name: str) -> bool:
|
||||||
|
"""任何 state(running / exited / created)都算存在。"""
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "inspect", "--type=container", name],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return r.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _container_running(name: str) -> bool:
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "inspect", "--type=container",
|
||||||
|
"--format={{.State.Running}}", name],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return r.returncode == 0 and r.stdout.strip() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxPool:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_root_base: Path,
|
||||||
|
image: Optional[str] = None,
|
||||||
|
runtime: Optional[str] = None,
|
||||||
|
idle_ttl: Optional[int] = None,
|
||||||
|
pg_ips: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
user_root_base: per-user 子树父目录,典型 `<workspace>/users`。bind mount 源
|
||||||
|
= `user_root_base / <user_id>`,目标 `/workspace`。
|
||||||
|
image: sandbox 镜像 tag(默 env `ZCBOT_SANDBOX_IMAGE`)
|
||||||
|
runtime: `docker run --runtime` 值(runc / runsc / kata 等);空 = 默认
|
||||||
|
(env `ZCBOT_SANDBOX_RUNTIME`)。§7.5 #5 / §7.9 升级表 ── 切
|
||||||
|
gVisor / Firecracker 时改这一项即可,应用层零改动。
|
||||||
|
idle_ttl: 秒;`mark_active` 时间戳 < now - ttl 的容器被 reap_idle 杀
|
||||||
|
(env `ZCBOT_SANDBOX_IDLE_TTL`,默 300)
|
||||||
|
pg_ips: 逗号分隔的 PG IP 串,塞容器 `ZCBOT_PG_IPS` env,init.sh 加 DROP 规则
|
||||||
|
(env `ZCBOT_PG_IPS`)。defense-in-depth ── 即便落内网三段。
|
||||||
|
"""
|
||||||
|
self.user_root_base = user_root_base
|
||||||
|
self.image = image or os.getenv("ZCBOT_SANDBOX_IMAGE", DEFAULT_IMAGE)
|
||||||
|
self.runtime = runtime or os.getenv("ZCBOT_SANDBOX_RUNTIME") or ""
|
||||||
|
self.idle_ttl = idle_ttl if idle_ttl is not None else int(
|
||||||
|
os.getenv("ZCBOT_SANDBOX_IDLE_TTL", str(DEFAULT_IDLE_TTL_SECONDS))
|
||||||
|
)
|
||||||
|
self.pg_ips = pg_ips if pg_ips is not None else os.getenv("ZCBOT_PG_IPS", "")
|
||||||
|
self._locks: Dict[UUID, asyncio.Lock] = {}
|
||||||
|
self._last_active: Dict[UUID, int] = {}
|
||||||
|
|
||||||
|
def _lock_for(self, user_id: UUID) -> asyncio.Lock:
|
||||||
|
if user_id not in self._locks:
|
||||||
|
self._locks[user_id] = asyncio.Lock()
|
||||||
|
return self._locks[user_id]
|
||||||
|
|
||||||
|
async def ensure(self, user_id: UUID) -> str:
|
||||||
|
"""返回容器名;create-or-reuse 原子。"""
|
||||||
|
async with self._lock_for(user_id):
|
||||||
|
name = container_name(user_id)
|
||||||
|
if _container_running(name):
|
||||||
|
self._last_active[user_id] = _now()
|
||||||
|
return name
|
||||||
|
if _container_exists(name):
|
||||||
|
# stopped / crashed ── rm 重起。iptables 规则随容器生命周期重新 apply。
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
capture_output=True, check=False,
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(self._docker_run, user_id, name)
|
||||||
|
self._last_active[user_id] = _now()
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _docker_run(self, user_id: UUID, name: str) -> None:
|
||||||
|
"""同步阻塞;由 ensure 在 to_thread 里调。"""
|
||||||
|
user_root = self.user_root_base / str(user_id)
|
||||||
|
user_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cmd: List[str] = [
|
||||||
|
"docker", "run", "-d",
|
||||||
|
"--name", name,
|
||||||
|
"--label", f"{LABEL_PRODUCT_KEY}={LABEL_PRODUCT_VALUE}",
|
||||||
|
"--label", f"{LABEL_USER_ID_KEY}={user_id}",
|
||||||
|
"--network", NETWORK_NAME,
|
||||||
|
# §7.5 硬限制(任一缺失视为 hardening 未完成)
|
||||||
|
"--read-only", # rootfs read-only
|
||||||
|
"--tmpfs", "/tmp:exec,size=512m,mode=1777", # 可写临时区,exec 允许 (run_python 写脚本)
|
||||||
|
"--cap-drop=ALL", # 默全丢
|
||||||
|
"--cap-add=NET_ADMIN", # init.sh 配 iptables 需要;exec 进来的 uid 1000 拿不到
|
||||||
|
"--security-opt=no-new-privileges",
|
||||||
|
"--pids-limit=256",
|
||||||
|
"--memory=2g",
|
||||||
|
"--cpus=1.0",
|
||||||
|
"-v", f"{user_root}:/workspace",
|
||||||
|
"-e", f"ZCBOT_PG_IPS={self.pg_ips}",
|
||||||
|
"--restart=no",
|
||||||
|
]
|
||||||
|
if self.runtime:
|
||||||
|
cmd += ["--runtime", self.runtime]
|
||||||
|
cmd.append(self.image)
|
||||||
|
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"docker run {name} failed (rc={r.returncode}): {r.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_active(self, user_id: UUID) -> None:
|
||||||
|
"""每次 `docker exec` 完调一次,刷新 idle 计时。"""
|
||||||
|
self._last_active[user_id] = _now()
|
||||||
|
|
||||||
|
def reap_idle(self) -> List[str]:
|
||||||
|
"""杀超过 idle_ttl 没活跃的容器。返回已杀容器名列表(供日志 / 审计)。"""
|
||||||
|
removed: List[str] = []
|
||||||
|
cutoff = _now() - self.idle_ttl
|
||||||
|
for uid, ts in list(self._last_active.items()):
|
||||||
|
if ts < cutoff:
|
||||||
|
name = container_name(uid)
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "rm", "-f", name],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
removed.append(name)
|
||||||
|
# 无论 rm 成功与否,从 dict 移除 ── 失败则下次启动靠 shutdown_all 兜底
|
||||||
|
del self._last_active[uid]
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def shutdown_all(self) -> List[str]:
|
||||||
|
"""杀所有 label=zcbot.product=sandbox 的容器。
|
||||||
|
|
||||||
|
典型用途:① app 启动时清前驱进程留下的孤儿 ② 测试 / 维护手动调。
|
||||||
|
"""
|
||||||
|
list_r = subprocess.run(
|
||||||
|
["docker", "ps", "-aq", "--filter",
|
||||||
|
f"label={LABEL_PRODUCT_KEY}={LABEL_PRODUCT_VALUE}"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if list_r.returncode != 0 or not list_r.stdout.strip():
|
||||||
|
return []
|
||||||
|
ids = list_r.stdout.strip().splitlines()
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", *ids],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
# 反查容器名给调用方记日志(rm 前先 inspect)── 这里简化只返 id
|
||||||
|
self._last_active.clear()
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def setup_pool(user_root_base: Path) -> SandboxPool:
|
||||||
|
"""app 启动便捷入口:ensure 网络存在 + 返回 pool 实例。
|
||||||
|
|
||||||
|
典型用法(lifespan 启动钩子):
|
||||||
|
pool = setup_pool(workspace / "users")
|
||||||
|
pool.shutdown_all() # 清前驱孤儿
|
||||||
|
# 后台 reaper task 周期跑 pool.reap_idle()
|
||||||
|
"""
|
||||||
|
ensure_network()
|
||||||
|
return SandboxPool(user_root_base=user_root_base)
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Per-user sandbox base image (DESIGN §7.5).
|
||||||
|
# 长驻 per-user 容器,工具调用走 `docker exec --user 1000:1000 --workdir /workspace/<wd> setsid <cmd>`(Step 3 接入)。
|
||||||
|
#
|
||||||
|
# 启动协议:tini(PID 1,reap 僵尸) → init.sh(root,跑 iptables 配 blocklist) → sleep infinity。
|
||||||
|
# `docker exec` 进来的进程由 --user flag 决定身份 ── 应用层(DockerExecutor)硬编 --user 1000,
|
||||||
|
# 不继承 PID 1 的 root 上下文。
|
||||||
|
#
|
||||||
|
# 构建上下文 = repo 根(用 -f 指定 Dockerfile 路径):
|
||||||
|
# docker build -f deploy/sandbox/Dockerfile -t zcbot-sandbox \
|
||||||
|
# --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) .
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# - iptables / ip6tables: init.sh 配 blocklist 需要(NET_ADMIN cap 在 docker run 处加)
|
||||||
|
# - iproute2: ip 命令(调试 / 排查)
|
||||||
|
# - netbase: /etc/protocols /etc/services(curl / 多数网络库依赖)
|
||||||
|
# - ca-certificates: HTTPS 出网(经 proxy 也要,Step 4)
|
||||||
|
# - tini: PID 1 信号转发 + 僵尸 reaper(setsid 包出的进程组结束时 PID 1 兜底回收)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
iptables iproute2 netbase ca-certificates tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 容器内非 root 用户的 uid/gid 必须与 host 上 `zcbot` 用户对齐(bind mount 保 host owner;
|
||||||
|
# 错配会导致 exec 进来后 write /workspace 时 EACCES)。build-arg 默认 1000,部署时按
|
||||||
|
# `id -u zcbot` 实际值显式传(详 RUN.md "sandbox 部署"段)。
|
||||||
|
ARG HOST_UID=1000
|
||||||
|
ARG HOST_GID=1000
|
||||||
|
RUN groupadd -g ${HOST_GID} zcbot && useradd -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash zcbot
|
||||||
|
|
||||||
|
# 装全套 requirements ── 模型在 run_python 里写的脚本可能用 fastapi / sqlalchemy / litellm
|
||||||
|
# 等,装齐免 "ModuleNotFoundError" 摩擦。镜像偏大(~1G)是接受成本。
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
|
||||||
|
|
||||||
|
COPY deploy/sandbox/init.sh /init.sh
|
||||||
|
RUN chmod +x /init.sh
|
||||||
|
|
||||||
|
# 默认 cwd /workspace ── 但每次 `docker exec --workdir /workspace/<wd>` 会覆盖
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
CMD ["/init.sh"]
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Sandbox container init (DESIGN §7.5 #1 网络 blocklist 协议)。
|
||||||
|
#
|
||||||
|
# 启动流程:配 iptables OUTPUT blocklist → sleep infinity 等 `docker exec` 进来。
|
||||||
|
# 以 root 跑(需要 NET_ADMIN),docker exec 进来的 --user 1000 进程拿不到 cap。
|
||||||
|
#
|
||||||
|
# Stage C 协议:任一规则 apply 失败 → `set -e` 退出,容器立即终止;视为部署未完成,
|
||||||
|
# 上层 ensure() 会 raise。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
apply_blocklist() {
|
||||||
|
# §7.5 #1 红线段(任一缺失视为 Stage C 未完成):
|
||||||
|
iptables -A OUTPUT -d 169.254.0.0/16 -j DROP # cloud metadata (Capital One 2019 SSRF)
|
||||||
|
iptables -A OUTPUT -d 127.0.0.0/8 -j DROP # IPv4 loopback (容器回头打宿主端口)
|
||||||
|
iptables -A OUTPUT -d 10.0.0.0/8 -j DROP # 内网段 A
|
||||||
|
iptables -A OUTPUT -d 172.16.0.0/12 -j DROP # 内网段 B(Docker 默认 bridge 网段在此)
|
||||||
|
iptables -A OUTPUT -d 192.168.0.0/16 -j DROP # 内网段 C
|
||||||
|
iptables -A OUTPUT -d 100.64.0.0/10 -j DROP # CGNAT(云平台常用)
|
||||||
|
|
||||||
|
# IPv6 loopback ── ip6tables 在某些精简镜像 / sysctl ipv6.disable_ipv6=1 下不可用,
|
||||||
|
# 失败降级为 warn(IPv6 没起的话本来也打不通,容忍)
|
||||||
|
if ! ip6tables -A OUTPUT -d ::1 -j DROP 2>/dev/null; then
|
||||||
|
echo "[init] ip6tables ::1 rule skipped (ip6tables unavailable or v6 disabled)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ZCBOT_PG_IPS=10.x.x.x,172.x.x.x[/prefix],...
|
||||||
|
# 部署侧把 PG 实际 IP 显式 block ── 即使已落在内网三段,defense-in-depth(§7.5 #1)。
|
||||||
|
if [ -n "${ZCBOT_PG_IPS:-}" ]; then
|
||||||
|
IFS=',' read -ra _pg_ips <<< "$ZCBOT_PG_IPS"
|
||||||
|
for ip in "${_pg_ips[@]}"; do
|
||||||
|
ip_trim="$(echo "$ip" | tr -d '[:space:]')"
|
||||||
|
[ -z "$ip_trim" ] && continue
|
||||||
|
iptables -A OUTPUT -d "$ip_trim" -j DROP
|
||||||
|
echo "[init] blocked PG IP: $ip_trim"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_blocklist
|
||||||
|
echo "[init] iptables OUTPUT blocklist applied:"
|
||||||
|
iptables -L OUTPUT -n --line-numbers
|
||||||
|
echo "[init] sleeping forever, ready for docker exec."
|
||||||
|
|
||||||
|
# tini 是 PID 1;这里把 sleep 作为 tini 的子进程,信号能正常转发(docker stop → SIGTERM
|
||||||
|
# → tini 转给 sleep → 容器干净退出)。exec 避免再 fork 一层。
|
||||||
|
exec sleep infinity
|
||||||
Loading…
Reference in New Issue