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:
2026-04-09 23:03:55 +00:00
commit f3e1c96872
59 changed files with 18377 additions and 0 deletions

8
ts/00_commitinfo_data.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: 'siprouter',
version: '1.8.0',
description: 'undefined'
}

261
ts/announcement.ts Normal file
View File

@@ -0,0 +1,261 @@
/**
* TTS announcement module — pre-generates audio announcements using Kokoro TTS
* and caches them as encoded RTP packets for playback during call setup.
*
* On startup, generates the announcement WAV via the Rust tts-engine binary
* (Kokoro neural TTS), encodes each 20ms frame to G.722 (for SIP) and Opus
* (for WebRTC) via the Rust transcoder, and caches the packets.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { buildRtpHeader, rtpClockIncrement } from './call/leg.ts';
import { encodePcm, isCodecReady } from './opusbridge.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A pre-encoded announcement ready for RTP playback. */
export interface IAnnouncementCache {
/** G.722 encoded frames (each is a 20ms frame payload, no RTP header). */
g722Frames: Buffer[];
/** Opus encoded frames for WebRTC playback. */
opusFrames: Buffer[];
/** Total duration in milliseconds. */
durationMs: number;
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let cachedAnnouncement: IAnnouncementCache | null = null;
const TTS_DIR = path.join(process.cwd(), '.nogit', 'tts');
const KOKORO_MODEL = 'kokoro-v1.0.onnx';
const KOKORO_VOICES = 'voices.bin';
const KOKORO_VOICE = 'af_bella'; // American female, clear and natural
const ANNOUNCEMENT_TEXT = "Hello. I'm connecting your call now.";
const CACHE_WAV = path.join(TTS_DIR, 'announcement.wav');
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
/**
* Pre-generate the announcement audio and encode to G.722 frames.
* Must be called after the codec bridge is initialized.
*/
export async function initAnnouncement(log: (msg: string) => void): Promise<boolean> {
const modelPath = path.join(TTS_DIR, KOKORO_MODEL);
const voicesPath = path.join(TTS_DIR, KOKORO_VOICES);
// Check if Kokoro model files exist.
if (!fs.existsSync(modelPath)) {
log('[tts] Kokoro model not found at ' + modelPath + ' — announcements disabled');
return false;
}
if (!fs.existsSync(voicesPath)) {
log('[tts] Kokoro voices not found at ' + voicesPath + ' — announcements disabled');
return false;
}
// Find tts-engine binary.
const root = process.cwd();
const ttsBinPaths = [
path.join(root, 'dist_rust', 'tts-engine'),
path.join(root, 'rust', 'target', 'release', 'tts-engine'),
path.join(root, 'rust', 'target', 'debug', 'tts-engine'),
];
const ttsBin = ttsBinPaths.find((p) => fs.existsSync(p));
if (!ttsBin) {
log('[tts] tts-engine binary not found — announcements disabled');
return false;
}
try {
// Generate WAV if not cached.
if (!fs.existsSync(CACHE_WAV)) {
log('[tts] generating announcement audio via Kokoro TTS...');
execSync(
`"${ttsBin}" --model "${modelPath}" --voices "${voicesPath}" --voice "${KOKORO_VOICE}" --output "${CACHE_WAV}" --text "${ANNOUNCEMENT_TEXT}"`,
{ timeout: 120000, stdio: 'pipe' },
);
log('[tts] announcement WAV generated');
}
// Read WAV and extract raw PCM.
const wav = fs.readFileSync(CACHE_WAV);
const pcm = extractPcmFromWav(wav);
if (!pcm) {
log('[tts] failed to parse WAV file');
return false;
}
// Wait for codec bridge to be ready.
if (!isCodecReady()) {
log('[tts] codec bridge not ready — will retry');
return false;
}
// Kokoro outputs 24000 Hz, 16-bit mono.
// We encode in chunks: 20ms at 24000 Hz = 480 samples = 960 bytes of PCM.
// The Rust encoder will resample to 16kHz internally for G.722.
const SAMPLE_RATE = 24000;
const FRAME_SAMPLES = Math.floor(SAMPLE_RATE * 0.02); // 480 samples per 20ms
const FRAME_BYTES = FRAME_SAMPLES * 2; // 16-bit = 2 bytes per sample
const totalFrames = Math.floor(pcm.length / FRAME_BYTES);
const g722Frames: Buffer[] = [];
const opusFrames: Buffer[] = [];
log(`[tts] encoding ${totalFrames} frames (${FRAME_SAMPLES} samples/frame @ ${SAMPLE_RATE}Hz)...`);
for (let i = 0; i < totalFrames; i++) {
const framePcm = pcm.subarray(i * FRAME_BYTES, (i + 1) * FRAME_BYTES);
const pcmBuf = Buffer.from(framePcm);
const [g722, opus] = await Promise.all([
encodePcm(pcmBuf, SAMPLE_RATE, 9), // G.722 for SIP devices
encodePcm(pcmBuf, SAMPLE_RATE, 111), // Opus for WebRTC browsers
]);
if (g722) g722Frames.push(g722);
if (opus) opusFrames.push(opus);
if (!g722 && !opus && i < 3) log(`[tts] frame ${i} encode failed`);
}
cachedAnnouncement = {
g722Frames,
opusFrames,
durationMs: totalFrames * 20,
};
log(`[tts] announcement cached: ${g722Frames.length} frames (${(totalFrames * 20 / 1000).toFixed(1)}s)`);
return true;
} catch (e: any) {
log(`[tts] init error: ${e.message}`);
return false;
}
}
// ---------------------------------------------------------------------------
// Playback
// ---------------------------------------------------------------------------
/**
* Play the pre-cached announcement to an RTP endpoint.
*
* @param sendPacket - function to send a raw RTP packet
* @param ssrc - SSRC to use in RTP headers
* @param onDone - called when the announcement finishes
* @returns a cancel function, or null if no announcement is cached
*/
export function playAnnouncement(
sendPacket: (pkt: Buffer) => void,
ssrc: number,
onDone?: () => void,
): (() => void) | null {
if (!cachedAnnouncement || cachedAnnouncement.g722Frames.length === 0) {
onDone?.();
return null;
}
const frames = cachedAnnouncement.g722Frames;
const PT = 9; // G.722
let frameIdx = 0;
let seq = Math.floor(Math.random() * 0xffff);
let rtpTs = Math.floor(Math.random() * 0xffffffff);
const timer = setInterval(() => {
if (frameIdx >= frames.length) {
clearInterval(timer);
onDone?.();
return;
}
const payload = frames[frameIdx];
const hdr = buildRtpHeader(PT, seq & 0xffff, rtpTs >>> 0, ssrc >>> 0, frameIdx === 0);
const pkt = Buffer.concat([hdr, payload]);
sendPacket(pkt);
seq++;
rtpTs += rtpClockIncrement(PT);
frameIdx++;
}, 20);
// Return cancel function.
return () => clearInterval(timer);
}
/**
* Play pre-cached Opus announcement to a WebRTC PeerConnection sender.
*
* @param sendRtpPacket - function to send a raw RTP packet via sender.sendRtp()
* @param ssrc - SSRC to use in RTP headers
* @param onDone - called when announcement finishes
* @returns cancel function, or null if no announcement cached
*/
export function playAnnouncementToWebRtc(
sendRtpPacket: (pkt: Buffer) => void,
ssrc: number,
counters: { seq: number; ts: number },
onDone?: () => void,
): (() => void) | null {
if (!cachedAnnouncement || cachedAnnouncement.opusFrames.length === 0) {
onDone?.();
return null;
}
const frames = cachedAnnouncement.opusFrames;
const PT = 111; // Opus
let frameIdx = 0;
const timer = setInterval(() => {
if (frameIdx >= frames.length) {
clearInterval(timer);
onDone?.();
return;
}
const payload = frames[frameIdx];
const hdr = buildRtpHeader(PT, counters.seq & 0xffff, counters.ts >>> 0, ssrc >>> 0, frameIdx === 0);
const pkt = Buffer.concat([hdr, payload]);
sendRtpPacket(pkt);
counters.seq++;
counters.ts += 960; // Opus at 48kHz: 960 samples per 20ms
frameIdx++;
}, 20);
return () => clearInterval(timer);
}
/** Check if an announcement is cached and ready. */
export function isAnnouncementReady(): boolean {
return cachedAnnouncement !== null && cachedAnnouncement.g722Frames.length > 0;
}
// ---------------------------------------------------------------------------
// WAV parsing
// ---------------------------------------------------------------------------
function extractPcmFromWav(wav: Buffer): Buffer | null {
// Minimal WAV parser — find the "data" chunk.
if (wav.length < 44) return null;
if (wav.toString('ascii', 0, 4) !== 'RIFF') return null;
if (wav.toString('ascii', 8, 12) !== 'WAVE') return null;
let offset = 12;
while (offset < wav.length - 8) {
const chunkId = wav.toString('ascii', offset, offset + 4);
const chunkSize = wav.readUInt32LE(offset + 4);
if (chunkId === 'data') {
return wav.subarray(offset + 8, offset + 8 + chunkSize);
}
offset += 8 + chunkSize;
// Word-align.
if (offset % 2 !== 0) offset++;
}
return null;
}

1139
ts/call/call-manager.ts Normal file

File diff suppressed because it is too large Load Diff

255
ts/call/call.ts Normal file
View File

@@ -0,0 +1,255 @@
/**
* Call — the hub entity in the hub model.
*
* A Call owns N legs and bridges their media. For 2-party calls, RTP packets
* from leg A are forwarded to leg B and vice versa. For N>2 party calls,
* packets from each leg are forwarded to all other legs (fan-out).
*
* Transcoding is applied per-leg when codecs differ.
*/
import { Buffer } from 'node:buffer';
import type { ILeg } from './leg.ts';
import type { TCallState, TCallDirection, ICallStatus } from './types.ts';
import { RtpPortPool } from './rtp-port-pool.ts';
import type { SipLeg } from './sip-leg.ts';
export class Call {
readonly id: string;
state: TCallState = 'setting-up';
direction: TCallDirection;
readonly createdAt: number;
callerNumber: string | null = null;
calleeNumber: string | null = null;
providerUsed: string | null = null;
/** All legs in this call. */
private legs = new Map<string, ILeg>();
/** Codec payload type for the "native" audio in the call (usually the first SIP leg's codec). */
private nativeCodec: number | null = null;
/** Port pool reference for cleanup. */
private portPool: RtpPortPool;
private log: (msg: string) => void;
private onChange: ((call: Call) => void) | null = null;
constructor(options: {
id: string;
direction: TCallDirection;
portPool: RtpPortPool;
log: (msg: string) => void;
onChange?: (call: Call) => void;
}) {
this.id = options.id;
this.direction = options.direction;
this.createdAt = Date.now();
this.portPool = options.portPool;
this.log = options.log;
this.onChange = options.onChange ?? null;
}
// -------------------------------------------------------------------------
// Leg management
// -------------------------------------------------------------------------
/** Add a leg to this call and wire up media forwarding. */
addLeg(leg: ILeg): void {
this.legs.set(leg.id, leg);
// Wire up RTP forwarding: when this leg receives a packet, forward to all other legs.
leg.onRtpReceived = (data: Buffer) => {
this.forwardRtp(leg.id, data);
};
this.log(`[call:${this.id}] added leg ${leg.id} (${leg.type}), total=${this.legs.size}`);
this.updateState();
}
/** Remove a leg from this call, tear it down, and release its port. */
removeLeg(legId: string): void {
const leg = this.legs.get(legId);
if (!leg) return;
leg.onRtpReceived = null;
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
this.legs.delete(legId);
this.log(`[call:${this.id}] removed leg ${legId}, total=${this.legs.size}`);
this.updateState();
}
getLeg(legId: string): ILeg | null {
return this.legs.get(legId) ?? null;
}
getLegs(): ILeg[] {
return [...this.legs.values()];
}
getLegByType(type: string): ILeg | null {
for (const leg of this.legs.values()) {
if (leg.type === type) return leg;
}
return null;
}
getLegBySipCallId(sipCallId: string): ILeg | null {
for (const leg of this.legs.values()) {
if (leg.sipCallId === sipCallId) return leg;
}
return null;
}
get legCount(): number {
return this.legs.size;
}
// -------------------------------------------------------------------------
// Media forwarding (the hub)
// -------------------------------------------------------------------------
private forwardRtp(fromLegId: string, data: Buffer): void {
for (const [id, leg] of this.legs) {
if (id === fromLegId) continue;
if (leg.state !== 'connected') continue;
// For WebRTC legs, sendRtp calls forwardToBrowser which handles transcoding internally.
// For SIP legs, forward the raw packet (same codec path) or let the leg handle it.
// The Call hub does NOT transcode — that's the leg's responsibility.
leg.sendRtp(data);
}
}
// -------------------------------------------------------------------------
// State management
// -------------------------------------------------------------------------
private updateState(): void {
if (this.state === 'terminated' || this.state === 'terminating') return;
const legs = [...this.legs.values()];
if (legs.length === 0) {
this.state = 'terminated';
} else if (legs.every((l) => l.state === 'terminated')) {
this.state = 'terminated';
} else if (legs.some((l) => l.state === 'connected') && legs.filter((l) => l.state !== 'terminated').length >= 2) {
this.state = 'connected';
} else if (legs.some((l) => l.state === 'ringing')) {
this.state = 'ringing';
} else {
this.state = 'setting-up';
}
this.onChange?.(this);
}
/** Notify the call that a leg's state has changed. */
notifyLegStateChange(_leg: ILeg): void {
this.updateState();
}
// -------------------------------------------------------------------------
// Hangup
// -------------------------------------------------------------------------
/** Tear down all legs and terminate the call. */
hangup(): void {
if (this.state === 'terminated' || this.state === 'terminating') return;
this.state = 'terminating';
this.log(`[call:${this.id}] hanging up (${this.legs.size} legs)`);
for (const [id, leg] of this.legs) {
// Send BYE/CANCEL for SIP legs.
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
(leg as SipLeg).sendHangup();
}
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
}
this.legs.clear();
this.state = 'terminated';
this.onChange?.(this);
}
/**
* Handle a BYE from one leg — tear down the other legs.
* Called by CallManager when a SipLeg receives a BYE.
*/
handleLegTerminated(terminatedLegId: string): void {
const terminatedLeg = this.legs.get(terminatedLegId);
if (!terminatedLeg) return;
// Remove the terminated leg.
terminatedLeg.onRtpReceived = null;
if (terminatedLeg.rtpPort) {
this.portPool.release(terminatedLeg.rtpPort);
}
this.legs.delete(terminatedLegId);
// If this is a 2-party call, hang up the other leg too.
if (this.legs.size <= 1) {
for (const [id, leg] of this.legs) {
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
(leg as SipLeg).sendHangup();
}
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
}
this.legs.clear();
this.state = 'terminated';
this.log(`[call:${this.id}] terminated`);
this.onChange?.(this);
} else {
this.log(`[call:${this.id}] leg ${terminatedLegId} removed, ${this.legs.size} remaining`);
this.updateState();
}
}
// -------------------------------------------------------------------------
// Transfer
// -------------------------------------------------------------------------
/**
* Detach a leg from this call (without tearing it down).
* The leg can then be added to another call.
*/
detachLeg(legId: string): ILeg | null {
const leg = this.legs.get(legId);
if (!leg) return null;
leg.onRtpReceived = null;
this.legs.delete(legId);
this.log(`[call:${this.id}] detached leg ${legId}`);
this.updateState();
return leg;
}
// -------------------------------------------------------------------------
// Status
// -------------------------------------------------------------------------
getStatus(): ICallStatus {
return {
id: this.id,
state: this.state,
direction: this.direction,
callerNumber: this.callerNumber,
calleeNumber: this.calleeNumber,
providerUsed: this.providerUsed,
createdAt: this.createdAt,
duration: Math.floor((Date.now() - this.createdAt) / 1000),
legs: [...this.legs.values()].map((l) => l.getStatus()),
};
}
}

