/** * Per-provider runtime state and upstream registration. * * Each configured provider gets its own ProviderState instance tracking * registration status, public IP, and the periodic REGISTER cycle. */ import { Buffer } from 'node:buffer'; import { SipMessage, generateCallId, generateTag, generateBranch, parseDigestChallenge, computeDigestAuth, } from './sip/index.ts'; import type { IEndpoint } from './sip/index.ts'; import type { IProviderConfig } from './config.ts'; // --------------------------------------------------------------------------- // Provider state // --------------------------------------------------------------------------- export class ProviderState { readonly config: IProviderConfig; publicIp: string | null; isRegistered = false; registeredAor: string; // Registration transaction state. private regCallId: string; private regCSeq = 0; private regFromTag: string; private regTimer: ReturnType | null = null; private sendSip: ((buf: Buffer, dest: IEndpoint) => void) | null = null; private logFn: ((msg: string) => void) | null = null; private onRegistrationChange: ((provider: ProviderState) => void) | null = null; constructor(config: IProviderConfig, publicIpSeed: string | null) { this.config = config; this.publicIp = publicIpSeed; this.registeredAor = `sip:${config.username}@${config.domain}`; this.regCallId = generateCallId(); this.regFromTag = generateTag(); } private log(msg: string): void { this.logFn?.(`[provider:${this.config.id}] ${msg}`); } // ------------------------------------------------------------------------- // Upstream registration // ------------------------------------------------------------------------- /** * Start the periodic REGISTER cycle with this provider. */ startRegistration( lanIp: string, lanPort: number, sendSip: (buf: Buffer, dest: IEndpoint) => void, log: (msg: string) => void, onRegistrationChange: (provider: ProviderState) => void, ): void { this.sendSip = sendSip; this.logFn = log; this.onRegistrationChange = onRegistrationChange; // Initial registration. this.sendRegister(lanIp, lanPort); // Re-register periodically. const intervalMs = (this.config.registerIntervalSec * 0.85) * 1000; this.regTimer = setInterval(() => this.sendRegister(lanIp, lanPort), intervalMs); } stopRegistration(): void { if (this.regTimer) { clearInterval(this.regTimer); this.regTimer = null; } } private sendRegister(lanIp: string, lanPort: number): void { this.regCSeq++; const pub = this.publicIp || lanIp; const { config } = this; const register = SipMessage.createRequest('REGISTER', `sip:${config.domain}`, { via: { host: pub, port: lanPort }, from: { uri: this.registeredAor, tag: this.regFromTag }, to: { uri: this.registeredAor }, callId: this.regCallId, cseq: this.regCSeq, contact: ``, maxForwards: 70, extraHeaders: [ ['Expires', String(config.registerIntervalSec)], ['User-Agent', 'SipRouter/1.0'], ['Allow', 'INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE'], ], }); this.log(`REGISTER -> ${config.outboundProxy.address}:${config.outboundProxy.port} (CSeq ${this.regCSeq})`); this.sendSip!(register.serialize(), config.outboundProxy); } /** * Handle an incoming SIP response that belongs to this provider's registration. * Returns true if the message was consumed. */ handleRegistrationResponse(msg: SipMessage): boolean { if (!msg.isResponse) return false; if (msg.callId !== this.regCallId) return false; if (msg.cseqMethod?.toUpperCase() !== 'REGISTER') return false; const code = msg.statusCode ?? 0; this.log(`REGISTER <- ${code}`); if (code === 200) { const wasRegistered = this.isRegistered; this.isRegistered = true; if (!wasRegistered) { this.log('registered'); this.onRegistrationChange?.(this); } return true; } if (code === 401 || code === 407) { const challengeHeader = code === 401 ? msg.getHeader('WWW-Authenticate') : msg.getHeader('Proxy-Authenticate'); if (!challengeHeader) { this.log(`${code} but no challenge header`); return true; } const challenge = parseDigestChallenge(challengeHeader); if (!challenge) { this.log(`${code} could not parse digest challenge`); return true; } const authValue = computeDigestAuth({ username: this.config.username, password: this.config.password, realm: challenge.realm, nonce: challenge.nonce, method: 'REGISTER', uri: `sip:${this.config.domain}`, algorithm: challenge.algorithm, opaque: challenge.opaque, }); // Resend REGISTER with auth. this.regCSeq++; const pub = this.publicIp || 'unknown'; // We need lanIp/lanPort but don't have them here — reconstruct from Via. const via = msg.getHeader('Via') || ''; const viaHost = via.match(/SIP\/2\.0\/UDP\s+([^;:]+)/)?.[1] || pub; const viaPort = parseInt(via.match(/:(\d+)/)?.[1] || '5070', 10); const register = SipMessage.createRequest('REGISTER', `sip:${this.config.domain}`, { via: { host: viaHost, port: viaPort }, from: { uri: this.registeredAor, tag: this.regFromTag }, to: { uri: this.registeredAor }, callId: this.regCallId, cseq: this.regCSeq, contact: ``, maxForwards: 70, extraHeaders: [ [code === 401 ? 'Authorization' : 'Proxy-Authorization', authValue], ['Expires', String(this.config.registerIntervalSec)], ['User-Agent', 'SipRouter/1.0'], ['Allow', 'INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE'], ], }); this.log(`REGISTER -> (with auth, CSeq ${this.regCSeq})`); this.sendSip!(register.serialize(), this.config.outboundProxy); return true; } if (code >= 400) { const wasRegistered = this.isRegistered; this.isRegistered = false; if (wasRegistered) { this.log(`registration lost (${code})`); this.onRegistrationChange?.(this); } return true; } return true; // consume 1xx etc. } /** * Update public IP from Via received= parameter. */ detectPublicIp(via: string): void { const m = via.match(/received=([\d.]+)/); if (m && m[1] !== this.publicIp) { this.log(`publicIp = ${m[1]}`); this.publicIp = m[1]; } } } // --------------------------------------------------------------------------- // Provider state management // --------------------------------------------------------------------------- let providerStates: Map; export function initProviderStates( providers: IProviderConfig[], publicIpSeed: string | null, ): Map { providerStates = new Map(); for (const p of providers) { providerStates.set(p.id, new ProviderState(p, publicIpSeed)); } return providerStates; } export function getProviderState(id: string): ProviderState | null { return providerStates?.get(id) ?? null; } /** * Sync running provider states with updated config. * - New providers: create state + start registration. * - Removed providers: stop registration + delete state. * - Changed providers: stop old, create new, start registration (preserves detected publicIp). */ export function syncProviderStates( newProviders: IProviderConfig[], publicIpSeed: string | null, lanIp: string, lanPort: number, sendSip: (buf: Buffer, dest: IEndpoint) => void, log: (msg: string) => void, onRegistrationChange: (provider: ProviderState) => void, ): void { if (!providerStates) return; const newIds = new Set(newProviders.map(p => p.id)); const oldIds = new Set(providerStates.keys()); // Remove providers no longer in config. for (const id of oldIds) { if (!newIds.has(id)) { const ps = providerStates.get(id)!; ps.stopRegistration(); providerStates.delete(id); log(`[provider:${id}] removed`); } } for (const p of newProviders) { if (!oldIds.has(p.id)) { // New provider. const ps = new ProviderState(p, publicIpSeed); providerStates.set(p.id, ps); ps.startRegistration(lanIp, lanPort, sendSip, log, onRegistrationChange); log(`[provider:${p.id}] added — registration started`); } else { // Existing provider — check if config changed. const existing = providerStates.get(p.id)!; if (JSON.stringify(existing.config) !== JSON.stringify(p)) { existing.stopRegistration(); const ps = new ProviderState(p, existing.publicIp || publicIpSeed); providerStates.set(p.id, ps); ps.startRegistration(lanIp, lanPort, sendSip, log, onRegistrationChange); log(`[provider:${p.id}] config changed — re-registering`); } } } } /** * Find which provider sent a packet, by matching the source address * against all providers' outbound proxy addresses. */ export function getProviderByUpstreamAddress(address: string, port: number): ProviderState | null { if (!providerStates) return null; for (const ps of providerStates.values()) { if (ps.config.outboundProxy.address === address && ps.config.outboundProxy.port === port) { return ps; } } return null; } /** * Check whether a response belongs to any provider's registration transaction. */ export function handleProviderRegistrationResponse(msg: SipMessage): boolean { if (!providerStates || !msg.isResponse) return false; for (const ps of providerStates.values()) { if (ps.handleRegistrationResponse(msg)) return true; } return false; }