瀏覽代碼

完善 9119 本机授权并保留 8800 登录保护

LSamien 1 月之前
父節點
當前提交
5ad07eea26

+ 14 - 11
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-夭夭 Web 是一个独立的本地 Web 工作台,通过端口 `8800` 连接 Hermes Dashboard/Gateway `9119`,提供普通聊天、群聊、文件库和产物浏览。通过本项目安装的 LaunchAgent 会持续监督 `9119`:缺失时自动配置 Dashboard 认证并启动它。
+夭夭 Web 是一个独立的本地 Web 工作台,通过端口 `8800` 连接 Hermes Dashboard/Gateway `9119`,提供普通聊天、群聊、文件库和产物浏览。通过本项目安装的 LaunchAgent 会持续监督本机 `9119`:缺失时在 `127.0.0.1` 启动它,不自动写入账号密码,也不重启或重绑定已有服务。
 
 ## 8800 用户与 iOS
 
@@ -16,9 +16,12 @@ iOS/Web 连接 `http://主机:8800` 并使用 8800 用户登录,客户端实
 登录页扫描后会把 8800 保存为普通服务器账号。`yaoyao://login` 登录码与
 `yaoyao://pair` 子节点码相互独立,不会把手机误登记为子节点。
 
-8800 使用单独托管的 9119 服务账号。受管本机首次安装会生成随机服务密码并加密
-保存;外部或 Docker 9119 可在“系统管理”中配置,或设置
+8800 直连 `127.0.0.1`(或 `::1`),且 9119 明确声明 `auth_required=false` 时,
+使用 Hermes 签发的本机临时会话令牌,无需配置 9119 用户名密码。令牌仅保存在服务端内存,
+不返回客户端;REST 和上游 WebSocket 均携带该凭据。远程或已开启账号鉴权的 9119
+仍使用独立服务账号,可在“系统管理”中配置,或设置
 `HERMES_YAOYAO_UPSTREAM_USERNAME` 和 `HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE`。
+`localhost`、Docker 宿主机别名和转发头不作为本机授权依据;详见 [本机授权边界](docs/loopback-authorization.md)。
 
 ### iOS 后台消息推送
 
@@ -245,21 +248,21 @@ npm run dev
 
 ## 网络暴露:9119 与 8800 分别控制
 
-通过 `service install` 安装的受管服务默认开放可信局域网:Hermes Dashboard `9119` 和夭夭 Web `8800` 都监听 `0.0.0.0`。手动 `npm start` 与 Docker 部署仍保留各自的绑定默认值。
+通过 `service install` 安装的受管服务默认只开放夭夭 Web `8800` 到可信局域网;新启动的 Hermes Dashboard `9119` 仅监听回环。手动 `npm start` 与 Docker 部署仍保留各自的绑定默认值。
 
 | 端口 | 服务 | 默认 | 单独开启局域网 |
 | --- | --- | --- | --- |
-| `9119` | Hermes Dashboard | `0.0.0.0` | 由夭夭 Web 监督;沿用或创建随机服务账号后启动 |
+| `9119` | Hermes Dashboard | `127.0.0.1` | 不自动开放;已有服务保持原配置,远程服务必须启用鉴权 |
 | `8800` | 夭夭 Web | `0.0.0.0` | LaunchAgent 显式启用可信局域网 HTTP |
 
-夭夭 Web 默认上游仍是 `http://127.0.0.1:9119`,8800 在本机通过回环连接受监督的 9119;两个服务对局域网的监听地址均为 `0.0.0.0`。
+夭夭 Web 默认上游仍是 `http://127.0.0.1:9119`;客户端只连接 8800。监督器仅管理此本机 HTTP 端口,不替外部上游启动本机 Dashboard。
 
-受管 `service install` 会覆盖遗留的 loopback 环境并保持局域网监听。若必须限制为仅本机访问,请使用手动 `npm start` 或 Docker 的回环绑定,不要使用受管 LaunchAgent。
+受管 `service install` 会覆盖 8800 遗留的 loopback 环境并保持 8800 局域网监听。若 8800 也必须仅本机访问,请使用手动 `npm start` 或 Docker 的回环绑定,不要使用受管 LaunchAgent。
 
 默认局域网 HTTP 只适合可信网络。跨不受信网络使用时必须配置 `HERMES_YAOYAO_TLS_CERT` 和 `HERMES_YAOYAO_TLS_KEY`,并限制防火墙或反向代理访问范围。
 