12
ts/call/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export type { TCallState, TLegState, TLegType, TCallDirection, ICallStatus, ILegStatus, ICallHistoryEntry } from './types.ts';
export type { ILeg } from './leg.ts';
export { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts';
export { RtpPortPool } from './rtp-port-pool.ts';
export type { IRtpAllocation } from './rtp-port-pool.ts';
export { SipLeg } from './sip-leg.ts';
export type { ISipLegConfig } from './sip-leg.ts';
export { WebRtcLeg } from './webrtc-leg.ts';
export type { IWebRtcLegConfig } from './webrtc-leg.ts';
export { Call } from './call.ts';
export { CallManager } from './call-manager.ts';
export type { ICallManagerConfig } from './call-manager.ts';

104
ts/call/leg.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* ILeg interface — abstract connection from a Call hub to an endpoint.
*
* Concrete implementations: SipLeg (SIP devices + providers) and WebRtcLeg (browsers).
* Shared RTP utilities (header building, clock rates) are also defined here.
*/
import { Buffer } from 'node:buffer';
import type dgram from 'node:dgram';
import type { IEndpoint } from '../sip/index.ts';
import type { TLegState, TLegType, ILegStatus } from './types.ts';
import type { IRtpTranscoder } from '../codec.ts';
import type { SipDialog } from '../sip/index.ts';
import type { SipMessage } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// ILeg interface
// ---------------------------------------------------------------------------
export interface ILeg {
readonly id: string;
readonly type: TLegType;
state: TLegState;
/** The SIP Call-ID used by this leg (for CallManager routing). */
readonly sipCallId: string;
/** Where this leg sends/receives RTP. */
readonly rtpPort: number | null;
readonly rtpSock: dgram.Socket | null;
remoteMedia: IEndpoint | null;
/** Negotiated codec payload type (e.g. 9 = G.722, 111 = Opus). */
codec: number | null;
/** Transcoder for converting to this leg's codec (set by Call when codecs differ). */
transcoder: IRtpTranscoder | null;
/** Packet counters. */
pktSent: number;
pktReceived: number;
/** SIP dialog (SipLegs only, null for WebRtcLegs). */
readonly dialog: SipDialog | null;
/**
* Send an RTP packet toward this leg's remote endpoint.
* If a transcoder is set, the Call should transcode before calling this.
*/
sendRtp(data: Buffer): void;
/**
* Callback set by the owning Call — invoked when this leg receives an RTP packet.
* The Call uses this to forward to other legs.
*/
onRtpReceived: ((data: Buffer) => void) | null;
/**
* Handle an incoming SIP message routed to this leg (SipLegs only).
* Returns a SipMessage response if one needs to be sent, or null.
*/
handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void;
/** Release all resources (sockets, peer connections, etc.). */
teardown(): void;
/** Status snapshot for the dashboard. */
getStatus(): ILegStatus;
}
// ---------------------------------------------------------------------------
// Shared RTP utilities
// ---------------------------------------------------------------------------
/** RTP clock increment per 20ms frame for each codec. */
export function rtpClockIncrement(pt: number): number {
if (pt === 111) return 960; // Opus: 48000 Hz x 0.02s
if (pt === 9) return 160; // G.722: 8000 Hz x 0.02s (SDP clock rate quirk)
return 160; // PCMU/PCMA: 8000 Hz x 0.02s
}
/** Build a fresh RTP header with correct PT, timestamp, seq, SSRC. */
export function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
const hdr = Buffer.alloc(12);
hdr[0] = 0x80; // V=2
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
hdr.writeUInt16BE(seq & 0xffff, 2);
hdr.writeUInt32BE(ts >>> 0, 4);
hdr.writeUInt32BE(ssrc >>> 0, 8);
return hdr;
}
/** Codec name for status display. */
export function codecDisplayName(pt: number | null): string | null {
if (pt === null) return null;
switch (pt) {
case 0: return 'PCMU';
case 8: return 'PCMA';
case 9: return 'G.722';
case 111: return 'Opus';
case 101: return 'telephone-event';
default: return `PT${pt}`;
}
}

71
ts/call/rtp-port-pool.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Unified RTP port pool — replaces the three separate allocators
* in sipproxy.ts, calloriginator.ts, and webrtcbridge.ts.
*
* Allocates even-numbered UDP ports from a configured range.
* Each allocation binds a dgram socket and returns it ready to use.
*/
import dgram from 'node:dgram';
export interface IRtpAllocation {
port: number;
sock: dgram.Socket;
}
export class RtpPortPool {
private min: number;
private max: number;
private allocated = new Map<number, dgram.Socket>();
private log: (msg: string) => void;
constructor(min: number, max: number, log: (msg: string) => void) {
this.min = min % 2 === 0 ? min : min + 1; // ensure even start
this.max = max;
this.log = log;
}
/**
* Allocate an even-numbered port and bind a UDP socket to it.
* Returns null if the pool is exhausted.
*/
allocate(): IRtpAllocation | null {
for (let port = this.min; port < this.max; port += 2) {
if (this.allocated.has(port)) continue;
const sock = dgram.createSocket('udp4');
try {
sock.bind(port, '0.0.0.0');
} catch {
try { sock.close(); } catch { /* ignore */ }
continue;
}
this.allocated.set(port, sock);
this.log(`[rtp-pool] allocated port ${port} (${this.allocated.size} in use)`);
return { port, sock };
}
this.log('[rtp-pool] WARN: port pool exhausted');
return null;
}
/**
* Release a port back to the pool and close its socket.
*/
release(port: number): void {
const sock = this.allocated.get(port);
if (!sock) return;
try { sock.close(); } catch { /* ignore */ }
this.allocated.delete(port);
this.log(`[rtp-pool] released port ${port} (${this.allocated.size} in use)`);
}
/** Number of currently allocated ports. */
get size(): number {
return this.allocated.size;
}
/** Total capacity (number of even ports in range). */
get capacity(): number {
return Math.floor((this.max - this.min) / 2);
}
}

622
ts/call/sip-leg.ts Normal file
View File

