import dgram from 'dgram' import { networkInterfaces } from 'os' import { config } from '../config' import { logger } from './logger' import { deviceIdFromPublicKey, getPublicSystemInfo, type PublicSystemInfo } from './system-info' const DISCOVERY_VERSION = 1 export const HERMES_DISCOVERY_PORT = 48640 const DISCOVERY_PORT_OFFSET = 40_000 const DEFAULT_HTTP_PORTS = [8648] const DEFAULT_SCAN_TIMEOUT_MS = 1000 type DiscoverySocket = dgram.Socket export type LanDeviceInfo = PublicSystemInfo & { id: string ip: string http_port: number endpoint_kind: LanEndpointKind url: string response_ms: number last_seen_at: string } export type LanEndpointKind = 'web' | 'custom' export type LanDiscoveryState = { scanning: boolean last_scanned_at: string | null devices: LanDeviceInfo[] } type DiscoveryRequest = { type?: string version?: number request_id?: string } type DiscoveryAnnouncement = PublicSystemInfo & { type: 'hermes.announce' version: number request_id?: string http_port: number endpoint_kind: LanEndpointKind url: string } type StartResponderOptions = { httpPort?: number getSystemInfo?: () => Promise } type ScanOptions = { timeoutMs?: number httpPorts?: number[] targetAddresses?: string[] includeSelf?: boolean } let responderSockets: DiscoverySocket[] = [] let cache: LanDiscoveryState = { scanning: false, last_scanned_at: null, devices: [], } let scanInFlight: Promise | null = null let localInfoCache: { value: PublicSystemInfo; expiresAt: number } | null = null function envFlagDisabled(name: string): boolean { const value = String(process.env[name] || '').trim().toLowerCase() return ['0', 'false', 'no', 'off'].includes(value) } export function isLanDiscoveryEnabled(): boolean { return !envFlagDisabled('HERMES_LAN_DISCOVERY_ENABLED') } function parsePortList(value: string | undefined): number[] { return String(value || '') .split(/[\s,]+/) .map(item => Number.parseInt(item, 10)) .filter(port => Number.isInteger(port) && port > 0 && port <= 65535) } export function discoveryPortForHttpPort(httpPort: number): number { const port = DISCOVERY_PORT_OFFSET + httpPort if (port > 65535) throw new Error(`HTTP port ${httpPort} cannot be mapped to a UDP discovery port`) return port } function discoveryPortsForHttpPorts(httpPorts: number[]): number[] { const ports = new Set([HERMES_DISCOVERY_PORT]) for (const httpPort of httpPorts) { try { ports.add(discoveryPortForHttpPort(httpPort)) } catch { // Fixed discovery port still covers endpoints with unmappable HTTP ports. } } return [...ports] } export function getLanEndpointKind(httpPort: number, currentPort = config.port): LanEndpointKind { if (httpPort === 8648 || httpPort === currentPort) return 'web' return 'custom' } export function getDiscoveryHttpPorts(currentPort = config.port): number[] { const configured = parsePortList(process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS) return [...new Set([...(configured.length ? configured : DEFAULT_HTTP_PORTS), currentPort])] } function ipv4ToInt(ip: string): number | null { const parts = ip.split('.').map(part => Number.parseInt(part, 10)) if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return null return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0 } function intToIpv4(value: number): string { return [ (value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255, ].join('.') } export function isPrivateOrLoopbackIPv4(ip: string): boolean { const value = ipv4ToInt(ip) if (value === null) return false const first = (value >>> 24) & 255 const second = (value >>> 16) & 255 if (first === 10 || first === 127) return true if (first === 172 && second >= 16 && second <= 31) return true if (first === 192 && second === 168) return true if (first === 169 && second === 254) return true return false } function getIpv4Interfaces() { try { return Object.values(networkInterfaces()) .flat() .filter(item => item && item.family === 'IPv4' && !item.internal && item.address && item.netmask) .map(item => ({ address: item!.address, netmask: item!.netmask })) } catch { return [] } } function getLocalIPv4Addresses(): Set { return new Set([ '127.0.0.1', ...getIpv4Interfaces().map(iface => iface.address), ]) } function broadcastAddress(address: string, netmask: string): string | null { const ip = ipv4ToInt(address) const mask = ipv4ToInt(netmask) if (ip === null || mask === null) return null return intToIpv4((ip | (~mask >>> 0)) >>> 0) } function selectLocalAddress(remoteAddress: string): string { const remote = ipv4ToInt(remoteAddress) const interfaces = getIpv4Interfaces() if (remote !== null) { for (const iface of interfaces) { const ip = ipv4ToInt(iface.address) const mask = ipv4ToInt(iface.netmask) if (ip !== null && mask !== null && (ip & mask) === (remote & mask)) return iface.address } } return interfaces[0]?.address || '127.0.0.1' } export function getDiscoveryTargetAddresses(): string[] { const targets = new Set(['255.255.255.255']) for (const iface of getIpv4Interfaces()) { const broadcast = broadcastAddress(iface.address, iface.netmask) if (broadcast) targets.add(broadcast) } return [...targets] } async function getCachedLocalInfo(getSystemInfo: () => Promise): Promise { const now = Date.now() if (localInfoCache && localInfoCache.expiresAt > now) return localInfoCache.value const value = await getSystemInfo() localInfoCache = { value, expiresAt: now + 60_000 } return value } function parseJson(buffer: Buffer): any | null { try { return JSON.parse(buffer.toString('utf8')) } catch { return null } } async function buildAnnouncement( request: DiscoveryRequest, remoteAddress: string, httpPort: number, getSystemInfo: () => Promise, ): Promise { const info = await getCachedLocalInfo(getSystemInfo) const localAddress = selectLocalAddress(remoteAddress) return { type: 'hermes.announce', version: DISCOVERY_VERSION, request_id: request.request_id, http_port: httpPort, endpoint_kind: getLanEndpointKind(httpPort), url: `http://${localAddress}:${httpPort}`, ...info, } } export function startLanDiscoveryResponder(options: StartResponderOptions = {}): DiscoverySocket | null { if (!isLanDiscoveryEnabled()) return null if (responderSockets.length > 0) return responderSockets[0] const httpPort = options.httpPort || config.port const getSystemInfo = options.getSystemInfo || getPublicSystemInfo const discoveryPorts = discoveryPortsForHttpPorts([httpPort]) for (const discoveryPort of discoveryPorts) { const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) socket.on('message', (message, rinfo) => { if (!isPrivateOrLoopbackIPv4(rinfo.address)) return const request = parseJson(message) as DiscoveryRequest | null if (!request || request.type !== 'hermes.discover' || request.version !== DISCOVERY_VERSION) return void buildAnnouncement(request, rinfo.address, httpPort, getSystemInfo) .then(announcement => { const response = Buffer.from(JSON.stringify(announcement)) socket.send(response, rinfo.port, rinfo.address) }) .catch(err => logger.warn(err, '[lan-discovery] failed to build discovery response')) }) socket.on('error', err => { logger.warn(err, '[lan-discovery] UDP responder error on port %d', discoveryPort) }) socket.bind(discoveryPort, '0.0.0.0', () => { socket.setBroadcast(true) socket.unref() logger.info('[lan-discovery] responder listening on udp://0.0.0.0:%d for http port %d', discoveryPort, httpPort) }) responderSockets.push(socket) } return responderSockets[0] || null } export function stopLanDiscoveryResponder(): void { for (const socket of responderSockets) { try { socket.close() } catch { // Ignore close races during shutdown/tests. } } responderSockets = [] } function normalizeDevice(data: any, sourceAddress: string, responseMs: number, seenAt: string): LanDeviceInfo | null { if (!data || data.type !== 'hermes.announce' || data.version !== DISCOVERY_VERSION) return null const httpPort = Number(data.http_port) if (!Number.isInteger(httpPort) || httpPort <= 0 || httpPort > 65535) return null const deviceId = typeof data.device_id === 'string' && data.device_id ? data.device_id : '' const devicePublicKey = typeof data.device_public_key === 'string' ? data.device_public_key : '' if (!deviceId || !devicePublicKey || deviceIdFromPublicKey(devicePublicKey) !== deviceId) return null const endpointKind = data.endpoint_kind === 'web' || data.endpoint_kind === 'custom' ? data.endpoint_kind : getLanEndpointKind(httpPort) const url = `http://${sourceAddress}:${httpPort}` return { id: deviceId, device_id: deviceId, device_public_key: devicePublicKey, ip: sourceAddress, http_port: httpPort, endpoint_kind: endpointKind, url, computer_name: String(data.computer_name || ''), os: { type: String(data.os?.type || ''), platform: String(data.os?.platform || '') as NodeJS.Platform, release: String(data.os?.release || ''), arch: String(data.os?.arch || ''), }, hermes_agent_version: String(data.hermes_agent_version || ''), hermes_web_ui_version: String(data.hermes_web_ui_version || ''), response_ms: responseMs, last_seen_at: seenAt, } } function isSelfDevice(device: LanDeviceInfo, localAddresses: Set): boolean { return localAddresses.has(device.ip) } export function getLanDiscoveryCache(): LanDiscoveryState { return { scanning: cache.scanning, last_scanned_at: cache.last_scanned_at, devices: [...cache.devices], } } export async function scanLanDevices(options: ScanOptions = {}): Promise { if (!isLanDiscoveryEnabled()) return getLanDiscoveryCache() if (scanInFlight) return scanInFlight const timeoutMs = Math.max(100, Math.min(options.timeoutMs || DEFAULT_SCAN_TIMEOUT_MS, 5000)) const httpPorts = [...new Set(options.httpPorts || getDiscoveryHttpPorts())] const targetAddresses = options.targetAddresses || getDiscoveryTargetAddresses() const localAddresses = getLocalIPv4Addresses() const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` cache = { ...cache, scanning: true } const startedAt = Date.now() scanInFlight = new Promise((resolve) => { const socket = dgram.createSocket('udp4') const found = new Map() socket.on('message', (message, rinfo) => { if (!isPrivateOrLoopbackIPv4(rinfo.address)) return const data = parseJson(message) if (data?.request_id && data.request_id !== requestId) return const device = normalizeDevice(data, rinfo.address, Date.now() - startedAt, new Date().toISOString()) if (device && !options.includeSelf && isSelfDevice(device, localAddresses)) return if (device) found.set(device.id, device) }) socket.on('error', err => { logger.warn(err, '[lan-discovery] UDP scan error') }) socket.bind(0, '0.0.0.0', () => { socket.setBroadcast(true) const packet = Buffer.from(JSON.stringify({ type: 'hermes.discover', version: DISCOVERY_VERSION, request_id: requestId, })) for (const discoveryPort of discoveryPortsForHttpPorts(httpPorts)) { for (const target of targetAddresses) { socket.send(packet, discoveryPort, target) } } }) setTimeout(() => { try { socket.close() } catch { // Ignore close races. } cache = { scanning: false, last_scanned_at: new Date().toISOString(), devices: [...found.values()].sort((a, b) => a.id.localeCompare(b.id)), } resolve(getLanDiscoveryCache()) }, timeoutMs) }).finally(() => { scanInFlight = null }) return scanInFlight } export function resetLanDiscoveryState(): void { cache = { scanning: false, last_scanned_at: null, devices: [], } scanInFlight = null localInfoCache = null stopLanDiscoveryResponder() }