223 lines
15 KiB
Markdown
223 lines
15 KiB
Markdown
# 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-*"`。
|