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:
280
ts/sip/dialog.ts
Normal file
280
ts/sip/dialog.ts
Normal 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
|
||||
* - 3xx–6xx → 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
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;
|
||||
}
|
||||
16
ts/sip/index.ts
Normal file
16
ts/sip/index.ts
Normal 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
316
ts/sip/message.ts
Normal 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
228
ts/sip/readme.md
Normal 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
54
ts/sip/rewrite.ts
Normal 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
8
ts/sip/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Shared SIP types.
|
||||
*/
|
||||
|
||||
export interface IEndpoint {
|
||||
address: string;
|
||||
port: number;
|
||||
}
|
||||
Reference in New Issue
Block a user