Browse Source

发布 v0.2.21 外网访问配置版本

LSamien 2 tháng trước cách đây
mục cha
commit
1ce18e3e08

+ 2 - 2
README.md

@@ -78,7 +78,7 @@ ID 和包名。浏览器只提交路径和公开元数据;8800 本地校验 RS
 插件安装接口安装。默认源为
 `https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
 
-当前发布版本:**Git `v0.2.20` / 夭夭 Web `0.2.20` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
+当前发布版本:**Git `v0.2.21` / 夭夭 Web `0.2.21` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 
@@ -320,7 +320,7 @@ curl --fail --silent http://127.0.0.1:8800/readyz
 
 Docker Desktop 会把 `host.docker.internal` 转发到宿主机。Linux 上的 `host-gateway` 只负责解析宿主机地址:如果外部 9119 仍只监听宿主机 `127.0.0.1`,桥接网络中的容器无法连接它。此时应让现有 Dashboard 在有认证和防火墙保护的前提下监听容器可达的宿主机地址,或由部署方改用经过评估的 host 网络方案;Compose 本身不会更改 9119 的监听配置。
 
-如需经可信局域网或反向代理访问,可将 `HERMES_YAOYAO_BIND_ADDRESS` 改为 `0.0.0.0`。使用域名时还需配置 `HERMES_YAOYAO_ALLOWED_HOSTS`。Compose 内部的 8800 使用 HTTP;对非可信网络发布时,应在容器前配置 HTTPS 反向代理,不要直接把 8800 暴露到公网。
+如需经可信局域网或反向代理访问,可将 `HERMES_YAOYAO_BIND_ADDRESS` 改为 `0.0.0.0`。首次使用外网域名或公网 IP 前,可先从 `localhost:8800` 进入“设置中心 → Hermes 连接 → 外网访问地址”添加;也可继续通过 `HERMES_YAOYAO_ALLOWED_HOSTS` 配置,环境变量地址会与 Web 设置合并。Compose 内部的 8800 使用 HTTP;对非可信网络发布时,应在容器前配置 HTTPS 反向代理,不要直接把 8800 暴露到公网。
 
 停止或升级不会删除运行数据:
 

+ 5 - 5
docs/agent-install.md

@@ -6,8 +6,8 @@
 
 | 项目 | 版本 | 校验位置 |
 | --- | --- | --- |
-| Git 发布标签 | `v0.2.20` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
-| 夭夭 Web | `0.2.20` | `release.json` 与 `package.json` 的 `version` |
+| Git 发布标签 | `v0.2.21` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
+| 夭夭 Web | `0.2.21` | `release.json` 与 `package.json` 的 `version` |
 | Hermes Dashboard 插件 | `1.7.3` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
@@ -78,12 +78,12 @@ curl --fail --silent --show-error http://127.0.0.1:9119/api/auth/providers
 ## 1. 获取并校验指定发布版本
 
 ```bash
