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