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.
This commit is contained in:
DrSmoothl
2026-09-13 18:12:26 +08:00
parent 6f278dc1da
commit acb0e04bbf
7 changed files with 201 additions and 29 deletions
+9 -1
View File
@@ -83,18 +83,20 @@ async function main() {
const notificationManager = createNotificationManager();
notificationManager.bind(bridgeManager);
let stopWebUI: (() => Promise<void>) | 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.
+1 -12
View File
@@ -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}(?<!-)$/.test(label));
}
function validateEffectiveTls(restore: PreparedRestoreFile[], opts: PrepareRestoreOptions): void {
const byName = new Map(restore.map((file) => [file.name, file.data]));
const pairTouched = byName.has('cert.pem') || byName.has('key.pem');
@@ -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}(?<!-)$/.test(label));
}
export function checkListenerExposure(input: {
webuiHost: string;
tlsEnabled: boolean;
pairOk: boolean;
}): ListenerExposureCheck {
if (!isValidBindHost(input.webuiHost)) return { ok: false, code: 'invalid-bind-host' };
if (input.tlsEnabled && !input.pairOk) return { ok: false, code: 'tls-pair-required' };
return { ok: true };
}
+40 -15
View File
@@ -56,7 +56,8 @@ import {
resolveEnvironmentConsent,
} from './consent';
import { requireTlsContext, resolveTlsContext, validateTlsPair } from './tls';
import { coerceSettingsPatch } from './system-settings';
import { coerceSettingsPatch, evaluateSettingsSave, tlsCertDeletionBlocked } from './system-settings';
import { checkListenerExposure } from './listener-exposure';
import { buildBackup } from './backup';
import { restoreBackup, RestoreTransactionError, type RestorePhase } from './restore';
import { collectActionDocs, collectCategories } from '@snowluma/onebot/action-docs';
@@ -470,7 +471,20 @@ export async function initWebUI(
hookManager?: HookManager,
notificationManager?: NotificationManager,
listener: { host?: string; tlsEnabled?: boolean; trustProxy?: string; stateBus?: StateBus } = {},
): Promise<{ port: number }> {
): Promise<{ port: number; stop: () => Promise<void> }> {
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<typeof startConnectionDiffLoop> | 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<void>((resolve) => {
serve({ fetch: app.fetch, port: finalPort, hostname: host, ...(tlsServe ?? {}) }, (info) => {
const listeningServer = await new Promise<ReturnType<typeof serve>>((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<void>((resolve, reject) => {
listeningServer.close((err) => (err ? reject(err) : resolve()));
});
},
};
}
@@ -4,6 +4,7 @@
// nested `webuiTls.enabled`.
import type { RuntimeConfig } from '@snowluma/common/runtime';
import { checkListenerExposure } from './listener-exposure';
export type SettingsPatch = Partial<Pick<RuntimeConfig, 'webuiPort' | 'webuiHost' | 'webuiTls' | 'trustProxy'>>;
@@ -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;
}
@@ -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/);
});
});
@@ -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);
});
});