fonlan/dsh-web-auth

dsh-web-auth

DSH web plugin: password-gate authentication (login page + HttpOnly signed cookie) for exposing dsh web behind a reverse proxy

未分類

dsh-web-auth

为 DSH Web GUI 增加密码访问认证的插件:所有 HTTP 请求与 WebSocket 升级都必须携带有效会话,未登录一律跳转到登录页。装上它之后,把 dsh 反代到公网(或直接暴露端口)才不至于裸奔。

特性

  • 登录页 + HttpOnly 签名 Cookie(HMAC-SHA256,7 天滑动续期),浏览器自动携带,HTTP / WebSocket / SSE / 静态资源全覆盖
  • 首次设置:未配置密码时,登录页显示「设置访问密码」表单(引导流程,见下文安全说明)
  • GUI 改密:设置 → 侧栏「访问认证」页面,需旧密码;改密后轮换签名密钥,所有已登录会话立即下线
  • 退出登录:设置卡片按钮,或访问 /logout
  • 防爆破:登录与改密接口按客户端 IP 限速(连续 5 次失败锁 1 分钟起,指数退避至 30 分钟;15 分钟内累计)
  • 反代友好:Cookie Secure 自动跟随 x-forwarded-proto;限速信任本机对端的 X-Forwarded-For
  • 监听地址切换:设置卡片可在本机网卡的具体地址之间切换(含 127.0.0.1 仅本机、以及每张网卡的 IP),热生效、无需重启进程(REST → host 半区应用 patch → HMR 重绑 webserver)。不再提供 `0.0.0.0`:dsh 0.2.1-alpha.2 起通配符地址会在解析配置阶段直接让启动失败,写进去即硬砖(见下文「通配符与启动失败」)
  • 远程设置解锁(可选):unlockRemoteSettings 组合配置在启动页注入 __DSH_TRANSPORT__.ownsHost 标志,让反代域名下的设置面(模型页、插件配置、常规设置等)完整可用,默认关闭(见下文安全权衡)
  • 启动令牌收口:未登录浏览器带旧 ?token= 链接访问一律重定向回登录页,只有插件自身的服务端 mint 走回环放行通道——既不需要手工引导,也不会再撞上 DSH 的 401 死页面(0.1.7 起)
  • 密码以 scrypt 加盐哈希存储($DSH_HOME/web-auth/password.hash,0600),永不回显、不落明文配置

安装

dsh plugin --profile web add @fonlan/dsh-web-auth

从 npm 安装:dsh plugin add 会把包写入 profile 的依赖与 bundle 栈(cordis.patch.yml 自动 insert 插件行,host 半区 + 浏览器半区一次挂载)。升级到新版本:

dsh plugin --profile web update @fonlan/dsh-web-auth
安装后需要重启 dsh web 进程才生效(当前会话会短暂断连重连)。

宿主兼容性:0.2.1 面向 dsh ^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2 || ^0.2.1-rc.2,含两处按宿主演进做的适配:取值域去掉通配符(宿主 0.2.1-alpha.2 起拒绝),设置页不再依赖已被移除的客户端 settingsScope 服务(改走本插件 REST)。在更早的 0.1.x 宿主上本插件同样可用,但请勿把监听地址设成 0.0.0.0——宿主一旦升级到 0.2.1-alpha.2,该值会让 dsh 无法启动(见下文「通配符与启动失败」)。

激活(首次设置密码)

重启后打开 dsh web,会被重定向到登录页:

  1. 未设置过密码时,登录页显示「设置访问密码」——输入 ≥8 位的新密码并确认
  2. 设置成功后立即签发会话并跳回原页面;此后每次访问都要输密码

改密入口在 设置 → 侧栏「访问认证」(需先登录)。

行为细节

