Files
dsh_shenxian/dsh-server-docs/04-调整方案/38a-实例软件安装共享与网络安边界核查.md
T

223 lines
15 KiB
Markdown
Raw Normal View History

# 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-*"`。