Files
dsh_shenxian/dsh-server-docs/04-调整方案/38a-实例软件安装共享与网络安边界核查.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

224 lines
15 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.
# 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)
**证据链**
1. `config.ts:245-247`:`bundledSkillDir = overrides ?? env.DSHS_BUNDLED_SKILL_DIR ?? join(dataRoot,'bundled-skills')`;`/etc/dshs.env` **未设**该变量 → 实际值 = **`/var/lib/dshs/bundled-skills`**。
2. `orchestrator.ts:363`:`DSH_BUNDLED_SKILL_DIR` 已注入实例 env ✅(环境变量这层没问题)。
3. `orchestrator.ts:454-486` 的 bwrap 参数**只挂** `/usr`、`/lib64`、`/etc`、`/dev`、`/proc`、`<userRoot>/tmp`、`<userRoot>`、profile 三个只读文件 —— **没有任何一条 `--ro-bind` 覆盖 `bundled-skills`**。
4. **以平台原样参数实测**(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
```
5. `dsh-skill-filesystem/lib/index.js:84,181-183` 是**实例内直接读盘**,不是由编排器推送数据:
```js
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 合成根,非宿主盘)| 借宿主机内存池 |
---
## 五、安全边界评级
**成立(实测有效)** ✅
1. **uid 隔离**:实例内 `uid=100002`(或 114801),非 root;`sudo` 不可用。
2. **路径隔离**:看不到其他用户目录(`ls users/` → Permission denied)、看不到 DB、看不到宿主凭据;`/usr` `/etc` 只读。
3. **元数据端点封禁**:实测 refused。
4. **平台策略文件只读**:`cordis.patch.yml` / `package.json` / `pnpm-lock.yaml` 无法被用户改写(防绕过投放管控)。
5. **`/tmp` 每用户独立** + 1777 sticky。
6. 会话全文扫描:**明文凭据 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/代理方案;**折中版**:保留出网,但用 nft `meta 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 且无磁盘配额 |
**正确做法(一次装、全员用)**:
1. **宿主层 `yum install`** 那 11 个库 → 因 `/usr` 已 ro-bind,**所有实例立即可见,零代码改动**;
2. 浏览器二进制放宿主公共位(如 `/usr/local/share/ms-playwright`);
3. 通过 env 注入 `PLAYWRIGHT_BROWSERS_PATH` 指向公共位(**需同时加 `spawn.ts` ALLOWED_ENV + `orchestrator.ts` baseEnv**,见档案 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)| 改码 | 容量与观测 |
---
## 八、实测命令记录(可复用)
```bash
# ① 权威 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-*"`。