Files
siprouter/ts/frontend.ts

448 lines
17 KiB
TypeScript

/**
* 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<WebSocket>();
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<string, { data: Buffer; contentType: string }>();
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(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SIP Router</title>
<style>body{margin:0;background:#0f172a;color:#e2e8f0;font-family:system-ui}sipproxy-app{display:block;position:fixed;inset:0;overflow:hidden}</style>
</head><body><sipproxy-app></sipproxy-app><script type="module" src="/bundle.js"></script></body></html>`);
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<void> {
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 a SIP device to a call (mid-call INVITE to desk phone).
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);
const { addDeviceLeg } = await import('./proxybridge.ts');
const legId = await addDeviceLeg(callId, body.deviceId);
if (legId) {
return sendJson(res, { ok: true, legId });
} else {
return sendJson(res, { ok: false, error: 'device not registered or call not found' }, 404);
}
} 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<void>,
onWebRtcIce?: (sessionId: string, candidate: any) => Promise<void>,
onWebRtcClose?: (sessionId: string) => Promise<void>,
/** 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<any> {
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);
});
}