-RELEASE_VERSION=v0.2.20
+RELEASE_VERSION=v0.2.21
 git fetch --tags
 git checkout "$RELEASE_VERSION"
 
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
-test "$(node -p \"require('./package.json').version\")" = "0.2.20"
+test "$(node -p \"require('./package.json').version\")" = "0.2.21"
 test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.3"
 npm run release:verify
 ```
@@ -247,7 +247,7 @@ Git、发布目录、命令输出或日志。配置后检查系统管理中的 A
 
 ## 7. 通过受管服务进行配套升级
 
-首次按第 6 步安装 Web `0.2.20` 后,可在左下角 Agent 菜单打开“系统更新”。
+首次按第 6 步安装 Web `0.2.21` 后,可在左下角 Agent 菜单打开“系统更新”。
 检查更新只读取固定的 `HERMES_YAOYAO_RELEASE_SOURCE`,并锁定最新 `vX.Y.Z`
 标签解析到的 Git 提交;执行升级前仍会检查插件持久目录是否安全。
 

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.2.20",
+  "version": "0.2.21",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.2.20",
+      "version": "0.2.21",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.2.20",
+  "version": "0.2.21",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,7 +1,7 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.2.20",
-  "webVersion": "0.2.20",
+  "releaseVersion": "0.2.21",
+  "webVersion": "0.2.21",
   "pluginVersion": "1.7.3",
-  "gitTag": "v0.2.20"
+  "gitTag": "v0.2.21"
 }

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

@@ -37,3 +37,21 @@ export async function setUpstreamCredentials(username: string, password: string)
     method: 'PUT', body: { username, password } as unknown as JsonValue,
   })
 }
+
+export interface AllowedHostsSettings {
+  source: 'none' | 'file' | 'environment'
+  hosts: string[]
+  editableHosts: string[]
+  environmentHosts: string[]
+  configurationError?: string
+}
+
+export function getAllowedHostsSettings(): Promise<AllowedHostsSettings> {
+  return apiRequest('/api/app/system/allowed-hosts')
+}
+
+export function saveAllowedHostsSettings(hosts: string[]): Promise<AllowedHostsSettings> {
+  return apiRequest('/api/app/system/allowed-hosts', {
+    method: 'PUT', body: { hosts } as unknown as JsonValue,
+  })
+}

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

@@ -1,7 +1,17 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
-import { createUser, deleteUser, listUsers, setUpstreamCredentials, updateUser, type ManagedUser } from '@/api/admin'
+import {
+  createUser,
+  deleteUser,
+  getAllowedHostsSettings,
+  listUsers,
+  saveAllowedHostsSettings,
+  setUpstreamCredentials,
+  updateUser,
+  type AllowedHostsSettings,
+  type ManagedUser,
+} from '@/api/admin'
 import {
   getPushSystemStatus,
   saveFCMPushSystemConfig,
@@ -31,6 +41,12 @@ const password = ref('')
 const upstreamUsername = ref('')
 const upstreamPassword = ref('')
 const connectionBaseline = ref({ username: '', password: '' })
+const allowedHostsSettings = ref<AllowedHostsSettings>()
+const allowedHostsText = ref('')
+const allowedHostsBaseline = ref('')
+const allowedHostsBusy = ref(false)
+const allowedHostsError = ref('')
+const allowedHostsNotice = ref('')
 const busy = ref(false)
 const error = ref('')
 const pushStatus = ref<PushSystemStatus>()
@@ -49,6 +65,7 @@ const fcmPackageName = ref('cn.samien.yaoyao.hermes')
 const fcmBusy = ref(false)
 const fcmError = ref('')
 const fcmNotice = ref('')
+const currentAccessHost = window.location.hostname || 'localhost'
 
 interface APNsFormSnapshot {
   keyFile: string
@@ -116,6 +133,7 @@ const dirty = computed(() => {
   if (props.section === 'connection') {
     return upstreamUsername.value !== connectionBaseline.value.username
       || upstreamPassword.value !== connectionBaseline.value.password
+      || allowedHostsText.value !== allowedHostsBaseline.value
   }
   if (props.section !== 'push') return false
   const apnsDirty = Boolean(apnsBaseline.value) && Object.keys(apnsSnapshot()).some(key => apnsSnapshot()[key as keyof APNsFormSnapshot] !== apnsBaseline.value?.[key as keyof APNsFormSnapshot])
@@ -155,6 +173,13 @@ async function refresh() {
     users.value = await listUsers()
     return
   }
+  if (props.section === 'connection') {
+    const settings = await getAllowedHostsSettings()
+    allowedHostsSettings.value = settings
+    allowedHostsText.value = settings.editableHosts.join('\n')
+    allowedHostsBaseline.value = allowedHostsText.value
+    return
+  }
   if (props.section !== 'push') return
 
   pushStatusLoadError.value = ''
@@ -223,6 +248,31 @@ async function saveUpstream() {
   }, false)
 }
 
+function parsedAllowedHosts(): string[] {
+  return allowedHostsText.value
+    .split(/[\s,]+/)
+    .map(value => value.trim())
+    .filter(Boolean)
+}
+
+async function saveAllowedHosts() {
+  if (allowedHostsBusy.value) return
+  allowedHostsBusy.value = true
+  allowedHostsError.value = ''
+  allowedHostsNotice.value = ''
+  try {
+    const settings = await saveAllowedHostsSettings(parsedAllowedHosts())
+    allowedHostsSettings.value = settings
+    allowedHostsText.value = settings.editableHosts.join('\n')
+    allowedHostsBaseline.value = allowedHostsText.value
+    allowedHostsNotice.value = '允许的访问地址已保存并立即生效'
+  } catch (cause) {
+    allowedHostsError.value = cause instanceof Error ? cause.message : '无法保存允许的访问地址'
+  } finally {
+    allowedHostsBusy.value = false
+  }
+}
+
 async function savePush() {
   if (!pushStatus.value?.editable || pushManagedByEnvironment.value || pushBusy.value || fcmBusy.value) return
   const keyFile = pushKeyFile.value.trim()
@@ -309,8 +359,12 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
     fcmError.value = ''
     fcmNotice.value = ''
   }
+  if (section === 'connection') {
+    allowedHostsError.value = ''
+    allowedHostsNotice.value = ''
+  }
   void refresh().catch(cause => {
-    error.value = cause instanceof Error ? cause.message : '读取用户失败'
+    error.value = cause instanceof Error ? cause.message : '读取系统设置失败'
   })
 }, { immediate: true })
 </script>
@@ -347,6 +401,25 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
         <label><span>9119 密码</span><input v-model="upstreamPassword" name="upstream-password" type="password" autocomplete="new-password" :disabled="busy" /></label>
         <button class="solid-button" :disabled="busy || !upstreamUsername.trim() || !upstreamPassword">验证并保存</button>
       </form>
+      <div class="block network-access-block">
+        <h3>外网访问地址</h3>
+        <p class="network-description">允许通过指定域名或公网 IP 访问 8800。每行填写一个地址,不要包含 <code>http://</code>、端口或路径。</p>
+        <form class="allowed-hosts-form" @submit.prevent="saveAllowedHosts">
+          <label>
+            <span>允许的域名和 IP</span>
+            <textarea v-model="allowedHostsText" name="allowed-hosts" rows="5" :disabled="allowedHostsBusy" spellcheck="false" placeholder="yaoyao.example.com&#10;203.0.113.10&#10;2001:db8::10"></textarea>
+            <small>当前访问地址:{{ currentAccessHost }}。保存后立即生效,无需重启 Web。</small>
+          </label>
+          <div v-if="allowedHostsSettings?.environmentHosts.length" class="environment-hosts">
+            <small>环境变量保留地址(Web 中不能移除)</small>
+            <code>{{ allowedHostsSettings.environmentHosts.join(', ') }}</code>
+          </div>
+          <p v-if="allowedHostsSettings?.configurationError" class="allowed-hosts-error" role="alert">现有配置异常:{{ allowedHostsSettings.configurationError }}</p>
+          <p v-if="allowedHostsError" class="allowed-hosts-error" role="alert">{{ allowedHostsError }}</p>
+          <p v-else-if="allowedHostsNotice" class="allowed-hosts-notice" role="status">{{ allowedHostsNotice }}</p>
+          <footer><button class="solid-button" :disabled="allowedHostsBusy || allowedHostsText === allowedHostsBaseline">{{ allowedHostsBusy ? '保存中…' : '保存访问地址' }}</button></footer>
+        </form>
+      </div>
     </div>
 
     <template v-else>
@@ -420,6 +493,19 @@ watch(() => [props.active, props.section] as const, ([active, section]) => {
 .block h3 { margin: 0 0 14px; font-size: 16px; }
 .block p { color: var(--danger); font-size: 13px; }
 .block p.ok { color: var(--success, #21845b); }
+.network-description { margin: 0; color: var(--text-muted) !important; line-height: 1.6; }
+.network-access-block { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--line); }
+.network-description code { color: var(--text-secondary); }
+.allowed-hosts-form { display: grid; grid-template-columns: 1fr; align-items: stretch; }
+.allowed-hosts-form textarea { min-width: 0; resize: vertical; padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-raised); color: var(--text-primary); font: 13px/1.55 var(--font-mono, monospace); }
+.allowed-hosts-form textarea:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
+.environment-hosts { display: grid; gap: 5px; padding: 10px 11px; border-radius: 9px; background: var(--surface-soft); }
+.environment-hosts small { color: var(--text-muted); font-size: 11px; }
+.environment-hosts code { overflow-wrap: anywhere; color: var(--text-secondary); font-size: 12px; }
+.allowed-hosts-error,.allowed-hosts-notice { margin: 0; padding: 9px 10px; border-radius: 9px; line-height: 1.55; }
+.allowed-hosts-error { background: color-mix(in srgb, var(--danger) 8%, transparent); }
+.allowed-hosts-notice { background: color-mix(in srgb, var(--success, #21845b) 9%, transparent); color: var(--success, #21845b) !important; }
+.allowed-hosts-form footer { display: flex; justify-content: flex-end; }
 .error { margin: 0 0 12px; color: var(--danger); font-size: 13px; }
 .push-status { display: grid; grid-template-columns: minmax(0, 2fr) 1fr 1fr; gap: 8px; }
 .push-status span { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 12px; border-radius: 9px; background: var(--surface-soft); }

+ 207 - 0
src/server/allowedHostsConfiguration.ts

@@ -0,0 +1,207 @@
+import { randomUUID } from 'node:crypto'
+import {
+  chmodSync,
+  closeSync,
+  existsSync,
+  fstatSync,
+  fsyncSync,
+  mkdirSync,
+  openSync,
+  readFileSync,
+  renameSync,
+  unlinkSync,
+  writeFileSync,
+} from 'node:fs'
+import { isIP } from 'node:net'
+import { dirname, join } from 'node:path'
+import { domainToASCII } from 'node:url'
+import { HttpError } from './errors.js'
+
+export type AllowedHostsConfigurationSource = 'none' | 'file' | 'environment'
+
+export interface AllowedHostsConfigurationSnapshot {
+  source: AllowedHostsConfigurationSource
+  hosts: string[]
+  editableHosts: string[]
+  environmentHosts: string[]
+  configurationError?: string
+}
+
+interface StoredAllowedHostsConfiguration {
+  schemaVersion: 1
+  hosts: string[]
+  updatedAt: string
+}
+
+const MAX_CONFIGURATION_FILE_BYTES = 64 * 1_024
+const MAX_ALLOWED_HOSTS = 128
+
+export function normalizeAllowedHost(raw: string): string {
+  let value = raw.trim().toLowerCase().replace(/\.$/, '')
+  if (value.startsWith('[') && value.endsWith(']')) value = value.slice(1, -1)
+  if (!value || value.length > 253 || /[\u0000-\u0020\u007f/@\\?#]/.test(value)) {
+    throw new HttpError(400, '允许的访问地址必须是域名或 IP,不能包含协议、端口或路径', 'invalid_allowed_host')
+  }
+  if (isIP(value)) return value
+  if (value.includes(':')) {
+    throw new HttpError(400, '地址格式无效;域名和 IP 均不能附加端口', 'invalid_allowed_host')
+  }
+  const ascii = domainToASCII(value).toLowerCase().replace(/\.$/, '')
+  const labels = ascii.split('.')
+  if (!ascii || ascii.length > 253 || labels.some(label => (
+    !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
+  ))) {
+    throw new HttpError(400, '允许的访问域名格式无效', 'invalid_allowed_host')
+  }
+  return ascii
+}
+
+export function canonicalAllowedHosts(values: readonly string[]): string[] {
+  if (values.length > MAX_ALLOWED_HOSTS) {
+    throw new HttpError(400, `允许的访问地址最多 ${MAX_ALLOWED_HOSTS} 个`, 'too_many_allowed_hosts')
+  }
+  return [...new Set(values.map(normalizeAllowedHost))].sort()
+}
+
+export function allowedHostsConfigurationPath(home: string): string {
+  return join(home, 'network', 'allowed-hosts.json')
+}
+
+function readStoredHosts(path: string): string[] {
+  const descriptor = openSync(path, 'r')
+  try {
+    const file = fstatSync(descriptor)
+    if (!file.isFile() || file.size < 1 || file.size > MAX_CONFIGURATION_FILE_BYTES) {
+      throw new Error('Allowed hosts configuration file has an invalid size')
+    }
+    const value = JSON.parse(readFileSync(descriptor).toString('utf8')) as Partial<StoredAllowedHostsConfiguration>
+    if (value.schemaVersion !== 1 || !Array.isArray(value.hosts)
+      || value.hosts.some(host => typeof host !== 'string')) {
+      throw new Error('Unsupported allowed hosts configuration file')
+    }
+    return canonicalAllowedHosts(value.hosts as string[])
+  } finally {
+    closeSync(descriptor)
+  }
+}
+
+export function loadAllowedHostsConfiguration(
+  home: string,
+  env: NodeJS.ProcessEnv = process.env,
+): AllowedHostsConfigurationSnapshot {
+  let environmentHosts: string[] = []
+  try {
+    environmentHosts = canonicalAllowedHosts(
+      (env.HERMES_YAOYAO_ALLOWED_HOSTS ?? '').split(',').filter(value => value.trim()),
+    )
+  } catch (cause) {
+    return {
+      source: 'environment',
+      hosts: [],
+      editableHosts: [],
+      environmentHosts: [],
+      configurationError: cause instanceof Error ? cause.message : '允许的访问地址环境变量无效',
+    }
+  }
+  const path = allowedHostsConfigurationPath(home)
+  if (!existsSync(path)) {
+    return {
+      source: environmentHosts.length ? 'environment' : 'none',
+      hosts: [...environmentHosts],
+      editableHosts: [],
+      environmentHosts,
+    }
+  }
+  try {
+    const editableHosts = readStoredHosts(path)
+    chmodSync(path, 0o600)
+    return {
+      source: 'file',
+      hosts: canonicalAllowedHosts([...environmentHosts, ...editableHosts]),
+      editableHosts,
+      environmentHosts,
+    }
+  } catch (cause) {
+    return {
+      source: 'file',
+      hosts: [...environmentHosts],
+      editableHosts: [],
+      environmentHosts,
+      configurationError: cause instanceof Error ? cause.message : '允许的访问地址配置文件无效',
+    }
+  }
+}
+
+function writeAtomic(path: string, value: string): void {
+  const directory = dirname(path)
+  mkdirSync(directory, { recursive: true, mode: 0o700 })
+  chmodSync(directory, 0o700)
+  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
+  let descriptor: number | undefined
+  try {
+    descriptor = openSync(temporary, 'wx', 0o600)
+    writeFileSync(descriptor, value)
+    fsyncSync(descriptor)
+    closeSync(descriptor)
+    descriptor = undefined
+    renameSync(temporary, path)
+    chmodSync(path, 0o600)
+  } catch (cause) {
+    if (descriptor !== undefined) closeSync(descriptor)
+    try { unlinkSync(temporary) } catch { /* already absent */ }
+    throw cause
+  }
+}
+
+function cloneSnapshot(value: AllowedHostsConfigurationSnapshot): AllowedHostsConfigurationSnapshot {
+  return {
+    ...value,
+    hosts: [...value.hosts],
+    editableHosts: [...value.editableHosts],
+    environmentHosts: [...value.environmentHosts],
+  }
+}
+
+export class AllowedHostsConfigurationManager {
+  readonly path: string
+  readonly #runtimeHosts: Set<string>
+  #snapshot: AllowedHostsConfigurationSnapshot
+
+  constructor(home: string, initial: AllowedHostsConfigurationSnapshot, runtimeHosts: Set<string>) {
+    this.path = allowedHostsConfigurationPath(home)
+    this.#snapshot = cloneSnapshot(initial)
+    this.#runtimeHosts = runtimeHosts
+    this.#applyRuntimeHosts(initial.hosts)
+  }
+
+  snapshot(): AllowedHostsConfigurationSnapshot {
+    return cloneSnapshot(this.#snapshot)
+  }
+
+  update(hosts: readonly string[]): AllowedHostsConfigurationSnapshot {
+    const editableHosts = canonicalAllowedHosts(hosts)
+    const effectiveHosts = canonicalAllowedHosts([
+      ...this.#snapshot.environmentHosts,
+      ...editableHosts,
+    ])
+    const stored: StoredAllowedHostsConfiguration = {
+      schemaVersion: 1,
+      hosts: editableHosts,
+      updatedAt: new Date().toISOString(),
+    }
+    writeAtomic(this.path, `${JSON.stringify(stored, null, 2)}\n`)
+    this.#snapshot = {
+      source: 'file',
+      hosts: effectiveHosts,
+      editableHosts,
+      environmentHosts: [...this.#snapshot.environmentHosts],
+    }
+    this.#applyRuntimeHosts(effectiveHosts)
+    return this.snapshot()
+  }
+
+  #applyRuntimeHosts(hosts: readonly string[]): void {
+    this.#runtimeHosts.clear()
+    for (const host of hosts) this.#runtimeHosts.add(host)
+  }
+}

+ 18 - 0
src/server/app.ts

@@ -34,6 +34,10 @@ import {
   type FCMConfigurationSnapshot,
 } from './fcmConfiguration.js'
 import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
+import {
+  AllowedHostsConfigurationManager,
+  type AllowedHostsConfigurationSnapshot,
+} from './allowedHostsConfiguration.js'
 import {
   ChatPushJobManager,
   GroupPushEventWatcher,
@@ -56,6 +60,7 @@ export interface ApplicationOptions {
   push?: PushCoordinator
   apnsConfiguration?: APNsConfigurationManager
   fcmConfiguration?: FCMConfigurationManager
+  allowedHostsConfiguration?: AllowedHostsConfigurationManager
 }
 
 export interface ApplicationRuntime {
@@ -74,6 +79,7 @@ export interface ApplicationRuntime {
   push: PushCoordinator
   apnsConfiguration: APNsConfigurationManager
   fcmConfiguration: FCMConfigurationManager
+  allowedHostsConfiguration: AllowedHostsConfigurationManager
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   groupPushEvents: GroupPushEventWatcher
@@ -109,6 +115,8 @@ function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
 
 export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
   const config = options.config ?? loadServerConfig()
+  const runtimeAllowedHosts = new Set(config.allowedHosts)
+  config.allowedHosts = runtimeAllowedHosts
   const app = new Koa()
   app.proxy = false
   const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
@@ -155,6 +163,14 @@ export function createApplication(options: ApplicationOptions = {}): Application
   }
   const fcmConfiguration = options.fcmConfiguration
     ?? new FCMConfigurationManager(config.home, initialFCMSettings)
+  const initialAllowedHostsSettings: AllowedHostsConfigurationSnapshot = config.allowedHostsSettings ?? {
+    source: runtimeAllowedHosts.size ? 'environment' : 'none',
+    hosts: [...runtimeAllowedHosts],
+    editableHosts: [],
+    environmentHosts: [...runtimeAllowedHosts],
+  }
+  const allowedHostsConfiguration = options.allowedHostsConfiguration
+    ?? new AllowedHostsConfigurationManager(config.home, initialAllowedHostsSettings, runtimeAllowedHosts)
   const push = options.push ?? new PushCoordinator({
     home: config.home,
     apns: config.apns,
@@ -291,6 +307,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     push,
     apnsConfiguration,
     fcmConfiguration,
+    allowedHostsConfiguration,
   })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
@@ -320,6 +337,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     push,
     apnsConfiguration,
     fcmConfiguration,
+    allowedHostsConfiguration,
     pushEventCoordinator,
     chatPushJobs,
     groupPushEvents,

+ 8 - 6
src/server/config.ts

@@ -5,6 +5,10 @@ import { readFileSync } from 'node:fs'
 import type { APNsEnvironment } from './apns.js'
 import { loadAPNsConfiguration, type APNsConfigurationSnapshot } from './apnsConfiguration.js'
 import { loadFCMConfiguration, type FCMConfigurationSnapshot } from './fcmConfiguration.js'
+import {
+  loadAllowedHostsConfiguration,
+  type AllowedHostsConfigurationSnapshot,
+} from './allowedHostsConfiguration.js'
 export { DEFAULT_APNS_TOPIC } from './apnsConfiguration.js'
 
 export interface APNsProviderConfig {
@@ -49,6 +53,7 @@ export interface ServerConfig {
   fcm?: FCMProviderConfig
   fcmConfigurationError?: string
   fcmSettings?: FCMConfigurationSnapshot
+  allowedHostsSettings?: AllowedHostsConfigurationSnapshot
 }
 
 export const DEFAULT_YAOYAO_PLUGIN_SOURCE = 'https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao'
@@ -184,17 +189,14 @@ export function loadServerConfig(
       'Plain HTTP on a non-loopback host requires HERMES_YAOYAO_ALLOW_INSECURE_LAN=1',
     )
   }
-  const allowedHosts = new Set(
-    (env.HERMES_YAOYAO_ALLOWED_HOSTS ?? '')
-      .split(',')
-      .map(normalizeConfiguredHost)
-      .filter(Boolean),
-  )
+  const allowedHostsSettings = loadAllowedHostsConfiguration(home, env)
+  const allowedHosts = new Set(allowedHostsSettings.hosts)
   return {
     host,
     port,
     upstream,
     allowedHosts,
+    allowedHostsSettings,
     home,
     mediaRoot,
     attachmentsRoot,

+ 24 - 0
src/server/routes.ts

@@ -40,6 +40,11 @@ import {
   FCMConfigurationManager,
   type FCMConfigurationInput,
 } from './fcmConfiguration.js'
+import {
+  AllowedHostsConfigurationManager,
+  canonicalAllowedHosts,
+  normalizeAllowedHost,
+} from './allowedHostsConfiguration.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -58,6 +63,7 @@ export interface RouteDependencies {
   push: PushCoordinator
   apnsConfiguration: APNsConfigurationManager
   fcmConfiguration: FCMConfigurationManager
+  allowedHostsConfiguration: AllowedHostsConfigurationManager
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -1751,6 +1757,24 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     dependencies.auth.requireAdmin(ctx)
     json(ctx, 200, pushSystemStatus(dependencies))
   })
+  router.get('/api/app/system/allowed-hosts', (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    json(ctx, 200, dependencies.allowedHostsConfiguration.snapshot())
+  })
+  router.put('/api/app/system/allowed-hosts', (ctx) => {
+    dependencies.auth.requireAdmin(ctx)
+    const request = body(ctx)
+    if (!Array.isArray(request.hosts) || request.hosts.some(host => typeof host !== 'string')) {
+      throw new HttpError(400, 'hosts 必须是域名或 IP 数组', 'invalid_allowed_hosts')
+    }
+    const requestedHosts = canonicalAllowedHosts(request.hosts as string[])
+    const currentHost = normalizeAllowedHost(ctx.hostname)
+    const environmentHosts = dependencies.allowedHostsConfiguration.snapshot().environmentHosts
+    if (!isPrivateHost(currentHost) && !new Set([...environmentHosts, ...requestedHosts]).has(currentHost)) {
+      throw new HttpError(409, `不能移除当前正在使用的访问地址 ${currentHost}`, 'current_host_required')
+    }
+    json(ctx, 200, dependencies.allowedHostsConfiguration.update(requestedHosts))
+  })
   router.put('/api/app/system/push-config', async (ctx) => {
     dependencies.auth.requireAdmin(ctx)
     const request = body(ctx)

+ 5 - 2
src/server/security.ts

@@ -65,7 +65,7 @@ export function isExactOrigin(
   // browser's same-origin request correctly carries an https Origin.
   if (!secure && actual === expectedRequestOrigin(host, true)) return true
   try {
-    return allowedHosts.has(new URL(actual).hostname.toLowerCase().replace(/\.$/, ''))
+    return allowedHosts.has(new URL(actual).hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''))
   } catch {
     return false
   }
@@ -171,7 +171,10 @@ export function requestAccountKey(request: IncomingMessage): string {
 
 export function applySecurityHeaders(ctx: Koa.Context, tls: boolean, allowedHosts: ReadonlySet<string> = new Set()): void {
   const socketOrigin = `${tls ? 'wss' : 'ws'}://${ctx.host}`
