只有一个 token 也想开仪表盘:Hermes 把部署令牌变成一等凭据

你把 Hermes 的 web 仪表盘搬到一台内网服务器上,想让同事通过浏览器访问,于是把监听地址从 127.0.0.1 改成 0.0.0.0。它没有起来,日志里只有一句冷冰冰的“需要认证 provider,拒绝绑定”。你手上明明有一个由 nix / agenix 注入的部署令牌,可它没被当成凭据。
接下来的半小时很典型:你被引导去配 basic auth,登录弹框出来了,密码散落在 .env 里;桌面端报“HTTP 能连上,但 WebSocket 连不上”。明明只差一步,却怎么都对不上。
为什么以前不行:一道 fail-closed 的门
仪表盘的认证门禁在 2026 年 6 月收紧过一次:只要绑定非回环地址,就必须注册一个认证 provider,没有就拒绝绑定——宁可不启动,也不暴露一个裸端口。这个设计本身是对的。
问题是它只认“能交互登录”的 provider:basic auth、Nous 账号、自建 OIDC。而部署场景里更常见的凭据是一个共享令牌——由密钥管理器解密成文件,交给服务进程读。旧代码不但不认它,还会在你试图满足门禁时,把一套你根本没要的 basic auth 凭据写进 .env。
即使令牌已经注入,两条腿也各自拒绝:REST 请求带着 Bearer 过来,得到 401 no_cookie;/api/ws 的升级请求直接 403。桌面端提示“HTTP 可达但 WebSocket 失败”,而那个 HTTP 检查只打了公开的 /api/status,看起来一切正常——这类报错最难查,因为一半是通的。
现在的做法:令牌自己就是一个 provider
Hermes 内置了一个 dashboard_auth/session_token provider。它的存在感很低:只要环境里提供了 HERMES_DASHBOARD_SESSION_TOKEN,它就自动注册;没有提供,它什么都不做。
令牌支持两种写法:
- 直接写值:
HERMES_DASHBOARD_SESSION_TOKEN=<token>; - 写文件路径:
HERMES_DASHBOARD_SESSION_TOKEN=file:/run/secrets/hermes-dashboard-token。
第二种是给密钥管理器用的。systemd 的 Environment=、launchd 的 plist、Nix 模块渲染的服务环境里,放一个路径是安全的,放明文令牌不是。Hermes 的 Nix 模块就是这么干的:backend.sessionTokenFile 指向解密后的文件,模块把它渲染成 file: 形式的环境变量。启动时会读这个文件(去掉首尾空白,兼容带 BOM 的文件);文件读不到或者是空的,进程直接报错退出,而不是带着一个空令牌继续跑。
校验用常数时间比较,避免通过响应时间猜测令牌。REST 的 Authorization: Bearer 和 /api/ws 升级用的 ?token= 查询参数走的是同一条解析路径,所以一个令牌同时打开两条腿——这正是桌面端“Session token”输入框一直承诺、但之前兑现不了的形态。
还有一个刻意的决定:它没有登录页。访问 /login 会看到一段说明,告诉你这个部署用令牌认证,而不是给你一个永远点不动的按钮;自动 SSO 跳转也不会把它算作候选。令牌模式不是“人登录”,是“机器带着凭据进门”。
部署怎么写
# 1. 生成一个 32 字节的随机令牌
openssl rand -base64 32 > /etc/hermes/dashboard-token
chmod 600 /etc/hermes/dashboard-token
chown hermes:hermes /etc/hermes/dashboard-token
# 2. 让服务进程拿到路径,而不是值
export HERMES_DASHBOARD_SESSION_TOKEN=file:/etc/hermes/dashboard-token
# 3. 绑到非回环地址(门禁现在能过了)
hermes dashboard --host 0.0.0.0 --port 9119
Nix 部署里不用自己写环境变量:Hermes 的 Nix 模块暴露一个 backend.sessionTokenFile 选项,指向解密后的令牌文件,由模块把环境变量渲染成 file:<path> 形式。服务的环境里出现的只是这个路径,密钥本身不进 Nix store。
验证:三条命令确认门真的关上了
# 门禁状态与已注册的 provider
curl -s http://YOUR_HOST:9119/api/status | jq '.auth_required, .auth_providers'
# true
# ["session-token"]
# 带令牌应返回 200
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN" http://YOUR_HOST:9119/api/auth/me
# 200
# 不带令牌应该被拒绝(这才是重点)
curl -s -o /dev/null -w '%{http_code}\n' http://YOUR_HOST:9119/api/auth/me
# 401
桌面端远程网关那边,把同一个令牌粘进 “Session token” 字段即可;它同时用于 HTTP 探测和 WebSocket 升级。
排错对照表
| 现象 | 原因 | 处理 |
|---|---|---|
| 启动即退出,日志提到 unreadable / empty token file | file: 路径不对、权限不足,或文件是空的 |
用运行服务的那个用户 cat 一次文件确认 |
/api/status 里 auth_providers 是空的 |
环境变量没传进服务进程 | 检查 systemd Environment= / plist / 容器 env |
HTTP reachable but the WebSocket fails |
HTTP 走的是公开接口,/api/ws 需要令牌;或令牌已被轮换 |
重新粘新令牌;轮换等于重新部署密钥 |
| 想继续用 basic auth / OIDC,令牌只给内部用 | provider 默认开启 | 设 dashboard.session_token_auth.enabled: false |
最后一条的写法:
dashboard:
session_token_auth:
enabled: false
什么时候不该用它
- 多人共用一个仪表盘:这个令牌背后没有任何用户身份,谁拿到谁就是管理员。要审计“谁改了什么”,用 OIDC(Authentik、Keycloak、Zitadel、Authelia、Auth0、Okta 都行)或 Nous 账号 provider。
- 面向公网:一个长期不变的 bearer 令牌扛不住泄漏,轮换还得挨个通知客户端。公网部署优先交互式登录。
- 本机自用:回环地址本来就不需要认证 provider,桌面端和本地 SPA 用内部令牌即可,不必额外配。
还有一条操作纪律:令牌要当密码对待。放密钥管理器、别放进镜像或代码仓库;轮换的方式是重新部署密钥而不是“改一下环境变量”——旧客户端里存的那份会立刻失效,表现就是那句令人困惑的 “WebSocket fails”。
仪表盘登录门禁的来龙去脉可以读仪表盘登录加固;如果撞上的是桌面端令牌串号,看会话令牌串号问题;通过 SSH 隧道访问的认证形态在SSH 上的 OAuth里。命令与端口参数见 hermes dashboard 参考页。
版本状态
这个 provider 在 2026 年 10 月 28 日合并进 main(PR #146139,建立在 #146093 的文件读取与 #146104 的 Nix 渲染之上)。当前正式版 v0.22.5(2026-10-23)还不包含它。
一个令牌、两条腿、没有登录页——内网部署终于不用为了过门禁去编一套自己不想要的密码体系。