Przeglądaj źródła

迁移受管服务到局域网监听

samien 2 miesięcy temu
rodzic
commit
505c31d785

+ 1 - 6
README.md

@@ -197,12 +197,7 @@ npm run dev
 
 夭夭 Web 默认上游仍是 `http://127.0.0.1:9119`,8800 在本机通过回环连接受监督的 9119;两个服务对局域网的监听地址均为 `0.0.0.0`。
 
-若需要把受管安装恢复为仅本机访问,可在安装前显式设置:
-
-```bash
-HERMES_YAOYAO_HOST=127.0.0.1
-HERMES_YAOYAO_ALLOW_INSECURE_LAN=0
-```
+受管 `service install` 会覆盖遗留的 loopback 环境并保持局域网监听。若必须限制为仅本机访问,请使用手动 `npm start` 或 Docker 的回环绑定,不要使用受管 LaunchAgent。
 
 默认局域网 HTTP 只适合可信网络。跨不受信网络使用时必须配置 `HERMES_YAOYAO_TLS_CERT` 和 `HERMES_YAOYAO_TLS_KEY`,并限制防火墙或反向代理访问范围。
 

+ 2 - 0
bin/hermes-yaoyao-updater.mjs

@@ -155,6 +155,8 @@ function serviceEnvironment(serviceRoot, plan) {
     HERMES_YAOYAO_SERVICE_ROOT: serviceRoot,
     HERMES_YAOYAO_RELEASE_ROOT: plan.releaseRoot,
     HERMES_YAOYAO_RELEASE_SOURCE: plan.source,
+    HERMES_YAOYAO_HOST: '0.0.0.0',
+    HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
   }
 }
 

+ 2 - 2
bin/hermes-yaoyao.mjs

@@ -61,12 +61,12 @@ export function launchAgentPlist(options = {}) {
     PATH: `${dirname(node)}:${join(homedir(), '.local', 'bin')}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
     NODE_ENV: 'production',
     HERMES_YAOYAO_HOME: dataHome,
-    HERMES_YAOYAO_HOST: '0.0.0.0',
     HERMES_YAOYAO_PORT: '8800',
     HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:9119',
-    HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
     HERMES_YAOYAO_SUPERVISE_DASHBOARD: '1',
     ...environment(options.environment),
+    HERMES_YAOYAO_HOST: '0.0.0.0',
+    HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
   }
   const envXml = Object.entries(envEntries).map(([key, value]) => (
     `      <key>${escapeXml(key)}</key>\n      <string>${escapeXml(value)}</string>`

+ 2 - 7
docs/agent-install.md

@@ -23,12 +23,7 @@
 
 受管服务不支持“仅开放 8800”:安装后会同时以局域网地址启动 8800 和 9119。该默认值只适用于受管 macOS LaunchAgent;手动 `npm start` 和 Docker 部署仍使用各自的绑定配置。
 
-若部署必须限制为仅本机访问,Agent 应在安装前显式覆盖:
-
-```bash
-export HERMES_YAOYAO_HOST=127.0.0.1
-export HERMES_YAOYAO_ALLOW_INSECURE_LAN=0
-```
+受管 `service install` 会覆盖遗留的 loopback 环境并保持局域网监听。若部署必须限制为仅本机访问,请使用手动 `npm start` 或 Docker 的回环绑定,不要使用受管 LaunchAgent。
 
 默认局域网模式会显式设置 `HERMES_YAOYAO_ALLOW_INSECURE_LAN=1`,只适合受信网络。跨不受信网络部署时必须设置 `HERMES_YAOYAO_TLS_CERT` 和 `HERMES_YAOYAO_TLS_KEY`,并使用防火墙或反向代理限制访问;`hermes dashboard --insecure` 不会关闭认证,不能将它当成局域网开关。
 
@@ -209,7 +204,7 @@ node bin/hermes-yaoyao.mjs service install
 node bin/hermes-yaoyao.mjs service status
 ```
 
-若需要显式指定 Dashboard 上游,只在安装服务前设置 `HERMES_YAOYAO_UPSTREAM`。默认值为 `http://127.0.0.1:9119`,因为 8800 仍通过本机回环访问受监督的 9119。若要恢复仅本机监听,请在安装前设置 `HERMES_YAOYAO_HOST=127.0.0.1` 和 `HERMES_YAOYAO_ALLOW_INSECURE_LAN=0`。
+若需要显式指定 Dashboard 上游,只在安装服务前设置 `HERMES_YAOYAO_UPSTREAM`。默认值为 `http://127.0.0.1:9119`,因为 8800 仍通过本机回环访问受监督的 9119。受管升级会迁移旧版 `127.0.0.1/0` 环境并继续使用局域网监听。
 
 受管服务的配置被写入 `~/Library/LaunchAgents/com.samien.hermes-yaoyao.plist`。任何监听、TLS、上游或监督配置变更后,都要再次执行 `service install`,随后确认状态、两个监听器和日志:
 

+ 30 - 2
src/server/dashboardSupervisor.ts

@@ -1,6 +1,8 @@
 import { execFileSync, spawn } from 'node:child_process'
 import { randomBytes, scryptSync } from 'node:crypto'
 import { createConnection } from 'node:net'
+import { networkInterfaces } from 'node:os'
+import { isPrivateHost } from './config.js'
 
 export const DEFAULT_DASHBOARD_USERNAME = 'admin'
 export const DEFAULT_DASHBOARD_PASSWORD = 'admin'
@@ -19,6 +21,7 @@ export interface DashboardSupervisorOptions {
   run?: Run
   launch?: Launch
   probe?: Probe
+  lanProbeHost?: string
   log?: (message: string) => void
   credentials?: { username: string; password: string }
 }
@@ -49,6 +52,17 @@ function portIsListening(host: string, port: number): Promise<boolean> {
   })
 }
 
