feat(rust-proxy-engine): add a Rust SIP proxy engine with shared SIP and codec libraries

This commit is contained in:
2026-04-10 09:57:27 +00:00
parent f3b18a7170
commit 3132ba8cbb
28 changed files with 5042 additions and 548 deletions

View File

@@ -1,39 +1,22 @@
/**
* SIP proxy — hub model entry point.
* SIP proxy — 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
* Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics.
* TypeScript is the control plane:
* - Loads config and pushes it to Rust
* - Receives high-level events (incoming calls, registration, etc.)
* - Drives the web dashboard
* - Manages IVR, voicemail, announcements
* - Handles WebRTC browser signaling (forwarded to Rust in Phase 2)
*
* All call/media logic lives in ts/call/.
* No raw SIP ever touches TypeScript.
*/
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, resolveOutboundRoute } 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 { loadConfig } from './config.ts';
import type { IAppConfig } from './config.ts';
import { broadcastWs, initWebUi } from './frontend.ts';
import {
initWebRtcSignaling,
@@ -43,19 +26,28 @@ import {
} from './webrtcbridge.ts';
import { initCodecBridge } from './opusbridge.ts';
import { initAnnouncement } from './announcement.ts';
import { CallManager } from './call/index.ts';
import { PromptCache } from './call/prompt-cache.ts';
import { VoiceboxManager } from './voicebox.ts';
import {
initProxyEngine,
configureProxyEngine,
onProxyEvent,
hangupCall,
shutdownProxyEngine,
} from './proxybridge.ts';
import type {
IIncomingCallEvent,
IOutboundCallEvent,
ICallEndedEvent,
IProviderRegisteredEvent,
IDeviceRegisteredEvent,
} from './proxybridge.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');
@@ -77,42 +69,82 @@ function log(msg: string): void {
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');
// ---------------------------------------------------------------------------
// Shadow state — maintained from Rust events for the dashboard
// ---------------------------------------------------------------------------
interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
}
interface IDeviceStatus {
id: string;
displayName: string;
address: string | null;
port: number;
connected: boolean;
isBrowser: boolean;
}
interface IActiveCall {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
}
interface ICallHistoryEntry {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
startedAt: number;
duration: number;
}
const providerStatuses = new Map<string, IProviderStatus>();
const deviceStatuses = new Map<string, IDeviceStatus>();
const activeCalls = new Map<string, IActiveCall>();
const callHistory: ICallHistoryEntry[] = [];
const MAX_HISTORY = 100;
// Initialize provider statuses from config (all start as unregistered).
for (const p of appConfig.providers) {
providerStatuses.set(p.id, {
id: p.id,
displayName: p.displayName,
registered: false,
publicIp: null,
});
}
// Initialize device statuses from config.
for (const d of appConfig.devices) {
deviceStatuses.set(d.id, {
id: d.id,
displayName: d.displayName,
address: null,
port: 0,
connected: false,
isBrowser: false,
});
}
// ---------------------------------------------------------------------------
// Initialize subsystems
// ---------------------------------------------------------------------------
const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSeed);
initRegistrar(appConfig.devices, log);
// Initialize voicemail and IVR subsystems.
const promptCache = new PromptCache(log);
const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []);
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,
promptCache,
voiceboxManager,
});
// Initialize WebRTC signaling (browser device registration only).
// WebRTC signaling (browser device registration).
initWebRtcSignaling({ log });
// ---------------------------------------------------------------------------
@@ -120,177 +152,176 @@ initWebRtcSignaling({ log });
// ---------------------------------------------------------------------------
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(),
lanIp: appConfig.proxy.lanIp,
providers: [...providerStatuses.values()],
devices: [...deviceStatuses.values()],
calls: [...activeCalls.values()].map((c) => ({
...c,
duration: Math.floor((Date.now() - c.startedAt) / 1000),
legs: [],
})),
callHistory,
contacts: appConfig.contacts || [],
voicemailCounts: voiceboxManager.getAllUnheardCounts(),
};
}
// ---------------------------------------------------------------------------
// Main UDP socket
// Start Rust proxy engine
// ---------------------------------------------------------------------------
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 = resolveOutboundRoute(appConfig, '');
if (dp) sock.send(data, dp.provider.outboundProxy.port, dp.provider.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 dialedNumber = SipMessage.extractUri(msg.requestUri || '') || '';
const routeResult = resolveOutboundRoute(
appConfig,
dialedNumber,
undefined,
(pid) => !!providerStates.get(pid)?.registeredAor,
);
if (routeResult) {
const provState = providerStates.get(routeResult.provider.id);
if (provState) {
// Apply number transformation to the INVITE if needed.
if (routeResult.transformedNumber !== dialedNumber) {
const newUri = msg.requestUri?.replace(dialedNumber, routeResult.transformedNumber);
if (newUri) msg.setRequestUri(newUri);
}
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, routeResult.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 fallback = resolveOutboundRoute(appConfig, '');
if (fallback) sock.send(msg.serialize(), fallback.provider.outboundProxy.port, fallback.provider.outboundProxy.address);
}
} catch (e: any) {
log(`[err] ${e?.stack || e}`);
async function startProxyEngine(): Promise<void> {
const ok = await initProxyEngine(log);
if (!ok) {
log('[FATAL] failed to start proxy engine');
process.exit(1);
}
});
sock.on('error', (err: Error) => log(`[main] sock err: ${err.message}`));
// Subscribe to events from Rust BEFORE sending configure.
onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => {
const ps = providerStatuses.get(data.provider_id);
if (ps) {
const wasRegistered = ps.registered;
ps.registered = data.registered;
ps.publicIp = data.public_ip;
if (data.registered && !wasRegistered) {
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
} else if (!data.registered && wasRegistered) {
log(`[provider:${data.provider_id}] registration lost`);
}
broadcastWs('registration', { providerId: data.provider_id, registered: data.registered });
}
});
onProxyEvent('device_registered', (data: IDeviceRegisteredEvent) => {
const ds = deviceStatuses.get(data.device_id);
if (ds) {
ds.address = data.address;
ds.port = data.port;
ds.connected = true;
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
}
});
onProxyEvent('incoming_call', (data: IIncomingCallEvent) => {
log(`[call] incoming: ${data.from_uri}${data.to_number} via ${data.provider_id} (${data.call_id})`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'inbound',
callerNumber: data.from_uri,
calleeNumber: data.to_number,
providerUsed: data.provider_id,
state: 'ringing',
startedAt: Date.now(),
});
// Notify browsers of incoming call.
const browserIds = getAllBrowserDeviceIds();
for (const bid of browserIds) {
sendToBrowserDevice(bid, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId: bid,
});
}
});
onProxyEvent('outbound_device_call', (data: IOutboundCallEvent) => {
log(`[call] outbound: device ${data.from_device}${data.to_number} (${data.call_id})`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: data.from_device,
calleeNumber: data.to_number,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
});
});
onProxyEvent('call_ringing', (data: { call_id: string }) => {
const call = activeCalls.get(data.call_id);
if (call) call.state = 'ringing';
});
onProxyEvent('call_answered', (data: { call_id: string }) => {
const call = activeCalls.get(data.call_id);
if (call) {
call.state = 'connected';
log(`[call] ${data.call_id} connected`);
}
});
onProxyEvent('call_ended', (data: ICallEndedEvent) => {
const call = activeCalls.get(data.call_id);
if (call) {
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
// Move to history.
callHistory.unshift({
id: call.id,
direction: call.direction,
callerNumber: call.callerNumber,
calleeNumber: call.calleeNumber,
startedAt: call.startedAt,
duration: data.duration,
});
if (callHistory.length > MAX_HISTORY) callHistory.pop();
activeCalls.delete(data.call_id);
}
});
onProxyEvent('sip_unhandled', (data: any) => {
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
});
// Send full config to Rust — this binds the SIP socket and starts registrations.
const configured = await configureProxyEngine({
proxy: appConfig.proxy,
providers: appConfig.providers,
devices: appConfig.devices,
routing: appConfig.routing,
});
if (!configured) {
log('[FATAL] failed to configure proxy engine');
process.exit(1);
}
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}`);
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | 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 (still needed for WebRTC transcoding).
try {
await initCodecBridge(log);
await initAnnouncement(log);
// Pre-generate prompts.
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
for (const vb of appConfig.voiceboxes ?? []) {
if (!vb.enabled) continue;
const promptId = `voicemail-greeting-${vb.id}`;
if (vb.greetingWavPath) {
await promptCache.loadWavPrompt(promptId, vb.greetingWavPath);
} else {
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
}
}
if (appConfig.ivr?.enabled) {
for (const menu of appConfig.ivr.menus) {
await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella');
}
}
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
} catch (e) {
log(`[codec] init failed: ${e}`);
}
// Initialize audio codec bridge (Rust binary via smartrust).
initCodecBridge(log)
.then(() => initAnnouncement(log))
.then(async () => {
// Pre-generate voicemail beep tone.
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
// Pre-generate voicemail greetings for all configured voiceboxes.
for (const vb of appConfig.voiceboxes ?? []) {
if (!vb.enabled) continue;
const promptId = `voicemail-greeting-${vb.id}`;
const wavPath = vb.greetingWavPath;
if (wavPath) {
await promptCache.loadWavPrompt(promptId, wavPath);
} else {
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
}
}
// Pre-generate IVR menu prompts.
if (appConfig.ivr?.enabled) {
for (const menu of appConfig.ivr.menus) {
const promptId = `ivr-menu-${menu.id}`;
await promptCache.generatePrompt(promptId, menu.promptText, menu.promptVoice || 'af_bella');
}
}
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
})
.catch((e) => log(`[codec] init failed: ${e}`));
});
}
// ---------------------------------------------------------------------------
// Web UI
@@ -299,34 +330,62 @@ sock.bind(LAN_PORT, '0.0.0.0', () => {
initWebUi(
getStatus,
log,
(number, deviceId, providerId) => {
const call = callManager.createOutboundCall(number, deviceId, providerId);
return call ? { id: call.id } : null;
(number, _deviceId, _providerId) => {
// Outbound calls from dashboard — send make_call command to Rust.
// For now, log only. Full implementation needs make_call in Rust.
log(`[dashboard] start call requested: ${number}`);
// TODO: send make_call command when implemented in Rust
return null;
},
(callId) => {
hangupCall(callId);
return true;
},
(callId) => callManager.hangup(callId),
() => {
// Reload config after UI save.
// Config saved — reconfigure Rust engine.
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');
// Update shadow state.
for (const p of fresh.providers) {
if (!providerStatuses.has(p.id)) {
providerStatuses.set(p.id, {
id: p.id, displayName: p.displayName, registered: false, publicIp: null,
});
}
}
for (const d of fresh.devices) {
if (!deviceStatuses.has(d.id)) {
deviceStatuses.set(d.id, {
id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false,
});
}
}
// Re-send config to Rust.
configureProxyEngine({
proxy: fresh.proxy,
providers: fresh.providers,
devices: fresh.devices,
routing: fresh.routing,
}).then((ok) => {
if (ok) log('[config] reloaded — proxy engine reconfigured');
else log('[config] reload failed — proxy engine rejected config');
});
} catch (e: any) {
log(`[config] reload failed: ${e.message}`);
}
},
callManager,
undefined, // callManager — WebRTC calls handled separately in Phase 2
voiceboxManager,
);
process.on('SIGINT', () => { log('SIGINT, exiting'); process.exit(0); });
process.on('SIGTERM', () => { log('SIGTERM, exiting'); process.exit(0); });
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
startProxyEngine();
process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); });
process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });