From a28c5f0eda4738decfbb4a627fc8f33a1713325c Mon Sep 17 00:00:00 2001 From: LemonNeko Date: Tue, 3 Feb 2026 20:32:51 +0800 Subject: [PATCH] fix(channel-server): allow wss on non-localhost (#1021) --- .../services/airi/channel-server/index.ts | 99 +++++++++++++++++-- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts index 5e8f11f1d..32b4267d1 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/channel-server/index.ts @@ -1,5 +1,8 @@ import { execSync } from 'node:child_process' +import { X509Certificate } from 'node:crypto' import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { isIP } from 'node:net' +import { networkInterfaces } from 'node:os' import { join } from 'node:path' import { env, platform } from 'node:process' @@ -14,6 +17,79 @@ import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' let serverInstance: { close: (closeActiveConnections?: boolean) => Promise } | null = null +function getLocalIPs(): string[] { + const interfaces = networkInterfaces() + const addresses: string[] = [] + + const VIRTUAL_INTERFACE_PREFIXES = [ + 'vboxnet', + 'vmnet', + 'docker', + 'br-', + 'veth', + 'utun', + 'wg', + 'tap', + 'tun', + ] + const isVirtualInterface = (name: string) => + VIRTUAL_INTERFACE_PREFIXES.some(prefix => name.startsWith(prefix)) + + for (const [name, entries] of Object.entries(interfaces)) { + if (!entries) + continue + if (isVirtualInterface(name)) + continue + + for (const entry of entries) { + const rawAddress = entry.address + if (!rawAddress) + continue + + const address = rawAddress.includes('%') ? rawAddress.split('%')[0] : rawAddress + if (isIP(address)) + addresses.push(address) + } + } + + return addresses +} + +function getCertificateDomains(): string[] { + const localIPs = getLocalIPs() + const hostname = env.SERVER_RUNTIME_HOSTNAME + return Array.from(new Set([ + 'localhost', + '127.0.0.1', + '::1', + ...(hostname ? [hostname] : []), + ...localIPs, + ])) +} + +function certHasAllDomains(certPem: string, domains: string[]): boolean { + try { + const cert = new X509Certificate(certPem) + const san = cert.subjectAltName || '' + const entries = san.split(',').map(part => part.trim()) + const values = entries + .map((entry) => { + if (entry.startsWith('DNS:')) + return entry.slice(4).trim() + if (entry.startsWith('IP Address:')) + return entry.slice(11).trim() + return '' + }) + .filter(Boolean) + + const sanSet = new Set(values) + return domains.every(domain => sanSet.has(domain)) + } + catch { + return false + } +} + async function installCACertificate(caCert: string) { const userDataPath = app.getPath('userData') const caCertPath = join(userDataPath, 'websocket-ca-cert.pem') @@ -79,9 +155,11 @@ async function generateCertificate() { await installCACertificate(ca.cert) } + const domains = getCertificateDomains() + const cert = await createCert({ ca: { key: ca.key, cert: ca.cert }, - domains: ['localhost', '127.0.0.1', env.SERVER_RUNTIME_HOSTNAME || 'localhost'], + domains, validity: 365, }) @@ -95,11 +173,13 @@ async function getOrCreateCertificate() { const userDataPath = app.getPath('userData') const certPath = join(userDataPath, 'websocket-cert.pem') const keyPath = join(userDataPath, 'websocket-key.pem') + const expectedDomains = getCertificateDomains() if (existsSync(certPath) && existsSync(keyPath)) { - return { - cert: readFileSync(certPath, 'utf-8'), - key: readFileSync(keyPath, 'utf-8'), + const cert = readFileSync(certPath, 'utf-8') + const key = readFileSync(keyPath, 'utf-8') + if (certHasAllDomains(cert, expectedDomains)) { + return { cert, key } } } @@ -123,7 +203,7 @@ export async function setupServerChannel(options?: { websocketSecureEnabled?: bo const h3App = serverRuntime.setupApp() const port = env.PORT ? Number(env.PORT) : 6121 - const hostname = env.SERVER_RUNTIME_HOSTNAME || 'localhost' + const hostname = env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0' // FIXME: should prompt user to grant permission to save certificate files on macOS const tls = secureEnabled ? await getOrCreateCertificate() : undefined @@ -167,7 +247,14 @@ export async function setupServerChannel(options?: { websocketSecureEnabled?: bo } const protocol = secureEnabled ? 'wss' : 'ws' - log.log(`@proj-airi/server-runtime started on ${protocol}://${hostname}:${port}`) + if (hostname === '0.0.0.0') { + const ips = getLocalIPs().filter(ip => ip !== '127.0.0.1' && ip !== '::1') + const targets = ips.length > 0 ? ips.join(', ') : 'localhost' + log.log(`@proj-airi/server-runtime started on ${protocol}://0.0.0.0:${port} (reachable via: ${targets})`) + } + else { + log.log(`@proj-airi/server-runtime started on ${protocol}://${hostname}:${port}`) + } onAppBeforeQuit(async () => { if (serverInstance && typeof serverInstance.close === 'function') {