Просмотр исходного кода

新增8800独立用户与子节点能力

LSamien 2 месяцев назад
Родитель
Сommit
39041e61c0

+ 20 - 2
README.md

@@ -2,6 +2,20 @@
 
 夭夭 Web 是一个独立的本地 Web 工作台,通过端口 `8800` 连接 Hermes Dashboard/Gateway `9119`,提供普通聊天、群聊、文件库和产物浏览。通过本项目安装的 LaunchAgent 会持续监督 `9119`:缺失时自动配置 Dashboard 认证并启动它。
 
+## 8800 用户与 iOS
+
+8800 拥有独立于 9119 的用户机制。首次安装会创建管理员 `admin/admin`,首次登录
+必须修改凭据。管理员可在“系统管理”中创建、禁用、删除普通用户和重置临时密码。
+所有 8800 用户共享本机 9119 的 Bots、历史、团队和文件;普通用户不能管理用户、
+节点、插件或系统升级。
+
+iOS 默认连接 `http://主机:8800` 并使用 8800 用户登录;已有 9119 账号继续兼容。
+9119 和 8800 都可管理由 8800 签发配对码的直接子节点,节点不会递归暴露孙节点。
+
+8800 使用单独托管的 9119 服务账号。受管本机首次安装会生成随机服务密码并加密
+保存;外部或 Docker 9119 可在“系统管理”中配置,或设置
+`HERMES_YAOYAO_UPSTREAM_USERNAME` 和 `HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE`。
+
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 
@@ -175,7 +189,7 @@ npm run dev
 
 | 端口 | 服务 | 默认 | 单独开启局域网 |
 | --- | --- | --- | --- |
-| `9119` | Hermes Dashboard | `127.0.0.1` | 由夭夭 Web 受管服务监督;首次缺失配置时自动创建 `admin/admin` 登录并启动 |
+| `9119` | Hermes Dashboard | `127.0.0.1` | 由夭夭 Web 监督;首次缺失配置时创建随机服务账号并启动 |
 | `8800` | 夭夭 Web | `127.0.0.1` | 显式设置 `HERMES_YAOYAO_HOST=0.0.0.0` 后与 9119 一起开放 |
 
 夭夭 Web 默认上游仍是 `http://127.0.0.1:9119`。受管服务的局域网开关会同时让它监听局域网地址,并让其启动的 Dashboard `9119` 监听局域网地址。
@@ -189,7 +203,11 @@ HERMES_YAOYAO_ALLOW_INSECURE_LAN=1
 
 生产局域网使用建议同时配置 `HERMES_YAOYAO_TLS_CERT` 和 `HERMES_YAOYAO_TLS_KEY`。
 