@@ -0,0 +1,622 @@
/**
* SipLeg — a SIP connection from the Call hub to a device or provider.
*
* Wraps a SipDialog and an RTP socket. Handles:
* - INVITE/ACK/BYE/CANCEL lifecycle
* - SDP rewriting (LAN IP for devices, public IP for providers)
* - Digest auth for provider legs (407/401)
* - Early-media silence for providers with quirks
* - Record-Route insertion for dialog-establishing requests
*/
import dgram from 'node:dgram';
import { Buffer } from 'node:buffer';
import {
SipMessage,
SipDialog,
buildSdp,
parseSdpEndpoint,
rewriteSdp,
rewriteSipUri,
parseDigestChallenge,
computeDigestAuth,
generateTag,
} from '../sip/index.ts';
import type { IEndpoint } from '../sip/index.ts';
import type { IProviderConfig, IQuirks } from '../config.ts';
import type { TLegState, TLegType, ILegStatus } from './types.ts';
import type { ILeg } from './leg.ts';
import { codecDisplayName } from './leg.ts';
import type { IRtpTranscoder } from '../codec.ts';
// ---------------------------------------------------------------------------
// SipLeg config
// ---------------------------------------------------------------------------
export interface ISipLegConfig {
/** Whether this leg faces a device (LAN) or a provider (WAN). */
role: 'device' | 'provider';
/** Proxy LAN IP (for SDP rewriting toward devices). */
lanIp: string;
/** Proxy LAN port (for Via, Contact, Record-Route). */
lanPort: number;
/** Public IP (for SDP rewriting toward providers). */
getPublicIp: () => string | null;
/** Send a SIP message via the main UDP socket. */
sendSip: (buf: Buffer, dest: IEndpoint) => void;
/** Logging function. */
log: (msg: string) => void;
/** Provider config (for provider legs: auth, codecs, quirks, outbound proxy). */
provider?: IProviderConfig;
/** The endpoint to send SIP messages to (device address or provider outbound proxy). */
sipTarget: IEndpoint;
/** RTP port and socket (pre-allocated from the pool). */
rtpPort: number;
rtpSock: dgram.Socket;
/** Payload types to offer in SDP. */
payloadTypes?: number[];
/** Registered AOR (for From header in provider leg). */
getRegisteredAor?: () => string | null;
/** SIP password (for digest auth). */
getSipPassword?: () => string | null;
}
// ---------------------------------------------------------------------------
// SipLeg
// ---------------------------------------------------------------------------
export class SipLeg implements ILeg {
readonly id: string;
readonly type: TLegType;
state: TLegState = 'inviting';
readonly config: ISipLegConfig;
/** The SIP dialog for this leg. */
dialog: SipDialog | null = null;
/** Original INVITE (needed for CANCEL). */
invite: SipMessage | null = null;
/** Original unauthenticated INVITE (for re-ACKing retransmitted 407s). */
private origInvite: SipMessage | null = null;
/** Whether we've attempted digest auth on this leg. */
private authAttempted = false;
/** RTP socket and port. */
readonly rtpPort: number;
readonly rtpSock: dgram.Socket;
/** Remote media endpoint (learned from SDP). */
remoteMedia: IEndpoint | null = null;
/** Negotiated codec. */
codec: number | null = null;
/** Transcoder (set by Call when codecs differ between legs). */
transcoder: IRtpTranscoder | null = null;
/** Stable SSRC for this leg (used for silence + forwarded audio). */
readonly ssrc: number = (Math.random() * 0xffffffff) >>> 0;
/** Packet counters. */
pktSent = 0;
pktReceived = 0;
/** Callback set by Call to receive RTP. */
onRtpReceived: ((data: Buffer) => void) | null = null;
/** Silence stream timer (for provider quirks). */
private silenceTimer: ReturnType<typeof setInterval> | null = null;
/** Callbacks for lifecycle events. */
onStateChange: ((leg: SipLeg) => void) | null = null;
onConnected: ((leg: SipLeg) => void) | null = null;
onTerminated: ((leg: SipLeg) => void) | null = null;
constructor(id: string, config: ISipLegConfig) {
this.id = id;
this.type = config.role === 'device' ? 'sip-device' : 'sip-provider';
this.config = config;
this.rtpPort = config.rtpPort;
this.rtpSock = config.rtpSock;
// Set up RTP receive handler.
this.rtpSock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
this.pktReceived++;
// Learn remote media endpoint from first packet if not yet known.
if (!this.remoteMedia) {
this.remoteMedia = { address: rinfo.address, port: rinfo.port };
this.config.log(`[sip-leg:${this.id}] learned remote media: ${rinfo.address}:${rinfo.port}`);
}
// Forward to the Call hub.
if (this.onRtpReceived) {
this.onRtpReceived(data);
}
});
this.rtpSock.on('error', (e: Error) => {
this.config.log(`[sip-leg:${this.id}] rtp error: ${e.message}`);
});
}
get sipCallId(): string {
return this.dialog?.callId || 'no-dialog';
}
// -------------------------------------------------------------------------
// Outbound INVITE (B2BUA mode — create a new dialog)
// -------------------------------------------------------------------------
/**
* Send an INVITE to establish this leg.
* Creates a new SipDialog (UAC side).
*/
sendInvite(options: {
fromUri: string;
toUri: string;
callId: string;
fromTag?: string;
fromDisplayName?: string;
cseq?: number;
extraHeaders?: [string, string][];
}): void {
const ip = this.type === 'sip-provider'
? (this.config.getPublicIp() || this.config.lanIp)
: this.config.lanIp;
const pts = this.config.payloadTypes || [9, 0, 8, 101];
const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts });
const invite = SipMessage.createRequest('INVITE', options.toUri, {
via: { host: ip, port: this.config.lanPort },
from: { uri: options.fromUri, displayName: options.fromDisplayName, tag: options.fromTag },
to: { uri: options.toUri },
callId: options.callId,
cseq: options.cseq,
contact: `<sip:${ip}:${this.config.lanPort}>`,
body: sdp,
contentType: 'application/sdp',
extraHeaders: options.extraHeaders,
});
this.invite = invite;
this.dialog = SipDialog.fromUacInvite(invite, ip, this.config.lanPort);
this.state = 'inviting';
this.config.log(`[sip-leg:${this.id}] INVITE -> ${this.config.sipTarget.address}:${this.config.sipTarget.port}`);
this.config.sendSip(invite.serialize(), this.config.sipTarget);
}
// -------------------------------------------------------------------------
// Passthrough mode — forward a SIP message with rewriting
// -------------------------------------------------------------------------
/**
* Accept an incoming INVITE as a UAS (for passthrough inbound calls).
* Creates a SipDialog on the UAS side.
*/
acceptIncoming(invite: SipMessage): void {
const localTag = generateTag();
this.dialog = SipDialog.fromUasInvite(invite, localTag, this.config.lanIp, this.config.lanPort);
this.invite = invite;
this.state = 'inviting';
// Learn remote media from SDP.
if (invite.hasSdpBody) {
const ep = parseSdpEndpoint(invite.body);
if (ep) {
this.remoteMedia = ep;
this.config.log(`[sip-leg:${this.id}] media from SDP: ${ep.address}:${ep.port}`);
}
}
}
/**
* Forward a SIP message through this leg with SDP rewriting.
* Used for passthrough calls where the proxy relays messages.
*/
forwardMessage(msg: SipMessage, dest: IEndpoint): void {
const rewriteIp = this.type === 'sip-provider'
? (this.config.getPublicIp() || this.config.lanIp)
: this.config.lanIp;
// Rewrite SDP if present.
if (msg.hasSdpBody) {
const { body, original } = rewriteSdp(msg.body, rewriteIp, this.rtpPort);
msg.body = body;
msg.updateContentLength();
if (original) {
this.remoteMedia = original;
this.config.log(`[sip-leg:${this.id}] media from SDP rewrite: ${original.address}:${original.port}`);
}
}
// Record-Route for dialog-establishing requests.
if (msg.isRequest && msg.isDialogEstablishing) {
msg.prependHeader('Record-Route', `<sip:${this.config.lanIp}:${this.config.lanPort};lr>`);
}
// Rewrite Contact.
if (this.type === 'sip-provider') {
const contact = msg.getHeader('Contact');
if (contact) {
const nc = rewriteSipUri(contact, rewriteIp, this.config.lanPort);
if (nc !== contact) msg.setHeader('Contact', nc);
}
}
// Rewrite Request-URI for inbound messages going to device.
if (this.type === 'sip-device' && msg.isRequest) {
msg.setRequestUri(rewriteSipUri(msg.requestUri!, dest.address, dest.port));
}
this.config.sendSip(msg.serialize(), dest);
}
// -------------------------------------------------------------------------
// SIP message handling (routed by CallManager)
// -------------------------------------------------------------------------
handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void {
if (msg.isResponse) {
this.handleResponse(msg, rinfo);
} else {
this.handleRequest(msg, rinfo);
}
}
private handleResponse(msg: SipMessage, _rinfo: IEndpoint): void {
const code = msg.statusCode ?? 0;
const method = msg.cseqMethod?.toUpperCase();
this.config.log(`[sip-leg:${this.id}] <- ${code} (${method})`);
if (method === 'INVITE') {
this.handleInviteResponse(msg, code);
}
// BYE/CANCEL responses don't need action beyond logging.
}
private handleInviteResponse(msg: SipMessage, code: number): void {
// Handle retransmitted 407 for the original unauthenticated INVITE.
if (this.authAttempted && this.dialog) {
const responseCSeqNum = parseInt((msg.getHeader('CSeq') || '').split(/\s+/)[0], 10);
if (responseCSeqNum < this.dialog.localCSeq && code >= 400) {
if (this.origInvite) {
const ack = buildNon2xxAck(this.origInvite, msg);
this.config.sendSip(ack.serialize(), this.config.sipTarget);
}
return;
}
}
// Handle 407 Proxy Authentication Required.
if (code === 407 && this.type === 'sip-provider') {
this.handleAuthChallenge(msg);
return;
}
// Update dialog state.
if (this.dialog) {
this.dialog.processResponse(msg);
}
if (code === 180 || code === 183) {
this.state = 'ringing';
this.onStateChange?.(this);
} else if (code >= 200 && code < 300) {
// ACK the 200 OK.
if (this.dialog) {
const ack = this.dialog.createAck();
this.config.sendSip(ack.serialize(), this.config.sipTarget);
this.config.log(`[sip-leg:${this.id}] ACK sent`);
}
// If already connected (200 retransmit), just re-ACK.
if (this.state === 'connected') {
this.config.log(`[sip-leg:${this.id}] re-ACK (200 retransmit)`);
return;
}
// Learn media endpoint from SDP.
if (msg.hasSdpBody) {
const ep = parseSdpEndpoint(msg.body);
if (ep) {
this.remoteMedia = ep;
this.config.log(`[sip-leg:${this.id}] media = ${ep.address}:${ep.port}`);
}
}
this.state = 'connected';
this.config.log(`[sip-leg:${this.id}] CONNECTED`);
// Start silence for provider legs with early media quirks.
if (this.type === 'sip-provider') {
this.startSilence();
}
// Prime the RTP path.
if (this.remoteMedia) {
this.primeRtp(this.remoteMedia);
}
this.onConnected?.(this);
this.onStateChange?.(this);
} else if (code >= 300) {
this.config.log(`[sip-leg:${this.id}] rejected ${code}`);
this.state = 'terminated';
if (this.dialog) this.dialog.terminate();
this.onTerminated?.(this);
this.onStateChange?.(this);
}
}
private handleAuthChallenge(msg: SipMessage): void {
if (this.authAttempted) {
this.config.log(`[sip-leg:${this.id}] 407 after auth attempt — credentials rejected`);
this.state = 'terminated';
if (this.dialog) this.dialog.terminate();
this.onTerminated?.(this);
return;
}
this.authAttempted = true;
const challenge = msg.getHeader('Proxy-Authenticate');
if (!challenge) {
this.config.log(`[sip-leg:${this.id}] 407 but no Proxy-Authenticate`);
this.state = 'terminated';
if (this.dialog) this.dialog.terminate();
this.onTerminated?.(this);
return;
}
const parsed = parseDigestChallenge(challenge);
if (!parsed) {
this.config.log(`[sip-leg:${this.id}] could not parse digest challenge`);
this.state = 'terminated';
if (this.dialog) this.dialog.terminate();
this.onTerminated?.(this);
return;
}
const password = this.config.getSipPassword?.();
const aor = this.config.getRegisteredAor?.();
if (!password || !aor) {
this.config.log(`[sip-leg:${this.id}] 407 but no password or AOR`);
this.state = 'terminated';
if (this.dialog) this.dialog.terminate();
this.onTerminated?.(this);
return;
}
const username = aor.replace(/^sips?:/, '').split('@')[0];
const destUri = this.invite?.requestUri || '';
const authValue = computeDigestAuth({
username,
password,
realm: parsed.realm,
nonce: parsed.nonce,
method: 'INVITE',
uri: destUri,
algorithm: parsed.algorithm,
opaque: parsed.opaque,
});
// ACK the 407.
if (this.invite) {
const ack407 = buildNon2xxAck(this.invite, msg);
this.config.sendSip(ack407.serialize(), this.config.sipTarget);
this.config.log(`[sip-leg:${this.id}] ACK-407 sent`);
}
// Keep original INVITE for re-ACKing retransmitted 407s.
this.origInvite = this.invite;
// Resend INVITE with auth, same From tag, incremented CSeq.
const ip = this.config.getPublicIp() || this.config.lanIp;
const fromTag = this.dialog!.localTag;
const pts = this.config.payloadTypes || [9, 0, 8, 101];
const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts });
const inviteAuth = SipMessage.createRequest('INVITE', destUri, {
via: { host: ip, port: this.config.lanPort },
from: { uri: aor, tag: fromTag },
to: { uri: destUri },
callId: this.dialog!.callId,
cseq: 2,
contact: `<sip:${ip}:${this.config.lanPort}>`,
body: sdp,
contentType: 'application/sdp',
extraHeaders: [['Proxy-Authorization', authValue]],
});
this.invite = inviteAuth;
this.dialog!.localCSeq = 2;
this.config.log(`[sip-leg:${this.id}] resending INVITE with auth`);
this.config.sendSip(inviteAuth.serialize(), this.config.sipTarget);
}
private handleRequest(msg: SipMessage, rinfo: IEndpoint): void {
const method = msg.method;
this.config.log(`[sip-leg:${this.id}] <- ${method} from ${rinfo.address}:${rinfo.port}`);
if (method === 'BYE') {
// Send 200 OK to the BYE.
const ok = SipMessage.createResponse(200, 'OK', msg);
this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port });
this.state = 'terminated';
if (this.dialog) this.dialog.terminate();
this.onTerminated?.(this);
this.onStateChange?.(this);
}
// Other in-dialog requests (re-INVITE, INFO, etc.) can be handled here in the future.
}
// -------------------------------------------------------------------------
// Send BYE / CANCEL
// -------------------------------------------------------------------------
/** Send BYE (if confirmed) or CANCEL (if early) to tear down this leg. */
sendHangup(): void {
if (!this.dialog) return;
if (this.dialog.state === 'confirmed') {
const bye = this.dialog.createRequest('BYE');
this.config.sendSip(bye.serialize(), this.config.sipTarget);
this.config.log(`[sip-leg:${this.id}] BYE sent`);
} else if (this.dialog.state === 'early' && this.invite) {
const cancel = this.dialog.createCancel(this.invite);
this.config.sendSip(cancel.serialize(), this.config.sipTarget);
this.config.log(`[sip-leg:${this.id}] CANCEL sent`);
}
this.state = 'terminating';
this.dialog.terminate();
}
// -------------------------------------------------------------------------
// RTP
// -------------------------------------------------------------------------
sendRtp(data: Buffer): void {
if (!this.remoteMedia) return;
this.rtpSock.send(data, this.remoteMedia.port, this.remoteMedia.address);
this.pktSent++;
}
/** Send a 1-byte UDP packet to punch NAT hole. */
private primeRtp(peer: IEndpoint): void {
try {
this.rtpSock.send(Buffer.alloc(1), peer.port, peer.address);
this.config.log(`[sip-leg:${this.id}] RTP primed -> ${peer.address}:${peer.port}`);
} catch (e: any) {
this.config.log(`[sip-leg:${this.id}] prime error: ${e.message}`);
}
}
// -------------------------------------------------------------------------
// Silence stream (provider quirks)
// -------------------------------------------------------------------------
private startSilence(): void {
if (this.silenceTimer) return;
const quirks = this.config.provider?.quirks;
if (!quirks?.earlyMediaSilence) return;
if (!this.remoteMedia) return;
const PT = quirks.silencePayloadType ?? 9;
const MAX = quirks.silenceMaxPackets ?? 250;
const PAYLOAD = 160;
let seq = Math.floor(Math.random() * 0xffff);
let rtpTs = Math.floor(Math.random() * 0xffffffff);
let count = 0;
// Use proper silence byte for the codec (0x00 is NOT silence for most codecs).
const silenceByte = silenceByteForPT(PT);
this.silenceTimer = setInterval(() => {
if (this.pktReceived > 0 || count >= MAX) {
clearInterval(this.silenceTimer!);
this.silenceTimer = null;
this.config.log(`[sip-leg:${this.id}] silence stop after ${count} pkts`);
return;
}
const pkt = Buffer.alloc(12 + PAYLOAD, silenceByte);
// RTP header (first 12 bytes).
pkt[0] = 0x80;
pkt[1] = PT;
pkt.writeUInt16BE(seq & 0xffff, 2);
pkt.writeUInt32BE(rtpTs >>> 0, 4);
pkt.writeUInt32BE(this.ssrc >>> 0, 8); // stable SSRC
this.rtpSock.send(pkt, this.remoteMedia!.port, this.remoteMedia!.address);
seq++;
rtpTs += PAYLOAD;
count++;
}, 20);
this.config.log(`[sip-leg:${this.id}] silence start -> ${this.remoteMedia.address}:${this.remoteMedia.port} (ssrc=${this.ssrc})`);
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
teardown(): void {
if (this.silenceTimer) {
clearInterval(this.silenceTimer);
this.silenceTimer = null;
}
this.state = 'terminated';
if (this.dialog) this.dialog.terminate();
// Note: RTP socket is NOT closed here — the RtpPortPool manages that.
}
getStatus(): ILegStatus {
return {
id: this.id,
type: this.type,
state: this.state,
remoteMedia: this.remoteMedia,
rtpPort: this.rtpPort,
pktSent: this.pktSent,
pktReceived: this.pktReceived,
codec: codecDisplayName(this.codec),
transcoding: this.transcoder !== null,
};
}
}
// ---------------------------------------------------------------------------
// Helper: proper silence byte per codec
// ---------------------------------------------------------------------------
/** Return the byte value representing digital silence for a given RTP payload type. */
function silenceByteForPT(pt: number): number {
switch (pt) {
case 0: return 0xFF; // PCMU: μ-law silence (zero amplitude)
case 8: return 0xD5; // PCMA: A-law silence (zero amplitude)
case 9: return 0xD5; // G.722: sub-band silence (zero amplitude)
default: return 0xFF; // safe default
}
}
// ---------------------------------------------------------------------------
// Helper: ACK for non-2xx (same transaction)
// ---------------------------------------------------------------------------
function buildNon2xxAck(originalInvite: SipMessage, response: SipMessage): SipMessage {
const via = originalInvite.getHeader('Via') || '';
const from = originalInvite.getHeader('From') || '';
const toFromResponse = response.getHeader('To') || '';
const callId = originalInvite.callId;
const cseqNum = parseInt((originalInvite.getHeader('CSeq') || '1').split(/\s+/)[0], 10);
return new SipMessage(
`ACK ${originalInvite.requestUri} SIP/2.0`,
[
['Via', via],
['From', from],
['To', toFromResponse],
['Call-ID', callId],
['CSeq', `${cseqNum} ACK`],
['Max-Forwards', '70'],
['Content-Length', '0'],
],
'',
);
}

68
ts/call/types.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Hub model type definitions — Call, Leg, and status types.
*/
import type { IEndpoint } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// State types
// ---------------------------------------------------------------------------
export type TCallState =
| 'setting-up'
| 'ringing'
| 'connected'
| 'on-hold'
| 'transferring'
| 'terminating'
| 'terminated';
export type TLegState =
| 'inviting'
| 'ringing'
| 'connected'
| 'on-hold'
| 'terminating'
| 'terminated';
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc';
export type TCallDirection = 'inbound' | 'outbound' | 'internal';
// ---------------------------------------------------------------------------
// Status interfaces (for frontend dashboard)
// ---------------------------------------------------------------------------
export interface ILegStatus {
id: string;
type: TLegType;
state: TLegState;
remoteMedia: IEndpoint | null;
rtpPort: number | null;
pktSent: number;
pktReceived: number;
codec: string | null;
transcoding: boolean;
}
export interface ICallStatus {
id: string;
state: TCallState;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
createdAt: number;
duration: number;
legs: ILegStatus[];
}
export interface ICallHistoryEntry {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
}

417
ts/call/webrtc-leg.ts Normal file
View File

@@ -0,0 +1,417 @@
/**
* WebRtcLeg — a WebRTC connection from the Call hub to a browser client.
*
* Wraps a werift RTCPeerConnection and handles:
* - WebRTC offer/answer/ICE negotiation
* - Opus <-> G.722/PCMU/PCMA transcoding via Rust IPC
* - RTP header rebuilding with correct PT, timestamp, SSRC
*/
import dgram from 'node:dgram';
import { Buffer } from 'node:buffer';
import { WebSocket } from 'ws';
import type { IEndpoint } from '../sip/index.ts';
import type { TLegState, ILegStatus } from './types.ts';
import type { ILeg } from './leg.ts';
import { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts';
import { createTranscoder, OPUS_PT } from '../codec.ts';
import type { IRtpTranscoder } from '../codec.ts';
import { createSession, destroySession } from '../opusbridge.ts';
import type { SipDialog } from '../sip/index.ts';
import type { SipMessage } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// WebRtcLeg config
// ---------------------------------------------------------------------------
export interface IWebRtcLegConfig {
/** The browser's WebSocket connection. */
ws: WebSocket;
/** The browser's session ID. */
sessionId: string;
/** RTP port and socket (pre-allocated from the pool). */
rtpPort: number;
rtpSock: dgram.Socket;
/** Logging function. */
log: (msg: string) => void;
}
// ---------------------------------------------------------------------------
// WebRtcLeg
// ---------------------------------------------------------------------------
export class WebRtcLeg implements ILeg {
readonly id: string;
readonly type = 'webrtc' as const;
state: TLegState = 'inviting';
readonly sessionId: string;
/** The werift RTCPeerConnection instance. */
private pc: any = null;
/** RTP socket for bridging to SIP. */
readonly rtpSock: dgram.Socket;
readonly rtpPort: number;
/** Remote media endpoint (the other side of the bridge, set by Call). */
remoteMedia: IEndpoint | null = null;
/** Negotiated WebRTC codec payload type. */
codec: number | null = null;
/** Transcoders for WebRTC <-> SIP conversion. */
transcoder: IRtpTranscoder | null = null; // used by Call for fan-out
private toSipTranscoder: IRtpTranscoder | null = null;
private fromSipTranscoder: IRtpTranscoder | null = null;
/** RTP counters for outgoing (to SIP) direction. */
private toSipSeq = 0;
private toSipTs = 0;
private toSipSsrc = (Math.random() * 0xffffffff) >>> 0;
/** RTP counters for incoming (from SIP) direction.
* Initialized to random values so announcements and provider audio share
* a continuous sequence — prevents the browser jitter buffer from discarding
* packets after the announcement→provider transition. */
readonly fromSipCounters = {
seq: Math.floor(Math.random() * 0xffff),
ts: Math.floor(Math.random() * 0xffffffff),
};
fromSipSsrc = (Math.random() * 0xffffffff) >>> 0;
/** Packet counters. */
pktSent = 0;
pktReceived = 0;
/** Callback set by Call. */
onRtpReceived: ((data: Buffer) => void) | null = null;
/** Callback to send transcoded RTP to the provider via the SipLeg's socket.
* Set by CallManager when the bridge is established. If null, falls back to own rtpSock. */
onSendToProvider: ((data: Buffer, dest: IEndpoint) => void) | null = null;
/** Lifecycle callbacks. */
onConnected: ((leg: WebRtcLeg) => void) | null = null;
onTerminated: ((leg: WebRtcLeg) => void) | null = null;
/** Cancel handle for an in-progress announcement. */
announcementCancel: (() => void) | null = null;
private ws: WebSocket;
private config: IWebRtcLegConfig;
private pendingIceCandidates: any[] = [];
// SipDialog is not applicable for WebRTC legs.
readonly dialog: SipDialog | null = null;
readonly sipCallId: string;
constructor(id: string, config: IWebRtcLegConfig) {
this.id = id;
this.sessionId = config.sessionId;
this.ws = config.ws;
this.rtpSock = config.rtpSock;
this.rtpPort = config.rtpPort;
this.config = config;
this.sipCallId = `webrtc-${id}`;
// Log RTP arriving on this socket (symmetric RTP from provider).
// Audio forwarding is handled by the Call hub: SipLeg → forwardRtp → WebRtcLeg.sendRtp.
// We do NOT transcode here to avoid double-processing (the SipLeg also receives these packets).
let sipRxCount = 0;
this.rtpSock.on('message', (data: Buffer) => {
sipRxCount++;
if (sipRxCount === 1 || sipRxCount === 50 || sipRxCount % 500 === 0) {
this.config.log(`[webrtc-leg:${this.id}] SIP->browser rtp #${sipRxCount} (${data.length}b) [symmetric, ignored]`);
}
});
}
// -------------------------------------------------------------------------
// WebRTC offer/answer
// -------------------------------------------------------------------------
/**
* Handle a WebRTC offer from the browser. Creates the PeerConnection,
* sets remote offer, creates answer, and sends it back.
*/
async handleOffer(offerSdp: string): Promise<void> {
this.config.log(`[webrtc-leg:${this.id}] received offer`);
try {
const werift = await import('werift');
this.pc = new werift.RTCPeerConnection({ iceServers: [] });
// Add sendrecv transceiver before setRemoteDescription.
this.pc.addTransceiver('audio', { direction: 'sendrecv' });
// Handle incoming audio from browser.
this.pc.ontrack = (event: any) => {
const track = event.track;
this.config.log(`[webrtc-leg:${this.id}] got track: ${track.kind}`);
let rxCount = 0;
track.onReceiveRtp.subscribe((rtp: any) => {
if (!this.remoteMedia) return;
rxCount++;
if (rxCount === 1 || rxCount === 50 || rxCount % 500 === 0) {
this.config.log(`[webrtc-leg:${this.id}] browser->SIP rtp #${rxCount}`);
}
this.forwardToSip(rtp, rxCount);
});
};
// ICE candidate handling.
this.pc.onicecandidate = (candidate: any) => {
if (candidate) {
const json = candidate.toJSON?.() || candidate;
this.wsSend({ type: 'webrtc-ice', sessionId: this.sessionId, candidate: json });
}
};
this.pc.onconnectionstatechange = () => {
this.config.log(`[webrtc-leg:${this.id}] connection state: ${this.pc.connectionState}`);
if (this.pc.connectionState === 'connected') {
this.state = 'connected';
this.onConnected?.(this);
} else if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
this.state = 'terminated';
this.onTerminated?.(this);
}
};
if (this.pc.oniceconnectionstatechange !== undefined) {
this.pc.oniceconnectionstatechange = () => {
this.config.log(`[webrtc-leg:${this.id}] ICE state: ${this.pc.iceConnectionState}`);
};
}
// Set remote offer and create answer.
await this.pc.setRemoteDescription({ type: 'offer', sdp: offerSdp });
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
const sdp: string = this.pc.localDescription!.sdp;
// Detect negotiated codec.
const mAudio = sdp.match(/m=audio\s+\d+\s+\S+\s+(\d+)/);
if (mAudio) {
this.codec = parseInt(mAudio[1], 10);
this.config.log(`[webrtc-leg:${this.id}] negotiated audio PT=${this.codec}`);
}
// Extract sender SSRC from SDP.
const ssrcMatch = sdp.match(/a=ssrc:(\d+)\s/);
if (ssrcMatch) {
this.fromSipSsrc = parseInt(ssrcMatch[1], 10);
}
// Also try from sender object.
const senders = this.pc.getSenders();
if (senders[0]) {
const senderSsrc = (senders[0] as any).ssrc ?? (senders[0] as any)._ssrc;
if (senderSsrc) this.fromSipSsrc = senderSsrc;
}
// Send answer to browser.
this.wsSend({ type: 'webrtc-answer', sessionId: this.sessionId, sdp });
this.config.log(`[webrtc-leg:${this.id}] sent answer, rtp port=${this.rtpPort}`);
// Process buffered ICE candidates.
for (const c of this.pendingIceCandidates) {
try { await this.pc.addIceCandidate(c); } catch { /* ignore */ }
}
this.pendingIceCandidates = [];
} catch (err: any) {
this.config.log(`[webrtc-leg:${this.id}] offer error: ${err.message}`);
this.wsSend({ type: 'webrtc-error', sessionId: this.sessionId, error: err.message });
this.state = 'terminated';
this.onTerminated?.(this);
}
}
/** Add an ICE candidate from the browser. */
async addIceCandidate(candidate: any): Promise<void> {
if (!this.pc) {
this.pendingIceCandidates.push(candidate);
return;
}
try {
if (candidate) await this.pc.addIceCandidate(candidate);
} catch (err: any) {
this.config.log(`[webrtc-leg:${this.id}] ICE error: ${err.message}`);
}
}
// -------------------------------------------------------------------------
// Transcoding setup
// -------------------------------------------------------------------------
/** Codec session ID for isolated Rust codec state (unique per leg). */
private codecSessionId = `webrtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
/**
* Set up transcoders for bridging between WebRTC and SIP codecs.
* Called by the Call when the remote media endpoint is known.
* Creates an isolated Rust codec session so concurrent calls don't
* corrupt each other's stateful codec state (Opus/G.722 ADPCM).
*/
async setupTranscoders(sipPT: number): Promise<void> {
const webrtcPT = this.codec ?? OPUS_PT;
// Create isolated codec session for this leg.
await createSession(this.codecSessionId);
this.toSipTranscoder = createTranscoder(webrtcPT, sipPT, this.codecSessionId, 'to_sip');
this.fromSipTranscoder = createTranscoder(sipPT, webrtcPT, this.codecSessionId, 'to_browser');
const mode = this.toSipTranscoder ? `transcoding PT ${webrtcPT}<->${sipPT}` : `pass-through PT ${webrtcPT}`;
this.config.log(`[webrtc-leg:${this.id}] ${mode} (session: ${this.codecSessionId})`);
}
// -------------------------------------------------------------------------
// RTP forwarding
// -------------------------------------------------------------------------
/** Forward RTP from SIP side to browser via WebRTC. */
private forwardToBrowser(data: Buffer, count: number): void {
const sender = this.pc?.getSenders()[0];
if (!sender) return;
if (this.fromSipTranscoder && data.length > 12) {
const payload = Buffer.from(data.subarray(12));
// Stop announcement if still playing — provider audio takes over.
if (this.announcementCancel) {
this.announcementCancel();
this.announcementCancel = null;
}
// Capture seq/ts BEFORE async transcode to preserve ordering.
const toPT = this.fromSipTranscoder.toPT;
const seq = this.fromSipCounters.seq++;
const ts = this.fromSipCounters.ts;
this.fromSipCounters.ts += rtpClockIncrement(toPT);
const result = this.fromSipTranscoder.payload(payload);
const sendTranscoded = (transcoded: Buffer) => {
if (transcoded.length === 0) return; // transcoding failed
try {
const hdr = buildRtpHeader(toPT, seq, ts, this.fromSipSsrc, false);
const out = Buffer.concat([hdr, transcoded]);
const r = sender.sendRtp(out);
if (r instanceof Promise) r.catch(() => {});
} catch { /* ignore */ }
};
if (result instanceof Promise) result.then(sendTranscoded).catch(() => {});
else sendTranscoded(result);
} else if (!this.fromSipTranscoder) {
// No transcoder — either same codec or not set up yet.
// Only forward if we don't expect transcoding.
if (this.codec === null) {
try { sender.sendRtp(data); } catch { /* ignore */ }
}
}
}
/** Forward RTP from browser to SIP side. */
private forwardToSip(rtp: any, count: number): void {
if (!this.remoteMedia) return;
if (this.toSipTranscoder) {
const payload: Buffer = rtp.payload;
if (!payload || payload.length === 0) return;
// Capture seq/ts BEFORE async transcode to preserve ordering.
const toPT = this.toSipTranscoder.toPT;
const seq = this.toSipSeq++;
const ts = this.toSipTs;
this.toSipTs += rtpClockIncrement(toPT);
const result = this.toSipTranscoder.payload(payload);
const sendTranscoded = (transcoded: Buffer) => {
if (transcoded.length === 0) return; // transcoding failed
const hdr = buildRtpHeader(toPT, seq, ts, this.toSipSsrc, false);
const out = Buffer.concat([hdr, transcoded]);
if (this.onSendToProvider) {
this.onSendToProvider(out, this.remoteMedia!);
} else {
this.rtpSock.send(out, this.remoteMedia!.port, this.remoteMedia!.address);
}
this.pktSent++;
};
if (result instanceof Promise) result.then(sendTranscoded).catch(() => {});
else sendTranscoded(result);
} else if (this.codec === null) {
// Same codec (no transcoding needed) — pass through.
const raw = rtp.serialize();
if (this.onSendToProvider) {
this.onSendToProvider(raw, this.remoteMedia);
} else {
this.rtpSock.send(raw, this.remoteMedia.port, this.remoteMedia.address);
}
this.pktSent++;
}
// If codec is set but transcoder is null, drop the packet — transcoder not ready yet.
// This prevents raw Opus from being sent to a G.722 endpoint.
}
/**
* Send RTP to the browser via WebRTC (used by Call hub for fan-out).
* This transcodes and sends through the PeerConnection, NOT to a UDP address.
*/
sendRtp(data: Buffer): void {
this.forwardToBrowser(data, this.pktSent);
this.pktSent++;
}
/**
* Send a pre-encoded RTP packet directly to the browser via PeerConnection.
* Used for announcements — the packet must already be in the correct codec (Opus).
*/
sendDirectToBrowser(pkt: Buffer): void {
const sender = this.pc?.getSenders()[0];
if (!sender) return;
try {
const r = sender.sendRtp(pkt);
if (r instanceof Promise) r.catch(() => {});
} catch { /* ignore */ }
}
/** No-op: WebRTC legs don't process SIP messages. */
handleSipMessage(_msg: SipMessage, _rinfo: IEndpoint): void {
// WebRTC legs don't handle SIP messages.
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
teardown(): void {
this.state = 'terminated';
try { this.pc?.close(); } catch { /* ignore */ }
this.pc = null;
// Destroy the isolated Rust codec session for this leg.
destroySession(this.codecSessionId).catch(() => {});
// Note: RTP socket is NOT closed here — the RtpPortPool manages that.
}
getStatus(): ILegStatus {
return {
id: this.id,
type: this.type,
state: this.state,
remoteMedia: this.remoteMedia,
rtpPort: this.rtpPort,
pktSent: this.pktSent,
pktReceived: this.pktReceived,
codec: codecDisplayName(this.codec),
transcoding: this.toSipTranscoder !== null || this.fromSipTranscoder !== null,
};
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private wsSend(data: unknown): void {
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
} catch { /* ignore */ }
}
}

40
ts/codec.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Audio codec translation layer for bridging between WebRTC and SIP.
*
* All actual codec work (Opus, G.722, PCMU, PCMA) is done in Rust via
* the smartrust bridge. This module provides the RTP-level transcoding
* interface used by the webrtcbridge.
*/
import { Buffer } from 'node:buffer';
import { transcode, isCodecReady } from './opusbridge.ts';
/** Opus dynamic payload type (standard WebRTC assignment). */
export const OPUS_PT = 111;
export interface IRtpTranscoder {
/** Transcode an RTP payload. Always async (Rust IPC). */
payload(data: Buffer): Promise<Buffer>;
fromPT: number;
toPT: number;
}
/**
* Create a transcoder that converts RTP payloads between two codecs.
* Returns null if the codecs are the same or the Rust bridge isn't ready.
*
* @param sessionId - optional Rust codec session for isolated state per call
*/
export function createTranscoder(fromPT: number, toPT: number, sessionId?: string, direction?: string): IRtpTranscoder | null {
if (fromPT === toPT) return null;
if (!isCodecReady()) return null;
return {
fromPT,
toPT,
async payload(data: Buffer): Promise<Buffer> {
const result = await transcode(data, fromPT, toPT, sessionId, direction);
return result || Buffer.alloc(0); // return empty on failure — never pass raw codec bytes
},
};
}

153
ts/config.ts Normal file
View File

@@ -0,0 +1,153 @@
/**
* Application configuration — loaded from .nogit/config.json.
*
* All network addresses, credentials, provider settings, device definitions,
* and routing rules come from this single config file. No hardcoded values
* in source.
*/
import fs from 'node:fs';
import path from 'node:path';
import type { IEndpoint } from './sip/index.ts';
// ---------------------------------------------------------------------------
// Config interfaces
// ---------------------------------------------------------------------------
export interface IQuirks {
earlyMediaSilence: boolean;
silencePayloadType?: number;
silenceMaxPackets?: number;
}
export interface IProviderConfig {
id: string;
displayName: string;
domain: string;
outboundProxy: IEndpoint;
username: string;
password: string;
registerIntervalSec: number;
codecs: number[];
quirks: IQuirks;
}
export interface IDeviceConfig {
id: string;
displayName: string;
expectedAddress: string;
extension: string;
}
export interface IRoutingConfig {
outbound: { default: string };
inbound: Record<string, string[]>;
ringBrowsers?: Record<string, boolean>;
}
export interface IProxyConfig {
lanIp: string;
lanPort: number;
publicIpSeed: string | null;
rtpPortRange: { min: number; max: number };
webUiPort: number;
}
export interface IContact {
id: string;
name: string;
number: string;
company?: string;
notes?: string;
starred?: boolean;
}
export interface IAppConfig {
proxy: IProxyConfig;
providers: IProviderConfig[];
devices: IDeviceConfig[];
routing: IRoutingConfig;
contacts: IContact[];
}
// ---------------------------------------------------------------------------
// Loader
// ---------------------------------------------------------------------------
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
export function loadConfig(): IAppConfig {
let raw: string;
try {
raw = fs.readFileSync(CONFIG_PATH, 'utf8');
} catch {
throw new Error(`config not found at ${CONFIG_PATH} — create .nogit/config.json`);
}
const cfg = JSON.parse(raw) as IAppConfig;
// Basic validation.
if (!cfg.proxy) throw new Error('config: missing "proxy" section');
if (!cfg.proxy.lanIp) throw new Error('config: missing proxy.lanIp');
if (!cfg.proxy.lanPort) throw new Error('config: missing proxy.lanPort');
if (!cfg.proxy.rtpPortRange?.min || !cfg.proxy.rtpPortRange?.max) {
throw new Error('config: missing proxy.rtpPortRange.min/max');
}
cfg.proxy.webUiPort ??= 3060;
cfg.proxy.publicIpSeed ??= null;
cfg.providers ??= [];
for (const p of cfg.providers) {
if (!p.id || !p.domain || !p.outboundProxy || !p.username || !p.password) {
throw new Error(`config: provider "${p.id || '?'}" missing required fields`);
}
p.displayName ??= p.id;
p.registerIntervalSec ??= 300;
p.codecs ??= [9, 0, 8, 101];
p.quirks ??= { earlyMediaSilence: false };
}
if (!Array.isArray(cfg.devices) || !cfg.devices.length) {
throw new Error('config: need at least one device');
}
for (const d of cfg.devices) {
if (!d.id || !d.expectedAddress) {
throw new Error(`config: device "${d.id || '?'}" missing required fields`);
}
d.displayName ??= d.id;
d.extension ??= '100';
}
cfg.routing ??= { outbound: { default: cfg.providers[0].id }, inbound: {} };
cfg.routing.outbound ??= { default: cfg.providers[0].id };
cfg.contacts ??= [];
for (const c of cfg.contacts) {
c.starred ??= false;
}
return cfg;
}
// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------
export function getProvider(cfg: IAppConfig, id: string): IProviderConfig | null {
return cfg.providers.find((p) => p.id === id) ?? null;
}
export function getDevice(cfg: IAppConfig, id: string): IDeviceConfig | null {
return cfg.devices.find((d) => d.id === id) ?? null;
}
export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null {
const id = cfg.routing?.outbound?.default;
if (!id) return cfg.providers[0] || null;
return getProvider(cfg, id) || cfg.providers[0] || null;
}
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] {
const ids = cfg.routing.inbound[providerId];
if (!ids?.length) return cfg.devices; // fallback: ring all devices
return ids.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
}

372
ts/frontend.ts Normal file
View File

@@ -0,0 +1,372 @@
/**
* Web dashboard server for the SIP proxy.
*
* Serves a bundled web component frontend (ts_web/) and pushes
* live updates over WebSocket. The frontend is built with
* @design.estate/dees-element web components and bundled with esbuild.
*/
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import https from 'node:https';
import { WebSocketServer, WebSocket } from 'ws';
import type { CallManager } from './call/index.ts';
import { handleWebRtcSignaling } from './webrtcbridge.ts';
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
// ---------------------------------------------------------------------------
// WebSocket broadcast
// ---------------------------------------------------------------------------
const wsClients = new Set<WebSocket>();
function timestamp(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
export function broadcastWs(type: string, data: unknown): void {
if (!wsClients.size) return;
const msg = JSON.stringify({ type, data, ts: timestamp() });
for (const ws of wsClients) {
try {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
} catch {
wsClients.delete(ws);
}
}
}
// ---------------------------------------------------------------------------
// Static file cache (loaded at startup)
// ---------------------------------------------------------------------------
const staticFiles = new Map<string, { data: Buffer; contentType: string }>();
function loadStaticFiles(): void {
const cwd = process.cwd();
// Load index.html
const htmlPath = path.join(cwd, 'html', 'index.html');
try {
const data = fs.readFileSync(htmlPath);
staticFiles.set('/', { data, contentType: 'text/html; charset=utf-8' });
staticFiles.set('/index.html', { data, contentType: 'text/html; charset=utf-8' });
} catch {
const fallback = Buffer.from(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SIP Router</title>
<style>body{margin:0;background:#0f172a;color:#e2e8f0;font-family:system-ui}sipproxy-app{display:block;position:fixed;inset:0;overflow:hidden}</style>
</head><body><sipproxy-app></sipproxy-app><script type="module" src="/bundle.js"></script></body></html>`);
staticFiles.set('/', { data: fallback, contentType: 'text/html; charset=utf-8' });
staticFiles.set('/index.html', { data: fallback, contentType: 'text/html; charset=utf-8' });
}
// Load bundle.js
const bundlePath = path.join(cwd, 'dist_ts_web', 'bundle.js');
try {
const data = fs.readFileSync(bundlePath);
staticFiles.set('/bundle.js', { data, contentType: 'application/javascript; charset=utf-8' });
} catch { /* Bundle not found */ }
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
async function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
getStatus: () => unknown,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
): Promise<void> {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = req.method || 'GET';
// API: status.
if (url.pathname === '/api/status') {
return sendJson(res, getStatus());
}
// API: start call (with optional providerId).
if (url.pathname === '/api/call' && method === 'POST') {
try {
const body = await readJsonBody(req);
const number = body?.number;
if (!number || typeof number !== 'string') {
return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
}
const call = onStartCall(number, body?.deviceId, body?.providerId);
if (call) return sendJson(res, { ok: true, callId: call.id });
return sendJson(res, { ok: false, error: 'call origination failed — provider not registered or no ports available' }, 503);
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: hangup.
if (url.pathname === '/api/hangup' && method === 'POST') {
try {
const body = await readJsonBody(req);
const callId = body?.callId;
if (!callId || typeof callId !== 'string') {
return sendJson(res, { ok: false, error: 'missing "callId" field' }, 400);
}
return sendJson(res, { ok: onHangupCall(callId) });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: add leg to call.
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
try {
const callId = url.pathname.split('/')[3];
const body = await readJsonBody(req);
if (!body?.deviceId) return sendJson(res, { ok: false, error: 'missing deviceId' }, 400);
const ok = callManager?.addDeviceToCall(callId, body.deviceId) ?? false;
return sendJson(res, { ok });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: add external participant (dial out) to existing call.
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addexternal') && method === 'POST') {
try {
const callId = url.pathname.split('/')[3];
const body = await readJsonBody(req);
if (!body?.number) return sendJson(res, { ok: false, error: 'missing number' }, 400);
const ok = callManager?.addExternalToCall(callId, body.number, body.providerId) ?? false;
return sendJson(res, { ok });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: remove leg from call.
if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/removeleg') && method === 'POST') {
try {
const callId = url.pathname.split('/')[3];
const body = await readJsonBody(req);
if (!body?.legId) return sendJson(res, { ok: false, error: 'missing legId' }, 400);
const ok = callManager?.removeLegFromCall(callId, body.legId) ?? false;
return sendJson(res, { ok });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: transfer leg.
if (url.pathname === '/api/transfer' && method === 'POST') {
try {
const body = await readJsonBody(req);
if (!body?.sourceCallId || !body?.legId || !body?.targetCallId) {
return sendJson(res, { ok: false, error: 'missing sourceCallId, legId, or targetCallId' }, 400);
}
const ok = callManager?.transferLeg(body.sourceCallId, body.legId, body.targetCallId) ?? false;
return sendJson(res, { ok });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// API: get config (sans passwords).
if (url.pathname === '/api/config' && method === 'GET') {
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
const cfg = JSON.parse(raw);
const safe = { ...cfg, providers: cfg.providers?.map((p: any) => ({ ...p, password: '••••••' })) };
return sendJson(res, safe);
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 500);
}
}
// API: update config.
if (url.pathname === '/api/config' && method === 'POST') {
try {
const updates = await readJsonBody(req);
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
const cfg = JSON.parse(raw);
// Update existing providers.
if (updates.providers) {
for (const up of updates.providers) {
const existing = cfg.providers?.find((p: any) => p.id === up.id);
if (existing) {
if (up.displayName !== undefined) existing.displayName = up.displayName;
if (up.password && up.password !== '••••••') existing.password = up.password;
if (up.domain !== undefined) existing.domain = up.domain;
if (up.outboundProxy !== undefined) existing.outboundProxy = up.outboundProxy;
if (up.username !== undefined) existing.username = up.username;
if (up.registerIntervalSec !== undefined) existing.registerIntervalSec = up.registerIntervalSec;
if (up.codecs !== undefined) existing.codecs = up.codecs;
if (up.quirks !== undefined) existing.quirks = up.quirks;
}
}
}
// Add a new provider.
if (updates.addProvider) {
cfg.providers ??= [];
cfg.providers.push(updates.addProvider);
}
// Remove a provider.
if (updates.removeProvider) {
cfg.providers = (cfg.providers || []).filter((p: any) => p.id !== updates.removeProvider);
// Clean up routing references.
if (cfg.routing?.inbound) delete cfg.routing.inbound[updates.removeProvider];
if (cfg.routing?.ringBrowsers) delete cfg.routing.ringBrowsers[updates.removeProvider];
if (cfg.routing?.outbound?.default === updates.removeProvider) {
cfg.routing.outbound.default = cfg.providers[0]?.id || '';
}
}
if (updates.devices) {
for (const ud of updates.devices) {
const existing = cfg.devices?.find((d: any) => d.id === ud.id);
if (existing && ud.displayName !== undefined) existing.displayName = ud.displayName;
}
}
if (updates.routing) {
if (updates.routing.inbound) cfg.routing.inbound = { ...cfg.routing.inbound, ...updates.routing.inbound };
if (updates.routing.ringBrowsers) cfg.routing.ringBrowsers = { ...cfg.routing.ringBrowsers, ...updates.routing.ringBrowsers };
}
if (updates.contacts !== undefined) cfg.contacts = updates.contacts;
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
log('[config] updated config.json');
onConfigSaved?.();
return sendJson(res, { ok: true });
} catch (e: any) {
return sendJson(res, { ok: false, error: e.message }, 400);
}
}
// Static files.
const file = staticFiles.get(url.pathname);
if (file) {
res.writeHead(200, {
'Content-Type': file.contentType,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
});
res.end(file.data);
return;
}
// SPA fallback.
const index = staticFiles.get('/');
if (index) {
res.writeHead(200, { 'Content-Type': index.contentType });
res.end(index.data);
return;
}
res.writeHead(404);
res.end('Not Found');
}
// ---------------------------------------------------------------------------
// HTTP + WebSocket server (Node.js)
// ---------------------------------------------------------------------------
export function initWebUi(
getStatus: () => unknown,
log: (msg: string) => void,
onStartCall: (number: string, deviceId?: string, providerId?: string) => { id: string } | null,
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
): void {
const WEB_PORT = 3060;
loadStaticFiles();
// Serve HTTPS if cert exists, otherwise fall back to HTTP.
const certPath = path.join(process.cwd(), '.nogit', 'cert.pem');
const keyPath = path.join(process.cwd(), '.nogit', 'key.pem');
let useTls = false;
let server: http.Server | https.Server;
try {
const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8');
server = https.createServer({ cert, key }, (req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).catch(() => { res.writeHead(500); res.end(); }),
);
useTls = true;
} catch {
server = http.createServer((req, res) =>
handleRequest(req, res, getStatus, log, onStartCall, onHangupCall, onConfigSaved, callManager).catch(() => { res.writeHead(500); res.end(); }),
);
}
// WebSocket server on the same port.
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (socket, req) => {
const remoteIp = req.socket.remoteAddress || null;
wsClients.add(socket);
socket.send(JSON.stringify({ type: 'status', data: getStatus(), ts: timestamp() }));
socket.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === 'webrtc-accept' && msg.callId) {
log(`[webrtc] browser accepted call ${msg.callId} session=${msg.sessionId || 'none'}`);
const ok = callManager?.acceptBrowserCall(msg.callId, msg.sessionId) ?? false;
log(`[webrtc] acceptBrowserCall result: ${ok}`);
} else if (msg.type === 'webrtc-offer' && msg.sessionId) {
callManager?.handleWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
log(`[webrtc] offer error: ${e.message}`));
} else if (msg.type === 'webrtc-ice' && msg.sessionId) {
callManager?.handleWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
} else if (msg.type === 'webrtc-hangup' && msg.sessionId) {
callManager?.handleWebRtcHangup(msg.sessionId);
} else if (msg.type?.startsWith('webrtc-')) {
msg._remoteIp = remoteIp;
handleWebRtcSignaling(socket as any, msg);
}
} catch { /* ignore */ }
});
socket.on('close', () => wsClients.delete(socket));
socket.on('error', () => wsClients.delete(socket));
});
server.listen(WEB_PORT, '0.0.0.0', () => {
log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${WEB_PORT}`);
});
setInterval(() => broadcastWs('status', getStatus()), 1000);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sendJson(res: http.ServerResponse, data: unknown, status = 200): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function readJsonBody(req: http.IncomingMessage): Promise<any> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
catch (e) { reject(e); }
});
req.on('error', reject);
});
}

199
ts/opusbridge.ts Normal file
View File

@@ -0,0 +1,199 @@
/**
* Audio transcoding bridge — uses smartrust to communicate with the Rust
* opus-codec binary, which handles Opus ↔ G.722 ↔ PCMU/PCMA transcoding.
*
* All codec conversion happens in Rust (libopus + SpanDSP G.722 port).
* The TypeScript side just passes raw payloads back and forth.
*/
import path from 'node:path';
import { RustBridge } from '@push.rocks/smartrust';
// ---------------------------------------------------------------------------
// Command type map for smartrust
// ---------------------------------------------------------------------------
type TCodecCommands = {
init: {
params: Record<string, never>;
result: Record<string, never>;
};
create_session: {
params: { session_id: string };
result: Record<string, never>;
};
destroy_session: {
params: { session_id: string };
result: Record<string, never>;
};
transcode: {
params: { data_b64: string; from_pt: number; to_pt: number; session_id?: string; direction?: string };
result: { data_b64: string };
};
encode_pcm: {
params: { data_b64: string; sample_rate: number; to_pt: number; session_id?: string };
result: { data_b64: string };
};
};
// ---------------------------------------------------------------------------
// Bridge singleton
// ---------------------------------------------------------------------------
let bridge: RustBridge<TCodecCommands> | null = null;
let initialized = false;
function buildLocalPaths(): string[] {
const root = process.cwd();
return [
path.join(root, 'dist_rust', 'opus-codec'),
path.join(root, 'rust', 'target', 'release', 'opus-codec'),
path.join(root, 'rust', 'target', 'debug', 'opus-codec'),
];
}
let logFn: ((msg: string) => void) | undefined;
/**
* Initialize the audio transcoding bridge. Spawns the Rust binary.
*/
export async function initCodecBridge(log?: (msg: string) => void): Promise<boolean> {
if (initialized && bridge) return true;
logFn = log;
try {
bridge = new RustBridge<TCodecCommands>({
binaryName: 'opus-codec',
localPaths: buildLocalPaths(),
});
const spawned = await bridge.spawn();
if (!spawned) {
log?.('[codec] failed to spawn opus-codec binary');
bridge = null;
return false;
}
// Auto-restart: reset state when the Rust process exits so the next
// transcode attempt triggers re-initialization instead of silent failure.
bridge.on('exit', () => {
logFn?.('[codec] Rust audio transcoder process exited — will re-init on next use');
bridge = null;
initialized = false;
});
await bridge.sendCommand('init', {} as any);
initialized = true;
log?.('[codec] Rust audio transcoder initialized (Opus + G.722 + PCMU/PCMA)');
return true;
} catch (e: any) {
log?.(`[codec] init error: ${e.message}`);
bridge = null;
return false;
}
}
// ---------------------------------------------------------------------------
// Session management — per-call codec isolation
// ---------------------------------------------------------------------------
/**
* Create an isolated codec session. Each session gets its own Opus/G.722
* encoder/decoder state, preventing concurrent calls from corrupting each
* other's stateful codec predictions.
*/
export async function createSession(sessionId: string): Promise<boolean> {
if (!bridge || !initialized) {
// Attempt auto-reinit if bridge died.
const ok = await initCodecBridge(logFn);
if (!ok) return false;
}
try {
await bridge!.sendCommand('create_session', { session_id: sessionId });
return true;
} catch (e: any) {
logFn?.(`[codec] create_session error: ${e?.message || e}`);
return false;
}
}
/**
* Destroy a codec session, freeing its encoder/decoder state.
*/
export async function destroySession(sessionId: string): Promise<void> {
if (!bridge || !initialized) return;
try {
await bridge.sendCommand('destroy_session', { session_id: sessionId });
} catch {
// Best-effort cleanup.
}
}
// ---------------------------------------------------------------------------
// Transcoding
// ---------------------------------------------------------------------------
/**
* Transcode an RTP payload between two codecs.
* All codec work (Opus, G.722, PCMU, PCMA) + resampling happens in Rust.
*
* @param data - raw RTP payload (no header)
* @param fromPT - source payload type (0=PCMU, 8=PCMA, 9=G.722, 111=Opus)
* @param toPT - target payload type
* @param sessionId - optional session for isolated codec state
* @returns transcoded payload, or null on failure
*/
export async function transcode(data: Buffer, fromPT: number, toPT: number, sessionId?: string, direction?: string): Promise<Buffer | null> {
if (!bridge || !initialized) return null;
try {
const params: any = {
data_b64: data.toString('base64'),
from_pt: fromPT,
to_pt: toPT,
};
if (sessionId) params.session_id = sessionId;
if (direction) params.direction = direction;
const result = await bridge.sendCommand('transcode', params);
return Buffer.from(result.data_b64, 'base64');
} catch {
return null;
}
}
/**
* Encode raw 16-bit PCM to a target codec.
* @param pcmData - raw 16-bit LE PCM bytes
* @param sampleRate - input sample rate (e.g. 22050 for Piper TTS)
* @param toPT - target payload type (9=G.722, 111=Opus, 0=PCMU, 8=PCMA)
* @param sessionId - optional session for isolated codec state
*/
export async function encodePcm(pcmData: Buffer, sampleRate: number, toPT: number, sessionId?: string): Promise<Buffer | null> {
if (!bridge || !initialized) return null;
try {
const params: any = {
data_b64: pcmData.toString('base64'),
sample_rate: sampleRate,
to_pt: toPT,
};
if (sessionId) params.session_id = sessionId;
const result = await bridge.sendCommand('encode_pcm', params);
return Buffer.from(result.data_b64, 'base64');
} catch (e: any) {
console.error('[encodePcm] error:', e?.message || e);
return null;
}
}
/** Check if the codec bridge is ready. */
export function isCodecReady(): boolean {
return initialized && bridge !== null;
}
/** Shut down the codec bridge. */
export function shutdownCodecBridge(): void {
if (bridge) {
try { bridge.kill(); } catch { /* ignore */ }
bridge = null;
initialized = false;
}
}

306
ts/providerstate.ts Normal file
View 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;
}

239
ts/registrar.ts Normal file
View File

@@ -0,0 +1,239 @@
/**
* 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<string, IRegisteredDevice>();
const browserDevices = new Map<string, IRegisteredDevice>();
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') || `<sip:${rinfo.address}:${rinfo.port}>`;
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;
}

280
ts/sip/dialog.ts Normal file
View File

@@ -0,0 +1,280 @@
/**
* SipDialog — tracks the state of a SIP dialog (RFC 3261 §12).
*
* A dialog is created by a dialog-establishing request (INVITE, SUBSCRIBE, …)
* and its 1xx/2xx response. It manages local/remote tags, CSeq counters,
* the route set, and provides helpers to build in-dialog requests (ACK, BYE,
* re-INVITE, …).
*
* Usage:
* ```ts
* // Caller (UAC) side — create from the outgoing INVITE we just sent:
* const dialog = SipDialog.fromUacInvite(invite);
*
* // When a 200 OK arrives:
* dialog.processResponse(response200);
*
* // Build ACK for the 2xx:
* const ack = dialog.createAck();
*
* // Later — hang up:
* const bye = dialog.createRequest('BYE');
* ```
*/
import { SipMessage } from './message.ts';
import { generateTag, generateBranch } from './helpers.ts';
import { Buffer } from 'node:buffer';
export type TDialogState = 'early' | 'confirmed' | 'terminated';
export class SipDialog {
callId: string;
localTag: string;
remoteTag: string | null = null;
localUri: string;
remoteUri: string;
localCSeq: number;
remoteCSeq: number = 0;
routeSet: string[] = [];
remoteTarget: string; // Contact URI of the remote party
state: TDialogState = 'early';
// Transport info for sending in-dialog messages.
localHost: string;
localPort: number;
constructor(options: {
callId: string;
localTag: string;
remoteTag?: string;
localUri: string;
remoteUri: string;
localCSeq: number;
remoteTarget: string;
localHost: string;
localPort: number;
routeSet?: string[];
}) {
this.callId = options.callId;
this.localTag = options.localTag;
this.remoteTag = options.remoteTag ?? null;
this.localUri = options.localUri;
this.remoteUri = options.remoteUri;
this.localCSeq = options.localCSeq;
this.remoteTarget = options.remoteTarget;
this.localHost = options.localHost;
this.localPort = options.localPort;
this.routeSet = options.routeSet ?? [];
}
// -------------------------------------------------------------------------
// Factory: create dialog from an outgoing INVITE (UAC side)
// -------------------------------------------------------------------------
/**
* Create a dialog from an INVITE we are sending.
* The dialog enters "early" state; call `processResponse()` when
* provisional or final responses arrive.
*/
static fromUacInvite(invite: SipMessage, localHost: string, localPort: number): SipDialog {
const from = invite.getHeader('From') || '';
const to = invite.getHeader('To') || '';
return new SipDialog({
callId: invite.callId,
localTag: SipMessage.extractTag(from) || generateTag(),
localUri: SipMessage.extractUri(from) || '',
remoteUri: SipMessage.extractUri(to) || '',
localCSeq: parseInt((invite.getHeader('CSeq') || '1').split(/\s+/)[0], 10),
remoteTarget: invite.requestUri || SipMessage.extractUri(to) || '',
localHost: localHost,
localPort: localPort,
});
}
// -------------------------------------------------------------------------
// Factory: create dialog from an incoming INVITE (UAS side)
// -------------------------------------------------------------------------
/**
* Create a dialog from an INVITE we received.
* Typically used when acting as a UAS (e.g. for call-back scenarios).
*/
static fromUasInvite(invite: SipMessage, localTag: string, localHost: string, localPort: number): SipDialog {
const from = invite.getHeader('From') || '';
const to = invite.getHeader('To') || '';
const contact = invite.getHeader('Contact');
return new SipDialog({
callId: invite.callId,
localTag,
remoteTag: SipMessage.extractTag(from) || undefined,
localUri: SipMessage.extractUri(to) || '',
remoteUri: SipMessage.extractUri(from) || '',
localCSeq: 0,
remoteTarget: (contact ? SipMessage.extractUri(contact) : null) || SipMessage.extractUri(from) || '',
localHost,
localPort,
});
}
// -------------------------------------------------------------------------
// Response processing
// -------------------------------------------------------------------------
/**
* Update dialog state from a received response.
* - 1xx with To-tag → early dialog
* - 2xx → confirmed dialog
* - 3xx6xx → terminated
*/
processResponse(response: SipMessage): void {
const to = response.getHeader('To') || '';
const tag = SipMessage.extractTag(to);
const code = response.statusCode ?? 0;
// Always update remoteTag from 2xx (RFC 3261 §12.1.2: tag in 2xx is definitive).
if (tag && (code >= 200 && code < 300)) {
this.remoteTag = tag;
} else if (tag && !this.remoteTag) {
this.remoteTag = tag;
}
// Update remote target from Contact.
const contact = response.getHeader('Contact');
if (contact) {
const uri = SipMessage.extractUri(contact);
if (uri) this.remoteTarget = uri;
}
// Record-Route → route set (in reverse for UAC).
if (this.state === 'early') {
const rr: string[] = [];
for (const [n, v] of response.headers) {
if (n.toLowerCase() === 'record-route') rr.push(v);
}
if (rr.length) this.routeSet = rr.reverse();
}
if (code >= 200 && code < 300) {
this.state = 'confirmed';
} else if (code >= 300) {
this.state = 'terminated';
}
}
// -------------------------------------------------------------------------
// Request building
// -------------------------------------------------------------------------
/**
* Build an in-dialog request (BYE, re-INVITE, INFO, …).
* Automatically increments the local CSeq.
*/
createRequest(method: string, options?: {
body?: string;
contentType?: string;
extraHeaders?: [string, string][];
}): SipMessage {
this.localCSeq++;
const branch = generateBranch();
const headers: [string, string][] = [
['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`],
['From', `<${this.localUri}>;tag=${this.localTag}`],
['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`],
['Call-ID', this.callId],
['CSeq', `${this.localCSeq} ${method}`],
['Max-Forwards', '70'],
];
// Route set → Route headers.
for (const route of this.routeSet) {
headers.push(['Route', route]);
}
headers.push(['Contact', `<sip:${this.localHost}:${this.localPort}>`]);
if (options?.extraHeaders) headers.push(...options.extraHeaders);
const body = options?.body || '';
if (body && options?.contentType) {
headers.push(['Content-Type', options.contentType]);
}
headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]);
// Determine Request-URI from route set or remote target.
let ruri = this.remoteTarget;
if (this.routeSet.length) {
const topRoute = SipMessage.extractUri(this.routeSet[0]);
if (topRoute && topRoute.includes(';lr')) {
ruri = this.remoteTarget; // loose routing — RURI stays as remote target
} else if (topRoute) {
ruri = topRoute; // strict routing — top route becomes RURI
}
}
return new SipMessage(`${method} ${ruri} SIP/2.0`, headers, body);
}
/**
* Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4).
* ACK for 2xx is a new transaction, so it gets its own Via/branch.
*/
createAck(): SipMessage {
const branch = generateBranch();
const headers: [string, string][] = [
['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`],
['From', `<${this.localUri}>;tag=${this.localTag}`],
['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`],
['Call-ID', this.callId],
['CSeq', `${this.localCSeq} ACK`],
['Max-Forwards', '70'],
];
for (const route of this.routeSet) {
headers.push(['Route', route]);
}
headers.push(['Content-Length', '0']);
let ruri = this.remoteTarget;
if (this.routeSet.length) {
const topRoute = SipMessage.extractUri(this.routeSet[0]);
if (topRoute && topRoute.includes(';lr')) {
ruri = this.remoteTarget;
} else if (topRoute) {
ruri = topRoute;
}
}
return new SipMessage(`ACK ${ruri} SIP/2.0`, headers, '');
}
/**
* Build a CANCEL for the original INVITE (same branch, CSeq).
* Used before the dialog is confirmed.
*/
createCancel(originalInvite: SipMessage): SipMessage {
const via = originalInvite.getHeader('Via') || '';
const from = originalInvite.getHeader('From') || '';
const to = originalInvite.getHeader('To') || '';
const headers: [string, string][] = [
['Via', via],
['From', from],
['To', to],
['Call-ID', this.callId],
['CSeq', `${this.localCSeq} CANCEL`],
['Max-Forwards', '70'],
['Content-Length', '0'],
];
const ruri = originalInvite.requestUri || this.remoteTarget;
return new SipMessage(`CANCEL ${ruri} SIP/2.0`, headers, '');
}
/** Transition the dialog to terminated state. */
terminate(): void {
this.state = 'terminated';
}
}

190
ts/sip/helpers.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* SIP helper utilities — ID generation and SDP construction.
*/
import { randomBytes, createHash } from 'node:crypto';
// ---------------------------------------------------------------------------
// ID generators
// ---------------------------------------------------------------------------
/** Generate a random SIP Call-ID. */
export function generateCallId(domain?: string): string {
const id = randomBytes(16).toString('hex');
return domain ? `${id}@${domain}` : id;
}
/** Generate a random SIP From/To tag. */
export function generateTag(): string {
return randomBytes(8).toString('hex');
}
/** Generate a RFC 3261 compliant Via branch (starts with z9hG4bK magic cookie). */
export function generateBranch(): string {
return `z9hG4bK-${randomBytes(8).toString('hex')}`;
}
// ---------------------------------------------------------------------------
// Codec registry
// ---------------------------------------------------------------------------
const CODEC_NAMES: Record<number, string> = {
0: 'PCMU/8000',
3: 'GSM/8000',
4: 'G723/8000',
8: 'PCMA/8000',
9: 'G722/8000',
18: 'G729/8000',
101: 'telephone-event/8000',
};
/** Look up the rtpmap name for a static payload type. */
export function codecName(pt: number): string {
return CODEC_NAMES[pt] || `unknown/${pt}`;
}
// ---------------------------------------------------------------------------
// SDP builder
// ---------------------------------------------------------------------------
export interface ISdpOptions {
/** IP address for the c= and o= lines. */
ip: string;
/** Audio port for the m=audio line. */
port: number;
/** RTP payload type numbers (e.g. [9, 0, 8, 101]). */
payloadTypes?: number[];
/** SDP session ID (random if omitted). */
sessionId?: string;
/** Session name for the s= line (defaults to '-'). */
sessionName?: string;
/** Direction attribute (defaults to 'sendrecv'). */
direction?: 'sendrecv' | 'recvonly' | 'sendonly' | 'inactive';
/** Extra a= lines to append (without "a=" prefix). */
attributes?: string[];
}
/**
* Build a minimal SDP body suitable for SIP INVITE offers/answers.
*
* ```ts
* const sdp = buildSdp({
* ip: '192.168.5.66',
* port: 20000,
* payloadTypes: [9, 0, 101],
* });
* ```
*/
export function buildSdp(options: ISdpOptions): string {
const {
ip,
port,
payloadTypes = [9, 0, 8, 101],
sessionId = String(Math.floor(Math.random() * 1e9)),
sessionName = '-',
direction = 'sendrecv',
attributes = [],
} = options;
const lines: string[] = [
'v=0',
`o=- ${sessionId} ${sessionId} IN IP4 ${ip}`,
`s=${sessionName}`,
`c=IN IP4 ${ip}`,
't=0 0',
`m=audio ${port} RTP/AVP ${payloadTypes.join(' ')}`,
];
for (const pt of payloadTypes) {
const name = CODEC_NAMES[pt];
if (name) lines.push(`a=rtpmap:${pt} ${name}`);
if (pt === 101) lines.push(`a=fmtp:101 0-16`);
}
lines.push(`a=${direction}`);
for (const attr of attributes) lines.push(`a=${attr}`);
lines.push(''); // trailing CRLF
return lines.join('\r\n');
}
// ---------------------------------------------------------------------------
// SIP Digest authentication (RFC 2617)
// ---------------------------------------------------------------------------
export interface IDigestChallenge {
realm: string;
nonce: string;
algorithm?: string;
opaque?: string;
qop?: string;
}
/**
* Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value
* into its constituent fields.
*/
export function parseDigestChallenge(header: string): IDigestChallenge | null {
if (!header.toLowerCase().startsWith('digest ')) return null;
const params = header.slice(7);
const get = (key: string): string | undefined => {
const re = new RegExp(`${key}\\s*=\\s*"([^"]*)"`, 'i');
const m = params.match(re);
if (m) return m[1];
// unquoted value
const re2 = new RegExp(`${key}\\s*=\\s*([^,\\s]+)`, 'i');
const m2 = params.match(re2);
return m2 ? m2[1] : undefined;
};
const realm = get('realm');
const nonce = get('nonce');
if (!realm || !nonce) return null;
return { realm, nonce, algorithm: get('algorithm'), opaque: get('opaque'), qop: get('qop') };
}
function md5(s: string): string {
return createHash('md5').update(s).digest('hex');
}
/**
* Compute a SIP Digest `Proxy-Authorization` or `Authorization` header value.
*/
export function computeDigestAuth(options: {
username: string;
password: string;
realm: string;
nonce: string;
method: string;
uri: string;
algorithm?: string;
opaque?: string;
}): string {
const ha1 = md5(`${options.username}:${options.realm}:${options.password}`);
const ha2 = md5(`${options.method}:${options.uri}`);
const response = md5(`${ha1}:${options.nonce}:${ha2}`);
let header = `Digest username="${options.username}", realm="${options.realm}", ` +
`nonce="${options.nonce}", uri="${options.uri}", response="${response}", ` +
`algorithm=${options.algorithm || 'MD5'}`;
if (options.opaque) header += `, opaque="${options.opaque}"`;
return header;
}
/**
* Parse the audio media port and connection address from an SDP body.
* Returns null when no c= + m=audio pair is found.
*/
export function parseSdpEndpoint(sdp: string): { address: string; port: number } | null {
let addr: string | null = null;
let port: number | null = null;
for (const raw of sdp.replace(/\r\n/g, '\n').split('\n')) {
const line = raw.trim();
if (line.startsWith('c=IN IP4 ')) {
addr = line.slice('c=IN IP4 '.length).trim();
} else if (line.startsWith('m=audio ')) {
const parts = line.split(' ');
if (parts.length >= 2) port = parseInt(parts[1], 10);
}
}
return addr && port ? { address: addr, port } : null;
}

16
ts/sip/index.ts Normal file
View File

@@ -0,0 +1,16 @@
export { SipMessage } from './message.ts';
export { SipDialog } from './dialog.ts';
export type { TDialogState } from './dialog.ts';
export { rewriteSipUri, rewriteSdp } from './rewrite.ts';
export {
generateCallId,
generateTag,
generateBranch,
codecName,
buildSdp,
parseSdpEndpoint,
parseDigestChallenge,
computeDigestAuth,
} from './helpers.ts';
export type { ISdpOptions, IDigestChallenge } from './helpers.ts';
export type { IEndpoint } from './types.ts';

316
ts/sip/message.ts Normal file
View File

@@ -0,0 +1,316 @@
/**
* SipMessage — parse, inspect, mutate, and serialize SIP messages.
*
* Provides a fluent (builder-style) API so callers can chain header
* manipulations before serializing:
*
* const buf = SipMessage.parse(raw)!
* .setHeader('Contact', newContact)
* .prependHeader('Record-Route', rr)
* .updateContentLength()
* .serialize();
*/
import { Buffer } from 'node:buffer';
import { generateCallId, generateTag, generateBranch } from './helpers.ts';
const SIP_FIRST_LINE_RE = /^(?:[A-Z]+\s+\S+\s+SIP\/\d\.\d|SIP\/\d\.\d\s+\d+\s+)/;
export class SipMessage {
startLine: string;
headers: [string, string][];
body: string;
constructor(startLine: string, headers: [string, string][], body: string) {
this.startLine = startLine;
this.headers = headers;
this.body = body;
}
// -------------------------------------------------------------------------
// Parsing
// -------------------------------------------------------------------------
static parse(buf: Buffer): SipMessage | null {
if (!buf.length) return null;
if (buf[0] < 0x41 || buf[0] > 0x7a) return null;
let text: string;
try { text = buf.toString('utf8'); } catch { return null; }
let head: string;
let body: string;
let sep = text.indexOf('\r\n\r\n');
if (sep !== -1) {
head = text.slice(0, sep);
body = text.slice(sep + 4);
} else {
sep = text.indexOf('\n\n');
if (sep !== -1) {
head = text.slice(0, sep);
body = text.slice(sep + 2);
} else {
head = text;
body = '';
}
}
const lines = head.replace(/\r\n/g, '\n').split('\n');
if (!lines.length || !lines[0]) return null;
const startLine = lines[0];
if (!SIP_FIRST_LINE_RE.test(startLine)) return null;
const headers: [string, string][] = [];
for (const line of lines.slice(1)) {
if (!line.trim()) continue;
const colon = line.indexOf(':');
if (colon === -1) continue;
headers.push([line.slice(0, colon).trim(), line.slice(colon + 1).trim()]);
}
return new SipMessage(startLine, headers, body);
}
// -------------------------------------------------------------------------
// Serialization
// -------------------------------------------------------------------------
serialize(): Buffer {
const head = [this.startLine, ...this.headers.map(([n, v]) => `${n}: ${v}`)].join('\r\n') + '\r\n\r\n';
return Buffer.concat([Buffer.from(head, 'utf8'), Buffer.from(this.body || '', 'utf8')]);
}
// -------------------------------------------------------------------------
// Inspectors
// -------------------------------------------------------------------------
get isRequest(): boolean {
return !this.startLine.startsWith('SIP/');
}
get isResponse(): boolean {
return this.startLine.startsWith('SIP/');
}
/** Request method (INVITE, REGISTER, ...) or null for responses. */
get method(): string | null {
if (!this.isRequest) return null;
return this.startLine.split(' ')[0];
}
/** Response status code or null for requests. */
get statusCode(): number | null {
if (!this.isResponse) return null;
return parseInt(this.startLine.split(' ')[1], 10);
}
get callId(): string {
return this.getHeader('Call-ID') || 'noid';
}
/** Method from the CSeq header (e.g. "INVITE"). */
get cseqMethod(): string | null {
const cseq = this.getHeader('CSeq');
if (!cseq) return null;
const parts = cseq.trim().split(/\s+/);
return parts.length >= 2 ? parts[1] : null;
}
/** True for INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE. */
get isDialogEstablishing(): boolean {
return /^(INVITE|SUBSCRIBE|REFER|NOTIFY|UPDATE)\s/.test(this.startLine);
}
/** True when the body carries an SDP payload. */
get hasSdpBody(): boolean {
const ct = (this.getHeader('Content-Type') || '').toLowerCase();
return !!this.body && ct.startsWith('application/sdp');
}
// -------------------------------------------------------------------------
// Header accessors (fluent)
// -------------------------------------------------------------------------
getHeader(name: string): string | null {
const nl = name.toLowerCase();
for (const [n, v] of this.headers) if (n.toLowerCase() === nl) return v;
return null;
}
/** Overwrites the first header with the given name, or appends it. */
setHeader(name: string, value: string): this {
const nl = name.toLowerCase();
for (const h of this.headers) {
if (h[0].toLowerCase() === nl) { h[1] = value; return this; }
}
this.headers.push([name, value]);
return this;
}
/** Inserts a header at the top of the header list. */
prependHeader(name: string, value: string): this {
this.headers.unshift([name, value]);
return this;
}
/** Removes all headers with the given name. */
removeHeader(name: string): this {
const nl = name.toLowerCase();
this.headers = this.headers.filter(([n]) => n.toLowerCase() !== nl);
return this;
}
/** Recalculates Content-Length to match the current body. */
updateContentLength(): this {
const len = Buffer.byteLength(this.body || '', 'utf8');
return this.setHeader('Content-Length', String(len));
}
// -------------------------------------------------------------------------
// Start-line mutation
// -------------------------------------------------------------------------
/** Replaces the Request-URI (second token) of a request start line. */
setRequestUri(uri: string): this {
if (!this.isRequest) return this;
const parts = this.startLine.split(' ');
if (parts.length >= 2) {
parts[1] = uri;
this.startLine = parts.join(' ');
}
return this;
}
/** Returns the Request-URI (second token) of a request start line. */
get requestUri(): string | null {
if (!this.isRequest) return null;
return this.startLine.split(' ')[1] || null;
}
// -------------------------------------------------------------------------
// Factory methods — build new SIP messages from scratch
// -------------------------------------------------------------------------
/**
* Build a new SIP request.
*
* ```ts
* const invite = SipMessage.createRequest('INVITE', 'sip:user@host', {
* from: { uri: 'sip:me@proxy', tag: 'abc' },
* to: { uri: 'sip:user@host' },
* via: { host: '192.168.5.66', port: 5070 },
* contact: '<sip:me@192.168.5.66:5070>',
* });
* ```
*/
static createRequest(method: string, requestUri: string, options: {
via: { host: string; port: number; transport?: string; branch?: string };
from: { uri: string; displayName?: string; tag?: string };
to: { uri: string; displayName?: string; tag?: string };
callId?: string;
cseq?: number;
contact?: string;
maxForwards?: number;
body?: string;
contentType?: string;
extraHeaders?: [string, string][];
}): SipMessage {
const branch = options.via.branch || generateBranch();
const transport = options.via.transport || 'UDP';
const fromTag = options.from.tag || generateTag();
const callId = options.callId || generateCallId();
const cseq = options.cseq ?? 1;
const fromDisplay = options.from.displayName ? `"${options.from.displayName}" ` : '';
const toDisplay = options.to.displayName ? `"${options.to.displayName}" ` : '';
const toTag = options.to.tag ? `;tag=${options.to.tag}` : '';
const headers: [string, string][] = [
['Via', `SIP/2.0/${transport} ${options.via.host}:${options.via.port};branch=${branch};rport`],
['From', `${fromDisplay}<${options.from.uri}>;tag=${fromTag}`],
['To', `${toDisplay}<${options.to.uri}>${toTag}`],
['Call-ID', callId],
['CSeq', `${cseq} ${method}`],
['Max-Forwards', String(options.maxForwards ?? 70)],
];
if (options.contact) {
headers.push(['Contact', options.contact]);
}
if (options.extraHeaders) {
headers.push(...options.extraHeaders);
}
const body = options.body || '';
if (body && options.contentType) {
headers.push(['Content-Type', options.contentType]);
}
headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]);
return new SipMessage(`${method} ${requestUri} SIP/2.0`, headers, body);
}
/**
* Build a SIP response to an incoming request.
*
* Copies Via, From, To, Call-ID, and CSeq from the original request.
*/
static createResponse(
statusCode: number,
reasonPhrase: string,
request: SipMessage,
options?: {
toTag?: string;
contact?: string;
body?: string;
contentType?: string;
extraHeaders?: [string, string][];
},
): SipMessage {
const headers: [string, string][] = [];
// Copy all Via headers (order matters).
for (const [n, v] of request.headers) {
if (n.toLowerCase() === 'via') headers.push(['Via', v]);
}
// From — copied verbatim.
const from = request.getHeader('From');
if (from) headers.push(['From', from]);
// To — add tag if provided and not already present.
let to = request.getHeader('To') || '';
if (options?.toTag && !to.includes('tag=')) {
to += `;tag=${options.toTag}`;
}
headers.push(['To', to]);
headers.push(['Call-ID', request.callId]);
const cseq = request.getHeader('CSeq');
if (cseq) headers.push(['CSeq', cseq]);
if (options?.contact) headers.push(['Contact', options.contact]);
if (options?.extraHeaders) headers.push(...options.extraHeaders);
const body = options?.body || '';
if (body && options?.contentType) {
headers.push(['Content-Type', options.contentType]);
}
headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]);
return new SipMessage(`SIP/2.0 ${statusCode} ${reasonPhrase}`, headers, body);
}
/** Extract the tag from a From or To header value. */
static extractTag(headerValue: string): string | null {
const m = headerValue.match(/;tag=([^\s;>]+)/);
return m ? m[1] : null;
}
/** Extract the URI from an addr-spec or name-addr (From/To/Contact). */
static extractUri(headerValue: string): string | null {
const m = headerValue.match(/<([^>]+)>/);
return m ? m[1] : headerValue.trim().split(/[;>]/)[0] || null;
}
}

