/** * Web dashboard server for the SIP proxy. * * Serves a bundled web component frontend (ts_web/) and pushes * live updates over WebSocket. The frontend is built with * @design.estate/dees-element web components and bundled with esbuild. */ import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import https from 'node:https'; import { WebSocketServer, WebSocket } from 'ws'; import { handleWebRtcSignaling } from './webrtcbridge.ts'; import type { VoiceboxManager } from './voicebox.ts'; // CallManager was previously used for WebRTC call handling. Now replaced by Rust proxy-engine. // Kept as `any` type for backward compat with the function signature until full WebRTC port. type CallManager = any; const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json'); // --------------------------------------------------------------------------- // WebSocket broadcast // --------------------------------------------------------------------------- const wsClients = new Set(); function timestamp(): string { return new Date().toISOString().replace('T', ' ').slice(0, 19); } export function broadcastWs(type: string, data: unknown): void { if (!wsClients.size) return; const msg = JSON.stringify({ type, data, ts: timestamp() }); for (const ws of wsClients) { try { if (ws.readyState === WebSocket.OPEN) ws.send(msg); } catch { wsClients.delete(ws); } } } // --------------------------------------------------------------------------- // Static file cache (loaded at startup) // --------------------------------------------------------------------------- const staticFiles = new Map(); function loadStaticFiles(): void { const cwd = process.cwd(); // Load index.html const htmlPath = path.join(cwd, 'html', 'index.html'); try { const data = fs.readFileSync(htmlPath); staticFiles.set('/', { data, contentType: 'text/html; charset=utf-8' }); staticFiles.set('/index.html', { data, contentType: 'text/html; charset=utf-8' }); } catch { const fallback = Buffer.from(` SIP Router `); staticFiles.set('/', { data: fallback, contentType: 'text/html; charset=utf-8' }); staticFiles.set('/index.html', { data: fallback, contentType: 'text/html; charset=utf-8' }); } // Load bundle.js const bundlePath = path.join(cwd, 'dist_ts_web', 'bundle.js'); try { const data = fs.readFileSync(bundlePath); staticFiles.set('/bundle.js', { data, contentType: 'application/javascript; charset=utf-8' }); } catch { /* Bundle not found */ } } // --------------------------------------------------------------------------- // HTTP request handler // --------------------------------------------------------------------------- async function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, getStatus: () => unknown, log: (msg: string) => void, onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null, onHangupCall: (callId: string) => boolean, onConfigSaved?: () => void, callManager?: CallManager, voiceboxManager?: VoiceboxManager, ): Promise { const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const method = req.method || 'GET'; // API: status. if (url.pathname === '/api/status') { return sendJson(res, getStatus()); } // API: start call (with optional providerId). if (url.pathname === '/api/call' && method === 'POST') { try { const body = await readJsonBody(req); const number = body?.number; if (!number || typeof number !== 'string') { return sendJson(res, { ok: false, error: 'missing "number" field' }, 400); } const call = onStartCall(number, body?.deviceId, body?.providerId); if (call) return sendJson(res, { ok: true, callId: call.id }); return sendJson(res, { ok: false, error: 'call origination failed — provider not registered or no ports available' }, 503); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 400); } } // API: hangup. if (url.pathname === '/api/hangup' && method === 'POST') { try { const body = await readJsonBody(req); const callId = body?.callId; if (!callId || typeof callId !== 'string') { return sendJson(res, { ok: false, error: 'missing "callId" field' }, 400); } return sendJson(res, { ok: onHangupCall(callId) }); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 400); } } // API: add leg to call (device — not yet implemented, needs device-to-call routing). if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') { try { const callId = url.pathname.split('/')[3]; const body = await readJsonBody(req); if (!body?.deviceId) return sendJson(res, { ok: false, error: 'missing deviceId' }, 400); // TODO: implement device leg addition (needs SIP INVITE to device). return sendJson(res, { ok: false, error: 'not yet implemented' }, 501); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 400); } } // API: add external participant (dial out) to existing call. if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addexternal') && method === 'POST') { try { const callId = url.pathname.split('/')[3]; const body = await readJsonBody(req); if (!body?.number) return sendJson(res, { ok: false, error: 'missing number' }, 400); const { addLeg: addLegFn } = await import('./proxybridge.ts'); const legId = await addLegFn(callId, body.number, body.providerId); return sendJson(res, { ok: !!legId, legId }); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 400); } } // API: remove leg from call. if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/removeleg') && method === 'POST') { try { const callId = url.pathname.split('/')[3]; const body = await readJsonBody(req); if (!body?.legId) return sendJson(res, { ok: false, error: 'missing legId' }, 400); const { removeLeg: removeLegFn } = await import('./proxybridge.ts'); const ok = await removeLegFn(callId, body.legId); return sendJson(res, { ok }); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 400); } } // API: transfer leg (not yet implemented). if (url.pathname === '/api/transfer' && method === 'POST') { try { const body = await readJsonBody(req); if (!body?.sourceCallId || !body?.legId || !body?.targetCallId) { return sendJson(res, { ok: false, error: 'missing sourceCallId, legId, or targetCallId' }, 400); } return sendJson(res, { ok: false, error: 'not yet implemented' }, 501); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 400); } } // API: get config (sans passwords). if (url.pathname === '/api/config' && method === 'GET') { try { const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); const cfg = JSON.parse(raw); const safe = { ...cfg, providers: cfg.providers?.map((p: any) => ({ ...p, password: '••••••' })) }; return sendJson(res, safe); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 500); } } // API: update config. if (url.pathname === '/api/config' && method === 'POST') { try { const updates = await readJsonBody(req); const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); const cfg = JSON.parse(raw); // Update existing providers. if (updates.providers) { for (const up of updates.providers) { const existing = cfg.providers?.find((p: any) => p.id === up.id); if (existing) { if (up.displayName !== undefined) existing.displayName = up.displayName; if (up.password && up.password !== '••••••') existing.password = up.password; if (up.domain !== undefined) existing.domain = up.domain; if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy; if (up.username !== undefined) existing.username = up.username; if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec; if (up.codecs !== undefined) existing.codecs = up.codecs; if (up.quirks !== undefined) existing.quirks = up.quirks; } } } // Add a new provider. if (updates.addProvider) { cfg.providers ??= []; cfg.providers.push(updates.addProvider); } // Remove a provider. if (updates.removeProvider) { cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider); // Clean up routing references — remove routes that reference this provider. if (cfg.routing?.routes) { cfg.routing.routes = cfg.routing.routes.filter((r: any) => r.match?.sourceProvider !== updates.removeProvider && r.action?.provider !== updates.removeProvider ); } } if (updates.devices) { for (const ud of updates.devices) { const existing = cfg.devices?.find((d: any) => d.id === ud.id); if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName; } } if (updates.routing) { if (updates.routing.routes) { cfg.routing.routes = updates.routing.routes; } } if (updates.contacts !== undefined) cfg.contacts = updates.contacts; if (updates.voiceboxes !== undefined) cfg.voiceboxes = updates.voiceboxes; if (updates.ivr !== undefined) cfg.ivr = updates.ivr; fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n'); log('[config] updated config.json'); onConfigSaved?.(); return sendJson(res, { ok: true }); } catch (e: any) { return sendJson(res, { ok: false, error: e.message }, 400); } } // API: voicemail - list messages. const vmListMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)$/); if (vmListMatch && method === 'GET' && voiceboxManager) { const boxId = vmListMatch[1]; return sendJson(res, { ok: true, messages: voiceboxManager.getMessages(boxId) }); } // API: voicemail - unheard count. const vmUnheardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/unheard$/); if (vmUnheardMatch && method === 'GET' && voiceboxManager) { const boxId = vmUnheardMatch[1]; return sendJson(res, { ok: true, count: voiceboxManager.getUnheardCount(boxId) }); } // API: voicemail - stream audio. const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/); if (vmAudioMatch && method === 'GET' && voiceboxManager) { const [, boxId, msgId] = vmAudioMatch; const audioPath = voiceboxManager.getMessageAudioPath(boxId, msgId); if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404); const stat = fs.statSync(audioPath); res.writeHead(200, { 'Content-Type': 'audio/wav', 'Content-Length': stat.size.toString(), 'Accept-Ranges': 'bytes', }); fs.createReadStream(audioPath).pipe(res); return; } // API: voicemail - mark as heard. const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/); if (vmHeardMatch && method === 'POST' && voiceboxManager) { const [, boxId, msgId] = vmHeardMatch; return sendJson(res, { ok: voiceboxManager.markHeard(boxId, msgId) }); } // API: voicemail - delete message. const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/); if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) { const [, boxId, msgId] = vmDeleteMatch; return sendJson(res, { ok: voiceboxManager.deleteMessage(boxId, msgId) }); } // Static files. const file = staticFiles.get(url.pathname); if (file) { res.writeHead(200, { 'Content-Type': file.contentType, 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', }); res.end(file.data); return; } // SPA fallback. const index = staticFiles.get('/'); if (index) { res.writeHead(200, { 'Content-Type': index.contentType }); res.end(index.data); return; } res.writeHead(404); res.end('Not Found'); } // --------------------------------------------------------------------------- // HTTP + WebSocket server (Node.js) // --------------------------------------------------------------------------- export function initWebUi( getStatus: () => unknown, log: (msg: string) => void, onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null, onHangupCall: (callId: string) => boolean, onConfigSaved?: () => void, callManager?: CallManager, voiceboxManager?: VoiceboxManager, /** WebRTC signaling handlers — forwarded to Rust proxy-engine. */ onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise, onWebRtcIce?: (sessionId: string, candidate: any) => Promise, onWebRtcClose?: (sessionId: string) => Promise, /** Called when browser sends webrtc-accept (callId + sessionId linking). */ onWebRtcAccept?: (callId: string, sessionId: string) => void, ): void { const WEB_PORT = 3060; loadStaticFiles(); // Serve HTTPS if cert exists, otherwise fall back to HTTP. const certPath = path.join(process.cwd(), '.nogit', 'cert.pem'); const keyPath = path.join(process.cwd(), '.nogit', 'key.pem'); let useTls = false; let server: http.Server | https.Server; try { const cert = fs.readFileSync(certPath, 'utf8'); const key = fs.readFileSync(keyPath, 'utf8'); server = https.createServer({ cert, key }, (req, res) => handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }), ); useTls = true; } catch { server = http.createServer((req, res) => handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager, voiceboxManager).catch(() => { res.writeHead(500); res.end(); }), ); } // WebSocket server on the same port. const wss = new WebSocketServer({ server, path: '/ws' }); wss.on('connection', (socket, req) => { const remoteIp = req.socket.remoteAddress || null; wsClients.add(socket); socket.send(JSON.stringify({ type: 'status', data: getStatus(), ts: timestamp() })); socket.on('message', (raw) => { try { const msg = JSON.parse(raw.toString()); if (msg.type === 'webrtc-offer' && msg.sessionId) { // Forward to Rust proxy-engine for WebRTC handling. if (onWebRtcOffer) { log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`); onWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) => log(`[webrtc] offer error: ${e.message}`)); } } else if (msg.type === 'webrtc-ice' && msg.sessionId) { if (onWebRtcIce) { onWebRtcIce(msg.sessionId, msg.candidate).catch(() => {}); } } else if (msg.type === 'webrtc-hangup' && msg.sessionId) { if (onWebRtcClose) { onWebRtcClose(msg.sessionId).catch(() => {}); } } else if (msg.type === 'webrtc-accept' && msg.callId) { log(`[webrtc] accept: call=${msg.callId} session=${msg.sessionId || 'none'}`); if (onWebRtcAccept && msg.sessionId) { onWebRtcAccept(msg.callId, msg.sessionId); } } else if (msg.type?.startsWith('webrtc-')) { msg._remoteIp = remoteIp; handleWebRtcSignaling(socket as any, msg); } } catch { /* ignore */ } }); socket.on('close', () => wsClients.delete(socket)); socket.on('error', () => wsClients.delete(socket)); }); server.listen(WEB_PORT, '0.0.0.0', () => { log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${WEB_PORT}`); }); setInterval(() => broadcastWs('status', getStatus()), 1000); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function sendJson(res: http.ServerResponse, data: unknown, status = 200): void { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } function readJsonBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch (e) { reject(e); } }); req.on('error', reject); }); }