From acb0e04bbf25beca672831bc0e8830726ceb2fb3 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 13 Sep 2026 18:12:26 +0800 Subject: [PATCH] fix(webui): keep listener settings, restore, and boot on one contract Invalid bind hosts and TLS without a certificate pair can no longer be saved. Deleting certificates while TLS is on is rejected. A bad listener does not start HTTP instead, and shutdown unbinds before tearing down the hook. --- packages/core/src/index.ts | 10 +++- packages/core/src/webui/backup.ts | 13 +--- packages/core/src/webui/listener-exposure.ts | 27 +++++++++ packages/core/src/webui/server.ts | 55 ++++++++++++----- packages/core/src/webui/system-settings.ts | 23 +++++++ .../tests/webui-listener-exposure.test.ts | 42 +++++++++++++ .../core/tests/webui-system-settings.test.ts | 60 ++++++++++++++++++- 7 files changed, 201 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/webui/listener-exposure.ts create mode 100644 packages/core/tests/webui-listener-exposure.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1f152214..dd36ad78 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -83,18 +83,20 @@ async function main() { const notificationManager = createNotificationManager(); notificationManager.bind(bridgeManager); + let stopWebUI: (() => Promise) | undefined; if ( (typeof __BUILD_WEBUI__ !== 'undefined' && __BUILD_WEBUI__) || process.env.SNOWLUMA_DEV_WEBUI === '1' ) { try { const { initWebUI } = await import('./webui/server'); - await initWebUI(runtimeConfig.webuiPort || 5099, oneBotManager, hookManager, notificationManager, { + const started = await initWebUI(runtimeConfig.webuiPort || 5099, oneBotManager, hookManager, notificationManager, { host: runtimeConfig.webuiHost, tlsEnabled: runtimeConfig.webuiTls?.enabled, trustProxy: runtimeConfig.trustProxy, stateBus: stateWiring.bus, }); + stopWebUI = started.stop; } catch (err) { log.error('Failed to start WebUI: ', err); } @@ -118,6 +120,12 @@ async function main() { exitCode = 1; log.error('notification shutdown failed: %s', error instanceof Error ? (error.stack ?? error.message) : String(error)); } + try { + if (stopWebUI) await stopWebUI(); + } catch (error) { + exitCode = 1; + log.error('WebUI shutdown failed: %s', error instanceof Error ? (error.stack ?? error.message) : String(error)); + } // Observers go deaf before Hook teardown. Hook dispose then emits the // same disconnected → PID detach → Bridge closed path as a live drop; // those closed edges must not look like a runtime offline. diff --git a/packages/core/src/webui/backup.ts b/packages/core/src/webui/backup.ts index 0c1041a1..8131602b 100644 --- a/packages/core/src/webui/backup.ts +++ b/packages/core/src/webui/backup.ts @@ -18,10 +18,10 @@ import { normalizeRuntimeConfig } from '@snowluma/common/runtime'; import { isRealUin } from '@snowluma/common/uin'; import { normalizeGlobalSettings } from '@snowluma/onebot/global-config'; import { prepareOneBotConfigForRestore } from '@snowluma/onebot/config'; -import { isIP } from 'node:net'; import { normalizeNotificationsConfig } from '../notifications/config'; import { prepareWebuiAuthStateForRestore } from './auth'; import { MAX_BACKGROUND_BYTES, normalizeStoredUiConfig, sniffImageMime } from './ui-config'; +import { isValidBindHost } from './listener-exposure'; import { validateTlsPair } from './tls'; export const BACKUP_VERSION = 1; @@ -450,17 +450,6 @@ function serializeJson(value: unknown): Buffer { return Buffer.from(JSON.stringify(value, null, 2), 'utf8'); } -function isValidBindHost(value: string): boolean { - const host = value.trim(); - if (!host || host !== value || host.length > 253 || /[\s/?#@]/u.test(host)) return false; - if (host.includes(':')) return isIP(host) === 6; - if (/^[\d.]+$/.test(host)) { - return isIP(host) === 4; - } - const normalized = host.endsWith('.') ? host.slice(0, -1) : host; - return normalized.split('.').every((label) => /^(?!-)[A-Za-z0-9-]{1,63}(? [file.name, file.data])); const pairTouched = byName.has('cert.pem') || byName.has('key.pem'); diff --git a/packages/core/src/webui/listener-exposure.ts b/packages/core/src/webui/listener-exposure.ts new file mode 100644 index 00000000..3470f72d --- /dev/null +++ b/packages/core/src/webui/listener-exposure.ts @@ -0,0 +1,27 @@ +import { isIP } from 'node:net'; + +export type ListenerExposureCheck = + | { ok: true } + | { ok: false; code: 'invalid-bind-host' | 'tls-pair-required' }; + +/** TCP bind-host accepted by WebUI Listener Exposure (settings, restore, boot). */ +export function isValidBindHost(value: string): boolean { + const host = value.trim(); + if (!host || host !== value || host.length > 253 || /[\s/?#@]/u.test(host)) return false; + if (host.includes(':')) return isIP(host) === 6; + if (/^[\d.]+$/.test(host)) { + return isIP(host) === 4; + } + const normalized = host.endsWith('.') ? host.slice(0, -1) : host; + return normalized.split('.').every((label) => /^(?!-)[A-Za-z0-9-]{1,63}(? { +): Promise<{ port: number; stop: () => Promise }> { + const host = listener.host || '127.0.0.1'; + const tlsEnabled = listener.tlsEnabled === true; + const bootCheck = checkListenerExposure({ + webuiHost: host, + tlsEnabled, + pairOk: resolveTlsContext('config').ok, + }); + if (!bootCheck.ok) { + const reason = bootCheck.code === 'invalid-bind-host' + ? `bind host is not a valid TCP address: ${host}` + : 'TLS is enabled but the certificate/private-key pair is unusable'; + throw new Error(`WebUI listener cannot start: ${reason}`); + } // Resolve the client IP for per-IP rate limiting from the configured // trust-proxy directive (runtime.json `trustProxy`, env-overridable via // SNOWLUMA_WEBUI_TRUST_PROXY which loadRuntimeConfig already merged in). @@ -505,10 +519,9 @@ export async function initWebUI( // adapter detail strings at sub-second cadence wasn't required. If it // becomes one, surface client-count as a structured field on // AdapterStatus and include it in the comparable. + let connectionDiffLoop: ReturnType | undefined; if (listener.stateBus) { - // Handle deliberately discarded — initWebUI has no shutdown path; the - // loop's setInterval is .unref'd so it doesn't pin the event loop. - startConnectionDiffLoop({ + connectionDiffLoop = startConnectionDiffLoop({ bus: listener.stateBus, getSnapshot: () => oneBotManager.getConnectionStatuses(), pickComparable: comparableConnectionSnapshot, @@ -1128,11 +1141,13 @@ export async function initWebUI( } const coerced = coerceSettingsPatch(body); if (!coerced.ok) return c.json({ success: false, message: coerced.error }, 400); - // Enabling TLS without a usable cert would brick HTTPS on restart — block it. - if (coerced.patch.webuiTls?.enabled && !resolveTlsContext('config').ok) { - return c.json({ success: false, message: '启用 TLS 前请先上传有效的证书与私钥' }, 400); - } - const saved = updateRuntimeConfig(coerced.patch); + const evaluated = evaluateSettingsSave( + readRuntimeConfig(), + coerced.patch, + resolveTlsContext('config').ok, + ); + if (!evaluated.ok) return c.json({ success: false, message: evaluated.error }, 400); + const saved = updateRuntimeConfig(evaluated.patch); return c.json({ success: true, settings: { @@ -1201,6 +1216,9 @@ export async function initWebUI( }); app.delete('/api/system/tls/cert', (c) => { + if (tlsCertDeletionBlocked(readRuntimeConfig().webuiTls?.enabled === true)) { + return c.json({ success: false, message: '请先关闭 TLS 再删除证书' }, 400); + } try { rmSync(SYSTEM_CERT_PATH, { force: true }); rmSync(SYSTEM_KEY_PATH, { force: true }); @@ -1778,7 +1796,6 @@ export async function initWebUI( ); }); - const host = listener.host || '127.0.0.1'; const finalPort = await findAvailablePort(desiredPort, { host }); if (finalPort !== desiredPort) { log.warn('port %d is in use, using %d instead', desiredPort, finalPort); @@ -1795,11 +1812,19 @@ export async function initWebUI( scheme = 'https'; } - await new Promise((resolve) => { - serve({ fetch: app.fetch, port: finalPort, hostname: host, ...(tlsServe ?? {}) }, (info) => { + const listeningServer = await new Promise>((resolve) => { + const server = serve({ fetch: app.fetch, port: finalPort, hostname: host, ...(tlsServe ?? {}) }, (info) => { log.info(`listening ${scheme}://${host}:${info.port}`); - resolve(); + resolve(server); }); }); - return { port: finalPort }; + return { + port: finalPort, + async stop() { + connectionDiffLoop?.dispose(); + await new Promise((resolve, reject) => { + listeningServer.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; } diff --git a/packages/core/src/webui/system-settings.ts b/packages/core/src/webui/system-settings.ts index fcecf80e..6bd51530 100644 --- a/packages/core/src/webui/system-settings.ts +++ b/packages/core/src/webui/system-settings.ts @@ -4,6 +4,7 @@ // nested `webuiTls.enabled`. import type { RuntimeConfig } from '@snowluma/common/runtime'; +import { checkListenerExposure } from './listener-exposure'; export type SettingsPatch = Partial>; @@ -50,3 +51,25 @@ export function coerceSettingsPatch(body: unknown): CoerceResult { return { ok: true, patch }; } + +const SETTINGS_ERRORS: Record<'invalid-bind-host' | 'tls-pair-required', string> = { + 'invalid-bind-host': '绑定地址不是有效的 TCP 地址', + 'tls-pair-required': '启用 TLS 前请先上传有效的证书与私钥', +}; + +/** Validate the listener that would be persisted after applying `patch` to `current`. */ +export function evaluateSettingsSave( + current: RuntimeConfig, + patch: SettingsPatch, + pairOk: boolean, +): CoerceResult { + const webuiHost = patch.webuiHost ?? current.webuiHost ?? '127.0.0.1'; + const tlsEnabled = patch.webuiTls?.enabled ?? current.webuiTls?.enabled ?? false; + const check = checkListenerExposure({ webuiHost, tlsEnabled, pairOk }); + if (!check.ok) return { ok: false, error: SETTINGS_ERRORS[check.code] }; + return { ok: true, patch }; +} + +export function tlsCertDeletionBlocked(tlsEnabled: boolean): boolean { + return tlsEnabled; +} diff --git a/packages/core/tests/webui-listener-exposure.test.ts b/packages/core/tests/webui-listener-exposure.test.ts new file mode 100644 index 00000000..b32901fe --- /dev/null +++ b/packages/core/tests/webui-listener-exposure.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import type { OneBotManager } from '@snowluma/onebot/manager'; +import { checkListenerExposure, isValidBindHost } from '../src/webui/listener-exposure'; +import { initWebUI } from '../src/webui/server'; + +describe('isValidBindHost', () => { + it('accepts loopback, all-interfaces, and IPv6 any', () => { + expect(isValidBindHost('127.0.0.1')).toBe(true); + expect(isValidBindHost('0.0.0.0')).toBe(true); + expect(isValidBindHost('::')).toBe(true); + expect(isValidBindHost('localhost')).toBe(true); + }); + + it('rejects spaces, underscores, and malformed IPv6 zone ids', () => { + expect(isValidBindHost('bad host')).toBe(false); + expect(isValidBindHost('not_a_host')).toBe(false); + expect(isValidBindHost('::1%bad%extra')).toBe(false); + expect(isValidBindHost(' 127.0.0.1')).toBe(false); + }); +}); + +describe('checkListenerExposure', () => { + it('requires a usable pair only when TLS is on', () => { + expect(checkListenerExposure({ webuiHost: '127.0.0.1', tlsEnabled: false, pairOk: false })).toEqual({ ok: true }); + expect(checkListenerExposure({ webuiHost: '127.0.0.1', tlsEnabled: true, pairOk: false })).toEqual({ + ok: false, + code: 'tls-pair-required', + }); + }); +}); + +describe('initWebUI boot', () => { + it('fails before listen when the bind host is invalid', async () => { + await expect(initWebUI( + 5099, + {} as OneBotManager, + undefined, + undefined, + { host: 'not_a_host' }, + )).rejects.toThrow(/bind host is not a valid TCP address/); + }); +}); diff --git a/packages/core/tests/webui-system-settings.test.ts b/packages/core/tests/webui-system-settings.test.ts index d79b6607..5f267731 100644 --- a/packages/core/tests/webui-system-settings.test.ts +++ b/packages/core/tests/webui-system-settings.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { coerceSettingsPatch } from '../src/webui/system-settings'; +import { + coerceSettingsPatch, + evaluateSettingsSave, + tlsCertDeletionBlocked, +} from '../src/webui/system-settings'; +import type { RuntimeConfig } from '@snowluma/common/runtime'; describe('coerceSettingsPatch', () => { it('accepts a full valid body and maps tlsEnabled → webuiTls.enabled', () => { @@ -37,3 +42,56 @@ describe('coerceSettingsPatch', () => { expect(coerceSettingsPatch({ trustProxy: '' })).toEqual({ ok: true, patch: { trustProxy: '' } }); }); }); + +describe('evaluateSettingsSave', () => { + const current: RuntimeConfig = { + webuiPort: 5099, + webuiHost: '127.0.0.1', + webuiTls: { enabled: false }, + }; + + it('rejects an invalid bind host on the patch', () => { + const r = evaluateSettingsSave(current, { webuiHost: 'not_a_host' }, true); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/TCP/); + }); + + it('rejects saving an unrelated field when the persisted host is invalid', () => { + const r = evaluateSettingsSave( + { ...current, webuiHost: 'not_a_host' }, + { webuiPort: 8080 }, + true, + ); + expect(r.ok).toBe(false); + }); + + it('rejects enabling TLS without a usable certificate pair', () => { + const r = evaluateSettingsSave(current, { webuiTls: { enabled: true } }, false); + expect(r).toEqual({ ok: false, error: '启用 TLS 前请先上传有效的证书与私钥' }); + }); + + it('rejects leaving TLS enabled when the pair is already missing', () => { + const r = evaluateSettingsSave( + { ...current, webuiTls: { enabled: true } }, + { webuiPort: 8080 }, + false, + ); + expect(r.ok).toBe(false); + }); + + it('allows disabling TLS even without a pair', () => { + const r = evaluateSettingsSave( + { ...current, webuiTls: { enabled: true } }, + { webuiTls: { enabled: false } }, + false, + ); + expect(r).toEqual({ ok: true, patch: { webuiTls: { enabled: false } } }); + }); +}); + +describe('tlsCertDeletionBlocked', () => { + it('blocks deletion while TLS is enabled and allows it when TLS is off', () => { + expect(tlsCertDeletionBlocked(true)).toBe(true); + expect(tlsCertDeletionBlocked(false)).toBe(false); + }); +});