fix(stage-tamagotchi): rollback when wss enable failed, show errors
This commit is contained in:
@@ -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,9 +227,9 @@ async function generateCertificate() {
|
||||
})
|
||||
writeFileSync(caCertPath, ca.cert)
|
||||
writeFileSync(caKeyPath, ca.key)
|
||||
}
|
||||
|
||||
await installCACertificate(ca.cert)
|
||||
}
|
||||
|
||||
const domains = getCertificateDomains()
|
||||
|
||||
@@ -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) => {
|
||||
try {
|
||||
const current = await getChannelServerConfig()
|
||||
const next = await normalizeChannelServerOptions(req, current)
|
||||
const changed = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig)
|
||||
|
||||
try {
|
||||
if (changed) {
|
||||
const nextRuntimeOptions = await resolveServerRuntimeOptions(next)
|
||||
|
||||
await params.serverChannel.updateConfig(nextRuntimeOptions)
|
||||
await params.serverChannel.restart()
|
||||
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()
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -112,8 +112,6 @@ export function createServer(opts?: ServerOptions): Server {
|
||||
}
|
||||
|
||||
const secureEnabled = options?.tlsConfig != null
|
||||
|
||||
try {
|
||||
const h3App = setupApp()
|
||||
|
||||
const port = options.port
|
||||
@@ -134,6 +132,9 @@ export function createServer(opts?: ServerOptions): Server {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await instance.serve()
|
||||
|
||||
serverInstance = {
|
||||
close: async (closeActiveConnections = false) => {
|
||||
log.log('closing all peers')
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user