228
ts/sip/readme.md Normal file
View File

@@ -0,0 +1,228 @@
# ts/sip — SIP Protocol Library
A zero-dependency SIP (Session Initiation Protocol) library for Deno / Node.
Provides parsing, construction, mutation, and dialog management for SIP
messages, plus helpers for SDP bodies and URI rewriting.
## Modules
| File | Purpose |
|------|---------|
| `message.ts` | `SipMessage` class — parse, inspect, mutate, serialize |
| `dialog.ts` | `SipDialog` class — track dialog state, build in-dialog requests |
| `helpers.ts` | ID generators, codec registry, SDP builder/parser |
| `rewrite.ts` | SIP URI and SDP body rewriting |
| `types.ts` | Shared types (`IEndpoint`) |
| `index.ts` | Barrel re-export |
## Quick Start
```ts
import {
SipMessage,
SipDialog,
buildSdp,
parseSdpEndpoint,
rewriteSipUri,
rewriteSdp,
generateCallId,
generateTag,
generateBranch,
} from './sip/index.ts';
```
## SipMessage
### Parsing
```ts
import { Buffer } from 'node:buffer';
const raw = Buffer.from(
'INVITE sip:user@example.com SIP/2.0\r\n' +
'Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK776\r\n' +
'From: <sip:alice@example.com>;tag=abc\r\n' +
'To: <sip:bob@example.com>\r\n' +
'Call-ID: a84b4c76e66710@10.0.0.1\r\n' +
'CSeq: 1 INVITE\r\n' +
'Content-Length: 0\r\n\r\n'
);
const msg = SipMessage.parse(raw);
// msg.method → "INVITE"
// msg.isRequest → true
// msg.callId → "a84b4c76e66710@10.0.0.1"
// msg.cseqMethod → "INVITE"
// msg.isDialogEstablishing → true
```
### Fluent mutation
All setter methods return `this` for chaining:
```ts
const buf = SipMessage.parse(raw)!
.setHeader('Contact', '<sip:proxy@192.168.1.1:5070>')
.prependHeader('Record-Route', '<sip:192.168.1.1:5070;lr>')
.updateContentLength()
.serialize();
```
### Building requests from scratch
```ts
const invite = SipMessage.createRequest('INVITE', 'sip:+4930123@voip.example.com', {
via: { host: '192.168.5.66', port: 5070 },
from: { uri: 'sip:alice@example.com', displayName: 'Alice' },
to: { uri: 'sip:+4930123@voip.example.com' },
contact: '<sip:192.168.5.66:5070>',
body: sdpBody,
contentType: 'application/sdp',
});
// Call-ID, From tag, Via branch are auto-generated if not provided.
```
### Building responses
```ts
const ok = SipMessage.createResponse(200, 'OK', incomingInvite, {
toTag: generateTag(),
contact: '<sip:192.168.5.66:5070>',
body: answerSdp,
contentType: 'application/sdp',
});
```
### Inspectors
| Property | Type | Description |
|----------|------|-------------|
| `isRequest` | `boolean` | True for requests (INVITE, BYE, ...) |
| `isResponse` | `boolean` | True for responses (SIP/2.0 200 OK, ...) |
| `method` | `string \| null` | Request method or null |
| `statusCode` | `number \| null` | Response status code or null |
| `callId` | `string` | Call-ID header value |
| `cseqMethod` | `string \| null` | Method from CSeq header |
| `requestUri` | `string \| null` | Request-URI (second token of start line) |
| `isDialogEstablishing` | `boolean` | INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE |
| `hasSdpBody` | `boolean` | Body present with Content-Type: application/sdp |
### Static helpers
```ts
SipMessage.extractTag('<sip:alice@x.com>;tag=abc') // → "abc"
SipMessage.extractUri('"Alice" <sip:alice@x.com>') // → "sip:alice@x.com"
```
## SipDialog
Tracks dialog state per RFC 3261 §12. A dialog is created from a
dialog-establishing request and updated as responses arrive.
### UAC (caller) side
```ts
// 1. Build and send INVITE
const invite = SipMessage.createRequest('INVITE', destUri, { ... });
const dialog = SipDialog.fromUacInvite(invite, '192.168.5.66', 5070);
// 2. Process responses as they arrive
dialog.processResponse(trying100); // state stays 'early'
dialog.processResponse(ringing180); // state stays 'early', remoteTag learned
dialog.processResponse(ok200); // state → 'confirmed'
// 3. ACK the 200
const ack = dialog.createAck();
// 4. In-dialog requests
const bye = dialog.createRequest('BYE');
dialog.terminate();
```
### UAS (callee) side
```ts
const dialog = SipDialog.fromUasInvite(incomingInvite, generateTag(), localHost, localPort);
```
### CANCEL (before answer)
```ts
const cancel = dialog.createCancel(originalInvite);
```
### Dialog states
`'early'``'confirmed'``'terminated'`
## Helpers
### ID generation
```ts
generateCallId() // → "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
generateCallId('example.com') // → "a3f8b2c1...@example.com"
generateTag() // → "1a2b3c4d5e6f7a8b"
generateBranch() // → "z9hG4bK-1a2b3c4d5e6f7a8b"
```
### SDP builder
```ts
const sdp = buildSdp({
ip: '192.168.5.66',
port: 20000,
payloadTypes: [9, 0, 8, 101], // G.722, PCMU, PCMA, telephone-event
direction: 'sendrecv',
});
```
### SDP parser
```ts
const ep = parseSdpEndpoint(sdpBody);
// → { address: '10.0.0.1', port: 20000 } or null
```
### Codec names
```ts
codecName(9) // → "G722/8000"
codecName(0) // → "PCMU/8000"
codecName(101) // → "telephone-event/8000"
```
## Rewriting
### SIP URI
Replaces the host:port in all `sip:` / `sips:` URIs found in a header value:
```ts
rewriteSipUri('<sip:user@10.0.0.1:5060>', '203.0.113.1', 5070)
// → '<sip:user@203.0.113.1:5070>'
```
### SDP body
Rewrites the connection address and audio media port, returning the original
endpoint that was replaced:
```ts
const { body, original } = rewriteSdp(sdpBody, '203.0.113.1', 20000);
// original → { address: '10.0.0.1', port: 8000 }
```
## Architecture Notes
This library is intentionally low-level — it operates on individual messages
and dialogs rather than providing a full SIP stack with transport and
transaction layers. This makes it suitable for building:
- **SIP proxies** — parse, rewrite headers/SDP, serialize, forward
- **B2BUA (back-to-back user agents)** — manage two dialogs, bridge media
- **SIP testing tools** — craft and send arbitrary messages
- **Protocol analyzers** — parse and inspect SIP traffic
The library does not manage sockets, timers, or retransmissions — those
concerns belong to the application layer.

