feat(stage-tamagotchi,stage-ui,server-sdk,server-runtime): zero-trust websocket authentication (#1582)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user