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.
240 lines
7.0 KiB
TypeScript
240 lines
7.0 KiB
TypeScript
/**
|
|
* Local SIP registrar — accepts REGISTER from devices and browser clients.
|
|
*
|
|
* Devices point their SIP registration at the proxy instead of the upstream
|
|
* provider. The registrar responds with 200 OK and stores the device's
|
|
* current contact (source IP:port). Browser softphones register via
|
|
* WebSocket signaling.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
SipMessage,
|
|
generateTag,
|
|
} from './sip/index.ts';
|
|
|
|
/** Hash a string to a 6-char hex ID. */
|
|
export function shortHash(input: string): string {
|
|
return createHash('sha256').update(input).digest('hex').slice(0, 6);
|
|
}
|
|
import type { IEndpoint } from './sip/index.ts';
|
|
import type { IDeviceConfig } from './config.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface IRegisteredDevice {
|
|
deviceConfig: IDeviceConfig;
|
|
contact: IEndpoint | null;
|
|
registeredAt: number;
|
|
expiresAt: number;
|
|
aor: string;
|
|
connected: boolean;
|
|
isBrowser: boolean;
|
|
}
|
|
|
|
export interface IDeviceStatusEntry {
|
|
id: string;
|
|
displayName: string;
|
|
contact: IEndpoint | null;
|
|
aor: string;
|
|
connected: boolean;
|
|
isBrowser: boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const registeredDevices = new Map<string, IRegisteredDevice>();
|
|
const browserDevices = new Map<string, IRegisteredDevice>();
|
|
let knownDevices: IDeviceConfig[] = [];
|
|
let logFn: (msg: string) => void = () => {};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function initRegistrar(
|
|
devices: IDeviceConfig[],
|
|
log: (msg: string) => void,
|
|
): void {
|
|
knownDevices = devices;
|
|
logFn = log;
|
|
}
|
|
|
|
/**
|
|
* Process a REGISTER from a SIP device. Returns a 200 OK response to send back,
|
|
* or null if this REGISTER should not be handled by the local registrar.
|
|
*/
|
|
export function handleDeviceRegister(
|
|
msg: SipMessage,
|
|
rinfo: IEndpoint,
|
|
): SipMessage | null {
|
|
if (msg.method !== 'REGISTER') return null;
|
|
|
|
const device = knownDevices.find((d) => d.expectedAddress === rinfo.address);
|
|
if (!device) return null;
|
|
|
|
const from = msg.getHeader('From');
|
|
const aor = from ? SipMessage.extractUri(from) || `sip:${device.extension}@${rinfo.address}` : `sip:${device.extension}@${rinfo.address}`;
|
|
|
|
const MAX_EXPIRES = 300;
|
|
const expiresHeader = msg.getHeader('Expires');
|
|
const requested = expiresHeader ? parseInt(expiresHeader, 10) : 3600;
|
|
const expires = Math.min(requested, MAX_EXPIRES);
|
|
|
|
const entry: IRegisteredDevice = {
|
|
deviceConfig: device,
|
|
contact: { address: rinfo.address, port: rinfo.port },
|
|
registeredAt: Date.now(),
|
|
expiresAt: Date.now() + expires * 1000,
|
|
aor,
|
|
connected: true,
|
|
isBrowser: false,
|
|
};
|
|
registeredDevices.set(device.id, entry);
|
|
|
|
logFn(`[registrar] ${device.displayName} (${device.id}) registered from ${rinfo.address}:${rinfo.port} expires=${expires}`);
|
|
|
|
const contact = msg.getHeader('Contact') || `<sip:${rinfo.address}:${rinfo.port}>`;
|
|
const response = SipMessage.createResponse(200, 'OK', msg, {
|
|
toTag: generateTag(),
|
|
contact,
|
|
extraHeaders: [['Expires', String(expires)]],
|
|
});
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Register a browser softphone as a device.
|
|
*/
|
|
export function registerBrowserDevice(sessionId: string, userAgent?: string, remoteIp?: string): void {
|
|
// Extract a short browser name from the UA string.
|
|
let browserName = 'Browser';
|
|
if (userAgent) {
|
|
if (userAgent.includes('Firefox/')) browserName = 'Firefox';
|
|
else if (userAgent.includes('Edg/')) browserName = 'Edge';
|
|
else if (userAgent.includes('Chrome/')) browserName = 'Chrome';
|
|
else if (userAgent.includes('Safari/') && !userAgent.includes('Chrome/')) browserName = 'Safari';
|
|
}
|
|
|
|
const entry: IRegisteredDevice = {
|
|
deviceConfig: {
|
|
id: `browser-${shortHash(sessionId)}`,
|
|
displayName: browserName,
|
|
expectedAddress: remoteIp || '127.0.0.1',
|
|
extension: 'webrtc',
|
|
},
|
|
contact: null,
|
|
registeredAt: Date.now(),
|
|
expiresAt: Date.now() + 60 * 1000, // 60s — browser must re-register to stay alive
|
|
aor: `sip:webrtc@browser`,
|
|
connected: true,
|
|
isBrowser: true,
|
|
};
|
|
browserDevices.set(sessionId, entry);
|
|
}
|
|
|
|
/**
|
|
* Unregister a browser softphone (on WebSocket close).
|
|
*/
|
|
export function unregisterBrowserDevice(sessionId: string): void {
|
|
browserDevices.delete(sessionId);
|
|
}
|
|
|
|
/**
|
|
* Get a registered device by its config ID.
|
|
*/
|
|
export function getRegisteredDevice(deviceId: string): IRegisteredDevice | null {
|
|
const entry = registeredDevices.get(deviceId);
|
|
if (!entry) return null;
|
|
if (Date.now() > entry.expiresAt) {
|
|
registeredDevices.delete(deviceId);
|
|
return null;
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Get a registered device by source IP address.
|
|
*/
|
|
export function getRegisteredDeviceByAddress(address: string): IRegisteredDevice | null {
|
|
for (const entry of registeredDevices.values()) {
|
|
if (entry.contact?.address === address && Date.now() <= entry.expiresAt) {
|
|
return entry;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check whether an address belongs to a known device (by config expectedAddress).
|
|
*/
|
|
export function isKnownDeviceAddress(address: string): boolean {
|
|
return knownDevices.some((d) => d.expectedAddress === address);
|
|
}
|
|
|
|
/**
|
|
* Get all devices for the dashboard.
|
|
* - Configured devices always show (connected or not).
|
|
* - Browser devices only show while connected.
|
|
*/
|
|
export function getAllDeviceStatuses(): IDeviceStatusEntry[] {
|
|
const now = Date.now();
|
|
const result: IDeviceStatusEntry[] = [];
|
|
|
|
// Configured devices — always show.
|
|
for (const dc of knownDevices) {
|
|
const reg = registeredDevices.get(dc.id);
|
|
const connected = reg ? now <= reg.expiresAt : false;
|
|
if (reg && now > reg.expiresAt) {
|
|
registeredDevices.delete(dc.id);
|
|
}
|
|
result.push({
|
|
id: dc.id,
|
|
displayName: dc.displayName,
|
|
contact: connected && reg ? reg.contact : null,
|
|
aor: reg?.aor || `sip:${dc.extension}@${dc.expectedAddress}`,
|
|
connected,
|
|
isBrowser: false,
|
|
});
|
|
}
|
|
|
|
// Browser devices — only while connected.
|
|
for (const [, entry] of browserDevices) {
|
|
const ip = entry.deviceConfig.expectedAddress;
|
|
result.push({
|
|
id: entry.deviceConfig.id,
|
|
displayName: entry.deviceConfig.displayName,
|
|
contact: ip && ip !== '127.0.0.1' ? { address: ip, port: 0 } : null,
|
|
aor: entry.aor,
|
|
connected: true,
|
|
isBrowser: true,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get all currently registered (connected) SIP devices.
|
|
*/
|
|
export function getAllRegisteredDevices(): IRegisteredDevice[] {
|
|
const now = Date.now();
|
|
const result: IRegisteredDevice[] = [];
|
|
for (const [id, entry] of registeredDevices) {
|
|
if (now > entry.expiresAt) {
|
|
registeredDevices.delete(id);
|
|
} else {
|
|
result.push(entry);
|
|
}
|
|
}
|
|
for (const [, entry] of browserDevices) {
|
|
result.push(entry);
|
|
}
|
|
return result;
|
|
}
|