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.
This commit is contained in:
306
ts/providerstate.ts
Normal file
306
ts/providerstate.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user