import dgram from 'node:dgram'; import os from 'node:os'; import { createHmac, timingSafeEqual } from 'node:crypto'; import type { AgentMesh, MeshPeer } from './agent-mesh.js'; import { getRootLogger } from '../logger.js'; const log = getRootLogger().child('mesh:lan-discovery'); const BROADCAST_PORT = parseInt(process.env.MOSS_MESH_DISCOVERY_PORT || '9091', 10) || 9091; const BROADCAST_INTERVAL_MS = 10_000; const HMAC_TIMESTAMP_TOLERANCE_MS = 30_000; export interface LanDiscoveryConfig { mesh: AgentMesh; meshPort: number; agentId: string; agentName: string; capabilities?: string[]; sharedSecret?: string; } interface BroadcastMessage { type: 'moss-announce'; id: string; name: string; meshPort: number; capabilities: string[]; deviceInfo?: string; timestamp?: number; hmac?: string; } function computeBroadcastHmac( id: string, name: string, meshPort: number, timestamp: number, secret: string ): string { const payload = JSON.stringify({ id, name, meshPort, timestamp }); return createHmac('sha256', secret).update(payload).digest('hex'); } function verifyBroadcastHmac( id: string, name: string, meshPort: number, timestamp: number, receivedHmac: string, secret: string ): boolean { const expected = computeBroadcastHmac(id, name, meshPort, timestamp, secret); const a = Buffer.from(expected); const b = Buffer.from(receivedHmac); return a.length === b.length && timingSafeEqual(a, b); } export class LanDiscovery { private socket: dgram.Socket | null = null; private timer: ReturnType | null = null; private expiryTimer: ReturnType | null = null; private readonly peerTtlMs: number; private readonly config: LanDiscoveryConfig; private running = false; private onPeerDiscovered: ((peer: MeshPeer) => void) | null = null; private warnedNoSecret = false; constructor(config: LanDiscoveryConfig) { this.config = config; this.peerTtlMs = Number(process.env.MOSS_MESH_PEER_TTL_MS) || 5 * 60 * 1000; } onNewPeer(handler: (peer: MeshPeer) => void): void { this.onPeerDiscovered = handler; } async start(): Promise { if (this.running) return; this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); this.socket.on('message', (data, rinfo) => { try { const msg: BroadcastMessage = JSON.parse(data.toString()); if (msg.type !== 'moss-announce') return; if (!msg.id || typeof msg.id !== 'string') return; if (typeof msg.name !== 'string') msg.name = msg.id; if (msg.id === this.config.agentId) return; const port = Number(msg.meshPort); if (!Number.isInteger(port) || port < 1 || port > 65535) return; if (!Array.isArray(msg.capabilities)) msg.capabilities = []; const secret = this.config.sharedSecret?.trim(); if (secret) { if (!msg.hmac || typeof msg.hmac !== 'string' || typeof msg.timestamp !== 'number') { log.warn('dropping unsigned broadcast: missing hmac or timestamp', { from: rinfo.address, peerId: msg.id, }); return; } const skew = Math.abs(Date.now() - msg.timestamp); if (skew > HMAC_TIMESTAMP_TOLERANCE_MS) { log.warn('dropping broadcast: timestamp skew exceeds tolerance', { from: rinfo.address, peerId: msg.id, skewMs: skew, }); return; } if (!verifyBroadcastHmac(msg.id, msg.name, port, msg.timestamp, msg.hmac, secret)) { log.warn('dropping broadcast: HMAC verification failed', { from: rinfo.address, peerId: msg.id, }); return; } } else if (!this.warnedNoSecret) { this.warnedNoSecret = true; log.warn( 'LAN discovery running without sharedSecret: broadcast messages are unauthenticated' ); } const existing = this.config.mesh.getPeers().find((p) => p.id === msg.id); if (!existing) { this.config.mesh .discoverPeer(rinfo.address, port, { allowPrivate: true }) .then((discovered) => { if (discovered && this.onPeerDiscovered) this.onPeerDiscovered(discovered); }) .catch((err) => log.warn('peer discovery failed', { error: err?.message ?? String(err) }) ); } } catch { } }); return new Promise((resolve, reject) => { this.socket!.bind(BROADCAST_PORT, () => { this.socket!.setBroadcast(true); this.running = true; this.broadcast(); this.timer = setInterval(() => this.broadcast(), BROADCAST_INTERVAL_MS); this.timer.unref?.(); this.expiryTimer = setInterval(() => this.evictStalePeers(), 60_000); this.expiryTimer.unref?.(); resolve(); }); this.socket!.on('error', (err) => { if (!this.running) { reject(err); } else { log.error('socket error after startup', { error: err?.message ?? String(err) }); } }); }); } stop(): void { this.running = false; if (this.timer) { clearInterval(this.timer); this.timer = null; } if (this.expiryTimer) { clearInterval(this.expiryTimer); this.expiryTimer = null; } if (this.socket) { this.socket.close(); this.socket = null; } } private broadcast(): void { if (!this.socket) return; const msg: BroadcastMessage = { type: 'moss-announce', id: this.config.agentId, name: this.config.agentName, meshPort: this.config.meshPort, capabilities: this.config.capabilities ?? [], }; const secret = this.config.sharedSecret?.trim(); if (secret) { msg.timestamp = Date.now(); msg.hmac = computeBroadcastHmac(msg.id, msg.name, msg.meshPort, msg.timestamp, secret); } const buf = Buffer.from(JSON.stringify(msg)); const addresses = this.getAllBroadcastAddresses(); for (const addr of addresses) { try { this.socket.send(buf, 0, buf.length, BROADCAST_PORT, addr); } catch { } } } private getAllBroadcastAddresses(): string[] { const interfaces = os.networkInterfaces(); const addresses: string[] = []; for (const iface of Object.values(interfaces)) { if (!iface) continue; for (const info of iface) { if (info.family === 'IPv4' && !info.internal) { const parts = info.address.split('.'); const maskParts = info.netmask.split('.'); const broadcast = parts .map((p, i) => (parseInt(p) | (~parseInt(maskParts[i]) & 255)).toString()) .join('.'); addresses.push(broadcast); } } } if (addresses.length === 0) { addresses.push('255.255.255.255'); } return addresses; } private evictStalePeers(): void { const cutoff = Date.now() - this.peerTtlMs; const peers = this.config.mesh.getPeers(); for (const peer of peers) { if (peer.lastSeen < cutoff) { this.config.mesh.removePeer(peer.id, 'discovery_ttl_expired'); } } } }