/** * SipLeg — a SIP connection from the Call hub to a device or provider. * * Wraps a SipDialog and an RTP socket. Handles: * - INVITE/ACK/BYE/CANCEL lifecycle * - SDP rewriting (LAN IP for devices, public IP for providers) * - Digest auth for provider legs (407/401) * - Early-media silence for providers with quirks * - Record-Route insertion for dialog-establishing requests */ import dgram from 'node:dgram'; import { Buffer } from 'node:buffer'; import { SipMessage, SipDialog, buildSdp, parseSdpEndpoint, rewriteSdp, rewriteSipUri, parseDigestChallenge, computeDigestAuth, generateTag, } from '../sip/index.ts'; import type { IEndpoint } from '../sip/index.ts'; import type { IProviderConfig, IQuirks } from '../config.ts'; import type { TLegState, TLegType, ILegStatus } from './types.ts'; import type { ILeg } from './leg.ts'; import { codecDisplayName } from './leg.ts'; import type { IRtpTranscoder } from '../codec.ts'; // --------------------------------------------------------------------------- // SipLeg config // --------------------------------------------------------------------------- export interface ISipLegConfig { /** Whether this leg faces a device (LAN) or a provider (WAN). */ role: 'device' | 'provider'; /** Proxy LAN IP (for SDP rewriting toward devices). */ lanIp: string; /** Proxy LAN port (for Via, Contact, Record-Route). */ lanPort: number; /** Public IP (for SDP rewriting toward providers). */ getPublicIp: () => string | null; /** Send a SIP message via the main UDP socket. */ sendSip: (buf: Buffer, dest: IEndpoint) => void; /** Logging function. */ log: (msg: string) => void; /** Provider config (for provider legs: auth, codecs, quirks, outbound proxy). */ provider?: IProviderConfig; /** The endpoint to send SIP messages to (device address or provider outbound proxy). */ sipTarget: IEndpoint; /** RTP port and socket (pre-allocated from the pool). */ rtpPort: number; rtpSock: dgram.Socket; /** Payload types to offer in SDP. */ payloadTypes?: number[]; /** Registered AOR (for From header in provider leg). */ getRegisteredAor?: () => string | null; /** SIP password (for digest auth). */ getSipPassword?: () => string | null; } // --------------------------------------------------------------------------- // SipLeg // --------------------------------------------------------------------------- export class SipLeg implements ILeg { readonly id: string; readonly type: TLegType; state: TLegState = 'inviting'; readonly config: ISipLegConfig; /** The SIP dialog for this leg. */ dialog: SipDialog | null = null; /** Original INVITE (needed for CANCEL). */ invite: SipMessage | null = null; /** Original unauthenticated INVITE (for re-ACKing retransmitted 407s). */ private origInvite: SipMessage | null = null; /** Whether we've attempted digest auth on this leg. */ private authAttempted = false; /** RTP socket and port. */ readonly rtpPort: number; readonly rtpSock: dgram.Socket; /** Remote media endpoint (learned from SDP). */ remoteMedia: IEndpoint | null = null; /** Negotiated codec. */ codec: number | null = null; /** Transcoder (set by Call when codecs differ between legs). */ transcoder: IRtpTranscoder | null = null; /** Stable SSRC for this leg (used for silence + forwarded audio). */ readonly ssrc: number = (Math.random() * 0xffffffff) >>> 0; /** Packet counters. */ pktSent = 0; pktReceived = 0; /** Callback set by Call to receive RTP. */ onRtpReceived: ((data: Buffer) => void) | null = null; /** Silence stream timer (for provider quirks). */ private silenceTimer: ReturnType | null = null; /** Callbacks for lifecycle events. */ onStateChange: ((leg: SipLeg) => void) | null = null; onConnected: ((leg: SipLeg) => void) | null = null; onTerminated: ((leg: SipLeg) => void) | null = null; /** Callback for SIP INFO messages (used for DTMF relay). */ onInfoReceived: ((msg: SipMessage) => void) | null = null; constructor(id: string, config: ISipLegConfig) { this.id = id; this.type = config.role === 'device' ? 'sip-device' : 'sip-provider'; this.config = config; this.rtpPort = config.rtpPort; this.rtpSock = config.rtpSock; // Set up RTP receive handler. this.rtpSock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => { this.pktReceived++; // Learn remote media endpoint from first packet if not yet known. if (!this.remoteMedia) { this.remoteMedia = { address: rinfo.address, port: rinfo.port }; this.config.log(`[sip-leg:${this.id}] learned remote media: ${rinfo.address}:${rinfo.port}`); } // Forward to the Call hub. if (this.onRtpReceived) { this.onRtpReceived(data); } }); this.rtpSock.on('error', (e: Error) => { this.config.log(`[sip-leg:${this.id}] rtp error: ${e.message}`); }); } get sipCallId(): string { return this.dialog?.callId || 'no-dialog'; } // ------------------------------------------------------------------------- // Outbound INVITE (B2BUA mode — create a new dialog) // ------------------------------------------------------------------------- /** * Send an INVITE to establish this leg. * Creates a new SipDialog (UAC side). */ sendInvite(options: { fromUri: string; toUri: string; callId: string; fromTag?: string; fromDisplayName?: string; cseq?: number; extraHeaders?: [string, string][]; }): void { const ip = this.type === 'sip-provider' ? (this.config.getPublicIp() || this.config.lanIp) : this.config.lanIp; const pts = this.config.payloadTypes || [9, 0, 8, 101]; const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts }); const invite = SipMessage.createRequest('INVITE', options.toUri, { via: { host: ip, port: this.config.lanPort }, from: { uri: options.fromUri, displayName: options.fromDisplayName, tag: options.fromTag }, to: { uri: options.toUri }, callId: options.callId, cseq: options.cseq, contact: ``, body: sdp, contentType: 'application/sdp', extraHeaders: options.extraHeaders, }); this.invite = invite; this.dialog = SipDialog.fromUacInvite(invite, ip, this.config.lanPort); this.state = 'inviting'; this.config.log(`[sip-leg:${this.id}] INVITE -> ${this.config.sipTarget.address}:${this.config.sipTarget.port}`); this.config.sendSip(invite.serialize(), this.config.sipTarget); } // ------------------------------------------------------------------------- // Passthrough mode — forward a SIP message with rewriting // ------------------------------------------------------------------------- /** * Accept an incoming INVITE as a UAS (for passthrough inbound calls). * Creates a SipDialog on the UAS side. */ acceptIncoming(invite: SipMessage): void { const localTag = generateTag(); this.dialog = SipDialog.fromUasInvite(invite, localTag, this.config.lanIp, this.config.lanPort); this.invite = invite; this.state = 'inviting'; // Learn remote media from SDP. if (invite.hasSdpBody) { const ep = parseSdpEndpoint(invite.body); if (ep) { this.remoteMedia = ep; this.config.log(`[sip-leg:${this.id}] media from SDP: ${ep.address}:${ep.port}`); } } } /** * Forward a SIP message through this leg with SDP rewriting. * Used for passthrough calls where the proxy relays messages. */ forwardMessage(msg: SipMessage, dest: IEndpoint): void { const rewriteIp = this.type === 'sip-provider' ? (this.config.getPublicIp() || this.config.lanIp) : this.config.lanIp; // Rewrite SDP if present. if (msg.hasSdpBody) { const { body, original } = rewriteSdp(msg.body, rewriteIp, this.rtpPort); msg.body = body; msg.updateContentLength(); if (original) { this.remoteMedia = original; this.config.log(`[sip-leg:${this.id}] media from SDP rewrite: ${original.address}:${original.port}`); } } // Record-Route for dialog-establishing requests. if (msg.isRequest && msg.isDialogEstablishing) { msg.prependHeader('Record-Route', ``); } // Rewrite Contact. if (this.type === 'sip-provider') { const contact = msg.getHeader('Contact'); if (contact) { const nc = rewriteSipUri(contact, rewriteIp, this.config.lanPort); if (nc !== contact) msg.setHeader('Contact', nc); } } // Rewrite Request-URI for inbound messages going to device. if (this.type === 'sip-device' && msg.isRequest) { msg.setRequestUri(rewriteSipUri(msg.requestUri!, dest.address, dest.port)); } this.config.sendSip(msg.serialize(), dest); } // ------------------------------------------------------------------------- // SIP message handling (routed by CallManager) // ------------------------------------------------------------------------- handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void { if (msg.isResponse) { this.handleResponse(msg, rinfo); } else { this.handleRequest(msg, rinfo); } } private handleResponse(msg: SipMessage, _rinfo: IEndpoint): void { const code = msg.statusCode ?? 0; const method = msg.cseqMethod?.toUpperCase(); this.config.log(`[sip-leg:${this.id}] <- ${code} (${method})`); if (method === 'INVITE') { this.handleInviteResponse(msg, code); } // BYE/CANCEL responses don't need action beyond logging. } private handleInviteResponse(msg: SipMessage, code: number): void { // Handle retransmitted 407 for the original unauthenticated INVITE. if (this.authAttempted && this.dialog) { const responseCSeqNum = parseInt((msg.getHeader('CSeq') || '').split(/\s+/)[0], 10); if (responseCSeqNum < this.dialog.localCSeq && code >= 400) { if (this.origInvite) { const ack = buildNon2xxAck(this.origInvite, msg); this.config.sendSip(ack.serialize(), this.config.sipTarget); } return; } } // Handle 407 Proxy Authentication Required. if (code === 407 && this.type === 'sip-provider') { this.handleAuthChallenge(msg); return; } // Update dialog state. if (this.dialog) { this.dialog.processResponse(msg); } if (code === 180 || code === 183) { this.state = 'ringing'; this.onStateChange?.(this); } else if (code >= 200 && code < 300) { // ACK the 200 OK. if (this.dialog) { const ack = this.dialog.createAck(); this.config.sendSip(ack.serialize(), this.config.sipTarget); this.config.log(`[sip-leg:${this.id}] ACK sent`); } // If already connected (200 retransmit), just re-ACK. if (this.state === 'connected') { this.config.log(`[sip-leg:${this.id}] re-ACK (200 retransmit)`); return; } // Learn media endpoint from SDP. if (msg.hasSdpBody) { const ep = parseSdpEndpoint(msg.body); if (ep) { this.remoteMedia = ep; this.config.log(`[sip-leg:${this.id}] media = ${ep.address}:${ep.port}`); } } this.state = 'connected'; this.config.log(`[sip-leg:${this.id}] CONNECTED`); // Start silence for provider legs with early media quirks. if (this.type === 'sip-provider') { this.startSilence(); } // Prime the RTP path. if (this.remoteMedia) { this.primeRtp(this.remoteMedia); } this.onConnected?.(this); this.onStateChange?.(this); } else if (code >= 300) { this.config.log(`[sip-leg:${this.id}] rejected ${code}`); this.state = 'terminated'; if (this.dialog) this.dialog.terminate(); this.onTerminated?.(this); this.onStateChange?.(this); } } private handleAuthChallenge(msg: SipMessage): void { if (this.authAttempted) { this.config.log(`[sip-leg:${this.id}] 407 after auth attempt — credentials rejected`); this.state = 'terminated'; if (this.dialog) this.dialog.terminate(); this.onTerminated?.(this); return; } this.authAttempted = true; const challenge = msg.getHeader('Proxy-Authenticate'); if (!challenge) { this.config.log(`[sip-leg:${this.id}] 407 but no Proxy-Authenticate`); this.state = 'terminated'; if (this.dialog) this.dialog.terminate(); this.onTerminated?.(this); return; } const parsed = parseDigestChallenge(challenge); if (!parsed) { this.config.log(`[sip-leg:${this.id}] could not parse digest challenge`); this.state = 'terminated'; if (this.dialog) this.dialog.terminate(); this.onTerminated?.(this); return; } const password = this.config.getSipPassword?.(); const aor = this.config.getRegisteredAor?.(); if (!password || !aor) { this.config.log(`[sip-leg:${this.id}] 407 but no password or AOR`); this.state = 'terminated'; if (this.dialog) this.dialog.terminate(); this.onTerminated?.(this); return; } const username = aor.replace(/^sips?:/, '').split('@')[0]; const destUri = this.invite?.requestUri || ''; const authValue = computeDigestAuth({ username, password, realm: parsed.realm, nonce: parsed.nonce, method: 'INVITE', uri: destUri, algorithm: parsed.algorithm, opaque: parsed.opaque, }); // ACK the 407. if (this.invite) { const ack407 = buildNon2xxAck(this.invite, msg); this.config.sendSip(ack407.serialize(), this.config.sipTarget); this.config.log(`[sip-leg:${this.id}] ACK-407 sent`); } // Keep original INVITE for re-ACKing retransmitted 407s. this.origInvite = this.invite; // Resend INVITE with auth, same From tag, incremented CSeq. const ip = this.config.getPublicIp() || this.config.lanIp; const fromTag = this.dialog!.localTag; const pts = this.config.payloadTypes || [9, 0, 8, 101]; const sdp = buildSdp({ ip, port: this.rtpPort, payloadTypes: pts }); const inviteAuth = SipMessage.createRequest('INVITE', destUri, { via: { host: ip, port: this.config.lanPort }, from: { uri: aor, tag: fromTag }, to: { uri: destUri }, callId: this.dialog!.callId, cseq: 2, contact: ``, body: sdp, contentType: 'application/sdp', extraHeaders: [['Proxy-Authorization', authValue]], }); this.invite = inviteAuth; this.dialog!.localCSeq = 2; this.config.log(`[sip-leg:${this.id}] resending INVITE with auth`); this.config.sendSip(inviteAuth.serialize(), this.config.sipTarget); } private handleRequest(msg: SipMessage, rinfo: IEndpoint): void { const method = msg.method; this.config.log(`[sip-leg:${this.id}] <- ${method} from ${rinfo.address}:${rinfo.port}`); if (method === 'BYE') { // Send 200 OK to the BYE. const ok = SipMessage.createResponse(200, 'OK', msg); this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port }); this.state = 'terminated'; if (this.dialog) this.dialog.terminate(); this.onTerminated?.(this); this.onStateChange?.(this); } if (method === 'INFO') { // Respond 200 OK to the INFO request. const ok = SipMessage.createResponse(200, 'OK', msg); this.config.sendSip(ok.serialize(), { address: rinfo.address, port: rinfo.port }); // Forward to DTMF handler (if attached). this.onInfoReceived?.(msg); } // Other in-dialog requests (re-INVITE, etc.) can be handled here in the future. } // ------------------------------------------------------------------------- // Send BYE / CANCEL // ------------------------------------------------------------------------- /** Send BYE (if confirmed) or CANCEL (if early) to tear down this leg. */ sendHangup(): void { if (!this.dialog) return; if (this.dialog.state === 'confirmed') { const bye = this.dialog.createRequest('BYE'); this.config.sendSip(bye.serialize(), this.config.sipTarget); this.config.log(`[sip-leg:${this.id}] BYE sent`); } else if (this.dialog.state === 'early' && this.invite) { const cancel = this.dialog.createCancel(this.invite); this.config.sendSip(cancel.serialize(), this.config.sipTarget); this.config.log(`[sip-leg:${this.id}] CANCEL sent`); } this.state = 'terminating'; this.dialog.terminate(); } // ------------------------------------------------------------------------- // RTP // ------------------------------------------------------------------------- sendRtp(data: Buffer): void { if (!this.remoteMedia) return; this.rtpSock.send(data, this.remoteMedia.port, this.remoteMedia.address); this.pktSent++; } /** Send a 1-byte UDP packet to punch NAT hole. */ private primeRtp(peer: IEndpoint): void { try { this.rtpSock.send(Buffer.alloc(1), peer.port, peer.address); this.config.log(`[sip-leg:${this.id}] RTP primed -> ${peer.address}:${peer.port}`); } catch (e: any) { this.config.log(`[sip-leg:${this.id}] prime error: ${e.message}`); } } // ------------------------------------------------------------------------- // Silence stream (provider quirks) // ------------------------------------------------------------------------- private startSilence(): void { if (this.silenceTimer) return; const quirks = this.config.provider?.quirks; if (!quirks?.earlyMediaSilence) return; if (!this.remoteMedia) return; const PT = quirks.silencePayloadType ?? 9; const MAX = quirks.silenceMaxPackets ?? 250; const PAYLOAD = 160; let seq = Math.floor(Math.random() * 0xffff); let rtpTs = Math.floor(Math.random() * 0xffffffff); let count = 0; // Use proper silence byte for the codec (0x00 is NOT silence for most codecs). const silenceByte = silenceByteForPT(PT); this.silenceTimer = setInterval(() => { if (this.pktReceived > 0 || count >= MAX) { clearInterval(this.silenceTimer!); this.silenceTimer = null; this.config.log(`[sip-leg:${this.id}] silence stop after ${count} pkts`); return; } const pkt = Buffer.alloc(12 + PAYLOAD, silenceByte); // RTP header (first 12 bytes). pkt[0] = 0x80; pkt[1] = PT; pkt.writeUInt16BE(seq & 0xffff, 2); pkt.writeUInt32BE(rtpTs >>> 0, 4); pkt.writeUInt32BE(this.ssrc >>> 0, 8); // stable SSRC this.rtpSock.send(pkt, this.remoteMedia!.port, this.remoteMedia!.address); seq++; rtpTs += PAYLOAD; count++; }, 20); this.config.log(`[sip-leg:${this.id}] silence start -> ${this.remoteMedia.address}:${this.remoteMedia.port} (ssrc=${this.ssrc})`); } // ------------------------------------------------------------------------- // Lifecycle // ------------------------------------------------------------------------- teardown(): void { if (this.silenceTimer) { clearInterval(this.silenceTimer); this.silenceTimer = null; } this.state = 'terminated'; if (this.dialog) this.dialog.terminate(); // Note: RTP socket is NOT closed here — the RtpPortPool manages that. } getStatus(): ILegStatus { return { id: this.id, type: this.type, state: this.state, remoteMedia: this.remoteMedia, rtpPort: this.rtpPort, pktSent: this.pktSent, pktReceived: this.pktReceived, codec: codecDisplayName(this.codec), transcoding: this.transcoder !== null, }; } } // --------------------------------------------------------------------------- // Helper: proper silence byte per codec // --------------------------------------------------------------------------- /** Return the byte value representing digital silence for a given RTP payload type. */ function silenceByteForPT(pt: number): number { switch (pt) { case 0: return 0xFF; // PCMU: μ-law silence (zero amplitude) case 8: return 0xD5; // PCMA: A-law silence (zero amplitude) case 9: return 0xD5; // G.722: sub-band silence (zero amplitude) default: return 0xFF; // safe default } } // --------------------------------------------------------------------------- // Helper: ACK for non-2xx (same transaction) // --------------------------------------------------------------------------- function buildNon2xxAck(originalInvite: SipMessage, response: SipMessage): SipMessage { const via = originalInvite.getHeader('Via') || ''; const from = originalInvite.getHeader('From') || ''; const toFromResponse = response.getHeader('To') || ''; const callId = originalInvite.callId; const cseqNum = parseInt((originalInvite.getHeader('CSeq') || '1').split(/\s+/)[0], 10); return new SipMessage( `ACK ${originalInvite.requestUri} SIP/2.0`, [ ['Via', via], ['From', from], ['To', toFromResponse], ['Call-ID', callId], ['CSeq', `${cseqNum} ACK`], ['Max-Forwards', '70'], ['Content-Length', '0'], ], '', ); }