-  const proxySocketOrigins = [...allowedHosts].flatMap(host => [`ws://${host}`, `wss://${host}`])
+  const proxySocketOrigins = [...allowedHosts].flatMap(host => {
+    const authority = host.includes(':') ? `[${host}]` : host
+    return [`ws://${authority}`, `wss://${authority}`]
+  })
   ctx.set('Content-Security-Policy', [
     "default-src 'self'",
     "base-uri 'none'",

+ 53 - 0
tests/client/allowedHostsPanel.test.ts

@@ -0,0 +1,53 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { clearApiSecurityContext, setApiCsrfToken } from '@/api/client'
+import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
+
+afterEach(() => {
+  clearApiSecurityContext()
+  vi.unstubAllGlobals()
+  vi.restoreAllMocks()
+})
+
+describe('allowed hosts settings panel', () => {
+  it('shows environment entries separately and saves Web-managed domains and IPs', async () => {
+    const calls: Array<{ path: string; init?: RequestInit }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, init })
+      const editableHosts = init?.method === 'PUT'
+        ? (JSON.parse(String(init.body)) as { hosts: string[] }).hosts
+        : ['old.example.com']
+      return new Response(JSON.stringify({
+        source: 'file',
+        hosts: ['127.0.0.1', ...editableHosts],
+        editableHosts,
+        environmentHosts: ['127.0.0.1', 'localhost'],
+      }), { status: 200, headers: { 'content-type': 'application/json' } })
+    }))
+    setApiCsrfToken('csrf-panel')
+    const wrapper = mount(SystemManagementPanel, {
+      props: { section: 'connection', active: true },
+      global: { stubs: { AppIcon: true } },
+    })
+    await flushPromises()
+
+    expect(wrapper.text()).toContain('外网访问地址')
+    expect(wrapper.text()).toContain('环境变量保留地址(Web 中不能移除)')
+    expect(wrapper.get<HTMLTextAreaElement>('textarea[name="allowed-hosts"]').element.value).toBe('old.example.com')
+
+    await wrapper.get('textarea[name="allowed-hosts"]').setValue('yaoyao.example.com\n203.0.113.10')
+    const save = wrapper.findAll('button').find(button => button.text() === '保存访问地址')
+    expect(save).toBeDefined()
+    await save!.trigger('submit')
+    await flushPromises()
+
+    expect(calls.map(call => call.path)).toEqual([
+      '/api/app/system/allowed-hosts',
+      '/api/app/system/allowed-hosts',
+    ])
+    expect(calls[1]!.init?.body).toBe(JSON.stringify({
+      hosts: ['yaoyao.example.com', '203.0.113.10'],
+    }))
+    expect(wrapper.text()).toContain('允许的访问地址已保存并立即生效')
+  })
+})

