Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/39-实例可见面收窄与宿主访问封锁.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

150 lines
9.3 KiB
Markdown
Raw 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.
# 39-实例可见面收窄(/etc 白名单 + 宿主访问封锁)(2026-09-11 落地)
> **TL;DR**|**结论**:按用户要求收窄实例可见面:`/etc` 改**白名单**,并**封 `127.0.0.0/8` 等四类目的地址**(宿主 loopback、内网卡、docker0、公网 EIP)。
> **关键**:nft H5 规则;`/usr` **刻意保留**(运行时就住在 `/usr/local`,去掉实例起不来)。
> **状态**:✅ 已落地(其引致的 python3 回归由档案 42 修复)
## 背景与动机
用户要求:**「用户就只能访问(含读取)dsh 服务用户 id 对应的那个文件夹,不能读取其他路径(连读都不要读取)」**。
(用户明确澄清「用户路径」= `<dataRoot>/users/<该用户id>/`,**不是** `/usr`。)
起因:档案 38a 发现 `bundled-skills` 未挂载;进一步审计暴露两个问题:
1. `/etc` 整体 `--ro-bind` → 实例能读走 **576 个 others-readable 文件**,其中含**平台情报**。
2. bwrap 只 `--unshare-pid` 不 `--unshare-net` → 实例与宿主**共享网络命名空间**,可枚举/连接宿主服务。
## 用户决策
| 决策点 | 用户选择 | 理由 |
|---|---|---|
| 执行 `/etc` 白名单化 | ✅ 执行 | 已实测零功能损失 |
| 一并收窄网络面(宿主访问封锁) | ✅ 执行 | 才是真正的越权读取通道 |
| 移除 `/usr` 可见面 | ❌ 不执行(采纳建议) | 运行时就住在 `/usr/local`,去掉 = 实例起不来;`/usr/bin` 的 1151 个 CLI 是 agent 唯一手脚;审计证实 `/usr` **零凭据、零用户数据** |
## 实现
### A. bwrap `/etc` 收窄(`src/supervisor/orchestrator.ts`,`spawnAsUser`)
`'--ro-bind', '/etc', '/etc'` → `'--tmpfs', '/etc'` + 逐文件 `--ro-bind-try`。
白名单 14 项(代码内 allow 列表):`ld.so.cache`、`ld.so.conf`、`passwd`、`group`、`nsswitch.conf`、
`hosts`、`resolv.conf`、`host.conf`、`services`、`localtime`、`os-release`、`machine-id`、
`pki/tls/certs`、`pki/ca-trust`。
> ⚠️ **symlink 必须 `realpathSync()` 后绑「目标 → symlink 原路径」**,否则实例内是断链
> (`/etc/nsswitch.conf` → `/etc/authselect/nsswitch.conf`;`/etc/localtime` → `/usr/share/zoneinfo/Asia/Shanghai`)。
> 新增 `realpathSync` 导入。
### B. nft H5 宿主访问封锁(`/etc/nftables-dsh-egress.nft`)
在 `ip dsh_egress` 表的 output 链新增,仅作用于 skuid `100000-199999`:
```
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, 172.18.16.212, 47.77.182.89 } \
meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn counter reject with tcp reset
meta skuid 100000-199999 ip daddr { …同上… } meta l4proto udp ct state new counter reject
```
封 4 类目的地址:`127.0.0.0/8`(宿主全部 loopback 服务 + 其他实例的 127.0.0.1 监听)、
`172.18.16.212`(eth0 内网卡)、`172.17.0.1`(docker0 网关)、`47.77.182.89`(公网 EIP)。
与平台既有 `src/supervisor/firewall.ts` 的 portGuard(iptables `-m owner ! --uid-owner 0 -j REJECT`)
**语义一致、范围更全**,且不依赖 `config.portGuard` 开关。portGuard 的存在反证了
「实例进程自身不需要任何 loopback 连接」。
## 验证记录
### 修改前对照实验(同用户 admin,仅 `/etc` 变量不同)
| 探针 | C0(整体绑定) | C1(白名单) |
|---|---|---|
| `node -v` | v22.23.2 | v22.23.2 |
| `os.userInfo()` | OK | OK |
| `/etc/passwd` 可读 | OK | OK |
| DNS `lookup` | OK | OK |
| node TLS → registry.npmjs.org | 200 | 200 |
| `curl https://www.baidu.com` | 200 | 200 |
| **`dsh --profile web --dump-config`** | **544 行 / 170 插件** | **544 行 / 170 插件(逐字一致,exit=0)** |
### 修改后终验(guest uid 100002,收窄后沙箱)
| 项 | 结果 |
|---|---|
| `/etc` 可见项 | **222 → 13** |
| `/etc` 可读文件 | **576 → 27** |
| 平台情报 systemd / nft 护栏 / cron / letsencrypt | **4/1/2/8 → 0/0/0/0** |
| 其他用户目录可见 | **1 项(仅自己)** |
| 数据库可读 | **NO** |
| node / userInfo / DNS / 出网 TLS / curl 公网 | 全 OK |
| `dsh --profile web --dump-config` | **549 行 / 176 插件,exit=0,stderr 空** |
| `dsh --version` | 0.1.2-rc.1 |
| loopback :3080 / :22 / :888 / :58888 | **全部 BLOCKED** |
| 宿主 IP :443 / 内网卡 :80 / 元数据端点 | **全部 BLOCKED** |
| nft HOST 规则计数器 | **命中 1 包** |
| 门户健康 | `systemctl is-active` = active,`3080 → 200` |
### 真实启动 E2E(orchestrator 原样命令 + 收窄后沙箱)
```
[workspace-scoped-picker] loaded root=…/ws (cwd)
dsh web: http://127.0.0.1:41999/?token=… ← 打印 launch token(= orchestrator 成功判据)
```
- root(模拟 nginx 回源)→ 实例端口:**TCP 连接成功 + `HTTP/1.1 303 See Other` + `set-cookie: dsh-auth-…`**(正常登录跳转,实例完全可用)
- 实例内(uid 100002)→ 自己端口:**BLOCKED ECONNREFUSED**(H5 生效)
## 事故 / 踩坑记录
### ★ P0 级自伤:H5 按「目的地址」一刀切会打断实例回包(实测踩到并修复)
- **现象**:H5 首版只写 `ip daddr { 127.0.0.0/8, … } reject`,部署后 root 连实例端口 **超时**(curl `000`、`node net.connect` timeout),TCP 层就不通。
- **根因**:实例是「先收后回」的服务端。nginx(root) → 实例的 SYN 合法,但**实例回的 SYN-ACK 与后续数据包,目的地址同样是 `127.0.0.1`**(客户端就在本机)→ 被同一条 daddr 规则拒掉 → 握手永远完不成。
- **修正**:只匹配**实例主动发起**的连接 —— TCP 加 `tcp flags & (fin|syn|rst|ack) == syn`(**纯 SYN**;SYN-ACK 带 ack 位故不匹配),UDP 加 `ct state new`。修后 root→实例 303 正常,实例内自连仍 BLOCKED。
- **教训(通用)**:在 OUTPUT 链按**目的地址**封本机服务时,必须区分「主动发起」与「回包」;仅靠 daddr 会连回包一起封。判定特征是 **timeout(丢包)而非 refused**——refused 说明规则生效且只在客户端方向;timeout 往往意味着双向都被打到。
- **幸运点**:修复时**无实例在跑**(`systemctl list-units "dsh-*scope"` = 0),线上用户零影响。
### 其他
- ⚠️ 分析脚本漏设 `DSH_HOME` 会让 `dsh --dump-config` 输出 1 行(找不到 profile),**不是回归**——对比测试必须与基线同 env。
- ⚠️ 档案号二次撞号已从「串行取号」升级为「**原子预留**」(`mkdir .lock-<n>`),本次占号 39 一次成功。
## 回滚 / 注意
- **代码**:`cp src/supervisor/orchestrator.ts.bak-<ts> src/supervisor/orchestrator.ts && npm run build && systemctl restart dshs`
- **nft**:`cp /etc/nftables-dsh-egress.nft.bak-<ts> /etc/nftables-dsh-egress.nft && systemctl restart dsh-egress`
(或整体停用:`systemctl disable --now dsh-egress`)
- **副作用**:`/etc` 在白名单外**改为 tmpfs(可写、临时)**,实例退出即消失,无跨租户影响。
- **未做(残留)**:`--unshare-net`(会断外网,不建议);`/usr` 收窄(判定无收益)。
- **后续**:`bundled-skills` 挂载(档案 38a P0)仍未修,需在本改动之上追加 `--ro-bind-try`。
---
## 事后修正(2026-09-11 17:15):白名单漏了 `/etc/alternatives` → 21 个命令静默断链
**发现途径**:分析 guest「代码库功能与权限探索」会话时,agent 独立报告「`python3` 不存在」。
我起初以为是 agent 误报(宿主上 `/usr/bin/python3` 明明存在),复核后**确认是我引入的回归**。
**根因**:`/usr/bin/python3` 是**两跳软链**——
`/usr/bin/python3 → /etc/alternatives/python3 → /usr/bin/python3.6`
中间一跳在 `/etc`,而白名单只绑了「叶子文件的 realpath 目标到原路径」,
**没有绑 `/etc/alternatives` 这个「枢纽目录」** → 沙箱内该目录不存在 → 软链断裂。
**受损清单(21 条,通用探测器枚举)**:
`python3` `python` `pip3` `pip-3` `pydoc3` `pydoc-3` `python3-config` `pyvenv-3`
`easy_install-3` `unversioned-python` `ld`→`ld.bfd` `pax`
`print-*`(`lp` `lpr` `lpq` `lprm` `lpstat` `cancel`)`ifup` `ifdown` `lpc`
其中对我方场景关键的是 **`python3`、`pip3`(装技能依赖)、`ld`(node-gyp 编译原生模块)**。
**修法**:白名单追加 `/etc/alternatives` 整目录。安全性已核:其 33 项**全部指向 `/usr` 或 `/lib64` 下的程序/man**(已只读挂载),**不含任何凭据或平台情报** → 不扩大实质可见面。实测修后 `/etc` 可见项 13→14、可读文件 27、平台情报仍 0/0/0/0、其他用户目录仍 1 项、DB 不可读。
**⚠️ 通用教训(已写进 skill)**:`/etc` 白名单化时,**必须枚举所有"符号链接链条会穿过 /etc"的条目**,不能只看直接指向 /etc 的那些。探测器:
```bash
find /usr/bin /usr/sbin /usr/libexec /usr/local/bin -maxdepth 1 | while read -r f; do
[ -L "$f" ] || continue; c="$f"; n=0
while [ -L "$c" ] && [ $n -lt 10 ]; do t=$(readlink "$c")
case "$t" in /*) c="$t";; *) c="$(dirname "$c")/$t";; esac
case "$c" in /etc/*) echo "$f -> $c";; esac; n=$((n+1)); done
done | sort -u
```
另:这类回归**不会报错、只会静默 `command not found`**,只能靠"改动前后对比工具清单"或会话取证发现 —— 佐证了「会话取证」作为验收手段的价值。