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:
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user