+ 37 - 0
tests/client/allowedHostsProtocol.test.ts

@@ -0,0 +1,37 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { getAllowedHostsSettings, saveAllowedHostsSettings } from '@/api/admin'
+import { clearApiSecurityContext, setApiCsrfToken } from '@/api/client'
+
+afterEach(() => {
+  clearApiSecurityContext()
+  vi.unstubAllGlobals()
+})
+
+describe('allowed hosts client protocol', () => {
+  it('reads and saves domains and IP addresses inside the protected system API', async () => {
+    const calls: Array<{ path: string; init?: RequestInit }> = []
+    vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => {
+      calls.push({ path, init })
+      return new Response(JSON.stringify({
+        source: 'file',
+        hosts: ['127.0.0.1', '203.0.113.10', 'yaoyao.example.com'],
+        editableHosts: ['203.0.113.10', 'yaoyao.example.com'],
+        environmentHosts: ['127.0.0.1'],
+      }), { status: 200, headers: { 'content-type': 'application/json' } })
+    }))
+    setApiCsrfToken('csrf-allowed-hosts')
+
+    await expect(getAllowedHostsSettings()).resolves.toMatchObject({ source: 'file' })
+    await expect(saveAllowedHostsSettings(['yaoyao.example.com', '203.0.113.10']))
+      .resolves.toMatchObject({ editableHosts: ['203.0.113.10', 'yaoyao.example.com'] })
+
+    expect(calls.map(call => call.path)).toEqual([
+      '/api/app/system/allowed-hosts',
+      '/api/app/system/allowed-hosts',
+    ])
+    expect(new Headers(calls[1]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-allowed-hosts')
+    expect(calls[1]!.init?.body).toBe(JSON.stringify({
+      hosts: ['yaoyao.example.com', '203.0.113.10'],
+    }))
+  })
+})

