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.
191 lines
5.7 KiB
TypeScript
191 lines
5.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|