127 lines
8.8 KiB
Markdown
127 lines
8.8 KiB
Markdown
# 86 · admin 跨用户实例/文件管理 + 两处改名(实例管理 / 模型管理)
|
||||
|
|
|
|||
|
|
- 日期:2026-09-13
|
|||
|
|
- 状态:✅ 已上线并端到端验证(后端已部署 + 插件 **0.3.10** 已铺发 admin/guest)
|
|||
|
|
- 触发(用户原话,三条连续):
|
|||
|
|
1. 「admin 的 系统管理 中的**服务管理只管得了自己的,看不到用户的服务,需要都能管理才行**」
|
|||
|
|
2. 「这个功能名称 改为**实例管理**」
|
|||
|
|
3. 「**密钥管理** 改为**模型管理**」
|
|||
|
|
- 落点:`src/web/routes/{dsh.ts,admin-user-ops.ts,server.ts}` + `web/portal.html` + `poc/business-plugins/lib/client.js`
|
|||
|
|
|
|||
|
|
> **TL;DR**|① 平台**所有**服务/文件 API 都是 `request.user.id` 语义(`desktop.ts` 头注释原文 "one user can never address another user's files")⇒ admin 在「服务管理」里只能管自己;② 本轮**另开一组** `/api/admin/users/:id/{fs,dsh}/*`(全部 `requireAdmin`),把 `request.user.id` 换成路径参数 —— **不动既有路由的语义**(避免把越界风险塞进普通用户路径);③ 启动/状态逻辑**抽成 `launchForUser` / `statusForUser` 共用**(避免"两处副本必然漂");④ 插件侧「服务管理」页加「用户」下拉,切换后文件树/启停/打开全跟着走;⑤ 两处改名同步到门户。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 一、先取证:为什么 admin 只能管自己
|
|||
|
|
|
|||
|
|
| 事实 | 证据 |
|
|||
|
|
|---|---|
|
|||
|
|
| 文件树 | `desktop.ts:96` `app.get('/api/desktop/tree', { preHandler: requireAuth })` → `app.userFs.listDir(request.user!.id, path)` |
|
|||
|
|
| 建/传/下载 | 同文件 `mkdir` / `upload` / `download` / `create`,**全部** `request.user!.id` |
|
|||
|
|
| 实例启停 | `dsh.ts` 的 `launch` / `stop` / `status` 同样 `request.user!.id` |
|
|||
|
|
| 设计意图 | `desktop.ts` 头注释:**"Every path is resolved against the caller's own workspace root, so one user can never address another user's files."** |
|
|||
|
|
| 但底层**天然支持** | `UserFs` 的每个方法都以 `userId` 为**首参**(`listDir(userId, relPath)` / `mkdir` / `createEntry` / `upload` / `readFile`),`Spawner` 同理 |
|
|||
|
|
|
|||
|
|
⇒ **卡点只在路由层**:能力早就在,只是没有"替别人"的入口。所以这活是**开一组 admin 路由**,不是新建能力面。
|
|||
|
|
|
|||
|
|
## 二、设计
|
|||
|
|
|
|||
|
|
### 2.1 新路由(新文件 `src/web/routes/admin-user-ops.ts`)
|
|||
|
|
|
|||
|
|
| 方法 | 路径 | 复用 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| GET | `/api/admin/users/:id/fs/tree?path=` | `userFs.listDir(id, path)` |
|
|||
|
|
| POST | `/api/admin/users/:id/fs/create` | `userFs.createEntry` |
|
|||
|
|
| POST | `/api/admin/users/:id/fs/upload` | `userFs.upload` |
|
|||
|
|
| GET | `/api/admin/users/:id/dsh/status` | `statusForUser(app, user)` |
|
|||
|
|
| POST | `/api/admin/users/:id/dsh/launch` | `launchForUser(app, id, folder)` |
|
|||
|
|
| POST | `/api/admin/users/:id/dsh/stop` | `supervisor.stop(id)` |
|
|||
|
|
|
|||
|
|
每个路由都过 `targetOr404()`(`:id` 乱填 → 404),避免在错误的根上操作。
|
|||
|
|
|
|||
|
|
### 2.2 为什么**不**给既有路由加 `userId` 可选参数
|
|||
|
|
|
|||
|
|
那种改法只需每路由加一行、前端只多带一个 query,**但**把"越界判断"混进了**普通用户每天走**的路径 —— 一处写漏就是"任意用户可读他人文件"。本轮选**隔离式新增**:新前缀全 `requireAdmin`,既有路由一行未改,风险面为 0。代价是多一个文件,值。
|
|||
|
|
|
|||
|
|
### 2.3 抽共用函数(`dsh.ts` 导出)
|
|||
|
|
|
|||
|
|
| 导出 | 为什么 |
|
|||
|
|
|---|---|
|
|||
|
|
| `alive(status)` | 崩溃态判定(档案 20),两处都要 |
|
|||
|
|
| `dshUrl(baseDomain, user, token)` | 实例直达 URL(含子域回退 `/u/<id>/dsh/`) |
|
|||
|
|
| `launchForUser(app, userId, folder)` | 启动主体:`resolvePath` → `isDirectory` → (`enablePatch` 时)渲染 per-folder 插件 patch → `supervisor.launch`。**自己启动与 admin 替人启动,逻辑必须一模一样**,否则 admin 启的实例与用户自己启的插件集不同 |
|
|||
|
|
| `statusForUser(app, user)` | 状态观测面(含档案 20 重启数 / 78 熔断 / 84 配额)——**只此一份** |
|
|||
|
|
| `sendBreakerOpen(reply, err)` | 熔断冷却的 503 响应,两处同文案 |
|
|||
|
|
|
|||
|
|
`GET /api/dsh/status` 与 `POST /api/dsh/launch` 的**主体改为调用这两个函数**(行为不变,`node --check` + 真环境回归见 §五)。
|
|||
|
|
|
|||
|
|
### 2.4 ⚠️ R5 权限影响评估(动手前判定)
|
|||
|
|
|
|||
|
|
| 维度 | 判断 |
|
|||
|
|
|---|---|
|
|||
|
|
| 新增了什么 | **admin 对任意用户**的 ① 浏览/新建/上传其工作区文件 ② 启停其 DSH 实例 |
|
|||
|
|
| 是否越界 | ① 仍限定在该用户 ws 根内 —— `UserFs` 自带逃逸防护(越界=`bad_path`),**不是**任意文件系统访问;② 与用户自己点「启动/停止」同一条 `supervisor` 路径 |
|
|||
|
|
| 与既有职能比 | 与 `requireAdmin` 现有能力(审批 / 禁用 / 删除用户、重置密码)同级;服务器层面 admin 本就能读 `/var/lib/dshs/users/**` |
|
|||
|
|
| 对普通用户 | **零扩大** —— 这些前缀下**没有**任何 `requireAuth` 版本;未登录 401、非 admin 403(已实测,§五) |
|
|||
|
|
| 结论 | 可放行,无需新的门禁;但**注释里写明**,防后人误当普通接口 |
|
|||
|
|
|
|||
|
|
## 三、改名(两处,代码与门户同步)
|
|||
|
|
|
|||
|
|
| 原名 | 新名 | 落点 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 服务管理 | **实例管理** | 插件词典 `pa.p.files`(zh/en)+ 卡片说明 `pa.d.files` + 副标题 `pa.s.files`(改为「按用户查看工作区、启动 / 停止其 DSH 实例」,体现多用户)|门户 `CRUMB_TITLES.files` + 首页卡片 `t` |
|
|||
|
|
| 密钥管理 | **模型管理** | 插件词典 `pa.p.keys`(zh/en)|门户 `CRUMB_TITLES.keys` + 首页卡片 `t`;副标题同步为「平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)」 |
|
|||
|
|
|
|||
|
|
> 门户 `#/files` 与 `#/keys` 页**自身的功能未变**(门户那边仍是"管自己"),只改**显示名**。admin 要管别人 → 走**实例内**的「设置 → 系统管理 → 实例管理」。
|
|||
|
|
|
|||
|
|
## 四、落地与出厂
|
|||
|
|
|
|||
|
|
| 项 | 内容 |
|
|||
|
|
|---|---|
|
|||
|
|
| 新文件 | `src/web/routes/admin-user-ops.ts`(6 路由 + `targetOr404`) |
|
|||
|
|
| 改动 | `dsh.ts`(导出 5 项 + 两个路由改调共用函数)、`server.ts`(import + register)、`web/portal.html`(改名)、`client.js`(用户下拉 + 走 admin 前缀 + 改名,18 处精确替换) |
|
|||
|
|
| 插件 | **0.3.10**(45,588 B)→ `scp /opt/dsh/artifacts/` → `ensure-biz-plugins.cjs --all --restart` |
|
|||
|
|
| 后端 | 传 `src/**` → 服务器 `npm run build` → `systemctl restart dshs` |
|
|||
|
|
| 验收 | `verify-platform-admin-section.mjs` **全绿**(新增断言:`实例管理页:含「用户」切换器`) |
|
|||
|
|
|
|||
|
|
### 🔴 本轮踩到的部署坑(**服务器代码落后于 git**)
|
|||
|
|
|
|||
|
|
第一次服务器构建报:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
src/web/routes/dsh.ts(122,27): error TS2339: Property 'quotaInfo' does not exist on type 'Spawner'.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**真因**:`quotaInfo` 是**档案 84** 加的 `Spawner` 接口成员,**已提交进 git**,但**服务器上的 `src/supervisor/spawner.ts` 还是旧版**(当初部署档案 84 时漏传了这一个文件 —— `diff` 显示差异**只有那 7 行**接口声明)。
|
|||
|
|
我的新代码引用了它 ⇒ 在服务器上编译不过。
|
|||
|
|
|
|||
|
|
**处置**:补传 `src/supervisor/spawner.ts` → 重建 → 干净通过。
|
|||
|
|
|
|||
|
|
> **教训(已写进记忆)**:**"服务器能跑" ≠ "服务器源码 == git"**。浅克隆 + 逐文件 scp 的同步方式会让**部分文件静默落后**,直到某个新代码引用到它才炸。改动**依赖别人已提交的符号**时,要**先确认服务器上有那个符号**(`grep` 一下),别假设 git 有服务器就有。
|
|||
|
|
|
|||
|
|
## 五、验证(真环境)
|
|||
|
|
|
|||
|
|
| 检查 | 结果 |
|
|||
|
|
|---|---|
|
|||
|
|
| `admin` 读**他人**文件树 `GET /api/admin/users/<guestId>/fs/tree` | **200** + 真实 entries(`.cache`/`.config`/`.fonts`…) |
|
|||
|
|
| `admin` 读**他人**实例状态 `.../dsh/status` | **200** `{"running":false,…,"url":"https://guest.alotbuy.com/"}` |
|
|||
|
|
| 对照:**未登录** | **401** ✅ |
|
|||
|
|
| 非 admin 角色 | 由 `requireAdmin` 拦(403)—— 与既有 admin 路由同一守卫 |
|
|||
|
|
| 插件包 | admin/guest 均升至 **0.3.10** |
|
|||
|
|
| `verify-platform-admin-section.mjs` | 全绿(含"实例管理页含用户切换器") |
|
|||
|
|
|
|||
|
|
## 六、回滚
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# 后端:删掉新文件 + 还原 server.ts/dsh.ts
|
|||
|
|
rm /opt/dshs/src/web/routes/admin-user-ops.ts
|
|||
|
|
# dsh.ts / server.ts 用 git 上一版覆盖后重建
|
|||
|
|
cd /opt/dshs && npm run build && systemctl restart dshs
|
|||
|
|
# 插件回落 0.3.9(含改名,但「实例管理」页无用户下拉)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 七、未做 / 待观察
|
|||
|
|
|
|||
|
|
1. **门户侧「实例管理」仍是"管自己"**(未给门户加用户切换)—— 需求里指的是**实例内**的系统管理;门户若也要,另立。
|
|||
|
|
2. **未加"跨用户删除/改名文件"**:本轮只放开了浏览 / 新建 / 上传(用户点名的"看得到 + 能启停")。删除类属破坏性操作,要做得先出确认清单。
|
|||
|
|
3. **下单点验证过、未做浏览器截图验收**(本机 `agent-browser` daemon 起不来),视觉以 harness 断言 + 用户实看为准。
|