Files
hermes-web-ui/tests/server/lan-discovery.test.ts
2026-06-20 12:35:46 +08:00

210 lines
8.6 KiB
TypeScript

import { readFileSync } from 'fs'
import { createHash, generateKeyPairSync } from 'crypto'
import { afterEach, describe, expect, it } from 'vitest'
import {
HERMES_DISCOVERY_PORT,
discoveryPortForHttpPort,
getDiscoveryHttpPorts,
getLanEndpointKind,
isPrivateOrLoopbackIPv4,
resetLanDiscoveryState,
scanLanDevices,
startLanDiscoveryResponder,
} from '../../packages/server/src/services/lan-discovery'
import type { PublicSystemInfo } from '../../packages/server/src/services/system-info'
const fakeKeyPair = generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
})
const fakeDeviceId = `hwui_${createHash('sha256').update(fakeKeyPair.publicKey).digest('base64url').slice(0, 32)}`
const fakeInfo: PublicSystemInfo = {
device_id: fakeDeviceId,
device_public_key: fakeKeyPair.publicKey,
computer_name: 'test-machine',
os: {
type: 'TestOS',
platform: 'linux',
release: '1.0.0',
arch: 'x64',
},
hermes_agent_version: 'v1.2.3',
hermes_web_ui_version: '9.9.9',
}
describe('LAN discovery', () => {
const originalPorts = process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS
afterEach(() => {
resetLanDiscoveryState()
if (originalPorts === undefined) delete process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS
else process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS = originalPorts
})
it('maps HTTP ports to UDP discovery ports', () => {
expect(HERMES_DISCOVERY_PORT).toBe(48640)
expect(discoveryPortForHttpPort(8648)).toBe(48648)
expect(discoveryPortForHttpPort(8748)).toBe(48748)
})
it('starts a fixed-port discovery responder for HTTP ports without a UDP mapping', async () => {
const socket = startLanDiscoveryResponder({
httpPort: 54321,
getSystemInfo: async () => fakeInfo,
})
expect(socket).not.toBeNull()
if (socket) await new Promise<void>(resolve => socket.once('listening', () => resolve()))
})
it('classifies well-known LAN endpoints', () => {
expect(getLanEndpointKind(8648)).toBe('web')
expect(getLanEndpointKind(8748)).toBe('desktop')
expect(getLanEndpointKind(19001)).toBe('custom')
})
it('limits discovery responses to local/private IPv4 senders', () => {
expect(isPrivateOrLoopbackIPv4('127.0.0.1')).toBe(true)
expect(isPrivateOrLoopbackIPv4('192.168.1.20')).toBe(true)
expect(isPrivateOrLoopbackIPv4('10.1.2.3')).toBe(true)
expect(isPrivateOrLoopbackIPv4('172.16.0.1')).toBe(true)
expect(isPrivateOrLoopbackIPv4('8.8.8.8')).toBe(false)
})
it('uses configured scan ports plus the active server port', () => {
process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS = '8648, 8748'
expect(getDiscoveryHttpPorts(9999)).toEqual([8648, 8748, 9999])
})
it('discovers a local responder over UDP', async () => {
const httpPort = 19001
const socket = startLanDiscoveryResponder({
httpPort,
getSystemInfo: async () => fakeInfo,
})
if (!socket) throw new Error('expected discovery responder socket')
await new Promise<void>(resolve => socket.once('listening', () => resolve()))
const result = await scanLanDevices({
httpPorts: [httpPort],
targetAddresses: ['127.0.0.1'],
timeoutMs: 300,
includeSelf: true,
})
expect(result.scanning).toBe(false)
const device = result.devices.find(item => item.device_id === fakeDeviceId && item.http_port === httpPort)
expect(device).toMatchObject({
id: fakeDeviceId,
device_id: fakeDeviceId,
ip: '127.0.0.1',
http_port: httpPort,
url: `http://127.0.0.1:${httpPort}`,
endpoint_kind: 'custom',
computer_name: 'test-machine',
hermes_agent_version: 'v1.2.3',
hermes_web_ui_version: '9.9.9',
})
})
it('builds device URLs from the UDP source address instead of announced URLs', async () => {
const httpPort = 19003
const socket = startLanDiscoveryResponder({
httpPort,
getSystemInfo: async () => fakeInfo,
})
if (!socket) throw new Error('expected discovery responder socket')
await new Promise<void>(resolve => socket.once('listening', () => resolve()))
const originalSend = socket.send.bind(socket)
socket.send = ((message: any, port: any, address: any, callback?: any) => {
const announced = JSON.parse(Buffer.from(message).toString('utf8'))
announced.url = 'http://127.0.0.1:1'
return originalSend(Buffer.from(JSON.stringify(announced)), port, address, callback)
}) as typeof socket.send
const result = await scanLanDevices({
httpPorts: [httpPort],
targetAddresses: ['127.0.0.1'],
timeoutMs: 300,
includeSelf: true,
})
const device = result.devices.find(item => item.device_id === fakeDeviceId && item.http_port === httpPort)
expect(device?.url).toBe(`http://127.0.0.1:${httpPort}`)
})
it('excludes the local machine from scan results by default', async () => {
const httpPort = 19002
const socket = startLanDiscoveryResponder({
httpPort,
getSystemInfo: async () => fakeInfo,
})
if (!socket) throw new Error('expected discovery responder socket')
await new Promise<void>(resolve => socket.once('listening', () => resolve()))
const result = await scanLanDevices({
httpPorts: [httpPort],
targetAddresses: ['127.0.0.1'],
timeoutMs: 300,
})
expect(result.scanning).toBe(false)
expect(result.devices).toEqual([])
})
it('registers device request routes before auth and management routes behind super admin auth', () => {
const source = readFileSync('packages/server/src/routes/index.ts', 'utf8')
const deviceRoutesSource = readFileSync('packages/server/src/routes/devices.ts', 'utf8')
const bootstrapSource = readFileSync('packages/server/src/index.ts', 'utf8')
const authIndex = source.indexOf('authMiddleware.forEach')
const publicDeviceIndex = source.indexOf('app.use(devicePublicRoutes.routes())')
const deviceIndex = source.indexOf('app.use(deviceRoutes.routes())')
expect(authIndex).toBeGreaterThanOrEqual(0)
expect(publicDeviceIndex).toBeGreaterThanOrEqual(0)
expect(deviceIndex).toBeGreaterThanOrEqual(0)
expect(deviceRoutesSource).toContain("devicePublicRoutes.post('/api/devices/link-status'")
expect(deviceRoutesSource).toContain("devicePublicRoutes.get('/api/devices/link-info'")
expect(deviceRoutesSource).toContain("import { requireSuperAdmin }")
expect(deviceRoutesSource).toContain('deviceRoutes.use(requireSuperAdmin)')
expect(deviceRoutesSource).toContain("deviceRoutes.get('/api/devices/pairing-link'")
expect(deviceRoutesSource).toContain("deviceRoutes.post('/api/devices/manual-request'")
expect(deviceRoutesSource).toContain("deviceRoutes.delete('/api/devices/:id/request-history'")
expect(deviceRoutesSource).toContain("deviceRoutes.get('/api/devices/peer-connections'")
expect(deviceRoutesSource).toContain("deviceRoutes.post('/api/devices/:id/connect'")
expect(deviceRoutesSource).toContain("deviceRoutes.get('/api/devices/peer-connections/:connectionId/terminals'")
expect(bootstrapSource).toContain('getLanPeerSocketPath()')
expect(publicDeviceIndex).toBeLessThan(authIndex)
expect(deviceIndex).toBeGreaterThan(authIndex)
})
it('keeps LAN peer terminals bounded and idle-reclaimable', () => {
const peerSocketSource = readFileSync('packages/server/src/services/lan-peer-socket.ts', 'utf8')
expect(peerSocketSource).toContain("boundedEnvInt('HERMES_LAN_PEER_MAX_TERMINALS', 4")
expect(peerSocketSource).toContain("boundedEnvInt('HERMES_LAN_PEER_TERMINAL_IDLE_MS', 10 * 60 * 1000")
expect(peerSocketSource).toContain("boundedEnvInt('HERMES_LAN_PEER_TERMINAL_BUFFER_BYTES', 1024 * 1024")
expect(peerSocketSource).toContain('Terminal limit reached')
expect(peerSocketSource).toContain('[lan-peer] closing idle terminal')
expect(peerSocketSource).toContain('this.disposeTerminalSession(session, { notify: false })')
})
it('exposes an MCP terminal list tool so agents can recover forgotten terminal ids', () => {
const mcpSource = readFileSync('bin/hermes-studio-mcp.mjs', 'utf8')
expect(mcpSource).toContain("name: 'hermes_studio_lan_devices_list'")
expect(mcpSource).toContain('online status')
expect(mcpSource).toContain('temporary profile token')
expect(mcpSource).toContain("'X-Hermes-Profile': profile")
expect(mcpSource).toContain('token: args.token')
expect(mcpSource).toContain('profile: args.profile')
expect(mcpSource).toContain("join(appHome(), 'profiles', segment, '.model-run-token')")
expect(mcpSource).toContain("name: 'hermes_studio_lan_terminal_list'")
expect(mcpSource).toContain('/terminals`, withAuthArgs(args))')
})
})