+function privateLanAddress(): string | undefined {
+  for (const addresses of Object.values(networkInterfaces())) {
+    for (const address of addresses ?? []) {
+      if (address.family === 'IPv4' && !address.internal && isPrivateHost(address.address)) {
+        return address.address
+      }
+    }
+  }
+  return undefined
+}
+
 function delay(milliseconds: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, milliseconds))
 }
@@ -66,6 +80,8 @@ export class DashboardSupervisor {
   private readonly run: Run
   private readonly launch: Launch
   private readonly probe: Probe
+  private readonly allowLan: boolean
+  private readonly lanProbeHost: string | undefined
   private readonly log: (message: string) => void
   private readonly intervalMs: number
   private readonly credentials: { username: string; password: string }
@@ -74,10 +90,12 @@ export class DashboardSupervisor {
 
   constructor(options: DashboardSupervisorOptions) {
     this.command = options.command ?? 'hermes'
+    this.allowLan = options.allowLan
     this.dashboardHost = options.allowLan ? '0.0.0.0' : '127.0.0.1'
     this.run = options.run ?? defaultRun(this.command)
     this.launch = options.launch ?? defaultLaunch(this.command)
     this.probe = options.probe ?? portIsListening
+    this.lanProbeHost = options.lanProbeHost ?? privateLanAddress()
     this.log = options.log ?? console.info
     this.intervalMs = options.intervalMs ?? SUPERVISION_INTERVAL_MS
     this.credentials = options.credentials ?? {
@@ -115,8 +133,12 @@ export class DashboardSupervisor {
     try {
       const credentialsChanged = this.ensureBasicAuthentication()
       let running = await this.probe('127.0.0.1', DASHBOARD_PORT)
-      if (credentialsChanged && running) {
-        this.log('Hermes Dashboard authentication was configured; restarting 9119 to load it.')
+      const bindingChanged = running && !(await this.bindingMatches())
+      if ((credentialsChanged || bindingChanged) && running) {
+        const reason = credentialsChanged
+          ? 'authentication was configured'
+          : `listener must move to ${this.dashboardHost}`
+        this.log(`Hermes Dashboard ${reason}; restarting 9119 to load it.`)
         this.run(['dashboard', '--stop'])
         running = await this.probe('127.0.0.1', DASHBOARD_PORT)
       }
@@ -130,6 +152,12 @@ export class DashboardSupervisor {
     }
   }
 
+  private async bindingMatches(): Promise<boolean> {
+    if (!this.lanProbeHost) return true
+    const listeningOnLan = await this.probe(this.lanProbeHost, DASHBOARD_PORT)
+    return this.allowLan ? listeningOnLan : !listeningOnLan
+  }
+
   private value(key: string): string {
     try {
       return this.run(['config', 'get', key]).trim()

+ 43 - 1
tests/server/dashboardSupervisor.test.ts

@@ -7,14 +7,18 @@ import {
 function supervisor(overrides: {
   values?: Record<string, string>
   running?: boolean
+  runningHost?: '127.0.0.1' | '0.0.0.0'
   allowLan?: boolean
 } = {}) {
   const values = { ...overrides.values }
   let running = overrides.running ?? false
+  let runningHost = overrides.runningHost ?? '127.0.0.1'
+  const lanProbeHost = '192.168.1.20'
   const calls: string[][] = []
   const launches: string[][] = []
   const instance = new DashboardSupervisor({
     allowLan: overrides.allowLan ?? false,
+    lanProbeHost,
     run(args) {
       calls.push([...args])
       if (args[0] === 'config' && args[1] === 'get') return values[args[2]] ?? ''
@@ -31,8 +35,12 @@ function supervisor(overrides: {
     launch(args) {
       launches.push([...args])
       running = true
+      runningHost = args[2] as typeof runningHost
     },
-    probe: async () => running,
+    probe: async (host) => running && (
+      host === '127.0.0.1'
+      || (host === lanProbeHost && runningHost === '0.0.0.0')
+    ),
     log: () => undefined,
   })
   return { instance, calls, launches, values }
@@ -72,6 +80,40 @@ describe('DashboardSupervisor', () => {
     expect(value.launches).toEqual([['dashboard', '--host', '0.0.0.0', '--no-open']])
   })
 
+  it('moves an authenticated loopback dashboard onto the LAN when managed installation requires it', async () => {
+    const value = supervisor({
+      running: true,
+      runningHost: '127.0.0.1',
+      allowLan: true,
+      values: {
+        'dashboard.basic_auth.username': 'operator',
+        'dashboard.basic_auth.password_hash': 'scrypt$existing',
+        'dashboard.basic_auth.secret': 'configured-secret',
+      },
+    })
+    await value.instance.checkNow()
+
+    expect(value.calls).toContainEqual(['dashboard', '--stop'])
+    expect(value.launches).toEqual([['dashboard', '--host', '0.0.0.0', '--no-open']])
+  })
+
+  it('moves an authenticated LAN dashboard back to loopback when unmanaged LAN is requested', async () => {
+    const value = supervisor({
+      running: true,
+      runningHost: '0.0.0.0',
+      allowLan: false,
+      values: {
+        'dashboard.basic_auth.username': 'operator',
+        'dashboard.basic_auth.password_hash': 'scrypt$existing',
+        'dashboard.basic_auth.secret': 'configured-secret',
+      },
+    })
+    await value.instance.checkNow()
+
+    expect(value.calls).toContainEqual(['dashboard', '--stop'])
+    expect(value.launches).toEqual([['dashboard', '--host', '127.0.0.1', '--no-open']])
+  })
+
   it('explicitly restarts a healthy dashboard and waits for 9119 to return', async () => {
     const value = supervisor({
       running: true,

+ 3 - 3
tests/serviceCli.test.ts

@@ -23,7 +23,7 @@ describe('hermes-yaoyao LaunchAgent plist', () => {
     expect(plist).toContain('<key>HERMES_YAOYAO_SUPERVISE_DASHBOARD</key>\n      <string>1</string>')
   })
 
-  it('allows an explicit loopback-only managed installation', () => {
+  it('keeps the managed installation on LAN even with stale loopback environment', () => {
     const plist = launchAgentPlist({
       environment: {
         HERMES_YAOYAO_HOST: '127.0.0.1',
@@ -31,8 +31,8 @@ describe('hermes-yaoyao LaunchAgent plist', () => {
       },
     })
 
-    expect(plist).toContain('<key>HERMES_YAOYAO_HOST</key>\n      <string>127.0.0.1</string>')
-    expect(plist).toContain('<key>HERMES_YAOYAO_ALLOW_INSECURE_LAN</key>\n      <string>0</string>')
+    expect(plist).toContain('<key>HERMES_YAOYAO_HOST</key>\n      <string>0.0.0.0</string>')
+    expect(plist).toContain('<key>HERMES_YAOYAO_ALLOW_INSECURE_LAN</key>\n      <string>1</string>')
   })
 
   it('can point the managed service at the stable current release link', () => {

+ 2 - 0
tests/updater.test.ts

@@ -70,6 +70,8 @@ describe('standalone updater primitives', () => {
     expect(invocation.cli).toBe(join(newRelease, 'bin', 'hermes-yaoyao.mjs'))
     expect(invocation.env.HERMES_YAOYAO_SERVICE_ROOT).toBe(join(releaseRoot, 'current'))
     expect(invocation.env.HERMES_YAOYAO_RELEASE_SOURCE).toBe('https://example.test/hermes-yaoyao.git')
+    expect(invocation.env.HERMES_YAOYAO_HOST).toBe('0.0.0.0')
+    expect(invocation.env.HERMES_YAOYAO_ALLOW_INSECURE_LAN).toBe('1')
 
     const sourceRollback = serviceInstallInvocation(
       oldRelease,