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.
284 lines
9.4 KiB
TypeScript
284 lines
9.4 KiB
TypeScript
/**
|
|
* SIP proxy — hub model entry point.
|
|
*
|
|
* Thin bootstrap that wires together:
|
|
* - UDP socket for all SIP signaling
|
|
* - CallManager (the hub model core)
|
|
* - Provider registration
|
|
* - Local device registrar
|
|
* - WebRTC signaling
|
|
* - Web dashboard
|
|
* - Rust codec bridge
|
|
*
|
|
* All call/media logic lives in ts/call/.
|
|
*/
|
|
|
|
import dgram from 'node:dgram';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { Buffer } from 'node:buffer';
|
|
|
|
import { SipMessage } from './sip/index.ts';
|
|
import type { IEndpoint } from './sip/index.ts';
|
|
import { loadConfig, getProviderForOutbound } from './config.ts';
|
|
import type { IAppConfig, IProviderConfig } from './config.ts';
|
|
import {
|
|
initProviderStates,
|
|
syncProviderStates,
|
|
getProviderByUpstreamAddress,
|
|
handleProviderRegistrationResponse,
|
|
} from './providerstate.ts';
|
|
import {
|
|
initRegistrar,
|
|
handleDeviceRegister,
|
|
isKnownDeviceAddress,
|
|
getAllDeviceStatuses,
|
|
} from './registrar.ts';
|
|
import { broadcastWs, initWebUi } from './frontend.ts';
|
|
import {
|
|
initWebRtcSignaling,
|
|
sendToBrowserDevice,
|
|
getAllBrowserDeviceIds,
|
|
getBrowserDeviceWs,
|
|
} from './webrtcbridge.ts';
|
|
import { initCodecBridge } from './opusbridge.ts';
|
|
import { initAnnouncement } from './announcement.ts';
|
|
import { CallManager } from './call/index.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let appConfig: IAppConfig = loadConfig();
|
|
const { proxy } = appConfig;
|
|
|
|
const LAN_IP = proxy.lanIp;
|
|
const LAN_PORT = proxy.lanPort;
|
|
|
|
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Logging
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const startTime = Date.now();
|
|
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
function now(): string {
|
|
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
}
|
|
|
|
function log(msg: string): void {
|
|
const line = `${now()} ${msg}\n`;
|
|
fs.appendFileSync(LOG_PATH, line);
|
|
process.stdout.write(line);
|
|
broadcastWs('log', { message: msg });
|
|
}
|
|
|
|
function logPacket(label: string, data: Buffer): void {
|
|
const head = `\n========== ${now()} ${label} (${data.length}b) ==========\n`;
|
|
const looksText = data.length > 0 && data[0] >= 0x41 && data[0] <= 0x7a;
|
|
const body = looksText
|
|
? data.toString('utf8')
|
|
: `[${data.length} bytes binary] ${data.toString('hex').slice(0, 80)}`;
|
|
fs.appendFileSync(LOG_PATH, head + body + '\n');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Initialize subsystems
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSeed);
|
|
|
|
initRegistrar(appConfig.devices, log);
|
|
|
|
const callManager = new CallManager({
|
|
appConfig,
|
|
sendSip: (buf, dest) => sock.send(buf, dest.port, dest.address),
|
|
log,
|
|
broadcastWs,
|
|
getProviderState: (id) => providerStates.get(id),
|
|
getAllBrowserDeviceIds,
|
|
sendToBrowserDevice,
|
|
getBrowserDeviceWs,
|
|
});
|
|
|
|
// Initialize WebRTC signaling (browser device registration only).
|
|
initWebRtcSignaling({ log });
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status snapshot (fed to web dashboard)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getStatus() {
|
|
const providers: unknown[] = [];
|
|
for (const ps of providerStates.values()) {
|
|
providers.push({
|
|
id: ps.config.id,
|
|
displayName: ps.config.displayName,
|
|
registered: ps.isRegistered,
|
|
publicIp: ps.publicIp,
|
|
});
|
|
}
|
|
|
|
return {
|
|
instanceId,
|
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
lanIp: LAN_IP,
|
|
providers,
|
|
devices: getAllDeviceStatuses(),
|
|
calls: callManager.getStatus(),
|
|
callHistory: callManager.getHistory(),
|
|
contacts: appConfig.contacts || [],
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main UDP socket
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const sock = dgram.createSocket('udp4');
|
|
|
|
sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
|
|
try {
|
|
const ps = getProviderByUpstreamAddress(rinfo.address, rinfo.port);
|
|
|
|
const msg = SipMessage.parse(data);
|
|
if (!msg) {
|
|
// Non-SIP data — forward raw based on direction.
|
|
if (ps) {
|
|
// From provider, forward to... nowhere useful without a call context.
|
|
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data);
|
|
} else {
|
|
// From device, forward to default provider.
|
|
const dp = getProviderForOutbound(appConfig);
|
|
if (dp) sock.send(data, dp.outboundProxy.port, dp.outboundProxy.address);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 1. Provider registration responses — consumed by providerstate.
|
|
if (handleProviderRegistrationResponse(msg)) return;
|
|
|
|
// 2. Device REGISTER — handled by local registrar.
|
|
if (!ps && msg.method === 'REGISTER') {
|
|
const response = handleDeviceRegister(msg, { address: rinfo.address, port: rinfo.port });
|
|
if (response) {
|
|
sock.send(response.serialize(), rinfo.port, rinfo.address);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 3. Route to existing call by SIP Call-ID.
|
|
if (callManager.routeSipMessage(msg, rinfo)) {
|
|
return;
|
|
}
|
|
|
|
// 4. New inbound call from provider.
|
|
if (ps && msg.isRequest && msg.method === 'INVITE') {
|
|
logPacket(`[new inbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
|
|
|
|
// Detect public IP from Via.
|
|
const via = msg.getHeader('Via');
|
|
if (via) ps.detectPublicIp(via);
|
|
|
|
callManager.createInboundCall(ps, msg, { address: rinfo.address, port: rinfo.port });
|
|
return;
|
|
}
|
|
|
|
// 5. New outbound call from device (passthrough).
|
|
if (!ps && msg.isRequest && msg.method === 'INVITE') {
|
|
logPacket(`[new outbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
|
|
const provider = getProviderForOutbound(appConfig);
|
|
if (provider) {
|
|
const provState = providerStates.get(provider.id);
|
|
if (provState) {
|
|
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, provider, provState);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 6. Fallback: forward based on direction (for mid-dialog messages
|
|
// that don't match any tracked call, e.g. OPTIONS, NOTIFY).
|
|
if (ps) {
|
|
// From provider -> forward to device.
|
|
logPacket(`[fallback inbound] from ${rinfo.address}:${rinfo.port}`, data);
|
|
const via = msg.getHeader('Via');
|
|
if (via) ps.detectPublicIp(via);
|
|
// Try to figure out where to send it...
|
|
// For now, just log. These should become rare once all calls are tracked.
|
|
log(`[fallback] unrouted inbound ${msg.isRequest ? msg.method : msg.statusCode} Call-ID=${msg.callId.slice(0, 30)}`);
|
|
} else {
|
|
// From device -> forward to provider.
|
|
logPacket(`[fallback outbound] from ${rinfo.address}:${rinfo.port}`, data);
|
|
const provider = getProviderForOutbound(appConfig);
|
|
if (provider) sock.send(msg.serialize(), provider.outboundProxy.port, provider.outboundProxy.address);
|
|
}
|
|
} catch (e: any) {
|
|
log(`[err] ${e?.stack || e}`);
|
|
}
|
|
});
|
|
|
|
sock.on('error', (err: Error) => log(`[main] sock err: ${err.message}`));
|
|
|
|
sock.bind(LAN_PORT, '0.0.0.0', () => {
|
|
const providerList = appConfig.providers.map((p) => p.displayName).join(', ');
|
|
const deviceList = appConfig.devices.map((d) => d.displayName).join(', ');
|
|
log(`sip proxy bound 0.0.0.0:${LAN_PORT} | providers: ${providerList} | devices: ${deviceList}`);
|
|
|
|
// Start upstream provider registrations.
|
|
for (const ps of providerStates.values()) {
|
|
ps.startRegistration(
|
|
LAN_IP,
|
|
LAN_PORT,
|
|
(buf, dest) => sock.send(buf, dest.port, dest.address),
|
|
log,
|
|
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
|
|
);
|
|
}
|
|
|
|
// Initialize audio codec bridge (Rust binary via smartrust).
|
|
initCodecBridge(log)
|
|
.then(() => initAnnouncement(log))
|
|
.catch((e) => log(`[codec] init failed: ${e}`));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Web UI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
initWebUi(
|
|
getStatus,
|
|
log,
|
|
(number, deviceId, providerId) => {
|
|
const call = callManager.createOutboundCall(number, deviceId, providerId);
|
|
return call ? { id: call.id } : null;
|
|
},
|
|
(callId) => callManager.hangup(callId),
|
|
() => {
|
|
// Reload config after UI save.
|
|
try {
|
|
const fresh = loadConfig();
|
|
Object.assign(appConfig, fresh);
|
|
// Sync provider registrations: add new, remove deleted, re-register changed.
|
|
syncProviderStates(
|
|
fresh.providers,
|
|
proxy.publicIpSeed,
|
|
LAN_IP,
|
|
LAN_PORT,
|
|
(buf, dest) => sock.send(buf, dest.port, dest.address),
|
|
log,
|
|
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
|
|
);
|
|
log('[config] reloaded config after save');
|
|
} catch (e: any) {
|
|
log(`[config] reload failed: ${e.message}`);
|
|
}
|
|
},
|
|
callManager,
|
|
);
|
|
|
|
process.on('SIGINT', () => { log('SIGINT, exiting'); process.exit(0); });
|
|
process.on('SIGTERM', () => { log('SIGTERM, exiting'); process.exit(0); });
|