54
ts/sip/rewrite.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* SIP URI and SDP body rewriting helpers.
*/
import type { IEndpoint } from './types.ts';
const SIP_URI_RE = /(sips?:)([^@>;,\s]+@)?([^>;,\s:]+)(:\d+)?/g;
/**
* Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
*/
export function rewriteSipUri(value: string, host: string, port: number): string {
return value.replace(SIP_URI_RE, (_m, scheme: string, userpart?: string) =>
`${scheme}${userpart || ''}${host}:${port}`);
}
/**
* Rewrites the connection address (`c=`) and audio media port (`m=audio`)
* in an SDP body. Returns the rewritten body together with the original
* endpoint that was replaced (if any).
*/
export function rewriteSdp(
body: string,
ip: string,
port: number,
): { body: string; original: IEndpoint | null } {
let origAddr: string | null = null;
let origPort: number | null = null;
const out = body
.replace(/\r\n/g, '\n')
.split('\n')
.map((line) => {
if (line.startsWith('c=IN IP4 ')) {
origAddr = line.slice('c=IN IP4 '.length).trim();
return `c=IN IP4 ${ip}`;
}
if (line.startsWith('m=audio ')) {
const parts = line.split(' ');
if (parts.length >= 2) {
origPort = parseInt(parts[1], 10);
parts[1] = String(port);
}
return parts.join(' ');
}
return line;
})
.join('\r\n');
return {
body: out,
original: origAddr && origPort ? { address: origAddr, port: origPort } : null,
};
}