-受管服务会在 `dashboard.basic_auth` 未配置时创建 `yaoyao-service` 和随机密码的
-scrypt 哈希,同时生成会话签名 `secret`;明文服务密码只加密保存在 8800 数据目录。
+受管服务不创建或修改 `dashboard.basic_auth`。已有密码配置不会被删除;若 9119
+仍要求账号鉴权,需在 8800 配置服务账号,不会自动降级成免账号模式。
 8800 本地管理员 `admin/admin` 首次登录强制修改。具体步骤见
 [Agent 安装手册](docs/agent-install.md#受管-9119-的默认认证与持续监督)。
 Dashboard 的 `--insecure` 参数不会关闭认证;不要并行安装其他 Dashboard 监督器。
@@ -374,13 +377,13 @@ curl --fail --silent http://127.0.0.1:9119/api/dashboard/plugins
 
 ## 受管服务操作与验收
 
-`service install` 会写入并启动 `com.samien.hermes-yaoyao` LaunchAgent;它默认让 8800 与受监督的 9119 监听局域网。已安装服务的环境变量写入 plist,因此改变监听地址、TLS 或监督开关后需要再次执行 `service install`,仅执行 `service start` 不会刷新这些配置。
+`service install` 会写入并启动 `com.samien.hermes-yaoyao` LaunchAgent;8800 默认监听局域网,新启动的受监督 9119 仅监听回环。已安装服务的环境变量写入 plist,因此改变监听地址、TLS 或监督开关后需要再次执行 `service install`,仅执行 `service start` 不会刷新这些配置。
 
 | 目标 | 命令 | 预期结果 |
 | --- | --- | --- |
 | 查看服务 | `node bin/hermes-yaoyao.mjs service status` | `com.samien.hermes-yaoyao` 为 running |
 | 查看 Web | `curl --noproxy '*' --fail --silent http://127.0.0.1:8800/` | 返回夭夭 Web 页面 |
-| 查看 Dashboard | `curl --noproxy '*' --fail --silent http://127.0.0.1:9119/api/auth/providers` | 返回基础认证 provider |
+| 查看 Dashboard | `curl --noproxy '*' --fail --silent http://127.0.0.1:9119/api/status` | 查看 `auth_required`;`false` 表示本机令牌模式 |
 | 查看监听器 | `lsof -nP -iTCP:8800 -sTCP:LISTEN` 与 `lsof -nP -iTCP:9119 -sTCP:LISTEN` | 每个端口恰好一个监听进程 |
 | 查看受管日志 | `tail -f ~/Library/Logs/hermes-yaoyao.log` | 记录 Web 监听和 Dashboard 监督事件 |
 

+ 11 - 18
docs/agent-install.md

@@ -18,35 +18,28 @@ v0.2.28 增加服务端热点读取缓存与 30 秒上游登录状态检查复
 
 ## 网络默认值与局域网策略
 
-通过 `service install` 安装的受管服务默认监听可信局域网,两个端口一起开放:
+通过 `service install` 安装的受管服务默认只将 8800 开放到可信局域网:
 
 | 端口 | 服务 | 默认监听地址 | 开启局域网的责任边界 |
 | --- | --- | --- | --- |
-| `9119` | Hermes Dashboard | `0.0.0.0` | 由 8800 服务持续监督;沿用或创建独立服务认证后启动 |
+| `9119` | Hermes Dashboard | `127.0.0.1` | 缺失时在回环启动,不生成密码,不改动已有服务 |
 | `8800` | 夭夭 Web | `0.0.0.0` | LaunchAgent 显式写入可信局域网 HTTP 开关 |
 
-受管服务不支持“仅开放 8800”:安装后会同时以局域网地址启动 8800 和 9119。该默认值只适用于受管 macOS LaunchAgent;手动 `npm start` 和 Docker 部署仍使用各自的绑定配置。
+新启动的受管 9119 不跟随 8800 暴露到局域网。已有 9119 的绑定和认证保持原状;手动 `npm start` 和 Docker 部署仍使用各自的绑定配置。
 
-受管 `service install` 会覆盖遗留的 loopback 环境并保持局域网监听。若部署必须限制为仅本机访问,请使用手动 `npm start` 或 Docker 的回环绑定,不要使用受管 LaunchAgent。
+受管 `service install` 会覆盖 8800 遗留的 loopback 环境并保持 8800 局域网监听。若 8800 也必须仅本机访问,请使用手动 `npm start` 或 Docker 的回环绑定,不要使用受管 LaunchAgent。
 
 默认局域网模式会显式设置 `HERMES_YAOYAO_ALLOW_INSECURE_LAN=1`,只适合受信网络。跨不受信网络部署时必须设置 `HERMES_YAOYAO_TLS_CERT` 和 `HERMES_YAOYAO_TLS_KEY`,并使用防火墙或反向代理限制访问;`hermes dashboard --insecure` 不会关闭认证,不能将它当成局域网开关。
 
 ## 受管 9119 的默认认证与持续监督
 
-夭夭 Web LaunchAgent 默认启用 `HERMES_YAOYAO_SUPERVISE_DASHBOARD=1`。它每 5 秒检查本机 `9119`;端口未监听时会启动 Dashboard。首次发现 `dashboard.basic_auth` 未配置时,它会写入 8800 生成的服务账号并重启 Dashboard 载入配置:
+夭夭 Web LaunchAgent 默认启用 `HERMES_YAOYAO_SUPERVISE_DASHBOARD=1`。仅当上游为 `http://127.0.0.1:9119` 时,每 5 秒检查该端口;未监听时执行 `hermes dashboard --host 127.0.0.1 --no-open`。不再自动生成 9119 服务账号、密码或签名密钥;不会因安装检查而停止或重绑定已有 Dashboard。
 
-| 项目 | 默认值 |
-| --- | --- |
-| 用户名 | `yaoyao-service` |
-| 密码 | 8800 首次安装随机生成并加密保存 |
-| 密码存储 | `password_hash` scrypt 哈希,不保存明文 |
-| 会话签名密钥 | 首次配置时随机生成 |
+直连回环且 Hermes 声明 `auth_required=false` 时,8800 自动取得并验证 Hermes 原生会话令牌;REST 和 WebSocket 都使用该令牌,不是跳过鉴权。令牌只驻留服务端内存,重启失效后自动重新取得。详见 [本机授权边界](loopback-authorization.md)。
 
-8800 自己的默认管理员仍为 `admin/admin`,首次登录必须修改。它与上述 9119 服务
-账号相互独立。监督器只会填补缺失的 9119 用户名、密码和会话签名密钥,绝不会
-覆盖已有配置;已有或外部 9119 请在 8800“系统管理”中验证并保存服务凭据。
+8800 自己的默认管理员仍为 `admin/admin`,首次登录必须修改。已有或外部 9119 若仍声明 `auth_required=true`,必须在 8800“系统管理”中配置服务账号;已有账号和密码不会被删除,`--insecure` 也不会绕过该认证。
 
-如需在首次登录后替换默认密码,可用下列命令以 scrypt 哈希方式写入配置。不要将新密码放入命令历史、Git 或日志。
+以下仅用于需要账号鉴权的部署,不是本机安装的必做步骤。可用下列命令以 scrypt 哈希方式配置账号;不要将密码放入命令历史、Git 或日志。
 
 ```bash
 HERMES_AGENT_HOME="${HERMES_AGENT_HOME:-$HOME/.hermes/hermes-agent}"
@@ -195,11 +188,11 @@ hermes dashboard --status
 lsof -nP -iTCP:9119 -sTCP:LISTEN
 ```
 
-验收条件:Dashboard 状态正常,且只有一个 `9119` TCP 监听器。插件路由位于 `/api/plugins/yaoyao/`,该 API 需要现有 Hermes 登录会话;不要为了探测路由而关闭认证。独立运行的 Dashboard 默认保持本机监听;通过夭夭受管服务安装时则跟随 8800 默认监听局域网。
+验收条件:Dashboard 状态正常,且只有一个 `9119` TCP 监听器。插件路由位于 `/api/plugins/yaoyao/`,该 API 需要 Hermes 登录会话或原生本机令牌;不要为了探测路由而关闭认证。新启动的受管 Dashboard 默认只监听 `127.0.0.1`。
 
 ## 6. 可选:部署夭夭 Web
 
-夭夭 Web 默认使用端口 `8800` 并连接 Dashboard `9119`。通过 LaunchAgent 安装后,8800 与受监督的 9119 都默认监听 `0.0.0.0`;8800 停止或卸载时不会主动停止正在运行的 Dashboard。
+夭夭 Web 默认使用端口 `8800` 并连接 Dashboard `9119`。通过 LaunchAgent 安装后,8800 默认监听 `0.0.0.0`,新启动的 9119 只监听 `127.0.0.1`;8800 停止或卸载时不会主动停止正在运行的 Dashboard。
 
 ```bash
 npm ci
@@ -257,7 +250,7 @@ Git、发布目录、命令输出或日志。配置后检查系统管理中的 A
 
 升级任务由 8800 外部的独立 updater 执行。运行文件会安装到
 `~/.local/share/hermes-yaoyao/releases/`,LaunchAgent 改为指向稳定的
-`current` 符号链接。切换期间 8800 和 9119 会短暂不可用,任务状态保存在
+`current` 符号链接。切换期间 8800 会短暂不可用,不因 Web 升级重启 9119;任务状态保存在
 `~/.hermes-yaoyao/updates/`,新服务启动后可继续读取。
 
 默认只能从目标 Mac 本机触发“升级”和“回滚”;仅当用户明确允许远程系统管理

+ 29 - 0
docs/loopback-authorization.md

@@ -0,0 +1,29 @@
+# 8800 到 9119 的本机授权
+
+本机 Web 安装不再要求预先配置 9119 用户名密码,也不生成或写入 Dashboard 账号。
+8800 的用户登录、权限、CSRF、设备 scope 与 HTTP+SSE 接口保持不变。
+
+## 自动选择授权方式
+
+- 目标是字面回环地址 `127.0.0.1` 或 `::1`,且 `/api/status` 明确声明 `auth_required=false`:取得 Hermes 首页提供的原生临时会话令牌,验证后仅保存在服务端内存。REST 使用 `X-Hermes-Session-Token`,仅内部上游 WebSocket 使用 Hermes 规定的 `token` 参数。
+- 上游声明需要账号鉴权:继续使用管理员保存的独立服务账号和 Cookie;WebSocket 使用一次性 ticket。即使目标是 127.0.0.1,也不会绕过账号门禁。
+- 远程上游声明免账号:拒绝连接。`localhost`、Docker 别名、局域网 IP、DNS 解析结果、客户端地址和转发头不作为回环免账号授权依据。
+
+真实回环请求使用直接 HTTP/HTTPS agent,不走环境变量代理,不跟随重定向;令牌不放入下游 URL、不写磁盘、不输出日志或返回浏览器/iOS。服务重启后令牌轮换,REST 401 或新 WebSocket 连接会重新验证并合并刷新;超时和 5xx 不自动重放命令。
+
+## 安装与已有部署
+
+仅配置为 `http://127.0.0.1:9119` 的受管实例会启动本机 Dashboard。9119 未运行时,以 `--host 127.0.0.1` 启动;已运行则保持不变,不自动删除已有账号、重启、修改监听或放宽认证。8800 仍是客户端唯一入口。
+
+已有 LAN 监听或 `dashboard.public_url` 等设置可能使 Hermes 继续要求账号鉴权;这种情况下需要服务账号,不能仅凭“客户端与服务在同一台机器”免密。Docker 桥接访问宿主机也不属于本容器回环;维持远程账号鉴权。
+
+本机令牌是服务端授权,不是可分发的设备凭据。现有子节点配对仍使用独立、可撤销的上游会话;不会复制或提升为本机服务令牌。普通 iOS/Web 账号登录 8800 不受此限制。
+
+本次不会修改 9119 源码或现有配置,旧客户端 WebSocket 入口仍不兼容。
+
+## 回归验证
+
+`npm test` 包含本机令牌、账号门禁、代理/重定向、令牌轮换、CSRF 与监督器测试。
+`npm run test:e2e` 使用账号鉴权模拟上游;`FAKE_HERMES_LOCAL_AUTH=1 npm run test:e2e`
+对同一组浏览器流程使用无账号、强制原生令牌的模拟上游。运行前确保 18801/19119
+未被其他测试实例占用,避免复用到错误授权模式;这些端口与实际 8800/9119 无关。

+ 2 - 0
src/client/components/app/SystemManagementPanel.vue

@@ -425,6 +425,8 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
       </div>
       <p v-if="connectionStatus?.authMode === 'loopback-token'" class="connection-mode-note">9119 使用本机临时 Token,8800 会自动读取并在 9119 重启后刷新;Token 不会在设置中显示。</p>
       <p v-else-if="connectionStatus?.authMode === 'loopback-direct'" class="connection-mode-note">当前 9119 允许本机直接访问,不需要账号或 Token;该兼容模式只允许回环地址使用。</p>
+      <p class="network-description">直连本机 127.0.0.1 或 ::1 且 Hermes 启用本机授权时,自动使用临时会话令牌,无需配置用户名和密码。远程上游或已启用账号鉴权的 9119 仍需服务账号;8800 登录保护保持不变,不会自动修改 Hermes 账号配置。</p>
+      <h4 v-if="passwordAuthentication">服务账号(按需配置)</h4>
       <form v-if="passwordAuthentication" @submit.prevent="saveUpstream">
         <label><span>9119 用户名</span><input v-model="upstreamUsername" name="upstream-username" autocomplete="off" :disabled="busy" /></label>
         <label><span>9119 密码</span><input v-model="upstreamPassword" name="upstream-password" type="password" autocomplete="new-password" :disabled="busy" /></label>

+ 1 - 4
src/server/app.ts

@@ -123,9 +123,6 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const uploads = options.uploads ?? new UploadStore(config.home)
   const updates = options.updates ?? new SystemUpdateManager(config)
   const auth = options.auth ?? new LocalAuthStore(config.home, Boolean(config.tlsCert))
-  if (config.superviseDashboard && !config.upstreamUsername && !config.upstreamPassword) {
-    auth.ensureUpstreamCredentials()
-  }
   const configuredUpstreamCredentials = config.upstreamUsername && config.upstreamPassword
     ? { username: config.upstreamUsername, password: config.upstreamPassword }
     : undefined
@@ -347,7 +344,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     chatPushJobs,
     groupPushEvents,
     close: () => {
-      upstream.readCache.close()
+      upstream.close()
       realtime.close()
       chatPushJobs.stop()
       groupPushEvents.stop()

+ 4 - 89
src/server/dashboardSupervisor.ts

@@ -1,11 +1,6 @@
 import { execFileSync, spawn } from 'node:child_process'
-import { randomBytes, scryptSync } from 'node:crypto'
 import { createConnection } from 'node:net'
-import { networkInterfaces } from 'node:os'
-import { isPrivateHost } from './config.js'
 
-export const DEFAULT_DASHBOARD_USERNAME = 'admin'
-export const DEFAULT_DASHBOARD_PASSWORD = 'admin'
 const DASHBOARD_PORT = 9119
 const SUPERVISION_INTERVAL_MS = 5_000
 const RESTART_READY_TIMEOUT_MS = 15_000
@@ -15,16 +10,12 @@ type Launch = (args: readonly string[]) => void
 type Probe = (host: string, port: number) => Promise<boolean>
 
 export interface DashboardSupervisorOptions {
-  allowLan: boolean
   command?: string
   intervalMs?: number
   run?: Run
   launch?: Launch
   probe?: Probe
-  lanProbeHost?: string
-  resolveLanProbeHost?: () => string | undefined
   log?: (message: string) => void
-  credentials?: { username: string; password: string }
 }
 
 function defaultRun(command: string): Run {
@@ -53,57 +44,29 @@ function portIsListening(host: string, port: number): Promise<boolean> {
   })
 }
 
-function privateLanAddress(): string | undefined {
-  for (const addresses of Object.values(networkInterfaces())) {
-    for (const address of addresses ?? []) {
-      if (address.family === 'IPv4' && !address.internal && isPrivateHost(address.address)) {
-        return address.address
-      }
-    }
-  }
-  return undefined
-}
-
 function delay(milliseconds: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, milliseconds))
 }
 
