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-globalexport 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.jsondependenciesdsh-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.bundledsh plugin add 会在 dsh.profile.bundles 里注册挂载,remove 也会一并移除;$DSH_HOME/cordis.patch.yml 里残留的 - id: dsh-auth-gate 配置覆盖行会变成空操作并带启动警告,可删除)。

2. 配置

  1. 建管理员users.yaml 自动创建于 $DSH_HOME/auth/users.yaml,0600):
    # 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   # 确认
    
    替代方式(运行时不依赖 pnpm): node "$DSH_HOME/profiles/web/node_modules/dsh-auth-gate/lib/cli.js" ...。 多管理员:重复 user add;禁用:dsh-auth user disable <name>
  2. 配置覆盖:把仓库 deploy/cordis.patch.yml 复制为 $DSH_HOME/cordis.patch.yml ——0.4.1 起该模板是纯配置覆盖(无 insert;挂载本身由 dsh plugin add 通过 dsh.bundle manifest 注册)。按需调整(cookieSecure 必须与 TLS 环境一致; 非默认路径才设 usersFile)。
  3. 确认无其他行占用 dsh-auth-gate id(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 升级后

  1. 启动(§3)——自检 fail loud 即失败;
  2. 跑验收清单 B/D/F 三组(守卫 + 会话 + WS);
  3. 重跑未认证入口覆盖探测(只读、不带凭证):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 —— dsh 0.1.5-rc.2,8 个包共 56 条入口,61/61 探测被拒(401,HTML 面 302 → /auth/login)。
  4. 检查 boot.log 无新增 error/warn;
  5. 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.loglaunch-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 验证):

  1. 新发布版本会被 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"
    
  2. 重启使在途 TOTP 挑战失效(挑战 cookie 带进程级 HMAC 签名,ADR D10):验证码页上的 用户需重新输入密码(窗口 ≤ 5 分钟)。旧明文 cookie 格式升级后也不再有效(视为无挑战, 用户看到密码页)。
  3. 升级后至少跑一轮 TOTP 验收:密码阶段 → 挑战 cookie(三段式签名值)→ 验证码页 → 正确码 → 会话;错码 → 401 + 挑战页错误槽位;带会话 cookie 访问 /auth/statusauthenticated: true

6. 故障诊断

症状原因处理
启动失败 guard self-check failed包装未覆盖全部入口(dsh 版本变化)升级 dsh-auth-gate 或报告(勿绕过自检)
登录恒 401口令错误 / 用户禁用 / users.yaml 缺失(空用户集)dsh-auth user list;确认 $DSH_HOME 与实例一致
登录 503 user store unavailableusers.yaml 语法/schema 错、权限过宽(非 600)chmod 600dsh-auth user list 复现错误信息
登录 429限速锁定(内存态,重启清零)retry-after 或重启实例
浏览器登录后仍被拒cookieSecure: true 但无 https补 TLS 前置,或临时 false(仅测试)
认证后 API 仍 401反代没透传 cookie/Authorization检查反代 header 透传配置

7. 安全注意事项(plan §8 落地清单)

  • $DSH_HOME/.credentials.yamlauth/users.yamlchmod 600(dsh-auth CLI 自动 600)。
  • 会话日志视同含密材料(备份/共享同等防护)。
  • 升级回归(§5)纳入运维流程;auth 行健康检查(boot.log + 验收 B/D/F)纳入监控。
  • 口令哈希为 scrypt(docs/implemented/impl-m3_zh.md P1);文件零明文。
  • 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-connectionsettings.*/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):

上游 HostOrigin结果
dsh.hi-ruofei.com(原样透传)任意403
127.0.0.1:3080(重写)匹配 loopback200
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-connectionisLoopback 只认 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 登录 → 「设置 → 模型」即可编辑
参数默认说明
--listen127.0.0.1:8443必须回环(非回环拒绝启动,防局域网跳板)
--targethttps://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.pickDirectoryhost.openPathsettings.openDocumentllm.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 验证清单(部署后逐项)

  1. 代理启动打印 listening on http://127.0.0.1:8443 -> …,非回环 --listen 直接报错退出;
  2. curl GET /Accept: text/html)→ 302 /auth/login
  3. POST /auth/login302 + set-cookieSecure 属性);
  4. 带 cookie POST /api/settings.describe(RPC 信封 {"type":"client-request","rpcId":"x","method":"…","payload":{}}Content-Type: application/json)→ 200 {"ok":true,…}
  5. 浏览器:登录后「设置 → 模型」无 "settings are unavailable"、提供方行可编辑;
  6. --mark-proxy:标记请求 settings.describe 仍 200,host.openPath → 403;
  7. 回归:直连 https://dsh.hi-ruofei.com 的模型页仍显示原错误(预期,未走代理)。