初始提交:DSH 多租户平台(dshs)

This commit is contained in:
admin committed 2026-09-13 16:18:10 +08:00
commit 43976fea6a
167 files changed
+24456

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
# 技术蓝图 — DSH 服务端登录插件
> 🧭 [← 返回 README](../README.md) · 部署(模式 A):[deployment](deployment.md) · 部署(模式 B):[k8s-deployment](k8s-deployment.md)
多租户托管平台,让用户通过域名安全访问自己的 DeepSeek Harness(DSH)实例。本文是权威技术设计;实现与本文冲突时以本文为准,并同步回改。
## 1. 运行拓扑
```
用户浏览器
├─(HTTPS)─> dsh.<域名> → 编排服务 Fastify(认证/管理/桌面/域名,/api/*)
└─(HTTPS)─> <用户名>.dsh.<域名> → 编排服务按 Host 头路由到该用户 DSH(HTTP + WebSocket)
```
- **主域 `dsh.<域名>`**:编排服务自己的登录 / 管理台 / 桌面 / API。
- **每用户子域 `<用户名>.dsh.<域名>`**:编排服务按 Host 头解析用户名 → 校验会话 cookie → 反向代理到该用户 DSH 的 `127.0.0.1:<动态端口>`(HTTP + WebSocket 隧道)。
- 每用户 DSH 只绑回环端口、不直接暴露公网;端口表随 spawn/respawn 即时更新,nginx 用通配 `*.dsh.<域名>` 透传即可,无需每次 reload。
- 编排服务是独立 Node 进程,`child_process.spawn('dsh --profile web --host 127.0.0.1 --port <随机>', ...)` 拉起每用户 DSH(主 + 按需守护)。
## 2. 打包与启动
- **主入口**:独立 `dshs` bin(`node lib/cli.js`)直接跑 Fastify + SQLite + 进程编排。
- **市场识别**:根 `package.json` 的 `dsh` 字段(`plugin`/`kind`/`bundle.patch`)+ `cordis.patch.yml`;不 import 任何 `@deepseek-ai/*` 宿主包,peerDependencies 为空,规避宿主包遮蔽。
- **cordis 入口 `apply()` 是带守卫空操作**:默认无副作用,装进任意 profile 都不起服务器。
- **产物型分发**:提交 `lib/`(构建产物),`prepare` = `npm run build` 供 git 安装自构建。
## 3. spawn 每用户 DSH
```ts
spawn(dshBinPath, ['--profile', 'web', '--patch', mainPatchPath, '--host', '127.0.0.1', '--port', String(port)], {
cwd: workspacePath, // 用户在桌面选的文件夹
env: { ...scrubEnv(process.env), HOME: workspacePath, DSH_HOME: homeDir, DEEPSEEK_API_KEY: userApiKey },
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
})
```
- env 擦除镜像 harness 的 `scrubbedParentEnv`/`SENSITIVE_ENV_PATTERN` 思路:只向子进程显式注入已解析 key。
- 端口是 CLI flag(`--port`),**不是** env、**不是** patch(踩坑记录见 [troubleshooting.md](troubleshooting.md)「端口冲突」)。
- 进程树 teardown 自行实现:SIGTERM → grace → SIGKILL。
## 4. 双 DSH「共享对话 + 崩溃接管」
**共享状态 = 每用户 `$DSH_HOME` 里的持久会话日志**(append-only;`session-persistence` 落盘)。
- **主 DSH** 独占实时会话并持续 append;绑定回环端口对外服务。
- **守护 DSH** 是**按需拉起的一次性 headless DSH**(不常驻):主 DSH 崩溃时、或需执行 post-restart 命令时,编排服务才 spawn 一次;它与主 DSH 同 home/同 workspace,通过 `loadStoredFrom(id, fromSeq)` 读同一日志,修复/resume 后退出。
**崩溃接管闭环**(主 DSH 崩溃时,编排服务拉起一次守护 DSH 并自动重启主 DSH):
1. **诊断**:读退出码 + stderr 尾部 + 会话日志尾部,判定崩溃点。
2. **修复会话日志**:`interruptedTurnClosers`(`packages/core/session/src/repair.ts`)+ `session-persistence.load`/`commitRepair` 把中断 turn 合成 `tool/result`/`step/end`/`turn/end{interrupted}`,产出可恢复的合法转录。
3. **修复根因**:守护 DSH 以 agent 身份(对共享 workspace 有工具权限)修文件/配置、摘坏插件、杀卡死子进程。
4. **接手会话**:`ctx.sessionPersistence.prepare`/`load`(或 `ctx.sessions.create({seed})`)恢复修复后的日志,接续对话成为新主 DSH;随后可选重拉 fresh 主 DSH 并退回守护位。
**计划内重启(装插件)**:主 DSH 退出前把「post-restart 自动命令」写成 JSON 落到 `$DSH_HOME`,守护 DSH 执行重启后命令。
**关键澄清**:「接手」= 顺序 failover(恢复同一持久日志续接对话),非两个活体同时驱动同一 turn——这是 harness 的 resume 语义,无需自建双向活体通道。
## 5. 数据模型(SQLite,migration v1)
- v1 使用:`users`、`sessions`、`workspaces`、`folder_plugins`、`dsh_instances`、`audit_log`。
- 预留:`domains`、`credential_vault`(references-not-secrets,密文引用不落明文)。
字段与约束见 `src/db/schema.ts`。
## 6. API 面
| 组 | 路由 | 脚手架状态 |
|---|---|---|
| Auth | `POST /api/auth/register\|login\|logout`、`GET /api/auth/me` | 已实现(P1) |
| Admin | `GET /api/admin/users`、`POST /api/admin/users/:id/approve\|disable\|enable` | 已实现(P1;disable 会同时删会话 + 停 DSH) |
| Desktop/FS | `GET /api/desktop/tree`、`POST /api/fs/mkdir\|upload\|create` | 已实现(P2;路径经词法 + 符号链接双重围栏) |
| 凭据库 | `GET/POST /api/me/keys`、`POST /api/me/keys/:id/select`、`DELETE /api/me/keys/:id` | 已实现(每用户命名密钥,AES-256-GCM 加密落库) |
| DSH | `POST /api/dsh/launch\|stop\|restart`、`GET /api/dsh/status`、`GET /u/:slug/dsh/*` | 已实现(P3/P5,HTTP 代理;main+watchdog 编排层) |
| Plugin | `GET /api/plugins`、`POST /api/plugins/select` | 已实现(P4) |
| Domain/nginx | `GET/PUT /api/domain`、`POST /api/nginx/regen`、`GET /api/admin/domains`、`POST /api/admin/domains/:id/verify` | 已实现(P6,管理员手动验证;ACME 待接入) |
| 静态 | `GET /*`(占位 SPA) | 已接 |
## 7. 安全模型
默认软隔离(每用户 `$DSH_HOME` + session `cwd` + 沙箱写隔离);Linux 生产建议开启账号级硬隔离(每用户 OS 账号),见 [hard-isolation.md](hard-isolation.md)。两个兜底:
- **端口守卫(portGuard)**:iptables OUTPUT owner-match 规则,阻止同机其他账号直连各用户 DSH 的回环端口、绕过编排服务认证;不支持的环境下开启即拒绝启动。
- **路径围栏**:所有文件面先做词法包含校验(`resolveWithinRoot`),再逐段 `lstat` 拒绝符号链接分量,防止经工作区内的链接越出用户根。
## 8. 分阶段路线
P1–P7 已完成:登录审核、桌面/FS、单 DSH 启动、每文件夹插件、守护/双 DSH、域名/nginx、硬隔离。模式 B(K8s + Postgres + leader election + reconcile,每用户 Pod)已落地:清单见 `deploy/`,部署教程见 [k8s-deployment.md](k8s-deployment.md)。
+370
View File
@@ -0,0 +1,370 @@
# 部署教程(新手版)— DSH 服务端登录插件
> 🧭 [← 返回 README](../README.md) · 进阶加固:[硬隔离](hard-isolation.md) · 出问题:[排查手册](troubleshooting.md) · 域名细节:[域名配置示例](domain-config.md)
这份教程假设你**第一次**部署这类服务。每一步都写清楚「做什么、为什么、怎么做、应该看到什么」。照着顺序做,大约 15–30 分钟能把整套跑通。
> 全文用 `example.com` 当例子,**请把所有 `example.com` 换成你自己的域名**。比如你的域名是 `dsh.baidu.com`,那么 `dsh.example.com` 就是 `dsh.baidu.com`,`*.dsh.example.com` 就是 `*.dsh.baidu.com`。
---
## 0. 先搞懂几个词(看不懂也没关系,后面会一直用到)
| 词 | 大白话解释 |
|---|---|
| **服务器** | 一台一直开机的电脑(这里指 Linux 云服务器),你的服务跑在上面。 |
| **域名** | 人类好记的名字,比如 `example.com`。它最终会被翻译成服务器的 IP。 |
| **DNS** | 负责「域名 → IP」翻译的系统。你在域名商那里改 DNS 记录,就是告诉全世界「这个名字指向那台服务器」。 |
| **子域名** | 在域名前面再加一段。比如 `dsh.example.com`、`carol.dsh.example.com` 都是 `example.com` 的子域名。 |
| **端口** | 一台服务器上的不同「门牌号」。一个服务占一个端口。 |
| **nginx** | 一个「反向代理」软件:站在门口,把外面来的请求按域名转发给里面跑的服务。 |
| **HTTPS / 证书** | 让浏览器显示「🔒 安全」的加密层。证书要针对具体域名签发。 |
| **环境变量** | 给程序传配置的方式,形如 `名字=值`。 |
| **进程** | 正在运行的一个程序。 |
## 0.1 整体长什么样(先有个全局印象)
```
你的用户(浏览器)
│
│ 访问 dsh.example.com(登录、管理、桌面)
│ 访问 carol.dsh.example.com(carol 这个用户的 DSH 聊天界面)
▼
nginx(门口,按域名分发 + 加 HTTPS 锁)
▼
编排服务 dshs(本插件,跑在 127.0.0.1:3080)
├─ 管登录、审核、桌面
└─ 按域名把 carol 的请求转发给「carol 的 DSH 进程」
└─ DSH 进程(DeepSeek Harness,跑在随机的本机端口上)
```
**两个东西别搞混**:
- **`dshs`(编排服务)**:我们这个插件,管「登录、审核、桌面、按域名转发」。
- **`dsh`(DSH)**:DeepSeek Harness,真正的 AI 聊天界面。它由编排服务**自动**为每个用户启动,你不需要手动跑它。
---
## 1. 部署前准备(清单)
1. 一台 Linux 服务器,能 `root` 登录(推荐 Ubuntu 22.04+)。
2. 一个自己的域名,能登录域名商后台改 DNS 记录。
3. 大概半小时。
---
## 2. 第 1 步:安装 Node.js
本插件需要 Node 22 以上。用 nvm 装最省心:
```sh
# 1) 安装 nvm(Node 版本管理器)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# 2) 让当前终端生效(或关掉重开一个终端)
source ~/.bashrc
# 3) 安装 Node 22
nvm install 22
# 4) 验证
node -v # 应该显示 v22.x.x
```
---
## 3. 第 2 步:安装 DSH(DeepSeek Harness)
这是聊天界面的本体。用 npm 装它的 CLI:
```sh
npm install -g @deepseek-ai/dsh
```
> 如果这个包名不对(DSH 还在预发布阶段,安装方式可能变),以 DeepSeek Harness 官方文档为准。**装完验证一句话**:
```sh
dsh --version # 能打印版本号就是装好了
```
---
## 4. 第 3 步:安装本插件(dshs)
```sh
# 1) 下载源码
git clone https://work.alotbuy.com/maogeigei/dsh_shenxian.git
cd dshs
# 2) 装依赖 + 编译
npm install
npm run build
```
装完这个目录里会有个 `lib/`(编译产物)和 `node_modules/`(依赖)。
> **让子 DSH 能加载本插件的运行时插件**:本插件会通过 `--patch` 给每个用户的 DSH 挂一个运行时插件(负责注入「守护 DSH」上下文、绑定端口等)。前提是 `dshs` 要装进 DSH 的 profile:
>
> ```sh
> dsh plugin --profile web add /dsh_login/dshs
> ```
>
> 不做这步,运行时插件加载不了,守护 DSH 的上下文注入就不会生效。
---
## 5. 第 4 步:创建管理员账号
管理员是第一个账号,由他审核其他注册用户。
```sh
# 先加载环境变量(让数据库路径一致,见下面警告)
source /etc/dshs.env
node lib/cli.js bootstrap-admin --username admin --password '你的强密码'
```
- `--username admin`:管理员用户名(可换)。
- `--password '你的强密码'`:管理员密码(换成你自己的,别用弱密码)。
看到 `admin "admin" created (...)` 就成功了。
> ⚠️ **数据库路径必须全程一致**:管理员、编排服务、systemd 用的必须是**同一个数据库**。
> 本教程统一用 `<DATA_ROOT>/dshs.db`(= `/var/lib/dshs/dshs.db`),所以**建管理员和启动服务都不加 `--db`、都先 `source /etc/dshs.env`**。
> 如果你在某处加了 `--db 别的路径`,那建管理员和 systemd **必须加同一个路径**,否则登录不进。
---
## 6. 第 5 步:配置环境变量 + 启动编排服务
先写一个环境变量文件,把配置集中放一起,方便以后改:
```sh
cat > /etc/dshs.env <<'EOF'
DSHS_PORT=3080
DSHS_DATA_ROOT=/var/lib/dshs
DSHS_BASE_DOMAIN=dsh.example.com
DSHS_COOKIE_DOMAIN=.dsh.example.com
DSHS_SECURE_COOKIES=true
DSHS_DSH_BIN=/root/.nvm/versions/node/v22.23.2/bin/dsh
EOF
```
逐个解释:
| 变量 | 值 | 为什么 |
|---|---|---|
| `DSHS_PORT` | `3080` | 编排服务自己监听的端口(nginx 会转发到这个端口)。 |
| `DSHS_DATA_ROOT` | `/var/lib/dshs` | 每个用户的文件/配置存哪里。 |
| `DSHS_BASE_DOMAIN` | `dsh.example.com` | **关键**:告诉编排服务「子域名长什么样」——`<用户名>.dsh.example.com`。 |
| `DSHS_COOKIE_DOMAIN` | `.dsh.example.com` | **关键**:登录 cookie 加这个 `Domain`,才能被子域名共享。注意前面的**点**。 |
| `DSHS_SECURE_COOKIES` | `true` | 走 HTTPS,cookie 必须标 `Secure`。 |
| `DSHS_DSH_BIN` | `/root/.nvm/versions/node/v22.23.2/bin/dsh` | 编排服务用它启动每个用户的 DSH。**用绝对路径**,别写 `dsh`——systemd 的 PATH 找不到(否则报 `spawn dsh ENOENT`)。版本号按你实际 nvm 版本改:`ls ~/.nvm/versions/node/`。 |
> 🔐 **可选加固(生产建议)**:在 env 文件里再加一行 `DSHS_PORT_GUARD=true`。它用 iptables 的 OUTPUT owner-match 规则,禁止同机其他账号直连各用户 DSH 的回环端口(编排服务自身不受影响)。仅 Linux + root 有效;在不支持的环境下开启会直接拒绝启动,避免「以为有防护其实没有」。
> 🔑 **API 密钥是每用户自己的密钥库**:不再在环境变量里配平台 key。每个用户登录后,在桌面 → DSH 窗口点「**管理密钥**」(在「管理插件」旁)添加多个**命名的**密钥(AES-256-GCM 加密存在该用户自己的数据里),并可**选择启用哪一个**;spawn 时只注入该用户当前启用的 key。未设置 key 的用户,其 DSH 能启动但无法调用模型。
### 6.1 先手动跑一次(验证能起来)
```sh
source /etc/dshs.env
node lib/cli.js
```
看到 `dshs listening on http://127.0.0.1:3080` 就是起来了。`Ctrl+C` 停掉,下面配成开机自启。
### 6.2 配成开机自启(systemd)
```sh
cat > /etc/systemd/system/dshs.service <<'EOF'
[Unit]
Description=DSH server login orchestrator
After=network.target
[Service]
WorkingDirectory=/root/dshs
EnvironmentFile=/etc/dshs.env
Environment=PATH=/root/.nvm/versions/node/v22.23.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/root/.nvm/versions/node/v22.23.2/bin/node lib/cli.js
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now dshs
systemctl status dshs # 看到 active (running) 就对了
```
> 把 `WorkingDirectory` 换成你实际 `git clone` 的目录(上面假设是 `/root/dshs`)。
> ⚠️ **两个 nvm + systemd 必踩的坑**(不这样写就会起不来):
> 1. **`ExecStart` 必须用 nvm node 的绝对路径**,不能写 `/usr/bin/env node`——systemd 的 PATH 没有 nvm,`node` 会解析到别的版本,导致 `better-sqlite3` 原生模块报 `ERR_DLOPEN_FAILED`(ABI 版本不匹配,编排服务起不来)。
> 2. **必须把 nvm 的 bin 加进 PATH**(上面那行 `Environment=PATH=...`)——否则编排服务 spawn 子 DSH 时 `dsh` 找不到(`spawn dsh ENOENT`),而且 `dsh` 脚本内部也是 `#!/usr/bin/env node`,同样需要 `node` 在 PATH。
> 上面所有 nvm 路径按你实际版本改:`ls ~/.nvm/versions/node/`。
---
## 7. 第 6 步:配置 DNS
登录你的域名商后台,加两条 **A 记录**,都指向服务器的 IP:
| 类型 | 主机记录 | 值 |
|---|---|---|
| A | `dsh` | 你的服务器 IP |
| A | `*.dsh` | 你的服务器 IP |
- `dsh` → 让 `dsh.example.com` 指向服务器。
- `*.dsh`(通配)→ 让 `carol.dsh.example.com`、`bob.dsh.example.com` 等任意子域名都指向服务器。
改完等几分钟 DNS 生效。验证(在本机跑,把 IP 换成你的服务器 IP):
```sh
ping dsh.example.com # 应该解析到你的服务器 IP
```
---
## 8. 第 7 步:签发 HTTPS 证书(通配证书)
子域名多、又不能挨个签,所以签一张**通配证书**(`*.dsh.example.com`)。通配证书必须用 **DNS 验证**(证明你真的拥有这个域名)。
以 Cloudflare 为例:
```sh
# 1) 装 Cloudflare 的 certbot 插件
apt install -y certbot python3-certbot-dns-cloudflare
# 2) 准备一个存 API 令牌的文件(去 Cloudflare 后台建一个 DNS 编辑权限的 token)
cat > /etc/cloudflare.ini <<'EOF'
dns_cloudflare_api_token = 你的token
EOF
chmod 600 /etc/cloudflare.ini
# 3) 签发(注意两条 -d:通配 + 主域)
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/cloudflare.ini \
-d '*.dsh.example.com' -d 'dsh.example.com'
```
> 如果你不用 Cloudflare,改用对应插件(阿里云 `--dns-aliyun`、腾讯云 `--dns-tencentcloud`、DNSPod `--dns-dnspod` 等),原理一样。
>
> ⚠️ Cloudflare 免费套餐只代理二级及以下的域名;若你的通配域名到了第三级(如 `*.dsh.example.com`),需要把该记录的 Cloudflare 代理关掉(灰云,仅 DNS)。这会暴露服务器真实 IP,请自行权衡风险。
签好后证书在这里:
```sh
ls /etc/letsencrypt/live/dsh.example.com/
# 应看到 fullchain.pem 和 privkey.pem
```
---
## 9. 第 8 步:配置 nginx
先装 nginx:
```sh
apt install -y nginx
```
新建配置文件:
```sh
cat > /etc/nginx/conf.d/dshs.conf <<'EOF'
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 主域:登录 / 管理台 / 桌面
server {
listen 443 ssl http2;
server_name dsh.example.com;
ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3080;
proxy_set_header Host $host; # 关键:保留真实域名
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
# 通配子域:每个用户的 DSH
server {
listen 443 ssl http2;
server_name *.dsh.example.com;
ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3080;
proxy_set_header Host $host; # 关键:保留原始子域名
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
EOF
```
**最重要的两行**(做错就会各种 404/401):
- `server_name dsh.example.com` 和 `server_name *.dsh.example.com`:区分主域和子域。
- `proxy_set_header Host $host`:**别删**,它把原始域名透传给编排服务,子域路由靠它。如果你写成 `Host dsh.example.com`(固定值)或干脆不写,所有子域都会被当成主域,路由就乱了。
改完重载:
```sh
nginx -t && systemctl reload nginx
```
---
## 10. 第 9 步:(可选)账号级硬隔离
> **新手可跳过这一步**,默认的「软隔离」已经能跑、能用。硬隔离是给生产环境防「一个用户偷看另一个用户文件」用的进阶项,需要 root 权限。想做就按 [hard-isolation.md](hard-isolation.md) 一步步操作。
---
## 11. 第 10 步:从头验证一遍
按这个顺序走一遍,每步看到对应结果就说明那一步对了:
1. **打开主域**:浏览器访问 `https://dsh.example.com` → 看到登录页(不是 502/404)。
2. **注册一个用户**:点「注册」,填用户名(比如 `carol`)+ 密码 → 提示「等待审核」。
3. **审核**:用管理员账号登录 → 管理台 → 点「通过」carol。
4. **用户登录**:carol 登录 → 进入桌面(文件浏览器)。
5. **启动 DSH**:桌面点「在此文件夹启动 DSH」→ 显示「运行中」。
6. **打开 DSH**:点「打开 DSH」→ 跳到 `https://carol.dsh.example.com/` → 看到 DSH 聊天界面(不是 502/404/401)。
> ⏳ **冷启动**:真实 DSH 启动要 **几秒到十几秒**(源码启动约 4s)。点「启动」后 `status` 会先显示 `running`,但端口要等插件树 boot 完才绑上——**别立刻点「打开 DSH」,等 10 秒再开**,否则会 502。
### 如果某一步卡住了
对照 [troubleshooting.md](troubleshooting.md) 找现象 → 根因 → 修法。最常见的几个:
- 502:DSH 刚启动还在「冷启动」(等 10 秒),或 DSH 没起来。
- 404:`BASE_DOMAIN` 没设、或 nginx 的 `Host` 头被改掉了。
- 401:cookie 没到子域(`COOKIE_DOMAIN` 没设或没带前导点),或浏览器里是旧 cookie(删掉重新登录)。
- 403:DSH 的信任栅栏不认请求(`Origin` 头问题,已在内置代理里处理)。
---
## 12. 以后怎么更新插件
```sh
cd /root/dshs
git pull
npm run build # 重新编译(改过代码就必须跑)
systemctl restart dshs
```
> 重要:**光 `git pull` 不够**,一定记得 `npm run build`——因为跑的是编译产物 `lib/`,不编译的话改动不会生效。
+153
View File
@@ -0,0 +1,153 @@
# 域名配置示例 — DSH 服务端登录插件
> 🧭 [← 返回 README](../README.md) · 基础部署:[deployment](deployment.md) · 出问题:[排查手册](troubleshooting.md)
本文给出域名与 nginx 的配置示例。**注意**:默认访问方式已改为**每用户子域名**(`<用户名>.dsh.<域名>`,HTTP + WebSocket 均已支持)——因为 DSH 的 SPA 用绝对路径、子路径方案不兼容。通配 nginx 配置见 [deployment.md §6](deployment.md),常见问题见 [troubleshooting.md](troubleshooting.md)。下面的「子路径」示例仅作遗留参考。
## 1. 访问拓扑
```
用户浏览器
└─(HTTPS)─> nginx(边缘:TLS 终结 + host 路由)
└─(反向代理)─> 编排服务 Fastify (127.0.0.1:3080)
├─ /api/* /login /register /admin /desktop (编排服务自管)
└─ /u/<userId>/dsh/* ──> 127.0.0.1:<动态端口> (每用户 DSH)
```
- 每用户 DSH 只绑定**回环端口**(`127.0.0.1:<动态端口>`),不直接暴露公网;端口由编排服务在启动时分配。
- nginx 只做边缘 TLS 与转发,把 `/u/*` 原样透传给编排服务即可——**每用户 DSH 的端口映射由编排服务自己维护**,nginx 无需在每次 DSH 重启时 reload。
## 2. 默认域名(所有用户共享一个域名)
用子路径区分用户:`https://dsh.example.com/u/<userId>/dsh/`。
```nginx
# /etc/nginx/conf.d/dshs.conf
# WebSocket 升级头(DSH Web UI 依赖)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream dsh_orchestrator {
server 127.0.0.1:3080; # 编排服务绑定端口(DSHS_PORT)
keepalive 32;
}
server {
listen 80;
server_name dsh.example.com;
return 301 https://$host$request_uri; # 强制 HTTPS
}
server {
listen 443 ssl http2;
server_name dsh.example.com;
ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem;
# 编排服务 trustProxy=true,会读取以下头还原真实 IP / 协议
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location / {
proxy_pass http://dsh_orchestrator;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # 长连接(DSH 对话 / WebSocket)
}
}
```
**说明**:上面只配置了默认域名一条规则;`/u/<userId>/dsh/` 的转发到哪台每用户 DSH,由编排服务内部决定,nginx 不关心。
## 3. 自定义域名(每用户专属域名)
设计目标:每个用户可用自己的域名直达自己的 DSH,例如 `https://alice.example.com` → alice 的 DSH。
### 3.1 手动映射(当前可手写生效)
由于编排服务已经支持 `/u/<userId>/dsh/*`,可先在 nginx 手写一条把自定义域名根路径重写到子路径:
```nginx
server {
listen 443 ssl;
server_name alice.example.com;
ssl_certificate /etc/letsencrypt/live/alice.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/alice.example.com/privkey.pem;
location / {
# 把自定义域名根路径重写到 alice 的 DSH 子路径
proxy_pass http://127.0.0.1:3080/u/<alice-user-id>/dsh/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
```
> `proxy_pass` 带 URI(以 `/` 结尾)时,nginx 会把匹配到的 `/` 替换为 `/u/<alice-user-id>/dsh/`,
> 于是 `https://alice.example.com/foo` → `http://127.0.0.1:3080/u/<alice-user-id>/dsh/foo`。
> 编排服务再剥掉 `/u/<userId>/dsh` 前缀转发给该用户的 DSH。
### 3.2 自动生成(已实现)
`POST /api/nginx/regen` 会根据 `domains` 表为每个已验证的自定义域名生成一个上面的 `server {}` 块,
由 [src/nginx/generate.ts](../src/nginx/generate.ts) 的 `renderServerBlock(domain, upstreamPort)` 渲染,运维写入
`/etc/nginx/conf.d/` 后 `nginx -s reload`。
## 4. HTTPS 证书(certbot / ACME)
```sh
# 默认域名
sudo certbot --nginx -d dsh.example.com
# 自定义域名(每个用户域各自签发)
sudo certbot --nginx -d alice.example.com
```
证书签发后 certbot 会自动改写对应 `server {}` 的 `ssl_certificate*`。证书的自动签发/续期(ACME)暂未接入:目前域名由管理员在管理台手动标记「已验证」,签发仍用 certbot 手动跑。后续计划把这一步与 `PUT /api/domain`(DNS/HTTP 挑战验证)串起来,实现「用户填域名 → 自动验证 → 自动签发 → 生成并热加载 nginx 配置」。
## 5. WebSocket 说明
DSH Web UI 使用 WebSocket。编排服务的反向代理已支持 WebSocket 隧道([proxy.ts](../src/supervisor/proxy.ts) 的 `app.server` `upgrade` 处理器),按 Host 头路由到该用户 DSH,与 HTTP 一致。nginx 侧只需 `map $http_upgrade` + `Upgrade/Connection` 头透传。子域名下 WS 走 `wss://<用户名>.dsh.<域名>/api/...`。
## 6. 域名配置 API(已实现;ACME 自动验证待接入)
| 方法 | 路径 | 作用 |
|---|---|---|
| `GET` | `/api/domain` | 查询当前用户的域名 + nginx 配置 |
| `PUT` | `/api/domain` | 设置自定义域名,生成对应的 nginx `server {}` 块(`verified` 重置为 0) |
| `POST` | `/api/nginx/regen` | 重新生成并预览 nginx `server {}` 块 |
| `GET` | `/api/admin/domains` | 管理员:列出所有自定义域名 |
| `POST` | `/api/admin/domains/:id/verify` | 管理员:手动标记域名「已验证」(DNS 归属校验暂未自动化) |
实现见 [src/web/routes/domain.ts](../src/web/routes/domain.ts) 与 [src/nginx/generate.ts](../src/nginx/generate.ts)。
## 7. 生产环境变量
| 变量 | 说明 |
|---|---|
| `DSHS_PORT` | 编排服务绑定端口(nginx 上游需一致),默认 `3080` |
| `DSHS_DATA_ROOT` | 每用户 home / workspace 根,生产建议 `/var/lib/dshs` |
| `DSHS_SECURE_COOKIES` | **HTTPS 部署必须设为 `true`**(否则会话 cookie 不带 `Secure`) |
| `DSHS_DSH_BIN` | 子 DSH 可执行文件(默认 `dsh`) |
编排服务 `host` 默认 `127.0.0.1`(只监听回环,由 nginx 作为唯一公网入口),保持默认即可。
## 8. 安全注意
- 每用户 DSH 只绑回环端口;不要把它们改成 `0.0.0.0`,否则绕过认证直接暴露。
- 会话 cookie 在 HTTPS 下必须启用 `Secure`(见 §7)。
- 编排服务 `trustProxy=true`,仅在与 nginx 同机且信任其 `X-Forwarded-*` 头时使用;不要把它直接暴露公网。
- 自定义域名的归属校验(DNS/HTTP 挑战)在 P6 落地前,**不要**开放 `PUT /api/domain` 给普通用户随意映射他人路径。
+251
View File
@@ -0,0 +1,251 @@
# 硬隔离教程(新手版)— 每用户独立 OS 账号
> 🧭 [← 返回 README](../README.md) · 前置:[基础部署](deployment.md) · 出问题:[排查手册](troubleshooting.md)
> 本教程是 [deployment.md](deployment.md) 的进阶篇。先按那篇把整套跑通、能正常登录和打开 DSH,再回来做这个。
> 全文用 `example.com` 举例,请替换成你自己的域名;`/root/dshs` 换成你实际 `git clone` 的目录。
## 0. 为什么需要硬隔离(先看懂再动手)
默认部署是**软隔离**:所有用户的 DSH 进程跑在**同一个系统账号(root)**下,每个用户自己的目录设了 `0700`(只有本人能进)。
问题是:`0700` 只对**别的系统账号**有效。同一个 root 账号下的进程之间,**权限检查形同虚设**——一个用户(的 DSH 进程)可以用 root 直接读另一个用户的文件。
**硬隔离**就是:给每个用户建一个**独立的 Linux 系统账号**,让它的 DSH 进程以这个账号运行。这样:
- `0700` 才真正生效(别的账号进不来)。
- 用户 A 的 DSH 进程,即使用户 B 的目录是 `0700`,也会被系统直接拒绝。
**代价**:需要 root 权限 + 每个用户创建时要做一次「建账号 + 改属主」。好在可以自动化(见 §3.1),一次配好就不用管。
---
## 1. 确认编排服务已用 root 运行
硬隔离要靠 `setpriv` 把进程降权到目标账号,这**只有 root 能调用**。所以编排服务必须以 root 跑。
如果你用了 [deployment.md](deployment.md) 的 systemd 配置(`/etc/systemd/system/dshs.service`),默认就是 root,跳过这步。
---
## 2. 打开账号级隔离的开关
在环境变量文件里加两个配置(`/etc/dshs.env`):
```sh
# 追加这两行
DSHS_ISOLATION_MODE=account
DSHS_BASE_UID=100000
```
| 变量 | 值 | 解释 |
|---|---|---|
| `DSHS_ISOLATION_MODE` | `account` | 开启账号级隔离(默认 `soft`)。 |
| `DSHS_BASE_UID` | `100000` | 每个用户 uid 的「起始数字」。系统 uid 一般 < 1000,这里从 10 万开始,避免撞系统账号。 |
改完**重启编排服务**让配置生效:
```sh
systemctl restart dshs
```
> 从这一刻起,编排服务 spawn 每个用户的 DSH 时,会用它内置的 `setpriv` 命令(`setpriv --reuid <uid> --regid <uid> --inh-caps=-all --clear-groups --`)把进程降权到该用户账号。
---
## 2.1 ⚠️ 前置:`dsh` 必须装在降权账号能读到的位置(不能装在 `/root` 下)
硬隔离降权后,子 DSH 以每用户账号(uid > 100000)运行,这些账号**读不到 root 的 home 目录 `/root`**。
如果 `dsh`(以及它依赖的 node)是用 nvm 装在 `/root/.nvm/...` 下的(默认就是),开了 `account` 之后,子 DSH 一启动就会报 `Cannot find module '/root/.nvm/.../bin/dsh'`,然后**每秒崩溃循环**。日志长这样:
```
[dsh-child main] node:internal/modules/cjs/loader:1210
Error: Cannot find module '/root/.nvm/versions/node/.../bin/dsh'
```
**所以开 §2 的开关之前,先把 node + dsh 装到系统级位置**(所有账号都能读):
```sh
# 1) 系统级安装 node 22(装到 /usr/bin)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
# 2) 系统级安装 dsh(注意:必须强制 prefix,否则会被 nvm 劫持装回 /root)
NPM_CONFIG_PREFIX=/usr/local /usr/bin/npm install -g @deepseek-ai/dsh
ls -l /usr/local/bin/dsh # 必须是 /usr/local/bin/dsh,不能在 /root 下
# 3) 环境变量指向系统 dsh
# /etc/dshs.env → DSHS_DSH_BIN=/usr/local/bin/dsh
# 4) systemd 的 PATH 去掉 /root/.nvm/...,改成系统路径
# (子 DSH 的 #!/usr/bin/env node 才找得到系统 node)
# /etc/systemd/system/dshs.service:
# Environment=PATH=/usr/bin:/usr/local/bin:/usr/sbin:/usr/local/sbin:/sbin:/bin
```
> **nvm 劫持 npm 的坑**:即使你用 `/usr/bin/npm`,它仍可能装到 `/root/.nvm/...`——因为 `/root/.npmrc` 里被 nvm 写了 `prefix=/root/.nvm/versions/node/...`。检查:`/usr/bin/npm prefix -g`(返回 nvm 路径就是被劫持了)。解决:命令前加 `NPM_CONFIG_PREFIX=/usr/local` 强制覆盖。
> **另一个降权后读不到的东西**:运行时插件 `dshs/runtime` 在 `git clone` 的目录(如 `/dsh_login/dshs/lib/`)里,降权账号也要能读到。检查 `ls -ld /dsh_login`,如果是 `drwx------`(只有 root),执行 `chmod 755 /dsh_login`。
---
## 3. 为每个用户创建系统账号(核心一步)
这是**唯一需要手动做的事**:用户注册后,要先给他建系统账号 + 把他目录改成这个账号所有,硬隔离才完整。**没做这一步,DSH 会以 root 运行(跟没开硬隔离一样)。**
创建脚本(`provision-user.sh`,root 运行):
```bash
#!/usr/bin/env bash
# 用法:provision-user.sh <userId> (userId 就是数据库里 users 表的 id,管理员界面能看到)
set -euo pipefail
uid="$(dshs uid-for-user "$1")"
user="dsh-$1"
# 1) 创建系统账号(uid 用插件算出来的同一个值,保证两边一致;不建 home、不能登录)
useradd -u "$uid" -M -s /usr/sbin/nologin "$user"
# 2) 把该用户的目录改成这个账号所有(关键!)
chown -R "$uid:$uid" "/var/lib/dshs/users/$1"
echo "provisioned $1 -> uid $uid"
```
怎么执行:
```sh
chmod +x provision-user.sh
./provision-user.sh <userId>
```
**userId 从哪拿**:管理员登录后访问 `/api/admin/users`(返回 JSON 里每个用户的 `id` 字段),或直接查数据库 `users` 表。
### 3.1 让它自动跑(不用每次手动)
每个用户注册后都手动跑一次太麻烦,这里给几个**自动触发**方案,按你服务器的环境挑一个:
**方案 A:systemd 监控目录(最简单,无需额外软件)**
把上面那个脚本存成 `/usr/local/bin/provision-user.sh`,然后用 systemd 的路径监控:**每当某个用户的目录被创建(即注册成功),就自动跑一次脚本**。
`/etc/systemd/system/dsh-provision.path`:
```ini
[Unit]
Description=Watch new user dirs and provision OS accounts
[Path]
PathExistsGlob=/var/lib/dshs/users/*/home
Unit=dsh-provision.service
[Install]
WantedBy=multi-user.target
```
`/etc/systemd/system/dsh-provision.service`:
```ini
[Unit]
Description=Provision a newly registered user
After=dshs.service
[Service]
Type=oneshot
# 找出刚注册、还没建账号的用户,挨个建
ExecStart=/usr/local/bin/provision-new-users.sh
```
`/usr/local/bin/provision-new-users.sh`(核心:遍历所有用户目录,没建账号的先建,然后**每次都 chown**):
```bash
#!/usr/bin/env bash
set -euo pipefail
for dir in /var/lib/dshs/users/*/; do
[ -d "$dir" ] || continue
id="$(basename "$dir")"
user="dsh-$id"
if ! id "$user" &>/dev/null; then
# 账号不存在才建(uid 用插件算出来的同一个值,保证两边一致)
uid="$(dshs uid-for-user "$id")"
useradd -u "$uid" -M -s /usr/sbin/nologin "$user"
fi
uid="$(id -u "$user")" # 账号已存在就复用它的 uid
chown -R "$uid:$uid" "$dir" # 每次都 chown(幂等,把漏掉的属主补上)
echo "provisioned $id -> uid $uid"
done
```
> ⚠️ **别写「账号已存在就跳过」**:那样会连 chown 一起跳过——如果某用户的目录后来变成 root 所有,重跑也不会修,他的 DSH 还是会因为读不了自己 home 而崩(外网 502)。所以 chown 要放在判断**外面**、每次都执行。
启用:
```sh
systemctl daemon-reload
systemctl enable --now dsh-provision.path
```
这样以后**用户一注册,目录一出现,systemd 就自动建账号 + 改属主**,管理员不用再管。
**方案 B:定时任务(简单粗暴,隔几分钟扫一次)**
如果不想用 systemd 的 path 监控,就用 cron 每 5 分钟跑一遍同一个 `provision-new-users.sh`(幂等,跑多少次都安全):
```sh
crontab -e
# 加一行:
*/5 * * * * /usr/local/bin/provision-new-users.sh
```
**方案 C:手动(用户少时够用)**
用户少、注册不频繁,管理员有空就手动跑 `./provision-user.sh <userId>` 也行。**但别忘了**——漏一个 = 那个用户还在 root 下跑,等于没隔离。
> **推荐**:方案 A 最省心、自动、幂等,一次配好就不用管。
---
## 4. 验证硬隔离真的生效
**第 1 步:看 DSH 进程的 uid。**
先在桌面启动一个用户的 DSH,然后:
```sh
# 找到 DSH 进程
ps aux | grep dsh | grep -v grep
# 假设拿到了 pid,看它的真实 uid
ps -o uid,user,cmd -p <pid>
```
**应该看到**:`uid` 等于 `dshs uid-for-user <那个userId>`(是一个 >100000 的账号),**不是** 0(root)。如果显示 0,说明没降权,回头检查第 2、3 步。
**第 2 步:越权读测试(最关键)。**
```sh
# 1) 在用户 A 的 home 里放个文件
echo "secret" > /var/lib/dshs/users/<A的id>/home/s.txt
chmod 600 /var/lib/dshs/users/<A的id>/home/s.txt
# 2) 用用户 B 的 DSH 去读它(在 B 的 DSH 里执行 cat)
# 正确结果:Permission denied(读不到)
```
看到 `Permission denied` = 硬隔离生效。
---
## 5. 常见问题
| 现象 | 原因 / 处理 |
|---|---|
| DSH 还是以 root 跑 | `ISOLATION_MODE=account` 没生效(重启了吗?)、或该用户没跑 provision-user.sh。 |
| `setpriv: no permission` 报错 | 编排服务没以 root 运行。 |
| 新用户启动 DSH 报权限错误 | 该用户目录 chown 了吗?重新跑 provision-user.sh。 |
| 忘了给某个用户做第 3 步 | 补跑,然后重启该用户的 DSH。 |
| DSH 每秒崩溃,日志 `Cannot find module '/root/.nvm/.../bin/dsh'` | `dsh` 装在 `/root` 下,降权账号读不到。按 §2.1 把 dsh 装到系统级位置。 |
| `/usr/bin/npm install -g` 还是装到 `/root/.nvm` | npm 全局前缀被 nvm 劫持(`/root/.npmrc` 里的 `prefix`)。用 `NPM_CONFIG_PREFIX=/usr/local` 强制覆盖。 |
---
+397
View File
@@ -0,0 +1,397 @@
# K8s 踩坑记录(模式 B)— 部署流程 + 根因排查
> 🧭 [← 返回 README](../README.md) · 分步教程:[K8s 部署教程](k8s-deployment.md)
> 记录模式 B 实机部署踩到的坑与根因:先在**阿里云 2C2G 单机 k3s** 上 PoC 验证,后在 **ACK 智能托管**上完整落地(§5–§8 含部署流程实录)。
> 想直接照着部署,从 [k8s-deployment.md](k8s-deployment.md) 开始;本文按「症状 → 根因 → 修复」组织,用于卡住时对查。
---
## 1. 环境 / k3s 安装
### 1.1 cgroup v1 导致 k3s v1.36+ kubelet 拒启
- **症状**:k3s v1.36 启动几秒后退出,`systemctl` 反复 auto-restart。日志:
```
Shutdown request received: "kubelet exited: ... kubelet is configured to not run on a host using cgroup v1 ..."
```
- **根因**:K8s 1.36 移除了 cgroup v1 支持;阿里云 Linux 3(RHEL8 系)默认跑 cgroup v1。
- **修复(二选一)**:
- 启用 cgroup v2(改内核参数 + **重启**):
```bash
grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1"
grubby --info=ALL | grep args # 确认参数已写入
reboot
# 重启后验证
stat -fc %T /sys/fs/cgroup/ # 输出 cgroup2fs 才对
```
- 或降级 k3s v1.31(还支持 cgroup v1)。
### 1.2 swap 未关
- **症状**:kubelet 起不来(`fail-swap-on` 默认 true)。
- **修复**:
```bash
swapoff -a
# 持久化(可选):注释 /etc/fstab 里的 swap 行
sed -i '/[[:space:]]swap[[:space:]]/s/^/#/' /etc/fstab
```
### 1.3 SELinux 依赖缺失(RHEL8 系)
- **症状**:k3s 安装脚本报:
```
nothing provides container-selinux >= 3:2.191.0-1 needed by k3s-selinux-...
```
- **修复**:跳过 SELinux RPM(PoC 不需要):
```bash
curl -sfL https://rancher-mirror.rancher.cn/k3s/k3s-install.sh | \
INSTALL_K3S_MIRROR=cn INSTALL_K3S_SKIP_SELINUX_RPM=true sh -s - --disable traefik --disable metrics-server --disable servicelb --disable local-storage
```
---
## 2. 镜像源(中国区)
### 2.1 docker hub 被墙
- **症状**:`registry-1.docker.io` 403 / 超时,busybox/node/socat 镜像拉不动。
- **修复**:给 k3s 的 containerd 配国内镜像 `/etc/rancher/k3s/registries.yaml`:
```yaml
mirrors:
docker.io:
endpoint:
- "https://docker.m.daocloud.io"
- "https://docker.1ms.run"
```
然后 `systemctl restart k3s`。
- **注意**:镜像拉取**极慢**(一个 29MB 镜像拉了 13 分钟),批量拉取要有耐心或换更快的镜像源。
### 2.2 raw.githubusercontent 被墙
- **症状**:`kubectl apply -f https://raw.githubusercontent.com/longhorn/...` 拉不到 manifest。
- **修复**:加 ghproxy 前缀:
```bash
curl -sL "https://ghfast.top/https://raw.githubusercontent.com/longhorn/longhorn/v1.7.2/deploy/longhorn.yaml" -o /tmp/longhorn.yaml
```
### 2.3 alpine/socat tag 写错
- **症状**:`alpine/socat:1.8.0.0-r0` 拉取 403。
- **根因**:该 tag 不存在。
- **修复**:用 `alpine/socat:1.8.0.0`(或 `latest`)。
⚠️ 内部设计稿 §4.3 里写的 `1.8.0.0-r0` 需按此改正;本项目已改用自带的 Node `tcp-bridge`(见 §7.5),不再依赖 socat 镜像。
### 2.4 ghcr.io 直连被墙(CNPG/Longhorn 镜像)
- **症状**:CloudNativePG operator 镜像 `ghcr.io/cloudnative-pg/cloudnative-pg` 卡 "Pulling" 十几分钟不动。
- **修复**:`registries.yaml` 里给 `ghcr.io` 也配镜像:
```yaml
ghcr.io:
endpoint:
- "https://ghcr.m.daocloud.io"
```
### 2.5 CloudNativePG `poolers` CRD apply 报 annotation 超长
- **症状**:`kubectl apply -f cnpg.yaml` 报 `CRD poolers.postgresql.cnpg.io is invalid: metadata.annotations: Too long`,operator 崩溃 `no matches for kind "Pooler"`。
- **根因**:client-side apply 写的 `last-applied-configuration` annotation 超过 256KB。
- **修复**:`kubectl apply --server-side --force-conflicts -f cnpg.yaml`(server-side 不写那个超长 annotation)。
---
## 3. Longhorn
### 3.1 磁盘保留比例过高
- **症状**:卷副本创建失败 `No available disk candidates`,卷状态 `faulted`。
- **根因**:Longhorn 默认**保留 30% 磁盘**;磁盘 88% 满时「可用 < 保留量」,拒绝调度副本。
- **修复**:把保留/最小可用降到 5%:
```bash
kubectl -n longhorn-system patch settings.longhorn.io storage-reserved-percentage-for-default-disk --type=merge -p '{"value":"5"}'
kubectl -n longhorn-system patch settings.longhorn.io storage-minimal-available-percentage --type=merge -p '{"value":"5"}'
```
### 3.2 iscsid 未启动
- **症状**:卷卡在 `attaching`,消费 Pod 一直 `ContainerCreating`。
- **修复**(装完 `iscsi-initiator-utils` 后记得启动):
```bash
dnf install -y iscsi-initiator-utils
systemctl enable --now iscsid
```
### 3.3 单节点 3 副本调度不了
- **症状**:2 个副本 `Failed to schedule replica`,卷卡 `attaching`。
- **根因**:Longhorn 默认 **hard anti-affinity**,同一卷的副本必须在不同节点;单节点只能调度 1 个。
- **修复**:副本降到 1:
```bash
kubectl -n longhorn-system patch volumes.longhorn.io <volume-name> --type=merge -p '{"spec":{"numberOfReplicas":1}}'
kubectl -n longhorn-system patch settings.longhorn.io default-replica-count --type=merge -p '{"value":"1"}'
```
### 3.4 RWX 卷需 nfs-utils
- **症状**:RWX 卷挂载失败(RWX 走 share-manager / NFS ganesha)。
- **修复**:装 `nfs-utils`(提供 `mount.nfs`):
```bash
dnf install -y nfs-utils
```
### 3.5 内存预算(重要)
- Longhorn 自身 ~600M。**2C2G 上 k3s + Longhorn 已占 ~1.4G**,无法再跑 CloudNativePG 3 实例(需 ~1.5G)。
- **PoC item 4(CNPG + pgbench)需 ≥4G 机器**,2G 必 OOM。
---
## 4. 方案修正(PoC 实测推翻/修正的结论)
### 4.1 flannel 实际强制 NetworkPolicy(§3.5 修正)
- 实测 k3s v1.31 flannel 下 NetworkPolicy **开箱即用**(k3s 内嵌 kube-router netpol 控制器,不再以 DaemonSet 形式;attacker 被拒,删策略后立刻恢复连通)。
- **§3.5「k3s 默认 flannel 不强制」不成立**。
- Cilium 的选型理由应改为:**L7 策略 / eBPF 性能 / Hubble 可观测**(这些 kube-router 没有),而不是「基础强制」。
### 4.2 gid ≠ fsGroup(§6.0 措辞)
- 实测 init Job 建目录 `0700` 属主 uid 正确,但 **gid=0**(不是 fsGroup=100001)——因 §4.3 的 Pod 只设 `runAsUser`+`fsGroup`、没设 `runAsGroup`,进程默认 gid=0。
- **不影响 0700 安全边界**(0700 已把 group 权限关成 `---`)。若要对齐 gid,需加 `runAsGroup:<uid>`。
### 4.3 socat tag(§4.3 修正)
- `alpine/socat:1.8.0.0-r0` → `alpine/socat:1.8.0.0`。见 §2.3。
---
## 5. 完整部署流程(ACK 实跑记录,2026-08-23)
> 在阿里云 **ACK 智能托管模式** 上跑通控制面 Phase 2 的完整步骤。k8s 方案里的
> 「3 server HA / kube-vip / MetalLB / Cilium」在 ACK 上由托管能力替代(§5.5 映射)。
> 每用户 DSH Pod(Phase 3)、cert-manager、Helm chart 尚未落地,仍待补。
### 5.1 集群与基础组件
1. 建 ACK 集群(智能托管模式):**网络插件 DataPath V2(eBPF)+ 勾选 NetworkPolicy**;服务转发模式 IPVS。
2. 装 CNPG operator:manifest 用 ghproxy 下载(§2.2),`kubectl apply --server-side --force-conflicts`(§2.5 annotation 超长坑)。
3. **ghcr.io 换源**:CNPG operator 与 Postgres 镜像在阿里云拉不动(§2.4),换 `ghcr.m.daocloud.io`。
### 5.2 部署步骤
1. `kubectl apply -f deploy/00-namespace.yaml`
2. `kubectl apply -f deploy/01-dsh-pg.yaml`(Postgres 集群;storageClass 用拓扑感知 `alicloud-disk-topology-alltype`,size ≥20GiB)
3. 等 `dsh-pg` 进入 `Cluster in healthy state`;读连接串:
`kubectl -n dsh get secret dsh-pg-app -o jsonpath='{.data.uri}' | base64 -d`
4. 推镜像到 ACR:CI master push 自动推(需 `ACR_USERNAME`/`ACR_PASSWORD` secret),或 workflow_dispatch。
5. 建三个 secret(不在 deploy/ YAML 里,因含动态值):
- `dsh-acr-pull`(`docker-registry` 类型,ACR 凭证)
- `dsh-secret`(共享加密密钥,`key`)
- `dsh-pg`(`url` = 上面读到的 URI)
6. `kubectl apply -f deploy/02-control-plane.yaml`
7. bootstrap admin:`kubectl -n dsh exec deploy/dsh-orchestrator -- node lib/cli.js bootstrap-admin --username admin --password '<p>'`
8. 按 §5.3 验收。
### 5.3 验收清单(Phase 2)
- 注册 → 审核 → 登录 → 管理台/域名 API 全通。
- 访问控制:普通用户访问管理台 403、域名 API 200。
- kill 一个控制面副本 → Deployment 自动重建(3/3)、Service 不中断。
- 数据在 Postgres,删副本/重启不丢。
### 5.4 踩坑记录(ACK 实测新增)
| 坑 | 修复 |
|---|---|
| ghcr.io 被墙:CNPG operator / Postgres 镜像拉不动 | 换 `ghcr.m.daocloud.io`(§2.4) |
| ESSD 最小 20GiB:`size: 10Gi` 报 `less than minimum 20GiB` | storage `size: 20Gi` |
| CNPG 重建集群密码漂移:`dsh-pg-app` 重新生成密码 | 读最新 URI 重建 `dsh-pg` secret;生产用固定密码 |
| 控制面启动依赖 Postgres 就绪:ECONNREFUSED 崩 | 等 Postgres healthy 再部署,或接受 CrashLoopBackOff 重试 |
| Auto Mode 节点有 taint,CNPG pod 调度失败 | GOATScaler 自动扩出无 taint 节点 |
### 5.5 ACK 与 k8s 方案的映射
| k8s.md 定案 | ACK 等价 |
|---|---|
| 3 server HA + kube-vip | 托管控制面(免费) |
| MetalLB(L2 ARP 云上不生效) | SLB / ALB |
| Cilium | Terway DataPath V2(eBPF + NetworkPolicy) |
| Longhorn RWX / RWO | NAS / ESSD 云盘 |
| CloudNativePG | CloudNativePG(自建)或 RDS PG 高可用版 |
---
## 6. PoC 实测基线(item 4:CNPG on Longhorn)
**机器**:阿里云 4C16G,Ubuntu 24.04,SSD 40G。k3s v1.31 + Longhorn v1.7.2(副本 1)+ CNPG v1.24.1,3 实例 healthy。
**pgbench(scale 5,30s,4 clients / 2 threads)**:
| 负载 | tps | latency average | 说明 |
|---|---|---|---|
| select-only(`-S`) | 19164.7 | 0.209 ms | 纯读,数据 ~70MB 全进内存缓冲,未打到磁盘 |
| TPC-B 混合(含写) | 705.3 | 5.671 ms | 写落到 Longhorn 磁盘,**这才是 I/O 基线** |
**结论**:CNPG on Longhorn 跑通(3 实例、pgbench 正常)。写路径 latency ~5.6ms / tps ~705,是 Longhorn 单副本的 I/O 成本。要对照「节点本地 NVMe + PG 主备」判断是否可接受,需换 NVMe 盘 + 更大 scale(让数据集超过内存)再压一次。
**另:pgbench 手动跑法**(`kubectl run --rm -i` 在后台/非 tty 会话会卡 stdin,别用):
```bash
kubectl run pgbench-init --restart=Never --image=postgres:16 -n <ns> \
--env PGHOST=<cluster>-rw --env PGUSER=dsh --env PGPASSWORD=<pw> --env PGDATABASE=dsh \
-- pgbench -i -s 5
# 等 pod phase=Succeeded 后 kubectl logs;跑完 kubectl delete pod
```
---
## 7. Phase 3 集群联调踩坑(2026-08-23,第二阶段)
> 控制面 `deployMode=k8s` + leader election + file sidecar + 每用户 Pod 在 ACK
> 上联调的实跑记录。前一个阶段(§5)只把控制面 3 副本 + CNPG 跑通了。
### 7.1 部署顺序(缺一步就挂,按序执行)
1. `deploy/00-namespace.yaml` → `01-dsh-pg.yaml`(Postgres healthy)→ 读 `dsh-pg-app` URI 建 `dsh-pg`/`dsh-secret`/`dsh-acr-pull` secret。
2. **`deploy/03-rbac.yaml`**(SA + Role + lease 权限)。漏了它,控制面 Deployment 报
`FailedCreate: serviceaccount "dsh-orchestrator" not found`,滚动更新卡死。
3. `deploy/02-control-plane.yaml`(含 `imagePullPolicy: Always`,同名 tag 否则节点
缓存旧镜像)。
4. NAS:控制台建 **CNFS**(容器网络文件系统)→ 应用 `deploy/05-storage.yaml` 引 CNFS
→ `04-pvc.yaml` → `08-bootstrap.yaml`(chmod 1777 PVC 根)→ **最后** `07-psa.yaml`。
5. 每用户 Pod 依赖 `dsh-acr-pull`(§7.3)。
### 7.2 NAS:用 CNFS,别手写 `server` 参数
- 手写 `alicloud-nas` StorageClass 的 `server` 必须是**挂载目标域 + `:/`**,且
**不含 FileSystemId 前缀**。我给错成 `<FSID>.<NAS_MOUNT_TARGET>`
(带 FSID 前缀)→ provisioner 报 `CreateDir: IllegalCharacters`;去掉 FSID 前缀
后 PVC 才 Bound。
- 但挂载目标还会变(重建 NAS 后后缀漂移),正确姿势是控制台建 **CNFS**
(`storage.alibabacloud.com/v1beta1 ContainerNetworkFileSystem`),StorageClass 用
`containerNetworkFileSystem: nas` 参数引用,provisioner 自动拿到当前挂载目标。
- bootstrap Job 第一次卡 `ContainerCreating` 无事件 = NFS 挂载挂起(挂载目标错/VPC 不通);
目标对了会报 `mount.nfs: Connection reset by peer`(通常是安全组/访问组或目标刚就绪)。
### 7.3 每用户 Pod 镜像拉取与资源
- 控制面镜像在**私有 ACR**,生成的 files Pod / DSH Pod / watchdog Job **必须带
`imagePullSecrets: [dsh-acr-pull]`**——控制面 Deployment 有,但生成的 Pod 不继承。
漏了就是 `Init:ImagePullBackOff insufficient_scope`。代码里 config `imagePullSecret`
默认 `dsh-acr-pull`,已在 K8sSpawner 生成的三类 Pod/Job 全部带上。
- bootstrap Job 也需 `imagePullSecrets` + 用控制面镜像(busybox 从 docker.io 拉不动)。
- files sidecar 内存 64Mi 跑不起 node:22-slim + Fastify(OOMKilled → Pod 不 Ready →
Headless DNS 无 A 记录 → 控制面 ENOTFOUND),提到 256Mi。
- files sidecar 以用户 uid 跑且无 home,`homedir()` 落到 `/`,`resolveConfig` 会尝试
`mkdir /.dshs` → EACCES 崩。`runFileService` 已钉 `dataRoot=/tmp` + 占位
`encryptionSecret` 跳过写文件。
### 7.5 docker.io 被墙 → socat sidecar 换成 Node tcp-bridge
- ACK 节点拉不动 docker.io(busybox/alpine/socat 都超时),而 `alpine/socat` 正是
每用户 DSH Pod 的 sidecar 镜像。改为控制面镜像里的 `tcp-bridge` 子命令(`net`
纯转发 8081→8080),DSH Pod 只依赖 ACR 里已有的 `dsh` + `dshs`
两个镜像 + `imagePullSecret`,彻底去掉 docker.io 依赖。
- 测鉴权/带 cookie 的接口用 `--resolve` 走域名,别用 `kubectl port-forward`:设了
`cookieDomain=.dsh.example.com` 后 cookie 不会发到 `127.0.0.1`。
### 7.6 每用户 Pod 的 uid 与就绪探针
- **uid 错位**:注册/建 admin 若先 `initUserRoot`(建 files Pod)再 `createUser`,此时
用户不存在,`resolveUid` 回退 hash uid,而 `createUser` 给的是 `baseUid+row_id`——
files Pod 用 hash uid 建 0700 目录,DSH Pod 用 row_id uid 访问 → EACCES。已改成先
建用户再 initUserRoot。
- **就绪探针**:DSH 只监听 loopback,`tcpSocket 8080` 探的是 Pod IP、永远不 Ready →
Headless 无 A 记录 → 子域 502。改成容器内 `node -e http.get(127.0.0.1:8080)` exec 探针。
- **子域代理端口**:Headless Service(clusterIP: None)没有 kube-proxy 做 DNAT,代理
必须直连 Pod 的 targetPort(sidecar 8081),不能连 Service port 80——否则 502。
`endpointFor` 返回 8081。
- **cookie 跨子域**:`dsh.<domain>` 登录后跳 `<user>.dsh.<domain>`,`SameSite=Lax` 实测
不带 cookie → 401;改 `SameSite=None; Secure`(App 已 HTTPS-only)+ `secureCookies=true`。
- **代理 502**:控制面 keep-alive 池缓存旧 DSH Pod IP,重建 Pod 后命中旧 IP 副本 → 502;
连接出错时 `agent.destroy()` + `agent:false` 重试一次重新 DNS。
### 7.4 域名入口被阿里云备案拦截
- 腾讯云入口机 nginx 反代到 ACK 公网 SLB(`<SLB_IP>`)时,`Host: dsh.example.com`
被阿里云返回 `403 Non-compliance ICP Filing`(`Server: Beaver`)——域名在腾讯云备案、
未在阿里云备案,走阿里云公网 SLB 的 80/443 被备案校验拦。直连 SLB IP(不带域名 Host)
则 200 正常。
- 解法:给域名在阿里云做 ICP 备案,或入口走 NodePort/专线绕开公网 SLB 备案层,或
控制面域名解析子域改用「SLB 直连 + 腾讯云 nginx 透传 Host」之外的方案(待定)。
---
## 8. Phase 4 加固 / 可观测(2026-08-23)
### 8.1 代码加固(已落地,随镜像生效)
- `readOnlyRootFilesystem: true` + `emptyDir` `/tmp`:DSH dsh/sidecar、file sidecar、
watchdog、控制面容器全部只读 rootfs,`/tmp` 走 emptyDir(PSA restricted 纵深)。
- 空闲回收:reconcile(仅 leader)对每个期望 main 检查 `hasActiveSession`,会话全过期
→ `stop` Pod + 删期望态,不自动拉起(复用 `sessionTtlSeconds`,默认 7 天)。
- egress 收敛:`DSHS_EGRESS_CIDRS`(逗号分隔 CIDR)非空时,每用户 DSH Pod 的
443 egress 从 `0.0.0.0/0` 收敛为白名单(仍 except 内网)。默认空 = 保持现状。
⚠️ DeepSeek API 走 CDN,IP 会漂移,收敛前先 `dig +short api.deepseek.com` 核对并定期重查。
### 8.2 etcd encryption-at-rest(ACK 托管,控制台操作)
1. ACK 控制台 → 集群 → 托管控制面 → 开启 **etcd 加密**(encryption-at-rest)。
2. 开启后**必须全量重写存量 Secret**,否则旧 Secret 仍明文:
```bash
kubectl get secrets -A -o json | kubectl replace -f -
```
3. 验证(应只剩 `k8s:enc:aescbc:v1:` 前缀的密文):
```bash
kubectl get secrets --all-namespaces -o json | grep -v 'k8s:enc:aescbc' || echo "all encrypted"
```
覆盖 `dsh-key-*`(每用户 API key)、`dsh-pg`(DB DSN)、`dsh-secret`(共享加密密钥)。
### 8.3 可观测(Prometheus + Loki + 告警)
- **指标**:控制面已可暴露 `/metrics`(未接入则用 prom-client 加一个);ACK 托管 Prometheus
(ARMS)在控制台开通后,用 ServiceMonitor 抓 `dsh-orchestrator` 的 3080。
关键指标:控制面副本数、Postgres 连接数、每用户 Pod 数、崩溃计数。
- **日志**:Loki + Promtail(或 ACK SLS)接入控制面与每用户 Pod 日志。
- **告警规则**(Prometheus):控制面副本 < 3、Postgres 不可写、DSH Pod 崩溃率、NAS 容量水位。
示例(自建 Prometheus 时用):
```yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata: { name: dsh-alerts, namespace: dsh }
spec:
groups:
- name: dsh
rules:
- alert: DshControlPlaneDown
expr: count(up{app="dsh-orchestrator"}) < 3
for: 5m
- alert: DshPostgresUnavailable
expr: pg_up == 0
for: 2m
```
注:托管 Prometheus/Loki 实际接入需在 ACK 控制台开通,本轮交付清单与步骤。
### 8.4 Web 安全审计(代码审计结论,2026-08-23)
- **XSS(已修)**:`desktop.html`/`admin.html` 把文件名 `e.name`、插件名/描述
`plugin.name`/`plugin.description`、密钥名 `k.name`、用户名 `u.username` 直接拼进
`innerHTML`/`insertAdjacentHTML`。文件名与插件描述不受字符集约束 → 存储型 XSS。
已加 `esc()` 转义 `<>&"'` 后再拼接。
- **输入校验(已确认安全)**:用户名 `^[a-zA-Z0-9_-]+$`、域名 `isValidDomain`
(小写字母数字+连字符,防 nginx 注入)、密钥名 `^[A-Za-z0-9\-_ .]{1,32}$`、路径
`resolveWithinRoot`/`safeFilename`。
- **CSRF(残余低风险,未修)**:会话 cookie 为 `SameSite=None` 后跨站请求也会带
cookie,但所有状态变更端点都是 `application/json` + 无 CORS 头,跨站 fetch 会触发
preflight 被浏览器拦;真正暴露的是无 body 的 `POST /api/auth/logout`(logout CSRF,
低危)。后续若要加固,给状态变更端点加 CSRF token / 自定义头校验。
- **OWASP ZAP 基线扫描**:未在本轮执行(需 ZAP 工具/容器)。已定位的 XSS 已人工修,
ZAP 可作为 CI 门禁后续接入。
### 8.5 联调后期修的 4 个根因 bug(2026-08-23)
| 症状 | 根因 | 修复 |
|---|---|---|
| leader 不接管,lease 挂旧 pod 数小时 | client-node `patchNamespacedLease` 走 JSON Patch(要数组),传对象被 Go 拒 400 被静默吞 | 改 `replaceNamespacedLease` 全量 PUT + RV 乐观并发 |
| admin DSH 反复 502 | `endpointFor` 返回 Service DNS,A 记录 ~30s TTL,Pod 重启后这 30s 内仍解析旧 IP | endpointFor 直接返回 `podIP`,每次请求实时读 Pod |
| 模型无法请求(EAI_AGAIN) | ACK DNS 是 `node-local-dns`(label `k8s-app=node-local-dns`),NP 规则却选 `k8s-app=kube-dns` → DNS 全被拦 | NP DNS egress 改 `0.0.0.0/0`(可移植) |
| keepAlive 池销毁后不可复用 | `agent.destroy()` 只销毁不替换,后续请求首跳全失败 | destroy 后 `new Agent()` 替换,再短连接重试一次 |
+166
View File
@@ -0,0 +1,166 @@
# K8s 部署教程(模式 B)— DSH 服务端登录插件
> 🧭 [← 返回 README](../README.md) · 卡住了:[踩坑记录](k8s-deploy.md) · 模式 A 教程:[deployment](deployment.md)
> 把 `dshs` 部署成**多机 HA、每用户独立 Pod** 的形态。本文是**可复制的分步部署教程**;
> 实机逐条踩坑与根因排查见 [k8s-deploy.md](k8s-deploy.md)。
>
> 实测环境:阿里云 ACK 智能托管(华东2)+ CNFS(NAS) + CloudNativePG + 私有 ACR。
---
## 0. 前置条件
- 一个 **ACK 智能托管集群**(网络插件 DataPath V2 / 勾选 NetworkPolicy;节点 ≥ 3 或启用自动扩容)。
- 一个 **NAS 文件系统**,并在集群里建 **CNFS**(控制台「容器网络文件系统」,名字记为 `nas`)。
- 一个 **私有 ACR 仓库**(本文用 `registry.example.com/dsh` 作占位)。
- 一个域名(本文用 `dsh.example.com` 作占位)+ 通配 TLS 证书(cert-manager 或 LB 证书)。
已安装:`kubectl`、`cnpg` operator(`kubectl apply --server-side -f cnpg.yaml`)。
---
## 1. 镜像
两种方式二选一:
- **CI(推荐)**:推代码到 master,GitHub Actions 自动 build + Trivy + push 两个镜像到 ACR。
- **手动**:
```sh
docker build -t <acr>/dshs:0.2.0 .
docker build -t <acr>/dsh:0.1.1-rc.2 -f Dockerfile.dsh .
docker push <acr>/dshs:0.2.0
docker push <acr>/dsh:0.1.1-rc.2
```
---
## 2. Namespace + Postgres
```sh
kubectl apply -f deploy/00-namespace.yaml
kubectl apply -f deploy/01-dsh-pg.yaml
# 等 healthy:
kubectl -n dsh wait --for=condition=Ready cluster/dsh-pg --timeout=600s
```
读连接串(后面建 `dsh-pg` secret 用):
```sh
kubectl -n dsh get secret dsh-pg-app -o jsonpath='{.data.uri}' | base64 -d
# postgresql://dsh:<password>@dsh-pg-rw.dsh:5432/dsh
```
---
## 3. Secrets(含动态值,不进 YAML)
```sh
# ① ACR 拉镜像凭证(生成 Pod 拉私有镜像用)
kubectl -n dsh create secret docker-registry dsh-acr-pull \
--docker-server=registry.example.com \
--docker-username='<ACR_USERNAME>' --docker-password='<ACR_PASSWORD>'
# ② 共享加密密钥(迁移时填旧 dataRoot/secret.key 内容,否则已加密的 API key 解不开)
kubectl -n dsh create secret generic dsh-secret --from-literal=key='<32字节hex>'
# ③ Postgres DSN(用 §2 读到的 uri)
kubectl -n dsh create secret generic dsh-pg --from-literal=url='postgresql://dsh:[email protected]:5432/dsh'
```
---
## 4. RBAC + 控制面
```sh
kubectl apply -f deploy/03-rbac.yaml # SA + Role(含 leases + list/watch,leader election 需要)
kubectl apply -f deploy/02-control-plane.yaml
kubectl -n dsh rollout status deploy/dsh-orchestrator --timeout=300s
```
初始化管理员:
```sh
kubectl -n dsh exec deploy/dsh-orchestrator -- node lib/cli.js bootstrap-admin --username admin --password '<强密码>'
```
> 控制面 3 副本,只有 leader 跑 reconcile(Lease 选主)。`deploy/02-control-plane.yaml` 里把
> `DSHS_BASE_DOMAIN` / `DSHS_COOKIE_DOMAIN` / 镜像地址改成你自己的。
---
## 5. 存储:NAS(CNFS) + PVC + bootstrap + PSA(严格时序)
> ⚠️ **顺序不能乱**:先建 PVC → 特权 bootstrap `chmod 1777` → 最后打 PSA restricted。先打 PSA,
> 非 root 的 initContainer 就写不了 PVC 根,用户目录永远建不出来。
```sh
# ① StorageClass(引用控制台建好的 CNFS `nas`)
kubectl apply -f deploy/05-storage.yaml
# ② 每用户共享 RWX PVC
kubectl apply -f deploy/04-pvc.yaml
kubectl -n dsh wait --for=jsonpath='{.status.phase}=Bound' pvc/dsh-users --timeout=300s
# ③ 特权 bootstrap:PVC 根 chmod 1777(world-writable + sticky)
kubectl apply -f deploy/08-bootstrap.yaml
kubectl -n dsh wait --for=condition=complete job/dsh-users-bootstrap --timeout=180s
# ④ 打 PSA restricted(放在最后)
kubectl apply -f deploy/07-psa.yaml
# ⑤ 配额(防单用户耗尽集群)
kubectl apply -f deploy/06-quota.yaml
```
---
## 6. 入口(域名 + TLS)
- 暴露控制面:给 `dsh-orchestrator` 加一个 **LoadBalancer Service**(`port 80 → targetPort 3080`),
或用 **Ingress**(Traefik / nginx-ingress)统一入口。
- DNS:把 `dsh.example.com` 与 `*.dsh.example.com` 解析到 LB 的 IP(或 Ingress 域名)。
- TLS:cert-manager 签通配证书(DNS-01),或直接用 LB/ALB 的证书。
- ⚠️ 域名经阿里云公网 SLB/ALB 暴露时需先做 **ICP 备案**,否则 80/443 会被备案校验拦(403)。
LoadBalancer Service 参考:
```yaml
apiVersion: v1
kind: Service
metadata: { name: dsh-orchestrator-lb, namespace: dsh }
spec:
type: LoadBalancer
selector: { app: dsh-orchestrator }
ports: [{ port: 80, targetPort: 3080 }]
```
---
## 7. 验收清单
1. `kubectl -n dsh get pods`:`dsh-orchestrator` 3/3、`dsh-pg-1` 1/1、`dsh-files-*`/`dsh-*`(按需出现)。
2. leader 单活:`kubectl -n dsh get lease dsh-orchestrator` holder 非空、`leaseTransitions` 随接管递增。
3. 域名全链路:注册 → 管理员审核 → 登录 → 桌面 tree/建文件夹/上传 → 启动 DSH → `https://<user>.dsh.example.com/` 200。
4. 模型请求:DSH 里配 key 后能解析并访问 `api.deepseek.com`(DNS + 443 出站通)。
5. 隔离:任意非控制面 Pod 访问某用户 DSH 的 8081/8082 被 NetworkPolicy 拒。
---
## 8. 配置参考(控制面 env)
| env | 默认 | 说明 |
|---|---|---|
| `DSHS_DEPLOY_MODE` | `local` | `k8s` 启用每用户 Pod |
| `DSHS_DB_URL` | 空 | Postgres DSN(k8s 必填) |
| `DSHS_SECRET` | 空 | 共享加密密钥 |
| `DSHS_NAMESPACE` | `dsh` | 每用户资源命名空间 |
| `DSHS_DSH_IMAGE` | 空 | 每用户 DSH 镜像 |
| `DSHS_CONTROL_PLANE_IMAGE` | 空 | file sidecar / tcp-bridge 镜像 |
| `DSHS_IMAGE_PULL_SECRET` | `dsh-acr-pull` | 生成 Pod 的拉镜像 secret |
| `DSHS_BASE_DOMAIN` | 空 | 子域路由基域 |
| `DSHS_COOKIE_DOMAIN` | 空 | cookie `Domain` |
| `DSHS_SECURE_COOKIES` | `false` | HTTPS 设 `true` |
| `DSHS_EGRESS_CIDRS` | 空 | 443 出站白名单(空=0.0.0.0/0) |
---
## 9. 常见问题 → [k8s-deploy.md](k8s-deploy.md)
- 部署顺序、NAS/CNFS、uid 错位、就绪探针、代理端口、leader 接管、DNS、备案、keepAlive 502 等全部踩坑与根因已记录在 `k8s-deploy.md` §5–§8。
+89
View File
@@ -0,0 +1,89 @@
# 常见问题排查 — DSH 服务端登录插件
> 🧭 [← 返回 README](../README.md) · 部署教程:[deployment](deployment.md) · 硬隔离:[hard-isolation](hard-isolation.md)
按「现象 → 根因 → 修法」组织,都是实际部署中踩过的坑。
## 502:启动 DSH 后打开是 Bad Gateway
- **现象**:桌面显示「运行中」,但打开 DSH(子域名)返回 502(nginx)。
- **根因**:子 DSH 没在编排服务分配的回环端口上监听——要么还在冷启动,要么 spawn 即崩。
- **排查**:
```sh
ps aux | grep dsh # 有没有子进程
ss -tulpn | grep <端口> # 有没有监听
```
- **冷启动**:真实 DSH 冷启动 ~4s(源码启动)。编排服务在 `spawn` 事件就标「running」,但端口要等插件树 boot 完才绑。等 10 秒再开 / 再查 `ss`。
- **spawn 即崩**:看编排服务终端的子进程 stderr(stderr 会被 pipe 过去)。常见:`--port` 改动没部署(→ EADDRINUSE)、缺 `DEEPSEEK_API_KEY` 等。
## 启动 DSH 报 `spawn dsh ENOENT`
- **现象**:`/api/dsh/status` 显示 `status: "crashed"`、`lastError: "spawn dsh ENOENT"`;`ps` 里没有任何 dsh 子进程;再点启动报「已有运行中的 DSH」(崩溃循环留下的残留)。
- **根因**:systemd 的 PATH 精简,`dsh`(只装在 nvm 下)解析不到。`dsh` 脚本内部也是 `#!/usr/bin/env node`,同样需要 nvm 在 PATH。
- **修法**:
1. env 里 `DSHS_DSH_BIN=/root/.nvm/versions/node/v22.23.2/bin/dsh`(绝对路径,版本按 `ls ~/.nvm/versions/node/` 改)。
2. systemd 单元加 `Environment=PATH=/root/.nvm/versions/node/v22.23.2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`。
3. `systemctl daemon-reload && systemctl restart dshs`,再先 stop 再 launch。
## 编排服务起不来:better-sqlite3 报 ERR_DLOPEN_FAILED / ABI 版本不匹配
- **现象**:`journalctl -u dshs` 里 `ERR_DLOPEN_FAILED`、`was compiled against ... NODE_MODULE_VERSION ... This version requires ...`;systemd 反复重启失败。
- **根因**:`npm install` 用 nvm Node 编译了 `better-sqlite3` 原生模块,但 systemd 的 `ExecStart=/usr/bin/env node` 解析到**另一个 Node 版本**,ABI 对不上。
- **修法**:`ExecStart` 用 nvm node 绝对路径(如 `/root/.nvm/versions/node/v22.23.2/bin/node lib/cli.js`),并 `npm rebuild better-sqlite3` 用同一个 node。
## 端口冲突:子 DSH 和编排服务抢 3080
- **现象**:手动跑 `dsh web` 报 `EADDRINUSE 0.0.0.0:3080`。
- **根因**:编排服务默认绑 3080,子 DSH(harness)默认也绑 3080。
- **关键机制**(读 harness 源码确认):harness 的 web 服务端口读 **`--port` 这个 CLI flag**(`web-startup` 插件解析 → `webStartup` 服务 → webserver),**不是** env、**不是** patch。`--cwd` 也不是合法 flag。
- **修法**:spawn 子 DSH 用 `dsh --profile web --host 127.0.0.1 --port <随机端口>`(已内置)。
## 404:打开 DSH 后静态资源全 404
- **现象**:HTML 能加载,但 `/assets/*`、`/favicon.svg`、`/manifest.webmanifest` 全 404;`manifest` 从域名根去取。
- **根因**:DSH 的 SPA(Vite)资源用**绝对路径**(`/assets/*`、`/api/*`),假设自己挂在域名根 `/`。子路径 `/u/<id>/dsh/*` 下,这些绝对路径会打到域名根 → 404;连 `/api` 也会打到编排服务自己的 API。**子路径方案与这个 SPA 从根上不兼容**。
- **修法**:改**每用户子域名** `<用户名>.<baseDomain>`,SPA 挂在自己域名根,绝对路径天然成立(含 HTTP 与 WebSocket 均已由编排服务转发)。
## 403:DSH 功能请求报 transport failure / HTTP 403(如 /api/settings.describe)
- **现象**:DSH 页面能加载,但功能 API(`/api/settings.describe`、`/api/host.describe` 等)返回 403,前端报 "transport failure for /api/xxx: HTTP 403"。
- **根因**:harness 的 `/api` 浏览器信任栅栏(`api-request-trust.ts`)检查 Origin——`origin.host` 必须等于 `host.host`。代理把 `host` 改成 loopback(`127.0.0.1:port`)却原样转发了浏览器的 `origin`(子域名)→ 不匹配 → 403。
- **修法**:代理到 DSH 时剥掉 `origin` / `referer` / `sec-fetch-*` / `x-forwarded-*`,只保留 loopback `host`(已内置在 [proxy.ts](../src/supervisor/proxy.ts) 的 `buildUpstreamHeaders`)。
## ERR_SSL_VERSION_OR_CIPHER_MISMATCH
- **根因**:子域名没有覆盖它的证书(只有主域单域证书)。
- **修法**:DNS 通配 + 通配证书(DNS challenge):
```sh
certbot certonly --dns-cloudflare -d '*.dsh.example.com' -d 'dsh.example.com'
```
## 401:子域名/接口返回 {"error":"unauthorized"}
- **根因**:session cookie 是 host-only(没带 `Domain`),到不了子域名;或浏览器里还是**改配置之前登录**的旧 cookie。
- **修法**:
1. 设 `DSHS_COOKIE_DOMAIN=.dsh.example.com`(注意前导点),重启。
2. **重新登录**拿带 `Domain` 的新 cookie。
## 登录后跳管理员界面、无法保持登录
- **现象**:登录后按 admin 角色跳到 `admin.html`,但 admin.html 又弹登录框、再登不上。
- **根因**:浏览器里存的是旧 cookie(改 `Domain`/`Secure` 之前登录的),不再匹配当前配置。
- **修法**:删掉浏览器里的 `sid` cookie(DevTools → Application → Cookies → 删除 `dsh.example.com` 域下的 `sid`)重新登录。或本地测试时 `unset DSHS_COOKIE_DOMAIN DSHS_SECURE_COOKIES` 让 cookie 回到 host-only + 非 Secure。
## nginx 把 Host 头改成了 upstream 名
- **现象**:编排服务日志里 `host: dsh_orchestrator`。
- **根因**:nginx 默认 `proxy_set_header Host $proxy_host`(upstream 名)。
- **修法**:在 location 里加 `proxy_set_header Host $host`(子域名路由依赖真实 Host 头)。
## 编排服务日志在哪
- 有 systemd 单元:`journalctl -u dshs -f`。
- 手动跑(`node lib/cli.js`):日志(含子 DSH 的 stdout/stderr,已被 pipe)在那个终端里。
## SEO 警告:`<html lang>` / `<title>` / `viewport` 缺失
- **现象**:Lighthouse 报这三条。
- **根因**:来自 **DSH 自己的聊天界面 SPA**(harness 前端),不是本插件页面(本插件的 login/desktop/admin 都写了这些)。
- **处理**:无害、不影响功能。要修需改 harness 前端或由 runtime 插件 `tapIndex` 注入,暂缓。