Files
dsh_shenxian/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

9.3 KiB
Raw Blame History

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 的那些。探测器:

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,只能靠"改动前后对比工具清单"或会话取证发现 —— 佐证了「会话取证」作为验收手段的价值。