8
ts/sip/types.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Shared SIP types.
*/
export interface IEndpoint {
address: string;
port: number;
}

283
ts/sipproxy.ts Normal file
View File

@@ -0,0 +1,283 @@
/**
* SIP proxy — hub model entry point.
*
* Thin bootstrap that wires together:
* - UDP socket for all SIP signaling
* - CallManager (the hub model core)
* - Provider registration
* - Local device registrar
* - WebRTC signaling
* - Web dashboard
* - Rust codec bridge
*
* All call/media logic lives in ts/call/.
*/
import dgram from 'node:dgram';
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { SipMessage } from './sip/index.ts';
import type { IEndpoint } from './sip/index.ts';
import { loadConfig, getProviderForOutbound } from './config.ts';
import type { IAppConfig, IProviderConfig } from './config.ts';
import {
initProviderStates,
syncProviderStates,
getProviderByUpstreamAddress,
handleProviderRegistrationResponse,
} from './providerstate.ts';
import {
initRegistrar,
handleDeviceRegister,
isKnownDeviceAddress,
getAllDeviceStatuses,
} from './registrar.ts';
import { broadcastWs, initWebUi } from './frontend.ts';
import {
initWebRtcSignaling,
sendToBrowserDevice,
getAllBrowserDeviceIds,
getBrowserDeviceWs,
} from './webrtcbridge.ts';
import { initCodecBridge } from './opusbridge.ts';
import { initAnnouncement } from './announcement.ts';
import { CallManager } from './call/index.ts';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
let appConfig: IAppConfig = loadConfig();
const { proxy } = appConfig;
const LAN_IP = proxy.lanIp;
const LAN_PORT = proxy.lanPort;
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
const startTime = Date.now();
const instanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
function now(): string {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
function log(msg: string): void {
const line = `${now()} ${msg}\n`;
fs.appendFileSync(LOG_PATH, line);
process.stdout.write(line);
broadcastWs('log', { message: msg });
}
function logPacket(label: string, data: Buffer): void {
const head = `\n========== ${now()} ${label} (${data.length}b) ==========\n`;
const looksText = data.length > 0 && data[0] >= 0x41 && data[0] <= 0x7a;
const body = looksText
? data.toString('utf8')
: `[${data.length} bytes binary] ${data.toString('hex').slice(0, 80)}`;
fs.appendFileSync(LOG_PATH, head + body + '\n');
}
// ---------------------------------------------------------------------------
// Initialize subsystems
// ---------------------------------------------------------------------------
const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSeed);
initRegistrar(appConfig.devices, log);
const callManager = new CallManager({
appConfig,
sendSip: (buf, dest) => sock.send(buf, dest.port, dest.address),
log,
broadcastWs,
getProviderState: (id) => providerStates.get(id),
getAllBrowserDeviceIds,
sendToBrowserDevice,
getBrowserDeviceWs,
});
// Initialize WebRTC signaling (browser device registration only).
initWebRtcSignaling({ log });
// ---------------------------------------------------------------------------
// Status snapshot (fed to web dashboard)
// ---------------------------------------------------------------------------
function getStatus() {
const providers: unknown[] = [];
for (const ps of providerStates.values()) {
providers.push({
id: ps.config.id,
displayName: ps.config.displayName,
registered: ps.isRegistered,
publicIp: ps.publicIp,
});
}
return {
instanceId,
uptime: Math.floor((Date.now() - startTime) / 1000),
lanIp: LAN_IP,
providers,
devices: getAllDeviceStatuses(),
calls: callManager.getStatus(),
callHistory: callManager.getHistory(),
contacts: appConfig.contacts || [],
};
}
// ---------------------------------------------------------------------------
// Main UDP socket
// ---------------------------------------------------------------------------
const sock = dgram.createSocket('udp4');
sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
try {
const ps = getProviderByUpstreamAddress(rinfo.address, rinfo.port);
const msg = SipMessage.parse(data);
if (!msg) {
// Non-SIP data — forward raw based on direction.
if (ps) {
// From provider, forward to... nowhere useful without a call context.
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data);
} else {
// From device, forward to default provider.
const dp = getProviderForOutbound(appConfig);
if (dp) sock.send(data, dp.outboundProxy.port, dp.outboundProxy.address);
}
return;
}
// 1. Provider registration responses — consumed by providerstate.
if (handleProviderRegistrationResponse(msg)) return;
// 2. Device REGISTER — handled by local registrar.
if (!ps && msg.method === 'REGISTER') {
const response = handleDeviceRegister(msg, { address: rinfo.address, port: rinfo.port });
if (response) {
sock.send(response.serialize(), rinfo.port, rinfo.address);
return;
}
}
// 3. Route to existing call by SIP Call-ID.
if (callManager.routeSipMessage(msg, rinfo)) {
return;
}
// 4. New inbound call from provider.
if (ps && msg.isRequest && msg.method === 'INVITE') {
logPacket(`[new inbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
// Detect public IP from Via.
const via = msg.getHeader('Via');
if (via) ps.detectPublicIp(via);
callManager.createInboundCall(ps, msg, { address: rinfo.address, port: rinfo.port });
return;
}
// 5. New outbound call from device (passthrough).
if (!ps && msg.isRequest && msg.method === 'INVITE') {
logPacket(`[new outbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
const provider = getProviderForOutbound(appConfig);
if (provider) {
const provState = providerStates.get(provider.id);
if (provState) {
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, provider, provState);
}
}
return;
}
// 6. Fallback: forward based on direction (for mid-dialog messages
// that don't match any tracked call, e.g. OPTIONS, NOTIFY).
if (ps) {
// From provider -> forward to device.
logPacket(`[fallback inbound] from ${rinfo.address}:${rinfo.port}`, data);
const via = msg.getHeader('Via');
if (via) ps.detectPublicIp(via);
// Try to figure out where to send it...
// For now, just log. These should become rare once all calls are tracked.
log(`[fallback] unrouted inbound ${msg.isRequest ? msg.method : msg.statusCode} Call-ID=${msg.callId.slice(0, 30)}`);
} else {
// From device -> forward to provider.
logPacket(`[fallback outbound] from ${rinfo.address}:${rinfo.port}`, data);
const provider = getProviderForOutbound(appConfig);
if (provider) sock.send(msg.serialize(), provider.outboundProxy.port, provider.outboundProxy.address);
}
} catch (e: any) {
log(`[err] ${e?.stack || e}`);
}
});
sock.on('error', (err: Error) => log(`[main] sock err: ${err.message}`));
sock.bind(LAN_PORT, '0.0.0.0', () => {
const providerList = appConfig.providers.map((p) => p.displayName).join(', ');
const deviceList = appConfig.devices.map((d) => d.displayName).join(', ');
log(`sip proxy bound 0.0.0.0:${LAN_PORT} | providers: ${providerList} | devices: ${deviceList}`);
// Start upstream provider registrations.
for (const ps of providerStates.values()) {
ps.startRegistration(
LAN_IP,
LAN_PORT,
(buf, dest) => sock.send(buf, dest.port, dest.address),
log,
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
);
}
// Initialize audio codec bridge (Rust binary via smartrust).
initCodecBridge(log)
.then(() => initAnnouncement(log))
.catch((e) => log(`[codec] init failed: ${e}`));
});
// ---------------------------------------------------------------------------
// Web UI
// ---------------------------------------------------------------------------
initWebUi(
getStatus,
log,
(number, deviceId, providerId) => {
const call = callManager.createOutboundCall(number, deviceId, providerId);
return call ? { id: call.id } : null;
},
(callId) => callManager.hangup(callId),
() => {
// Reload config after UI save.
try {
const fresh = loadConfig();
Object.assign(appConfig, fresh);
// Sync provider registrations: add new, remove deleted, re-register changed.
syncProviderStates(
fresh.providers,
proxy.publicIpSeed,
LAN_IP,
LAN_PORT,
(buf, dest) => sock.send(buf, dest.port, dest.address),
log,
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
);
log('[config] reloaded config after save');
} catch (e: any) {
log(`[config] reload failed: ${e.message}`);
}
},
callManager,
);
process.on('SIGINT', () => { log('SIGINT, exiting'); process.exit(0); });
process.on('SIGTERM', () => { log('SIGTERM, exiting'); process.exit(0); });