-受管服务会在 `dashboard.basic_auth` 未配置时创建用户名 `admin`、密码 `admin` 的 scrypt 哈希和随机会话签名 `secret`。这是已知默认凭据,首次登录后必须立即修改。具体步骤见 [Agent 安装手册](docs/agent-install.md#受管-9119-的默认认证与持续监督)。Dashboard 的 `--insecure` 参数不会关闭认证;不要并行安装其他 Dashboard 监督器。
+受管服务会在 `dashboard.basic_auth` 未配置时创建 `yaoyao-service` 和随机密码的
+scrypt 哈希,同时生成会话签名 `secret`;明文服务密码只加密保存在 8800 数据目录。
+8800 本地管理员 `admin/admin` 首次登录强制修改。具体步骤见
+[Agent 安装手册](docs/agent-install.md#受管-9119-的默认认证与持续监督)。
+Dashboard 的 `--insecure` 参数不会关闭认证;不要并行安装其他 Dashboard 监督器。
 
 ## 构建与运行
 

+ 2 - 0
bin/hermes-yaoyao.mjs

@@ -36,6 +36,8 @@ function environment() {
     'HERMES_YAOYAO_HOST',
     'HERMES_YAOYAO_PORT',
     'HERMES_YAOYAO_UPSTREAM',
+    'HERMES_YAOYAO_UPSTREAM_USERNAME',
+    'HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE',
     'HERMES_YAOYAO_ALLOWED_HOSTS',
     'HERMES_YAOYAO_HOME',
     'HERMES_YAOYAO_TLS_CERT',

+ 2 - 0
docker.env.example

@@ -5,6 +5,8 @@ HERMES_YAOYAO_PUBLISHED_PORT=8800
 # 9119 由项目外部的 Hermes Dashboard 提供,Compose 不会创建、启动或停止它。
 # Docker Desktop 可用这个地址访问宿主机;Linux 上的 9119 还必须监听容器可达的宿主机地址。
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
+# HERMES_YAOYAO_UPSTREAM_USERNAME=yaoyao-service
+# HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE=/run/secrets/hermes_dashboard_password
 
 # 使用域名访问时填写域名,多个值以英文逗号分隔;localhost 和私网 IP 无需填写。
 HERMES_YAOYAO_ALLOWED_HOSTS=

+ 6 - 4
docs/agent-install.md

@@ -34,16 +34,18 @@ export HERMES_YAOYAO_ALLOW_INSECURE_LAN=1
 
 ## 受管 9119 的默认认证与持续监督
 
-夭夭 Web LaunchAgent 默认启用 `HERMES_YAOYAO_SUPERVISE_DASHBOARD=1`。它每 5 秒检查本机 `9119`;端口未监听时会启动 Dashboard。首次发现 `dashboard.basic_auth` 未配置时,它会写入下列默认认证并重启 Dashboard 载入配置:
+夭夭 Web LaunchAgent 默认启用 `HERMES_YAOYAO_SUPERVISE_DASHBOARD=1`。它每 5 秒检查本机 `9119`;端口未监听时会启动 Dashboard。首次发现 `dashboard.basic_auth` 未配置时,它会写入 8800 生成的服务账号并重启 Dashboard 载入配置:
 
 | 项目 | 默认值 |
 | --- | --- |
-| 用户名 | `admin` |
-| 密码 | `admin` |
+| 用户名 | `yaoyao-service` |
+| 密码 | 8800 首次安装随机生成并加密保存 |
 | 密码存储 | `password_hash` scrypt 哈希,不保存明文 |
 | 会话签名密钥 | 首次配置时随机生成 |
 
-`admin/admin` 是发布要求的已知默认凭据,不适合长期使用。首次登录后,管理员必须用 Hermes 配置替换该账号或密码,并重启 8800 服务,让监督器加载新的认证配置。监督器只会填补缺失的用户名、密码和会话签名密钥,绝不会覆盖已有的用户名、密码哈希、密码或密钥。
+8800 自己的默认管理员仍为 `admin/admin`,首次登录必须修改。它与上述 9119 服务
+账号相互独立。监督器只会填补缺失的 9119 用户名、密码和会话签名密钥,绝不会
+覆盖已有配置;已有或外部 9119 请在 8800“系统管理”中验证并保存服务凭据。
 
 如需在首次登录后替换默认密码,可用下列命令以 scrypt 哈希方式写入配置。不要将新密码放入命令历史、Git 或日志。
 

+ 2 - 0
src/client/App.vue

@@ -3,6 +3,7 @@ import { computed, onMounted, watch } from 'vue'
 import { useRoute } from 'vue-router'
 import LoadingScreen from '@/components/app/LoadingScreen.vue'
 import LoginView from '@/views/LoginView.vue'
+import PasswordChangeView from '@/views/PasswordChangeView.vue'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { useGroupsStore } from '@/stores/groups'
@@ -45,5 +46,6 @@ onMounted(() => auth.bootstrap())
 <template>
   <LoadingScreen v-if="auth.status === 'checking'" />
   <LoginView v-else-if="!auth.isAuthenticated" />
+  <PasswordChangeView v-else-if="auth.user?.mustChangePassword" />
   <RouterView v-else />
 </template>

+ 39 - 0
src/client/api/admin.ts

@@ -0,0 +1,39 @@
+import type { JsonValue } from '@shared/types'
+import { apiRequest } from './client'
+
+export interface ManagedUser {
+  id: string
+  username: string
+  role: 'admin' | 'user'
+  enabled: boolean
+  mustChangePassword: boolean
+  createdAt: number
+  updatedAt: number
+}
+
+export async function listUsers(): Promise<ManagedUser[]> {
+  const response = await apiRequest<{ items: ManagedUser[] }>('/api/app/admin/users')
+  return response.items
+}
+
+export async function createUser(username: string, password: string): Promise<ManagedUser> {
+  return apiRequest('/api/app/admin/users', {
+    method: 'POST', body: { username, password } as unknown as JsonValue,
+  })
+}
+
+export async function updateUser(id: string, input: { enabled?: boolean; password?: string }): Promise<ManagedUser> {
+  return apiRequest(`/api/app/admin/users/${encodeURIComponent(id)}`, {
+    method: 'PATCH', body: input as unknown as JsonValue,
+  })
+}
+
+export async function deleteUser(id: string): Promise<void> {
+  await apiRequest(`/api/app/admin/users/${encodeURIComponent(id)}`, { method: 'DELETE', body: {} })
+}
+
+export async function setUpstreamCredentials(username: string, password: string): Promise<void> {
+  await apiRequest('/api/app/admin/upstream-credentials', {
+    method: 'PUT', body: { username, password } as unknown as JsonValue,
+  })
+}

+ 14 - 0
src/client/api/auth.ts

@@ -17,6 +17,9 @@ function normalizeBootstrap(payload: unknown): BootstrapResponse {
     csrfToken,
     insecureLan: bool(root.insecureLan ?? root.insecure_lan),
     groupUploadsEnabled: bool(root.groupUploadsEnabled ?? root.group_uploads_enabled),
+    upstreamReady: bool(root.upstreamReady ?? root.upstream_ready),
+    upstreamError: string(root.upstreamError ?? root.upstream_error) || undefined,
+    serverKind: string(root.serverKind ?? root.server_kind) || undefined,
   }
   if (rawUser) response.user = normalizeUser(rawUser)
   setApiCsrfToken(csrfToken)
@@ -62,3 +65,14 @@ export async function fetchCurrentUser(): Promise<CurrentUser | undefined> {
   const response = await bootstrap()
   return response.user
 }
+
+export async function changeCredentials(input: {
+  currentPassword: string
+  newPassword: string
+  username?: string
+}): Promise<CurrentUser> {
+  const payload = record(unwrapData(await apiRequest<unknown>('/api/app/account/credentials', {
+    method: 'PUT', body: input as unknown as JsonValue,
+  })))
+  return normalizeUser(payload.user ?? payload)
+}

+ 7 - 3
src/client/api/pairing.ts

@@ -34,12 +34,16 @@ export interface PairedDevicesResponse {
 
 export async function createPairing(
   scopes: NodeScope[],
-  username: string,
-  password: string,
 ): Promise<PairingSession> {
   return apiRequest<PairingSession>('/api/app/pairings', {
     method: 'POST',
-    body: { scopes, username, password } as unknown as JsonValue,
+    body: { scopes } as unknown as JsonValue,
+  })
+}
+
+export async function pairChildNode(qrPayload: string, name?: string): Promise<void> {
+  await apiRequest('/api/app/groups/nodes/pair', {
+    method: 'POST', body: { qrPayload, name: name || '' } as unknown as JsonValue,
   })
 }
 

+ 36 - 0
src/client/components/app/AccountSecurityDialog.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { useAuthStore } from '@/stores/auth'
+
+const props = defineProps<{ open: boolean }>()
+const emit = defineEmits<{ close: [] }>()
+const auth = useAuthStore()
+const username = ref('')
+const currentPassword = ref('')
+const newPassword = ref('')
+const confirmation = ref('')
+const busy = ref(false)
+const error = ref('')
+
+async function save() {
+  if (newPassword.value !== confirmation.value) { error.value = '两次输入的新密码不一致'; return }
+  busy.value = true; error.value = ''
+  try {
+    await auth.changeCredentials({
+      currentPassword: currentPassword.value,
+      newPassword: newPassword.value,
+      username: auth.user?.role === 'admin' ? username.value.trim() : undefined,
+    })
+    emit('close')
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '修改账号失败' }
+  finally { busy.value = false }
+}
+watch(() => props.open, open => {
+  if (open) { username.value = auth.user?.username || ''; currentPassword.value = ''; newPassword.value = ''; confirmation.value = ''; error.value = '' }
+})
+</script>
+
+<template><Teleport to="body"><div v-if="open" class="layer" @mousedown.self="emit('close')"><section role="dialog" aria-modal="true" aria-label="账号安全"><header><h2>账号安全</h2><button class="icon-button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button></header><form @submit.prevent="save"><label v-if="auth.user?.role === 'admin'"><span>管理员用户名</span><input v-model="username" autocomplete="username" /></label><label><span>当前密码</span><input v-model="currentPassword" type="password" autocomplete="current-password" /></label><label><span>新密码</span><input v-model="newPassword" type="password" autocomplete="new-password" placeholder="至少 8 个字符" /></label><label><span>确认新密码</span><input v-model="confirmation" type="password" autocomplete="new-password" /></label><p v-if="error">{{ error }}</p><button class="solid-button" :disabled="busy || !currentPassword || newPassword.length < 8 || newPassword !== confirmation">{{ busy ? '正在保存…' : '保存修改' }}</button></form></section></div></Teleport></template>
+
+<style scoped>.layer{position:fixed;z-index:285;inset:0;display:grid;place-items:center;padding:20px;background:var(--scrim);backdrop-filter:blur(6px)}section{width:min(420px,100%);padding:19px;border:1px solid var(--line);border-radius:18px;background:var(--surface-raised);box-shadow:var(--shadow-float)}header{display:flex;align-items:center;justify-content:space-between}h2{margin:0;font-size:18px}form{display:grid;gap:12px;margin-top:18px}label{display:grid;gap:5px;color:var(--text-secondary);font-size:10px}input{height:40px;padding:0 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface-soft);color:var(--text-primary)}p{margin:0;color:var(--danger);font-size:10px}.solid-button{min-height:41px}@media(max-width:520px){.layer{place-items:end center;padding:0}section{border-radius:18px 18px 0 0;padding-bottom:max(20px,env(safe-area-inset-bottom))}}</style>

+ 70 - 18
src/client/components/app/NodePairingDialog.vue

@@ -4,6 +4,7 @@ import QRCode from 'qrcode'
 import AppIcon from '@/components/common/AppIcon.vue'
 import {
   createPairing,
+  pairChildNode,
   pairedDevices,
   pairingStatus,
   revokePairedDevice,
@@ -27,10 +28,17 @@ const devices = ref<PairedDevice[]>([])
 const nodeID = ref('')
 const busy = ref(false)
 const error = ref('')
-const username = ref('')
-const password = ref('')
+const childCode = ref('')
+const childName = ref('')
+const scanVideo = ref<HTMLVideoElement>()
+const scanning = ref(false)
 const now = ref(Date.now())
 let timer: number | undefined
+let scanTimer: number | undefined
+let scanStream: MediaStream | undefined
+
+type BarcodeDetectorLike = { detect(source: ImageBitmapSource): Promise<Array<{ rawValue?: string }>> }
+type BarcodeDetectorConstructor = new (options: { formats: string[] }) => BarcodeDetectorLike
 
 const secondsRemaining = computed(() => pairing.value
   ? Math.max(0, Math.ceil((pairing.value.expiresAt - now.value) / 1_000))
@@ -41,6 +49,38 @@ function stopPolling() {
   timer = undefined
 }
 
+function stopScanning() {
+  if (scanTimer !== undefined) window.clearInterval(scanTimer)
+  scanTimer = undefined
+  scanStream?.getTracks().forEach(track => track.stop())
+  scanStream = undefined
+  scanning.value = false
+}
+
+async function startScanning() {
+  const Detector = (window as unknown as { BarcodeDetector?: BarcodeDetectorConstructor }).BarcodeDetector
+  if (!Detector || !navigator.mediaDevices?.getUserMedia) {
+    error.value = '当前浏览器不支持直接扫码,请粘贴配对码。'
+    return
+  }
+  stopScanning(); error.value = ''
+  try {
+    scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
+    scanning.value = true
+    await new Promise(resolve => window.setTimeout(resolve, 0))
+    if (scanVideo.value) { scanVideo.value.srcObject = scanStream; await scanVideo.value.play() }
+    const detector = new Detector({ formats: ['qr_code'] })
+    scanTimer = window.setInterval(() => {
+      const video = scanVideo.value
+      if (!video || video.readyState < 2) return
+      void detector.detect(video).then(values => {
+        const payload = values.find(value => value.rawValue?.startsWith('yaoyao://pair'))?.rawValue
+        if (payload) { childCode.value = payload; stopScanning() }
+      }).catch(() => undefined)
+    }, 350)
+  } catch { error.value = '无法使用摄像头,请检查权限或直接粘贴配对码。'; stopScanning() }
+}
+
 async function refreshDevices() {
   const response = await pairedDevices()
   devices.value = response.devices
@@ -74,12 +114,7 @@ async function beginPairing() {
   error.value = ''
   stopPolling()
   try {
-    const created = await createPairing(
-      ALL_SCOPES,
-      username.value.trim(),
-      password.value,
-    )
-    password.value = ''
+    const created = await createPairing(ALL_SCOPES)
     pairing.value = created
     now.value = Date.now()
     qrImage.value = await QRCode.toDataURL(created.qrPayload, {
@@ -96,6 +131,20 @@ async function beginPairing() {
   }
 }
 
+async function addChild() {
+  busy.value = true
+  error.value = ''
+  try {
+    await pairChildNode(childCode.value.trim(), childName.value.trim() || undefined)
+    childCode.value = ''
+    childName.value = ''
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '添加子节点失败'
+  } finally {
+    busy.value = false
+  }
+}
+
 async function revoke(device: PairedDevice) {
   if (!window.confirm(`撤销“${device.name}”的 Hermes 访问权限?`)) return
   busy.value = true
@@ -112,12 +161,11 @@ async function revoke(device: PairedDevice) {
 
 watch(() => props.open, open => {
   stopPolling()
+  stopScanning()
   pairing.value = undefined
   qrImage.value = ''
   error.value = ''
-  password.value = ''
   if (open) {
-    username.value = props.userName
     busy.value = true
     void refreshDevices()
       .catch(cause => { error.value = cause instanceof Error ? cause.message : '读取配对设备失败' })
@@ -125,7 +173,7 @@ watch(() => props.open, open => {
   }
 })
 
-onBeforeUnmount(stopPolling)
+onBeforeUnmount(() => { stopPolling(); stopScanning() })
 </script>
 
 <template>
@@ -142,12 +190,6 @@ onBeforeUnmount(stopPolling)
           <p v-if="insecureTransport" class="pairing-warning"><AppIcon name="alert" :size="15" />当前是局域网 HTTP,二维码免密配对仅适用于可信网络。正式使用请配置 HTTPS/WSS 或 Tailscale。</p>
           <p v-if="error" class="pairing-error">{{ error }}</p>
 
-          <div v-if="!pairing" class="pairing-credentials">
-            <label><span>Hermes 用户名</span><input v-model="username" autocomplete="username" /></label>
-            <label><span>密码</span><input v-model="password" type="password" autocomplete="current-password" @keydown.enter="beginPairing" /></label>
-            <small>密码只用于向 Hermes 创建一份独立的设备会话,不写入二维码,也不会由夭夭 Web 保存。</small>
-          </div>
-
           <section v-if="pairing" class="qr-card">
             <img v-if="qrImage" :src="qrImage" alt="添加此 Hermes 节点的二维码" />
             <strong>用夭夭 iOS 扫描</strong>
@@ -155,7 +197,7 @@ onBeforeUnmount(stopPolling)
             <span v-else>二维码已失效</span>
             <button v-if="!secondsRemaining" class="quiet-button" type="button" @click="beginPairing">重新生成</button>
           </section>
-          <button v-else class="solid-button create-pairing" type="button" :disabled="busy || !username.trim() || !password" @click="beginPairing">
+          <button v-else class="solid-button create-pairing" type="button" :disabled="busy" @click="beginPairing">
             <AppIcon name="users" :size="17" />{{ busy ? '正在准备…' : '生成手机配对二维码' }}
           </button>
 
@@ -167,6 +209,14 @@ onBeforeUnmount(stopPolling)
               <button type="button" :disabled="busy" @click="revoke(device)">撤销</button>
             </div>
           </section>
+          <section class="device-section child-section">
+            <div class="device-heading"><strong>添加 8800 子节点</strong><small>只管理直接子节点</small></div>
+            <label><span>节点名称(可选)</span><input v-model="childName" placeholder="例如:办公室 Mac" /></label>
+            <label><span>子节点配对码</span><textarea v-model="childCode" rows="3" placeholder="粘贴子节点生成的 yaoyao://pair…" /></label>
+            <video v-if="scanning" ref="scanVideo" class="scanner-video" muted playsinline />
+            <button class="quiet-button" type="button" @click="scanning ? stopScanning() : startScanning()">{{ scanning ? '停止扫码' : '使用摄像头扫码' }}</button>
+            <button class="solid-button create-pairing" type="button" :disabled="busy || !childCode.trim()" @click="addChild">验证并添加子节点</button>
+          </section>
         </section>
       </div>
     </Transition>
@@ -184,6 +234,8 @@ header { display: flex; align-items: flex-start; justify-content: space-between;
 .qr-card { display: grid; justify-items: center; gap: 7px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: #fff; color: #191918; }.qr-card img { width: min(296px, 100%); aspect-ratio: 1; }.qr-card strong { font-size: 12px; }.qr-card span { color: #686865; font-size: 10px; }.qr-card .quiet-button { color: #191918; }
 .device-section { margin-top: 19px; padding-top: 15px; border-top: 1px solid var(--line); }.device-heading { display: flex; justify-content: space-between; margin-bottom: 8px; }.device-heading strong { font-size: 11px; }.device-heading small, .device-empty { color: var(--text-muted); font-size: 9px; }.device-empty { margin: 12px 1px; }
 .device-row { display: flex; align-items: center; gap: 12px; min-height: 47px; padding: 6px 2px; border-bottom: 1px solid var(--line); }.device-row span { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.device-row b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.device-row small { color: var(--text-muted); font-size: 9px; }.device-row button { border: 0; background: transparent; color: var(--danger); font-size: 10px; cursor: pointer; }
+.child-section { display: grid; gap: 9px; }.child-section label { display: grid; gap: 5px; color: var(--text-secondary); font-size: 10px; }.child-section input,.child-section textarea { padding: 9px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-soft); color: var(--text-primary); font: inherit; resize: vertical; }
+.scanner-video { width: 100%; max-height: 260px; border-radius: 11px; background: #111; object-fit: cover; }
 .pairing-fade-enter-active, .pairing-fade-leave-active { transition: opacity 140ms ease; }.pairing-fade-enter-active .pairing-dialog, .pairing-fade-leave-active .pairing-dialog { transition: transform 170ms var(--ease-out); }.pairing-fade-enter-from, .pairing-fade-leave-to { opacity: 0; }.pairing-fade-enter-from .pairing-dialog, .pairing-fade-leave-to .pairing-dialog { transform: translateY(8px) scale(.985); }
 @media (max-width: 540px) { .pairing-layer { place-items: end center; padding: 0; }.pairing-dialog { width: 100%; max-height: calc(100vh - 12px); border-radius: 19px 19px 0 0; padding-bottom: max(18px, env(safe-area-inset-bottom)); } }
 </style>

+ 50 - 0
src/client/components/app/SystemManagementDialog.vue

@@ -0,0 +1,50 @@
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import { createUser, deleteUser, listUsers, setUpstreamCredentials, updateUser, type ManagedUser } from '@/api/admin'
+
+const props = defineProps<{ open: boolean; upstreamReady?: boolean; upstreamError?: string }>()
+const emit = defineEmits<{ close: [] }>()
+const users = ref<ManagedUser[]>([])
+const username = ref('')
+const password = ref('')
+const upstreamUsername = ref('admin')
+const upstreamPassword = ref('')
+const busy = ref(false)
+const error = ref('')
+
+async function refresh() { users.value = await listUsers() }
+async function run(action: () => Promise<void>) {
+  busy.value = true; error.value = ''
+  try { await action(); await refresh() } catch (cause) { error.value = cause instanceof Error ? cause.message : '操作失败' }
+  finally { busy.value = false }
+}
+function add() { void run(async () => { await createUser(username.value.trim(), password.value); username.value = ''; password.value = '' }) }
+function toggle(user: ManagedUser) { void run(async () => { await updateUser(user.id, { enabled: !user.enabled }) }) }
+function reset(user: ManagedUser) {
+  const value = window.prompt(`为“${user.username}”设置临时密码(至少 8 个字符)`)
+  if (value) void run(async () => { await updateUser(user.id, { password: value }) })
+}
+function remove(user: ManagedUser) {
+  if (window.confirm(`删除用户“${user.username}”?`)) void run(async () => { await deleteUser(user.id) })
+}
+async function saveUpstream() {
+  await run(async () => { await setUpstreamCredentials(upstreamUsername.value.trim(), upstreamPassword.value); upstreamPassword.value = '' })
+}
+watch(() => props.open, open => { if (open) void refresh().catch(cause => { error.value = cause instanceof Error ? cause.message : '读取用户失败' }) })
+</script>
+
+<template>
+  <Teleport to="body"><div v-if="open" class="layer" @mousedown.self="emit('close')"><section role="dialog" aria-modal="true" aria-label="系统管理">
+    <header><div><small>ADMIN</small><h2>系统管理</h2></div><button class="icon-button" type="button" aria-label="关闭" @click="emit('close')"><AppIcon name="close" /></button></header>
+    <p v-if="error" class="error">{{ error }}</p>
+    <div class="block"><h3>用户</h3><div v-for="user in users" :key="user.id" class="user"><span><b>{{ user.username }}</b><small>{{ user.role === 'admin' ? '管理员' : user.enabled ? (user.mustChangePassword ? '等待修改临时密码' : '普通用户') : '已禁用' }}</small></span><template v-if="user.role !== 'admin'"><button @click="reset(user)">重置密码</button><button @click="toggle(user)">{{ user.enabled ? '禁用' : '启用' }}</button><button class="danger" @click="remove(user)">删除</button></template></div>
+      <form @submit.prevent="add"><input v-model="username" placeholder="新用户名" autocomplete="off" /><input v-model="password" type="password" placeholder="临时密码(至少 8 位)" autocomplete="new-password" /><button class="solid-button" :disabled="busy || !username.trim() || password.length < 8">创建用户</button></form>
+    </div>
+    <div class="block"><h3>9119 服务账号</h3><p :class="{ ok: upstreamReady }">{{ upstreamReady ? '上游连接正常' : upstreamError || '尚未验证上游连接' }}</p><form @submit.prevent="saveUpstream"><input v-model="upstreamUsername" placeholder="9119 用户名" autocomplete="off" /><input v-model="upstreamPassword" type="password" placeholder="9119 密码" autocomplete="new-password" /><button class="solid-button" :disabled="busy || !upstreamUsername.trim() || !upstreamPassword">验证并保存</button></form></div>
+  </section></div></Teleport>
+</template>
+
+<style scoped>
+.layer { position: fixed; z-index: 280; inset: 0; display: grid; place-items: center; padding: 20px; background: var(--scrim); backdrop-filter: blur(6px); }.layer > section { width: min(620px, 100%); max-height: calc(100vh - 40px); overflow: auto; padding: 19px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface-raised); box-shadow: var(--shadow-float); }header { display: flex; justify-content: space-between; }header small { color: var(--text-muted); font-size: 9px; }h2 { margin: 3px 0 0; font-size: 19px; }.block { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line); }.block h3 { margin: 0 0 10px; font-size: 12px; }.block p { color: var(--danger); font-size: 10px; }.block p.ok { color: var(--success, #21845b); }.user { display: flex; min-height: 48px; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); }.user span { display: flex; min-width: 0; flex: 1; flex-direction: column; }.user b { font-size: 11px; }.user small { color: var(--text-muted); font-size: 9px; }.user button { border: 0; background: transparent; color: var(--text-secondary); font-size: 9px; cursor: pointer; }.user button.danger,.error { color: var(--danger); }form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-top: 12px; }input { min-width: 0; height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-soft); color: var(--text-primary); }.solid-button { min-height: 38px; }@media(max-width:600px){.layer{place-items:end center;padding:0}.layer>section{max-height:calc(100vh - 12px);border-radius:18px 18px 0 0}form{grid-template-columns:1fr}.user{flex-wrap:wrap;padding:8px 0}.user span{flex-basis:100%}}
+</style>

+ 20 - 4
src/client/components/app/WorkspaceShell.vue

@@ -7,6 +7,8 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
 import NodePairingDialog from '@/components/app/NodePairingDialog.vue'
 import SystemUpdateDialog from '@/components/app/SystemUpdateDialog.vue'
+import SystemManagementDialog from '@/components/app/SystemManagementDialog.vue'
+import AccountSecurityDialog from '@/components/app/AccountSecurityDialog.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 
 type NavItem = {
@@ -33,6 +35,9 @@ const props = withDefaults(defineProps<{
   sidebarFocusMode?: boolean
   inspectorOpen?: boolean
   inspectorCloseLabel?: string
+  isAdmin?: boolean
+  upstreamReady?: boolean
+  upstreamError?: string
 }>(), {
   userName: '',
   pairingUserName: '',
@@ -46,6 +51,9 @@ const props = withDefaults(defineProps<{
   sidebarFocusMode: false,
   inspectorOpen: false,
   inspectorCloseLabel: '关闭预览',
+  isAdmin: false,
+  upstreamReady: false,
+  upstreamError: '',
 })
 
 const emit = defineEmits<{
@@ -64,6 +72,8 @@ const sidebarCollapsed = ref(false)
 const sidebarSearchOpen = ref(false)
 const nodePairingOpen = ref(false)
 const systemUpdateOpen = ref(false)
+const systemManagementOpen = ref(false)
+const accountSecurityOpen = ref(false)
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
 
@@ -272,7 +282,7 @@ onBeforeUnmount(() => {
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
           </button>
-          <button class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true">
+          <button v-if="isAdmin" class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true">
             <AppIcon name="users" :size="17" />
           </button>
           <Transition name="menu-fade">
@@ -289,7 +299,9 @@ onBeforeUnmount(() => {
                 <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
               <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>编辑当前 Agent</strong></button>
-              <button class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
+              <button class="profile-menu__edit" type="button" @click="profileMenuOpen = false; accountSecurityOpen = true"><AppIcon name="settings" :size="15" /><strong>账号安全</strong></button>
+              <button v-if="isAdmin" class="profile-menu__edit" type="button" @click="profileMenuOpen = false; systemManagementOpen = true"><AppIcon name="settings" :size="15" /><strong>系统管理</strong></button>
+              <button v-if="isAdmin" class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
               <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
                 <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
               </button>
@@ -383,7 +395,7 @@ onBeforeUnmount(() => {
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
           </button>
-          <button class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true; mobileDrawerOpen = false">
+          <button v-if="isAdmin" class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true; mobileDrawerOpen = false">
             <AppIcon name="users" :size="17" />
           </button>
           <Transition name="menu-fade">
@@ -400,7 +412,9 @@ onBeforeUnmount(() => {
                 <AppIcon v-if="profile.name === activeProfile?.name" name="check" :size="15" />
               </button>
               <button class="profile-menu__edit" type="button" :disabled="!activeProfile" @click="profileMenuOpen = false; emit('editProfile')"><AppIcon name="settings" :size="15" /><strong>编辑当前 Agent</strong></button>
-              <button class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true; mobileDrawerOpen = false"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
+              <button class="profile-menu__edit" type="button" @click="profileMenuOpen = false; accountSecurityOpen = true; mobileDrawerOpen = false"><AppIcon name="settings" :size="15" /><strong>账号安全</strong></button>
+              <button v-if="isAdmin" class="profile-menu__edit" type="button" @click="profileMenuOpen = false; systemManagementOpen = true; mobileDrawerOpen = false"><AppIcon name="settings" :size="15" /><strong>系统管理</strong></button>
+              <button v-if="isAdmin" class="profile-menu__update" type="button" @click="profileMenuOpen = false; systemUpdateOpen = true; mobileDrawerOpen = false"><AppIcon name="download" :size="15" /><strong>系统更新</strong></button>
               <button class="profile-menu__logout" type="button" @click="profileMenuOpen = false; emit('logout')">
                 <AppIcon name="logout" :size="15" /><strong>退出登录</strong>
               </button>
@@ -427,6 +441,8 @@ onBeforeUnmount(() => {
 
     <NodePairingDialog :open="nodePairingOpen" :insecure-transport="insecureTransport" :user-name="pairingUserName || userName" @close="nodePairingOpen = false" />
     <SystemUpdateDialog :open="systemUpdateOpen" @close="systemUpdateOpen = false" />
+    <SystemManagementDialog :open="systemManagementOpen" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @close="systemManagementOpen = false" />
+    <AccountSecurityDialog :open="accountSecurityOpen" @close="accountSecurityOpen = false" />
   </div>
 </template>
 

+ 3 - 0
src/client/components/workspace/WorkspaceView.vue

@@ -65,6 +65,9 @@ onBeforeUnmount(() => window.removeEventListener('focus', refreshOnWindowFocus))
     :profiles="auth.profiles"
     :theme="theme.resolvedTheme"
     :insecure-transport="auth.insecureLan"
+    :is-admin="auth.user?.role === 'admin'"
+    :upstream-ready="auth.upstreamReady"
+    :upstream-error="auth.upstreamError"
     :sidebar-title="sidebarTitle"
     :sidebar-subtitle="sidebarSubtitle"
     :sidebar-context-title="sidebarContextTitle"

+ 11 - 1
src/client/stores/auth.ts

@@ -18,6 +18,8 @@ export const useAuthStore = defineStore('auth', () => {
   const authRequired = ref(true)
   const insecureLan = ref(false)
   const groupUploadsEnabled = ref(false)
+  const upstreamReady = ref(false)
+  const upstreamError = ref<string>()
 
   const activeProfile = computed(() => profiles.value.find(profile => profile.name === activeProfileName.value)
     ?? profiles.value.find(profile => profile.isDefault)
@@ -29,6 +31,8 @@ export const useAuthStore = defineStore('auth', () => {
     csrfToken.value = response.csrfToken
     insecureLan.value = Boolean(response.insecureLan)
     groupUploadsEnabled.value = Boolean(response.groupUploadsEnabled)
+    upstreamReady.value = Boolean(response.upstreamReady)
+    upstreamError.value = response.upstreamError
     profiles.value = response.profiles
     user.value = response.user ?? (!response.authRequired
       ? { id: 'local', username: '本机 Hermes', role: 'local' }
@@ -108,8 +112,14 @@ export const useAuthStore = defineStore('auth', () => {
     }))
   }
 
+  async function changeCredentials(input: { currentPassword: string; newPassword: string; username?: string }): Promise<void> {
+    user.value = await authApi.changeCredentials(input)
+    await bootstrap()
+  }
+
   return {
     status, user, profiles, activeProfileName, activeProfile, csrfToken, error, authRequired, insecureLan, groupUploadsEnabled,
-    isAuthenticated, bootstrap, login, logout, selectProfile, refreshProfiles, refreshProfileAvatars, expire,
+    upstreamReady, upstreamError, isAuthenticated, bootstrap, login, logout, selectProfile, refreshProfiles, refreshProfileAvatars,
+    changeCredentials, expire,
   }
 })

+ 2 - 0
src/client/utils/normalize.ts

@@ -69,6 +69,8 @@ export function normalizeUser(value: unknown): CurrentUser {
     email: string(source.email) || undefined,
     provider: string(source.provider) || undefined,
     role: string(source.role) || undefined,
+    enabled: source.enabled === undefined ? undefined : bool(source.enabled),
+    mustChangePassword: bool(pick(source, 'mustChangePassword', 'must_change_password')),
   }
 }
 

+ 2 - 4
src/client/views/LoginView.vue

@@ -9,7 +9,6 @@ const auth = useAuthStore()
 const theme = useThemeStore()
 const username = ref('')
 const password = ref('')
-const provider = ref('')
 const localError = ref('')
 const submitting = computed(() => auth.status === 'authenticating')
 
@@ -17,7 +16,7 @@ async function login() {
   if (!username.value.trim() || !password.value) return
   localError.value = ''
   try {
-    await auth.login({ username: username.value.trim(), password: password.value, provider: provider.value || undefined })
+    await auth.login({ username: username.value.trim(), password: password.value })
   } catch (error) {
     localError.value = error instanceof Error ? error.message : '登录失败,请检查账号和密码'
     password.value = ''
@@ -36,12 +35,11 @@ async function login() {
     <section class="login-panel" aria-labelledby="login-title">
       <BrandMark :size="72" />
       <div class="login-copy">
-        <h1 id="login-title">登录 Hermes</h1>
+        <h1 id="login-title">登录夭夭</h1>
       </div>
       <form @submit.prevent="login">
         <label><span>账号</span><input v-model="username" name="username" autocomplete="username" autofocus required placeholder="用户名" /></label>
         <label><span>密码</span><input id="password" v-model="password" name="password" type="password" autocomplete="current-password" required placeholder="密码" /></label>
-        <label class="provider"><span>认证方式 <small>可选</small></span><input v-model="provider" name="provider" autocomplete="off" placeholder="使用服务器默认设置" /></label>
         <p v-if="localError || auth.error" class="login-error" role="alert"><AppIcon name="alert" :size="14" />{{ localError || auth.error }}</p>
         <button class="login-submit solid-button" type="submit" :disabled="submitting || !username.trim() || !password">
           <span>{{ submitting ? '正在登录…' : '登录' }}</span><AppIcon v-if="!submitting" name="arrow-up" :size="15" />

+ 54 - 0
src/client/views/PasswordChangeView.vue

@@ -0,0 +1,54 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import BrandMark from '@/components/common/BrandMark.vue'
+import { useAuthStore } from '@/stores/auth'
+
+const auth = useAuthStore()
+const username = ref(auth.user?.username || '')
+const currentPassword = ref('')
+const newPassword = ref('')
+const confirmation = ref('')
+const busy = ref(false)
+const error = ref('')
+const canSubmit = computed(() => currentPassword.value && newPassword.value.length >= 8
+  && newPassword.value === confirmation.value && username.value.trim())
+
+async function submit() {
+  if (!canSubmit.value) return
+  busy.value = true
+  error.value = ''
+  try {
+    await auth.changeCredentials({
+      currentPassword: currentPassword.value,
+      newPassword: newPassword.value,
+      username: auth.user?.role === 'admin' ? username.value.trim() : undefined,
+    })
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '修改密码失败'
+  } finally {
+    busy.value = false
+  }
+}
+</script>
+
+<template>
+  <main class="password-change">
+    <section>
+      <BrandMark :size="68" />
+      <div><h1>先修改初始密码</h1><p>默认密码或管理员重置的临时密码不能继续用于工作区。</p></div>
+      <form @submit.prevent="submit">
+        <label v-if="auth.user?.role === 'admin'"><span>管理员用户名</span><input v-model="username" autocomplete="username" /></label>
+        <label><span>当前密码</span><input v-model="currentPassword" type="password" autocomplete="current-password" /></label>
+        <label><span>新密码</span><input v-model="newPassword" type="password" autocomplete="new-password" placeholder="至少 8 个字符" /></label>
+        <label><span>确认新密码</span><input v-model="confirmation" type="password" autocomplete="new-password" /></label>
+        <p v-if="newPassword && confirmation && newPassword !== confirmation" class="error">两次输入的新密码不一致</p>
+        <p v-else-if="error" class="error">{{ error }}</p>
+        <button class="solid-button" type="submit" :disabled="busy || !canSubmit">{{ busy ? '正在保存…' : '保存并进入夭夭' }}</button>
+      </form>
+    </section>
+  </main>
+</template>
+
+<style scoped>
+.password-change { display: grid; min-height: 100%; place-items: center; padding: 24px; background: var(--canvas); }.password-change > section { width: min(380px, 100%); }.password-change :deep(.brand-mark) { display: flex; justify-content: center; }.password-change > section > div { margin: 28px 0 22px; text-align: center; }h1 { margin: 0 0 8px; font-size: 19px; }p { margin: 0; color: var(--text-secondary); font-size: 11px; line-height: 1.6; }form { display: grid; gap: 13px; }label { display: grid; gap: 6px; color: var(--text-secondary); font-size: 10px; }input { height: 43px; padding: 0 11px; border: 1px solid var(--line); border-radius: 11px; outline: none; background: var(--surface-raised); color: var(--text-primary); }input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.error { color: var(--danger); }.solid-button { min-height: 43px; margin-top: 3px; }
+</style>

+ 59 - 0
src/server/app.ts

@@ -10,6 +10,7 @@ import { loadServerConfig } from './config.js'
 import { errorMessage, HttpError } from './errors.js'
 import { RealtimeLeaseStore } from './leases.js'
 import { NodePairingStore } from './pairing.js'
+import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
 import { createApiRouter } from './routes.js'
 import {
   applySecurityHeaders,
@@ -30,6 +31,8 @@ export interface ApplicationOptions {
   pairings?: NodePairingStore
   uploads?: UploadStore
   updates?: SystemUpdateManager
+  auth?: LocalAuthStore
+  upstreamSession?: UpstreamServiceSession
 }
 
 export interface ApplicationRuntime {
@@ -41,6 +44,8 @@ export interface ApplicationRuntime {
   upstream: UpstreamClient
   uploads: UploadStore
   updates: SystemUpdateManager
+  auth: LocalAuthStore
+  upstreamSession: UpstreamServiceSession
   close(): void
 }
 
@@ -81,6 +86,17 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
   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
+  const upstreamSession = options.upstreamSession ?? new UpstreamServiceSession(
+    upstream,
+    () => auth.upstreamCredentials(configuredUpstreamCredentials),
+  )
   try {
     uploads.cleanupUncommitted()
   } catch {
@@ -141,6 +157,45 @@ export function createApplication(options: ApplicationOptions = {}): Application
       throw new HttpError(ctx.status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
     },
   }))
+  app.use(async (ctx, next) => {
+    ctx.state.localAuth = auth
+    ctx.state.upstreamSession = upstreamSession
+    const anonymous = ctx.path === '/api/app/bootstrap'
+      || ctx.path === '/api/app/login'
+      || ctx.path === '/api/status'
+      || ctx.path === '/api/auth/providers'
+      || ctx.path === '/auth/password-login'
+      || ctx.path.startsWith('/api/pair/v1/')
+      || ctx.path === '/healthz'
+      || ctx.path === '/readyz'
+    if (ctx.path.startsWith('/api/app/') && !anonymous) {
+      const allowPasswordChange = ctx.path === '/api/app/account/credentials'
+        || ctx.path === '/api/app/logout'
+      ctx.state.localUser = auth.require(ctx, allowPasswordChange)
+      const adminOnly = ctx.path.startsWith('/api/app/admin/')
+        || ctx.path.startsWith('/api/app/system/')
+        || ctx.path.startsWith('/api/app/plugins/')
+        || ctx.path.startsWith('/api/app/pairings')
+        || ctx.path.startsWith('/api/app/paired-devices')
+        || ctx.path.startsWith('/api/app/groups/nodes')
+      if (adminOnly) ctx.state.localUser = auth.requireAdmin(ctx)
+    }
+    const gatewayCompatibility = (ctx.path.startsWith('/api/') && !ctx.path.startsWith('/api/app/')
+      && !ctx.path.startsWith('/api/pair/'))
+      || ctx.path === '/auth/logout'
+    if (gatewayCompatibility && !anonymous) {
+      const allowPasswordChange = ctx.path === '/api/auth/me' || ctx.path === '/api/profiles'
+        || ctx.path === '/api/account/credentials'
+      ctx.state.localUser = auth.require(ctx, allowPasswordChange)
+      if (isMutation(ctx.method)) {
+        const origin = ctx.get('origin')
+        if (origin && !isExactOrigin(origin, ctx.get('host'), ctx.secure || Boolean(config.tlsCert), config.allowedHosts)) {
+          throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
+        }
+      }
+    }
+    await next()
+  })
 
   const router = createApiRouter({
     config,
@@ -150,6 +205,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
     pairings,
     uploads,
     updates,
+    auth,
+    upstreamSession,
   })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
@@ -172,6 +229,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
     upstream,
     uploads,
     updates,
+    auth,
+    upstreamSession,
     close: () => uploads.close(),
   }
 }

+ 12 - 0
src/server/config.ts

@@ -1,6 +1,7 @@
 import { isIP } from 'node:net'
 import { homedir } from 'node:os'
 import { basename, resolve } from 'node:path'
+import { readFileSync } from 'node:fs'
 
 export interface ServerConfig {
   host: string
@@ -22,6 +23,8 @@ export interface ServerConfig {
   releaseSource?: string
   releaseRoot?: string
   allowRemoteUpdate?: boolean
+  upstreamUsername?: string
+  upstreamPassword?: string
 }
 
 export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
@@ -142,6 +145,13 @@ export function loadServerConfig(
   const releaseSource = parseReleaseSource(env.HERMES_YAOYAO_RELEASE_SOURCE)
   const releaseRoot = resolve(env.HERMES_YAOYAO_RELEASE_ROOT?.trim() || `${homedir()}/.local/share/hermes-yaoyao`)
   const allowRemoteUpdate = flag(env.HERMES_YAOYAO_ALLOW_REMOTE_UPDATE)
+  const upstreamUsername = env.HERMES_YAOYAO_UPSTREAM_USERNAME?.trim() || undefined
+  const upstreamPasswordFile = env.HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE?.trim()
+  if (env.HERMES_YAOYAO_UPSTREAM_PASSWORD && upstreamPasswordFile) {
+    throw new Error('Set only one of HERMES_YAOYAO_UPSTREAM_PASSWORD or HERMES_YAOYAO_UPSTREAM_PASSWORD_FILE')
+  }
+  const upstreamPassword = env.HERMES_YAOYAO_UPSTREAM_PASSWORD
+    || (upstreamPasswordFile ? readFileSync(resolve(upstreamPasswordFile), 'utf8').replace(/[\r\n]+$/, '') : undefined)
   const insecureLan = !tlsCert && !isLoopbackHost(host)
   if (production && insecureLan && !allowInsecureLan) {
     throw new Error(
@@ -174,5 +184,7 @@ export function loadServerConfig(
     releaseSource,
     releaseRoot,
     allowRemoteUpdate,
+    upstreamUsername,
+    upstreamPassword,
   }
 }

+ 8 - 2
src/server/dashboardSupervisor.ts

@@ -20,6 +20,7 @@ export interface DashboardSupervisorOptions {
   launch?: Launch
   probe?: Probe
   log?: (message: string) => void
+  credentials?: { username: string; password: string }
 }
 
 function defaultRun(command: string): Run {
@@ -67,6 +68,7 @@ export class DashboardSupervisor {
   private readonly probe: Probe
   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
 
@@ -78,6 +80,10 @@ export class DashboardSupervisor {
     this.probe = options.probe ?? portIsListening
     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 {
@@ -144,11 +150,11 @@ export class DashboardSupervisor {
     let changed = false
 
     if (!username) {
-      this.set('dashboard.basic_auth.username', DEFAULT_DASHBOARD_USERNAME)
+      this.set('dashboard.basic_auth.username', this.credentials.username)
       changed = true
     }
     if (!configuredPasswordHash && !password) {
-      this.set('dashboard.basic_auth.password_hash', passwordHash(DEFAULT_DASHBOARD_PASSWORD))
+      this.set('dashboard.basic_auth.password_hash', passwordHash(this.credentials.password))
       changed = true
     }
     if (!secret) {

+ 6 - 3
src/server/index.ts

@@ -7,12 +7,15 @@ import { isLoopbackHost, loadServerConfig } from './config.js'
 import { DashboardSupervisor } from './dashboardSupervisor.js'
 
 const config = loadServerConfig()
-const dashboardSupervisor = config.superviseDashboard
-  ? new DashboardSupervisor({ allowLan: !isLoopbackHost(config.host) })
-  : undefined
 const runtime = createApplication({
   config,
 })
+const dashboardSupervisor = config.superviseDashboard
+  ? new DashboardSupervisor({
+      allowLan: !isLoopbackHost(config.host),
+      credentials: runtime.auth.upstreamCredentials(),
+    })
+  : undefined
 const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined
 

+ 476 - 0
src/server/localAuth.ts

@@ -0,0 +1,476 @@
+import {
+  createCipheriv,
+  createDecipheriv,
+  createHash,
+  randomBytes,
+  randomUUID,
+  scryptSync,
+  timingSafeEqual,
+} from 'node:crypto'
+import {
+  chmodSync,
+  mkdirSync,
+  readFileSync,
+  renameSync,
+  writeFileSync,
+} from 'node:fs'
+import { dirname, join } from 'node:path'
+import type Koa from 'koa'
+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'
+
+const SESSION_COOKIE = 'hermes_yaoyao_session'
+const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
+const USERNAME_PATTERN = /^[^\u0000-\u001f\u007f\s/@\\]{1,100}$/u
+
+export type LocalRole = 'admin' | 'user'
+
+interface StoredUser {
+  id: string
+  username: string
+  normalizedUsername: string
+  role: LocalRole
+  enabled: boolean
+  mustChangePassword: boolean
+  salt: string
+  passwordHash: string
+  authVersion: number
+  createdAt: number
+  updatedAt: number
+}
+
+interface StoredUsers { version: 1; users: StoredUser[] }
+
+interface SessionRecord {
+  userID: string
+  authVersion: number
+  expiresAt: number
+}
+interface StoredSessions { version: 1; sessions: Array<SessionRecord & { tokenHash: string }> }
+
+export interface LocalUser {
+  id: string
+  username: string
+  role: LocalRole
+  enabled: boolean
+  mustChangePassword: boolean
+  createdAt: number
+  updatedAt: number
+}
+
+export interface UpstreamCredentials { username: string; password: string }
+
+function passwordDigest(password: string, salt: Buffer): Buffer {
+  return scryptSync(password, salt, 32, {
+    N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1_024 * 1_024,
+  })
+}
+
+function canonicalUsername(value: string): string {
+  const username = value.trim()
+  if (!USERNAME_PATTERN.test(username)) {
+    throw new HttpError(400, '用户名必须为 1 到 100 个不含空格的字符', 'invalid_username')
+  }
+  return username
+}
+
+function validatePassword(value: string, allowDefault = false): string {
+  if (value.length > 4_096 || (!allowDefault && value.length < 8) || (allowDefault && !value)) {
+    throw new HttpError(400, '密码必须至少 8 个字符', 'invalid_password')
+  }
+  return value
+}
+
+function publicUser(user: StoredUser): LocalUser {
+  return {
+    id: user.id,
+    username: user.username,
+    role: user.role,
+    enabled: user.enabled,
+    mustChangePassword: user.mustChangePassword,
+    createdAt: user.createdAt,
+    updatedAt: user.updatedAt,
+  }
+}
+
+export class LocalAuthStore {
+  readonly #path: string
+  readonly #credentialPath: string
+  readonly #keyPath: string
+  readonly #sessionsPath: string
+  readonly #secureCookie: boolean
+  readonly #sessions = new Map<string, SessionRecord>()
+  #users: StoredUser[]
+
+  constructor(home: string, secureCookie = false) {
+    this.#path = join(home, 'users.json')
+    this.#credentialPath = join(home, 'upstream-credentials.enc')
+    this.#keyPath = join(home, 'local-auth.key')
+    this.#sessionsPath = join(home, 'sessions.json')
+    this.#secureCookie = secureCookie
+    this.#users = this.#loadUsers()
+    this.#loadSessions()
+  }
+
+  login(ctx: Koa.Context, usernameValue: string, password: string): LocalUser {
+    const normalized = usernameValue.trim().toLocaleLowerCase('en-US')
+    const user = this.#users.find(candidate => candidate.normalizedUsername === normalized)
+    if (!user || !user.enabled || !this.#passwordMatches(user, password)) {
+      throw new HttpError(401, '用户名或密码错误', 'login_failed')
+    }
+    const token = randomBytes(32).toString('base64url')
+    this.#sessions.set(this.#sessionKey(token), {
+      userID: user.id,
+      authVersion: user.authVersion,
+      expiresAt: Date.now() + SESSION_TTL_SECONDS * 1_000,
+    })
+    this.#saveSessions()
+    appendSetCookies(ctx, [serialize(SESSION_COOKIE, token, {
+      httpOnly: true,
+      secure: this.#secureCookie,
+      sameSite: 'strict',
+      path: '/',
+      maxAge: SESSION_TTL_SECONDS,
+    })])
+    const published = publicUser(user)
+    ctx.state.localUser = published
+    return published
+  }
+
+  logout(ctx: Koa.Context): void {
+    const token = this.#token(ctx)
+    if (token) this.#sessions.delete(this.#sessionKey(token))
+    this.#saveSessions()
+    appendSetCookies(ctx, [serialize(SESSION_COOKIE, '', {
+      httpOnly: true, secure: this.#secureCookie, sameSite: 'strict', path: '/', maxAge: 0,
+    })])
+  }
+
+  current(ctx: Koa.Context): LocalUser | undefined {
+    const injected = ctx.state.localUser as LocalUser | undefined
+    if (injected) return injected
+    const token = this.#token(ctx)
+    if (!token) return undefined
+    const key = this.#sessionKey(token)
+    const session = this.#sessions.get(key)
+    if (!session || session.expiresAt <= Date.now()) {
+      this.#sessions.delete(key)
+      this.#saveSessions()
+      return undefined
+    }
+    const user = this.#users.find(candidate => candidate.id === session.userID)
+    if (!user || !user.enabled || user.authVersion !== session.authVersion) {
+      this.#sessions.delete(key)
+      this.#saveSessions()
+      return undefined
+    }
+    return publicUser(user)
+  }
+
+  require(ctx: Koa.Context, allowPasswordChange = false): LocalUser {
+    const user = this.current(ctx)
+    if (!user) throw new HttpError(401, '请先登录夭夭', 'authentication_required')
+    if (user.mustChangePassword && !allowPasswordChange) {
+      throw new HttpError(428, '必须先修改初始密码', 'password_change_required')
+    }
+    return user
+  }
+
+  requireAdmin(ctx: Koa.Context): LocalUser {
+    const user = this.require(ctx)
+    if (user.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
+    return user
+  }
+
+  list(admin: LocalUser): LocalUser[] {
+    if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
+    return this.#users.map(publicUser).sort((a, b) => a.createdAt - b.createdAt)
+  }
+
+  create(admin: LocalUser, usernameValue: string, passwordValue: string): LocalUser {
+    if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
+    const username = canonicalUsername(usernameValue)
+    const normalizedUsername = username.toLocaleLowerCase('en-US')
+    if (this.#users.some(user => user.normalizedUsername === normalizedUsername)) {
+      throw new HttpError(409, '用户名已存在', 'username_exists')
+    }
+    const password = validatePassword(passwordValue)
+    const now = Date.now()
+    const user = this.#newUser(username, password, 'user', true, now)
+    this.#users.push(user)
+    this.#saveUsers()
+    return publicUser(user)
+  }
+
+  updateUser(admin: LocalUser, userID: string, input: { enabled?: boolean; password?: string }): LocalUser {
+    if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
+    const user = this.#users.find(candidate => candidate.id === userID)
+    if (!user) throw new HttpError(404, '用户不存在', 'user_not_found')
+    if (user.role === 'admin') throw new HttpError(409, '管理员账号请在账号设置中修改', 'admin_account_protected')
+    if (typeof input.enabled === 'boolean') user.enabled = input.enabled
+    if (input.password !== undefined) {
+      this.#setPassword(user, validatePassword(input.password), true)
+    } else {
+      user.authVersion += 1
+    }
+    user.updatedAt = Date.now()
+    this.#saveUsers()
+    return publicUser(user)
+  }
+
+  deleteUser(admin: LocalUser, userID: string): void {
+    if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
+    const user = this.#users.find(candidate => candidate.id === userID)
+    if (!user) throw new HttpError(404, '用户不存在', 'user_not_found')
+    if (user.role === 'admin') throw new HttpError(409, '不能删除管理员账号', 'admin_account_protected')
+    this.#users = this.#users.filter(candidate => candidate.id !== userID)
+    this.#saveUsers()
+    this.#revokeUser(userID)
+  }
+
+  changeCredentials(
+    ctx: Koa.Context,
+    currentPassword: string,
+    newPassword: string,
+    newUsername?: string,
+  ): LocalUser {
+    const current = this.require(ctx, true)
+    const user = this.#users.find(candidate => candidate.id === current.id)!
+    if (!this.#passwordMatches(user, currentPassword)) {
+      throw new HttpError(401, '当前密码错误', 'invalid_current_password')
+    }
+    const username = newUsername === undefined ? user.username : canonicalUsername(newUsername)
+    const normalized = username.toLocaleLowerCase('en-US')
+    if (this.#users.some(candidate => candidate.id !== user.id && candidate.normalizedUsername === normalized)) {
+      throw new HttpError(409, '用户名已存在', 'username_exists')
+    }
+    user.username = username
+    user.normalizedUsername = normalized
+    this.#setPassword(user, validatePassword(newPassword), false)
+    user.mustChangePassword = false
+    user.updatedAt = Date.now()
+    this.#saveUsers()
+    this.#revokeUser(user.id)
+    return this.login(ctx, username, newPassword)
+  }
+
+  upstreamCredentials(fallback?: UpstreamCredentials): UpstreamCredentials | undefined {
+    try {
+      const encoded = Buffer.from(readFileSync(this.#credentialPath, 'utf8').trim(), 'base64url')
+      if (encoded.byteLength < 29) return fallback
+      const key = this.#key()
+      const decipher = createDecipheriv('aes-256-gcm', key, encoded.subarray(0, 12))
+      decipher.setAuthTag(encoded.subarray(12, 28))
+      const raw = Buffer.concat([decipher.update(encoded.subarray(28)), decipher.final()]).toString('utf8')
+      const value = JSON.parse(raw) as UpstreamCredentials
+      return value.username && value.password ? value : fallback
+    } catch {
+      return fallback
+    }
+  }
+
+  setUpstreamCredentials(admin: LocalUser, credentials: UpstreamCredentials): void {
+    if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
+    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)
+    const iv = randomBytes(12)
+    const cipher = createCipheriv('aes-256-gcm', this.#key(), iv)
+    const encrypted = Buffer.concat([
+      iv,
+      cipher.update(JSON.stringify({ username, password }), 'utf8'),
+      cipher.final(),
+      cipher.getAuthTag(),
+    ])
+    // Store as IV + auth tag + ciphertext for a stable, inspectable envelope.
+    const normalized = Buffer.concat([encrypted.subarray(0, 12), encrypted.subarray(-16), encrypted.subarray(12, -16)])
+    this.#atomicWrite(this.#credentialPath, `${normalized.toString('base64url')}\n`)
+  }
+
+  #token(ctx: Koa.Context): string | undefined {
+    return parse(ctx.get('cookie') || '')[SESSION_COOKIE]
+  }
+
+  #revokeUser(userID: string): void {
+    for (const [token, session] of this.#sessions) {
+      if (session.userID === userID) this.#sessions.delete(token)
+    }
+    this.#saveSessions()
+  }
+
+  #passwordMatches(user: StoredUser, password: string): boolean {
+    try {
+      const expected = Buffer.from(user.passwordHash, 'base64')
+      const actual = passwordDigest(password, Buffer.from(user.salt, 'base64'))
+      return expected.byteLength === actual.byteLength && timingSafeEqual(expected, actual)
+    } catch {
+      return false
+    }
+  }
+
+  #setPassword(user: StoredUser, password: string, mustChange: boolean): void {
+    const salt = randomBytes(16)
+    user.salt = salt.toString('base64')
+    user.passwordHash = passwordDigest(password, salt).toString('base64')
+    user.mustChangePassword = mustChange
+    user.authVersion += 1
+  }
+
+  #newUser(username: string, password: string, role: LocalRole, mustChange: boolean, now: number): StoredUser {
+    const salt = randomBytes(16)
+    return {
+      id: randomUUID().toLowerCase(),
+      username,
+      normalizedUsername: username.toLocaleLowerCase('en-US'),
+      role,
+      enabled: true,
+      mustChangePassword: mustChange,
+      salt: salt.toString('base64'),
+      passwordHash: passwordDigest(password, salt).toString('base64'),
+      authVersion: 1,
+      createdAt: now,
+      updatedAt: now,
+    }
+  }
+
+  #loadUsers(): StoredUser[] {
+    try {
+      const value = JSON.parse(readFileSync(this.#path, 'utf8')) as StoredUsers
+      if (value.version !== 1 || !Array.isArray(value.users) || value.users.length === 0) throw new Error()
+      return value.users
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+        throw new Error('8800 用户库损坏,已停止启动以避免覆盖数据')
+      }
+      const now = Date.now()
+      const users = [this.#newUser('admin', 'admin', 'admin', true, now)]
+      this.#users = users
+      this.#saveUsers()
+      return users
+    }
+  }
+
+  #saveUsers(): void {
+    this.#atomicWrite(this.#path, `${JSON.stringify({ version: 1, users: this.#users }, null, 2)}\n`)
+  }
+
+  #sessionKey(token: string): string {
+    return createHash('sha256').update(token, 'utf8').digest('base64url')
+  }
+
+  #loadSessions(): void {
+    try {
+      const value = JSON.parse(readFileSync(this.#sessionsPath, 'utf8')) as StoredSessions
+      if (value.version !== 1 || !Array.isArray(value.sessions)) throw new Error('invalid sessions')
+      const now = Date.now()
+      for (const session of value.sessions) {
+        if (session.tokenHash && session.expiresAt > now) {
+          this.#sessions.set(session.tokenHash, {
+            userID: session.userID, authVersion: session.authVersion, expiresAt: session.expiresAt,
+          })
+        }
+      }
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+        // Corrupt sessions are safely discarded; user credentials remain authoritative.
+      }
+    }
+  }
+
+  #saveSessions(): void {
+    const sessions = [...this.#sessions.entries()].map(([tokenHash, value]) => ({ tokenHash, ...value }))
+    this.#atomicWrite(this.#sessionsPath, `${JSON.stringify({ version: 1, sessions })}\n`)
+  }
+
+  #key(): Buffer {
+    try {
+      const key = readFileSync(this.#keyPath)
+      if (key.byteLength !== 32) throw new Error('invalid key')
+      return key
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+      const key = randomBytes(32)
+      this.#atomicWrite(this.#keyPath, key)
+      return key
+    }
+  }
+
+  #atomicWrite(path: string, value: string | Buffer): void {
+    mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
+    const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
+    writeFileSync(temporary, value, { mode: 0o600 })
+    chmodSync(temporary, 0o600)
+    renameSync(temporary, path)
+  }
+}
+
+export class UpstreamServiceSession {
+  readonly #jar = new CookieJar('')
+  #loginTask: Promise<void> | undefined
+
+  constructor(
+    readonly client: UpstreamClient,
+    readonly credentials: () => UpstreamCredentials | undefined,
+  ) {}
+
+  get jar(): CookieJar { return this.#jar }
+
+  async ensure(): Promise<void> {
+    const status = await this.client.request('/api/status', this.#jar)
+    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>
+    if (parsed.auth_required !== true && parsed.authRequired !== true) return
+    const me = await this.client.request('/api/auth/me', this.#jar)
+    if (me.status >= 200 && me.status < 300) return
+    if (!this.#loginTask) this.#loginTask = this.#login().finally(() => { this.#loginTask = undefined })
+    await this.#loginTask
+  }
+
+  async request(path: string, options: UpstreamRequestOptions = {}): Promise<UpstreamResponse> {
+    await this.ensure()
+    let response = await this.client.request(path, this.#jar, options)
+    if (response.status === 401) {
+      await this.#login()
+      response = await this.client.request(path, this.#jar, options)
+    }
+    return response
+  }
+
+  async verify(credentials: UpstreamCredentials): Promise<void> {
+    await this.#authenticate(credentials, new CookieJar(''))
+  }
+
+  async #login(): Promise<void> {
+    const credentials = this.credentials()
+    if (!credentials) throw new HttpError(503, '尚未配置 9119 服务账号', 'upstream_credentials_required')
+    await this.#authenticate(credentials, this.#jar)
+  }
+
+  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')
+    const response = await this.client.request('/auth/password-login', jar, {
+      method: 'POST',
+      body: { provider: 'basic', username: credentials.username, password: credentials.password, next: '' },
+    })
+    if (response.status < 200 || response.status >= 300) {
+      throw new HttpError(503, '9119 服务账号验证失败', 'upstream_credentials_invalid')
+    }
+  }
+}

+ 274 - 83
src/server/routes.ts

@@ -6,7 +6,7 @@ import { basename, resolve, sep } from 'node:path'
 import { lookup as mimeLookup } from 'mime-types'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '../shared/types.js'
 import type { ServerConfig } from './config.js'
-import { DEFAULT_YAOYAO_PLUGIN_SOURCE, isLoopbackHost, isLoopbackUpstream } from './config.js'
+import { DEFAULT_YAOYAO_PLUGIN_SOURCE, isLoopbackHost, isLoopbackUpstream, isPrivateHost } from './config.js'
 import { HttpError } from './errors.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
 import { compareReleaseVersions } from './releases.js'
@@ -28,6 +28,7 @@ import {
 } from './upstream.js'
 import { receiveGroupUploads, uploadMarkdown, UploadStore } from './uploads.js'
 import { SystemUpdateManager, type SystemUpdateStatus } from './updateManager.js'
+import { LocalAuthStore, type LocalUser, UpstreamServiceSession } from './localAuth.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -39,6 +40,8 @@ export interface RouteDependencies {
   pairings: NodePairingStore
   uploads: UploadStore
   updates: SystemUpdateManager
+  auth: LocalAuthStore
+  upstreamSession: UpstreamServiceSession
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -332,12 +335,13 @@ function updateFailure(error: unknown): HttpError {
 }
 
 async function withJar<T>(ctx: Koa.Context, run: (jar: CookieJar) => Promise<T>): Promise<T> {
-  const jar = new CookieJar(ctx.get('cookie'))
-  try {
-    return await run(jar)
-  } finally {
-    applyUpstreamCookies(ctx, jar)
+  const service = ctx.state.upstreamSession as UpstreamServiceSession | undefined
+  if (service) {
+    await service.ensure()
+    return run(service.jar)
   }
+  const jar = new CookieJar(ctx.get('cookie'))
+  try { return await run(jar) } finally { applyUpstreamCookies(ctx, jar) }
 }
 
 function json(ctx: Koa.Context, status: number, value: unknown): void {
@@ -359,6 +363,102 @@ function pairingScopes(value: unknown): string[] | undefined {
   return value
 }
 
+async function addPairedChildNode(
+  qrPayload: string,
+  nameValue: string | undefined,
+  dependencies: RouteDependencies,
+): Promise<JsonObject> {
+  let deepLink: URL
+  try { deepLink = new URL(qrPayload.trim()) } catch {
+    throw new HttpError(400, '配对码格式无效', 'invalid_pairing_code')
+  }
+  if (deepLink.protocol !== 'yaoyao:' || deepLink.hostname !== 'pair' || deepLink.searchParams.get('v') !== '1') {
+    throw new HttpError(400, '这不是 8800 节点配对码', 'invalid_pairing_code')
+  }
+  const serviceValue = deepLink.searchParams.get('url') ?? ''
+  let serviceURL: URL
+  try { serviceURL = new URL(serviceValue) } catch {
+    throw new HttpError(400, '配对码缺少有效的 8800 地址', 'invalid_pairing_code')
+  }
+  if (!['http:', 'https:'].includes(serviceURL.protocol)
+    || serviceURL.username || serviceURL.password || serviceURL.search || serviceURL.hash
+    || (serviceURL.protocol === 'http:' && !isPrivateHost(serviceURL.hostname))) {
+    throw new HttpError(400, '子节点必须使用可信局域网 HTTP 或 HTTPS', 'invalid_node_url')
+  }
+  const requestJSON = async (url: URL, init?: RequestInit): Promise<JsonObject> => {
+    let response: Response
+    let timeout: ReturnType<typeof setTimeout> | undefined
+    try {
+      response = await Promise.race([
+        fetch(url, { ...init, redirect: 'error' }),
+        new Promise<never>((_resolve, reject) => {
+          timeout = setTimeout(() => reject(new Error('request timed out')), 20_000)
+          timeout.unref()
+        }),
+      ])
+    } catch (error) {
+      const reason = error instanceof Error ? error.message : 'connection failed'
+      throw new HttpError(502, `无法连接子 8800 节点:${reason}`, 'child_node_unavailable')
+    } finally {
+      if (timeout) clearTimeout(timeout)
+    }
+    let value: unknown
+    try { value = await response.json() } catch { value = undefined }
+    if (!response.ok || !value || typeof value !== 'object' || Array.isArray(value)) {
+      throw new HttpError(response.status || 502, '子节点拒绝了配对请求', 'child_node_rejected')
+    }
+    return value as JsonObject
+  }
+  const capabilities = await requestJSON(new URL('/api/pair/v1/capabilities', serviceURL))
+  if (capabilities.serviceType !== 'yaoyao-web') {
+    throw new HttpError(409, '子节点必须是 8800 夭夭 Web 服务', 'child_node_must_be_8800')
+  }
+  const pairingId = deepLink.searchParams.get('id') ?? ''
+  const secret = deepLink.searchParams.get('secret') ?? ''
+  const expectedNode = deepLink.searchParams.get('node') ?? ''
+  const expectedFingerprint = deepLink.searchParams.get('fingerprint') ?? ''
+  const claimed = await requestJSON(new URL('/api/pair/v1/claim', serviceURL), {
+    method: 'POST',
+    headers: { accept: 'application/json', 'content-type': 'application/json' },
+    body: JSON.stringify({ pairingId, secret, deviceName: '夭夭 8800 父节点' }),
+  })
+  if (claimed.serviceType !== 'yaoyao-web'
+    || claimed.nodeId !== expectedNode
+    || claimed.fingerprint !== expectedFingerprint
+    || typeof claimed.serverUrl !== 'string'
+    || typeof claimed.token !== 'string') {
+    throw new HttpError(502, '子节点身份与二维码不一致', 'child_node_identity_mismatch')
+  }
+  const nodeURL = new URL(claimed.serverUrl)
+  const profiles = await requestJSON(new URL(`${nodeURL.pathname.replace(/\/$/, '')}/api/profiles`, nodeURL), {
+    headers: { accept: 'application/json', authorization: `Bearer ${claimed.token}` },
+  })
+  const profileItems = normalizedProfiles(profiles).flatMap(entry => {
+    if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
+    const value = entry as JsonObject
+    const name = typeof value.name === 'string' ? value.name : ''
+    if (!name) return []
+    return [{
+      name,
+      displayName: typeof value.agentName === 'string' ? value.agentName
+        : typeof value.display_name === 'string' ? value.display_name : name,
+      model: typeof value.model === 'string' ? value.model : '',
+    }]
+  })
+  const registration = {
+    nodeId: claimed.nodeId,
+    name: nameValue?.trim() || nodeURL.host,
+    serverUrl: claimed.serverUrl,
+    fingerprint: claimed.fingerprint,
+    accessToken: claimed.token,
+    profiles: profileItems,
+  }
+  const response = await dependencies.upstreamSession.request('/api/plugins/yaoyao/v1/nodes', {
+    method: 'POST', body: registration,
+  })
+  return requireSuccess(response)
+}
+
 function pairedProxyScope(path: string, method: string): NodeScope {
   if (path === '/profiles' || path.startsWith('/profiles/')) {
     return path.includes('/sessions') ? 'history.read' : 'agents.read'
@@ -445,62 +545,61 @@ async function proxyOptionalUnread(
 async function bootstrap(
   ctx: Koa.Context,
   dependencies: RouteDependencies,
-  jar: CookieJar,
   rotateCsrf = false,
 ): Promise<void> {
-  const statusResponse = await dependencies.upstream.request('/api/status', jar)
-  if (statusResponse.status < 200 || statusResponse.status >= 300) {
-    sendUpstreamResponse(ctx, statusResponse, jar)
-    return
-  }
-  const status = parseJson(statusResponse)
-  const required = authRequired(status)
-  const identityResponse = await dependencies.upstream.request('/api/auth/me', jar)
-  if (required && [401, 403].includes(identityResponse.status)) {
+  const user = dependencies.auth.current(ctx)
+  const csrfToken = dependencies.csrf.issue(ctx, rotateCsrf)
+  if (!user) {
     json(ctx, 200, {
-      status: publicStatus(status),
-      authRequired: required,
+      status: { state: 'ready' },
+      authRequired: true,
       authenticated: false,
       profiles: [],
-      csrfToken: dependencies.csrf.issue(ctx, rotateCsrf),
+      csrfToken,
       insecureLan: dependencies.config.insecureLan,
       groupUploadsEnabled: isLoopbackUpstream(dependencies.config.upstream),
+      upstreamReady: false,
+      serverKind: 'yaoyao-web',
     })
     return
   }
-  if (identityResponse.status < 200 || identityResponse.status >= 300) {
-    // An auth-disabled local Gateway may not expose an identity route.
-    if (required) {
-      sendUpstreamResponse(ctx, identityResponse, jar)
-      return
+  let status: JsonObject = { state: user.mustChangePassword ? 'password_change_required' : 'degraded' }
+  let profiles: unknown[] = []
+  let upstreamReady = false
+  let upstreamError: string | undefined
+  if (!user.mustChangePassword) {
+    try {
+      const statusResponse = await dependencies.upstreamSession.request('/api/status')
+      const rawStatus = requireSuccess(statusResponse)
+      status = publicStatus(rawStatus)
+      const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
+      const pluginProfilesResponse = await dependencies.upstreamSession.request('/api/plugins/yaoyao/profiles')
+      profiles = profilesWithHermesBotNames(
+        normalizedProfiles(requireSuccess(profilesResponse)),
+        pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300
+          ? parseJson(pluginProfilesResponse) : undefined,
+      )
+      upstreamReady = true
+    } catch (error) {
+      upstreamError = error instanceof Error ? error.message : '9119 不可用'
     }
   }
-  const profilesResponse = await dependencies.upstream.request('/api/profiles', jar)
-  if (profilesResponse.status < 200 || profilesResponse.status >= 300) {
-    sendUpstreamResponse(ctx, profilesResponse, jar)
-    return
-  }
-  const identity = identityResponse.status >= 200 && identityResponse.status < 300
-    ? parseJson(identityResponse)
-    : { user_id: 'local', display_name: '本机 Hermes', provider: 'local' }
-  const pluginProfilesResponse = await dependencies.upstream.request('/api/plugins/yaoyao/profiles', jar)
-  const profiles = profilesWithHermesBotNames(
-    normalizedProfiles(parseJson(profilesResponse)),
-    pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300 ? parseJson(pluginProfilesResponse) : undefined,
-  )
   json(ctx, 200, {
-    status: publicStatus(status),
-    authRequired: required,
+    status,
+    authRequired: true,
     authenticated: true,
-    user: identity.user && typeof identity.user === 'object' ? identity.user : identity,
+    user,
     profiles,
-    csrfToken: dependencies.csrf.issue(ctx, rotateCsrf),
+    csrfToken,
     insecureLan: dependencies.config.insecureLan,
     groupUploadsEnabled: isLoopbackUpstream(dependencies.config.upstream),
+    upstreamReady,
+    upstreamError,
+    serverKind: 'yaoyao-web',
   })
 }
 
-async function login(ctx: Koa.Context, dependencies: RouteDependencies, jar: CookieJar): Promise<void> {
+async function login(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
   const request = body(ctx)
   const username = typeof request.username === 'string' ? request.username.trim() : ''
   const password = typeof request.password === 'string' ? request.password : ''
@@ -508,30 +607,8 @@ async function login(ctx: Koa.Context, dependencies: RouteDependencies, jar: Coo
     throw new HttpError(400, 'Username and password are required', 'invalid_credentials')
   }
 
-  const providersResponse = await dependencies.upstream.request('/api/auth/providers', jar)
-  const providersBody = requireSuccess(providersResponse)
-  const providers = Array.isArray(providersBody.providers) ? providersBody.providers : []
-  const passwordProviders = providers.filter((entry): entry is JsonObject =>
-    Boolean(entry && typeof entry === 'object' && !Array.isArray(entry)
-      && (entry as JsonObject).supports_password === true),
-  )
-  const requestedProvider = typeof request.provider === 'string' ? request.provider.trim() : ''
-  const provider = requestedProvider
-    ? passwordProviders.find((entry) => entry.name === requestedProvider)
-    : passwordProviders.find((entry) => String(entry.name).toLowerCase() === 'basic')
-      ?? (passwordProviders.length === 1 ? passwordProviders[0] : undefined)
-  if (!provider || typeof provider.name !== 'string') {
-    throw new HttpError(403, 'Hermes does not offer the requested password provider', 'password_login_unavailable')
-  }
-
-  const response = await dependencies.upstream.request('/auth/password-login', jar, {
-    method: 'POST',
-    body: { provider: provider.name, username, password, next: '' },
-    clientAddress: ctx.req.socket.remoteAddress,
-  })
-  const result = requireSuccess(response)
-  if (result.ok !== true) throw new HttpError(401, 'Hermes rejected the login', 'login_failed')
-  await bootstrap(ctx, dependencies, jar, true)
+  dependencies.auth.login(ctx, username, password)
+  await bootstrap(ctx, dependencies, true)
 }
 
 async function independentPairingCookies(
@@ -759,16 +836,59 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     json(ctx, 200, { ok: true })
   })
 
+  // Hermes Gateway-compatible authentication surface for native clients.
+  // Content requests are still executed through the server-owned 9119 session.
+  router.get('/api/status', (ctx) => {
+    json(ctx, 200, {
+      overall: 'ready',
+      auth_required: true,
+      gateway_running: true,
+      server_kind: 'yaoyao-web',
+      node_transport: '8800',
+    })
+  })
+  router.get('/api/auth/providers', (ctx) => {
+    json(ctx, 200, { providers: [{ name: 'basic', supports_password: true }] })
+  })
+  router.post('/auth/password-login', async (ctx) => {
+    const request = body(ctx)
+    const username = typeof request.username === 'string' ? request.username : ''
+    const password = typeof request.password === 'string' ? request.password : ''
+    const user = dependencies.auth.login(ctx, username, password)
+    json(ctx, 200, { ok: true, must_change_password: user.mustChangePassword, server_kind: 'yaoyao-web' })
+  })
+  router.post('/auth/logout', (ctx) => {
+    dependencies.auth.logout(ctx)
+    json(ctx, 200, { ok: true })
+  })
+  router.get('/api/auth/me', (ctx) => {
+    const user = dependencies.auth.require(ctx, true)
+    json(ctx, 200, {
+      user_id: user.id,
+      display_name: user.username,
+      username: user.username,
+      provider: 'yaoyao-local',
+      role: user.role,
+      must_change_password: user.mustChangePassword,
+      server_kind: 'yaoyao-web',
+    })
+  })
+  router.put('/api/account/credentials', (ctx) => {
+    const request = body(ctx)
+    const currentPassword = typeof request.currentPassword === 'string' ? request.currentPassword : ''
+    const newPassword = typeof request.newPassword === 'string' ? request.newPassword : ''
+    const username = typeof request.username === 'string' ? request.username : undefined
+    const user = dependencies.auth.changeCredentials(ctx, currentPassword, newPassword, username)
+    json(ctx, 200, { user, must_change_password: false, server_kind: 'yaoyao-web' })
+  })
+
   router.post('/api/app/pairings', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       const request = optionalBody(ctx)
-      const cookieHeader = await independentPairingCookies(
-        ctx,
-        dependencies,
-        jar,
-        request,
-      )
+      const cookieHeader = jar.header
+      if (!cookieHeader) throw new HttpError(503, '9119 服务会话尚未就绪', 'upstream_session_missing')
       const pairing = dependencies.pairings.create(
         cookieHeader,
         pairingScopes(request?.scopes),
@@ -783,6 +903,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       deepLink.searchParams.set('fingerprint', pairing.fingerprint)
       json(ctx, 201, {
         protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
+        serviceType: 'yaoyao-web',
         pairingId: pairing.id,
         nodeId: pairing.nodeID,
         fingerprint: pairing.fingerprint,
@@ -794,6 +915,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
 
   router.get('/api/app/pairings/:pairingID', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       json(ctx, 200, dependencies.pairings.status(ctx.params.pairingID))
@@ -801,6 +923,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
 
   router.get('/api/app/paired-devices', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       json(ctx, 200, {
@@ -812,6 +935,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
 
   router.delete('/api/app/paired-devices/:deviceID', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       try {
@@ -841,6 +965,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const origin = requestOrigin(ctx, dependencies.config)
     json(ctx, 201, {
       protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
+      serviceType: 'yaoyao-web',
       nodeId: claimed.nodeID,
       fingerprint: claimed.fingerprint,
       deviceId: claimed.device.id,
@@ -854,6 +979,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.get('/api/pair/v1/capabilities', (ctx) => {
     json(ctx, 200, {
       protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
+      serviceType: 'yaoyao-web',
       nodeId: dependencies.pairings.nodeID,
       fingerprint: dependencies.pairings.fingerprint,
       scopes: [...DEFAULT_NODE_SCOPES],
@@ -1003,21 +1129,55 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
 
   router.get('/api/app/bootstrap', async (ctx) => {
-    await withJar(ctx, (jar) => bootstrap(ctx, dependencies, jar))
+    await bootstrap(ctx, dependencies)
   })
   router.post('/api/app/login', async (ctx) => {
-    await withJar(ctx, (jar) => login(ctx, dependencies, jar))
+    await login(ctx, dependencies)
   })
   router.post('/api/app/logout', async (ctx) => {
-    await withJar(ctx, async (jar) => {
-      const response = await dependencies.upstream.request('/auth/logout', jar, { method: 'POST' })
-      const nextCsrf = dependencies.csrf.issue(ctx, true)
-      if (response.status >= 400 && ![401, 403].includes(response.status)) {
-        sendUpstreamResponse(ctx, response, jar)
-      } else {
-        json(ctx, 200, { ok: true, csrfToken: nextCsrf })
-      }
-    })
+    dependencies.auth.logout(ctx)
+    json(ctx, 200, { ok: true, csrfToken: dependencies.csrf.issue(ctx, true) })
+  })
+  router.put('/api/app/account/credentials', async (ctx) => {
+    const request = body(ctx)
+    const currentPassword = typeof request.currentPassword === 'string' ? request.currentPassword : ''
+    const newPassword = typeof request.newPassword === 'string' ? request.newPassword : ''
+    const username = typeof request.username === 'string' ? request.username : undefined
+    const user = dependencies.auth.changeCredentials(ctx, currentPassword, newPassword, username)
+    json(ctx, 200, { user, mustChangePassword: false, csrfToken: dependencies.csrf.issue(ctx, true) })
+  })
+  router.get('/api/app/admin/users', (ctx) => {
+    const admin = dependencies.auth.requireAdmin(ctx)
+    json(ctx, 200, { items: dependencies.auth.list(admin) })
+  })
+  router.post('/api/app/admin/users', (ctx) => {
+    const admin = dependencies.auth.requireAdmin(ctx)
+    const request = body(ctx)
+    const username = typeof request.username === 'string' ? request.username : ''
+    const password = typeof request.password === 'string' ? request.password : ''
+    json(ctx, 201, dependencies.auth.create(admin, username, password))
+  })
+  router.patch('/api/app/admin/users/:userID', (ctx) => {
+    const admin = dependencies.auth.requireAdmin(ctx)
+    const request = body(ctx)
+    json(ctx, 200, dependencies.auth.updateUser(admin, canonicalUUID(ctx.params.userID, 'user ID'), {
+      enabled: typeof request.enabled === 'boolean' ? request.enabled : undefined,
+      password: typeof request.password === 'string' ? request.password : undefined,
+    }))
+  })
+  router.delete('/api/app/admin/users/:userID', (ctx) => {
+    const admin = dependencies.auth.requireAdmin(ctx)
+    dependencies.auth.deleteUser(admin, canonicalUUID(ctx.params.userID, 'user ID'))
+    json(ctx, 200, { ok: true })
+  })
+  router.put('/api/app/admin/upstream-credentials', async (ctx) => {
+    const admin = dependencies.auth.requireAdmin(ctx)
+    const request = body(ctx)
+    const username = typeof request.username === 'string' ? request.username : ''
+    const password = typeof request.password === 'string' ? request.password : ''
+    await dependencies.upstreamSession.verify({ username, password })
+    dependencies.auth.setUpstreamCredentials(admin, { username, password })
+    json(ctx, 200, { ok: true })
   })
   router.get('/api/app/system/update/status', async (ctx) => {
     await withJar(ctx, async (jar) => {
@@ -1279,6 +1439,19 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       method: 'POST', requestBody: body(ctx),
     })
   })
+  router.post('/api/app/groups/nodes/pair', async (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    const request = body(ctx)
+    const qrPayload = typeof request.qrPayload === 'string' ? request.qrPayload : ''
+    if (!qrPayload || qrPayload.length > 4_096) {
+      throw new HttpError(400, '请粘贴有效的 8800 配对码', 'invalid_pairing_code')
+    }
+    json(ctx, 201, await addPairedChildNode(
+      qrPayload,
+      typeof request.name === 'string' ? request.name : undefined,
+      dependencies,
+    ))
+  })
   router.delete('/api/app/groups/nodes/:nodeID', async (ctx) => {
     const nodeID = canonicalUUID(ctx.params.nodeID, 'node ID')
     await proxy(
@@ -1459,6 +1632,24 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     })
   }
 
+  router.all('/api/*gatewayPath', async (ctx) => {
+    if (ctx.path.startsWith('/api/app/') || ctx.path.startsWith('/api/pair/')) {
+      throw new HttpError(404, 'Route not found', 'not_found')
+    }
+    dependencies.auth.require(ctx, ctx.path === '/api/profiles')
+    const raw = Array.isArray(ctx.params.gatewayPath)
+      ? ctx.params.gatewayPath.join('/')
+      : ctx.params.gatewayPath
+    const upstreamPath = pairedProxyPath(raw)
+    const response = await dependencies.upstreamSession.request(upstreamPath, {
+      method: ctx.method,
+      search: new URLSearchParams(ctx.querystring),
+      body: ['GET', 'HEAD'].includes(ctx.method) ? undefined : optionalBody(ctx),
+      clientAddress: ctx.req.socket.remoteAddress,
+    })
+    sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
+  })
+
   return router
 }
 

+ 1 - 1
src/server/security.ts

@@ -191,6 +191,6 @@ export function applySecurityHeaders(ctx: Koa.Context, tls: boolean, allowedHost
   ctx.set('Referrer-Policy', 'no-referrer')
   ctx.set('X-Content-Type-Options', 'nosniff')
   ctx.set('X-Frame-Options', 'DENY')
-  ctx.set('Permissions-Policy', 'camera=(), geolocation=(), microphone=()')
+  ctx.set('Permissions-Policy', 'camera=(self), geolocation=(), microphone=()')
   if (tls) ctx.set('Strict-Transport-Security', 'max-age=31536000')
 }

+ 64 - 0
src/server/websocket.ts

@@ -517,6 +517,70 @@ export function installWebSocketRelay(
       rejectUpgrade(socket, 400, 'Invalid WebSocket URL')
       return
     }
+    const gatewayChannel: RealtimeChannel | undefined = url.pathname === '/api/ws'
+      ? 'chat'
+      : url.pathname === '/api/plugins/yaoyao/v1/events' ? 'groups' : undefined
+    if (gatewayChannel) {
+      const host = request.headers.host
+      const origin = typeof request.headers.origin === 'string' ? request.headers.origin : undefined
+      const secure = Boolean(config.tlsCert)
+      if (!isAllowedHostHeader(host, config)
+        || (origin !== undefined && !isExactOrigin(origin, host, secure, config.allowedHosts))) {
+        rejectUpgrade(socket, 403, 'Host or Origin rejected')
+        return
+      }
+      const allowed = gatewayChannel === 'groups'
+        ? new Set(['ticket', 'epoch', 'cursor']) : new Set(['ticket'])
+      if ([...url.searchParams.keys()].some(name => !allowed.has(name))) {
+        rejectUpgrade(socket, 400, 'Unexpected WebSocket query parameter')
+        return
+      }
+      const ticket = url.searchParams.get('ticket')
+      const epoch = gatewayChannel === 'groups' ? url.searchParams.get('epoch') : undefined
+      const cursor = Number(url.searchParams.get('cursor') ?? '0')
+      if (!ticket || ticket.length > 4_096
+        || (gatewayChannel === 'groups' && (!epoch || !Number.isSafeInteger(cursor) || cursor < 0))) {
+        rejectUpgrade(socket, 401, 'Missing or invalid Gateway ticket')
+        return
+      }
+      const connectionKey = `gateway:${request.socket.remoteAddress ?? 'unknown'}:${gatewayChannel}`
+      if ((active.get(connectionKey) ?? 0) >= (gatewayChannel === 'chat' ? 4 : 2)) {
+        rejectUpgrade(socket, 429, 'Too many realtime connections')
+        return
+      }
+      active.set(connectionKey, (active.get(connectionKey) ?? 0) + 1)
+      socketServer.handleUpgrade(request, socket, head, (client) => {
+        const upstream = new WebSocket(pairedUpstreamWebSocketURL(
+          config, gatewayChannel, ticket, epoch ?? undefined, cursor,
+        ), {
+          headers: { Origin: config.upstream.origin },
+          maxPayload: gatewayChannel === 'chat' ? CHAT_MAX_PAYLOAD : GROUP_MAX_PAYLOAD,
+          handshakeTimeout: 15_000,
+        })
+        let released = false
+        const release = () => {
+          if (released) return
+          released = true
+          const count = (active.get(connectionKey) ?? 1) - 1
+          if (count <= 0) active.delete(connectionKey)
+          else active.set(connectionKey, count)
+        }
+        upstream.once('open', () => relaySocket(
+          client, upstream, gatewayChannel, release,
+          gatewayChannel === 'groups' ? { epoch: epoch!, cursor } : undefined,
+        ))
+        upstream.once('unexpected-response', release)
+        upstream.once('error', () => {
+          if (client.readyState === WebSocket.OPEN) client.close(1011, 'Unable to connect to Hermes')
+          release()
+        })
+        client.once('close', () => {
+          if (upstream.readyState === WebSocket.CONNECTING) upstream.terminate()
+          release()
+        })
+      })
+      return
+    }
     const pairedMatch = url.pathname.match(
       /^\/node\/([0-9a-f-]{36})\/api\/(ws|plugins\/yaoyao\/v1\/events)$/,
     )

+ 5 - 0
src/shared/types.ts

@@ -16,6 +16,8 @@ export interface CurrentUser {
   email?: string
   provider?: string
   role?: string
+  enabled?: boolean
+  mustChangePassword?: boolean
 }
 
 export interface Profile {
@@ -48,6 +50,9 @@ export interface BootstrapResponse {
   csrfToken: string
   insecureLan?: boolean
   groupUploadsEnabled?: boolean
+  upstreamReady?: boolean
+  upstreamError?: string
+  serverKind?: string
   models?: ModelOption[]
 }
 

+ 77 - 0
tests/server/localAuth.test.ts

@@ -0,0 +1,77 @@
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type Koa from 'koa'
+import { afterEach, describe, expect, it } from 'vitest'
+import { LocalAuthStore } from '../../src/server/localAuth.js'
+
+const homes: string[] = []
+
+function context(cookie = ''): Koa.Context {
+  const headers: Record<string, string | string[]> = {}
+  return {
+    state: {},
+    get: (name: string) => name.toLowerCase() === 'cookie' ? cookie : '',
+    set: (name: string, value: string | string[]) => { headers[name.toLowerCase()] = value },
+    response: { headers },
+  } as unknown as Koa.Context
+}
+
+function issuedCookie(ctx: Koa.Context): string {
+  const value = ctx.response.headers['set-cookie']
+  const raw = Array.isArray(value) ? value.at(-1) : String(value)
+  return raw.split(';', 1)[0]!
+}
+
+afterEach(() => {
+  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+describe('8800 local users', () => {
+  it('creates admin/admin and forces a credential change', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-auth-'))
+    homes.push(home)
+    const store = new LocalAuthStore(home)
+    const login = context()
+    const admin = store.login(login, 'admin', 'admin')
+    expect(admin).toMatchObject({ role: 'admin', mustChangePassword: true })
+    const authenticated = context(issuedCookie(login))
+    expect(() => store.require(authenticated)).toThrow(/修改初始密码/)
+
+    const changed = store.changeCredentials(authenticated, 'admin', 'new-password', 'owner')
+    expect(changed).toMatchObject({ username: 'owner', mustChangePassword: false })
+    expect(store.require(context(issuedCookie(authenticated))).username).toBe('owner')
+    const restarted = new LocalAuthStore(home)
+    expect(restarted.require(context(issuedCookie(authenticated))).username).toBe('owner')
+  })
+
+  it('lets the administrator create, reset, disable and delete ordinary users', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-auth-'))
+    homes.push(home)
+    const store = new LocalAuthStore(home)
+    const first = context()
+    store.login(first, 'admin', 'admin')
+    const changedContext = context(issuedCookie(first))
+    store.changeCredentials(changedContext, 'admin', 'new-password', 'admin')
+    const admin = store.require(context(issuedCookie(changedContext)))
+    const user = store.create(admin, 'member', 'temporary-password')
+    expect(user.mustChangePassword).toBe(true)
+    expect(store.updateUser(admin, user.id, { enabled: false }).enabled).toBe(false)
+    store.deleteUser(admin, user.id)
+    expect(store.list(admin).map(item => item.username)).toEqual(['admin'])
+  })
+
+  it('encrypts the 9119 service password at rest', () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-local-auth-'))
+    homes.push(home)
+    const store = new LocalAuthStore(home)
+    const first = context()
+    store.login(first, 'admin', 'admin')
+    const changedContext = context(issuedCookie(first))
+    store.changeCredentials(changedContext, 'admin', 'new-password', 'admin')
+    const admin = store.require(context(issuedCookie(changedContext)))
+    store.setUpstreamCredentials(admin, { username: 'service', password: 'very-secret' })
+    expect(store.upstreamCredentials()).toEqual({ username: 'service', password: 'very-secret' })
+    expect(readFileSync(join(home, 'upstream-credentials.enc'), 'utf8')).not.toContain('very-secret')
+  })
+})

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

@@ -0,0 +1,146 @@
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createApplication, createNodeServer, type ApplicationRuntime, type NodeServerRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+
+const roots: string[] = []
+const runtimes: ApplicationRuntime[] = []
+const nodeRuntimes: NodeServerRuntime[] = []
+afterEach(async () => {
+  for (const runtime of nodeRuntimes.splice(0)) await runtime.close()
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function cookies(response: request.Response): string {
+  const values = response.headers['set-cookie'] as unknown as string[] | undefined
+  return (values ?? []).map(value => value.split(';', 1)[0]).join('; ')
+}
+
+describe('8800 local authentication routes', () => {
+  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)
+    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 fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+      const path = new URL(input instanceof Request ? input.url : String(input)).pathname
+      const headers = new Headers(init?.headers)
+      if (path === '/api/status') return Response.json({ auth_required: true })
+      if (path === '/api/auth/me') return headers.get('cookie')
+        ? Response.json({ user_id: 'service' })
+        : Response.json({ error: 'unauthorized' }, { status: 401 })
+      if (path === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
+      if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), {
+        headers: { 'content-type': 'application/json', 'set-cookie': 'hermes_service=session; Path=/; HttpOnly' },
+      })
+      if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+      if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'upstream-ticket' })
+      if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
+      return Response.json({ ok: true })
+    }) as typeof fetch
+    const runtime = createApplication({ config, fetchImpl })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const initial = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const login = await agent.post('/api/app/login')
+      .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', initial.body.csrfToken).send({ username: 'admin', password: 'admin' }).expect(200)
+    expect(login.body.user).toMatchObject({ username: 'admin', role: 'admin', mustChangePassword: true })
+    expect(login.body.profiles).toEqual([])
+
+    const changed = await agent.put('/api/app/account/credentials')
+      .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', login.body.csrfToken)
+      .send({ currentPassword: 'admin', newPassword: 'new-password', username: 'owner' }).expect(200)
+    expect(changed.body.user).toMatchObject({ username: 'owner', mustChangePassword: false })
+
+    const ready = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    expect(ready.body).toMatchObject({ upstreamReady: true, serverKind: 'yaoyao-web' })
+    expect(ready.body.profiles).toHaveLength(1)
+
+    await agent.post('/api/app/admin/users')
+      .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', ready.body.csrfToken)
+      .send({ username: 'member', password: 'temporary-password' }).expect(201)
+    const users = await agent.get('/api/app/admin/users').set('Host', '127.0.0.1:8800').expect(200)
+    expect(users.body.items.map((user: { username: string }) => user.username)).toEqual(['owner', 'member'])
+    expect(cookies(changed)).not.toContain('admin')
+
+    const native = request.agent(runtime.app.callback())
+    await native.get('/api/status').set('Host', '127.0.0.1:8800').expect(200, /yaoyao-web/)
+    await native.post('/auth/password-login').set('Host', '127.0.0.1:8800')
+      .send({ provider: 'basic', username: 'owner', password: 'new-password', next: '' }).expect(200)
+    const identity = await native.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)
+    expect(identity.body).toMatchObject({ username: 'owner', role: 'admin', server_kind: 'yaoyao-web' })
+    await native.get('/api/profiles').set('Host', '127.0.0.1:8800').expect(200)
+    await native.post('/api/auth/ws-ticket').set('Host', '127.0.0.1:8800').send({}).expect(200, /upstream-ticket/)
+  })
+
+  it('lets one 8800 claim another 8800 as a direct child node', async () => {
+    const registrations: unknown[] = []
+    const make = (label: string) => {
+      const home = mkdtempSync(join(tmpdir(), `yaoyao-${label}-`)); roots.push(home)
+      const config: ServerConfig = {
+        host: '127.0.0.1', port: 0, upstream: new URL('http://127.0.0.1:9119'),
+        allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+        mediaOwner: 'tester', allowInsecureLan: true, insecureLan: true, production: false,
+        superviseDashboard: true,
+      }
+      const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+        const path = new URL(input instanceof Request ? input.url : String(input)).pathname
+        const headers = new Headers(init?.headers)
+        if (path === '/api/status') return Response.json({ auth_required: true })
+        if (path === '/api/auth/me') return headers.get('cookie') ? Response.json({ user_id: 'service' }) : Response.json({}, { status: 401 })
+        if (path === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
+        if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json', 'set-cookie': `service_${label}=session; Path=/; HttpOnly` } })
+        if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true, model: 'test' }] })
+        if (path === '/api/plugins/yaoyao/profiles') return Response.json({ profiles: [] })
+        if (path === '/api/plugins/yaoyao/v1/nodes' && init?.method === 'POST') {
+          registrations.push(JSON.parse(String(init.body))); return Response.json({ nodeId: JSON.parse(String(init.body)).nodeId })
+        }
+        return Response.json({ ok: true })
+      }) as typeof fetch
+      const app = createApplication({ config, fetchImpl })
+      const node = createNodeServer(app); nodeRuntimes.push(node)
+      return { app, node, config }
+    }
+    const child = make('child')
+    const parent = make('parent')
+    await Promise.all([child, parent].map(({ node, config }) => new Promise<void>(resolve => {
+      node.server.listen(0, '127.0.0.1', () => {
+        config.port = (node.server.address() as { port: number }).port
+        resolve()
+      })
+    })))
+    const authenticate = async (runtime: ApplicationRuntime, port: number) => {
+      const agent = request.agent(runtime.app.callback())
+      const initial = await agent.get('/api/app/bootstrap').set('Host', `127.0.0.1:${port}`)
+      const login = await agent.post('/api/app/login').set('Host', `127.0.0.1:${port}`)
+        .set('Origin', `http://127.0.0.1:${port}`).set('X-CSRF-Token', initial.body.csrfToken)
+        .send({ username: 'admin', password: 'admin' })
+      const changed = await agent.put('/api/app/account/credentials').set('Host', `127.0.0.1:${port}`)
+        .set('Origin', `http://127.0.0.1:${port}`).set('X-CSRF-Token', login.body.csrfToken)
+        .send({ currentPassword: 'admin', newPassword: 'new-password', username: 'admin' })
+      const ready = await agent.get('/api/app/bootstrap').set('Host', `127.0.0.1:${port}`)
+      return { agent, csrf: ready.body.csrfToken, origin: `http://127.0.0.1:${port}`, changed }
+    }
+    const childAuth = await authenticate(child.app, child.config.port)
+    const parentAuth = await authenticate(parent.app, parent.config.port)
+    const pairing = await childAuth.agent.post('/api/app/pairings').set('Host', `127.0.0.1:${child.config.port}`)
+      .set('Origin', childAuth.origin).set('X-CSRF-Token', childAuth.csrf)
+      .send({ scopes: ['agents.read', 'history.read', 'sessions.execute', 'groups.read', 'groups.execute'] }).expect(201)
+    await parentAuth.agent.post('/api/app/groups/nodes/pair').set('Host', `127.0.0.1:${parent.config.port}`)
+      .set('Origin', parentAuth.origin).set('X-CSRF-Token', parentAuth.csrf)
+      .send({ qrPayload: pairing.body.qrPayload, name: '子 8800' }).expect(201)
+    expect(registrations).toHaveLength(1)
+    expect(registrations[0]).toMatchObject({ name: '子 8800', profiles: [{ name: 'default' }] })
+  })
+})