Files
siprouter/ts/sipproxy.ts

222 lines
6.8 KiB
TypeScript

/**
* SIP proxy bootstrap.
*
* Spawns the Rust proxy-engine, wires runtime state/event handling,
* and starts the web dashboard plus browser signaling layer.
*/
import fs from 'node:fs';
import path from 'node:path';
import { loadConfig, type IAppConfig } from './config.ts';
import { broadcastWs, initWebUi } from './frontend.ts';
import { initWebRtcSignaling, getAllBrowserDeviceIds, sendToBrowserDevice } from './webrtcbridge.ts';
import { VoiceboxManager } from './voicebox.ts';
import {
initProxyEngine,
configureProxyEngine,
hangupCall,
makeCall,
shutdownProxyEngine,
webrtcOffer,
webrtcIce,
webrtcLink,
webrtcClose,
} from './proxybridge.ts';
import { registerProxyEventHandlers } from './runtime/proxy-events.ts';
import { StatusStore } from './runtime/status-store.ts';
import { WebRtcLinkManager, type IProviderMediaInfo } from './runtime/webrtc-linking.ts';
let appConfig: IAppConfig = loadConfig();
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
const startTime = Date.now();
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const statusStore = new StatusStore(appConfig);
const webRtcLinks = new WebRtcLinkManager();
const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []);
initWebRtcSignaling({ log });
function now(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
function log(message: string): void {
const line = `${now()} ${message}\n`;
fs.appendFileSync(LOG_PATH, line);
process.stdout.write(line);
broadcastWs('log', { message });
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildProxyConfig(config: IAppConfig): Record<string, unknown> {
return {
proxy: config.proxy,
providers: config.providers,
devices: config.devices,
routing: config.routing,
voiceboxes: config.voiceboxes ?? [],
ivr: config.ivr,
};
}
function getStatus() {
return statusStore.buildStatusSnapshot(
instanceId,
startTime,
getAllBrowserDeviceIds(),
voiceboxManager.getAllUnheardCounts(),
);
}
function requestWebRtcLink(callId: string, sessionId: string, media: IProviderMediaInfo): void {
log(`[webrtc] linking session=${sessionId.slice(0, 8)} to call=${callId} media=${media.addr}:${media.port} pt=${media.sipPt}`);
void webrtcLink(sessionId, callId, media.addr, media.port, media.sipPt).then((ok) => {
log(`[webrtc] link result: ${ok}`);
});
}
async function configureRuntime(config: IAppConfig): Promise<boolean> {
return configureProxyEngine(buildProxyConfig(config));
}
async function reloadConfig(): Promise<void> {
try {
const previousConfig = appConfig;
const nextConfig = loadConfig();
appConfig = nextConfig;
statusStore.updateConfig(nextConfig);
voiceboxManager.init(nextConfig.voiceboxes ?? []);
if (nextConfig.proxy.lanPort !== previousConfig.proxy.lanPort) {
log('[config] proxy.lanPort changed; restart required for SIP socket rebinding');
}
if (nextConfig.proxy.webUiPort !== previousConfig.proxy.webUiPort) {
log('[config] proxy.webUiPort changed; restart required for web UI rebinding');
}
const configured = await configureRuntime(nextConfig);
if (configured) {
log('[config] reloaded - proxy engine reconfigured');
} else {
log('[config] reload failed - proxy engine rejected config');
}
} catch (error: unknown) {
log(`[config] reload failed: ${errorMessage(error)}`);
}
}
async function startProxyEngine(): Promise<void> {
const started = await initProxyEngine(log);
if (!started) {
log('[FATAL] failed to start proxy engine');
process.exit(1);
}
registerProxyEventHandlers({
log,
statusStore,
voiceboxManager,
webRtcLinks,
getBrowserDeviceIds: getAllBrowserDeviceIds,
sendToBrowserDevice,
broadcast: broadcastWs,
onLinkWebRtcSession: requestWebRtcLink,
onCloseWebRtcSession: (sessionId) => {
void webrtcClose(sessionId);
},
});
const configured = await configureRuntime(appConfig);
if (!configured) {
log('[FATAL] failed to configure proxy engine');
process.exit(1);
}
const providerList = appConfig.providers.map((provider) => provider.displayName).join(', ');
const deviceList = appConfig.devices.map((device) => device.displayName).join(', ');
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
}
initWebUi({
port: appConfig.proxy.webUiPort,
getStatus,
log,
onStartCall: (number, deviceId, providerId) => {
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
void makeCall(number, deviceId, providerId).then((callId) => {
if (callId) {
log(`[dashboard] call started: ${callId}`);
statusStore.noteDashboardCallStarted(callId, number, providerId);
} else {
log(`[dashboard] call failed for ${number}`);
}
});
return { id: `pending-${Date.now()}` };
},
onHangupCall: (callId) => {
void hangupCall(callId);
return true;
},
onConfigSaved: reloadConfig,
voiceboxManager,
onWebRtcOffer: async (sessionId, sdp, ws) => {
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)} sdp_type=${typeof sdp} sdp_len=${sdp?.length || 0}`);
if (!sdp || typeof sdp !== 'string' || sdp.length < 10) {
log(`[webrtc] WARNING: invalid SDP (type=${typeof sdp}), skipping offer`);
return;
}
log(`[webrtc] sending offer to Rust (${sdp.length}b)...`);
const result = await webrtcOffer(sessionId, sdp);
log(`[webrtc] Rust result: ${JSON.stringify(result)?.slice(0, 200)}`);
if (result?.sdp) {
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
return;
}
log('[webrtc] ERROR: no answer SDP from Rust');
},
onWebRtcIce: async (sessionId, candidate) => {
await webrtcIce(sessionId, candidate);
},
onWebRtcClose: async (sessionId) => {
webRtcLinks.removeSession(sessionId);
await webrtcClose(sessionId);
},
onWebRtcAccept: (callId, sessionId) => {
log(`[webrtc] accept: callId=${callId} sessionId=${sessionId.slice(0, 8)}`);
const pendingMedia = webRtcLinks.acceptCall(callId, sessionId);
if (pendingMedia) {
requestWebRtcLink(callId, sessionId, pendingMedia);
return;
}
log(`[webrtc] session ${sessionId.slice(0, 8)} accepted, waiting for call_answered media info`);
},
});
void startProxyEngine();
process.on('SIGINT', () => {
log('SIGINT, exiting');
shutdownProxyEngine();
process.exit(0);
});
process.on('SIGTERM', () => {
log('SIGTERM, exiting');
shutdownProxyEngine();
process.exit(0);
});