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.
307 lines
9.8 KiB
TypeScript
307 lines
9.8 KiB
TypeScript
/**
|
|
* 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<typeof setInterval> | 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: `<sip:${config.username}@${pub}:${lanPort}>`,
|
|
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: `<sip:${this.config.username}@${viaHost}:${viaPort}>`,
|
|
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<string, ProviderState>;
|
|
|
|
export function initProviderStates(
|
|
providers: IProviderConfig[],
|
|
publicIpSeed: string | null,
|
|
): Map<string, ProviderState> {
|
|
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;
|
|
}
|