fix(stage-tamagotchi): rollback when wss enable failed, show errors

This commit is contained in:
LemonNeko
2026-04-03 18:28:07 +08:00
parent b43a527275
commit 1fccad5fc7
5 changed files with 203 additions and 72 deletions
@@ -9,9 +9,10 @@ import { env, platform } from 'node:process'
import { useLogg } from '@guiiai/logg'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { errorMessageFrom } from '@moeru/std'
import { createServer, getLocalIPs } from '@proj-airi/server-runtime/server'
import { Mutex } from 'async-mutex'
import { app, ipcMain } from 'electron'
import { app, ipcMain, session } from 'electron'
import { createCA, createCert } from 'mkcert'
import { x } from 'tinyexec'
import { nullable, object, optional, string } from 'valibot'
@@ -41,11 +42,44 @@ const channelServerConfigStore = createConfig('server-channel', 'config.json', c
},
autoHeal: true,
})
let serverChannelServiceRegistered = false
let serverChannelCertificateTrustConfigured = false
interface ServerChannelCertificateVerifyRequest {
hostname: string
verificationResult: string
errorCode: number
certificate: {
subject: {
commonName: string
}
issuer: {
commonName: string
country: string
locality: string
organizations: string[]
}
}
}
async function getChannelServerConfig(): Promise<ServerOptions> {
return channelServerConfigStore.get() || { tlsConfig: null }
}
function getServerRuntimeBaseOptions() {
return {
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
}
}
async function resolveServerRuntimeOptions(config: ServerOptions): Promise<ServerOptions> {
return {
...getServerRuntimeBaseOptions(),
tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null,
}
}
async function normalizeChannelServerOptions(payload: unknown, fallback?: ServerOptions) {
if (!fallback) {
fallback = await getChannelServerConfig()
@@ -96,17 +130,53 @@ function certHasAllDomains(certPem: string, domains: string[]): boolean {
}
}
function isTrustedServerChannelCertificate(request: ServerChannelCertificateVerifyRequest): boolean {
if (!['CERT_AUTHORITY_INVALID', 'ERR_CERT_AUTHORITY_INVALID'].includes(request.verificationResult)
&& request.errorCode !== -202) {
return false
}
if (!getCertificateDomains().includes(request.hostname)) {
return false
}
const issuer = request.certificate.issuer
return request.certificate.subject.commonName === 'localhost'
&& issuer.commonName === 'AIRI'
&& issuer.country === 'US'
&& issuer.locality === 'Local'
&& issuer.organizations.includes('AIRI')
}
function configureServerChannelCertificateTrust() {
if (serverChannelCertificateTrustConfigured) {
return
}
session.defaultSession.setCertificateVerifyProc((request, callback) => {
if (isTrustedServerChannelCertificate(request)) {
callback(0)
return
}
callback(-3)
})
serverChannelCertificateTrustConfigured = true
}
async function installCACertificate(caCert: string) {
const userDataPath = app.getPath('userData')
const caCertPath = join(userDataPath, 'websocket-ca-cert.pem')
const log = useLogg('main/server-runtime').useGlobalConfig()
writeFileSync(caCertPath, caCert)
try {
if (platform === 'darwin') {
await x(`security`, ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', '/Library/Keychains/System.keychain', `"${caCertPath}"`], { nodeOptions: { stdio: 'ignore' } })
await x('security', ['add-trusted-cert', '-d', '-r', 'trustRoot', '-k', join(app.getPath('home'), 'Library/Keychains/login.keychain-db'), caCertPath], { nodeOptions: { stdio: 'ignore' } })
}
else if (platform === 'win32') {
await x(`certutil`, ['-addstore', '-f', 'Root', `"${caCertPath}"`], { nodeOptions: { stdio: 'ignore' } })
await x('certutil', ['-addstore', '-f', 'Root', caCertPath], { nodeOptions: { stdio: 'ignore' } })
}
else if (platform === 'linux') {
const caDir = '/usr/local/share/ca-certificates'
@@ -119,7 +189,7 @@ async function installCACertificate(caCert: string) {
const userCaDir = join(env.HOME || '', '.local/share/ca-certificates')
try {
if (!existsSync(userCaDir)) {
await x(`mkdir`, ['-p', `"${userCaDir}"`], { nodeOptions: { stdio: 'ignore' } })
await x('mkdir', ['-p', userCaDir], { nodeOptions: { stdio: 'ignore' } })
}
writeFileSync(join(userCaDir, caFileName), caCert)
}
@@ -129,8 +199,8 @@ async function installCACertificate(caCert: string) {
}
}
}
catch {
// Ignore installation errors
catch (error) {
log.withError(error).warn(`Failed to install AIRI WebSocket CA certificate from ${caCertPath}`)
}
}
@@ -157,10 +227,10 @@ async function generateCertificate() {
})
writeFileSync(caCertPath, ca.cert)
writeFileSync(caKeyPath, ca.key)
await installCACertificate(ca.cert)
}
await installCACertificate(ca.cert)
const domains = getCertificateDomains()
const cert = await createCert({
@@ -198,14 +268,13 @@ async function getOrCreateCertificate() {
export async function setupServerChannel(params: { lifecycle: Lifecycle }): Promise<Server> {
channelServerConfigStore.setup()
configureServerChannelCertificateTrust()
const storedConfig = await getChannelServerConfig()
const serverChannel = createServer({
...storedConfig,
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
tlsConfig: storedConfig.tlsConfig ? await getOrCreateCertificate() : null,
...(await resolveServerRuntimeOptions(storedConfig)),
})
const mutex = new Mutex()
@@ -291,6 +360,11 @@ export async function setupServerChannel(params: { lifecycle: Lifecycle }): Prom
}
export async function createServerChannelService(params: { serverChannel: Server }) {
if (serverChannelServiceRegistered) {
return
}
serverChannelServiceRegistered = true
const { context } = createContext(ipcMain)
defineInvokeHandler(context, electronGetServerChannelConfig, async () => {
@@ -298,30 +372,40 @@ export async function createServerChannelService(params: { serverChannel: Server
})
defineInvokeHandler(context, electronApplyServerChannelConfig, async (req) => {
const current = await getChannelServerConfig()
const next = await normalizeChannelServerOptions(req, current)
const changed = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig)
try {
const current = await getChannelServerConfig()
const next = await normalizeChannelServerOptions(req, current)
const changed = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig)
channelServerConfigStore.update(next)
if (changed) {
await params.serverChannel.stop()
await params.serverChannel.updateConfig({
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
tlsConfig: next.tlsConfig ? await getOrCreateCertificate() : null,
})
await params.serverChannel.start()
}
else {
await params.serverChannel.start()
const nextRuntimeOptions = await resolveServerRuntimeOptions(next)
await params.serverChannel.updateConfig(nextRuntimeOptions)
await params.serverChannel.restart()
channelServerConfigStore.update(next)
return next
}
await params.serverChannel.start()
channelServerConfigStore.update(next)
return next
}
catch (error) {
useLogg('main/server-runtime').withError(error).error('Failed to apply server channel configuration')
if (changed) {
const previousRuntimeOptions = await resolveServerRuntimeOptions(current)
try {
await params.serverChannel.updateConfig(previousRuntimeOptions)
await params.serverChannel.restart()
}
catch (rollbackError) {
useLogg('main/server-runtime').withError(rollbackError).error('Failed to restore previous server channel configuration')
}
}
throw new Error(errorMessageFrom(error) ?? 'Failed to apply server channel configuration')
}
})
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import ConnectionSettings from '@proj-airi/stage-pages/pages/settings/connection/ConnectionSettings.vue'
import { FieldCheckbox } from '@proj-airi/ui'
import { Callout, FieldCheckbox } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -9,7 +9,7 @@ import { useI18n } from 'vue-i18n'
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
const serverChannelSettingsStore = useServerChannelSettingsStore()
const { websocketTlsConfig } = storeToRefs(serverChannelSettingsStore)
const { lastApplyError, websocketTlsConfig } = storeToRefs(serverChannelSettingsStore)
const { t } = useI18n()
const websocketTlsEnabled = computed({
@@ -23,6 +23,7 @@ const websocketTlsEnabled = computed({
<template>
<ConnectionSettings>
<template #platform-specific>
<!-- TODO: show connected remote -->
<FieldCheckbox
v-model="websocketTlsEnabled"
v-motion
@@ -33,6 +34,14 @@ const websocketTlsEnabled = computed({
:label="t('settings.websocket-secure-enabled.title')"
:description="t('settings.websocket-secure-enabled.description')"
/>
<Callout
v-if="lastApplyError"
theme="orange"
:label="t('settings.websocket-secure-enabled.title')"
>
{{ lastApplyError }}
</Callout>
</template>
</ConnectionSettings>
</template>
@@ -1,28 +1,56 @@
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAsyncState, useLocalStorage } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { watch } from 'vue'
import { ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { electronApplyServerChannelConfig, electronGetServerChannelConfig } from '../../../shared/eventa'
export const useServerChannelSettingsStore = defineStore('tamagotchi-server-channel-settings', () => {
const websocketTlsConfig = useLocalStorage<{ cert?: string, key?: string, passphrase?: string } | null | undefined>('settings/server-channel/websocket-tls-config', null)
const lastApplyError = ref<string | null>(null)
const syncingWithServer = ref(false)
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
const applyServerChannelConfig = useElectronEventaInvoke(electronApplyServerChannelConfig)
const serverChannelConfig = useAsyncState(getServerChannelConfig, null)
function syncTlsConfigFromServer(value: { cert?: string, key?: string, passphrase?: string } | null | undefined) {
syncingWithServer.value = true
websocketTlsConfig.value = value ?? null
syncingWithServer.value = false
}
watch(websocketTlsConfig, async (newValue) => {
websocketTlsConfig.value = newValue
await applyServerChannelConfig({ tlsConfig: newValue ? {} : null })
async function refreshServerChannelConfig() {
const config = await getServerChannelConfig()
syncTlsConfigFromServer(config.tlsConfig)
return config
}
watch(websocketTlsConfig, async (newValue, oldValue) => {
if (syncingWithServer.value || (newValue != null) === (oldValue != null)) {
return
}
lastApplyError.value = null
try {
const config = await applyServerChannelConfig({ tlsConfig: newValue ? {} : null })
syncTlsConfigFromServer(config.tlsConfig)
}
catch (error) {
const message = errorMessageFrom(error) ?? 'Failed to apply WebSocket security setting'
lastApplyError.value = message
syncTlsConfigFromServer(oldValue)
toast.error(message)
}
})
watch(serverChannelConfig.state, (newConfig) => {
websocketTlsConfig.value = newConfig?.tlsConfig
})
void refreshServerChannelConfig()
return {
lastApplyError,
refreshServerChannelConfig,
websocketTlsConfig,
}
})
+24 -32
View File
@@ -112,27 +112,28 @@ export function createServer(opts?: ServerOptions): Server {
}
const secureEnabled = options?.tlsConfig != null
const h3App = setupApp()
const port = options.port
const hostname = options.hostname
const instance = serve(h3App.app, {
// @ts-expect-error - the .crossws property wasn't extended in types
plugins: [ws({ resolve: async req => (await h3App.app.fetch(req)).crossws })],
port,
hostname,
tls: options?.tlsConfig || undefined,
reusePort: true,
silent: true,
manual: true,
gracefulShutdown: {
forceTimeout: 0.5,
gracefulTimeout: 0.5,
},
})
try {
const h3App = setupApp()
const port = options.port
const hostname = options.hostname
const instance = serve(h3App.app, {
// @ts-expect-error - the .crossws property wasn't extended in types
plugins: [ws({ resolve: async req => (await h3App.app.fetch(req)).crossws })],
port,
hostname,
tls: options?.tlsConfig || undefined,
reusePort: true,
silent: true,
manual: true,
gracefulShutdown: {
forceTimeout: 0.5,
gracefulTimeout: 0.5,
},
})
await instance.serve()
serverInstance = {
close: async (closeActiveConnections = false) => {
@@ -144,19 +145,6 @@ export function createServer(opts?: ServerOptions): Server {
},
}
const servePromise = instance.serve()
if (servePromise instanceof Promise) {
servePromise.catch((error) => {
const nodejsError = error as NodeJS.ErrnoException
if ('code' in nodejsError && nodejsError.code === 'EADDRINUSE') {
log.withError(error).warn('Port already in use, assuming server is already running')
return
}
log.withError(error).error('Error serving WebSocket server')
})
}
const protocol = secureEnabled ? 'wss' : 'ws'
if (hostname === '0.0.0.0') {
const ips = getLocalIPs().filter(ip => ip !== '127.0.0.1' && ip !== '::1')
@@ -168,7 +156,11 @@ export function createServer(opts?: ServerOptions): Server {
}
}
catch (error) {
serverInstance = null
h3App.closeAllPeers()
await instance.close(true).catch(() => {})
log.withError(error).error('failed to start WebSocket server')
throw error
}
}
@@ -1,4 +1,12 @@
import type { ContextUpdate, InputContextUpdate, WebSocketBaseEvent, WebSocketEvent, WebSocketEventOptionalSource, WebSocketEvents } from '@proj-airi/server-sdk'
import type {
ContextUpdate,
InputContextUpdate,
WebSocketBaseEvent,
WebSocketEvent,
WebSocketEventOptionalSource,
WebSocketEvents,
WebSocketLikeConstructor,
} from '@proj-airi/server-sdk'
import type { CommonContentPart } from '@xsai/shared-chat'
import { Client, WebSocketEventSource } from '@proj-airi/server-sdk'
@@ -28,6 +36,7 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
const connected = ref(false)
const client = ref<Client>()
const initializing = ref<Promise<void> | null>(null)
const websocketConstructor = ref<WebSocketLikeConstructor>()
const pendingSend = ref<Array<WebSocketEvent>>([])
const pendingSendCount = computed(() => pendingSend.value.length)
@@ -60,12 +69,20 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
'ui:configure',
]
async function initialize(options?: { token?: string, possibleEvents?: Array<keyof WebSocketEvents> }) {
async function initialize(options?: {
token?: string
possibleEvents?: Array<keyof WebSocketEvents>
websocketConstructor?: WebSocketLikeConstructor
}) {
if (connected.value && client.value)
return Promise.resolve()
if (initializing.value)
return initializing.value
if (options?.websocketConstructor) {
websocketConstructor.value = options.websocketConstructor
}
const possibleEvents = Array.from(new Set<keyof WebSocketEvents>([
...basePossibleEvents,
...(options?.possibleEvents ?? []),
@@ -76,6 +93,7 @@ export const useModsServerChannelStore = defineStore('mods:channels:proj-airi:se
name: isStageWeb() ? WebSocketEventSource.StageWeb : isStageTamagotchi() ? WebSocketEventSource.StageTamagotchi : WebSocketEventSource.StageWeb,
url: websocketUrl.value || defaultWebSocketUrl,
token: options?.token,
websocketConstructor: websocketConstructor.value,
possibleEvents,
onAnyMessage: (event) => {
if (REPLAYABLE_EVENT_TYPES.has(event.type as keyof WebSocketEvents))