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 一律写「远程服务器」。
15 KiB
38a · 实例内软件安装、共享复用可行性、网络与安全边界核查
- 日期:2026-09-11
- 触发:用户问「推荐安装 Playwright 和其他软件包,是每个用户都要单独安装一份吗?能否复用?主要排查功能限制(如联网、DNS 等)以及安全性相关问题」
- 状态:🔍 核查完成(未改码)
- 关联:档案 10/11(共享技能层)、档案 14(出网护栏)、档案 16/17/18/23(沙箱与可见面)、档案 37a(guest 会话取证)
TL;DR|结论:回答"每个用户是否要各装一份":宿主
/usr已 ro-bind → 宿主装一次、全部实例共享;同时暴露最大隐患 = 实例与宿主共享网络命名空间(可访问127.0.0.1:3080/22等)。 关键:结论:软件走宿主层(rg/jq/ffmpeg、Playwright 系统库同理);网络面问题指向档案 39。 状态:🔍 核查完成(未改码)
一、结论速览
| 问题 | 结论 |
|---|---|
| 每个用户要单独装一份吗? | 是。HOME=<userRoot>/ws 每用户独立,pip/npm 全落在自己工作区;实例间互不可见 |
| 能复用吗? | 能,且有一条零改造通道:宿主 /usr 已 --ro-bind 进实例(含 /usr/local)→ 宿主装一次 = 全员生效 |
| Playwright 能装吗? | 当前完全不能(缺 11 个系统 .so,/usr 只读 + 无 sudo);要让它能用必须在宿主层装系统库 |
| 联网 | 全开:仅封云元数据端点(实测 Connection refused),其余只记日志不拦。DNS 走阿里云内网 DNS,可用 |
| 最大安全隐患 | ①实例与宿主共享网络命名空间(可访问 127.0.0.1:22/80/443/3080、可枚举宿主所有监听、可 bind 0.0.0.0 跨租户互通)②磁盘无配额(单用户可写满 40G 打瘫全平台)③并发上限仅 2–3 个实例(宿主 1870MB / 每实例 512M) |
| 新 P0 | bundled-skills 在实例命名空间内不存在 → 档案 10/11 的「全员共享只读技能层」实际失效(见 §二) |
二、新 P0 · bundled-skills 在实例内不存在 → 共享技能层实际失效(修正档案 10/11)
证据链
config.ts:245-247:bundledSkillDir = overrides ?? env.DSHS_BUNDLED_SKILL_DIR ?? join(dataRoot,'bundled-skills');/etc/dshs.env未设该变量 → 实际值 =/var/lib/dshs/bundled-skills。orchestrator.ts:363:DSH_BUNDLED_SKILL_DIR已注入实例 env ✅(环境变量这层没问题)。orchestrator.ts:454-486的 bwrap 参数只挂/usr、/lib64、/etc、/dev、/proc、<userRoot>/tmp、<userRoot>、profile 三个只读文件 —— 没有任何一条--ro-bind覆盖bundled-skills。- 以平台原样参数实测(guest uid,见 §八):
$ ls /var/lib/dshs/
users ← 只有 bwrap 合成的 users,其余全是空的合成父目录
$ ls /var/lib/dshs/bundled-skills
ls: cannot access '...': No such file or directory
$ echo $DSH_BUNDLED_SKILL_DIR
/var/lib/dshs/bundled-skills
dsh-skill-filesystem/lib/index.js:84,181-183是实例内直接读盘,不是由编排器推送数据:
const bundledSkillDir = config.bundledSkillDir
?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : void 0);
this.bundledSkillDir = bundledSkillDir === void 0 ? void 0 : resolve(bundledSkillDir);
...
if (this.bundledSkillDir !== void 0) roots.push({ path: this.bundledSkillDir, source: "bundled" })
→ 结论:即使把技能投进 /var/lib/dshs/bundled-skills,实例端也扫不到,技能永远不会被发现。
这修正了档案 10(「已实施 2026-09-09」)与档案 11 的生效判断 —— 机制代码写完了,但被 bwrap 挂载面切断。也说明档案 37a 的 P0-2「零技能投放」不只是"没投",而是投了也无效,必须先修挂载。
修法:bwrapArgs 在 --bind root root 之后补一条
'--ro-bind-try', this.config.bundledSkillDir, this.config.bundledSkillDir
(用 -try 容忍目录暂不存在;放在 --bind root root 之后以免被覆盖)。
通用规则(本次新增):平台侧任何新增的共享目录,都必须同步加 bwrap --ro-bind,否则实例内不可见 —— 这是本架构最容易漏的一环。
三、每用户一份 vs 复用:现状与三条路径
现状(为什么是每用户一份)
| 环节 | 实测值 |
|---|---|
HOME |
<userRoot>/ws(每用户独立) |
| pip 缓存/安装 | ws/.cache/pip、ws/.local/lib/python3.6/site-packages |
| npm 缓存 | ws/.npm/{_cacache,_logs} |
| 磁盘实占 | guest 13M / admin 47M(重复下载的包各自一份) |
| 跨用户可见 | ❌ bwrap 只暴露 <userRoot>,看不到别人的 |
三条复用路径对比
| 方案 | 做法 | 改动量 | 生效面 | 评价 |
|---|---|---|---|---|
A. 宿主 /usr/local(推荐首选) |
装到宿主 /usr/local/{bin,lib,share} |
零代码 —— /usr 已 ro-bind |
全员、所有实例、新用户自动 | 实测实例内可见 /usr/local/bin(含 dsh/npm/pnpm/node)与 /usr/local/lib/node_modules。唯一注意:全局单版本、升级要动宿主 |
| B. dataRoot 共享只读目录 | 建 <dataRoot>/shared-runtime/ + bwrap 加 --ro-bind + env 白名单(PLAYWRIGHT_BROWSERS_PATH/PYTHONPATH/PATH) |
改 orchestrator.ts + spawn.ts ALLOWED_ENV |
全员 | 与 bundled-skills 同构。必须先修 bind,否则重蹈 §二 |
| C. 每用户一份 + 共享缓存 | 保留现状,把 pip/npm 指向私有镜像/共享缓存 | 无 | 无 | 只降重复下载,不降磁盘占用,且仍污染工作区 |
附带发现:
PLAYWRIGHT_BROWSERS_PATH默认落在$HOME/.cache/ms-playwright(就在用户工作区里)→ 与ws/.npm、ws/.cache/pip同类,会被门户#/files展示、也可能被 ws-cleanup 触碰。
四、功能限制清单(实测)
4.1 网络 / DNS
| 项 | 实测 | 判定 |
|---|---|---|
| DNS 服务器 | 100.100.2.136 / 100.100.2.138(阿里云内网 DNS) |
可用,解析成功 ✅ |
/etc/resolv.conf 可改? |
/etc 是 ro-bind → touch /etc/x = Read-only file system |
用户改不了 DNS ✅ |
| 外网出站 | https://www.baidu.com → 200;pip / npm 均可下载 |
全开 ⚠️ |
| 云元数据端点 | http://100.100.100.200/ → Connection refused(nft reject + log prefix "dsh-egress-BLOCK") |
已封 ✅ |
| 出网护栏性质 | /etc/nftables-dsh-egress.nft:只 reject 元数据端点;其余 counter ... log prefix "dsh-egress" level info 只记录不拦截(tcp/udp,排除 127/8 与 53) |
观测 ≠ 管控 ⚠️ |
| 网络命名空间 | 实例 net:[4026531994],bwrap 只 --unshare-pid,未 --unshare-net |
与宿主共享 🔴 |
127.0.0.1 可达 |
3080(门户) OPEN、22(SSH) OPEN、80 OPEN、443 OPEN |
🔴 |
| 宿主监听枚举 | 实例内 /proc/net/tcp 能看到宿主全部 LISTEN(实测 8 条) |
🔴 内网信息泄露 |
| 自建监听 | bind 0.0.0.0:ephemeral OK、bind 127.0.0.1 OK |
🔴 见 §五 |
4.2 文件系统
| 路径 | 权限 | 说明 |
|---|---|---|
/usr、/lib64、/etc |
只读(ro-bind) | touch /usr/local/lib/x → Read-only file system |
/bin、/sbin、/lib |
符号链接 → usr/{bin,sbin,lib}(合成) |
档案 23 的修复 |
<userRoot>、<userRoot>/ws |
可写,属主 = 用户 uid | 工作区 |
<userRoot>/tmp |
1777,每用户独立 bind 到 /tmp |
✅ 隔离正确 |
<userRoot>/home/profiles/web/{cordis.patch.yml,package.json,pnpm-lock.yaml} |
额外的只读覆盖 | ✅ 防绕过平台策略 |
/etc/passwd |
可读(30 行) | ⚠️ 可枚举平台账号名(低危) |
宿主 /usr/bin |
可读,1151 个二进制 | 共享通道(也是攻击面) |
4.3 资源上限(systemd-run --scope)
| 项 | 值 | 备注 |
|---|---|---|
MemoryMax |
512M / 实例 | |
CPUQuota |
150% | 宿主仅 2 vCPU → 单实例可占 75% |
TasksMax |
128 | |
| 宿主内存 | 1870 MB(swap 1024M) | → 并发上限约 2–3 个实例(512M×3 已 1.5G)🔴 |
| 宿主磁盘 | 40G ext4,可用 29G,单分区 | |
| 磁盘配额 | 未启用(quotaon / → not found) |
🔴 单用户可写满 40G |
实例内 df / |
tmpfs 936M(bwrap 合成根,非宿主盘) | 借宿主机内存池 |
五、安全边界评级
成立(实测有效) ✅
- uid 隔离:实例内
uid=100002(或 114801),非 root;sudo不可用。 - 路径隔离:看不到其他用户目录(
ls users/→ Permission denied)、看不到 DB、看不到宿主凭据;/usr/etc只读。 - 元数据端点封禁:实测 refused。
- 平台策略文件只读:
cordis.patch.yml/package.json/pnpm-lock.yaml无法被用户改写(防绕过投放管控)。 /tmp每用户独立 + 1777 sticky。- 会话全文扫描:明文凭据 0 命中(档案 37a)。
缺口 ⚠️
| # | 缺口 | 后果 | 等级 |
|---|---|---|---|
| S1 | 共享宿主网络命名空间 | ① 可访问宿主 127.0.0.1:22(SSH 爆破/探测)② 可访问 127.0.0.1:3080 门户 ③ /proc/net/tcp 枚举宿主监听 ④ bind 0.0.0.0 后其他租户与宿主均可访问 → 跨租户横向通道 |
P0 |
| S2 | 磁盘无配额 | 单租户写满 40G → 编排器/DB/所有实例全挂(DoS) | P0 |
| S3 | 并发容量仅 2–3 实例 | 1870MB 内存硬顶;第 4 个用户进场即 OOM 风险 | P1 |
| S4 | 出网"只记录不拦" | 数据外传、SSRF、挖矿;日志有 skuid 覆盖但无告警、无阈值 | P1 |
| S5 | 用户可 pip/npm install 任意包 |
供应链;且包落在工作区(污染 + 占用) | P1 |
| S6 | /etc/passwd 可读 |
枚举 30 个账号名 | P2 |
| S7 | 宿主 /usr 全量可见(1151 个二进制 + 全局 node 包) |
攻击面扩大(本地提权利用链的探测素材) | P2 |
修法方向(按性价比)
- S1:给 bwrap 加
--unshare-net+ 每实例独立的 loopback(--unshare-net会自带 lo,但需ip link set lo up)→ 实例失去对外网访问,需配套 DNS/代理方案;折中版:保留出网,但用 nftmeta skuid规则拒绝实例访问127.0.0.1:22/3080等宿主服务端口(成本最低、收益最直接,不破坏联网能力)。 - S2:
--property=MemoryMax已有 → 加--property=IOWeight/DevicePolicy不够;正解是给<userRoot>上项目配额(ext4 project quota,需 remount 加prjquota)或限制ws大小 + 定期ws-cleanup兜底。 - S3:加全局并发闸门(同时实例数上限)+ 空闲回收(档案 30 已有部分)。
- S4:把 log 升级为计数阈值告警;对已知恶意目标(挖矿池常见域名/IP 段)改
reject。
六、Playwright 专项:为什么"装不起来",以及正确做法
用户问"是不是每人装一份" —— 前提不成立,当前装不起来:
| 要素 | 实测 | 结论 |
|---|---|---|
| 浏览器二进制 | 可下载到 $HOME/.cache/ms-playwright(ws 内可写) |
二进制这层没问题 |
| 系统共享库 | 缺 11 个(libnss3.so/libgbm.so.1/libatk-1.0.so.0/libcups.so.2/libdrm.so.2/libxkbcommon.so.0 …) |
❌ 致命 |
| 补库的途径 | /usr、/lib64 只读;yum/dnf 在但无 sudo |
❌ 用户侧无解 |
| Chromium 大小 | ~300MB/份 | 若每人一份,×N 且无磁盘配额 |
正确做法(一次装、全员用):
- 宿主层
yum install那 11 个库 → 因/usr已 ro-bind,所有实例立即可见,零代码改动; - 浏览器二进制放宿主公共位(如
/usr/local/share/ms-playwright); - 通过 env 注入
PLAYWRIGHT_BROWSERS_PATH指向公共位(需同时加spawn.tsALLOWED_ENV +orchestrator.tsbaseEnv,见档案 10 的 env 白名单卡点)。
但需先回答"值不值得"(档案 37a P1-1 已论证):抖音签名墙只挡「更全」,不挡「够用」;而 MCN 真正要的播放量/完播/涨粉/带货数据 Playwright 拿不到。→ 建议优先级:RedFox 数据源接入 > Playwright。
其他软件包同理:rg/jq/ffmpeg 也是宿主装一次、全员共享(走 /usr/local 或 /usr/bin),不要引导用户自己装 —— 用户自己装既装不上(只读 + 无 sudo),装上了也是 N 份重复。
七、建议行动项
| # | 行动 | 类型 | 说明 |
|---|---|---|---|
| 1 | bwrap 补 --ro-bind bundledSkillDir(§二 P0) |
改码 | 修完技能投放才真正生效;否则档案 37a P0-2 白做 |
| 2 | nft 拒绝实例访问 127.0.0.1:22/3080(S1 折中版) |
改配置 | 成本最低、直接堵住最大安全缺口,不破坏联网 |
| 3 | 给用户目录上配额或大小兜底(S2) | 改码/运维 | 防单租户打瘫全平台 |
| 4 | 公共软件统一走宿主层(rg/jq/ffmpeg/Playwright 系统库) |
运维 | 一次装全员共享,别让用户自己装 |
| 5 | 全局并发闸门 + 出网阈值告警(S3/S4) | 改码 | 容量与观测 |
八、实测命令记录(可复用)
# ① 权威 bwrap 参数(从失败的 scope 里直接读,比读源码更可靠)
systemctl list-units "dsh-*" --no-pager --all | grep scope
# ② 以平台原样参数进实例做只读诊断(把 diag 脚本 ro-bind 进 /tmp 即可,不动用户目录)
B=/usr/bin/bwrap; R=/var/lib/dshs/users/<UID>
$B --ro-bind /usr /usr --ro-bind /lib64 /lib64 \
--symlink usr/bin /bin --symlink usr/sbin /sbin --symlink usr/lib /lib \
--ro-bind /etc /etc --dev /dev --proc /proc \
--bind $R/tmp /tmp --bind $R $R \
--ro-bind /tmp/diag.sh /tmp/diag.sh \
--unshare-pid --chdir $R/ws \
-- setpriv --reuid <uid> --regid <uid> --clear-groups /bin/sh /tmp/diag.sh
# ③ 资源上限 / 宿主容量
free -m; nproc; df -h /; quotaon -p /
# ④ 出网护栏实况
nft list table ip dsh_egress; cat /etc/nftables-dsh-egress.nft
# ⑤ 元数据端点是否真的被封(从实例 uid 发起)
setpriv --reuid 100002 --regid 100002 --clear-groups \
curl -sS -m 8 -o /dev/null -w '%{http_code}\n' http://100.100.100.200/latest/meta-data/
本次踩坑:pgrep -f "dsh --profile web" 会匹配到我自己的 bash 命令行(因为命令串里含该模式)→ 拿到的是假 PID。改用 ps -eo pid,user,cmd | grep -E "^ *[0-9]+ .*node /usr/local/bin/dsh --profile web" 或直接读 systemctl list-units "dsh-*"。