-function passwordHash(password: string): string {
-  const salt = randomBytes(16)
-  const derived = scryptSync(password, salt, 32, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 })
-  return `scrypt$16384$8$1$${salt.toString('base64')}$${derived.toString('base64')}`
-}
-
 /** Keeps the local Hermes Dashboard available for the managed 8800 service. */
 export class DashboardSupervisor {
   private readonly command: string
-  private readonly dashboardHost: string
+  private readonly dashboardHost = '127.0.0.1'
   private readonly run: Run
   private readonly launch: Launch
   private readonly probe: Probe
-  private readonly allowLan: boolean
-  private readonly resolveLanProbeHost: () => string | undefined
   private readonly log: (message: string) => void
   private readonly intervalMs: number
-  private readonly credentials: { username: string; password: string }
   private timer: NodeJS.Timeout | undefined
   private checking = false
 
   constructor(options: DashboardSupervisorOptions) {
     this.command = options.command ?? 'hermes'
-    this.allowLan = options.allowLan
-    this.dashboardHost = options.allowLan ? '0.0.0.0' : '127.0.0.1'
     this.run = options.run ?? defaultRun(this.command)
     this.launch = options.launch ?? defaultLaunch(this.command)
     this.probe = options.probe ?? portIsListening
-    this.resolveLanProbeHost = options.resolveLanProbeHost
-      ?? (() => options.lanProbeHost ?? privateLanAddress())
     this.log = options.log ?? console.info
     this.intervalMs = options.intervalMs ?? SUPERVISION_INTERVAL_MS
-    this.credentials = options.credentials ?? {
-      username: DEFAULT_DASHBOARD_USERNAME,
-      password: DEFAULT_DASHBOARD_PASSWORD,
-    }
   }
 
   start(): void {
@@ -133,17 +96,9 @@ export class DashboardSupervisor {
     if (this.checking) return
     this.checking = true
     try {
-      const credentialsChanged = this.ensureBasicAuthentication()
-      let running = await this.probe('127.0.0.1', DASHBOARD_PORT)
-      const bindingChanged = running && !(await this.bindingMatches())
-      if ((credentialsChanged || bindingChanged) && running) {
-        const reason = credentialsChanged
-          ? 'authentication was configured'
-          : `listener must move to ${this.dashboardHost}`
-        this.log(`Hermes Dashboard ${reason}; restarting 9119 to load it.`)
-        this.run(['dashboard', '--stop'])
-        running = await this.probe('127.0.0.1', DASHBOARD_PORT)
-      }
+      // Installation must not rewrite credentials or restart/rebind an existing
+      // upstream. Only a missing managed service is started on loopback.
+      const running = await this.probe('127.0.0.1', DASHBOARD_PORT)
       if (running) return
       this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
       this.launch(['dashboard', '--host', this.dashboardHost, '--no-open'])
@@ -154,44 +109,4 @@ export class DashboardSupervisor {
     }
   }
 
-  private async bindingMatches(): Promise<boolean> {
-    const lanProbeHost = this.resolveLanProbeHost()
-    if (!lanProbeHost) return true
-    const listeningOnLan = await this.probe(lanProbeHost, DASHBOARD_PORT)
-    return this.allowLan ? listeningOnLan : !listeningOnLan
-  }
-
-  private value(key: string): string {
-    try {
-      return this.run(['config', 'get', key]).trim()
-    } catch {
-      return ''
-    }
-  }
-
-  private set(key: string, value: string): void {
-    this.run(['config', 'set', key, value])
-  }
-
-  private ensureBasicAuthentication(): boolean {
-    const username = this.value('dashboard.basic_auth.username')
-    const configuredPasswordHash = this.value('dashboard.basic_auth.password_hash')
-    const password = this.value('dashboard.basic_auth.password')
-    const secret = this.value('dashboard.basic_auth.secret')
-    let changed = false
-
-    if (!username) {
-      this.set('dashboard.basic_auth.username', this.credentials.username)
-      changed = true
-    }
-    if (!configuredPasswordHash && !password) {
-      this.set('dashboard.basic_auth.password_hash', passwordHash(this.credentials.password))
-      changed = true
-    }
-    if (!secret) {
-      this.set('dashboard.basic_auth.secret', randomBytes(32).toString('base64url'))
-      changed = true
-    }
-    return changed
-  }
 }

+ 5 - 5
src/server/index.ts

@@ -3,18 +3,18 @@ import { resolve } from 'node:path'
 import send from 'koa-send'
 import serve from 'koa-static'
 import { createApplication, createNodeServer } from './app.js'
-import { isLoopbackHost, loadServerConfig } from './config.js'
+import { loadServerConfig } from './config.js'
 import { DashboardSupervisor } from './dashboardSupervisor.js'
+import { isLocalAuthorizationTarget } from './loopbackAuthorization.js'
 
 const config = loadServerConfig()
 const runtime = createApplication({
   config,
 })
 const dashboardSupervisor = config.superviseDashboard
-  ? new DashboardSupervisor({
-      allowLan: !isLoopbackHost(config.host),
-      credentials: runtime.auth.upstreamCredentials(),
-    })
+  && isLocalAuthorizationTarget(config.upstream) && config.upstream.hostname === '127.0.0.1'
+  && config.upstream.protocol === 'http:' && config.upstream.port === '9119'
+  ? new DashboardSupervisor({})
   : undefined
 const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined

+ 86 - 24
src/server/localAuth.ts

@@ -20,6 +20,7 @@ import { parse, serialize } from 'cookie'
 import { HttpError } from './errors.js'
 import { appendSetCookies } from './security.js'
 import { CookieJar, type UpstreamRequestOptions, type UpstreamResponse, UpstreamClient } from './upstream.js'
+import { allowsLocalAuthorization, isLocalAuthorizationTarget, localSessionToken } from './loopbackAuthorization.js'
 
 const SESSION_COOKIE = 'hermes_yaoyao_session'
 const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
@@ -302,17 +303,6 @@ export class LocalAuthStore {
     this.#writeUpstreamCredentials(credentials)
   }
 
-  ensureUpstreamCredentials(): UpstreamCredentials {
-    const existing = this.upstreamCredentials()
-    if (existing) return existing
-    const credentials = {
-      username: 'yaoyao-service',
-      password: randomBytes(32).toString('base64url'),
-    }
-    this.#writeUpstreamCredentials(credentials)
-    return credentials
-  }
-
   #writeUpstreamCredentials(credentials: UpstreamCredentials): void {
     const username = canonicalUsername(credentials.username)
     const password = validatePassword(credentials.password, true)
@@ -455,6 +445,7 @@ export class UpstreamServiceSession {
   #generation = 0
   #authMode: 'unknown' | 'loopback-token' | 'loopback-direct' | 'password' = 'unknown'
   #lastVerifiedAt: number | undefined
+  #localAuthorization = false
   readonly #now: () => number
   readonly #validationTTL: number
 
@@ -468,11 +459,40 @@ export class UpstreamServiceSession {
     client.setReauthenticationHandler(this.#jar, async () => {
       this.#syncCredentials()
       this.#validatedUntil = 0
-      await this.ensure()
+      await this.#renewAuthorization(this.#generation)
     })
   }
 
   get jar(): CookieJar { return this.#jar }
+  get authorizationMode(): 'local' | 'account' { return this.#localAuthorization ? 'local' : 'account' }
+
+  async webSocketCredential(): Promise<{ name: 'token' | 'ticket'; value: string }> {
+    await this.ensure()
+    if (this.#localAuthorization) {
+      if (!this.#jar.sessionToken) await this.#renewAuthorization(this.#generation)
+      else {
+        // A restarted Hermes rotates its process token even within our REST
+        // validation lease. Validate before a new socket; a 401 renews once.
+        const check = await this.client.request('/api/profiles', this.#jar, { cache: 'reload' })
+        if (check.status !== 200) throw new HttpError(502, 'Hermes 本机会话令牌验证失败', 'local_authorization_rejected')
+      }
+      if (this.#localAuthorization && this.#jar.sessionToken) return { name: 'token', value: this.#jar.sessionToken }
+    }
+    let response = await this.request('/api/auth/ws-ticket', { method: 'POST' })
+    // Auth endpoints are excluded from generic request retries. Repair an
+    // expired account cookie or a restarted upstream's changed auth mode here.
+    if (response.status === 401) {
+      await this.#renewAuthorization(this.#generation)
+      if (this.#localAuthorization && this.#jar.sessionToken) return { name: 'token', value: this.#jar.sessionToken }
+      response = await this.request('/api/auth/ws-ticket', { method: 'POST' })
+    }
+    if (this.#localAuthorization && this.#jar.sessionToken) return { name: 'token', value: this.#jar.sessionToken }
+    if (response.status < 200 || response.status >= 300) throw new HttpError(502, 'Hermes 未签发实时连接凭据', 'upstream_auth_failed')
+    let value: unknown
+    try { value = JSON.parse(response.body.toString()).ticket } catch { /* fail closed */ }
+    if (typeof value !== 'string' || !value.trim()) throw new HttpError(502, 'Hermes 返回了空实时连接凭据', 'invalid_ticket')
+    return { name: 'ticket', value: value.trim() }
+  }
 
   connectionInfo(): {
     endpoint: string
@@ -511,6 +531,8 @@ export class UpstreamServiceSession {
       this.#validatedUntil = 0; this.#ensureTask = undefined; this.#loginTask = undefined
       this.#jar.replace(new CookieJar())
       this.#authMode = 'unknown'
+      this.#localAuthorization = false
+      this.#lastVerifiedAt = undefined
       this.client.invalidateReads()
     }
     this.#credentialFingerprint = fingerprint
@@ -525,22 +547,25 @@ export class UpstreamServiceSession {
     // Probe into a temporary jar. A delayed old probe must not overwrite freshly
     // rotated service credentials/cookies in the live jar.
     const probe = new CookieJar(this.#jar.header)
+    probe.setSessionToken(this.#jar.sessionToken)
     const status = await this.client.request('/api/status', probe)
     if (status.status < 200 || status.status >= 300) throw new HttpError(502, '9119 状态不可用', 'upstream_unavailable')
     const parsed = JSON.parse(status.body.toString('utf8')) as Record<string, unknown>
     this.#checkGeneration(generation)
-    if (parsed.auth_required !== true && parsed.authRequired !== true) {
-      if (!this.#isLoopbackUpstream()) {
-        throw new HttpError(502, '9119 未启用认证,且不是本机回环地址', 'upstream_auth_unsafe')
-      }
-      this.#authMode = await this.#loadLoopbackToken(probe)
+    if (allowsLocalAuthorization(parsed)) {
+      this.#requireLocalTarget()
+      const authMode = await this.#loadLoopbackToken(probe)
         ? 'loopback-token'
         : 'loopback-direct'
       this.#checkGeneration(generation)
+      this.#authMode = authMode
+      this.#localAuthorization = true
       this.#jar.replace(probe)
       return
     }
     this.#authMode = 'password'
+    this.#localAuthorization = false
+    probe.setSessionToken(undefined)
     const me = await this.client.request('/api/auth/me', probe)
     this.#checkGeneration(generation)
     if (me.status >= 200 && me.status < 300) {
@@ -563,19 +588,54 @@ export class UpstreamServiceSession {
   async #login(generation: number): Promise<void> {
     if (this.#loginTask) return this.#loginTask
     const credentials = this.credentials()
-    if (!credentials) throw new HttpError(503, '尚未配置 9119 服务账号', 'upstream_credentials_required')
+    if (!credentials) throw new HttpError(503, '9119 当前启用了账号鉴权,请配置服务账号;本机授权需由回环 9119 明确启用', 'upstream_credentials_required')
     const jar = new CookieJar()
     const task = this.#authenticate({ ...credentials }, jar).then(() => {
       this.#checkGeneration(generation)
       this.#jar.replace(jar)
       this.#authMode = 'password'
+      this.#localAuthorization = false
       this.client.invalidateReads()
-      this.#validatedUntil = this.#now() + this.#validationTTL
+      this.#lastVerifiedAt = this.#now()
+      this.#validatedUntil = this.#lastVerifiedAt + this.#validationTTL
     }).finally(() => { if (this.#loginTask === task) this.#loginTask = undefined })
     this.#loginTask = task
     return task
   }
 
+  #requireLocalTarget(): void {
+    if (!isLocalAuthorizationTarget(this.client.baseURL)) throw new HttpError(403, '免账号本机授权仅支持直连 127.0.0.1 或 ::1;远程上游必须启用鉴权', 'loopback_auth_required')
+  }
+
+  async #renewAuthorization(generation: number): Promise<void> {
+    if (this.#loginTask) return this.#loginTask
+    const probe = new CookieJar()
+    const status = await this.client.request('/api/status', probe)
+    if (status.status !== 200) throw new HttpError(502, '无法检查 9119 授权模式', 'upstream_auth_unavailable')
+    const mode = JSON.parse(status.body.toString()) as Record<string, unknown>
+    this.#checkGeneration(generation)
+    if (this.#loginTask) return this.#loginTask
+    if (!allowsLocalAuthorization(mode)) return this.#login(generation)
+    this.#requireLocalTarget()
+    const task = (async () => {
+      const page = await this.client.request('/', probe, { maxResponseBytes: 2 * 1024 * 1024, cache: 'reload' })
+      if (page.status !== 200) throw new HttpError(502, 'Hermes 本机授权入口不可用', 'local_authorization_unavailable')
+      const token = localSessionToken(page.body.toString('utf8'))
+      if (!token) throw new HttpError(502, 'Hermes 未提供本机会话令牌;不会降级或自动修改账号配置', 'local_authorization_unavailable')
+      probe.setSessionToken(token)
+      const check = await this.client.request('/api/profiles', probe, { cache: 'reload' })
+      if (check.status !== 200) throw new HttpError(502, 'Hermes 本机会话令牌验证失败', 'local_authorization_rejected')
+      this.#checkGeneration(generation)
+      this.#jar.replace(probe); this.#localAuthorization = true
+      this.#authMode = 'loopback-token'
+      this.client.invalidateReads()
+      this.#lastVerifiedAt = this.#now()
+      this.#validatedUntil = this.#lastVerifiedAt + this.#validationTTL
+    })().finally(() => { if (this.#loginTask === task) this.#loginTask = undefined })
+    this.#loginTask = task
+    return task
+  }
+
   async #authenticate(credentials: UpstreamCredentials, jar: CookieJar): Promise<void> {
     const providers = await this.client.request('/api/auth/providers', jar)
     if (providers.status < 200 || providers.status >= 300) throw new HttpError(502, '无法读取 9119 登录方式', 'upstream_auth_unavailable')
@@ -594,17 +654,19 @@ export class UpstreamServiceSession {
   }
 
   async #loadLoopbackToken(jar: CookieJar): Promise<boolean> {
+    this.#requireLocalTarget()
+    jar.setSessionToken(undefined)
     const response = await this.client.request('/', jar, {
       headers: { accept: 'text/html' },
       maxResponseBytes: 2 * 1_024 * 1_024,
+      cache: 'reload',
     })
     if (response.status < 200 || response.status >= 300) return false
-    const html = response.body.toString('utf8')
-    const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(html)
-    let token: unknown
-    try { token = match ? JSON.parse(match[1]!) : undefined } catch { token = undefined }
-    if (typeof token !== 'string' || token.length < 16 || token.length > 4_096) return false
+    const token = localSessionToken(response.body.toString('utf8'))
+    if (!token) return false
     jar.setSessionToken(token)
+    const check = await this.client.request('/api/profiles', jar, { cache: 'reload' })
+    if (check.status !== 200) throw new HttpError(502, 'Hermes 本机会话令牌验证失败', 'local_authorization_rejected')
     return true
   }
 }

+ 56 - 0
src/server/loopbackAuthorization.ts

@@ -0,0 +1,56 @@
+import { Agent as HTTPAgent, request as httpRequest } from 'node:http'
+import { Agent as HTTPSAgent, request as httpsRequest } from 'node:https'
+import { Readable } from 'node:stream'
+
+/** Literal loopback only: never treat forwarded headers, LAN addresses, Docker
+ * hostnames or a hostname resolving to localhost as permission to acquire a token. */
+export function isLocalAuthorizationTarget(url: URL): boolean {
+  return ['http:', 'https:'].includes(url.protocol)
+    && ['127.0.0.1', '[::1]', '::1'].includes(url.hostname)
+    && !url.username && !url.password
+}
+
+export function localSessionToken(html: string): string | undefined {
+  const value = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(html)?.[1]
+  if (!value) return undefined
+  try {
+    const token: unknown = JSON.parse(value)
+    return typeof token === 'string' && /^[A-Za-z0-9_-]{20,4096}$/.test(token) ? token : undefined
+  } catch { return undefined }
+}
+
+export function allowsLocalAuthorization(status: Record<string, unknown>): boolean {
+  return (status.auth_required === false || status.authRequired === false)
+    && status.auth_required !== true && status.authRequired !== true
+}
+
+/** Explicit agents do not inherit proxy environment settings. Loopback tokens
+ * must not travel through an HTTP_PROXY configured for unrelated remote traffic. */
+export class LoopbackTransport {
+  readonly httpAgent = new HTTPAgent({ keepAlive: true, proxyEnv: {} })
+  readonly httpsAgent = new HTTPSAgent({ keepAlive: true, proxyEnv: {} })
+  agent(url: URL): HTTPAgent | HTTPSAgent { return url.protocol === 'https:' || url.protocol === 'wss:' ? this.httpsAgent : this.httpAgent }
+
+  async fetch(url: URL, init: RequestInit): Promise<Response> {
+    if (!isLocalAuthorizationTarget(url)) throw new Error('Loopback transport requires a literal loopback URL')
+    const source = new Request(url, init)
+    const body = source.body ? Buffer.from(await source.arrayBuffer()) : undefined
+    return new Promise((resolve, reject) => {
+      const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
+        method: source.method, headers: Object.fromEntries(source.headers.entries()),
+        agent: this.agent(url), signal: source.signal,
+      }, response => {
+        const headers = new Headers()
+        for (let i = 0; i < response.rawHeaders.length; i += 2) headers.append(response.rawHeaders[i]!, response.rawHeaders[i + 1]!)
+        const empty = source.method === 'HEAD' || [204, 205, 304].includes(response.statusCode ?? 200)
+        if (empty) response.resume()
+        resolve(new Response(empty ? null : Readable.toWeb(response) as ReadableStream<Uint8Array>, {
+          status: response.statusCode ?? 502, headers,
+        }))
+      })
+      request.once('error', reject)
+      request.end(body)
+    })
+  }
+  close(): void { this.httpAgent.destroy(); this.httpsAgent.destroy() }
+}

+ 3 - 10
src/server/profileIdentities.ts

@@ -29,15 +29,7 @@ export class UpstreamProfileIdentityService {
   ) {}
 
   async load(): Promise<RemoteProfileIdentity[]> {
-    const ticketResponse = await this.upstream.request('/api/auth/ws-ticket', {
-      method: 'POST',
-    })
-    if (ticketResponse.status < 200 || ticketResponse.status >= 300) {
-      throw new HttpError(502, 'Hermes 未签发身份读取凭据', 'profile_identity_ticket_failed')
-    }
-    let ticket = ''
-    try { ticket = String(JSON.parse(ticketResponse.body.toString('utf8')).ticket ?? '') } catch { /* handled below */ }
-    if (!ticket) throw new HttpError(502, 'Hermes 返回了空身份读取凭据', 'profile_identity_ticket_failed')
+    const credential = await this.upstream.webSocketCredential()
 
     const url = new URL(this.config.upstream)
     url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -45,10 +37,11 @@ export class UpstreamProfileIdentityService {
       ? '' : this.config.upstream.pathname.replace(/\/$/, '')
     url.pathname = `${prefix}/api/ws`
     url.search = ''
-    url.searchParams.set('ticket', ticket)
+    url.searchParams.set(credential.name, credential.value)
 
     return new Promise<RemoteProfileIdentity[]>((resolve, reject) => {
       const socket = new WebSocket(url, {
+        agent: this.upstream.client.directAgent,
         headers: { Origin: this.config.upstream.origin },
         handshakeTimeout: 15_000,
         maxPayload: 36 * 1_024 * 1_024,

+ 6 - 14
src/server/pushEvents.ts

@@ -1195,20 +1195,16 @@ export class HermesChatPushJobWatcher {
         transport.start()
         return
       }
-      const ticketResponse = await this.upstreamSession.request('/api/auth/ws-ticket', { method: 'POST' })
-      if (ticketResponse.status < 200 || ticketResponse.status >= 300) {
-        throw new Error(`Hermes WebSocket ticket failed (${ticketResponse.status})`)
-      }
-      const ticket = string(parseUpstreamJSON(ticketResponse.body).ticket)
-      if (!ticket) throw new Error('Hermes returned an empty WebSocket ticket')
+      const credential = await this.upstreamSession.webSocketCredential()
       if (!this.#running || generation !== this.#generation) return
       const url = new URL(this.config.upstream)
       url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
       const prefix = this.config.upstream.pathname === '/' ? '' : this.config.upstream.pathname.replace(/\/$/, '')
       url.pathname = `${prefix}/api/ws`
       url.search = ''
-      url.searchParams.set('ticket', ticket)
+      url.searchParams.set(credential.name, credential.value)
       const socket = new WebSocket(url, {
+        agent: this.upstreamSession.client.directAgent,
         headers: { Origin: this.config.upstream.origin },
         maxPayload: 36 * 1_024 * 1_024,
         handshakeTimeout: 15_000,
@@ -1534,23 +1530,19 @@ export class HermesGroupEventSource implements GroupEventSource {
     anchor: GroupWatchAnchor,
     handlers: { onEnvelope(envelope: GroupEventEnvelope): void; onClose(error?: Error): void },
   ): Promise<GroupEventConnection> {
-    const ticketResponse = await this.upstreamSession.request('/api/auth/ws-ticket', { method: 'POST' })
-    if (ticketResponse.status < 200 || ticketResponse.status >= 300) {
-      throw new Error(`Hermes WebSocket ticket failed (${ticketResponse.status})`)
-    }
-    const ticket = string(parseUpstreamJSON(ticketResponse.body).ticket)
-    if (!ticket) throw new Error('Hermes returned an empty WebSocket ticket')
+    const credential = await this.upstreamSession.webSocketCredential()
     const url = new URL(this.config.upstream)
     url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
     const prefix = this.config.upstream.pathname === '/' ? '' : this.config.upstream.pathname.replace(/\/$/, '')
     url.pathname = `${prefix}/api/plugins/yaoyao/v1/events`
     url.search = ''
-    url.searchParams.set('ticket', ticket)
+    url.searchParams.set(credential.name, credential.value)
     url.searchParams.set('epoch', anchor.epoch)
     url.searchParams.set('cursor', String(anchor.cursor))
 
     return await new Promise<GroupEventConnection>((resolve, reject) => {
       const socket = new WebSocket(url, {
+        agent: this.upstreamSession.client.directAgent,
         headers: { Origin: this.config.upstream.origin },
         maxPayload: 2 * 1_024 * 1_024,
         handshakeTimeout: 15_000,

+ 15 - 11
src/server/realtimeApi.ts

@@ -54,20 +54,25 @@ export class RealtimeAPI {
     }
     return {
       key, instanceKey: this.config.upstream.href, upstreamKey: `${this.config.upstream.href}:${device ? `device:${device}` : 'service'}`, paired: Boolean(device), valid, authorize,
+      agent: this.upstream.directAgent,
       observeCommand: f => observer?.observeClientFrame(f),
       observeEvent: f => observer?.observeUpstreamFrame(Buffer.from(f), false),
       url: async (kind, anchor) => {
         if (device && !valid()) throw new HttpError(401, 'Device revoked', 'authentication_required')
-        const response = jar
-          ? await this.upstream.request('/api/auth/ws-ticket', jar, { method: 'POST' })
-          : await this.session.request('/api/auth/ws-ticket', { method: 'POST' })
-        if (response.status !== 200) throw new HttpError(502, 'Upstream authentication failed', 'upstream_auth_failed')
-        const ticket = JSON.parse(response.body.toString()).ticket
-        if (typeof ticket !== 'string' || !ticket) throw new HttpError(502, 'Invalid upstream ticket', 'invalid_ticket')
+        // Paired devices retain their delegated identity; never upgrade a device
+        // cookie into the local service's loopback authorization.
+        let credential: { name: string; value: string }
+        if (jar) {
+          const response = await this.upstream.request('/api/auth/ws-ticket', jar, { method: 'POST' })
+          if (response.status !== 200) throw new HttpError(502, 'Upstream authentication failed', 'upstream_auth_failed')
+          const ticket = JSON.parse(response.body.toString()).ticket
+          if (typeof ticket !== 'string' || !ticket) throw new HttpError(502, 'Invalid upstream ticket', 'invalid_ticket')
+          credential = { name: 'ticket', value: ticket }
+        } else credential = await this.session.webSocketCredential()
         const url = new URL(this.config.upstream)
         url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
         url.pathname = `${url.pathname.replace(/\/$/, '')}/api/${kind === 'chat' ? 'ws' : 'plugins/yaoyao/v1/events'}`
-        url.search = ''; url.searchParams.set('ticket', ticket)
+        url.search = ''; url.searchParams.set(credential.name, credential.value)
         if (anchor) { url.searchParams.set('epoch', anchor.epoch); url.searchParams.set('cursor', String(anchor.cursor)) }
         return url
       },
@@ -78,15 +83,14 @@ export class RealtimeAPI {
       key: `push:${job.localUserID}:${this.auth.pushAuthorizationVersion(job.localUserID)}`,
       upstreamKey: `${this.config.upstream.href}:service`, paired: false,
       instanceKey: this.config.upstream.href,
+      agent: this.upstream.directAgent,
       valid: () => this.auth.isUserActive(job.localUserID),
       url: async () => {
-        const response = await this.session.request('/api/auth/ws-ticket', { method: 'POST' })
-        const ticket = JSON.parse(response.body.toString()).ticket
-        if (response.status !== 200 || !ticket) throw new Error('Upstream authentication failed')
+        const credential = await this.session.webSocketCredential()
         const url = new URL(this.config.upstream)
         url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
         url.pathname = `${url.pathname.replace(/\/$/, '')}/api/ws`
-        url.search = ''; url.searchParams.set('ticket', ticket)
+        url.search = ''; url.searchParams.set(credential.name, credential.value)
         return url
       },
     }

+ 3 - 2
src/server/realtimeBroker.ts

@@ -11,6 +11,7 @@ export interface RealtimePrincipal {
   upstreamKey: string
   instanceKey?: string
   paired: boolean
+  agent?: WebSocket.ClientOptions['agent']
   valid(): boolean
   authorize?(kind: 'chat' | 'groups', method?: string): void
   url(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<URL>
@@ -260,7 +261,7 @@ export class RealtimeBroker {
       const url = await u.principal.url('chat')
       if (this.closed) throw new Error('Broker closed')
       await new Promise<void>((resolve, reject) => {
-        const ws = new WebSocket(url, { headers: { Origin: `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}` }, maxPayload: CHAT_MAX_PAYLOAD, handshakeTimeout: 15_000 })
+        const ws = new WebSocket(url, { agent: u.principal.agent, headers: { Origin: `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}` }, maxPayload: CHAT_MAX_PAYLOAD, handshakeTimeout: 15_000 })
         u.socket = ws
         const timeout = setTimeout(() => { reject(new Error('Upstream ready timeout')); ws.terminate() }, 20_000)
         let ready = false
@@ -383,7 +384,7 @@ export class RealtimeBroker {
     const url = await c.principal.url('groups', c.group)
     const validator = new GroupFrameValidator(c.group!.epoch, c.group!.cursor)
     await new Promise<void>((resolve, reject) => {
-      const ws = new WebSocket(url, { headers: { Origin: `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}` }, maxPayload: 2 * 1024 * 1024, handshakeTimeout: 15_000 })
+      const ws = new WebSocket(url, { agent: c.principal.agent, headers: { Origin: `${url.protocol === 'wss:' ? 'https:' : 'http:'}//${url.host}` }, maxPayload: 2 * 1024 * 1024, handshakeTimeout: 15_000 })
       c.socket = ws
       const timer = setTimeout(() => { ws.terminate(); reject(new Error('Group ready timeout')) }, 20_000)
       ws.on('message', (data, binary) => {

+ 42 - 9
src/server/upstream.ts

@@ -1,6 +1,7 @@
 import type Koa from 'koa'
 import { createHash } from 'node:crypto'
 import { readCacheContext, readPolicy, mutationTags, UpstreamReadCache } from './upstreamReadCache.js'
+import { isLocalAuthorizationTarget, LoopbackTransport } from './loopbackAuthorization.js'
 import { parse } from 'cookie'
 import { HttpError } from './errors.js'
 import { appendSetCookies, CSRF_COOKIE } from './security.js'
@@ -115,6 +116,7 @@ export interface UpstreamRequestOptions {
 
 export class UpstreamClient {
   readonly readCache: UpstreamReadCache
+  private loopback = new LoopbackTransport()
   private reauthentication = new WeakMap<CookieJar, () => Promise<void>>()
   constructor(
     readonly baseURL: URL,
@@ -122,6 +124,8 @@ export class UpstreamClient {
     readonly publicSecure = false,
     now: () => number = Date.now,
   ) { this.readCache = new UpstreamReadCache(now) }
+  get directAgent() { return isLocalAuthorizationTarget(this.baseURL) ? this.loopback.agent(this.baseURL) : undefined }
+  close(): void { this.readCache.close(); this.loopback.close() }
 
   withReadScope<T>(scope: string, fresh: boolean, run: () => T): T {
     return readCacheContext.run({ scope, fresh }, run)
@@ -155,14 +159,14 @@ export class UpstreamClient {
     const forcedTags = method === 'GET' && (context?.fresh || options.cache === 'reload') ? readPolicy(path, options.search)?.tags : undefined
     if (forcedTags) this.invalidateReads(forcedTags)
     const load = async () => {
-      const sentCookies = jar.header
+      const sentCredentials = JSON.stringify([jar.header, jar.sessionToken])
       let response = await this.fetchResponse(path, jar, options)
       if (response.status === 401 || response.status === 403) this.invalidateReads()
       const renew = this.reauthentication.get(jar)
       if (response.status === 401 && renew && !path.startsWith('/api/auth/') && !path.startsWith('/auth/')) {
         // A parallel request may already have rotated the service cookies. Never replay
         // a whole route handler (it may have performed several writes); repair this one 401 only.
-        if (jar.header === sentCookies) await renew()
+        if (JSON.stringify([jar.header, jar.sessionToken]) === sentCredentials) await renew()
         response = await this.fetchResponse(path, jar, options)
       }
       return response
@@ -176,7 +180,7 @@ export class UpstreamClient {
       if (policy && context) {
         const search = new URLSearchParams(options.search); search.sort()
         const key = createHash('sha256').update(JSON.stringify([
-          context.scope, this.baseURL.href, path, search.toString(), jar.header ?? '',
+          context.scope, this.baseURL.href, path, search.toString(), jar.header ?? '', jar.sessionToken ?? '',
           [...headers.entries()].sort(([a], [b]) => a.localeCompare(b)), options.clientAddress ?? '', options.maxResponseBytes ?? null,
         ])).digest('hex')
         response = await this.readCache.read(key, policy, load)
@@ -221,7 +225,10 @@ export class UpstreamClient {
       if (/^[0-9a-f:.]+$/i.test(clientAddress)) headers.set('x-forwarded-for', clientAddress)
     }
     if (jar.header) headers.set('cookie', jar.header)
-    if (jar.sessionToken) headers.set('x-hermes-session-token', jar.sessionToken)
+    if (jar.sessionToken) {
+      if (!isLocalAuthorizationTarget(this.baseURL)) throw new HttpError(403, 'Local authorization cannot be sent to a remote upstream', 'loopback_auth_required')
+      headers.set('X-Hermes-Session-Token', jar.sessionToken)
+    }
     let body: BodyInit | undefined
     if (options.rawBody !== undefined) {
       body = options.rawBody
@@ -232,16 +239,18 @@ export class UpstreamClient {
 
     let response: Response
     let timeout: ReturnType<typeof setTimeout> | undefined
+    const controller = new AbortController()
     try {
       response = await Promise.race([
-        this.fetchImpl(url, {
+        (isLocalAuthorizationTarget(url) && this.fetchImpl === globalThis.fetch ? this.loopback.fetch.bind(this.loopback) : this.fetchImpl)(url, {
           method: options.method ?? 'GET',
           headers,
           body,
           redirect: 'manual',
+          signal: controller.signal,
         }),
         new Promise<never>((_resolve, reject) => {
-          timeout = setTimeout(() => reject(new Error('request timed out')), 30_000)
+          timeout = setTimeout(() => { controller.abort(); reject(new Error('request timed out')) }, 30_000)
           timeout.unref()
         }),
       ])
@@ -258,10 +267,34 @@ export class UpstreamClient {
       await response.body?.cancel()
       throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
     }
-    const bodyBuffer = Buffer.from(await response.arrayBuffer())
-    if (bodyBuffer.byteLength > limit) {
-      throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
+    // Enforce limits while reading, including chunked native-token pages without
+    // Content-Length. Do not buffer an unbounded response before checking it.
+    const chunks: Buffer[] = []
+    let bytes = 0
+    const reader = response.body?.getReader()
+    if (reader) {
+      let bodyTimeout: ReturnType<typeof setTimeout> | undefined
+      try {
+        const expired = new Promise<never>((_resolve, reject) => {
+          bodyTimeout = setTimeout(() => {
+            reject(new HttpError(502, 'Hermes response timed out', 'upstream_unavailable'))
+            void reader.cancel().catch(() => {})
+          }, 30_000)
+          bodyTimeout.unref()
+        })
+        while (true) {
+          const part = await Promise.race([reader.read(), expired])
+          if (part.done) break
+          bytes += part.value.byteLength
+          if (bytes > limit) {
+            void reader.cancel().catch(() => {})
+            throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
+          }
+          chunks.push(Buffer.from(part.value))
+        }
+      } finally { if (bodyTimeout) clearTimeout(bodyTimeout); reader.releaseLock() }
     }
+    const bodyBuffer = Buffer.concat(chunks, bytes)
     return { status: response.status, headers: response.headers, body: bodyBuffer }
   }
 

+ 13 - 0
tests/e2e/workspace.spec.ts

@@ -36,6 +36,19 @@ test.beforeEach(async ({ page }) => {
   await expect(page.getByRole('navigation')).toBeVisible()
 })
 
+test('explains optional upstream credentials without blocking a ready connection', async ({ page }, testInfo) => {
+  await page.getByRole('button', { name: '打开设置中心' }).first().click()
+  const dialog = page.getByRole('dialog', { name: '设置中心' })
+  await dialog.getByRole('button', { name: 'Hermes 连接', exact: true }).click()
+  await expect(dialog.locator('[aria-label="Hermes 连接状态"]')).toBeVisible()
+  await expect(dialog.getByText('9119 连接正常', { exact: true })).toBeVisible()
+  await expect(dialog.getByRole('heading', { name: '服务账号(按需配置)' })).toBeVisible()
+  await expect(dialog.getByText(/无需配置用户名和密码/)).toBeVisible()
+  await expect(dialog.locator('input[name="upstream-username"]')).toHaveValue('')
+  await expect(dialog.locator('input[name="upstream-password"]')).toHaveValue('')
+  await page.screenshot({ path: testInfo.outputPath('loopback-authorization.png') })
+})
+
 test('uses the yaoyao-webui grouped model picker without a mobile full-screen sheet', async ({ page }) => {
   await page.goto('/chat/session-demo')
   await page.locator('.composer-tool--model').click()

+ 6 - 0
tests/fixtures/e2e-web-server.mjs

@@ -13,4 +13,10 @@ Object.assign(process.env, {
   HERMES_YAOYAO_UPSTREAM_PASSWORD: 'test',
 })
 
+if (process.env.FAKE_HERMES_LOCAL_AUTH === '1') {
+  delete process.env.HERMES_YAOYAO_UPSTREAM_USERNAME
+  delete process.env.HERMES_YAOYAO_UPSTREAM_PASSWORD
+  delete process.env.HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE
+}
+
 await import('../../dist-server/server/index.js')

+ 11 - 2
tests/fixtures/fake-hermes.mjs

@@ -2,6 +2,8 @@ import { createServer } from 'node:http'
 import WebSocket, { WebSocketServer } from 'ws'
 
 const port = Number(process.env.FAKE_HERMES_PORT || 19119)
+const localAuthorization = process.env.FAKE_HERMES_LOCAL_AUTH === '1'
+const localToken = 'fixture_native_loopback_session_token_123456'
 const epoch = '11111111-1111-4111-8111-111111111111'
 const roomId = '22222222-2222-4222-8222-222222222222'
 const agentId = '33333333-3333-4333-8333-333333333333'
@@ -72,6 +74,7 @@ function broadcastGroup(event, payload) {
 }
 
 function authenticated(request) {
+  if (localAuthorization) return request.headers['x-hermes-session-token'] === localToken
   return String(request.headers.cookie || '').includes('fake_session=authenticated')
 }
 
@@ -154,7 +157,12 @@ const server = createServer(async (request, response) => {
     rpcRequests.length = 0
     return json(response, 200, { ok: true })
   }
-  if (url.pathname === '/api/status') return json(response, 200, { version: 'test', overall: 'ok', auth_required: true, auth_providers: ['basic'], gateway_running: true, gateway_state: 'running' })
+  if (localAuthorization && url.pathname === '/') {
+    response.writeHead(200, { 'Content-Type': 'text/html' })
+    return response.end(`<script>window.__HERMES_SESSION_TOKEN__=${JSON.stringify(localToken)};</script>`)
+  }
+  if (url.pathname === '/api/status') return json(response, 200, { version: 'test', overall: 'ok', auth_required: !localAuthorization, auth_providers: localAuthorization ? [] : ['basic'], gateway_running: true, gateway_state: 'running' })
+  if (localAuthorization && ['/api/auth/providers', '/api/auth/ws-ticket', '/auth/password-login'].includes(url.pathname)) return json(response, 403, { detail: 'No account or ticket in local mode' })
   if (url.pathname === '/api/auth/providers') return json(response, 200, { providers: [{ name: 'basic', display_name: '账号密码', supports_password: true }] })
   if (url.pathname === '/auth/password-login' && request.method === 'POST') {
     const input = await body(request)
@@ -413,7 +421,8 @@ const server = createServer(async (request, response) => {
 const sockets = new WebSocketServer({ noServer: true })
 server.on('upgrade', (request, socket, head) => {
   const url = new URL(request.url || '/', `http://${request.headers.host}`)
-  if (url.searchParams.get('ticket') !== 'fake-ticket') return socket.destroy()
+  if (localAuthorization ? url.searchParams.get('token') !== localToken || url.searchParams.has('ticket')
+    : url.searchParams.get('ticket') !== 'fake-ticket') return socket.destroy()
   sockets.handleUpgrade(request, socket, head, client => {
     if (url.pathname.endsWith('/events')) {
       groupConnectionCount += 1

+ 33 - 160
tests/server/dashboardSupervisor.test.ts

@@ -1,174 +1,47 @@
 import { describe, expect, it } from 'vitest'
-import {
-  DashboardSupervisor,
-  DEFAULT_DASHBOARD_USERNAME,
-} from '../../src/server/dashboardSupervisor.js'
+import { DashboardSupervisor } from '../../src/server/dashboardSupervisor.js'
 
-function supervisor(overrides: {
-  values?: Record<string, string>
-  running?: boolean
-  runningHost?: '127.0.0.1' | '0.0.0.0'
-  allowLan?: boolean
-  resolveLanProbeHost?: () => string | undefined
-} = {}) {
-  const values = { ...overrides.values }
-  let running = overrides.running ?? false
-  let runningHost = overrides.runningHost ?? '127.0.0.1'
-  const lanProbeHost = '192.168.1.20'
-  const calls: string[][] = []
-  const launches: string[][] = []
+function supervisor(initiallyRunning = false) {
+  let running = initiallyRunning
+  const calls: string[][] = [], launches: string[][] = [], probes: string[] = []
   const instance = new DashboardSupervisor({
-    allowLan: overrides.allowLan ?? false,
-    lanProbeHost: overrides.resolveLanProbeHost ? undefined : lanProbeHost,
-    resolveLanProbeHost: overrides.resolveLanProbeHost,
     run(args) {
       calls.push([...args])
-      if (args[0] === 'config' && args[1] === 'get') return values[args[2]] ?? ''
-      if (args[0] === 'config' && args[1] === 'set') {
-        values[args[2]] = args[3]
-        return ''
-      }
-      if (args[0] === 'dashboard' && args[1] === '--stop') {
-        running = false
-        return ''
-      }
+      if (args[0] === 'dashboard' && args[1] === '--stop') running = false
       return ''
     },
-    launch(args) {
-      launches.push([...args])
-      running = true
-      runningHost = args[2] as typeof runningHost
-    },
-    probe: async (host) => running && (
-      host === '127.0.0.1'
-      || (host === lanProbeHost && runningHost === '0.0.0.0')
-    ),
+    launch(args) { launches.push([...args]); running = true },
+    probe: async (host, port) => { probes.push(host + ':' + port); return running },
     log: () => undefined,
   })
-  return { instance, calls, launches, values }
+  return { instance, calls, launches, probes }
 }
 
 describe('DashboardSupervisor', () => {
-  it('configures the requested default credentials and starts an unavailable dashboard', async () => {
-    const value = supervisor()
-    await value.instance.checkNow()
-
-    expect(value.values['dashboard.basic_auth.username']).toBe(DEFAULT_DASHBOARD_USERNAME)
-    expect(value.values['dashboard.basic_auth.password_hash']).toMatch(/^scrypt\$16384\$8\$1\$/)
-    expect(value.values['dashboard.basic_auth.secret']).toMatch(/.{32,}/)
-    expect(value.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
-  })
-
-  it('does not overwrite configured dashboard credentials', async () => {
-    const value = supervisor({
-      running: true,
-      values: {
-        'dashboard.basic_auth.username': 'operator',
-        'dashboard.basic_auth.password_hash': 'scrypt$existing',
-        'dashboard.basic_auth.secret': 'configured-secret',
-      },
-    })
-    await value.instance.checkNow()
-
-    expect(value.calls.filter((args) => args[1] === 'set')).toEqual([])
-    expect(value.launches).toEqual([])
-  })
-
-  it('restarts a running dashboard after filling missing authentication and uses LAN binding when enabled', async () => {
-    const value = supervisor({ running: true, allowLan: true })
-    await value.instance.checkNow()
-
-    expect(value.calls).toContainEqual(['dashboard', '--stop'])
-    expect(value.launches).toEqual([['dashboard', '--host', '0.0.0.0', '--no-open']])
-  })
-
-  it('moves an authenticated loopback dashboard onto the LAN when managed installation requires it', async () => {
-    const value = supervisor({
-      running: true,
-      runningHost: '127.0.0.1',
-      allowLan: true,
-      values: {
-        'dashboard.basic_auth.username': 'operator',
-        'dashboard.basic_auth.password_hash': 'scrypt$existing',
-        'dashboard.basic_auth.secret': 'configured-secret',
-      },
-    })
-    await value.instance.checkNow()
-
-    expect(value.calls).toContainEqual(['dashboard', '--stop'])
-    expect(value.launches).toEqual([['dashboard', '--host', '0.0.0.0', '--no-open']])
-  })
-
-  it('keeps an authenticated dashboard running when it already listens on the LAN', async () => {
-    const value = supervisor({
-      running: true,
-      runningHost: '0.0.0.0',
-      allowLan: true,
-      values: {
-        'dashboard.basic_auth.username': 'operator',
-        'dashboard.basic_auth.password_hash': 'scrypt$existing',
-        'dashboard.basic_auth.secret': 'configured-secret',
-      },
-    })
-    await value.instance.checkNow()
-
-    expect(value.calls).not.toContainEqual(['dashboard', '--stop'])
-    expect(value.launches).toEqual([])
-  })
-
-  it('moves an authenticated LAN dashboard back to loopback when unmanaged LAN is requested', async () => {
-    const value = supervisor({
-      running: true,
-      runningHost: '0.0.0.0',
-      allowLan: false,
-      values: {
-        'dashboard.basic_auth.username': 'operator',
-        'dashboard.basic_auth.password_hash': 'scrypt$existing',
-        'dashboard.basic_auth.secret': 'configured-secret',
-      },
-    })
-    await value.instance.checkNow()
-
-    expect(value.calls).toContainEqual(['dashboard', '--stop'])
-    expect(value.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
-  })
-
-  it('rechecks LAN binding after the network interface becomes available', async () => {
-    let networkReady = false
-    const value = supervisor({
-      running: true,
-      runningHost: '127.0.0.1',
-      allowLan: true,
-      resolveLanProbeHost: () => networkReady ? '192.168.1.20' : undefined,
-      values: {
-        'dashboard.basic_auth.username': 'operator',
-        'dashboard.basic_auth.password_hash': 'scrypt$existing',
-        'dashboard.basic_auth.secret': 'configured-secret',
-      },
-    })
-
-    await value.instance.checkNow()
-    expect(value.launches).toEqual([])
-
-    networkReady = true
-    await value.instance.checkNow()
-    expect(value.calls).toContainEqual(['dashboard', '--stop'])
-    expect(value.launches).toEqual([['dashboard', '--host', '0.0.0.0', '--no-open']])
-  })
-
-  it('explicitly restarts a healthy dashboard and waits for 9119 to return', async () => {
-    const value = supervisor({
-      running: true,
-      values: {
-        'dashboard.basic_auth.username': 'operator',
-        'dashboard.basic_auth.password_hash': 'scrypt$existing',
-        'dashboard.basic_auth.secret': 'configured-secret',
-      },
-    })
-
-    await value.instance.restart()
-
-    expect(value.calls).toContainEqual(['dashboard', '--stop'])
-    expect(value.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
+  it('starts a missing service on loopback without reading or writing authentication config', async () => {
+    const f = supervisor()
+    await f.instance.checkNow()
+    expect(f.calls).toEqual([])
+    expect(f.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
+    await f.instance.checkNow()
+    expect(f.launches).toHaveLength(1)
+  })
+  it('leaves an existing service and its authentication/binding untouched', async () => {
+    const f = supervisor(true)
+    await f.instance.checkNow(); await f.instance.checkNow()
+    expect(f.calls).toEqual([])
+    expect(f.launches).toEqual([])
+    expect(new Set(f.probes)).toEqual(new Set(['127.0.0.1:9119']))
+  })
+  it('only restarts on an explicit request, without configuring any credentials', async () => {
+    const f = supervisor(true)
+    await f.instance.restart()
+    expect(f.calls).toEqual([['dashboard', '--stop']])
+    expect(f.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
+  })
+  it('coalesces concurrent checks', async () => {
+    const f = supervisor()
+    await Promise.all([f.instance.checkNow(), f.instance.checkNow()])
+    expect(f.launches).toHaveLength(1)
   })
 })

+ 6 - 1
tests/server/kanbanRoutes.test.ts

@@ -312,7 +312,12 @@ describe('Kanban proxy routes', () => {
     config.production = true
     config.upstreamUsername = 'service-admin'
     config.upstreamPassword = 'service-password'
-    const runtime = createAuthenticatedApplication({ config, fetchImpl: fixture.fetchImpl })
+    const runtime = createAuthenticatedApplication({ config, fetchImpl: (async (input, init) => {
+      const path = new URL(String(input)).pathname
+      if (path === '/api/status') return json({ auth_required: true })
+      if (path === '/api/auth/providers') return json({}, { status: 503 })
+      return fixture.fetchImpl(input, init)
+    }) as typeof fetch })
     runtimes.push(runtime)
     const agent = request.agent(runtime.app.callback())
 

+ 34 - 0
tests/server/localAuthRoutes.test.ts

@@ -21,6 +21,38 @@ function cookies(response: request.Response): string {
 }
 
 describe('8800 local authentication routes', () => {
+  it('installs without upstream credentials while keeping local login and CSRF mandatory', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-token-')); roots.push(home)
+    const token = 'fixture_local_only_session_token_123456'
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+      mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false, superviseDashboard: true,
+    }
+    const runtime = createApplication({ config, fetchImpl: (async (input, init) => {
+      const path = new URL(String(input)).pathname
+      if (path === '/api/status') return Response.json({ auth_required: false })
+      if (path === '/') return new Response(`<script>window.__HERMES_SESSION_TOKEN__="${token}";</script>`)
+      if (new Headers(init?.headers).get('x-hermes-session-token') !== token) return Response.json({}, { status: 401 })
+      return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+    }) as typeof fetch }); runtimes.push(runtime)
+    expect(runtime.auth.upstreamCredentials()).toBeUndefined()
+    const agent = request.agent(runtime.app.callback()), origin = 'http://127.0.0.1:8800'
+    await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(401)
+    const boot = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    expect(boot.body.authRequired).toBe(true)
+    const login = await agent.post('/api/app/login').set('Host', '127.0.0.1:8800').set('Origin', origin)
+      .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(200)
+    await agent.put('/api/app/account/credentials').set('Host', '127.0.0.1:8800').set('Origin', origin)
+      .set('X-CSRF-Token', login.body.csrfToken).send({ currentPassword: 'admin', newPassword: 'fixture-password', username: 'owner' }).expect(200)
+    const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    expect(ready.body).toMatchObject({ authRequired: true, authenticated: true, upstreamReady: true })
+    expect(ready.body.profiles).toHaveLength(1)
+    expect(JSON.stringify([ready.body, ready.headers])).not.toContain(token)
+    expect(runtime.auth.upstreamCredentials()).toBeUndefined()
+    await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:8800').set('Origin', origin).send({ channel: 'chat' }).expect(403)
+    await request(runtime.app.callback()).get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(401)
+  })
   it('forces admin/admin to change, then serves shared profiles through the service account', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-routes-'))
     roots.push(home)
@@ -29,6 +61,7 @@ describe('8800 local authentication routes', () => {
       allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
       mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
       superviseDashboard: true,
+      upstreamUsername: 'service', upstreamPassword: 'fixture-password',
     }
     let wsTicketForwardedFor: string | null | undefined
     const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
@@ -127,6 +160,7 @@ describe('8800 local authentication routes', () => {
         allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
         mediaOwner: 'tester', allowInsecureLan: true, insecureLan: true, production: false,
         superviseDashboard: true,
+        upstreamUsername: 'service', upstreamPassword: 'fixture-password',
       }
       const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
         const path = new URL(input instanceof Request ? input.url : String(input)).pathname

+ 163 - 0
tests/server/loopbackAuthorization.test.ts

@@ -0,0 +1,163 @@
+// @vitest-environment node
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import type { AddressInfo } from 'node:net'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { allowsLocalAuthorization, isLocalAuthorizationTarget, localSessionToken, LoopbackTransport } from '../../src/server/loopbackAuthorization.js'
+import { UpstreamServiceSession } from '../../src/server/localAuth.js'
+import { CookieJar, UpstreamClient } from '../../src/server/upstream.js'
+
+const clients: UpstreamClient[] = []
+afterEach(() => { clients.splice(0).forEach(client => client.close()); vi.unstubAllEnvs() })
+
+function fixture(base = 'http://127.0.0.1:9119') {
+  let token = 'native_loopback_fixture_token_initial', gated = false, page = true
+  const paths: string[] = [], headers: Headers[] = []
+  const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
+    const path = new URL(String(input)).pathname, header = new Headers(init?.headers)
+    paths.push(path); headers.push(header)
+    if (path === '/api/status') return Response.json({ auth_required: gated })
+    if (path === '/') return new Response(page ? `<script>window.__HERMES_SESSION_TOKEN__=${JSON.stringify(token)};</script>` : '<html>login required</html>')
+    if (path === '/api/auth/me') return Response.json({}, { status: 401 })
+    if (header.get('x-hermes-session-token') !== token || gated) return Response.json({}, { status: 401 })
+    return Response.json({ profiles: [], ok: true })
+  })
+  const client = new UpstreamClient(new URL(base), fetch); clients.push(client)
+  const session = new UpstreamServiceSession(client, () => undefined)
+  return { session, client, fetch, paths, headers, get token() { return token },
+    rotate() { token = 'native_loopback_fixture_token_rotated' }, gate() { gated = true }, hideToken() { page = false } }
+}
+
+describe('native loopback authorization', () => {
+  it('uses the native session token for protected REST and WebSocket, without password login', async () => {
+    const f = fixture()
+    expect((await f.session.request('/api/profiles')).status).toBe(200)
+    expect(await f.session.webSocketCredential()).toEqual({ name: 'token', value: f.token })
+    expect(f.session.authorizationMode).toBe('local')
+    expect(f.paths).not.toContain('/auth/password-login')
+    expect(f.paths).not.toContain('/api/auth/ws-ticket')
+    expect(f.headers.every(h => !h.has('x-forwarded-for'))).toBe(true)
+    expect(f.session.jar.browserCookies).toEqual([])
+    expect(f.session.jar.header).toBeUndefined()
+  })
+  it('coalesces token acquisition and refreshes once after parallel 401s', async () => {
+    const f = fixture()
+    await Promise.all(Array.from({ length: 8 }, () => f.session.webSocketCredential()))
+    expect(f.paths.filter(p => p === '/')).toHaveLength(1)
+    f.rotate()
+    const result = await Promise.all(Array.from({ length: 8 }, (_, i) => f.session.request(`/api/sessions/${i}`)))
+    expect(result.every(r => r.status === 200)).toBe(true)
+    expect(f.paths.filter(p => p === '/')).toHaveLength(2)
+    expect(f.session.jar.sessionToken).toBe(f.token)
+  })
+  it('detects a rotated token before opening another socket, within the validation lease', async () => {
+    const f = fixture()
+    await f.session.webSocketCredential(); f.rotate()
+    expect(await f.session.webSocketCredential()).toEqual({ name: 'token', value: f.token })
+    expect(f.paths.filter(p => p === '/')).toHaveLength(2)
+  })
+  it('does not bypass account authorization on a loopback server', async () => {
+    const f = fixture(); f.gate()
+    await expect(f.session.request('/api/profiles')).rejects.toMatchObject({ code: 'upstream_credentials_required' })
+    expect(f.paths).not.toContain('/')
+    expect(f.paths).not.toContain('/auth/password-login')
+  })
+  it('fails closed when local Hermes begins requiring an account', async () => {
+    const f = fixture(); await f.session.webSocketCredential(); f.gate()
+    await expect(f.session.webSocketCredential()).rejects.toMatchObject({ code: 'upstream_credentials_required' })
+    expect(f.paths.filter(p => p === '/')).toHaveLength(1)
+  })
+  it('rechecks auth mode on a ticket 401 after the upstream restarts into local mode', async () => {
+    const f = fixture()
+    f.fetch.mockResolvedValueOnce(Response.json({ auth_required: true }))
+      .mockResolvedValueOnce(Response.json({ user_id: 'service' }))
+    await f.session.ensure()
+    f.fetch.mockResolvedValueOnce(Response.json({}, { status: 401 }))
+    expect(await f.session.webSocketCredential()).toEqual({ name: 'token', value: f.token })
+    expect(f.session.authorizationMode).toBe('local')
+    expect(f.paths).not.toContain('/auth/password-login')
+  })
+  it('renews an expired account before retrying ticket issuance only once', async () => {
+    const f = fixture()
+    const session = new UpstreamServiceSession(f.client, () => ({ username: 'service', password: 'fixture' }))
+    f.fetch.mockResolvedValueOnce(Response.json({ auth_required: true }))
+      .mockResolvedValueOnce(Response.json({ user_id: 'service' }))
+      .mockResolvedValueOnce(Response.json({}, { status: 401 }))
+      .mockResolvedValueOnce(Response.json({ auth_required: true }))
+      .mockResolvedValueOnce(Response.json({ providers: [] }))
+      .mockResolvedValueOnce(Response.json({ ok: true }, { headers: { 'Set-Cookie': 'service=renewed; Path=/' } }))
+      .mockResolvedValueOnce(Response.json({ ticket: 'new-ticket' }))
+    expect(await session.webSocketCredential()).toEqual({ name: 'ticket', value: 'new-ticket' })
+    expect(f.fetch.mock.calls.map(([input]) => new URL(String(input)).pathname)).toEqual([
+      '/api/status', '/api/auth/me', '/api/auth/ws-ticket', '/api/status', '/api/auth/providers', '/auth/password-login', '/api/auth/ws-ticket',
+    ])
+    expect(session.jar.sessionToken).toBeUndefined()
+  })
+  it.each(['http://192.168.1.2:9119', 'http://host.docker.internal:9119', 'http://localhost:9119', 'https://remote.example'])('rejects account-free authorization outside literal loopback: %s', async base => {
+    const f = fixture(base)
+    await expect(f.session.ensure()).rejects.toMatchObject({ code: 'loopback_auth_required' })
+    expect(f.paths).toEqual(['/api/status'])
+  })
+  it('never attaches a local token to a remote request', async () => {
+    const f = fixture('https://remote.example'), jar = new CookieJar()
+    jar.setSessionToken(f.token)
+    await expect(f.client.request('/api/profiles', jar)).rejects.toMatchObject({ code: 'loopback_auth_required' })
+    expect(f.fetch).not.toHaveBeenCalled()
+  })
+  it('fails closed if the native token is absent, without persisting partial authorization', async () => {
+    const f = fixture(); f.hideToken()
+    await expect(f.session.webSocketCredential()).rejects.toMatchObject({ code: 'local_authorization_unavailable' })
+    expect(f.session.jar.sessionToken).toBeUndefined()
+  })
+  it('does not replay a command after transport failure or an upstream 500', async () => {
+    const f = fixture(); await f.session.webSocketCredential()
+    f.fetch.mockResolvedValueOnce(Response.json({}, { status: 500 }))
+    const before = f.fetch.mock.calls.length
+    expect((await f.session.request('/api/command', { method: 'POST', body: { text: 'once' } })).status).toBe(500)
+    expect(f.fetch.mock.calls.length - before).toBe(1)
+    f.fetch.mockRejectedValueOnce(new Error('connection lost'))
+    await expect(f.session.request('/api/command', { method: 'POST', body: { text: 'once' } })).rejects.toMatchObject({ code: 'upstream_unavailable' })
+    expect(f.fetch.mock.calls.length - before).toBe(2)
+  })
+  it('parses only a literal native token, never executes HTML or JavaScript', () => {
+    expect(allowsLocalAuthorization({ auth_required: true, authRequired: false })).toBe(false)
+    expect(allowsLocalAuthorization({})).toBe(false)
+    expect(isLocalAuthorizationTarget(new URL('http://[::1]:9119'))).toBe(true)
+    expect(isLocalAuthorizationTarget(new URL('http://user:pass@127.0.0.1:9119'))).toBe(false)
+    expect(localSessionToken('window.__HERMES_SESSION_TOKEN__ = "abc"')).toBeUndefined()
+    expect(localSessionToken('window.__HERMES_SESSION_TOKEN__ = alert(1)')).toBeUndefined()
+    expect(localSessionToken('window.__HERMES_SESSION_TOKEN__ = "native_token_with_\\u0031_escape";')).toBe('native_token_with_1_escape')
+  })
+  it('bounds token-page reads even when the response has no Content-Length', async () => {
+    const f = fixture()
+    let cancelled = false
+    f.fetch.mockResolvedValueOnce(new Response(new ReadableStream({
+      pull(controller) { controller.enqueue(new Uint8Array(1024)) },
+      cancel() { cancelled = true },
+    })))
+    await expect(f.client.request('/', new CookieJar(), { maxResponseBytes: 128 })).rejects.toMatchObject({ code: 'upstream_too_large' })
+    expect(cancelled).toBe(true)
+  })
+  it('connects directly despite proxy settings, never follows redirects or leaks a token to a redirect target', async () => {
+    let proxyHits = 0, redirectHits = 0, received = ''
+    const proxy = createServer((_req, res) => { proxyHits++; res.end('proxy') })
+    proxy.listen(0, '127.0.0.1'); await once(proxy, 'listening')
+    const origin = createServer((req, res) => {
+      if (req.url === '/target') redirectHits++
+      received = String(req.headers['x-hermes-session-token'] ?? '')
+      res.writeHead(302, { Location: '/target' }); res.end()
+    })
+    origin.listen(0, '127.0.0.1'); await once(origin, 'listening')
+    vi.stubEnv('HTTP_PROXY', `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`)
+    vi.stubEnv('NODE_USE_ENV_PROXY', '1'); vi.stubEnv('NO_PROXY', '')
+    const transport = new LoopbackTransport()
+    try {
+      const response = await transport.fetch(new URL(`http://127.0.0.1:${(origin.address() as AddressInfo).port}/`), { headers: { 'X-Hermes-Session-Token': 'fixture-token' } })
+      expect(response.status).toBe(302); await response.arrayBuffer()
+      expect(received).toBe('fixture-token'); expect(proxyHits).toBe(0); expect(redirectHits).toBe(0)
+    } finally {
+      transport.close()
+      await Promise.all([new Promise<void>(r => origin.close(() => r())), new Promise<void>(r => proxy.close(() => r()))])
+    }
+  })
+})

+ 5 - 3
tests/server/pushEvents.test.ts

@@ -1,3 +1,4 @@
+// @vitest-environment node
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import WebSocket, { WebSocketServer } from 'ws'
@@ -521,7 +522,7 @@ describe('push event bridge', () => {
     })
     let ticketRequests = 0
     const session = {
-      request: () => {
+      webSocketCredential: () => {
         ticketRequests += 1
         return new Promise<never>(() => undefined)
       },
@@ -550,7 +551,7 @@ describe('push event bridge', () => {
     const upstream = createServer((request, response) => {
       response.setHeader('Content-Type', 'application/json')
       const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
-      if (path === '/api/status') response.end('{"auth_required":false}')
+      if (path === '/api/status') response.end('{"auth_required":true}')
       else if (path === '/api/auth/ws-ticket') response.end('{"ticket":"ownership-ticket"}')
       else response.end('{}')
     })
@@ -610,7 +611,8 @@ describe('push event bridge', () => {
     const upstream = createServer((request, response) => {
       response.setHeader('Content-Type', 'application/json')
       const url = new URL(request.url ?? '/', 'http://127.0.0.1')
-      if (url.pathname === '/api/status') response.end(JSON.stringify({ auth_required: false }))
+      if (url.pathname === '/api/status') response.end(JSON.stringify({ auth_required: true }))
+      else if (url.pathname === '/api/auth/me') response.end(JSON.stringify({ user_id: 'service' }))
       else if (url.pathname === '/api/auth/ws-ticket') response.end(JSON.stringify({ ticket: 'push-ticket' }))
       else if (url.pathname === '/api/sessions/stored-1/messages') {
         messageReads += 1

+ 24 - 5
tests/server/realtimeApi.test.ts

@@ -15,9 +15,19 @@ import type { ServerConfig } from '../../src/server/config.js'
 const closers: Array<() => Promise<void>> = []
 afterEach(async () => { for (const close of closers.splice(0).reverse()) await close() })
 
-async function setup() {
-  const server = createServer()
-  const wss = new WebSocketServer({ server })
+async function setup(local = false) {
+  const localToken = 'fixture_native_loopback_token_123456'
+  const server = createServer((req, res) => {
+    if (req.url === '/api/status') { res.end(JSON.stringify({ auth_required: false })); return }
+    if (req.url === '/') { res.end(`<script>window.__HERMES_SESSION_TOKEN__=${JSON.stringify(localToken)};</script>`); return }
+    if (req.headers['x-hermes-session-token'] !== localToken) { res.writeHead(401); res.end('{}'); return }
+    res.end(JSON.stringify({ profiles: [] }))
+  })
+  const wss = new WebSocketServer({ server, verifyClient: ({ req }) => {
+    const url = new URL(req.url!, 'http://127.0.0.1')
+    return local ? url.searchParams.get('token') === localToken && !url.searchParams.has('ticket')
+      : url.searchParams.get('ticket') === 'upstream-secret'
+  } })
   const peers = new Set<WebSocket>(), commands: any[] = []
   let connectionCount = 0, sequence = 0
   wss.on('connection', peer => {
@@ -36,10 +46,10 @@ async function setup() {
     upstream: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`), allowedHosts: new Set(),
     home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home, mediaOwner: 'test',
     allowInsecureLan: false, insecureLan: false, production: false }
-  const runtime = createAuthenticatedApplication({ config, fetchImpl: (async input => {
+  const runtime = createAuthenticatedApplication({ config, fetchImpl: local ? undefined : (async input => {
     const path = new URL(String(input)).pathname
     if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'upstream-secret' })
-    if (path === '/api/status') return Response.json({ auth_required: false })
+    if (path === '/api/status') return Response.json({ auth_required: true })
     return Response.json({ user_id: 'service', profiles: [] })
   }) as typeof fetch })
   const node = createNodeServer(runtime)
@@ -70,6 +80,15 @@ async function setup() {
 }
 
 describe('HTTP/SSE realtime API', () => {
+  it('connects to native loopback Hermes without an account for REST and realtime', async () => {
+    const f = await setup(true)
+    const c = await f.create()
+    const result = await f.command(c, 'local-resume', 'session.resume', { session_id: 'stored', profile: 'default' })
+    expect(result.receipt).toMatchObject({ state: 'confirmed', response: { result: { session_id: 'runtime' } } })
+    const invalid = await fetch(`${f.origin}/api/realtime/channels`, { method: 'POST', headers: { ...f.headers, 'X-CSRF-Token': 'bad' }, body: '{"channel":"chat"}' })
+    expect(invalid.status).toBe(403)
+    expect(f.count()).toBe(1)
+  })
   it('enforces CSRF and returns upstream-confirmed receipts', async () => {
     const f = await setup()
     const invalid = await fetch(`${f.origin}/api/realtime/channels`, { method: 'POST', headers: { ...f.headers, 'X-CSRF-Token': 'bad' }, body: '{"channel":"chat"}' })

+ 1 - 1
tests/server/realtimeMigration.test.ts

@@ -1,3 +1,4 @@
+// @vitest-environment node
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import WebSocket, { WebSocketServer } from 'ws'
@@ -213,4 +214,3 @@ describe('HTTP+SSE migration', () => {
     expect(status).toBe(410)
   })
 })
-

+ 6 - 5
tests/server/upstreamServiceSession.test.ts

@@ -121,7 +121,7 @@ describe('upstream service validation lease', () => {
     const session = new UpstreamServiceSession(client, () => ({ username: 'unused', password: 'unused' }))
 
     expect((await session.request('/api/sessions')).status).toBe(200)
-    expect(paths).toEqual(['/api/status', '/', '/api/sessions'])
+    expect(paths).toEqual(['/api/status', '/', '/api/profiles', '/api/sessions'])
     expect(session.connectionInfo()).toMatchObject({
       endpoint: 'http://127.0.0.1:9119',
       authMode: 'loopback-token',
@@ -130,7 +130,7 @@ describe('upstream service validation lease', () => {
   })
 
   it('refreshes a rotated loopback token once and replays parallel reads', async () => {
-    let activeToken = 'loopback-token-first'
+    let activeToken = 'loopback-token-first-fixture'
     let pageLoads = 0
     const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
       const path = new URL(String(input)).pathname
@@ -143,10 +143,10 @@ describe('upstream service validation lease', () => {
         ? Response.json({ ok: true })
         : Response.json({ detail: 'Unauthorized' }, { status: 401 })
     })
-    const client = new UpstreamClient(new URL('http://localhost:9119'), fetch)
+    const client = new UpstreamClient(new URL('http://127.0.0.1:9119'), fetch)
     const session = new UpstreamServiceSession(client, () => undefined)
     await session.ensure()
-    activeToken = 'loopback-token-second'
+    activeToken = 'loopback-token-second-fixture'
 
     const responses = await Promise.all([
       client.request('/api/sessions/a', session.jar),
@@ -154,6 +154,7 @@ describe('upstream service validation lease', () => {
     ])
     expect(responses.map(response => response.status)).toEqual([200, 200])
     expect(pageLoads).toBe(2)
+    expect(session.connectionInfo().authMode).toBe('loopback-token')
   })
 
   it('refuses token discovery from a non-loopback upstream', async () => {
@@ -161,7 +162,7 @@ describe('upstream service validation lease', () => {
     const client = new UpstreamClient(new URL('http://192.168.1.20:9119'), fetch)
     const session = new UpstreamServiceSession(client, () => undefined)
 
-    await expect(session.ensure()).rejects.toMatchObject({ code: 'upstream_auth_unsafe' })
+    await expect(session.ensure()).rejects.toMatchObject({ code: 'loopback_auth_required' })
     expect(fetch).toHaveBeenCalledTimes(1)
   })
 

+ 1 - 1
tests/serviceCli.test.ts

@@ -15,7 +15,7 @@ describe('hermes-yaoyao LaunchAgent plist', () => {
     expect(launchAgentPlist()).toContain('<key>HERMES_YAOYAO_SUPERVISE_DASHBOARD</key>')
   })
 
-  it('binds managed Web and Dashboard services to the trusted LAN by default', () => {
+  it('binds managed Web to the trusted LAN and enables local Dashboard supervision by default', () => {
     const plist = launchAgentPlist({ environment: {} })
 
     expect(plist).toContain('<key>HERMES_YAOYAO_HOST</key>\n      <string>0.0.0.0</string>')