场景 · 行为 未登录访问页面(GET/HEAD) · 302 → /login?next=原路径,登录后跳回 未登录访问 API(POST 等) · 401 JSON DSH 启动令牌交换(GET /?token=…) · 仅插件服务端的 mint 重入(直连回环对端 + 内部标记头)或已持有插件会话的请求放行并改写为回环来源转发:DSH 自行校验令牌、签发自己的 dsh-auth cookie(303 → 干净 /);令牌无效则收到 DSH 自带的 401 浏览器直接打开打印的令牌 URL(未登录) · 302 → /login?next=/,丢弃 `token` 参数:令牌每次启动重新生成,旧书签/旧链接本就无效,放行只会把用户丢到 DSH 的 401 页面;登录后服务端 mint 会自动补发有效的 dsh-auth cookie DSH 会话 cookie 自动签发 · 插件在放行响应上服务端代理 DSH 的令牌交换(走本机回环 /?token=),把 dsh-auth Set-Cookie 随响应转发给浏览器——新浏览器无需再手工打开打印的 token URL;每分钟最多一次(结果会持续续期 DSH 的 30 天 cookie) 反代域名下已登录访问任意路径(含 /api、插件前缀) · 改写为回环来源放行,网关特权方法与插件围栏不再 403 反代域名下的设置页(模型页等) · 默认报 settings are unavailable in this browser——dsh 客户端按页面 hostname 判定"非本机浏览器",设置镜像保持进程本地。开启 unlockRemoteSettings 后启动页注入 ownsHost,设置面完整可用(读写仍过密码门 + 回环围栏) 跨站请求(即使带 Cookie) · 仍被围栏 403 拒绝 未登录 WebSocket 升级 · 握手直接 401 拒绝 登录失败 · 跳回登录页显示「密码错误」(统一文案) 连续失败 · 按 IP 限速,提示「尝试过于频繁」 会话过期 · 跳回登录页;滑动续期:剩余不足 24h 自动刷新 修改密码 · 校验旧密码 → 轮换签名密钥 → 全员下线,跳转登录页 切换监听地址(设置卡片) · 校验取值(必须是具体 IPv4/IPv6 字面量,通配符被拒绝)→ POST /api/web-auth/listen → host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;WebSocket 短暂断连后自动重连 /logout · 清除 Cookie,跳转登录页

