/** * 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(); const browserDevices = new Map(); 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') || ``; 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; }