Files
siprouter/ts/registrar.ts
Juergen Kunz f3e1c96872 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.
2026-04-09 23:03:55 +00:00

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;
}