feat(stage-tamagotchi,stage-ui,server-sdk,server-runtime): zero-trust websocket authentication (#1582)
This commit is contained in:
@@ -71,8 +71,12 @@ async function main() {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => { void close() })
|
||||
process.on('SIGTERM', () => { void close() })
|
||||
process.on('SIGINT', () => {
|
||||
void close()
|
||||
})
|
||||
process.on('SIGTERM', () => {
|
||||
void close()
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ensureServerChannelConfigDefaults } from './config'
|
||||
|
||||
describe('ensureServerChannelConfigDefaults', () => {
|
||||
it('keeps an existing auth token', () => {
|
||||
const generateToken = vi.fn(() => 'generated-token')
|
||||
|
||||
const result = ensureServerChannelConfigDefaults({
|
||||
authToken: 'existing-token',
|
||||
hostname: '0.0.0.0',
|
||||
tlsConfig: null,
|
||||
}, generateToken)
|
||||
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.config).toEqual({
|
||||
authToken: 'existing-token',
|
||||
hostname: '0.0.0.0',
|
||||
tlsConfig: null,
|
||||
})
|
||||
expect(generateToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('generates a token when the config is missing one', () => {
|
||||
const generateToken = vi.fn(() => 'generated-token')
|
||||
|
||||
const result = ensureServerChannelConfigDefaults({
|
||||
authToken: '',
|
||||
hostname: '',
|
||||
tlsConfig: null,
|
||||
}, generateToken)
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.config).toEqual({
|
||||
authToken: 'generated-token',
|
||||
hostname: '127.0.0.1',
|
||||
tlsConfig: null,
|
||||
})
|
||||
expect(generateToken).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ElectronServerChannelConfig } from '../../../../shared/eventa'
|
||||
|
||||
export function ensureServerChannelConfigDefaults(
|
||||
config: Partial<ElectronServerChannelConfig>,
|
||||
generateToken: () => string,
|
||||
) {
|
||||
const nextConfig: ElectronServerChannelConfig = {
|
||||
authToken: config.authToken?.trim() || generateToken(),
|
||||
hostname: config.hostname?.trim() || '127.0.0.1',
|
||||
tlsConfig: config.tlsConfig || null,
|
||||
}
|
||||
|
||||
const previousConfig: ElectronServerChannelConfig = {
|
||||
authToken: config.authToken?.trim() || '',
|
||||
hostname: config.hostname?.trim() || '127.0.0.1',
|
||||
tlsConfig: config.tlsConfig || null,
|
||||
}
|
||||
|
||||
return {
|
||||
changed: JSON.stringify(previousConfig) !== JSON.stringify(nextConfig),
|
||||
config: nextConfig,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Server, ServerOptions } from '@proj-airi/server-runtime/server'
|
||||
import type { Lifecycle } from 'injeca'
|
||||
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import type { ElectronServerChannelConfig } from '../../../../shared/eventa'
|
||||
|
||||
import { randomUUID, X509Certificate } from 'node:crypto'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { env, platform } from 'node:process'
|
||||
@@ -23,8 +25,11 @@ import {
|
||||
electronGetServerChannelConfig,
|
||||
} from '../../../../shared/eventa'
|
||||
import { createConfig } from '../../../libs/electron/persistence'
|
||||
import { ensureServerChannelConfigDefaults } from './config'
|
||||
|
||||
const channelServerConfigSchema = object({
|
||||
hostname: optional(string()),
|
||||
authToken: optional(string()),
|
||||
tlsConfig: optional(nullable(object({
|
||||
cert: optional(string()),
|
||||
key: optional(string()),
|
||||
@@ -33,11 +38,15 @@ const channelServerConfigSchema = object({
|
||||
})
|
||||
|
||||
const channelServerInvokeConfigSchema = z.object({
|
||||
hostname: z.string().optional(),
|
||||
authToken: z.string().optional(),
|
||||
tlsConfig: z.object({ }).nullable().optional(),
|
||||
}).strict()
|
||||
|
||||
const channelServerConfigStore = createConfig('server-channel', 'config.json', channelServerConfigSchema, {
|
||||
default: {
|
||||
hostname: '127.0.0.1',
|
||||
authToken: '',
|
||||
tlsConfig: null,
|
||||
},
|
||||
autoHeal: true,
|
||||
@@ -62,25 +71,41 @@ interface ServerChannelCertificateVerifyRequest {
|
||||
}
|
||||
}
|
||||
|
||||
async function getChannelServerConfig(): Promise<ServerOptions> {
|
||||
return channelServerConfigStore.get() || { tlsConfig: null }
|
||||
function getServerChannelPort() {
|
||||
return env.SERVER_CHANNEL_PORT ? Number.parseInt(env.SERVER_CHANNEL_PORT) : 6121
|
||||
}
|
||||
|
||||
async function getChannelServerConfig(): Promise<ElectronServerChannelConfig> {
|
||||
const config = channelServerConfigStore.get() || { hostname: '127.0.0.1', authToken: '', tlsConfig: null }
|
||||
|
||||
return {
|
||||
hostname: config.hostname || '127.0.0.1',
|
||||
authToken: config.authToken || '',
|
||||
tlsConfig: config.tlsConfig || null,
|
||||
}
|
||||
}
|
||||
|
||||
function getServerRuntimeBaseOptions() {
|
||||
return {
|
||||
port: env.PORT ? Number.parseInt(env.PORT) : 6121,
|
||||
hostname: env.SERVER_RUNTIME_HOSTNAME || '0.0.0.0',
|
||||
port: getServerChannelPort(),
|
||||
hostname: '127.0.0.1',
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServerRuntimeOptions(config: ServerOptions): Promise<ServerOptions> {
|
||||
return {
|
||||
...getServerRuntimeBaseOptions(),
|
||||
auth: {
|
||||
token: 'authToken' in config && typeof config.authToken === 'string' ? config.authToken : '',
|
||||
},
|
||||
hostname: 'hostname' in config && typeof config.hostname === 'string'
|
||||
? config.hostname || '127.0.0.1'
|
||||
: '127.0.0.1',
|
||||
tlsConfig: config.tlsConfig ? await getOrCreateCertificate() : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeChannelServerOptions(payload: unknown, fallback?: ServerOptions) {
|
||||
async function normalizeChannelServerOptions(payload: unknown, fallback?: ElectronServerChannelConfig) {
|
||||
if (!fallback) {
|
||||
fallback = await getChannelServerConfig()
|
||||
}
|
||||
@@ -90,14 +115,18 @@ async function normalizeChannelServerOptions(payload: unknown, fallback?: Server
|
||||
return fallback
|
||||
}
|
||||
|
||||
return {
|
||||
const normalizedConfig = {
|
||||
hostname: parsed.data.hostname ?? fallback.hostname,
|
||||
authToken: parsed.data.authToken ?? fallback.authToken,
|
||||
tlsConfig: typeof parsed.data.tlsConfig === 'undefined' ? null : parsed.data.tlsConfig,
|
||||
}
|
||||
|
||||
return ensureServerChannelConfigDefaults(normalizedConfig, randomUUID).config
|
||||
}
|
||||
|
||||
function getCertificateDomains(): string[] {
|
||||
const localIPs = getLocalIPs()
|
||||
const hostname = env.SERVER_RUNTIME_HOSTNAME
|
||||
const hostname = channelServerConfigStore.get()?.hostname || env.SERVER_RUNTIME_HOSTNAME
|
||||
return Array.from(new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
@@ -283,11 +312,12 @@ export async function setupServerChannel(params: { lifecycle: Lifecycle }): Prom
|
||||
configureServerChannelCertificateTrust()
|
||||
|
||||
const storedConfig = await getChannelServerConfig()
|
||||
const { changed: storedConfigChanged, config: normalizedStoredConfig } = ensureServerChannelConfigDefaults(storedConfig, randomUUID)
|
||||
if (storedConfigChanged) {
|
||||
channelServerConfigStore.update(normalizedStoredConfig)
|
||||
}
|
||||
|
||||
const serverChannel = createServer({
|
||||
...storedConfig,
|
||||
...(await resolveServerRuntimeOptions(storedConfig)),
|
||||
})
|
||||
const serverChannel = createServer(await resolveServerRuntimeOptions(normalizedStoredConfig))
|
||||
|
||||
const mutex = new Mutex()
|
||||
|
||||
@@ -386,26 +416,28 @@ 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)
|
||||
const tlsChanged = JSON.stringify(next.tlsConfig) !== JSON.stringify(current.tlsConfig)
|
||||
const hostnameChanged = next.hostname !== current.hostname
|
||||
const authTokenChanged = next.authToken !== current.authToken
|
||||
const runtimeChanged = tlsChanged || hostnameChanged || authTokenChanged
|
||||
|
||||
try {
|
||||
if (changed) {
|
||||
if (runtimeChanged) {
|
||||
const nextRuntimeOptions = await resolveServerRuntimeOptions(next)
|
||||
|
||||
await params.serverChannel.updateConfig(nextRuntimeOptions)
|
||||
await params.serverChannel.restart()
|
||||
channelServerConfigStore.update(next)
|
||||
|
||||
return next
|
||||
}
|
||||
else {
|
||||
await params.serverChannel.start()
|
||||
}
|
||||
|
||||
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) {
|
||||
if (runtimeChanged) {
|
||||
const previousRuntimeOptions = await resolveServerRuntimeOptions(current)
|
||||
|
||||
try {
|
||||
|
||||
@@ -131,9 +131,14 @@ onMounted(async () => {
|
||||
await settingsAudioDeviceStore.initialize()
|
||||
|
||||
const serverChannelConfig = await getServerChannelConfig()
|
||||
serverChannelSettingsStore.websocketTlsConfig = serverChannelConfig.tlsConfig
|
||||
serverChannelSettingsStore.tlsConfig = serverChannelConfig.tlsConfig ?? null
|
||||
serverChannelSettingsStore.hostname = serverChannelConfig.hostname
|
||||
serverChannelSettingsStore.authToken = serverChannelConfig.authToken
|
||||
|
||||
await serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
|
||||
await serverChannelStore.initialize({
|
||||
token: serverChannelConfig.authToken || undefined,
|
||||
possibleEvents: ['ui:configure'],
|
||||
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
|
||||
if (!isChatWindowRoute()) {
|
||||
contextBridgeStore.initialize()
|
||||
characterOrchestratorStore.initialize()
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import ConnectionSettings from '@proj-airi/stage-pages/pages/settings/connection/ConnectionSettings.vue'
|
||||
|
||||
import { Callout, FieldCheckbox } from '@proj-airi/ui'
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { Callout, FieldCheckbox, FieldInput, SelectTab } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
|
||||
import {
|
||||
hostnameFromExposureMode,
|
||||
serverChannelExposureModeFromHostname,
|
||||
} from '../../../stores/settings/server-channel-options'
|
||||
|
||||
const serverChannelSettingsStore = useServerChannelSettingsStore()
|
||||
const { lastApplyError, websocketTlsConfig } = storeToRefs(serverChannelSettingsStore)
|
||||
const { authToken, hostname, lastApplyError, tlsConfig } = storeToRefs(serverChannelSettingsStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const websocketTlsEnabled = computed({
|
||||
get: () => websocketTlsConfig.value != null,
|
||||
get: () => tlsConfig.value != null,
|
||||
set: (value: boolean) => {
|
||||
serverChannelSettingsStore.websocketTlsConfig = value ? {} : null
|
||||
serverChannelSettingsStore.tlsConfig = value ? {} : null
|
||||
},
|
||||
})
|
||||
|
||||
const exposureMode = computed({
|
||||
get: () => serverChannelExposureModeFromHostname(hostname.value),
|
||||
set: (mode) => {
|
||||
hostname.value = hostnameFromExposureMode(mode, hostname.value)
|
||||
},
|
||||
})
|
||||
|
||||
const showAdvancedHostname = computed(() => exposureMode.value === 'advanced')
|
||||
const showDesktopServerControls = computed(() => isStageTamagotchi())
|
||||
|
||||
const exposureModeOptions = computed(() => [
|
||||
{
|
||||
label: t('settings.pages.connection.server-hostname.options.this-device'),
|
||||
value: 'this-device',
|
||||
},
|
||||
{
|
||||
label: t('settings.pages.connection.server-hostname.options.all'),
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: t('settings.pages.connection.server-hostname.options.advanced'),
|
||||
value: 'advanced',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,6 +65,55 @@ const websocketTlsEnabled = computed({
|
||||
:description="t('settings.websocket-secure-enabled.description')"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showDesktopServerControls"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + (6 * 10)"
|
||||
:delay="6 * 50"
|
||||
:class="['flex', 'flex-col', 'gap-2']"
|
||||
>
|
||||
<div :class="['text-sm', 'font-medium', 'text-neutral-900', 'dark:text-neutral-100']">
|
||||
{{ t('settings.pages.connection.server-hostname.label') }}
|
||||
</div>
|
||||
<div :class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
|
||||
{{ t('settings.pages.connection.server-hostname.description') }}
|
||||
</div>
|
||||
<SelectTab
|
||||
v-model="exposureMode"
|
||||
size="sm"
|
||||
:options="exposureModeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldInput
|
||||
v-if="showDesktopServerControls && showAdvancedHostname"
|
||||
v-model="hostname"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + (7 * 10)"
|
||||
:delay="7 * 50"
|
||||
:label="t('settings.pages.connection.server-hostname.advanced-label')"
|
||||
:description="t('settings.pages.connection.server-hostname.advanced-description')"
|
||||
placeholder="192.168.1.25"
|
||||
/>
|
||||
|
||||
<FieldInput
|
||||
v-if="showDesktopServerControls"
|
||||
v-model="authToken"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + (8 * 10)"
|
||||
:delay="8 * 50"
|
||||
secure
|
||||
:label="t('settings.pages.connection.server-auth-token.label')"
|
||||
:description="t('settings.pages.connection.server-auth-token.description')"
|
||||
:placeholder="t('settings.pages.connection.server-auth-token.placeholder')"
|
||||
/>
|
||||
|
||||
<Callout
|
||||
v-if="lastApplyError"
|
||||
theme="orange"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hostnameFromExposureMode, serverChannelExposureModeFromHostname } from './server-channel-options'
|
||||
|
||||
describe('serverChannelExposureModeFromHostname', () => {
|
||||
it('maps loopback hostnames to this-device mode', () => {
|
||||
expect(serverChannelExposureModeFromHostname('127.0.0.1')).toBe('this-device')
|
||||
expect(serverChannelExposureModeFromHostname('localhost')).toBe('this-device')
|
||||
expect(serverChannelExposureModeFromHostname('::1')).toBe('this-device')
|
||||
expect(serverChannelExposureModeFromHostname('')).toBe('this-device')
|
||||
})
|
||||
|
||||
it('maps wildcard bind hostnames to all mode', () => {
|
||||
expect(serverChannelExposureModeFromHostname('0.0.0.0')).toBe('all')
|
||||
expect(serverChannelExposureModeFromHostname('::')).toBe('all')
|
||||
})
|
||||
|
||||
it('maps custom hostnames to advanced mode', () => {
|
||||
expect(serverChannelExposureModeFromHostname('192.168.1.25')).toBe('advanced')
|
||||
expect(serverChannelExposureModeFromHostname('airi.local')).toBe('advanced')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostnameFromExposureMode', () => {
|
||||
it('returns a secure loopback hostname for this-device mode', () => {
|
||||
expect(hostnameFromExposureMode('this-device', '192.168.1.25')).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('returns an all-interfaces hostname for all mode', () => {
|
||||
expect(hostnameFromExposureMode('all', '127.0.0.1')).toBe('0.0.0.0')
|
||||
})
|
||||
|
||||
it('uses the manual hostname for advanced mode and trims whitespace', () => {
|
||||
expect(hostnameFromExposureMode('advanced', ' 192.168.1.25 ')).toBe('192.168.1.25')
|
||||
})
|
||||
|
||||
it('falls back to loopback when advanced mode is empty', () => {
|
||||
expect(hostnameFromExposureMode('advanced', ' ')).toBe('127.0.0.1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
export type ServerChannelExposureMode = 'this-device' | 'all' | 'advanced'
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(['', '127.0.0.1', 'localhost', '::1'])
|
||||
const ALL_INTERFACE_HOSTNAMES = new Set(['0.0.0.0', '::'])
|
||||
|
||||
export function serverChannelExposureModeFromHostname(hostname?: string): ServerChannelExposureMode {
|
||||
const normalizedHostname = hostname?.trim() ?? ''
|
||||
|
||||
if (LOOPBACK_HOSTNAMES.has(normalizedHostname))
|
||||
return 'this-device'
|
||||
|
||||
if (ALL_INTERFACE_HOSTNAMES.has(normalizedHostname))
|
||||
return 'all'
|
||||
|
||||
return 'advanced'
|
||||
}
|
||||
|
||||
export function hostnameFromExposureMode(mode: ServerChannelExposureMode, manualHostname?: string) {
|
||||
if (mode === 'all')
|
||||
return '0.0.0.0'
|
||||
|
||||
if (mode === 'advanced') {
|
||||
const normalizedHostname = manualHostname?.trim() ?? ''
|
||||
return normalizedHostname || '127.0.0.1'
|
||||
}
|
||||
|
||||
return '127.0.0.1'
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
const invokeMocks = vi.hoisted(() => {
|
||||
const getConfig = vi.fn(async () => ({
|
||||
authToken: 'existing-token',
|
||||
hostname: '127.0.0.1',
|
||||
tlsConfig: null,
|
||||
}))
|
||||
const applyConfig = vi.fn(async (config: unknown) => config)
|
||||
|
||||
return {
|
||||
applyConfig,
|
||||
getConfig,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@proj-airi/electron-vueuse', () => ({
|
||||
useElectronEventaInvoke: (event: { receiveEvent?: { id?: string } }) => {
|
||||
if (event?.receiveEvent?.id === 'eventa:invoke:electron:server-channel:get-config-receive')
|
||||
return invokeMocks.getConfig
|
||||
if (event?.receiveEvent?.id === 'eventa:invoke:electron:server-channel:apply-config-receive')
|
||||
return invokeMocks.applyConfig
|
||||
|
||||
throw new Error(`Unexpected eventa invoke: ${JSON.stringify(event)}`)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useLocalStorage: <T>(key: string, initialValue: T) => {
|
||||
if (key === 'settings/server-channel/hostname')
|
||||
return ref('127.0.0.1')
|
||||
if (key === 'settings/server-channel/auth-token')
|
||||
return ref('existing-token')
|
||||
if (key === 'settings/server-channel/websocket-tls-config')
|
||||
return ref(null)
|
||||
|
||||
return ref(initialValue)
|
||||
},
|
||||
}))
|
||||
|
||||
const toastError = vi.fn()
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: {
|
||||
error: toastError,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useServerChannelSettingsStore', async () => {
|
||||
const { useServerChannelSettingsStore } = await import('./server-channel')
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
invokeMocks.getConfig.mockClear()
|
||||
invokeMocks.applyConfig.mockClear()
|
||||
toastError.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('rolls back optimistic values when applying server channel config fails', async () => {
|
||||
invokeMocks.applyConfig.mockRejectedValueOnce(new Error('apply failed'))
|
||||
|
||||
const store = useServerChannelSettingsStore()
|
||||
await Promise.resolve()
|
||||
|
||||
store.hostname = '0.0.0.0'
|
||||
store.authToken = 'next-token'
|
||||
store.tlsConfig = {}
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.hostname).toBe('127.0.0.1')
|
||||
expect(store.authToken).toBe('existing-token')
|
||||
expect(store.tlsConfig).toBeNull()
|
||||
expect(store.lastApplyError).toBe('apply failed')
|
||||
expect(toastError).toHaveBeenCalledWith('apply failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ElectronServerChannelConfig } from '../../../shared/eventa'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
@@ -5,43 +7,65 @@ import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { electronApplyServerChannelConfig, electronGetServerChannelConfig } from '../../../shared/eventa'
|
||||
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 tlsConfig = useLocalStorage<{ cert?: string, key?: string, passphrase?: string } | null | undefined>('settings/server-channel/websocket-tls-config', null)
|
||||
const hostname = useLocalStorage<string>('settings/server-channel/hostname', '127.0.0.1')
|
||||
const authToken = useLocalStorage<string>('settings/server-channel/auth-token', '')
|
||||
const lastApplyError = ref<string | null>(null)
|
||||
const syncingWithServer = ref(false)
|
||||
|
||||
const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig)
|
||||
const applyServerChannelConfig = useElectronEventaInvoke(electronApplyServerChannelConfig)
|
||||
|
||||
function syncTlsConfigFromServer(value: { cert?: string, key?: string, passphrase?: string } | null | undefined) {
|
||||
function syncConfigFromServer(config: ElectronServerChannelConfig) {
|
||||
syncingWithServer.value = true
|
||||
websocketTlsConfig.value = value ?? null
|
||||
tlsConfig.value = config.tlsConfig ?? null
|
||||
if (config.hostname !== undefined) {
|
||||
hostname.value = config.hostname
|
||||
}
|
||||
if (config.authToken !== undefined) {
|
||||
authToken.value = config.authToken
|
||||
}
|
||||
syncingWithServer.value = false
|
||||
}
|
||||
|
||||
async function refreshServerChannelConfig() {
|
||||
const config = await getServerChannelConfig()
|
||||
syncTlsConfigFromServer(config.tlsConfig)
|
||||
syncConfigFromServer(config)
|
||||
return config
|
||||
}
|
||||
|
||||
watch(websocketTlsConfig, async (newValue, oldValue) => {
|
||||
if (syncingWithServer.value || (newValue != null) === (oldValue != null)) {
|
||||
watch([tlsConfig, hostname, authToken], async ([newTls, newHost, newAuth], [oldTls, oldHost, oldAuth]) => {
|
||||
if (syncingWithServer.value || (JSON.stringify(newTls) === JSON.stringify(oldTls) && newHost === oldHost && newAuth === oldAuth)) {
|
||||
return
|
||||
}
|
||||
|
||||
lastApplyError.value = null
|
||||
|
||||
try {
|
||||
const config = await applyServerChannelConfig({ tlsConfig: newValue ? {} : null })
|
||||
syncTlsConfigFromServer(config.tlsConfig)
|
||||
const config = await applyServerChannelConfig({
|
||||
tlsConfig: newTls ? {} : null,
|
||||
hostname: newHost,
|
||||
authToken: newAuth,
|
||||
})
|
||||
syncConfigFromServer(config)
|
||||
}
|
||||
catch (error) {
|
||||
const message = errorMessageFrom(error) ?? 'Failed to apply WebSocket security setting'
|
||||
lastApplyError.value = message
|
||||
syncTlsConfigFromServer(oldValue)
|
||||
|
||||
syncingWithServer.value = true
|
||||
tlsConfig.value = oldTls
|
||||
hostname.value = oldHost
|
||||
authToken.value = oldAuth
|
||||
syncingWithServer.value = false
|
||||
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
@@ -51,6 +75,8 @@ export const useServerChannelSettingsStore = defineStore('tamagotchi-server-chan
|
||||
return {
|
||||
lastApplyError,
|
||||
refreshServerChannelConfig,
|
||||
websocketTlsConfig,
|
||||
tlsConfig,
|
||||
hostname,
|
||||
authToken,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,6 +25,8 @@ export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { route?: str
|
||||
|
||||
export interface ElectronServerChannelConfig {
|
||||
tlsConfig?: ServerOptions['tlsConfig'] | null
|
||||
authToken: string
|
||||
hostname: string
|
||||
}
|
||||
export const electronGetServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig>('eventa:invoke:electron:server-channel:get-config')
|
||||
export const electronApplyServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig, Partial<ElectronServerChannelConfig>>('eventa:invoke:electron:server-channel:apply-config')
|
||||
|
||||
@@ -944,6 +944,19 @@ pages:
|
||||
description: Full address of the WebSocket server (e.g., ws://localhost:6121/ws)
|
||||
label: WebSocket Server Address
|
||||
placeholder: ws://localhost:6121/ws
|
||||
server-hostname:
|
||||
label: Expose On Network
|
||||
description: Choose whether the AIRI gateway only listens on this device or can be reached from your local network.
|
||||
advanced-label: Bind Hostname
|
||||
advanced-description: Use a specific hostname or IP address when you need manual network binding.
|
||||
options:
|
||||
this-device: This device
|
||||
all: All
|
||||
advanced: Advanced
|
||||
server-auth-token:
|
||||
label: Auth Token
|
||||
description: Security token required by remote clients that connect to this gateway. When left empty, AIRI generates a new token automatically.
|
||||
placeholder: Auto-generate
|
||||
scene:
|
||||
description: Configure the environment where the character lives
|
||||
title: Scene
|
||||
|
||||
@@ -909,6 +909,19 @@ pages:
|
||||
description: WebSocket 服务器的完整地址(例如,ws://localhost:6121/ws)
|
||||
label: WebSocket 服务器地址
|
||||
placeholder: ws://localhost:6121/ws
|
||||
server-hostname:
|
||||
label: 网络暴露范围
|
||||
description: 选择 AIRI 网关只监听当前设备,还是允许局域网中的其他设备连接。
|
||||
advanced-label: 绑定主机名
|
||||
advanced-description: 需要手动绑定网络地址时,填写指定的主机名或 IP 地址。
|
||||
options:
|
||||
this-device: 当前设备
|
||||
all: 全部
|
||||
advanced: 高级
|
||||
server-auth-token:
|
||||
label: Auth Token
|
||||
description: 连接到此网关的远程客户端需要提供此安全令牌。留空时 AIRI 会自动生成新的令牌。
|
||||
placeholder: 自动生成
|
||||
scene:
|
||||
description: 配置角色所在环境
|
||||
title: 场景
|
||||
|
||||
+27
-27
@@ -64,6 +64,9 @@ function isTimeoutLikeError(error: unknown): boolean {
|
||||
export default defineScenario({
|
||||
id: 'demo-controls-settings-chat-websocket',
|
||||
async run({ capture, controlsIsland, settingsWindow, stageWindows }) {
|
||||
const mainWindow = await stageWindows.waitFor('main')
|
||||
await controlsIsland.waitForReady(mainWindow.page)
|
||||
|
||||
async function ensureControlsIslandExpanded() {
|
||||
const chatButton = mainWindow.page
|
||||
.locator('button')
|
||||
@@ -79,33 +82,6 @@ export default defineScenario({
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSettingsRoute(name: string, routePath: string, readyPattern: RegExp, waitMs = 250) {
|
||||
await settingsWindow.goToRoute(settingsWindowSnapshot.page, routePath)
|
||||
try {
|
||||
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 15_000 })
|
||||
}
|
||||
catch (error) {
|
||||
if (!isTimeoutLikeError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const currentHashPath = normalizeHashPath(new URL(settingsWindowSnapshot.page.url()).hash)
|
||||
if (currentHashPath !== routePath) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// NOTICE: Some settings/devtools pages animate in or hydrate content asynchronously.
|
||||
// Give known-slow pages one final bounded grace period, but still fail if the target route never becomes ready.
|
||||
await sleep(1250)
|
||||
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 5_000 })
|
||||
}
|
||||
await sleep(waitMs)
|
||||
await capture(name, settingsWindowSnapshot.page)
|
||||
}
|
||||
|
||||
const mainWindow = await stageWindows.waitFor('main')
|
||||
await controlsIsland.waitForReady(mainWindow.page)
|
||||
|
||||
await capture('00-stage-tamagotchi', mainWindow.page)
|
||||
await sleep(500)
|
||||
|
||||
@@ -130,6 +106,30 @@ export default defineScenario({
|
||||
await sleep(1000)
|
||||
await capture('02-settings-window', settingsWindowSnapshot.page)
|
||||
|
||||
async function captureSettingsRoute(name: string, routePath: string, readyPattern: RegExp, waitMs = 250) {
|
||||
await settingsWindow.goToRoute(settingsWindowSnapshot.page, routePath)
|
||||
try {
|
||||
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 15_000 })
|
||||
}
|
||||
catch (error) {
|
||||
if (!isTimeoutLikeError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const currentHashPath = normalizeHashPath(new URL(settingsWindowSnapshot.page.url()).hash)
|
||||
if (currentHashPath !== routePath) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// NOTICE: Some settings/devtools pages animate in or hydrate content asynchronously.
|
||||
// Give known-slow pages one final bounded grace period, but still fail if the target route never becomes ready.
|
||||
await sleep(1250)
|
||||
await settingsWindowSnapshot.page.getByText(readyPattern).first().waitFor({ state: 'visible', timeout: 5_000 })
|
||||
}
|
||||
await sleep(waitMs)
|
||||
await capture(name, settingsWindowSnapshot.page)
|
||||
}
|
||||
|
||||
await settingsWindow.goToRoute(settingsWindowSnapshot.page, '/settings/airi-card')
|
||||
await settingsWindowSnapshot.page.getByText(airiCardPattern).first().waitFor({ state: 'visible' })
|
||||
await sleep(1000)
|
||||
|
||||
@@ -15,7 +15,12 @@ function createPeer(options: {
|
||||
labels?: Record<string, string>
|
||||
}): AuthenticatedPeer {
|
||||
return {
|
||||
peer: { id: options.id, send: () => 0 },
|
||||
peer: {
|
||||
id: options.id,
|
||||
send: () => 0,
|
||||
request: { url: 'http://localhost', headers: new Headers() },
|
||||
remoteAddress: '127.0.0.1',
|
||||
},
|
||||
authenticated: true,
|
||||
name: options.name,
|
||||
identity: options.plugin && options.instanceId
|
||||
@@ -92,6 +97,21 @@ describe('route middleware', () => {
|
||||
|
||||
expect(collectDestinations(event)).toEqual(['label:env=prod'])
|
||||
})
|
||||
it('respects explicit empty destinations as an override', () => {
|
||||
const event = createSparkNotifyEvent({
|
||||
data: {
|
||||
id: 'evt-3',
|
||||
eventId: 'spark-3',
|
||||
kind: 'ping',
|
||||
urgency: 'soon',
|
||||
headline: 'hello',
|
||||
destinations: ['module:character'],
|
||||
},
|
||||
route: { destinations: [] },
|
||||
})
|
||||
|
||||
expect(collectDestinations(event)).toEqual([])
|
||||
})
|
||||
|
||||
it('treats an explicit empty route destination list as the override', () => {
|
||||
const event = createSparkNotifyEvent({
|
||||
|
||||
@@ -42,7 +42,7 @@ function getPeerLabels(peer: AuthenticatedPeer) {
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesRouteExpression(expression: RouteTargetExpression, peer: AuthenticatedPeer) {
|
||||
export function matchesRouteExpression(expression: RouteTargetExpression, peer: AuthenticatedPeer): boolean {
|
||||
switch (expression.type) {
|
||||
case 'and':
|
||||
return expression.all.every(expr => matchesRouteExpression(expr, peer))
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const serveMocks = vi.hoisted(() => {
|
||||
let resolveServe: (() => void) | null = null
|
||||
let rejectServe: ((error: Error) => void) | null = null
|
||||
|
||||
const serveCall = vi.fn(() => new Promise<void>((resolve, reject) => {
|
||||
resolveServe = resolve
|
||||
rejectServe = reject
|
||||
}))
|
||||
|
||||
const closeCall = vi.fn(async () => {})
|
||||
const setupAppCall = vi.fn(() => ({
|
||||
app: {
|
||||
fetch: vi.fn(async () => ({ crossws: {} })),
|
||||
},
|
||||
closeAllPeers: vi.fn(),
|
||||
}))
|
||||
|
||||
return {
|
||||
closeCall,
|
||||
rejectServe: (error: Error) => rejectServe?.(error),
|
||||
resolveServe: () => resolveServe?.(),
|
||||
serveCall,
|
||||
setupAppCall,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('h3', () => ({
|
||||
H3: class {
|
||||
get = vi.fn()
|
||||
},
|
||||
defineWebSocketHandler: vi.fn(handler => handler),
|
||||
serve: vi.fn(() => ({
|
||||
serve: serveMocks.serveCall,
|
||||
close: serveMocks.closeCall,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('crossws/server', () => ({
|
||||
plugin: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('..', () => ({
|
||||
normalizeLoggerConfig: () => ({
|
||||
appLogFormat: 'pretty',
|
||||
appLogLevel: 'log',
|
||||
}),
|
||||
setupApp: serveMocks.setupAppCall,
|
||||
}))
|
||||
|
||||
describe('createServer', async () => {
|
||||
const { createServer } = await import('./server')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('deduplicates concurrent start calls while a start is already in progress', async () => {
|
||||
const server = createServer({ hostname: '127.0.0.1', port: 6121 })
|
||||
|
||||
const firstStart = server.start()
|
||||
const secondStart = server.start()
|
||||
|
||||
expect(serveMocks.serveCall).toHaveBeenCalledTimes(1)
|
||||
|
||||
serveMocks.resolveServe()
|
||||
|
||||
await Promise.all([firstStart, secondStart])
|
||||
expect(serveMocks.serveCall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears the single-flight state when start fails', async () => {
|
||||
const server = createServer({ hostname: '127.0.0.1', port: 6121 })
|
||||
|
||||
const firstStart = server.start()
|
||||
serveMocks.rejectServe(new Error('bind failed'))
|
||||
|
||||
await expect(firstStart).rejects.toThrow('bind failed')
|
||||
|
||||
const retryStart = server.start()
|
||||
expect(serveMocks.serveCall).toHaveBeenCalledTimes(2)
|
||||
|
||||
serveMocks.resolveServe()
|
||||
await retryStart
|
||||
})
|
||||
})
|
||||
@@ -71,11 +71,12 @@ export function getLocalIPs(): string[] {
|
||||
}
|
||||
|
||||
export function createServer(opts?: ServerOptions): Server {
|
||||
let options = merge<ServerOptions>({ port: 6121, hostname: '0.0.0.0' }, opts)
|
||||
let options = merge<ServerOptions>({ port: 6121, hostname: '127.0.0.1' }, opts)
|
||||
|
||||
const { appLogFormat, appLogLevel } = normalizeLoggerConfig(options)
|
||||
const log = useLogg('@proj-airi/server-runtime/server').withLogLevelString(appLogLevel).withFormat(appLogFormat)
|
||||
let serverInstance: ServerInstance | null = null
|
||||
let startTask: Promise<void> | null = null
|
||||
|
||||
log.withFields({ hasTlsConfig: !!options?.tlsConfig }).log('creating server channel')
|
||||
|
||||
@@ -110,60 +111,68 @@ export function createServer(opts?: ServerOptions): Server {
|
||||
if (serverInstance) {
|
||||
return
|
||||
}
|
||||
if (startTask) {
|
||||
return startTask
|
||||
}
|
||||
|
||||
const secureEnabled = options?.tlsConfig != null
|
||||
const h3App = setupApp()
|
||||
startTask = (async () => {
|
||||
const secureEnabled = options?.tlsConfig != null
|
||||
const h3App = setupApp(options)
|
||||
|
||||
const port = options.port
|
||||
const hostname = options.hostname
|
||||
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,
|
||||
},
|
||||
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 {
|
||||
serverInstance = {
|
||||
close: async (closeActiveConnections = false) => {
|
||||
log.log('closing all peers')
|
||||
h3App.closeAllPeers()
|
||||
log.log('closing server instance')
|
||||
await instance.close(closeActiveConnections)
|
||||
log.log('server instance closed')
|
||||
},
|
||||
}
|
||||
|
||||
await instance.serve()
|
||||
|
||||
const protocol = secureEnabled ? 'wss' : 'ws'
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
serverInstance = null
|
||||
h3App.closeAllPeers()
|
||||
await instance.close(true).catch(() => {})
|
||||
log.withError(error).error('failed to start WebSocket server')
|
||||
throw error
|
||||
}
|
||||
})().finally(() => {
|
||||
startTask = null
|
||||
})
|
||||
|
||||
try {
|
||||
await instance.serve()
|
||||
|
||||
serverInstance = {
|
||||
close: async (closeActiveConnections = false) => {
|
||||
log.log('closing all peers')
|
||||
h3App.closeAllPeers()
|
||||
log.log('closing server instance')
|
||||
await instance.close(closeActiveConnections)
|
||||
log.log('server instance closed')
|
||||
},
|
||||
}
|
||||
|
||||
const protocol = secureEnabled ? 'wss' : 'ws'
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
serverInstance = null
|
||||
h3App.closeAllPeers()
|
||||
await instance.close(true).catch(() => {})
|
||||
log.withError(error).error('failed to start WebSocket server')
|
||||
throw error
|
||||
}
|
||||
return startTask
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
await closeServer(true)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface Peer {
|
||||
* WebSocket lifecycle state (mirrors WebSocket.readyState)
|
||||
*/
|
||||
readyState?: number
|
||||
request?: {
|
||||
url?: string
|
||||
headers?: Headers
|
||||
}
|
||||
remoteAddress?: string
|
||||
}
|
||||
|
||||
export interface NamedPeer {
|
||||
|
||||
Generated
+3
@@ -2642,6 +2642,9 @@ importers:
|
||||
'@proj-airi/plugin-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../plugin-protocol
|
||||
crossws:
|
||||
specifier: ^0.4.4
|
||||
version: 0.4.4(patch_hash=4d79ec736d10d2a81a9e2a31b067d43f0b6665122267981e652ab9923d165958)(srvx@0.11.13(patch_hash=c761d25a70e22a0925c88fe91a9dd1c3153dd341d8fd4f260166678156c4df2d))
|
||||
|
||||
packages/stage-layouts:
|
||||
dependencies:
|
||||
|
||||
@@ -226,12 +226,6 @@ export class RuleEngine {
|
||||
if (this.detectorDecisions.length > MAX_DETECTOR_DECISIONS) {
|
||||
this.detectorDecisions.splice(0, this.detectorDecisions.length - MAX_DETECTOR_DECISIONS)
|
||||
}
|
||||
|
||||
// NOTICE: matched_not_fired is expected on high-frequency streams and would
|
||||
// dominate logs; keep it in snapshots/devtools and reserve default logs for fired.
|
||||
if (snapshot.decision === 'matched_not_fired') {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user