无需再处理 DSH 自己的启动令牌:DSH 的浏览器会话认证(dsh web 启动时打印的 http://127.0.0.1:3080/?token=…)用 cookie 绑定 Host。插件会把所有已认证请求的 Host 改写为回环地址,而 DSH 的 cookie 必须与「DSH 看到的 Host」一致——0.1.5 起插件会在登录/会话期间自动在服务端完成 DSH 令牌交换并把 dsh-auth cookie 转交给浏览器(含持续续期),所以外部设备、新浏览器都不需要再手工访问带 token 的 URL。0.1.7 起这条路径进一步收紧:未登录浏览器带 token 访问会被重定向到 /login 并丢弃该参数(旧令牌在 dsh 重启后即失效,放行只会展示 DSH 自己的 401 页面),登录本身就已经完成 mint,不再需要任何手工引导步骤。

切换监听地址

dsh 默认只监听 127.0.0.1。在 设置 → 侧栏「访问认证」 页面底部可以把它切换为本机任意网卡上的具体地址(列表由 host 半区枚举 os.networkInterfaces() 生成),或切回 127.0.0.1 仅本机——无需重启 dsh 进程:

  • 页面先 GET /api/web-auth/status 取当前地址与候选列表(allowed),提交时 POST /api/web-auth/listen;host 半区把取值写进当前部署的 patch 层:profile 的 cordis.patch.yml;若 home 层($DSH_HOME/cordis.patch.yml)已声明 webserver 行则写 home 层(home 优先级更高,改在低层会被覆盖)。文件里其余内容(注释、!!js 端口表达式)原样保留。
  • 写入前统一过 isLocalListenHost:必须是 IPv4/IPv6 字面量、不是通配符、并且确实是本机网卡拥有的地址,否则直接拒绝(400 invalid-host,响应里附上合法候选列表),不会写坏文件。下拉列表即由同一枚举生成,页面不可能提供一个写不进去的值。
  • dsh 的 HMR 会监听到 patch 文件变化并热重载 webserver 行:关闭旧监听、按新地址重新绑定,全程无需重启进程。重启进程后该设置依然生效(patch 文件在)。
  • 切换瞬间所有连接(包括当前页面与 WebSocket)会断开重连,属正常现象;卡片会轮询状态确认新地址生效。
  • 绑定具体网卡地址后 `127.0.0.1` 不再监听:本机也必须改用该地址访问(http://:3080)。想同时保留本机与局域网入口,请让 dsh 继续监听 127.0.0.1,另在本机放一层反代监听局域网/公网并指向它——这也是 dsh 现在推荐的暴露方式。
  • 注意:若你正通过局域网 IP 访问并切回 127.0.0.1,切回后只能在本机访问,请改用 127.0.0.1 重新打开页面。
该能力依赖 webserver 行位于 profile/home patch 层(默认安装即如此)。若你的 webserver 配置来自自定义 --patch overlay,请直接编辑 overlay 文件。

写入值导致启动失败(三类,及恢复方式)

监听地址是组合配置,写在 patch 文件里、由 loader 在启动时读取。因此一个写坏的取值不是「绑定失败」而已——它会让下一次启动整体失败,而本插件自己也在 waiting、无法自救。0.2.0 把所有已知的三类都堵在写入之前:

类别 · 写坏后会看到 · 插件现在如何拦截 通配符 0.0.0.0 / :: · 解析配置阶段报 host "0.0.0.0" is an unspecified (wildcard) address, which is not supported … · 通配符一律拒绝(400 invalid-host / 写盘前抛错) 本机没有的地址(如 macOS 上的 127.0.0.2、过期 DHCP 地址) · listen EADDRNOTAVAIL: address not available … · 只接受本机网卡实际拥有的地址(os.networkInterfaces() 枚举,127.0.0.1 恒定可用) 把行追加到模板的空序列 [] 之后 · failed to parse overlay …: end of the stream or a document separator is expected · 识别模板 [] 并替换它(非空 flow 文档直接拒绝编辑)

三类症状长这样(webserver 未激活,所有依赖它的插件排队等 webServer):

dsh: startup failed: 2 required plugins did not activate
  webserver (required)  @deepseek-ai/dsh-host-webserver  → 上面某一类报错
Plugins waiting for services:
  web-runtime / web-auth / better-sidebar / task-kanban / … (missing webServer)

恢复方式都是手工改 patch 文件(插件此时不会加载):

# 1) 看当前写死的 host
grep -n -A3 '^- id: webserver' ~/.dsh/profiles/web/cordis.patch.yml
# 2) 改成 127.0.0.1(或本机某张网卡的实际地址)后保存

若第一行是 [] 之类的空序列而下面又跟了 - id: 行,把那个 [] 行整行删掉即为合法文档。

DHCP 提醒:绑定具体网卡地址后,地址由 DHCP 变化(或网卡消失)时,下一次启动同样会 EADDRNOTAVAIL。长期部署建议让 dsh 监听 127.0.0.1,把对外暴露交给本机反代(反代再指向回环),这样地址变化不影响 dsh 启动。

0.2.0 起插件的所有写路径都会在改动任何字节之前校验,因此这三类砖不会再被本插件写出来。(若你仍在用 0.1.10 及更早版本并已中招,同样按上面方式手工修复,再升级到 0.2.0。)

远程设置解锁(unlockRemoteSettings,默认关闭)

现象:通过域名反代访问 dsh 时,设置 → 模型页报「加载提供方目录失败: settings are unavailable in this browser」,常规设置、插件配置等设置面同样不可用。这不是故障,是 dsh 的有意设计:设置文档(含 provider 凭据引用等敏感配置)只对"本机浏览器"开放——客户端在页面加载时按 location.hostname 判定是否回环(localhost / 127.x),反代域名永远不算,设置镜像因此保持进程本地、根本不发起读取。注意这与服务端无关:本插件已把已认证请求改写为回环来源,/api 通道完全正常,只有客户端这道门在拦。

开启:在 profile 的 cordis.patch.yml(~/.dsh/profiles//cordis.patch.yml)加一条 config 覆盖:

- id: web-auth
  config:
    unlockRemoteSettings: true

HMR 会监听到 patch 变化并热重载 web-auth 行(无需重启进程)。生效后,域名下已登录页面的启动 HTML 会携带一段内联脚本,设置 window.__DSH_TRANSPORT__.ownsHost = true——dsh 客户端把它读作"该页面由宿主直接提供",设置镜像随之正常加载。只在成功渲染的 index 响应上注入(未登录请求在闸门处就被 302/401,根本到不了渲染);已存在的 __DSH_TRANSPORT__ 载体只会合并、不会被覆盖。不需要时删掉该配置即回退(同样热生效)。

安全权衡(请务必理解后再开启):ownsHost 是 dsh 画的"设置仅本机可改"边界;开启等于把这道边界改由本插件的密码门承担——每个通过密码认证的浏览器都能读写 host 设置文档(包括 provider API key 的引用、监听地址等)。若域名暴露在公网,请确保:强密码、反代全程 HTTPS、最好叠加反代层限流/IP 白名单。只在本机/内网使用或设置面不受影响时,保持默认关闭即可。

安全模型与风险(请务必阅读)

  • 认证是访问控制,不是安全边界。dsh 的 agent 拥有 bash 执行能力(远程代码执行级别),即使有密码保护,也不要把它当成不可攻破的堡垒:密码强度、反代层 WAF/限流、IP 白名单仍然值得做。
  • 首密窗口期:未设置密码时,任何人都能访问登录页并抢注密码(设计如此,作为 bootstrap 通道)。启动日志会打印警告;请装好插件后第一时间设置密码。生产环境建议先用 127.0.0.1 + SSH 端口转发完成首密设置,再开放公网。
  • 会话是无状态的(HMAC 签名),服务端无法单独吊销某一个会话;/logout 只清浏览器 Cookie。密码泄露时请修改密码——这会轮换密钥并让所有会话失效。
  • 密码哈希与签名密钥存放于 $DSH_HOME/web-auth/(默认 ~/.dsh/web-auth/),权限 0600;删除 password.hash 即回到未配置状态(认证关闭)。
  • 限速为进程内存级,多实例部署时各自独立,重启清零;如需更强防护请依赖反代层限流。

开发

pnpm install
npm run build      # host: tsc → lib/;client: esbuild → lib/client.js(ModuleLoader 格式)
npm run typecheck
npm run test       # 单元 + 集成测试(node:test,真实 HTTP/WS 全流程)

仓库结构:

  • src/auth-core.ts — 纯逻辑:scrypt 哈希、Cookie 签名/校验、限速器、next 校验
  • src/gate.ts — 把认证闸门包到 node:http 服务器的 request/upgrade 监听器外层
  • src/handlers.ts — 登录页/登录/登出/改密/状态/监听切换 路由处理
  • src/profile-patch.ts — 监听地址切换:webserver 行的 patch 层文本改写(保留注释与 !!js 表达式),并在写入前拒绝通配符
  • src/listen-addresses.ts — 监听地址取值域:字面量/通配符判定、本机网卡枚举(候选列表)、本机对端判定(mint 重入与 XFF 信任)
  • src/remote-unlock.ts — 远程设置解锁:unlockRemoteSettings 开启时经 webServer.tapIndex 注入 ownsHost 启动页脚本
  • src/index.ts — cordis 插件装配(host 半区)
  • src/client/index.tsx — 设置卡片(浏览器半区,settings.section 插槽,id web-auth;读写走本插件 REST)
  • cordis.patch.yml — bundle 补丁层(insert 插件行)

License

MIT