+ 6 - 0
tests/client/systemManagementDialog.test.ts

@@ -8,7 +8,13 @@ import { getPushSystemStatus, saveFCMPushSystemConfig, savePushSystemConfig, typ
 vi.mock('@/api/admin', () => ({
   createUser: vi.fn(async () => undefined),
   deleteUser: vi.fn(async () => undefined),
+  getAllowedHostsSettings: vi.fn(async () => ({
+    source: 'none', hosts: [], editableHosts: [], environmentHosts: [],
+  })),
   listUsers: vi.fn(async () => []),
+  saveAllowedHostsSettings: vi.fn(async (hosts: string[]) => ({
+    source: 'file', hosts, editableHosts: hosts, environmentHosts: [],
+  })),
   setUpstreamCredentials: vi.fn(async () => undefined),
   updateUser: vi.fn(async () => undefined),
 }))

+ 111 - 0
tests/server/allowedHostsConfiguration.test.ts

@@ -0,0 +1,111 @@
+import { mkdtempSync, readFileSync, rmSync, statSync } 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 type { ApplicationRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+import {
+  AllowedHostsConfigurationManager,
+  allowedHostsConfigurationPath,
+  loadAllowedHostsConfiguration,
+  normalizeAllowedHost,
+} from '../../src/server/allowedHostsConfiguration.js'
+import { createAuthenticatedApplication } from './authenticatedApplication.js'
+
+const roots: string[] = []
+const runtimes: ApplicationRuntime[] = []
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+function temporaryHome(): string {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-allowed-hosts-'))
+  roots.push(home)
+  return home
+}
+
+function config(home: string, allowedHosts = new Set<string>()): ServerConfig {
+  return {
+    host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+    allowedHosts, home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+    mediaOwner: 'tester', allowInsecureLan: false, insecureLan: false, production: false,
+  }
+}
+
+describe('allowed hosts configuration', () => {
+  it('normalizes domains and IP addresses and rejects URLs or ports', () => {
+    expect(normalizeAllowedHost('YAOYAO.Example.COM.')).toBe('yaoyao.example.com')
+    expect(normalizeAllowedHost('[2001:db8::10]')).toBe('2001:db8::10')
+    expect(normalizeAllowedHost('例子.测试')).toBe('xn--fsqu00a.xn--0zwm56d')
+    expect(() => normalizeAllowedHost('https://yaoyao.example.com')).toThrow(/不能包含协议/)
+    expect(() => normalizeAllowedHost('203.0.113.10:8800')).toThrow(/不能附加端口/)
+  })
+
+  it('persists Web entries with restrictive permissions and merges environment entries after restart', () => {
+    const home = temporaryHome()
+    const initial = loadAllowedHostsConfiguration(home, {
+      HERMES_YAOYAO_ALLOWED_HOSTS: 'localhost,127.0.0.1',
+    })
+    const runtimeHosts = new Set(initial.hosts)
+    const manager = new AllowedHostsConfigurationManager(home, initial, runtimeHosts)
+
+    const saved = manager.update(['YAOYAO.Example.COM.', '203.0.113.10'])
+    expect(saved).toMatchObject({
+      editableHosts: ['203.0.113.10', 'yaoyao.example.com'],
+      environmentHosts: ['127.0.0.1', 'localhost'],
+    })
+    expect([...runtimeHosts].sort()).toEqual([
+      '127.0.0.1', '203.0.113.10', 'localhost', 'yaoyao.example.com',
+    ])
+    expect(statSync(allowedHostsConfigurationPath(home)).mode & 0o777).toBe(0o600)
+    expect(JSON.parse(readFileSync(allowedHostsConfigurationPath(home), 'utf8'))).toMatchObject({
+      schemaVersion: 1,
+      hosts: ['203.0.113.10', 'yaoyao.example.com'],
+    })
+
+    expect(loadAllowedHostsConfiguration(home, {
+      HERMES_YAOYAO_ALLOWED_HOSTS: 'localhost,proxy.example.com',
+    })).toMatchObject({
+      hosts: ['203.0.113.10', 'localhost', 'proxy.example.com', 'yaoyao.example.com'],
+      editableHosts: ['203.0.113.10', 'yaoyao.example.com'],
+      environmentHosts: ['localhost', 'proxy.example.com'],
+    })
+  })
+
+  it('updates the running Host guard immediately and keeps the current public host reachable', async () => {
+    const home = temporaryHome()
+    const serverConfig = config(home)
+    serverConfig.allowedHostsSettings = {
+      source: 'none', hosts: [], editableHosts: [], environmentHosts: [],
+    }
+    const runtime = createAuthenticatedApplication({ config: serverConfig })
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+
+    const saved = await agent.put('/api/app/system/allowed-hosts')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ hosts: ['yaoyao.example.com', '203.0.113.10'] })
+      .expect(200)
+    expect(saved.body).toMatchObject({
+      hosts: ['203.0.113.10', 'yaoyao.example.com'],
+      editableHosts: ['203.0.113.10', 'yaoyao.example.com'],
+    })
+    await agent.get('/healthz').set('Host', 'yaoyao.example.com').expect(200)
+    await agent.get('/healthz').set('Host', 'not-allowed.example.com').expect(421)
+
+    const publicBootstrap = await agent.get('/api/app/bootstrap').set('Host', 'yaoyao.example.com').expect(200)
+    const rejected = await agent.put('/api/app/system/allowed-hosts')
+      .set('Host', 'yaoyao.example.com')
+      .set('Origin', 'http://yaoyao.example.com')
+      .set('X-CSRF-Token', publicBootstrap.body.csrfToken)
+      .send({ hosts: ['203.0.113.10'] })
+      .expect(409)
+    expect(rejected.body.code).toBe('current_host_required')
+  })
+})

+ 11 - 0
tests/server/security.test.ts

@@ -79,6 +79,17 @@ describe('server security boundary', () => {
     expect(headers['Content-Security-Policy']).toContain('wss://yaoyao-lc.samien.cn')
   })
 
+  it('accepts configured IPv6 addresses and brackets them in WebSocket CSP origins', () => {
+    const allowed = new Set(['2001:db8::10'])
+    expect(isAllowedHostHeader('[2001:db8::10]:8800', config({ allowedHosts: allowed }))).toBe(true)
+    expect(isExactOrigin('https://[2001:db8::10]', '10.1.5.100', false, allowed)).toBe(true)
+    const headers: Record<string, string> = {}
+    const ctx = { host: '10.1.5.100', set(name: string, value: string) { headers[name] = value } } as unknown as Koa.Context
+    applySecurityHeaders(ctx, false, allowed)
+    expect(headers['Content-Security-Policy']).toContain('ws://[2001:db8::10]')
+    expect(headers['Content-Security-Policy']).toContain('wss://[2001:db8::10]')
+  })
+
   it('requires an exact scheme and host Origin', () => {
     expect(isExactOrigin('http://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)
     expect(isExactOrigin('https://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)