Files
siprouter/ts/sip/helpers.ts
Juergen Kunz f3e1c96872 initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone
via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML
noise suppression, Kokoro neural TTS announcements, and a Lit-based
web dashboard with live call monitoring and REST API.
2026-04-09 23:03:55 +00:00

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