dsh-auth-gate 部署与验收清单
September 12, 2026 · View on GitHub
本文档描述如何把 dsh-auth-gate(password 模式,M3)部署到一个公网 dsh web 实例,并完成
部署验收。适用于:实例已跑通 dsh web(dsh --profile web),需要加认证门。
设计依据:docs/specs/dsh-auth-plan_zh.md §7/§8(无上游 PR 通道的限制、纵深防御);
规格:docs/implemented/impl-m2_zh.md(token 模式)、docs/implemented/impl-m3_zh.md(password 模式)。
两个模式二选一:下面按 password 模式(推荐,M3)编写;token 模式只需跳过
"建用户"一步并配置 tokenRef。
0. 前置条件
- TLS 前置终结(Nginx/Caddy 反代或 LB):
cookieSecure: true依赖 https,否则浏览器 不保存会话 cookie(curl/脚本不受影响)。http-only 环境只能cookieSecure: false(仅测试)。 --trusted-host与认证正交:--trusted-host只是 DNS-rebinding 防栏,不是认证; 两者都需配置。公网实例:--trusted-host <域名>指到你的域名。- 低权限 OS 用户运行 dsh(无 sudo、无其他项目文件)——plan §8 纵深防御第 1 条。
- 服务器有 Node ≥ 22.19(与部署一致)与 pnpm(
dsh plugin转发 pnpm)。实测:npm 默认 global prefix 是/usr(无 root 权限装不了),用npm i -g pnpm --prefix ~/.npm-global并export PATH="$HOME/.npm-global/bin:$PATH"(dsh本身也装在这个 prefix,见docs/handoff/handoff-m2_zh.md§3.2)。
1. 安装 dsh-auth-gate(一次性)
包已发布到 npm(dsh-auth-gate),一条命令装进目标 profile:
# 服务器($DSH_HOME 指向目标实例,如 ~/.dsh 或隔离目录):
export PATH="$HOME/.npm-global/bin:$PATH"
dsh plugin --profile web add dsh-auth-gate # 转发 pnpm,从公共 npm 解析(实测)
- 安装后
$DSH_HOME/profiles/web/package.json的dependencies含dsh-auth-gate; 依赖(yaml、@deepseek-ai/*)自动从公共 npm 解析。 - CLI 二进制不会进
PATH——dsh plugin add只是在 profile 目录里跑 pnpm,dsh-auth只能从$DSH_HOME/profiles/web/node_modules/.bin解析。正确的调用方式见 §2。 - 升级:重跑同一命令(pnpm 拉新版本)。
- 卸载:
dsh plugin --profile web remove dsh-auth-gate(0.4.1 起插件声明了dsh.bundle,dsh plugin add会在dsh.profile.bundles里注册挂载,remove也会一并移除;$DSH_HOME/cordis.patch.yml里残留的- id: dsh-auth-gate配置覆盖行会变成空操作并带启动警告,可删除)。
2. 配置
- 建管理员(
users.yaml自动创建于$DSH_HOME/auth/users.yaml,0600):
替代方式(运行时不依赖 pnpm):# CLI 不在 PATH 上(见 §1):经由 profile 调用。 printf '%s\n' '<强口令>' | \ pnpm --dir "$DSH_HOME/profiles/web" exec dsh-auth user add admin --password-stdin pnpm --dir "$DSH_HOME/profiles/web" exec dsh-auth user list # 确认node "$DSH_HOME/profiles/web/node_modules/dsh-auth-gate/lib/cli.js" ...。 多管理员:重复user add;禁用:dsh-auth user disable <name>。 - 配置覆盖:把仓库
deploy/cordis.patch.yml复制为$DSH_HOME/cordis.patch.yml——0.4.1 起该模板是纯配置覆盖(无insert;挂载本身由dsh plugin add通过dsh.bundlemanifest 注册)。按需调整(cookieSecure必须与 TLS 环境一致; 非默认路径才设usersFile)。 - 确认无其他行占用
dsh-auth-gateid(patch 栈按 id 覆盖)。
3. 启动与健康检查
cd "$DSH_HOME" && DSH_HOME="$DSH_HOME" setsid dsh --profile web --port 3081 \
> ~/dsh.log 2>&1 < /dev/null & # 等 ~25s
tail -f ~/dsh.log # 期望:无 error
(实测:SSH 会话里 nohup ... & 可能因子进程持有 fd 让 ssh 挂起 2 分钟——挂起不代表失败,
setsid 可立即返回;启动后另开连接检查 pgrep 与端口。停止:
pkill -f "[d]sh --profile web"——括号技巧防自杀,kill 与启动分两个连接。)
启动自检(M1):四类入口未全覆盖会 fail loud(进程启动失败并报
guard self-check failed)——启动成功即守卫在位。日志应出现
session domain opened: dsh_auth_sessions;无 user store unavailable /
users file not found(首次登录前该 warn 正常——文件缺失=空用户集,fail-closed)。
4. 验收清单(部署后逐项执行)
服务器本机(或经 SSH 隧道)执行。jar 是 curl cookie jar;<TOKEN> 是登录响应
set-cookie 里的 dsh_auth 值(43 字符)。
# A. 登录页与守卫
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3081/auth/login # 200
curl -s http://127.0.0.1:3081/auth/login | grep -o 'name="username"' # 命中
# B. 未认证拒绝(导航 302 / API 401)
curl -s -o /dev/null -w "%{http_code}\n" -H "Accept: text/html" http://127.0.0.1:3081/__dsh_api # 302
curl -s -o /dev/null -w "%{http_code}\n" -H "Accept: application/json" http://127.0.0.1:3081/__dsh_api # 401
# C. 登录(错 → 401;对 → 302 + set-cookie)
curl -s -o /dev/null -w "%{http_code}\n" -d "username=admin&password=wrong" http://127.0.0.1:3081/auth/login # 401
curl -s -i -d "username=admin&password=<口令>" -c jar http://127.0.0.1:3081/auth/login | head -3 # 302 + set-cookie
# D. 会话 cookie 与 Bearer 会话 token
curl -s -o /dev/null -w "%{http_code}\n" -b jar http://127.0.0.1:3081/__dsh_api # 200
curl -s -b jar http://127.0.0.1:3081/auth/status # {"authenticated":true}
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer <TOKEN>" http://127.0.0.1:3081/__dsh_api # 200
# E. 路由纪律(/auth 兜底不落 SPA fallback;method 纪律)
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3081/auth/whatever # 404
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://127.0.0.1:3081/auth/login # 405
# F. WS 升级通道(首行状态即可;--max-time 超时退出正常)
curl --http1.1 -s -i --max-time 2 -b jar -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: $(openssl rand -base64 16)" -H "Sec-WebSocket-Version: 13" \
http://127.0.0.1:3081/api/events.host | head -1 # 101
# 无 cookie 变体 → 首行 401
# G. 登出与吊销
curl -s -i -X POST "http://127.0.0.1:3081/auth/logout?next=/" -b jar | head -3 # 302 + Max-Age=0
curl -s -o /dev/null -w "%{http_code}\n" -b jar http://127.0.0.1:3081/__dsh_api # 401
# H. 限速(放在最后——锁定 30s 起)
for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" -d "username=admin&password=wrong" \
http://127.0.0.1:3081/auth/login; done # 第 1~N 次 401,锁定后 429(IP 桶会累计此前失败)
curl -s -i -d "username=admin&password=<口令>" http://127.0.0.1:3081/auth/login | head -3 # 429 + retry-after
# I. 浏览器通路(可选,须 https 环境):无痕窗口访问 → 302 到 /auth/login →
# 登录 → 进入实例;设置面板里有醒目的「退出登录 / Sign out」按钮
# (设置 → 通用设置 页最下方;client 半边,0.6.5+),
# 也可 URL 访问 /auth/logout?next=/ 登出。
预期全绿 = 部署验收通过。全部失败路径必须是失败(401/403 语义不吞错)——任何"静默放行"
(未认证拿到 200/101)都是部署错误。实测注:cookieSecure: true 只影响浏览器(curl 的
cookie jar 不检查 Secure,验收序列照常);H 组的锁定次数会累计此前步骤的失败(如 C 的
wrong 一次)——以 429 + retry-after 出现为准。
5. 升级与回归(dsh 升级必做)
守卫包装依赖 webServer 非契约内部结构(plan §7)——每次 dsh 升级后:
- 启动(§3)——自检 fail loud 即失败;
- 跑验收清单 B/D/F 三组(守卫 + 会话 + WS);
- 重跑未认证入口覆盖探测(只读、不带凭证):
node scripts/check-live-entries.mjs(默认打http://127.0.0.1:3080)。脚本会从已加载 profile 里发现全部已注册入口 (exact/prefix/upgrade/fallback),只要有任一条未带会话却答 2xx 就以非零码退出。 最近一次已验证运行:entry-coverage-0.1.5-rc.2_zh.md—— dsh0.1.5-rc.2,8 个包共 56 条入口,61/61 探测被拒(401,HTML 面 302 →/auth/login)。 - 检查
boot.log无新增 error/warn; - 0.1.2-alpha 起因 dsh 升级:dsh web 新增页面级 launch-token 门(新浏览器首访需
/?token=)——auth-gate 登录成功会自动桥接(相对跳转/?token=…,见docs/implemented/impl-launch-token-bridge_zh.md)。升级后用全新浏览器(无 dsh cookie)跑一遍验收 A:登录成功即直达实例,不应撞 401 token 门;boot.log里launch-token bridge inactive只应在「dsh 无authenticatedUrl」时出现(旧版 dsh 属预期),launch-token bridge unavailable出现则需排查 connection 服务。
5.1 升级 dsh-auth-gate(0.11.0 → 0.11.1,TOTP 加固)
实测踩坑(2026-08-30,web-test 验证):
- 新发布版本会被 pnpm 的
minimumReleaseAge拦截——普通pnpm up dsh-auth-gate可能静默不升。显式钉版本,并用与 profile 的node_modules匹配的 pnpm 主版本 (profile 声明packageManager: pnpm@11.22.0;系统 pnpm 9.x 会因 store 不匹配失败):corepack pnpm@11.22.0 --dir "$DSH_HOME/profiles/<profile>" up dsh-auth-gate@0.11.1 # 验证:grep '"version"' "$DSH_HOME/profiles/<profile>/node_modules/dsh-auth-gate/package.json" - 重启使在途 TOTP 挑战失效(挑战 cookie 带进程级 HMAC 签名,ADR D10):验证码页上的 用户需重新输入密码(窗口 ≤ 5 分钟)。旧明文 cookie 格式升级后也不再有效(视为无挑战, 用户看到密码页)。
- 升级后至少跑一轮 TOTP 验收:密码阶段 → 挑战 cookie(三段式签名值)→ 验证码页 →
正确码 → 会话;错码 → 401 + 挑战页错误槽位;带会话 cookie 访问
/auth/status→authenticated: true。
6. 故障诊断
| 症状 | 原因 | 处理 |
|---|---|---|
启动失败 guard self-check failed | 包装未覆盖全部入口(dsh 版本变化) | 升级 dsh-auth-gate 或报告(勿绕过自检) |
| 登录恒 401 | 口令错误 / 用户禁用 / users.yaml 缺失(空用户集) | dsh-auth user list;确认 $DSH_HOME 与实例一致 |
登录 503 user store unavailable | users.yaml 语法/schema 错、权限过宽(非 600) | chmod 600;dsh-auth user list 复现错误信息 |
| 登录 429 | 限速锁定(内存态,重启清零) | 等 retry-after 或重启实例 |
| 浏览器登录后仍被拒 | cookieSecure: true 但无 https | 补 TLS 前置,或临时 false(仅测试) |
| 认证后 API 仍 401 | 反代没透传 cookie/Authorization | 检查反代 header 透传配置 |
7. 安全注意事项(plan §8 落地清单)
-
$DSH_HOME/.credentials.yaml与auth/users.yaml均chmod 600(dsh-auth CLI 自动 600)。 - 会话日志视同含密材料(备份/共享同等防护)。
- 升级回归(§5)纳入运维流程;auth 行健康检查(
boot.log+ 验收 B/D/F)纳入监控。 - 口令哈希为 scrypt(
docs/implemented/impl-m3_zh.mdP1);文件零明文。 -
dsh-auth user disable <name>既拦新登录,也吊销该用户已发的会话(插件按revokeSweepMs周期扫描 users.yaml,默认 5000 毫秒;设 0 = 退回 M3「只拦新登录」的旧行为)。 - 限速内存态重启清零;反代部署时限速按出口 IP 聚合(不信任 X-Forwarded-For)。
8. 公网部署变体(2026-08-15 起,dsh.hi-ruofei.com 生效):半外壳
本文档 §1-§7 为"插件形态"(门卫进 dsh 进程)。2026-08-15 生产实证后,公网实例改用 半外壳变体;长期方向见
docs/specs/dsh-auth-plan_zh.md§9 M5(独立反代外壳)。
8.1 为什么需要外壳:浏览器信任栅栏与认证正交
dsh 0.1.0-rc.6 的 dsh-client-connection 把 settings.*/credentials.*/llm.discoverModels
等 privileged 方法钉死为仅 loopback(PRIVILEGED_METHODS,--trusted-host 放不开)。
公网反代下设置页的 settings.describe/credentials.describe 恒 403("transport failure"),
与 dsh-auth-gate 无关——移除门卫裸奔后 403 依旧(2026-08-15 实测)。
实测 header 矩阵(登录后 cookie 访问 /api/settings.describe):
| 上游 Host | Origin | 结果 |
|---|---|---|
dsh.hi-ruofei.com(原样透传) | 任意 | 403 |
127.0.0.1:3080(重写) | 匹配 loopback | 200 |
127.0.0.1:3080(重写) | 剥离 | 200 |
127.0.0.1:3080(重写) | 不匹配 | 403 |
8.2 半外壳拓扑(当前生产)
公网 dsh.hi-ruofei.com (Caddy, TLS)
└─ reverse_proxy 127.0.0.1:3080 {
header_up Host 127.0.0.1:3080 # 重写 Host → dsh 视为 loopback
header_up -Origin # 剥离 Origin → 通过栅栏 Origin 匹配
}
└─ dsh web(含 dsh-auth-gate 门卫,认证逻辑不变)
- 效果:设置页 13 个 API 全 200、零 console 报错;登录/限速/吊销/Bearer 全保留;WS 101 正常。
- 代价:dsh 浏览器信任栅栏被架空(Host 恒 loopback、Origin 恒缺);由门卫补偿——会话 cookie
SameSite=Lax→ 跨站/重绑定请求拿不到 cookie → 401。纵深防御从"栅栏 + 门卫"变为 "门卫 + SameSite"。 - 回滚:
/etc/caddy/Caddyfile.bak.shell(外壳前)与$DSH_HOME/cordis.patch.yml.bak(门卫 停用态)已留档;还原后重启 dsh-web + reload caddy 即回插件形态。
8.3 运维注意(半外壳特有)
- 升级回归(§5)照跑;另加设置页冒烟:登录后点「设置」,确认无
transport failure、 无 403 console 报错。 --trusted-host dsh.hi-ruofei.com在重写后已冗余(Host 恒 loopback),保留无害。- 会话能扛住 dsh-web 重启:按实例落盘在
$DSH_HOME/storages/dsh_auth_sessions.json(mode 0600;键为会话 token 的 sha256,盘上无明文 token),在到期(sessionTtl,默认 7 天) 前一直有效,除非被吊销(POST /auth/logout会删掉落盘行)。重启真正清掉的是进程级状态: 在途 TOTP 挑战(§5.1)、登录限速器(§6)与 TOTP 防重放记录。所以已登录的浏览器不用重新 登录,停在验证码页的用户需重新输入密码。 - 裸奔测试教训:不要在无外壳无门卫状态下公网运行——agent 有工作区写权限且
$DSH_HOME/.credentials.yaml含模型 API key,任何人可白嫖调用。
9. 认证本地代理(可选扩展,2026-08-26 起)
半外壳(§8)解决了服务端
/api栅栏;但 dsh 客户端还有一道"页面 origin 必须回环" 的检查(dsh-client-connection的isLoopback只认localhost/[::1]/127/8):页面在 域名下时设置镜像以 memory 模式运行,设置页报 "settings are unavailable in this browser" (客户端自报,与认证无关)。本扩展在用户本机提供回环页面入口,配合半外壳与门卫实现 "远程编辑配置 + 全程认证",全程不修改 dsh 源码。
9.1 拓扑与认证
用户浏览器 (http://127.0.0.1:8443 ← 页面 origin 回环,客户端放行)
└─ dsh-auth-proxy(用户本机,严格绑定 127.0.0.1,无状态透传)
└─ https://dsh.hi-ruofei.com(SNI/Host = 域名)
└─ Caddy(§8.2 头改写:Host/Origin → 127.0.0.1:3080)
└─ dsh web + dsh-auth-gate(认证逻辑不变)
- 认证复用 auth-gate:登录页与 302/Set-Cookie 原样透传(cookie 归
127.0.0.1:8443名下);--strip-secure-cookie(默认开)在本地明文 http 下移除Secure属性(回环一跳, Chrome/Firefox 本可保留,Safari 兜底;HttpOnly/SameSite=Lax/Path=/保留)。 - 代理不保存任何会话/凭证(无状态;重启只断它自己的连接,不影响 dsh-web 落盘的会话)。
- WS 升级(
/api/events.mux、/api/events.host)同样经代理隧道转发。
9.2 使用
node lib/proxy-cli.js --listen 127.0.0.1:8443 --target https://dsh.hi-ruofei.com --mark-proxy
# 浏览器打开 http://127.0.0.1:8443 → auth-gate 登录 → 「设置 → 模型」即可编辑
| 参数 | 默认 | 说明 |
|---|---|---|
--listen | 127.0.0.1:8443 | 必须回环(非回环拒绝启动,防局域网跳板) |
--target | https://dsh.hi-ruofei.com | 上游;默认 https 并校验 TLS |
--strip-secure-cookie | 开(--no-… 关闭) | 本地明文 http 下去掉 Secure |
--mark-proxy | 关 | 每请求加 X-Dsh-Proxy: 1(启用 §9.3 的 deny-list) |
--local-token-env <VAR> | 无 | 所有经代理请求须带 Authorization: Bearer <环境变量值>(fail-closed:变量未设置则拒绝启动) |
--unsafe-plain-target | 关 | 允许 http:// 上游(仅本机验证场景) |
9.3 安全边界:X-Dsh-Proxy deny-list(Phase 2.1)
代理链路下 /api 围栏会把 host.pickDirectory、host.openPath、settings.openDocument、
llm.discoverModels 也判为 loopback → 远程"认证用户"可触发宿主原生能力(文件对话框、
打开宿主路径、SSRF 式探测)。防线:代理加 --mark-proxy,auth-gate guard 在认证通过后对
命中上述方法的标记请求直接 403 forbidden(与 /api 围栏同形)。
- 默认不标记 → 行为与未部署代理完全一致;安全边界由运维显式开启。
- 仅 HTTP 路由生效;WS 通道(事件流)不在禁行列表,不受影响。
- 标记头可被伪造,但只会反过来禁行伪造者自己(拒绝是自指的),无放大面。
9.4 systemd 单元
模板见 deploy/systemd/dsh-auth-proxy.service.example:
sudo cp deploy/systemd/dsh-auth-proxy.service.example /etc/systemd/system/dsh-auth-proxy.service
# 按实际路径编辑 ExecStart(bin 与 --target 两项)
sudo systemctl daemon-reload && sudo systemctl enable --now dsh-auth-proxy
9.5 验证清单(部署后逐项)
- 代理启动打印
listening on http://127.0.0.1:8443 -> …,非回环--listen直接报错退出; - curl
GET /(Accept: text/html)→302 /auth/login; POST /auth/login→302 + set-cookie(无Secure属性);- 带 cookie
POST /api/settings.describe(RPC 信封{"type":"client-request","rpcId":"x","method":"…","payload":{}},Content-Type: application/json)→200 {"ok":true,…}; - 浏览器:登录后「设置 → 模型」无 "settings are unavailable"、提供方行可编辑;
- 开
--mark-proxy:标记请求settings.describe仍 200,host.openPath→ 403; - 回归:直连
https://dsh.hi-ruofei.com的模型页仍显示原错误(预期,未走代理)。