Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
373 lines
14 KiB
TypeScript
373 lines
14 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 type { CallManager } from './call/index.ts';
|
|
import { handleWebRtcSignaling } from './webrtcbridge.ts';
|
|
|
|
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,
|
|
): 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 leg to call.
|
|
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 ok = callManager?.addDeviceToCall(callId, body.deviceId) ?? false;
|
|
return sendJson(res, { ok });
|
|
} 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 ok = callManager?.addExternalToCall(callId, body.number, body.providerId) ?? false;
|
|
return sendJson(res, { ok });
|
|
} 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 ok = callManager?.removeLegFromCall(callId, body.legId) ?? false;
|
|
return sendJson(res, { ok });
|
|
} catch (e: any) {
|
|
return sendJson(res, { ok: false, error: e.message }, 400);
|
|
}
|
|
}
|
|
|
|
// API: transfer leg.
|
|
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);
|
|
}
|
|
const ok = callManager?.transferLeg(body.sourceCallId, body.legId, body.targetCallId) ?? false;
|
|
return sendJson(res, { ok });
|
|
} 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.
|
|
if (cfg.routing?.inbound) delete cfg.routing.inbound[updates.removeProvider];
|
|
if (cfg.routing?.ringBrowsers) delete cfg.routing.ringBrowsers[updates.removeProvider];
|
|
if (cfg.routing?.outbound?.default === updates.removeProvider) {
|
|
cfg.routing.outbound.default = cfg.providers[0]?.id || '';
|
|
}
|
|
}
|
|
|
|
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.inbound) cfg.routing.inbound = { ...cfg.routing.inbound, ...updates.routing.inbound };
|
|
if (updates.routing.ringBrowsers) cfg.routing.ringBrowsers = { ...cfg.routing.ringBrowsers, ...updates.routing.ringBrowsers };
|
|
}
|
|
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
): 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).catch(() => { res.writeHead(500); res.end(); }),
|
|
);
|
|
useTls = true;
|
|
} catch {
|
|
server = http.createServer((req, res) =>
|
|
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).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-accept' && msg.callId) {
|
|
log(`[webrtc] browser accepted call ${msg.callId} session=${msg.sessionId || 'none'}`);
|
|
const ok = callManager?.acceptBrowserCall(msg.callId, msg.sessionId) ?? false;
|
|
log(`[webrtc] acceptBrowserCall result: ${ok}`);
|
|
} else if (msg.type === 'webrtc-offer' && msg.sessionId) {
|
|
callManager?.handleWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
|
|
log(`[webrtc] offer error: ${e.message}`));
|
|
} else if (msg.type === 'webrtc-ice' && msg.sessionId) {
|
|
callManager?.handleWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
|
|
} else if (msg.type === 'webrtc-hangup' && msg.sessionId) {
|
|
callManager?.handleWebRtcHangup(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);
|
|
});
|
|
}
|