448 lines
17 KiB
TypeScript
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);
|
|
});
|
|
}
|