initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
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.
This commit is contained in:
239
ts/registrar.ts
Normal file
239
ts/registrar.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user