Files
siprouter/ts/providerstate.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

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