124
ts/webrtcbridge.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* WebRTC signaling — browser device registration and WebSocket dispatch.
*
* This module handles ONLY the signaling side:
* - Browser device registration/unregistration via WebSocket
* - WS → deviceId mapping
*
* All WebRTC media logic (PeerConnection, RTP, transcoding) lives in
* ts/call/webrtc-leg.ts and is managed by the CallManager.
*/
import { WebSocket } from 'ws';
import { registerBrowserDevice, unregisterBrowserDevice, shortHash } from './registrar.ts';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
interface IWebRtcSignalingConfig {
log: (msg: string) => void;
}
let config: IWebRtcSignalingConfig;
// ---------------------------------------------------------------------------
// State: WS ↔ deviceId mapping
// ---------------------------------------------------------------------------
const wsToSession = new WeakMap<WebSocket, string>();
const deviceIdToWs = new Map<string, WebSocket>();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function initWebRtcSignaling(cfg: IWebRtcSignalingConfig): void {
config = cfg;
}
/**
* Handle a WebRTC signaling message from a browser client.
* Only handles registration; offer/ice/hangup are routed through CallManager.
*/
export function handleWebRtcSignaling(
ws: WebSocket,
message: { type: string; sessionId?: string; [key: string]: any },
): void {
const { type } = message;
if (type === 'webrtc-register') {
handleRegister(ws, message.sessionId!, message.userAgent, message._remoteIp);
}
// Other webrtc-* types (offer, ice, hangup, accept) are handled
// by the CallManager via frontend.ts WebSocket handler.
}
/**
* Send a message to a specific browser device by its device ID.
*/
export function sendToBrowserDevice(deviceId: string, data: unknown): boolean {
const ws = deviceIdToWs.get(deviceId);
if (!ws) return false;
wsSend(ws, data);
return true;
}
/**
* Get the WebSocket for a browser device (used by CallManager to create WebRtcLegs).
*/
export function getBrowserDeviceWs(deviceId: string): WebSocket | null {
return deviceIdToWs.get(deviceId) ?? null;
}
/**
* Get all registered browser device IDs.
*/
export function getAllBrowserDeviceIds(): string[] {
return [...deviceIdToWs.keys()];
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
function handleRegister(ws: WebSocket, sessionId: string, userAgent?: string, remoteIp?: string): void {
// Clean up any previous browser device from this same WS connection.
const prevSession = wsToSession.get(ws);
if (prevSession && prevSession !== sessionId) {
unregisterBrowserDevice(prevSession);
}
unregisterBrowserDevice(sessionId);
registerBrowserDevice(sessionId, userAgent, remoteIp);
wsToSession.set(ws, sessionId);
config.log(`[webrtc:${sessionId.slice(0, 8)}] browser registered as device`);
const deviceId = `browser-${shortHash(sessionId)}`;
deviceIdToWs.set(deviceId, ws);
// Echo back the assigned device ID.
wsSend(ws, { type: 'webrtc-registered', deviceId });
// Only add close handler once per WS connection.
if (!prevSession) {
ws.on('close', () => {
const sid = wsToSession.get(ws) || sessionId;
config.log(`[webrtc:${sid.slice(0, 8)}] browser disconnected`);
deviceIdToWs.delete(`browser-${shortHash(sid)}`);
unregisterBrowserDevice(sid);
});
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function wsSend(ws: WebSocket, data: unknown): void {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
} catch { /* ignore */ }
}