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:
190
ts/sip/helpers.ts
Normal file
190
ts/sip/helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user