4 Commits

60 changed files with 8377 additions and 6967 deletions

View File

@@ -1,5 +1,21 @@
# Changelog
## 2026-04-10 - 1.12.0 - feat(proxy-engine)
add Rust-based outbound calling, WebRTC bridging, and voicemail handling
- adds outbound call origination through the Rust proxy engine with dashboard make_call support
- routes unanswered inbound calls to voicemail, including greeting playback, beep generation, and WAV message recording
- introduces Rust WebRTC session handling and SIP audio bridging, replacing the previous TypeScript WebRTC path
- moves SIP registration and routing responsibilities further into the Rust proxy engine and removes legacy TypeScript call/SIP modules
## 2026-04-10 - 1.11.0 - feat(rust-proxy-engine)
add a Rust SIP proxy engine with shared SIP and codec libraries
- add new Rust workspace crates for proxy-engine, sip-proto, and codec-lib
- move transcoding logic out of opus-codec into reusable codec-lib and keep opus-codec as a thin CLI wrapper
- implement SIP message parsing, dialog handling, SDP/URI rewriting, provider registration, device registration, call management, RTP relay, and DTMF detection in Rust
- add a TypeScript proxy bridge and update the SIP proxy entrypoint to spawn and configure the Rust engine as the SIP data plane
## 2026-04-10 - 1.10.0 - feat(call, voicemail, ivr)
add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management

View File

@@ -1,6 +1,6 @@
{
"name": "siprouter",
"version": "1.10.0",
"version": "1.12.0",
"private": true,
"type": "module",
"scripts": {
@@ -14,7 +14,6 @@
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/smartrust": "^1.3.2",
"@push.rocks/smartstate": "^2.3.0",
"werift": "^0.22.9",
"ws": "^8.20.0"
},
"devDependencies": {

1964
rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
[workspace]
members = ["crates/opus-codec", "crates/tts-engine"]
members = [
"crates/codec-lib",
"crates/opus-codec",
"crates/tts-engine",
"crates/sip-proto",
"crates/proxy-engine",
]
resolver = "2"
[profile.release]

View File

@@ -0,0 +1,10 @@
[package]
name = "codec-lib"
version = "0.1.0"
edition = "2021"
[dependencies]
audiopus = "0.3.0-rc.0"
ezk-g722 = "0.1"
rubato = "0.14"
nnnoiseless = { version = "0.5", default-features = false }

View File

@@ -0,0 +1,349 @@
//! Audio codec library for the SIP router.
//!
//! Handles Opus ↔ G.722 ↔ PCMU/PCMA transcoding with ML noise suppression.
//! Used by both the standalone `opus-codec` CLI and the `proxy-engine` binary.
use audiopus::coder::{Decoder as OpusDecoder, Encoder as OpusEncoder};
use audiopus::packet::Packet as OpusPacket;
use audiopus::{Application, Bitrate as OpusBitrate, Channels, MutSignals, SampleRate};
use ezk_g722::libg722::{self, Bitrate};
use nnnoiseless::DenoiseState;
use rubato::{FftFixedIn, Resampler};
use std::collections::HashMap;
// ---- Payload type constants ------------------------------------------------
pub const PT_PCMU: u8 = 0;
pub const PT_PCMA: u8 = 8;
pub const PT_G722: u8 = 9;
pub const PT_OPUS: u8 = 111;
/// Return the native sample rate for a given payload type.
pub fn codec_sample_rate(pt: u8) -> u32 {
match pt {
PT_OPUS => 48000,
PT_G722 => 16000,
_ => 8000, // PCMU, PCMA
}
}
// ---- G.711 µ-law (PCMU) ---------------------------------------------------
pub fn mulaw_encode(sample: i16) -> u8 {
const BIAS: i16 = 0x84;
const CLIP: i16 = 32635;
let sign = if sample < 0 { 0x80u8 } else { 0 };
let mut s = (sample as i32).unsigned_abs().min(CLIP as u32) as i16;
s += BIAS;
let mut exp = 7u8;
let mut mask = 0x4000i16;
while exp > 0 && (s & mask) == 0 {
exp -= 1;
mask >>= 1;
}
let mantissa = ((s >> (exp + 3)) & 0x0f) as u8;
!(sign | (exp << 4) | mantissa)
}
pub fn mulaw_decode(mulaw: u8) -> i16 {
let v = !mulaw;
let sign = v & 0x80;
let exp = (v >> 4) & 0x07;
let mantissa = v & 0x0f;
// Use i32 to avoid overflow when exp=7, mantissa=15 (result > i16::MAX).
let mut sample = (((mantissa as i32) << 4) + 0x84) << exp;
sample -= 0x84;
let sample = if sign != 0 { -sample } else { sample };
sample.clamp(-32768, 32767) as i16
}
// ---- G.711 A-law (PCMA) ---------------------------------------------------
pub fn alaw_encode(sample: i16) -> u8 {
let sign = if sample >= 0 { 0x80u8 } else { 0 };
let s = (sample as i32).unsigned_abs().min(32767) as i16;
let mut exp = 7u8;
let mut mask = 0x4000i16;
while exp > 0 && (s & mask) == 0 {
exp -= 1;
mask >>= 1;
}
let mantissa = if exp > 0 {
((s >> (exp + 3)) & 0x0f) as u8
} else {
((s >> 4) & 0x0f) as u8
};
(sign | (exp << 4) | mantissa) ^ 0x55
}
pub fn alaw_decode(alaw: u8) -> i16 {
let v = alaw ^ 0x55;
let sign = v & 0x80;
let exp = (v >> 4) & 0x07;
let mantissa = v & 0x0f;
// Use i32 to avoid overflow for extreme values.
let sample = if exp == 0 {
((mantissa as i32) << 4) + 8
} else {
(((mantissa as i32) << 4) + 0x108) << (exp - 1)
};
let sample = if sign != 0 { sample } else { -sample };
sample.clamp(-32768, 32767) as i16
}
// ---- TranscodeState --------------------------------------------------------
/// Per-session codec state holding Opus, G.722, resampler, and denoiser instances.
///
/// Each concurrent call should get its own `TranscodeState` to prevent stateful
/// codecs (Opus, G.722 ADPCM) from corrupting each other.
pub struct TranscodeState {
opus_enc: OpusEncoder,
opus_dec: OpusDecoder,
g722_enc: libg722::encoder::Encoder,
g722_dec: libg722::decoder::Decoder,
/// Cached FFT resamplers keyed by (from_rate, to_rate, chunk_size).
resamplers: HashMap<(u32, u32, usize), FftFixedIn<f64>>,
/// ML noise suppression for the SIP-bound direction.
denoiser_to_sip: Box<DenoiseState<'static>>,
/// ML noise suppression for the browser-bound direction.
denoiser_to_browser: Box<DenoiseState<'static>>,
}
impl TranscodeState {
/// Create a new transcoding session with fresh codec state.
pub fn new() -> Result<Self, String> {
let mut opus_enc =
OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
.map_err(|e| format!("opus encoder: {e}"))?;
opus_enc
.set_complexity(5)
.map_err(|e| format!("opus set_complexity: {e}"))?;
opus_enc
.set_bitrate(OpusBitrate::BitsPerSecond(24000))
.map_err(|e| format!("opus set_bitrate: {e}"))?;
let opus_dec = OpusDecoder::new(SampleRate::Hz48000, Channels::Mono)
.map_err(|e| format!("opus decoder: {e}"))?;
let g722_enc = libg722::encoder::Encoder::new(Bitrate::Mode1_64000, false, false);
let g722_dec = libg722::decoder::Decoder::new(Bitrate::Mode1_64000, false, false);
Ok(Self {
opus_enc,
opus_dec,
g722_enc,
g722_dec,
resamplers: HashMap::new(),
denoiser_to_sip: DenoiseState::new(),
denoiser_to_browser: DenoiseState::new(),
})
}
/// High-quality sample rate conversion using rubato FFT resampler.
/// Resamplers are cached by (from_rate, to_rate, chunk_size) and reused,
/// maintaining proper inter-frame state for continuous audio streams.
pub fn resample(
&mut self,
pcm: &[i16],
from_rate: u32,
to_rate: u32,
) -> Result<Vec<i16>, String> {
if from_rate == to_rate || pcm.is_empty() {
return Ok(pcm.to_vec());
}
let chunk = pcm.len();
let key = (from_rate, to_rate, chunk);
if !self.resamplers.contains_key(&key) {
let r =
FftFixedIn::<f64>::new(from_rate as usize, to_rate as usize, chunk, 1, 1)
.map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?;
self.resamplers.insert(key, r);
}
let resampler = self.resamplers.get_mut(&key).unwrap();
let float_in: Vec<f64> = pcm.iter().map(|&s| s as f64 / 32768.0).collect();
let input = vec![float_in];
let result = resampler
.process(&input, None)
.map_err(|e| format!("resample {from_rate}->{to_rate}: {e}"))?;
Ok(result[0]
.iter()
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
.collect())
}
/// Apply RNNoise ML noise suppression to 48kHz PCM audio.
/// Processes in 480-sample (10ms) frames. State persists across calls.
pub fn denoise(denoiser: &mut DenoiseState, pcm: &[i16]) -> Vec<i16> {
let frame_size = DenoiseState::FRAME_SIZE; // 480
let total = pcm.len();
let whole = (total / frame_size) * frame_size;
let mut output = Vec::with_capacity(total);
let mut out_buf = [0.0f32; 480];
for offset in (0..whole).step_by(frame_size) {
let input: Vec<f32> = pcm[offset..offset + frame_size]
.iter()
.map(|&s| s as f32)
.collect();
denoiser.process_frame(&mut out_buf, &input);
output.extend(
out_buf
.iter()
.map(|&s| s.round().clamp(-32768.0, 32767.0) as i16),
);
}
if whole < total {
output.extend_from_slice(&pcm[whole..]);
}
output
}
/// Transcode audio payload from one codec to another.
///
/// `direction`: `Some("to_sip")` or `Some("to_browser")` selects per-direction
/// denoiser. `None` skips denoising (backward compat).
pub fn transcode(
&mut self,
data: &[u8],
from_pt: u8,
to_pt: u8,
direction: Option<&str>,
) -> Result<Vec<u8>, String> {
if from_pt == to_pt {
return Ok(data.to_vec());
}
let (pcm, rate) = self.decode_to_pcm(data, from_pt)?;
let processed = if let Some(dir) = direction {
let pcm_48k = self.resample(&pcm, rate, 48000)?;
let denoiser = match dir {
"to_sip" => &mut self.denoiser_to_sip,
_ => &mut self.denoiser_to_browser,
};
let denoised = Self::denoise(denoiser, &pcm_48k);
let target_rate = codec_sample_rate(to_pt);
self.resample(&denoised, 48000, target_rate)?
} else {
let target_rate = codec_sample_rate(to_pt);
if rate == target_rate {
pcm
} else {
self.resample(&pcm, rate, target_rate)?
}
};
self.encode_from_pcm(&processed, to_pt)
}
/// Decode an encoded audio payload to raw 16-bit PCM samples.
/// Returns (samples, sample_rate).
pub fn decode_to_pcm(&mut self, data: &[u8], pt: u8) -> Result<(Vec<i16>, u32), String> {
match pt {
PT_OPUS => {
let mut pcm = vec![0i16; 5760]; // up to 120ms at 48kHz
let packet =
OpusPacket::try_from(data).map_err(|e| format!("opus packet: {e}"))?;
let out =
MutSignals::try_from(&mut pcm[..]).map_err(|e| format!("opus signals: {e}"))?;
let n: usize = self
.opus_dec
.decode(Some(packet), out, false)
.map_err(|e| format!("opus decode: {e}"))?
.into();
pcm.truncate(n);
Ok((pcm, 48000))
}
PT_G722 => {
let pcm = self.g722_dec.decode(data);
Ok((pcm, 16000))
}
PT_PCMU => {
let pcm: Vec<i16> = data.iter().map(|&b| mulaw_decode(b)).collect();
Ok((pcm, 8000))
}
PT_PCMA => {
let pcm: Vec<i16> = data.iter().map(|&b| alaw_decode(b)).collect();
Ok((pcm, 8000))
}
_ => Err(format!("unsupported source PT {pt}")),
}
}
/// Encode raw PCM samples to an audio codec.
pub fn encode_from_pcm(&mut self, pcm: &[i16], pt: u8) -> Result<Vec<u8>, String> {
match pt {
PT_OPUS => {
let mut buf = vec![0u8; 4000];
let n: usize = self
.opus_enc
.encode(pcm, &mut buf)
.map_err(|e| format!("opus encode: {e}"))?
.into();
buf.truncate(n);
Ok(buf)
}
PT_G722 => Ok(self.g722_enc.encode(pcm)),
PT_PCMU => Ok(pcm.iter().map(|&s| mulaw_encode(s)).collect()),
PT_PCMA => Ok(pcm.iter().map(|&s| alaw_encode(s)).collect()),
_ => Err(format!("unsupported target PT {pt}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mulaw_roundtrip() {
for sample in [-32768i16, -1000, -1, 0, 1, 1000, 32767] {
let encoded = mulaw_encode(sample);
let decoded = mulaw_decode(encoded);
// µ-law is lossy; verify the decoded value is close.
assert!((sample as i32 - decoded as i32).abs() < 1000,
"µ-law roundtrip failed for {sample}: got {decoded}");
}
}
#[test]
fn alaw_roundtrip() {
for sample in [-32768i16, -1000, -1, 0, 1, 1000, 32767] {
let encoded = alaw_encode(sample);
let decoded = alaw_decode(encoded);
assert!((sample as i32 - decoded as i32).abs() < 1000,
"A-law roundtrip failed for {sample}: got {decoded}");
}
}
#[test]
fn codec_sample_rates() {
assert_eq!(codec_sample_rate(PT_OPUS), 48000);
assert_eq!(codec_sample_rate(PT_G722), 16000);
assert_eq!(codec_sample_rate(PT_PCMU), 8000);
assert_eq!(codec_sample_rate(PT_PCMA), 8000);
}
#[test]
fn transcode_same_pt_is_passthrough() {
let mut st = TranscodeState::new().unwrap();
let data = vec![0u8; 160];
let result = st.transcode(&data, PT_PCMU, PT_PCMU, None).unwrap();
assert_eq!(result, data);
}
#[test]
fn pcmu_to_pcma_roundtrip() {
let mut st = TranscodeState::new().unwrap();
// 160 bytes = 20ms of PCMU at 8kHz
let pcmu_data: Vec<u8> = (0..160).map(|i| mulaw_encode((i as i16 * 200) - 16000)).collect();
let pcma = st.transcode(&pcmu_data, PT_PCMU, PT_PCMA, None).unwrap();
assert_eq!(pcma.len(), 160); // Same frame size
let back = st.transcode(&pcma, PT_PCMA, PT_PCMU, None).unwrap();
assert_eq!(back.len(), 160);
}
}

View File

@@ -8,10 +8,7 @@ name = "opus-codec"
path = "src/main.rs"
[dependencies]
audiopus = "0.3.0-rc.0"
ezk-g722 = "0.1"
rubato = "0.14"
codec-lib = { path = "../codec-lib" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
nnnoiseless = { version = "0.5", default-features = false }

View File

@@ -1,10 +1,6 @@
/// Audio transcoding bridge for smartrust.
///
/// Handles Opus ↔ G.722 ↔ PCMU transcoding for the SIP router.
/// Uses audiopus (libopus) for Opus and ezk-g722 (SpanDSP port) for G.722.
///
/// Supports per-session codec state so concurrent calls don't corrupt each
/// other's stateful codecs (Opus, G.722 ADPCM).
/// Thin CLI wrapper around `codec-lib`. Handles Opus ↔ G.722 ↔ PCMU transcoding.
///
/// Protocol:
/// -> {"id":"1","method":"init","params":{}}
@@ -16,24 +12,13 @@
/// -> {"id":"4","method":"destroy_session","params":{"session_id":"call-abc"}}
/// <- {"id":"4","success":true,"result":{}}
use audiopus::coder::{Decoder as OpusDecoder, Encoder as OpusEncoder};
use audiopus::packet::Packet as OpusPacket;
use audiopus::{Application, Bitrate as OpusBitrate, Channels, MutSignals, SampleRate};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use ezk_g722::libg722::{self, Bitrate};
use nnnoiseless::DenoiseState;
use rubato::{FftFixedIn, Resampler};
use codec_lib::{codec_sample_rate, TranscodeState};
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{self, BufRead, Write};
// Payload type constants.
const PT_PCMU: u8 = 0;
const PT_PCMA: u8 = 8;
const PT_G722: u8 = 9;
const PT_OPUS: u8 = 111;
#[derive(Deserialize)]
struct Request {
id: String,
@@ -42,261 +27,24 @@ struct Request {
params: serde_json::Value,
}
fn respond(out: &mut impl Write, id: &str, success: bool, result: Option<serde_json::Value>, error: Option<&str>) {
fn respond(
out: &mut impl Write,
id: &str,
success: bool,
result: Option<serde_json::Value>,
error: Option<&str>,
) {
let mut resp = serde_json::json!({ "id": id, "success": success });
if let Some(r) = result { resp["result"] = r; }
if let Some(e) = error { resp["error"] = serde_json::Value::String(e.to_string()); }
if let Some(r) = result {
resp["result"] = r;
}
if let Some(e) = error {
resp["error"] = serde_json::Value::String(e.to_string());
}
let _ = writeln!(out, "{}", resp);
let _ = out.flush();
}
// ---------------------------------------------------------------------------
// Codec state
// ---------------------------------------------------------------------------
struct TranscodeState {
opus_enc: OpusEncoder,
opus_dec: OpusDecoder,
g722_enc: libg722::encoder::Encoder,
g722_dec: libg722::decoder::Decoder,
// Cached FFT resamplers keyed by (from_rate, to_rate, chunk_size).
resamplers: HashMap<(u32, u32, usize), FftFixedIn<f64>>,
// Per-direction ML noise suppression (RNNoise). Separate state per direction
// prevents the RNN hidden state from being corrupted by interleaved audio streams.
denoiser_to_sip: Box<DenoiseState<'static>>,
denoiser_to_browser: Box<DenoiseState<'static>>,
}
impl TranscodeState {
fn new() -> Result<Self, String> {
let mut opus_enc = OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
.map_err(|e| format!("opus encoder: {e}"))?;
// Telephony-grade tuning: complexity 5 is sufficient for voice bridged to G.722.
opus_enc.set_complexity(5).map_err(|e| format!("opus set_complexity: {e}"))?;
opus_enc.set_bitrate(OpusBitrate::BitsPerSecond(24000)).map_err(|e| format!("opus set_bitrate: {e}"))?;
let opus_dec = OpusDecoder::new(SampleRate::Hz48000, Channels::Mono)
.map_err(|e| format!("opus decoder: {e}"))?;
let g722_enc = libg722::encoder::Encoder::new(Bitrate::Mode1_64000, false, false);
let g722_dec = libg722::decoder::Decoder::new(Bitrate::Mode1_64000, false, false);
Ok(Self {
opus_enc, opus_dec, g722_enc, g722_dec,
resamplers: HashMap::new(),
denoiser_to_sip: DenoiseState::new(),
denoiser_to_browser: DenoiseState::new(),
})
}
/// High-quality sample rate conversion using rubato FFT resampler.
/// Resamplers are cached by (from_rate, to_rate, chunk_size) and reused,
/// maintaining proper inter-frame state for continuous audio streams.
fn resample(&mut self, pcm: &[i16], from_rate: u32, to_rate: u32) -> Result<Vec<i16>, String> {
if from_rate == to_rate || pcm.is_empty() {
return Ok(pcm.to_vec());
}
let chunk = pcm.len();
let key = (from_rate, to_rate, chunk);
// Get or create cached resampler for this rate pair + chunk size.
if !self.resamplers.contains_key(&key) {
let r = FftFixedIn::<f64>::new(from_rate as usize, to_rate as usize, chunk, 1, 1)
.map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?;
self.resamplers.insert(key, r);
}
let resampler = self.resamplers.get_mut(&key).unwrap();
// i16 → f64 normalized to [-1.0, 1.0]
let float_in: Vec<f64> = pcm.iter().map(|&s| s as f64 / 32768.0).collect();
let input = vec![float_in];
let result = resampler.process(&input, None)
.map_err(|e| format!("resample {from_rate}->{to_rate}: {e}"))?;
// f64 → i16
Ok(result[0].iter()
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
.collect())
}
/// Apply RNNoise ML noise suppression to 48kHz PCM audio.
/// Processes in 480-sample (10ms) frames. State persists across calls.
fn denoise(denoiser: &mut DenoiseState, pcm: &[i16]) -> Vec<i16> {
let frame_size = DenoiseState::FRAME_SIZE; // 480
let total = pcm.len();
// Round down to whole frames — don't process partial frames to avoid
// injecting artificial silence into the RNN state.
let whole = (total / frame_size) * frame_size;
let mut output = Vec::with_capacity(total);
let mut out_buf = [0.0f32; 480];
for offset in (0..whole).step_by(frame_size) {
let input: Vec<f32> = pcm[offset..offset + frame_size]
.iter().map(|&s| s as f32).collect();
denoiser.process_frame(&mut out_buf, &input);
output.extend(out_buf.iter()
.map(|&s| s.round().clamp(-32768.0, 32767.0) as i16));
}
// Pass through any trailing partial-frame samples unmodified.
if whole < total {
output.extend_from_slice(&pcm[whole..]);
}
output
}
/// Transcode audio payload from one codec to another.
/// `direction`: "to_sip" or "to_browser" — selects the per-direction denoiser.
/// If None, denoising is skipped (backward compat).
fn transcode(&mut self, data: &[u8], from_pt: u8, to_pt: u8, direction: Option<&str>) -> Result<Vec<u8>, String> {
if from_pt == to_pt {
return Ok(data.to_vec());
}
// Decode to PCM (at source sample rate).
let (pcm, rate) = self.decode_to_pcm(data, from_pt)?;
// Apply noise suppression if direction is specified.
let processed = if let Some(dir) = direction {
// Resample to 48kHz for denoising (no-op when already 48kHz).
let pcm_48k = self.resample(&pcm, rate, 48000)?;
let denoiser = match dir {
"to_sip" => &mut self.denoiser_to_sip,
_ => &mut self.denoiser_to_browser,
};
let denoised = Self::denoise(denoiser, &pcm_48k);
// Resample to target rate (no-op when target is 48kHz).
let target_rate = codec_sample_rate(to_pt);
self.resample(&denoised, 48000, target_rate)?
} else {
// No denoising — direct resample.
let target_rate = codec_sample_rate(to_pt);
if rate == target_rate { pcm } else { self.resample(&pcm, rate, target_rate)? }
};
// Encode from PCM.
self.encode_from_pcm(&processed, to_pt)
}
fn decode_to_pcm(&mut self, data: &[u8], pt: u8) -> Result<(Vec<i16>, u32), String> {
match pt {
PT_OPUS => {
let mut pcm = vec![0i16; 5760]; // up to 120ms at 48kHz (RFC 6716 max)
let packet = OpusPacket::try_from(data)
.map_err(|e| format!("opus packet: {e}"))?;
let out = MutSignals::try_from(&mut pcm[..])
.map_err(|e| format!("opus signals: {e}"))?;
let n: usize = self.opus_dec.decode(Some(packet), out, false)
.map_err(|e| format!("opus decode: {e}"))?.into();
pcm.truncate(n);
Ok((pcm, 48000))
}
PT_G722 => {
let pcm = self.g722_dec.decode(data);
Ok((pcm, 16000))
}
PT_PCMU => {
let pcm: Vec<i16> = data.iter().map(|&b| mulaw_decode(b)).collect();
Ok((pcm, 8000))
}
PT_PCMA => {
let pcm: Vec<i16> = data.iter().map(|&b| alaw_decode(b)).collect();
Ok((pcm, 8000))
}
_ => Err(format!("unsupported source PT {pt}")),
}
}
fn encode_from_pcm(&mut self, pcm: &[i16], pt: u8) -> Result<Vec<u8>, String> {
match pt {
PT_OPUS => {
let mut buf = vec![0u8; 4000];
let n: usize = self.opus_enc.encode(pcm, &mut buf)
.map_err(|e| format!("opus encode: {e}"))?.into();
buf.truncate(n);
Ok(buf)
}
PT_G722 => {
Ok(self.g722_enc.encode(pcm))
}
PT_PCMU => {
Ok(pcm.iter().map(|&s| mulaw_encode(s)).collect())
}
PT_PCMA => {
Ok(pcm.iter().map(|&s| alaw_encode(s)).collect())
}
_ => Err(format!("unsupported target PT {pt}")),
}
}
}
fn codec_sample_rate(pt: u8) -> u32 {
match pt {
PT_OPUS => 48000,
PT_G722 => 16000,
_ => 8000, // PCMU, PCMA
}
}
// ---------------------------------------------------------------------------
// G.711 µ-law (PCMU)
// ---------------------------------------------------------------------------
fn mulaw_encode(sample: i16) -> u8 {
const BIAS: i16 = 0x84;
const CLIP: i16 = 32635;
let sign = if sample < 0 { 0x80u8 } else { 0 };
// Use i32 to avoid overflow when sample == i16::MIN (-32768).
let mut s = (sample as i32).unsigned_abs().min(CLIP as u32) as i16;
s += BIAS;
let mut exp = 7u8;
let mut mask = 0x4000i16;
while exp > 0 && (s & mask) == 0 { exp -= 1; mask >>= 1; }
let mantissa = ((s >> (exp + 3)) & 0x0f) as u8;
!(sign | (exp << 4) | mantissa)
}
fn mulaw_decode(mulaw: u8) -> i16 {
let v = !mulaw;
let sign = v & 0x80;
let exp = (v >> 4) & 0x07;
let mantissa = v & 0x0f;
let mut sample = (((mantissa as i16) << 4) + 0x84) << exp;
sample -= 0x84;
if sign != 0 { -sample } else { sample }
}
// ---------------------------------------------------------------------------
// G.711 A-law (PCMA)
// ---------------------------------------------------------------------------
fn alaw_encode(sample: i16) -> u8 {
let sign = if sample >= 0 { 0x80u8 } else { 0 };
// Use i32 to avoid overflow when sample == i16::MIN (-32768).
let s = (sample as i32).unsigned_abs().min(32767) as i16;
let mut exp = 7u8;
let mut mask = 0x4000i16;
while exp > 0 && (s & mask) == 0 { exp -= 1; mask >>= 1; }
let mantissa = if exp > 0 { ((s >> (exp + 3)) & 0x0f) as u8 } else { ((s >> 4) & 0x0f) as u8 };
(sign | (exp << 4) | mantissa) ^ 0x55
}
fn alaw_decode(alaw: u8) -> i16 {
let v = alaw ^ 0x55;
let sign = v & 0x80;
let exp = (v >> 4) & 0x07;
let mantissa = v & 0x0f;
let sample = if exp == 0 {
((mantissa as i16) << 4) + 8
} else {
(((mantissa as i16) << 4) + 0x108) << (exp - 1)
};
if sign != 0 { sample } else { -sample }
}
// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------
/// Resolve a session: if session_id is provided, look it up in the sessions map;
/// otherwise fall back to the default state (backward compat with `init`).
fn get_session<'a>(
@@ -319,9 +67,7 @@ fn main() {
let _ = writeln!(out, r#"{{"event":"ready","data":{{}}}}"#);
let _ = out.flush();
// Default state for backward-compat `init` (no session_id).
let mut default_state: Option<TranscodeState> = None;
// Per-session codec state for concurrent call isolation.
let mut sessions: HashMap<String, TranscodeState> = HashMap::new();
for line in stdin.lock().lines() {
@@ -340,22 +86,21 @@ fn main() {
};
match req.method.as_str() {
// Backward-compat: init the default (shared) session.
"init" => {
match TranscodeState::new() {
Ok(s) => {
default_state = Some(s);
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
}
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
"init" => match TranscodeState::new() {
Ok(s) => {
default_state = Some(s);
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
}
}
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
},
// Create an isolated session with its own codec state.
"create_session" => {
let session_id = match req.params.get("session_id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => { respond(&mut out, &req.id, false, None, Some("missing session_id")); continue; }
None => {
respond(&mut out, &req.id, false, None, Some("missing session_id"));
continue;
}
};
if sessions.contains_key(&session_id) {
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
@@ -370,95 +115,172 @@ fn main() {
}
}
// Destroy a session, freeing its codec state.
"destroy_session" => {
let session_id = match req.params.get("session_id").and_then(|v| v.as_str()) {
Some(s) => s,
None => { respond(&mut out, &req.id, false, None, Some("missing session_id")); continue; }
None => {
respond(&mut out, &req.id, false, None, Some("missing session_id"));
continue;
}
};
sessions.remove(session_id);
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
}
// Transcode: uses session_id if provided, else default state.
"transcode" => {
let st = match get_session(&mut sessions, &mut default_state, &req.params) {
Some(s) => s,
None => { respond(&mut out, &req.id, false, None, Some("not initialized (no session or default state)")); continue; }
None => {
respond(
&mut out,
&req.id,
false,
None,
Some("not initialized (no session or default state)"),
);
continue;
}
};
let data_b64 = match req.params.get("data_b64").and_then(|v| v.as_str()) {
Some(s) => s,
None => { respond(&mut out, &req.id, false, None, Some("missing data_b64")); continue; }
None => {
respond(&mut out, &req.id, false, None, Some("missing data_b64"));
continue;
}
};
let from_pt = req.params.get("from_pt").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let from_pt =
req.params.get("from_pt").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let to_pt = req.params.get("to_pt").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let direction = req.params.get("direction").and_then(|v| v.as_str());
let data = match B64.decode(data_b64) {
Ok(b) => b,
Err(e) => { respond(&mut out, &req.id, false, None, Some(&format!("b64: {e}"))); continue; }
Err(e) => {
respond(
&mut out,
&req.id,
false,
None,
Some(&format!("b64: {e}")),
);
continue;
}
};
match st.transcode(&data, from_pt, to_pt, direction) {
Ok(result) => {
respond(&mut out, &req.id, true, Some(serde_json::json!({ "data_b64": B64.encode(&result) })), None);
respond(
&mut out,
&req.id,
true,
Some(serde_json::json!({ "data_b64": B64.encode(&result) })),
None,
);
}
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
}
}
// Encode raw 16-bit PCM to a target codec.
// Params: data_b64 (raw PCM bytes, 16-bit LE), sample_rate (input Hz), to_pt
// Optional: session_id for isolated codec state.
"encode_pcm" => {
let st = match get_session(&mut sessions, &mut default_state, &req.params) {
Some(s) => s,
None => { respond(&mut out, &req.id, false, None, Some("not initialized (no session or default state)")); continue; }
None => {
respond(
&mut out,
&req.id,
false,
None,
Some("not initialized (no session or default state)"),
);
continue;
}
};
let data_b64 = match req.params.get("data_b64").and_then(|v| v.as_str()) {
Some(s) => s,
None => { respond(&mut out, &req.id, false, None, Some("missing data_b64")); continue; }
None => {
respond(&mut out, &req.id, false, None, Some("missing data_b64"));
continue;
}
};
let sample_rate = req.params.get("sample_rate").and_then(|v| v.as_u64()).unwrap_or(22050) as u32;
let sample_rate = req
.params
.get("sample_rate")
.and_then(|v| v.as_u64())
.unwrap_or(22050) as u32;
let to_pt = req.params.get("to_pt").and_then(|v| v.as_u64()).unwrap_or(9) as u8;
let data = match B64.decode(data_b64) {
Ok(b) => b,
Err(e) => { respond(&mut out, &req.id, false, None, Some(&format!("b64: {e}"))); continue; }
Err(e) => {
respond(
&mut out,
&req.id,
false,
None,
Some(&format!("b64: {e}")),
);
continue;
}
};
if data.len() % 2 != 0 {
respond(&mut out, &req.id, false, None, Some("PCM data has odd byte count (expected 16-bit LE samples)"));
respond(
&mut out,
&req.id,
false,
None,
Some("PCM data has odd byte count (expected 16-bit LE samples)"),
);
continue;
}
// Convert raw bytes to i16 samples.
let pcm: Vec<i16> = data.chunks_exact(2)
let pcm: Vec<i16> = data
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
// Resample to target codec's sample rate.
let target_rate = codec_sample_rate(to_pt);
let resampled = match st.resample(&pcm, sample_rate, target_rate) {
Ok(r) => r,
Err(e) => { respond(&mut out, &req.id, false, None, Some(&e)); continue; }
Err(e) => {
respond(&mut out, &req.id, false, None, Some(&e));
continue;
}
};
// Encode to target codec (reuse encode_from_pcm).
match st.encode_from_pcm(&resampled, to_pt) {
Ok(encoded) => {
respond(&mut out, &req.id, true, Some(serde_json::json!({ "data_b64": B64.encode(&encoded) })), None);
respond(
&mut out,
&req.id,
true,
Some(serde_json::json!({ "data_b64": B64.encode(&encoded) })),
None,
);
}
Err(e) => {
respond(&mut out, &req.id, false, None, Some(&e));
}
Err(e) => { respond(&mut out, &req.id, false, None, Some(&e)); continue; }
}
}
// Legacy commands (kept for backward compat).
"encode" | "decode" => {
respond(&mut out, &req.id, false, None, Some("use 'transcode' command instead"));
respond(
&mut out,
&req.id,
false,
None,
Some("use 'transcode' command instead"),
);
}
_ => respond(&mut out, &req.id, false, None, Some(&format!("unknown: {}", req.method))),
_ => respond(
&mut out,
&req.id,
false,
None,
Some(&format!("unknown: {}", req.method)),
),
}
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "proxy-engine"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "proxy-engine"
path = "src/main.rs"
[dependencies]
codec-lib = { path = "../codec-lib" }
sip-proto = { path = "../sip-proto" }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
regex-lite = "0.1"
webrtc = "0.8"
rand = "0.8"
hound = "3.5"

View File

@@ -0,0 +1,173 @@
//! Audio player — reads a WAV file and streams it as RTP packets.
use crate::rtp::{build_rtp_header, rtp_clock_increment};
use codec_lib::{codec_sample_rate, TranscodeState};
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::time::{self, Duration};
/// Play a WAV file as RTP to a destination.
/// Returns when playback is complete.
pub async fn play_wav_file(
file_path: &str,
socket: Arc<UdpSocket>,
dest: SocketAddr,
codec_pt: u8,
ssrc: u32,
) -> Result<u32, String> {
let path = Path::new(file_path);
if !path.exists() {
return Err(format!("WAV file not found: {file_path}"));
}
// Read WAV file.
let mut reader =
hound::WavReader::open(path).map_err(|e| format!("open WAV {file_path}: {e}"))?;
let spec = reader.spec();
let wav_rate = spec.sample_rate;
// Read all samples as i16.
let samples: Vec<i16> = if spec.bits_per_sample == 16 {
reader
.samples::<i16>()
.filter_map(|s| s.ok())
.collect()
} else if spec.bits_per_sample == 32 && spec.sample_format == hound::SampleFormat::Float {
reader
.samples::<f32>()
.filter_map(|s| s.ok())
.map(|s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
.collect()
} else {
return Err(format!(
"unsupported WAV format: {}bit {:?}",
spec.bits_per_sample, spec.sample_format
));
};
if samples.is_empty() {
return Ok(0);
}
// Create codec state for encoding.
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
// Resample to target codec rate.
let target_rate = codec_sample_rate(codec_pt);
let resampled = if wav_rate != target_rate {
transcoder
.resample(&samples, wav_rate, target_rate)
.map_err(|e| format!("resample: {e}"))?
} else {
samples
};
// Calculate frame size (20ms of audio at target rate).
let frame_samples = (target_rate as usize) / 50; // 20ms = 1/50 second
// Stream as RTP at 20ms intervals.
let mut seq: u16 = 0;
let mut ts: u32 = 0;
let mut offset = 0;
let mut interval = time::interval(Duration::from_millis(20));
let mut frames_sent = 0u32;
while offset < resampled.len() {
interval.tick().await;
let end = (offset + frame_samples).min(resampled.len());
let frame = &resampled[offset..end];
// Pad short final frame with silence.
let frame_data = if frame.len() < frame_samples {
let mut padded = frame.to_vec();
padded.resize(frame_samples, 0);
padded
} else {
frame.to_vec()
};
// Encode to target codec.
let encoded = match transcoder.encode_from_pcm(&frame_data, codec_pt) {
Ok(e) if !e.is_empty() => e,
_ => {
offset += frame_samples;
continue;
}
};
// Build RTP packet.
let header = build_rtp_header(codec_pt, seq, ts, ssrc);
let mut packet = header.to_vec();
packet.extend_from_slice(&encoded);
let _ = socket.send_to(&packet, dest).await;
seq = seq.wrapping_add(1);
ts = ts.wrapping_add(rtp_clock_increment(codec_pt));
offset += frame_samples;
frames_sent += 1;
}
Ok(frames_sent)
}
/// Generate and play a beep tone (sine wave) as RTP.
pub async fn play_beep(
socket: Arc<UdpSocket>,
dest: SocketAddr,
codec_pt: u8,
ssrc: u32,
start_seq: u16,
start_ts: u32,
freq_hz: u32,
duration_ms: u32,
) -> Result<(u16, u32), String> {
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
let target_rate = codec_sample_rate(codec_pt);
let frame_samples = (target_rate as usize) / 50;
let total_samples = (target_rate as usize * duration_ms as usize) / 1000;
// Generate sine wave.
let amplitude = 16000i16;
let sine: Vec<i16> = (0..total_samples)
.map(|i| {
let t = i as f64 / target_rate as f64;
(amplitude as f64 * (2.0 * std::f64::consts::PI * freq_hz as f64 * t).sin()) as i16
})
.collect();
let mut seq = start_seq;
let mut ts = start_ts;
let mut offset = 0;
let mut interval = time::interval(Duration::from_millis(20));
while offset < sine.len() {
interval.tick().await;
let end = (offset + frame_samples).min(sine.len());
let mut frame = sine[offset..end].to_vec();
frame.resize(frame_samples, 0);
let encoded = match transcoder.encode_from_pcm(&frame, codec_pt) {
Ok(e) if !e.is_empty() => e,
_ => {
offset += frame_samples;
continue;
}
};
let header = build_rtp_header(codec_pt, seq, ts, ssrc);
let mut packet = header.to_vec();
packet.extend_from_slice(&encoded);
let _ = socket.send_to(&packet, dest).await;
seq = seq.wrapping_add(1);
ts = ts.wrapping_add(rtp_clock_increment(codec_pt));
offset += frame_samples;
}
Ok((seq, ts))
}

View File

@@ -0,0 +1,103 @@
//! Call hub — owns legs and bridges media.
//!
//! Each Call has a unique ID and tracks its state, direction, and associated
//! SIP Call-IDs for message routing.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
use tokio::net::UdpSocket;
/// Call state machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallState {
SettingUp,
Ringing,
Connected,
Voicemail,
Ivr,
Terminating,
Terminated,
}
impl CallState {
pub fn as_str(&self) -> &'static str {
match self {
Self::SettingUp => "setting-up",
Self::Ringing => "ringing",
Self::Connected => "connected",
Self::Voicemail => "voicemail",
Self::Ivr => "ivr",
Self::Terminating => "terminating",
Self::Terminated => "terminated",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallDirection {
Inbound,
Outbound,
}
impl CallDirection {
pub fn as_str(&self) -> &'static str {
match self {
Self::Inbound => "inbound",
Self::Outbound => "outbound",
}
}
}
/// A passthrough call — both sides share the same SIP Call-ID.
/// The proxy rewrites SDP/Contact/Request-URI and relays RTP.
pub struct PassthroughCall {
pub id: String,
pub sip_call_id: String,
pub state: CallState,
pub direction: CallDirection,
pub created_at: Instant,
// Call metadata.
pub caller_number: Option<String>,
pub callee_number: Option<String>,
pub provider_id: String,
// Provider side.
pub provider_addr: SocketAddr,
pub provider_media: Option<SocketAddr>,
// Device side.
pub device_addr: SocketAddr,
pub device_media: Option<SocketAddr>,
// RTP relay.
pub rtp_port: u16,
pub rtp_socket: Arc<UdpSocket>,
// Packet counters.
pub pkt_from_device: u64,
pub pkt_from_provider: u64,
}
impl PassthroughCall {
pub fn duration_secs(&self) -> u64 {
self.created_at.elapsed().as_secs()
}
pub fn to_status_json(&self) -> serde_json::Value {
serde_json::json!({
"id": self.id,
"state": self.state.as_str(),
"direction": self.direction.as_str(),
"callerNumber": self.caller_number,
"calleeNumber": self.callee_number,
"providerUsed": self.provider_id,
"createdAt": self.created_at.elapsed().as_millis(),
"duration": self.duration_secs(),
"rtpPort": self.rtp_port,
"pktFromDevice": self.pkt_from_device,
"pktFromProvider": self.pkt_from_provider,
})
}
}

View File

@@ -0,0 +1,822 @@
//! Call manager — central registry and orchestration for all calls.
//!
//! Handles:
//! - Inbound passthrough calls (provider → proxy → device)
//! - Outbound passthrough calls (device → proxy → provider)
//! - SIP message routing by Call-ID
//! - BYE/CANCEL handling
//! - RTP relay setup
//!
//! Ported from ts/call/call-manager.ts (passthrough mode).
use crate::call::{CallDirection, CallState, PassthroughCall};
use crate::config::{AppConfig, ProviderConfig};
use crate::dtmf::DtmfDetector;
use crate::ipc::{emit_event, OutTx};
use crate::registrar::Registrar;
use crate::rtp::RtpPortPool;
use sip_proto::helpers::parse_sdp_endpoint;
use sip_proto::message::SipMessage;
use sip_proto::rewrite::{rewrite_sdp, rewrite_sip_uri};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
use tokio::net::UdpSocket;
pub struct CallManager {
/// Active passthrough calls, keyed by SIP Call-ID.
calls: HashMap<String, PassthroughCall>,
/// Call ID counter.
next_call_num: u64,
/// Output channel for events.
out_tx: OutTx,
}
impl CallManager {
pub fn new(out_tx: OutTx) -> Self {
Self {
calls: HashMap::new(),
next_call_num: 0,
out_tx,
}
}
/// Generate a unique call ID.
fn next_call_id(&mut self) -> String {
let id = format!(
"call-{}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
self.next_call_num,
);
self.next_call_num += 1;
id
}
/// Try to route a SIP message to an existing call.
/// Returns true if handled.
pub async fn route_sip_message(
&mut self,
msg: &SipMessage,
from_addr: SocketAddr,
socket: &UdpSocket,
config: &AppConfig,
_registrar: &Registrar,
) -> bool {
let sip_call_id = msg.call_id().to_string();
// Check if this Call-ID belongs to an active call.
if !self.calls.contains_key(&sip_call_id) {
return false;
}
// Extract needed data from the call to avoid borrow conflicts.
let (call_id, provider_addr, device_addr, rtp_port, from_provider) = {
let call = self.calls.get(&sip_call_id).unwrap();
let from_provider = from_addr.ip().to_string() == call.provider_addr.ip().to_string();
(
call.id.clone(),
call.provider_addr,
call.device_addr,
call.rtp_port,
from_provider,
)
};
let lan_ip = config.proxy.lan_ip.clone();
let lan_port = config.proxy.lan_port;
if msg.is_request() {
let method = msg.method().unwrap_or("");
let forward_to = if from_provider { device_addr } else { provider_addr };
// Handle BYE.
if method == "BYE" {
let ok = SipMessage::create_response(200, "OK", msg, None);
let _ = socket.send_to(&ok.serialize(), from_addr).await;
let _ = socket.send_to(&msg.serialize(), forward_to).await;
let duration = self.calls.get(&sip_call_id).unwrap().duration_secs();
emit_event(
&self.out_tx,
"call_ended",
serde_json::json!({
"call_id": call_id,
"reason": "bye",
"duration": duration,
"from_side": if from_provider { "provider" } else { "device" },
}),
);
self.calls.get_mut(&sip_call_id).unwrap().state = CallState::Terminated;
return true;
}
// Handle CANCEL.
if method == "CANCEL" {
let ok = SipMessage::create_response(200, "OK", msg, None);
let _ = socket.send_to(&ok.serialize(), from_addr).await;
let _ = socket.send_to(&msg.serialize(), forward_to).await;
let duration = self.calls.get(&sip_call_id).unwrap().duration_secs();
emit_event(
&self.out_tx,
"call_ended",
serde_json::json!({
"call_id": call_id, "reason": "cancel", "duration": duration,
}),
);
self.calls.get_mut(&sip_call_id).unwrap().state = CallState::Terminated;
return true;
}
// Handle INFO (DTMF relay).
if method == "INFO" {
let ok = SipMessage::create_response(200, "OK", msg, None);
let _ = socket.send_to(&ok.serialize(), from_addr).await;
// Detect DTMF from INFO body.
if let Some(ct) = msg.get_header("Content-Type") {
let mut detector = DtmfDetector::new(call_id.clone(), self.out_tx.clone());
detector.process_sip_info(ct, &msg.body);
}
return true;
}
// Forward other requests with SDP rewriting.
let mut fwd = msg.clone();
if from_provider {
rewrite_sdp_for_device(&mut fwd, &lan_ip, rtp_port);
if let Some(ruri) = fwd.request_uri().map(|s| s.to_string()) {
let new_ruri = rewrite_sip_uri(&ruri, &device_addr.ip().to_string(), device_addr.port());
fwd.set_request_uri(&new_ruri);
}
} else {
rewrite_sdp_for_provider(&mut fwd, &lan_ip, rtp_port);
}
if fwd.is_dialog_establishing() {
fwd.prepend_header("Record-Route", &format!("<sip:{lan_ip}:{lan_port};lr>"));
}
let _ = socket.send_to(&fwd.serialize(), forward_to).await;
return true;
}
// --- Responses ---
if msg.is_response() {
let code = msg.status_code().unwrap_or(0);
let cseq_method = msg.cseq_method().unwrap_or("").to_uppercase();
let forward_to = if from_provider { device_addr } else { provider_addr };
let mut fwd = msg.clone();
if from_provider {
rewrite_sdp_for_device(&mut fwd, &lan_ip, rtp_port);
} else {
rewrite_sdp_for_provider(&mut fwd, &lan_ip, rtp_port);
if let Some(contact) = fwd.get_header("Contact").map(|s| s.to_string()) {
let new_contact = rewrite_sip_uri(&contact, &lan_ip, lan_port);
if new_contact != contact {
fwd.set_header("Contact", &new_contact);
}
}
}
// State transitions.
if cseq_method == "INVITE" {
let call = self.calls.get_mut(&sip_call_id).unwrap();
if (code == 180 || code == 183) && call.state == CallState::SettingUp {
call.state = CallState::Ringing;
emit_event(&self.out_tx, "call_ringing", serde_json::json!({ "call_id": call_id }));
} else if code >= 200 && code < 300 {
call.state = CallState::Connected;
emit_event(&self.out_tx, "call_answered", serde_json::json!({ "call_id": call_id }));
} else if code >= 300 {
let duration = call.duration_secs();
call.state = CallState::Terminated;
emit_event(
&self.out_tx,
"call_ended",
serde_json::json!({
"call_id": call_id,
"reason": format!("rejected_{code}"),
"duration": duration,
}),
);
}
}
let _ = socket.send_to(&fwd.serialize(), forward_to).await;
return true;
}
false
}
/// Create an inbound passthrough call (provider → device).
pub async fn create_inbound_call(
&mut self,
invite: &SipMessage,
from_addr: SocketAddr,
provider_id: &str,
provider_config: &ProviderConfig,
config: &AppConfig,
registrar: &Registrar,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
) -> Option<String> {
let call_id = self.next_call_id();
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
// Extract caller/callee info.
let from_header = invite.get_header("From").unwrap_or("");
let caller_number = SipMessage::extract_uri(from_header)
.unwrap_or("Unknown")
.to_string();
let called_number = invite
.request_uri()
.and_then(|uri| SipMessage::extract_uri(uri))
.unwrap_or("")
.to_string();
// Resolve target device (first registered device).
let device_addr = match self.resolve_first_device(config, registrar) {
Some(addr) => addr,
None => {
// No device registered — route to voicemail.
return self
.route_to_voicemail(
&call_id,
invite,
from_addr,
&caller_number,
provider_id,
provider_config,
config,
rtp_pool,
socket,
public_ip,
)
.await;
}
};
// Allocate RTP port.
let rtp_alloc = match rtp_pool.allocate().await {
Some(a) => a,
None => {
let resp = SipMessage::create_response(503, "Service Unavailable", invite, None);
let _ = socket.send_to(&resp.serialize(), from_addr).await;
return None;
}
};
// Create the call.
let mut call = PassthroughCall {
id: call_id.clone(),
sip_call_id: invite.call_id().to_string(),
state: CallState::Ringing,
direction: CallDirection::Inbound,
created_at: Instant::now(),
caller_number: Some(caller_number),
callee_number: Some(called_number),
provider_id: provider_id.to_string(),
provider_addr: from_addr,
provider_media: None,
device_addr,
device_media: None,
rtp_port: rtp_alloc.port,
rtp_socket: rtp_alloc.socket.clone(),
pkt_from_device: 0,
pkt_from_provider: 0,
};
// Extract provider media from SDP.
if invite.has_sdp_body() {
if let Some(ep) = parse_sdp_endpoint(&invite.body) {
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
call.provider_media = Some(addr);
}
}
}
// Start RTP relay.
let rtp_socket = rtp_alloc.socket.clone();
let device_addr_for_relay = device_addr;
let provider_addr_for_relay = from_addr;
tokio::spawn(async move {
rtp_relay_loop(rtp_socket, device_addr_for_relay, provider_addr_for_relay).await;
});
// Rewrite and forward INVITE to device.
let mut fwd_invite = invite.clone();
fwd_invite.set_request_uri(&rewrite_sip_uri(
fwd_invite.request_uri().unwrap_or(""),
&device_addr.ip().to_string(),
device_addr.port(),
));
fwd_invite.prepend_header("Record-Route", &format!("<sip:{lan_ip}:{lan_port};lr>"));
if fwd_invite.has_sdp_body() {
let (new_body, original) = rewrite_sdp(&fwd_invite.body, lan_ip, rtp_alloc.port);
fwd_invite.body = new_body;
fwd_invite.update_content_length();
if let Some(ep) = original {
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
call.provider_media = Some(addr);
}
}
}
let _ = socket.send_to(&fwd_invite.serialize(), device_addr).await;
// Store the call.
self.calls.insert(call.sip_call_id.clone(), call);
Some(call_id)
}
/// Create an outbound passthrough call (device → provider).
pub async fn create_outbound_passthrough(
&mut self,
invite: &SipMessage,
from_addr: SocketAddr,
provider_config: &ProviderConfig,
config: &AppConfig,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
) -> Option<String> {
let call_id = self.next_call_id();
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
let callee = invite.request_uri().unwrap_or("").to_string();
// Allocate RTP port.
let rtp_alloc = match rtp_pool.allocate().await {
Some(a) => a,
None => return None,
};
let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() {
Some(a) => a,
None => return None,
};
let mut call = PassthroughCall {
id: call_id.clone(),
sip_call_id: invite.call_id().to_string(),
state: CallState::SettingUp,
direction: CallDirection::Outbound,
created_at: Instant::now(),
caller_number: None,
callee_number: Some(callee),
provider_id: provider_config.id.clone(),
provider_addr: provider_dest,
provider_media: None,
device_addr: from_addr,
device_media: None,
rtp_port: rtp_alloc.port,
rtp_socket: rtp_alloc.socket.clone(),
pkt_from_device: 0,
pkt_from_provider: 0,
};
// Start RTP relay.
let rtp_socket = rtp_alloc.socket.clone();
let device_addr_for_relay = from_addr;
let provider_addr_for_relay = provider_dest;
tokio::spawn(async move {
rtp_relay_loop(rtp_socket, device_addr_for_relay, provider_addr_for_relay).await;
});
// Rewrite and forward INVITE to provider.
let mut fwd_invite = invite.clone();
fwd_invite.prepend_header("Record-Route", &format!("<sip:{lan_ip}:{lan_port};lr>"));
// Rewrite Contact to public IP.
if let Some(contact) = fwd_invite.get_header("Contact").map(|s| s.to_string()) {
let new_contact = rewrite_sip_uri(&contact, pub_ip, lan_port);
if new_contact != contact {
fwd_invite.set_header("Contact", &new_contact);
}
}
// Rewrite SDP.
if fwd_invite.has_sdp_body() {
let (new_body, original) = rewrite_sdp(&fwd_invite.body, pub_ip, rtp_alloc.port);
fwd_invite.body = new_body;
fwd_invite.update_content_length();
if let Some(ep) = original {
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
call.device_media = Some(addr);
}
}
}
let _ = socket.send_to(&fwd_invite.serialize(), provider_dest).await;
self.calls.insert(call.sip_call_id.clone(), call);
Some(call_id)
}
/// Hangup a call by call ID (from TypeScript command).
pub async fn hangup(&mut self, call_id: &str, socket: &UdpSocket) -> bool {
// Find the call by our internal call ID.
let sip_call_id = self
.calls
.iter()
.find(|(_, c)| c.id == call_id)
.map(|(k, _)| k.clone());
let sip_call_id = match sip_call_id {
Some(id) => id,
None => return false,
};
let call = match self.calls.get_mut(&sip_call_id) {
Some(c) => c,
None => return false,
};
if call.state == CallState::Terminated {
return false;
}
// Build and send BYE to both sides.
// For passthrough, we build a simple BYE using the SIP Call-ID.
let bye_msg = format!(
"BYE sip:hangup SIP/2.0\r\n\
Via: SIP/2.0/UDP 0.0.0.0:0;branch=z9hG4bK-hangup\r\n\
Call-ID: {}\r\n\
CSeq: 99 BYE\r\n\
Max-Forwards: 70\r\n\
Content-Length: 0\r\n\r\n",
sip_call_id
);
let bye_bytes = bye_msg.as_bytes();
let _ = socket.send_to(bye_bytes, call.provider_addr).await;
let _ = socket.send_to(bye_bytes, call.device_addr).await;
call.state = CallState::Terminated;
emit_event(
&self.out_tx,
"call_ended",
serde_json::json!({
"call_id": call.id,
"reason": "hangup_command",
"duration": call.duration_secs(),
}),
);
true
}
/// Get all active call statuses.
pub fn get_all_statuses(&self) -> Vec<serde_json::Value> {
self.calls
.values()
.filter(|c| c.state != CallState::Terminated)
.map(|c| c.to_status_json())
.collect()
}
/// Clean up terminated calls.
pub fn cleanup_terminated(&mut self) {
self.calls.retain(|_, c| c.state != CallState::Terminated);
}
/// Check if a SIP Call-ID belongs to any active call.
pub fn has_call(&self, sip_call_id: &str) -> bool {
self.calls.contains_key(sip_call_id)
}
// --- Dashboard outbound call (B2BUA) ---
/// Initiate an outbound call from the dashboard.
/// Builds an INVITE from scratch and sends it to the provider.
/// The browser connects separately via WebRTC and gets linked to this call.
pub async fn make_outbound_call(
&mut self,
number: &str,
provider_config: &ProviderConfig,
config: &AppConfig,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
registered_aor: &str,
) -> Option<String> {
let call_id = self.next_call_id();
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() {
Some(a) => a,
None => return None,
};
// Allocate RTP port for the provider leg.
let rtp_alloc = match rtp_pool.allocate().await {
Some(a) => a,
None => return None,
};
// Build the SIP Call-ID for this new dialog.
let sip_call_id = sip_proto::helpers::generate_call_id(None);
// Build SDP offer.
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
ip: pub_ip,
port: rtp_alloc.port,
payload_types: &provider_config.codecs,
..Default::default()
});
// Build INVITE.
let to_uri = format!("sip:{number}@{}", provider_config.domain);
let invite = SipMessage::create_request(
"INVITE",
&to_uri,
sip_proto::message::RequestOptions {
via_host: pub_ip.to_string(),
via_port: lan_port,
via_transport: None,
via_branch: Some(sip_proto::helpers::generate_branch()),
from_uri: registered_aor.to_string(),
from_display_name: None,
from_tag: Some(sip_proto::helpers::generate_tag()),
to_uri: to_uri.clone(),
to_display_name: None,
to_tag: None,
call_id: Some(sip_call_id.clone()),
cseq: Some(1),
contact: Some(format!("<sip:{pub_ip}:{lan_port}>")),
max_forwards: Some(70),
body: Some(sdp),
content_type: Some("application/sdp".to_string()),
extra_headers: Some(vec![
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
("Allow".to_string(), "INVITE, ACK, OPTIONS, CANCEL, BYE, INFO".to_string()),
]),
},
);
// Send INVITE to provider.
let _ = socket.send_to(&invite.serialize(), provider_dest).await;
// Create call entry — device_addr is a dummy (WebRTC will be linked later).
let dummy_addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let call = PassthroughCall {
id: call_id.clone(),
sip_call_id: sip_call_id.clone(),
state: CallState::SettingUp,
direction: CallDirection::Outbound,
created_at: Instant::now(),
caller_number: Some(registered_aor.to_string()),
callee_number: Some(number.to_string()),
provider_id: provider_config.id.clone(),
provider_addr: provider_dest,
provider_media: None,
device_addr: dummy_addr,
device_media: None,
rtp_port: rtp_alloc.port,
rtp_socket: rtp_alloc.socket.clone(),
pkt_from_device: 0,
pkt_from_provider: 0,
};
self.calls.insert(sip_call_id, call);
Some(call_id)
}
// --- Voicemail ---
/// Route a call to voicemail: answer the INVITE, play greeting, record message.
async fn route_to_voicemail(
&mut self,
call_id: &str,
invite: &SipMessage,
from_addr: SocketAddr,
caller_number: &str,
provider_id: &str,
provider_config: &ProviderConfig,
config: &AppConfig,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
) -> Option<String> {
let lan_ip = &config.proxy.lan_ip;
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
// Allocate RTP port for the voicemail session.
let rtp_alloc = match rtp_pool.allocate().await {
Some(a) => a,
None => {
let resp =
SipMessage::create_response(503, "Service Unavailable", invite, None);
let _ = socket.send_to(&resp.serialize(), from_addr).await;
return None;
}
};
// Determine provider's preferred codec.
let codec_pt = provider_config.codecs.first().copied().unwrap_or(9); // default G.722
// Build SDP with our RTP port.
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
ip: pub_ip,
port: rtp_alloc.port,
payload_types: &provider_config.codecs,
..Default::default()
});
// Answer the INVITE with 200 OK.
let response = SipMessage::create_response(
200,
"OK",
invite,
Some(sip_proto::message::ResponseOptions {
to_tag: Some(sip_proto::helpers::generate_tag()),
contact: Some(format!("<sip:{}:{}>", lan_ip, config.proxy.lan_port)),
body: Some(sdp),
content_type: Some("application/sdp".to_string()),
..Default::default()
}),
);
let _ = socket.send_to(&response.serialize(), from_addr).await;
// Extract provider media from original SDP.
let provider_media = if invite.has_sdp_body() {
sip_proto::helpers::parse_sdp_endpoint(&invite.body)
.and_then(|ep| format!("{}:{}", ep.address, ep.port).parse().ok())
} else {
Some(from_addr) // fallback to signaling address
};
let provider_media = provider_media.unwrap_or(from_addr);
// Create a voicemail call entry for BYE routing.
let call = PassthroughCall {
id: call_id.to_string(),
sip_call_id: invite.call_id().to_string(),
state: CallState::Voicemail,
direction: CallDirection::Inbound,
created_at: std::time::Instant::now(),
caller_number: Some(caller_number.to_string()),
callee_number: None,
provider_id: provider_id.to_string(),
provider_addr: from_addr,
provider_media: Some(provider_media),
device_addr: from_addr, // no device — just use provider addr as placeholder
device_media: None,
rtp_port: rtp_alloc.port,
rtp_socket: rtp_alloc.socket.clone(),
pkt_from_device: 0,
pkt_from_provider: 0,
};
self.calls.insert(invite.call_id().to_string(), call);
// Build recording file path.
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let recording_dir = format!(".nogit/voicemail/default");
let recording_path = format!("{recording_dir}/msg-{timestamp}.wav");
// Look for a greeting WAV file.
let greeting_wav = find_greeting_wav();
// Spawn the voicemail session.
let out_tx = self.out_tx.clone();
let call_id_owned = call_id.to_string();
let caller_owned = caller_number.to_string();
let rtp_socket = rtp_alloc.socket;
tokio::spawn(async move {
crate::voicemail::run_voicemail_session(
rtp_socket,
provider_media,
codec_pt,
greeting_wav,
recording_path,
120_000, // max 120 seconds
call_id_owned,
caller_owned,
out_tx,
)
.await;
});
Some(call_id.to_string())
}
// --- Internal helpers ---
fn resolve_first_device(&self, config: &AppConfig, registrar: &Registrar) -> Option<SocketAddr> {
for device in &config.devices {
if let Some(addr) = registrar.get_device_contact(&device.id) {
return Some(addr);
}
}
None // No device registered — caller goes to voicemail.
}
}
/// Find a voicemail greeting WAV file.
fn find_greeting_wav() -> Option<String> {
// Check common locations for a pre-generated greeting.
let candidates = [
".nogit/voicemail/default/greeting.wav",
".nogit/voicemail/greeting.wav",
];
for path in &candidates {
if std::path::Path::new(path).exists() {
return Some(path.to_string());
}
}
None // No greeting found — voicemail will just play the beep.
}
/// Rewrite SDP for provider→device direction (use LAN IP).
fn rewrite_sdp_for_device(msg: &mut SipMessage, lan_ip: &str, rtp_port: u16) {
if msg.has_sdp_body() {
let (new_body, _original) = rewrite_sdp(&msg.body, lan_ip, rtp_port);
msg.body = new_body;
msg.update_content_length();
}
}
/// Rewrite SDP for device→provider direction (use public IP).
fn rewrite_sdp_for_provider(msg: &mut SipMessage, pub_ip: &str, rtp_port: u16) {
if msg.has_sdp_body() {
let (new_body, _original) = rewrite_sdp(&msg.body, pub_ip, rtp_port);
msg.body = new_body;
msg.update_content_length();
}
}
/// Bidirectional RTP relay loop.
/// Receives packets on the relay socket and forwards based on source address.
async fn rtp_relay_loop(
socket: Arc<UdpSocket>,
device_addr: SocketAddr,
provider_addr: SocketAddr,
) {
let mut buf = vec![0u8; 65535];
let device_ip = device_addr.ip().to_string();
let provider_ip = provider_addr.ip().to_string();
// Track learned media endpoints (may differ from signaling addresses).
let mut learned_device: Option<SocketAddr> = None;
let mut learned_provider: Option<SocketAddr> = None;
loop {
match socket.recv_from(&mut buf).await {
Ok((n, from)) => {
let data = &buf[..n];
let from_ip = from.ip().to_string();
if from_ip == device_ip || learned_device.map(|d| d == from).unwrap_or(false) {
// From device → forward to provider.
if learned_device.is_none() {
learned_device = Some(from);
}
if let Some(target) = learned_provider {
let _ = socket.send_to(data, target).await;
} else {
// Provider media not yet learned; try signaling address.
let _ = socket.send_to(data, provider_addr).await;
}
} else if from_ip == provider_ip
|| learned_provider.map(|p| p == from).unwrap_or(false)
{
// From provider → forward to device.
if learned_provider.is_none() {
learned_provider = Some(from);
}
if let Some(target) = learned_device {
let _ = socket.send_to(data, target).await;
} else {
let _ = socket.send_to(data, device_addr).await;
}
} else {
// Unknown source — try to identify by known device addresses.
// For now, assume it's the device if not from provider IP range.
if learned_device.is_none() {
learned_device = Some(from);
}
}
}
Err(_) => {
// Socket closed or error — exit relay.
break;
}
}
}
}

View File

@@ -0,0 +1,325 @@
//! Configuration types received from the TypeScript control plane.
//!
//! TypeScript loads config from `.nogit/config.json` and sends it to the
//! proxy engine via the `configure` command. These types mirror the TS interfaces.
use serde::Deserialize;
use std::net::SocketAddr;
/// Network endpoint.
#[derive(Debug, Clone, Deserialize)]
pub struct Endpoint {
pub address: String,
pub port: u16,
}
impl Endpoint {
/// Resolve to a SocketAddr. Handles both IP addresses and hostnames.
pub fn to_socket_addr(&self) -> Option<SocketAddr> {
// Try direct parse first (IP address).
if let Ok(addr) = format!("{}:{}", self.address, self.port).parse() {
return Some(addr);
}
// DNS resolution for hostnames.
use std::net::ToSocketAddrs;
format!("{}:{}", self.address, self.port)
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.next())
}
}
/// Provider quirks for codec/protocol workarounds.
#[derive(Debug, Clone, Deserialize)]
pub struct Quirks {
#[serde(rename = "earlyMediaSilence")]
pub early_media_silence: bool,
#[serde(rename = "silencePayloadType")]
pub silence_payload_type: Option<u8>,
#[serde(rename = "silenceMaxPackets")]
pub silence_max_packets: Option<u32>,
}
/// A SIP trunk provider configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderConfig {
pub id: String,
#[serde(rename = "displayName")]
pub display_name: String,
pub domain: String,
#[serde(rename = "outboundProxy")]
pub outbound_proxy: Endpoint,
pub username: String,
pub password: String,
#[serde(rename = "registerIntervalSec")]
pub register_interval_sec: u32,
pub codecs: Vec<u8>,
pub quirks: Quirks,
}
/// A SIP device (phone) configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceConfig {
pub id: String,
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "expectedAddress")]
pub expected_address: String,
pub extension: String,
}
/// Route match criteria.
#[derive(Debug, Clone, Deserialize)]
pub struct RouteMatch {
pub direction: String, // "inbound" | "outbound"
#[serde(rename = "numberPattern")]
pub number_pattern: Option<String>,
#[serde(rename = "callerPattern")]
pub caller_pattern: Option<String>,
#[serde(rename = "sourceProvider")]
pub source_provider: Option<String>,
#[serde(rename = "sourceDevice")]
pub source_device: Option<String>,
}
/// Route action.
#[derive(Debug, Clone, Deserialize)]
pub struct RouteAction {
pub targets: Option<Vec<String>>,
#[serde(rename = "ringBrowsers")]
pub ring_browsers: Option<bool>,
#[serde(rename = "voicemailBox")]
pub voicemail_box: Option<String>,
#[serde(rename = "ivrMenuId")]
pub ivr_menu_id: Option<String>,
#[serde(rename = "noAnswerTimeout")]
pub no_answer_timeout: Option<u32>,
pub provider: Option<String>,
#[serde(rename = "failoverProviders")]
pub failover_providers: Option<Vec<String>>,
#[serde(rename = "stripPrefix")]
pub strip_prefix: Option<String>,
#[serde(rename = "prependPrefix")]
pub prepend_prefix: Option<String>,
}
/// A routing rule.
#[derive(Debug, Clone, Deserialize)]
pub struct Route {
pub id: String,
pub name: String,
pub priority: i32,
pub enabled: bool,
#[serde(rename = "match")]
pub match_criteria: RouteMatch,
pub action: RouteAction,
}
/// Proxy network settings.
#[derive(Debug, Clone, Deserialize)]
pub struct ProxyConfig {
#[serde(rename = "lanIp")]
pub lan_ip: String,
#[serde(rename = "lanPort")]
pub lan_port: u16,
#[serde(rename = "publicIpSeed")]
pub public_ip_seed: Option<String>,
#[serde(rename = "rtpPortRange")]
pub rtp_port_range: RtpPortRange,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RtpPortRange {
pub min: u16,
pub max: u16,
}
/// Full application config pushed from TypeScript.
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub proxy: ProxyConfig,
pub providers: Vec<ProviderConfig>,
pub devices: Vec<DeviceConfig>,
pub routing: RoutingConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RoutingConfig {
pub routes: Vec<Route>,
}
// ---------------------------------------------------------------------------
// Pattern matching (ported from ts/config.ts)
// ---------------------------------------------------------------------------
/// Test a value against a pattern string.
/// - None/empty: matches everything (wildcard)
/// - Trailing '*': prefix match
/// - Starts with '/': regex match
/// - Otherwise: exact match
pub fn matches_pattern(pattern: Option<&str>, value: &str) -> bool {
let pattern = match pattern {
None => return true,
Some(p) if p.is_empty() => return true,
Some(p) => p,
};
// Prefix match: "+49*"
if pattern.ends_with('*') {
return value.starts_with(&pattern[..pattern.len() - 1]);
}
// Regex match: "/^\\+49/" or "/pattern/i"
if pattern.starts_with('/') {
if let Some(last_slash) = pattern[1..].rfind('/') {
let re_str = &pattern[1..1 + last_slash];
let flags = &pattern[2 + last_slash..];
let case_insensitive = flags.contains('i');
if let Ok(re) = if case_insensitive {
regex_lite::Regex::new(&format!("(?i){re_str}"))
} else {
regex_lite::Regex::new(re_str)
} {
return re.is_match(value);
}
}
}
// Exact match.
value == pattern
}
/// Result of resolving an outbound route.
pub struct OutboundRouteResult {
pub provider: ProviderConfig,
pub transformed_number: String,
}
/// Result of resolving an inbound route.
pub struct InboundRouteResult {
pub device_ids: Vec<String>,
pub ring_browsers: bool,
pub voicemail_box: Option<String>,
pub ivr_menu_id: Option<String>,
pub no_answer_timeout: Option<u32>,
}
impl AppConfig {
/// Resolve which provider to use for an outbound call.
pub fn resolve_outbound_route(
&self,
dialed_number: &str,
source_device_id: Option<&str>,
is_provider_registered: &dyn Fn(&str) -> bool,
) -> Option<OutboundRouteResult> {
let mut routes: Vec<&Route> = self
.routing
.routes
.iter()
.filter(|r| r.enabled && r.match_criteria.direction == "outbound")
.collect();
routes.sort_by(|a, b| b.priority.cmp(&a.priority));
for route in &routes {
let m = &route.match_criteria;
if !matches_pattern(m.number_pattern.as_deref(), dialed_number) {
continue;
}
if let Some(sd) = &m.source_device {
if source_device_id != Some(sd.as_str()) {
continue;
}
}
// Find a registered provider.
let mut candidates: Vec<&str> = Vec::new();
if let Some(p) = &route.action.provider {
candidates.push(p);
}
if let Some(fps) = &route.action.failover_providers {
candidates.extend(fps.iter().map(|s| s.as_str()));
}
for pid in candidates {
let provider = match self.providers.iter().find(|p| p.id == pid) {
Some(p) => p,
None => continue,
};
if !is_provider_registered(pid) {
continue;
}
let mut num = dialed_number.to_string();
if let Some(strip) = &route.action.strip_prefix {
if num.starts_with(strip.as_str()) {
num = num[strip.len()..].to_string();
}
}
if let Some(prepend) = &route.action.prepend_prefix {
num = format!("{prepend}{num}");
}
return Some(OutboundRouteResult {
provider: provider.clone(),
transformed_number: num,
});
}
}
// Fallback: first provider.
self.providers.first().map(|p| OutboundRouteResult {
provider: p.clone(),
transformed_number: dialed_number.to_string(),
})
}
/// Resolve which devices to ring for an inbound call.
pub fn resolve_inbound_route(
&self,
provider_id: &str,
called_number: &str,
caller_number: &str,
) -> InboundRouteResult {
let mut routes: Vec<&Route> = self
.routing
.routes
.iter()
.filter(|r| r.enabled && r.match_criteria.direction == "inbound")
.collect();
routes.sort_by(|a, b| b.priority.cmp(&a.priority));
for route in &routes {
let m = &route.match_criteria;
if let Some(sp) = &m.source_provider {
if sp != provider_id {
continue;
}
}
if !matches_pattern(m.number_pattern.as_deref(), called_number) {
continue;
}
if !matches_pattern(m.caller_pattern.as_deref(), caller_number) {
continue;
}
return InboundRouteResult {
device_ids: route.action.targets.clone().unwrap_or_default(),
ring_browsers: route.action.ring_browsers.unwrap_or(false),
voicemail_box: route.action.voicemail_box.clone(),
ivr_menu_id: route.action.ivr_menu_id.clone(),
no_answer_timeout: route.action.no_answer_timeout,
};
}
// Fallback: ring all devices + browsers.
InboundRouteResult {
device_ids: vec![],
ring_browsers: true,
voicemail_box: None,
ivr_menu_id: None,
no_answer_timeout: None,
}
}
}

View File

@@ -0,0 +1,200 @@
//! DTMF detection — parses RFC 2833 telephone-event RTP packets.
//!
//! Deduplicates repeated packets (same digit sent multiple times with
//! increasing duration) and fires once per detected digit.
//!
//! Ported from ts/call/dtmf-detector.ts.
use crate::ipc::{emit_event, OutTx};
/// RFC 2833 event ID → character mapping.
const EVENT_CHARS: &[char] = &[
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#', 'A', 'B', 'C', 'D',
];
/// Safety timeout: report digit if no End packet arrives within this many ms.
const SAFETY_TIMEOUT_MS: u64 = 200;
/// DTMF detector for a single RTP stream.
pub struct DtmfDetector {
/// Negotiated telephone-event payload type (default 101).
telephone_event_pt: u8,
/// Clock rate for duration calculation (default 8000 Hz).
clock_rate: u32,
/// Call ID for event emission.
call_id: String,
// Deduplication state.
current_event_id: Option<u8>,
current_event_ts: Option<u32>,
current_event_reported: bool,
current_event_duration: u16,
out_tx: OutTx,
}
impl DtmfDetector {
pub fn new(call_id: String, out_tx: OutTx) -> Self {
Self {
telephone_event_pt: 101,
clock_rate: 8000,
call_id,
current_event_id: None,
current_event_ts: None,
current_event_reported: false,
current_event_duration: 0,
out_tx,
}
}
/// Feed an RTP packet. Checks PT; ignores non-DTMF packets.
/// Returns Some(digit_char) if a digit was detected.
pub fn process_rtp(&mut self, data: &[u8]) -> Option<char> {
if data.len() < 16 {
return None; // 12-byte header + 4-byte telephone-event minimum
}
let pt = data[1] & 0x7F;
if pt != self.telephone_event_pt {
return None;
}
let marker = (data[1] & 0x80) != 0;
let rtp_timestamp = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
// Parse telephone-event payload.
let event_id = data[12];
let end_bit = (data[13] & 0x80) != 0;
let duration = u16::from_be_bytes([data[14], data[15]]);
if event_id as usize >= EVENT_CHARS.len() {
return None;
}
// Detect new event.
let is_new = marker
|| self.current_event_id != Some(event_id)
|| self.current_event_ts != Some(rtp_timestamp);
if is_new {
// Report pending unreported event.
let pending = self.report_pending();
self.current_event_id = Some(event_id);
self.current_event_ts = Some(rtp_timestamp);
self.current_event_reported = false;
self.current_event_duration = duration;
if pending.is_some() {
return pending;
}
}
if duration > self.current_event_duration {
self.current_event_duration = duration;
}
// Report on End bit (first time only).
if end_bit && !self.current_event_reported {
self.current_event_reported = true;
let digit = EVENT_CHARS[event_id as usize];
let duration_ms = (self.current_event_duration as f64 / self.clock_rate as f64) * 1000.0;
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"duration_ms": duration_ms.round() as u32,
"source": "rfc2833",
}),
);
return Some(digit);
}
None
}
/// Report a pending unreported event.
fn report_pending(&mut self) -> Option<char> {
if let Some(event_id) = self.current_event_id {
if !self.current_event_reported && (event_id as usize) < EVENT_CHARS.len() {
self.current_event_reported = true;
let digit = EVENT_CHARS[event_id as usize];
let duration_ms =
(self.current_event_duration as f64 / self.clock_rate as f64) * 1000.0;
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"duration_ms": duration_ms.round() as u32,
"source": "rfc2833",
}),
);
return Some(digit);
}
}
None
}
/// Process a SIP INFO message body for DTMF.
pub fn process_sip_info(&mut self, content_type: &str, body: &str) -> Option<char> {
let ct = content_type.to_ascii_lowercase();
if ct.contains("application/dtmf-relay") {
// Format: "Signal= 5\r\nDuration= 160\r\n"
let signal = body
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("signal"))
.and_then(|l| l.split('=').nth(1))
.map(|s| s.trim().to_string())?;
if signal.len() != 1 {
return None;
}
let digit = signal.chars().next()?.to_ascii_uppercase();
if !"0123456789*#ABCD".contains(digit) {
return None;
}
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"source": "sip-info",
}),
);
return Some(digit);
}
if ct.contains("application/dtmf") {
let digit = body.trim().chars().next()?.to_ascii_uppercase();
if !"0123456789*#ABCD".contains(digit) {
return None;
}
emit_event(
&self.out_tx,
"dtmf_digit",
serde_json::json!({
"call_id": self.call_id,
"digit": digit.to_string(),
"source": "sip-info",
}),
);
return Some(digit);
}
None
}
}

View File

@@ -0,0 +1,47 @@
//! IPC protocol — command dispatch and event emission.
//!
//! All communication with the TypeScript control plane goes through
//! JSON-line messages on stdin/stdout (smartrust protocol).
use serde::Deserialize;
use tokio::sync::mpsc;
/// Sender for serialized stdout output.
pub type OutTx = mpsc::UnboundedSender<String>;
/// A command received from the TypeScript control plane.
#[derive(Deserialize)]
pub struct Command {
pub id: String,
pub method: String,
#[serde(default)]
pub params: serde_json::Value,
}
/// Send a response to a command.
pub fn respond(tx: &OutTx, id: &str, success: bool, result: Option<serde_json::Value>, error: Option<&str>) {
let mut resp = serde_json::json!({ "id": id, "success": success });
if let Some(r) = result {
resp["result"] = r;
}
if let Some(e) = error {
resp["error"] = serde_json::Value::String(e.to_string());
}
let _ = tx.send(resp.to_string());
}
/// Send a success response.
pub fn respond_ok(tx: &OutTx, id: &str, result: serde_json::Value) {
respond(tx, id, true, Some(result), None);
}
/// Send an error response.
pub fn respond_err(tx: &OutTx, id: &str, error: &str) {
respond(tx, id, false, None, Some(error));
}
/// Emit an event to the TypeScript control plane.
pub fn emit_event(tx: &OutTx, event: &str, data: serde_json::Value) {
let msg = serde_json::json!({ "event": event, "data": data });
let _ = tx.send(msg.to_string());
}

View File

@@ -0,0 +1,626 @@
/// SIP proxy engine — the Rust data plane for the SIP router.
///
/// Handles ALL SIP protocol mechanics. TypeScript only sends high-level
/// commands (routing decisions, config) and receives high-level events
/// (incoming calls, registration state).
///
/// No raw SIP ever touches TypeScript.
mod audio_player;
mod call;
mod call_manager;
mod config;
mod dtmf;
mod ipc;
mod provider;
mod recorder;
mod registrar;
mod rtp;
mod sip_transport;
mod voicemail;
mod webrtc_engine;
use crate::call_manager::CallManager;
use crate::config::AppConfig;
use crate::ipc::{emit_event, respond_err, respond_ok, Command, OutTx};
use crate::provider::ProviderManager;
use crate::registrar::Registrar;
use crate::rtp::RtpPortPool;
use crate::sip_transport::SipTransport;
use crate::webrtc_engine::WebRtcEngine;
use sip_proto::message::SipMessage;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, Mutex};
/// Shared mutable state for the proxy engine.
struct ProxyEngine {
config: Option<AppConfig>,
transport: Option<SipTransport>,
provider_mgr: ProviderManager,
registrar: Registrar,
call_mgr: CallManager,
webrtc: WebRtcEngine,
rtp_pool: Option<RtpPortPool>,
out_tx: OutTx,
}
impl ProxyEngine {
fn new(out_tx: OutTx) -> Self {
Self {
config: None,
transport: None,
provider_mgr: ProviderManager::new(out_tx.clone()),
registrar: Registrar::new(out_tx.clone()),
call_mgr: CallManager::new(out_tx.clone()),
webrtc: WebRtcEngine::new(out_tx.clone()),
rtp_pool: None,
out_tx,
}
}
}
#[tokio::main]
async fn main() {
// Output channel: all stdout writes go through here for serialization.
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
// Stdout writer task.
tokio::spawn(async move {
let mut stdout = tokio::io::stdout();
while let Some(line) = out_rx.recv().await {
let mut output = line.into_bytes();
output.push(b'\n');
if stdout.write_all(&output).await.is_err() {
break;
}
let _ = stdout.flush().await;
}
});
// Emit ready event.
emit_event(&out_tx, "ready", serde_json::json!({}));
// Shared engine state.
let engine = Arc::new(Mutex::new(ProxyEngine::new(out_tx.clone())));
// Read commands from stdin.
let stdin = tokio::io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
let cmd: Command = match serde_json::from_str(&line) {
Ok(c) => c,
Err(e) => {
respond_err(&out_tx, "", &format!("parse: {e}"));
continue;
}
};
let engine = engine.clone();
let out_tx = out_tx.clone();
// Handle commands — some are async, so we spawn.
tokio::spawn(async move {
handle_command(engine, &out_tx, cmd).await;
});
}
}
async fn handle_command(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: Command) {
match cmd.method.as_str() {
"configure" => handle_configure(engine, out_tx, &cmd).await,
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
"make_call" => handle_make_call(engine, out_tx, &cmd).await,
"get_status" => handle_get_status(engine, out_tx, &cmd).await,
"webrtc_offer" => handle_webrtc_offer(engine, out_tx, &cmd).await,
"webrtc_ice" => handle_webrtc_ice(engine, out_tx, &cmd).await,
"webrtc_link" => handle_webrtc_link(engine, out_tx, &cmd).await,
"webrtc_close" => handle_webrtc_close(engine, out_tx, &cmd).await,
_ => respond_err(out_tx, &cmd.id, &format!("unknown command: {}", cmd.method)),
}
}
/// Handle the `configure` command — receives full app config from TypeScript.
/// First call: initializes SIP transport + everything.
/// Subsequent calls: reconfigures providers/devices/routing without rebinding.
async fn handle_configure(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let app_config: AppConfig = match serde_json::from_value(cmd.params.clone()) {
Ok(c) => c,
Err(e) => {
respond_err(out_tx, &cmd.id, &format!("bad config: {e}"));
return;
}
};
let mut eng = engine.lock().await;
let is_reconfigure = eng.transport.is_some();
let socket = if is_reconfigure {
// Reconfigure — socket already bound, just update subsystems.
eng.transport.as_ref().unwrap().socket()
} else {
// First configure — bind SIP transport.
let bind_addr = format!("0.0.0.0:{}", app_config.proxy.lan_port);
let transport = match SipTransport::bind(&bind_addr).await {
Ok(t) => t,
Err(e) => {
respond_err(out_tx, &cmd.id, &format!("SIP bind failed: {e}"));
return;
}
};
let socket = transport.socket();
// Start UDP receiver.
let engine_for_recv = engine.clone();
let socket_for_recv = socket.clone();
transport.spawn_receiver(move |data: &[u8], addr: SocketAddr| {
let engine = engine_for_recv.clone();
let socket = socket_for_recv.clone();
let data = data.to_vec();
tokio::spawn(async move {
handle_sip_packet(engine, &socket, &data, addr).await;
});
});
eng.transport = Some(transport);
// Initialize RTP port pool (only on first configure).
eng.rtp_pool = Some(RtpPortPool::new(
app_config.proxy.rtp_port_range.min,
app_config.proxy.rtp_port_range.max,
));
socket
};
// (Re)configure registrar.
eng.registrar.configure(&app_config.devices);
// (Re)configure provider registrations.
eng.provider_mgr
.configure(
&app_config.providers,
app_config.proxy.public_ip_seed.as_deref(),
&app_config.proxy.lan_ip,
app_config.proxy.lan_port,
socket,
)
.await;
let bind_info = format!("0.0.0.0:{}", app_config.proxy.lan_port);
eng.config = Some(app_config);
respond_ok(
out_tx,
&cmd.id,
serde_json::json!({
"bound": bind_info,
"reconfigure": is_reconfigure,
}),
);
}
/// Handle incoming SIP packets from the UDP socket.
/// This is the core routing pipeline — entirely in Rust.
async fn handle_sip_packet(
engine: Arc<Mutex<ProxyEngine>>,
socket: &UdpSocket,
data: &[u8],
from_addr: SocketAddr,
) {
let msg = match SipMessage::parse(data) {
Some(m) => m,
None => return, // Not a valid SIP message, ignore.
};
let mut eng = engine.lock().await;
// 1. Provider registration responses — consumed internally.
if msg.is_response() {
if eng.provider_mgr.handle_response(&msg, socket).await {
return;
}
}
// 2. Device REGISTER — handled by registrar.
let is_from_provider = eng
.provider_mgr
.find_by_address(&from_addr)
.await
.is_some();
if !is_from_provider && msg.method() == Some("REGISTER") {
if let Some(response_buf) = eng.registrar.handle_register(&msg, from_addr) {
let _ = socket.send_to(&response_buf, from_addr).await;
return;
}
}
// 3. Route to existing call by SIP Call-ID.
// Check if this Call-ID belongs to an active call (avoids borrow conflict).
if eng.call_mgr.has_call(msg.call_id()) {
let config_ref = eng.config.as_ref().unwrap().clone();
// Temporarily take registrar to avoid overlapping borrows.
let registrar_dummy = Registrar::new(eng.out_tx.clone());
if eng
.call_mgr
.route_sip_message(&msg, from_addr, socket, &config_ref, &registrar_dummy)
.await
{
return;
}
}
let config_ref = eng.config.as_ref().unwrap().clone();
// 4. New inbound INVITE from provider.
if is_from_provider && msg.is_request() && msg.method() == Some("INVITE") {
// Detect public IP from Via.
if let Some(via) = msg.get_header("Via") {
if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
let mut ps = ps_arc.lock().await;
ps.detect_public_ip(via);
}
}
// Send 100 Trying immediately.
let trying = SipMessage::create_response(100, "Trying", &msg, None);
let _ = socket.send_to(&trying.serialize(), from_addr).await;
// Determine provider info.
let (provider_id, provider_config, public_ip) =
if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
let ps = ps_arc.lock().await;
(
ps.config.id.clone(),
ps.config.clone(),
ps.public_ip.clone(),
)
} else {
return;
};
// Create the inbound call — Rust handles everything.
// Split borrows via destructuring to satisfy the borrow checker.
let ProxyEngine {
ref registrar,
ref mut call_mgr,
ref mut rtp_pool,
..
} = *eng;
let rtp_pool = rtp_pool.as_mut().unwrap();
let call_id = call_mgr
.create_inbound_call(
&msg,
from_addr,
&provider_id,
&provider_config,
&config_ref,
registrar,
rtp_pool,
socket,
public_ip.as_deref(),
)
.await;
if let Some(call_id) = call_id {
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
let from_header = msg.get_header("From").unwrap_or("");
let from_uri = SipMessage::extract_uri(from_header).unwrap_or("Unknown");
let called_number = msg
.request_uri()
.and_then(|uri| SipMessage::extract_uri(uri))
.unwrap_or("");
emit_event(
&eng.out_tx,
"incoming_call",
serde_json::json!({
"call_id": call_id,
"from_uri": from_uri,
"to_number": called_number,
"provider_id": provider_id,
}),
);
}
return;
}
// 5. New outbound INVITE from device.
if !is_from_provider && msg.is_request() && msg.method() == Some("INVITE") {
// Resolve outbound route.
let dialed_number = msg
.request_uri()
.and_then(|uri| SipMessage::extract_uri(uri))
.unwrap_or(msg.request_uri().unwrap_or(""))
.to_string();
let device = eng.registrar.find_by_address(&from_addr);
let device_id = device.map(|d| d.device_id.clone());
// Find provider via routing rules.
let route_result = config_ref.resolve_outbound_route(
&dialed_number,
device_id.as_deref(),
&|pid: &str| {
// Can't call async here — use a sync check.
// For now, assume all configured providers are available.
true
},
);
if let Some(route) = route_result {
let public_ip = if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
let ps = ps_arc.lock().await;
ps.public_ip.clone()
} else {
None
};
let ProxyEngine {
ref mut call_mgr,
ref mut rtp_pool,
..
} = *eng;
let rtp_pool = rtp_pool.as_mut().unwrap();
let call_id = call_mgr
.create_outbound_passthrough(
&msg,
from_addr,
&route.provider,
&config_ref,
rtp_pool,
socket,
public_ip.as_deref(),
)
.await;
if let Some(call_id) = call_id {
emit_event(
&eng.out_tx,
"outbound_device_call",
serde_json::json!({
"call_id": call_id,
"from_device": device_id,
"to_number": dialed_number,
}),
);
}
}
return;
}
// 6. Other messages — log for debugging.
let label = if msg.is_request() {
msg.method().unwrap_or("?").to_string()
} else {
msg.status_code().map(|c| c.to_string()).unwrap_or_default()
};
emit_event(
&eng.out_tx,
"sip_unhandled",
serde_json::json!({
"method_or_status": label,
"call_id": msg.call_id(),
"from_addr": from_addr.ip().to_string(),
"from_port": from_addr.port(),
"is_from_provider": is_from_provider,
}),
);
}
/// Handle `get_status` — return active call statuses from Rust.
async fn handle_get_status(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let eng = engine.lock().await;
let calls = eng.call_mgr.get_all_statuses();
respond_ok(out_tx, &cmd.id, serde_json::json!({ "calls": calls }));
}
/// Handle `make_call` — initiate an outbound call to a number via a provider.
async fn handle_make_call(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let number = match cmd.params.get("number").and_then(|v| v.as_str()) {
Some(n) => n.to_string(),
None => { respond_err(out_tx, &cmd.id, "missing number"); return; }
};
let provider_id = cmd.params.get("provider_id").and_then(|v| v.as_str());
let mut eng = engine.lock().await;
let config_ref = match &eng.config {
Some(c) => c.clone(),
None => { respond_err(out_tx, &cmd.id, "not configured"); return; }
};
// Resolve provider.
let provider_config = if let Some(pid) = provider_id {
config_ref.providers.iter().find(|p| p.id == pid).cloned()
} else {
// Use route resolution or first provider.
let route = config_ref.resolve_outbound_route(&number, None, &|_| true);
route.map(|r| r.provider)
};
let provider_config = match provider_config {
Some(p) => p,
None => { respond_err(out_tx, &cmd.id, "no provider available"); return; }
};
// Get public IP and registered AOR from provider state.
let (public_ip, registered_aor) = if let Some(ps_arc) = eng.provider_mgr.find_by_address(
&provider_config.outbound_proxy.to_socket_addr().unwrap_or_else(|| "0.0.0.0:0".parse().unwrap())
).await {
let ps = ps_arc.lock().await;
(ps.public_ip.clone(), ps.registered_aor.clone())
} else {
// Fallback — construct AOR from config.
(None, format!("sip:{}@{}", provider_config.username, provider_config.domain))
};
let socket = match &eng.transport {
Some(t) => t.socket(),
None => { respond_err(out_tx, &cmd.id, "not initialized"); return; }
};
let ProxyEngine { ref mut call_mgr, ref mut rtp_pool, .. } = *eng;
let rtp_pool = rtp_pool.as_mut().unwrap();
let call_id = call_mgr.make_outbound_call(
&number,
&provider_config,
&config_ref,
rtp_pool,
&socket,
public_ip.as_deref(),
&registered_aor,
).await;
match call_id {
Some(id) => {
emit_event(out_tx, "outbound_call_started", serde_json::json!({
"call_id": id,
"number": number,
"provider_id": provider_config.id,
}));
respond_ok(out_tx, &cmd.id, serde_json::json!({ "call_id": id }));
}
None => {
respond_err(out_tx, &cmd.id, "call origination failed — provider not registered or no ports available");
}
}
}
/// Handle the `hangup` command.
async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
respond_err(out_tx, &cmd.id, "missing call_id");
return;
}
};
let mut eng = engine.lock().await;
let socket = match &eng.transport {
Some(t) => t.socket(),
None => {
respond_err(out_tx, &cmd.id, "not initialized");
return;
}
};
if eng.call_mgr.hangup(&call_id, &socket).await {
respond_ok(out_tx, &cmd.id, serde_json::json!({}));
} else {
respond_err(out_tx, &cmd.id, &format!("call {call_id} not found"));
}
}
/// Handle `webrtc_offer` — browser sends SDP offer, we create PeerConnection and return answer.
async fn handle_webrtc_offer(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; }
};
let offer_sdp = match cmd.params.get("sdp").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => { respond_err(out_tx, &cmd.id, "missing sdp"); return; }
};
let mut eng = engine.lock().await;
match eng.webrtc.handle_offer(&session_id, &offer_sdp).await {
Ok(answer_sdp) => {
respond_ok(out_tx, &cmd.id, serde_json::json!({
"session_id": session_id,
"sdp": answer_sdp,
}));
}
Err(e) => respond_err(out_tx, &cmd.id, &e),
}
}
/// Handle `webrtc_ice` — forward ICE candidate from browser to Rust PeerConnection.
async fn handle_webrtc_ice(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; }
};
let candidate = cmd.params.get("candidate").and_then(|v| v.as_str()).unwrap_or("");
let sdp_mid = cmd.params.get("sdp_mid").and_then(|v| v.as_str());
let sdp_mline_index = cmd.params.get("sdp_mline_index").and_then(|v| v.as_u64()).map(|v| v as u16);
let eng = engine.lock().await;
match eng.webrtc.add_ice_candidate(&session_id, candidate, sdp_mid, sdp_mline_index).await {
Ok(()) => respond_ok(out_tx, &cmd.id, serde_json::json!({})),
Err(e) => respond_err(out_tx, &cmd.id, &e),
}
}
/// Handle `webrtc_link` — link a WebRTC session to a SIP call for audio bridging.
async fn handle_webrtc_link(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; }
};
let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => { respond_err(out_tx, &cmd.id, "missing call_id"); return; }
};
let provider_addr = match cmd.params.get("provider_media_addr").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => { respond_err(out_tx, &cmd.id, "missing provider_media_addr"); return; }
};
let provider_port = match cmd.params.get("provider_media_port").and_then(|v| v.as_u64()) {
Some(p) => p as u16,
None => { respond_err(out_tx, &cmd.id, "missing provider_media_port"); return; }
};
let sip_pt = cmd.params.get("sip_pt").and_then(|v| v.as_u64()).unwrap_or(9) as u8;
let provider_media: SocketAddr = match format!("{provider_addr}:{provider_port}").parse() {
Ok(a) => a,
Err(e) => { respond_err(out_tx, &cmd.id, &format!("bad address: {e}")); return; }
};
let mut eng = engine.lock().await;
let sip_socket = match &eng.transport {
Some(t) => t.socket(),
None => { respond_err(out_tx, &cmd.id, "not initialized"); return; }
};
let bridge_info = crate::webrtc_engine::SipBridgeInfo {
provider_media,
sip_pt,
sip_socket,
};
if eng.webrtc.link_to_sip(&session_id, &call_id, bridge_info).await {
respond_ok(out_tx, &cmd.id, serde_json::json!({
"session_id": session_id,
"call_id": call_id,
"bridged": true,
}));
} else {
respond_err(out_tx, &cmd.id, &format!("session {session_id} not found"));
}
}
/// Handle `webrtc_close` — close a WebRTC session.
async fn handle_webrtc_close(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let session_id = match cmd.params.get("session_id").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => { respond_err(out_tx, &cmd.id, "missing session_id"); return; }
};
let mut eng = engine.lock().await;
match eng.webrtc.close_session(&session_id).await {
Ok(()) => respond_ok(out_tx, &cmd.id, serde_json::json!({})),
Err(e) => respond_err(out_tx, &cmd.id, &e),
}
}

View File

@@ -0,0 +1,367 @@
//! Provider registration state machine.
//!
//! Handles the REGISTER cycle with upstream SIP providers:
//! - Sends periodic REGISTER messages
//! - Handles 401/407 Digest authentication challenges
//! - Detects public IP from Via received= parameter
//! - Emits registration state events to TypeScript
//!
//! Ported from ts/providerstate.ts.
use crate::config::ProviderConfig;
use crate::ipc::{emit_event, OutTx};
use sip_proto::helpers::{
compute_digest_auth, generate_branch, generate_call_id, generate_tag, parse_digest_challenge,
};
use sip_proto::message::{RequestOptions, SipMessage};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::Mutex;
use tokio::time::{self, Duration};
/// Runtime state for a single SIP provider.
pub struct ProviderState {
pub config: ProviderConfig,
pub public_ip: Option<String>,
pub is_registered: bool,
pub registered_aor: String,
// Registration transaction state.
reg_call_id: String,
reg_cseq: u32,
reg_from_tag: String,
// Network.
lan_ip: String,
lan_port: u16,
}
impl ProviderState {
pub fn new(config: ProviderConfig, public_ip_seed: Option<&str>) -> Self {
let aor = format!("sip:{}@{}", config.username, config.domain);
Self {
public_ip: public_ip_seed.map(|s| s.to_string()),
is_registered: false,
registered_aor: aor,
reg_call_id: generate_call_id(None),
reg_cseq: 0,
reg_from_tag: generate_tag(),
lan_ip: String::new(),
lan_port: 0,
config,
}
}
/// Build and send a REGISTER request.
pub fn build_register(&mut self) -> Vec<u8> {
self.reg_cseq += 1;
let pub_ip = self.public_ip.as_deref().unwrap_or(&self.lan_ip);
let register = SipMessage::create_request(
"REGISTER",
&format!("sip:{}", self.config.domain),
RequestOptions {
via_host: pub_ip.to_string(),
via_port: self.lan_port,
via_transport: None,
via_branch: Some(generate_branch()),
from_uri: self.registered_aor.clone(),
from_display_name: None,
from_tag: Some(self.reg_from_tag.clone()),
to_uri: self.registered_aor.clone(),
to_display_name: None,
to_tag: None,
call_id: Some(self.reg_call_id.clone()),
cseq: Some(self.reg_cseq),
contact: Some(format!(
"<sip:{}@{}:{}>",
self.config.username, pub_ip, self.lan_port
)),
max_forwards: Some(70),
body: None,
content_type: None,
extra_headers: Some(vec![
(
"Expires".to_string(),
self.config.register_interval_sec.to_string(),
),
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
(
"Allow".to_string(),
"INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE"
.to_string(),
),
]),
},
);
register.serialize()
}
/// Handle a SIP response that might be for this provider's REGISTER.
/// Returns true if the message was consumed.
pub fn handle_registration_response(&mut self, msg: &SipMessage) -> Option<Vec<u8>> {
if !msg.is_response() {
return None;
}
if msg.call_id() != self.reg_call_id {
return None;
}
let cseq_method = msg.cseq_method().unwrap_or("");
if !cseq_method.eq_ignore_ascii_case("REGISTER") {
return None;
}
let code = msg.status_code().unwrap_or(0);
if code == 200 {
self.is_registered = true;
return Some(Vec::new()); // consumed, no reply needed
}
if code == 401 || code == 407 {
let challenge_header = if code == 401 {
msg.get_header("WWW-Authenticate")
} else {
msg.get_header("Proxy-Authenticate")
};
let challenge_header = match challenge_header {
Some(h) => h,
None => return Some(Vec::new()), // consumed but no challenge
};
let challenge = match parse_digest_challenge(challenge_header) {
Some(c) => c,
None => return Some(Vec::new()),
};
let auth_value = compute_digest_auth(
&self.config.username,
&self.config.password,
&challenge.realm,
&challenge.nonce,
"REGISTER",
&format!("sip:{}", self.config.domain),
challenge.algorithm.as_deref(),
challenge.opaque.as_deref(),
);
// Resend REGISTER with auth credentials.
self.reg_cseq += 1;
let pub_ip = self.public_ip.as_deref().unwrap_or(&self.lan_ip);
let auth_header_name = if code == 401 {
"Authorization"
} else {
"Proxy-Authorization"
};
let register = SipMessage::create_request(
"REGISTER",
&format!("sip:{}", self.config.domain),
RequestOptions {
via_host: pub_ip.to_string(),
via_port: self.lan_port,
via_transport: None,
via_branch: Some(generate_branch()),
from_uri: self.registered_aor.clone(),
from_display_name: None,
from_tag: Some(self.reg_from_tag.clone()),
to_uri: self.registered_aor.clone(),
to_display_name: None,
to_tag: None,
call_id: Some(self.reg_call_id.clone()),
cseq: Some(self.reg_cseq),
contact: Some(format!(
"<sip:{}@{}:{}>",
self.config.username, pub_ip, self.lan_port
)),
max_forwards: Some(70),
body: None,
content_type: None,
extra_headers: Some(vec![
(auth_header_name.to_string(), auth_value),
(
"Expires".to_string(),
self.config.register_interval_sec.to_string(),
),
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
(
"Allow".to_string(),
"INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE"
.to_string(),
),
]),
},
);
return Some(register.serialize());
}
if code >= 400 {
self.is_registered = false;
}
Some(Vec::new()) // consumed
}
/// Detect public IP from Via received= parameter.
pub fn detect_public_ip(&mut self, via: &str) {
if let Some(m) = via.find("received=") {
let rest = &via[m + 9..];
let end = rest
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(rest.len());
let ip = &rest[..end];
if !ip.is_empty() && self.public_ip.as_deref() != Some(ip) {
self.public_ip = Some(ip.to_string());
}
}
}
pub fn set_network(&mut self, lan_ip: &str, lan_port: u16) {
self.lan_ip = lan_ip.to_string();
self.lan_port = lan_port;
}
}
/// Manages all provider states and their registration cycles.
pub struct ProviderManager {
providers: Vec<Arc<Mutex<ProviderState>>>,
out_tx: OutTx,
}
impl ProviderManager {
pub fn new(out_tx: OutTx) -> Self {
Self {
providers: Vec::new(),
out_tx,
}
}
/// Initialize providers from config and start registration cycles.
pub async fn configure(
&mut self,
configs: &[ProviderConfig],
public_ip_seed: Option<&str>,
lan_ip: &str,
lan_port: u16,
socket: Arc<UdpSocket>,
) {
self.providers.clear();
for cfg in configs {
let mut ps = ProviderState::new(cfg.clone(), public_ip_seed);
ps.set_network(lan_ip, lan_port);
let ps = Arc::new(Mutex::new(ps));
self.providers.push(ps.clone());
// Start the registration cycle.
let socket = socket.clone();
let out_tx = self.out_tx.clone();
tokio::spawn(async move {
provider_register_loop(ps, socket, out_tx).await;
});
}
}
/// Try to handle a SIP response as a provider registration response.
/// Returns true if consumed.
pub async fn handle_response(
&self,
msg: &SipMessage,
socket: &UdpSocket,
) -> bool {
for ps_arc in &self.providers {
let mut ps = ps_arc.lock().await;
let was_registered = ps.is_registered;
if let Some(reply) = ps.handle_registration_response(msg) {
// If there's a reply to send (e.g. auth retry).
if !reply.is_empty() {
if let Some(dest) = ps.config.outbound_proxy.to_socket_addr() {
let _ = socket.send_to(&reply, dest).await;
}
}
// Emit registration state change.
if ps.is_registered != was_registered {
emit_event(
&self.out_tx,
"provider_registered",
serde_json::json!({
"provider_id": ps.config.id,
"registered": ps.is_registered,
"public_ip": ps.public_ip,
}),
);
}
return true;
}
}
false
}
/// Find which provider sent a packet by matching source address.
pub async fn find_by_address(&self, addr: &SocketAddr) -> Option<Arc<Mutex<ProviderState>>> {
for ps_arc in &self.providers {
let ps = ps_arc.lock().await;
let proxy_addr = format!(
"{}:{}",
ps.config.outbound_proxy.address, ps.config.outbound_proxy.port
);
if let Ok(expected) = proxy_addr.parse::<SocketAddr>() {
if expected == *addr {
return Some(ps_arc.clone());
}
}
// Also match by IP only (port may differ).
if ps.config.outbound_proxy.address == addr.ip().to_string() {
return Some(ps_arc.clone());
}
}
None
}
/// Check if a provider is currently registered.
pub async fn is_registered(&self, provider_id: &str) -> bool {
for ps_arc in &self.providers {
let ps = ps_arc.lock().await;
if ps.config.id == provider_id {
return ps.is_registered;
}
}
false
}
}
/// Registration loop for a single provider.
async fn provider_register_loop(
ps: Arc<Mutex<ProviderState>>,
socket: Arc<UdpSocket>,
_out_tx: OutTx,
) {
// Initial registration.
{
let mut state = ps.lock().await;
let register_buf = state.build_register();
if let Some(dest) = state.config.outbound_proxy.to_socket_addr() {
let _ = socket.send_to(&register_buf, dest).await;
}
}
// Re-register periodically (85% of the interval).
let interval_sec = {
let state = ps.lock().await;
(state.config.register_interval_sec as f64 * 0.85) as u64
};
let mut interval = time::interval(Duration::from_secs(interval_sec.max(30)));
interval.tick().await; // skip first immediate tick
loop {
interval.tick().await;
let mut state = ps.lock().await;
let register_buf = state.build_register();
if let Some(dest) = state.config.outbound_proxy.to_socket_addr() {
let _ = socket.send_to(&register_buf, dest).await;
}
}
}

View File

@@ -0,0 +1,132 @@
//! Audio recorder — receives RTP packets and writes a WAV file.
use codec_lib::TranscodeState;
use std::path::Path;
/// Active recording session.
pub struct Recorder {
writer: hound::WavWriter<std::io::BufWriter<std::fs::File>>,
transcoder: TranscodeState,
source_pt: u8,
total_samples: u64,
sample_rate: u32,
max_samples: Option<u64>,
file_path: String,
}
impl Recorder {
/// Create a new recorder that writes to a WAV file.
/// `source_pt` is the RTP payload type of the incoming audio.
/// `max_duration_ms` optionally limits the recording length.
pub fn new(
file_path: &str,
source_pt: u8,
max_duration_ms: Option<u64>,
) -> Result<Self, String> {
// Ensure parent directory exists.
if let Some(parent) = Path::new(file_path).parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create dir: {e}"))?;
}
let sample_rate = 8000u32; // Record at 8kHz (standard telephony)
let spec = hound::WavSpec {
channels: 1,
sample_rate,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let writer = hound::WavWriter::create(file_path, spec)
.map_err(|e| format!("create WAV {file_path}: {e}"))?;
let transcoder = TranscodeState::new().map_err(|e| format!("codec init: {e}"))?;
let max_samples = max_duration_ms.map(|ms| (sample_rate as u64 * ms) / 1000);
Ok(Self {
writer,
transcoder,
source_pt,
total_samples: 0,
sample_rate,
max_samples,
file_path: file_path.to_string(),
})
}
/// Process an incoming RTP packet (full packet with header).
/// Returns true if recording should continue, false if max duration reached.
pub fn process_rtp(&mut self, data: &[u8]) -> bool {
if data.len() <= 12 {
return true; // Too short, skip.
}
let pt = data[1] & 0x7F;
// Skip telephone-event (DTMF) packets.
if pt == 101 {
return true;
}
let payload = &data[12..];
if payload.is_empty() {
return true;
}
// Decode to PCM.
let (pcm, rate) = match self.transcoder.decode_to_pcm(payload, self.source_pt) {
Ok(result) => result,
Err(_) => return true, // Decode failed, skip packet.
};
// Resample to 8kHz if needed.
let pcm_8k = if rate != self.sample_rate {
match self.transcoder.resample(&pcm, rate, self.sample_rate) {
Ok(r) => r,
Err(_) => return true,
}
} else {
pcm
};
// Write samples.
for &sample in &pcm_8k {
if let Err(_) = self.writer.write_sample(sample) {
return false;
}
self.total_samples += 1;
if let Some(max) = self.max_samples {
if self.total_samples >= max {
return false; // Max duration reached.
}
}
}
true
}
/// Stop recording and finalize the WAV file.
pub fn stop(self) -> RecordingResult {
let duration_ms = if self.sample_rate > 0 {
(self.total_samples * 1000) / self.sample_rate as u64
} else {
0
};
// Writer is finalized on drop (writes RIFF header sizes).
drop(self.writer);
RecordingResult {
file_path: self.file_path,
duration_ms,
total_samples: self.total_samples,
}
}
}
pub struct RecordingResult {
pub file_path: String,
pub duration_ms: u64,
pub total_samples: u64,
}

View File

@@ -0,0 +1,171 @@
//! Device registrar — accepts REGISTER from SIP phones and tracks contacts.
//!
//! When a device sends REGISTER, the registrar responds with 200 OK
//! and stores the device's current contact (source IP:port).
//!
//! Ported from ts/registrar.ts.
use crate::config::DeviceConfig;
use crate::ipc::{emit_event, OutTx};
use sip_proto::helpers::generate_tag;
use sip_proto::message::{ResponseOptions, SipMessage};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
const MAX_EXPIRES: u32 = 300;
/// A registered device entry.
#[derive(Debug, Clone)]
pub struct RegisteredDevice {
pub device_id: String,
pub display_name: String,
pub extension: String,
pub contact_addr: SocketAddr,
pub registered_at: Instant,
pub expires_at: Instant,
pub aor: String,
}
/// Manages device registrations.
pub struct Registrar {
/// Known device configs (from app config).
devices: Vec<DeviceConfig>,
/// Currently registered devices, keyed by device ID.
registered: HashMap<String, RegisteredDevice>,
out_tx: OutTx,
}
impl Registrar {
pub fn new(out_tx: OutTx) -> Self {
Self {
devices: Vec::new(),
registered: HashMap::new(),
out_tx,
}
}
/// Update the known device list from config.
pub fn configure(&mut self, devices: &[DeviceConfig]) {
self.devices = devices.to_vec();
}
/// Try to handle a SIP REGISTER from a device.
/// Returns Some(response_bytes) if handled, None if not a known device.
pub fn handle_register(
&mut self,
msg: &SipMessage,
from_addr: SocketAddr,
) -> Option<Vec<u8>> {
if msg.method() != Some("REGISTER") {
return None;
}
// Find the device by matching the source IP against expectedAddress.
let from_ip = from_addr.ip().to_string();
let device = self.devices.iter().find(|d| d.expected_address == from_ip)?;
let from_header = msg.get_header("From").unwrap_or("");
let aor = SipMessage::extract_uri(from_header)
.map(|s| s.to_string())
.unwrap_or_else(|| format!("sip:{}@{}", device.extension, from_ip));
let expires_header = msg.get_header("Expires");
let requested: u32 = expires_header
.and_then(|s| s.parse().ok())
.unwrap_or(3600);
let expires = requested.min(MAX_EXPIRES);
let entry = RegisteredDevice {
device_id: device.id.clone(),
display_name: device.display_name.clone(),
extension: device.extension.clone(),
contact_addr: from_addr,
registered_at: Instant::now(),
expires_at: Instant::now() + Duration::from_secs(expires as u64),
aor: aor.clone(),
};
self.registered.insert(device.id.clone(), entry);
// Emit event to TypeScript.
emit_event(
&self.out_tx,
"device_registered",
serde_json::json!({
"device_id": device.id,
"display_name": device.display_name,
"address": from_ip,
"port": from_addr.port(),
"aor": aor,
"expires": expires,
}),
);
// Build 200 OK response.
let contact = msg
.get_header("Contact")
.map(|s| s.to_string())
.unwrap_or_else(|| format!("<sip:{}:{}>", from_ip, from_addr.port()));
let response = SipMessage::create_response(
200,
"OK",
msg,
Some(ResponseOptions {
to_tag: Some(generate_tag()),
contact: Some(contact),
extra_headers: Some(vec![(
"Expires".to_string(),
expires.to_string(),
)]),
..Default::default()
}),
);
Some(response.serialize())
}
/// Get the contact address for a registered device.
pub fn get_device_contact(&self, device_id: &str) -> Option<SocketAddr> {
let entry = self.registered.get(device_id)?;
if Instant::now() > entry.expires_at {
return None;
}
Some(entry.contact_addr)
}
/// Check if a source address belongs to a known device.
pub fn is_known_device_address(&self, addr: &str) -> bool {
self.devices.iter().any(|d| d.expected_address == addr)
}
/// Find a registered device by its source IP address.
pub fn find_by_address(&self, addr: &SocketAddr) -> Option<&RegisteredDevice> {
let ip = addr.ip().to_string();
self.registered.values().find(|e| {
e.contact_addr.ip().to_string() == ip && Instant::now() <= e.expires_at
})
}
/// Get all device statuses for the dashboard.
pub fn get_all_statuses(&self) -> Vec<serde_json::Value> {
let now = Instant::now();
let mut result = Vec::new();
for dc in &self.devices {
let reg = self.registered.get(&dc.id);
let connected = reg.map(|r| now <= r.expires_at).unwrap_or(false);
result.push(serde_json::json!({
"id": dc.id,
"displayName": dc.display_name,
"address": reg.filter(|_| connected).map(|r| r.contact_addr.ip().to_string()),
"port": reg.filter(|_| connected).map(|r| r.contact_addr.port()),
"aor": reg.map(|r| r.aor.as_str()).unwrap_or(""),
"connected": connected,
"isBrowser": false,
}));
}
result
}
}

View File

@@ -0,0 +1,158 @@
//! RTP port pool and media forwarding.
//!
//! Manages a pool of even-numbered UDP ports for RTP media.
//! Each port gets a bound tokio UdpSocket. Supports:
//! - Direct forwarding (SIP-to-SIP, no transcoding)
//! - Transcoding forwarding (via codec-lib, e.g. G.722 ↔ Opus)
//! - Silence generation
//! - NAT priming
//!
//! Ported from ts/call/rtp-port-pool.ts + sip-leg.ts RTP handling.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
/// A single RTP port allocation.
pub struct RtpAllocation {
pub port: u16,
pub socket: Arc<UdpSocket>,
}
/// RTP port pool — allocates even-numbered UDP ports.
pub struct RtpPortPool {
min: u16,
max: u16,
allocated: HashMap<u16, Arc<UdpSocket>>,
}
impl RtpPortPool {
pub fn new(min: u16, max: u16) -> Self {
let min = if min % 2 == 0 { min } else { min + 1 };
Self {
min,
max,
allocated: HashMap::new(),
}
}
/// Allocate an even-numbered port and bind a UDP socket.
pub async fn allocate(&mut self) -> Option<RtpAllocation> {
let mut port = self.min;
while port < self.max {
if !self.allocated.contains_key(&port) {
match UdpSocket::bind(format!("0.0.0.0:{port}")).await {
Ok(sock) => {
let sock = Arc::new(sock);
self.allocated.insert(port, sock.clone());
return Some(RtpAllocation { port, socket: sock });
}
Err(_) => {
// Port in use, try next.
}
}
}
port += 2;
}
None // Pool exhausted.
}
/// Release a port back to the pool.
pub fn release(&mut self, port: u16) {
self.allocated.remove(&port);
// Socket is dropped when the last Arc reference goes away.
}
pub fn size(&self) -> usize {
self.allocated.len()
}
pub fn capacity(&self) -> usize {
((self.max - self.min) / 2) as usize
}
}
/// An active RTP relay between two endpoints.
/// Receives on `local_socket` and forwards to `remote_addr`.
pub struct RtpRelay {
pub local_port: u16,
pub local_socket: Arc<UdpSocket>,
pub remote_addr: Option<SocketAddr>,
/// If set, transcode packets using this codec session before forwarding.
pub transcode: Option<TranscodeConfig>,
/// Packets received counter.
pub pkt_received: u64,
/// Packets sent counter.
pub pkt_sent: u64,
}
pub struct TranscodeConfig {
pub from_pt: u8,
pub to_pt: u8,
pub session_id: String,
}
impl RtpRelay {
pub fn new(port: u16, socket: Arc<UdpSocket>) -> Self {
Self {
local_port: port,
local_socket: socket,
remote_addr: None,
transcode: None,
pkt_received: 0,
pkt_sent: 0,
}
}
pub fn set_remote(&mut self, addr: SocketAddr) {
self.remote_addr = Some(addr);
}
}
/// Send a 1-byte NAT priming packet to open a pinhole.
pub async fn prime_nat(socket: &UdpSocket, remote: SocketAddr) {
let _ = socket.send_to(&[0u8], remote).await;
}
/// Build an RTP silence frame for PCMU (payload type 0).
pub fn silence_frame_pcmu() -> Vec<u8> {
// 12-byte RTP header + 160 bytes of µ-law silence (0xFF)
let mut frame = vec![0u8; 172];
frame[0] = 0x80; // V=2
frame[1] = 0; // PT=0 (PCMU)
// seq, timestamp, ssrc left as 0 — caller should set these
frame[12..].fill(0xFF); // µ-law silence
frame
}
/// Build an RTP silence frame for G.722 (payload type 9).
pub fn silence_frame_g722() -> Vec<u8> {
// 12-byte RTP header + 160 bytes of G.722 silence
let mut frame = vec![0u8; 172];
frame[0] = 0x80; // V=2
frame[1] = 9; // PT=9 (G.722)
// G.722 silence: all zeros is valid silence
frame
}
/// Build an RTP header with the given parameters.
pub fn build_rtp_header(pt: u8, seq: u16, timestamp: u32, ssrc: u32) -> [u8; 12] {
let mut header = [0u8; 12];
header[0] = 0x80; // V=2
header[1] = pt & 0x7F;
header[2..4].copy_from_slice(&seq.to_be_bytes());
header[4..8].copy_from_slice(&timestamp.to_be_bytes());
header[8..12].copy_from_slice(&ssrc.to_be_bytes());
header
}
/// Get the RTP clock increment per 20ms frame for a payload type.
pub fn rtp_clock_increment(pt: u8) -> u32 {
match pt {
9 => 160, // G.722: 8000 Hz clock rate (despite 16kHz audio) × 0.02s
0 | 8 => 160, // PCMU/PCMA: 8000 × 0.02
111 => 960, // Opus: 48000 × 0.02
_ => 160,
}
}

View File

@@ -0,0 +1,67 @@
//! SIP UDP transport — owns the main SIP socket.
//!
//! Binds a UDP socket, receives SIP messages, and provides a send method.
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
/// The SIP UDP transport layer.
pub struct SipTransport {
socket: Arc<UdpSocket>,
}
impl SipTransport {
/// Bind a UDP socket on the given address (e.g. "0.0.0.0:5070").
pub async fn bind(bind_addr: &str) -> Result<Self, String> {
let socket = UdpSocket::bind(bind_addr)
.await
.map_err(|e| format!("bind {bind_addr}: {e}"))?;
Ok(Self {
socket: Arc::new(socket),
})
}
/// Get a clone of the socket Arc for the receiver task.
pub fn socket(&self) -> Arc<UdpSocket> {
self.socket.clone()
}
/// Send a raw SIP message to a destination.
pub async fn send_to(&self, data: &[u8], dest: SocketAddr) -> Result<usize, String> {
self.socket
.send_to(data, dest)
.await
.map_err(|e| format!("send to {dest}: {e}"))
}
/// Send a raw SIP message to an address:port pair.
pub async fn send_to_addr(&self, data: &[u8], addr: &str, port: u16) -> Result<usize, String> {
let dest: SocketAddr = format!("{addr}:{port}")
.parse()
.map_err(|e| format!("bad address {addr}:{port}: {e}"))?;
self.send_to(data, dest).await
}
/// Spawn the UDP receive loop. Calls the handler for every received packet.
pub fn spawn_receiver<F>(
&self,
handler: F,
) where
F: Fn(&[u8], SocketAddr) + Send + 'static,
{
let socket = self.socket.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 65535];
loop {
match socket.recv_from(&mut buf).await {
Ok((n, addr)) => handler(&buf[..n], addr),
Err(e) => {
eprintln!("[sip_transport] recv error: {e}");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
}
});
}
}

View File

@@ -0,0 +1,137 @@
//! Voicemail session — answer → play greeting → beep → record → done.
use crate::audio_player::{play_beep, play_wav_file};
use crate::ipc::{emit_event, OutTx};
use crate::recorder::Recorder;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
/// Run a voicemail session on an RTP port.
///
/// 1. Plays the greeting WAV file to the caller
/// 2. Plays a beep tone
/// 3. Records the caller's message until BYE or max duration
///
/// The RTP receive loop is separate — it feeds packets to the recorder
/// via the returned channel.
pub async fn run_voicemail_session(
rtp_socket: Arc<UdpSocket>,
provider_media: SocketAddr,
codec_pt: u8,
greeting_wav: Option<String>,
recording_path: String,
max_recording_ms: u64,
call_id: String,
caller_number: String,
out_tx: OutTx,
) {
let ssrc: u32 = rand::random();
emit_event(
&out_tx,
"voicemail_started",
serde_json::json!({
"call_id": call_id,
"caller_number": caller_number,
}),
);
// Step 1: Play greeting.
let mut next_seq: u16 = 0;
let mut next_ts: u32 = 0;
if let Some(wav_path) = &greeting_wav {
match play_wav_file(wav_path, rtp_socket.clone(), provider_media, codec_pt, ssrc).await {
Ok(frames) => {
next_seq = frames as u16;
next_ts = frames * crate::rtp::rtp_clock_increment(codec_pt);
}
Err(e) => {
emit_event(
&out_tx,
"voicemail_error",
serde_json::json!({ "call_id": call_id, "error": format!("greeting: {e}") }),
);
}
}
}
// Step 2: Play beep (1kHz, 500ms).
match play_beep(
rtp_socket.clone(),
provider_media,
codec_pt,
ssrc,
next_seq,
next_ts,
1000,
500,
)
.await
{
Ok((_seq, _ts)) => {}
Err(e) => {
emit_event(
&out_tx,
"voicemail_error",
serde_json::json!({ "call_id": call_id, "error": format!("beep: {e}") }),
);
}
}
// Step 3: Record incoming audio.
let recorder = match Recorder::new(&recording_path, codec_pt, Some(max_recording_ms)) {
Ok(r) => r,
Err(e) => {
emit_event(
&out_tx,
"voicemail_error",
serde_json::json!({ "call_id": call_id, "error": format!("recorder: {e}") }),
);
return;
}
};
// Receive RTP and feed to recorder.
let result = record_from_socket(rtp_socket, recorder, max_recording_ms).await;
// Step 4: Done — emit recording result.
emit_event(
&out_tx,
"recording_done",
serde_json::json!({
"call_id": call_id,
"file_path": result.file_path,
"duration_ms": result.duration_ms,
"caller_number": caller_number,
}),
);
}
/// Read RTP packets from the socket and feed them to the recorder.
/// Returns when the socket errors out (BYE closes the call/socket)
/// or max duration is reached.
async fn record_from_socket(
socket: Arc<UdpSocket>,
mut recorder: Recorder,
max_ms: u64,
) -> crate::recorder::RecordingResult {
let mut buf = vec![0u8; 65535];
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(max_ms + 2000);
loop {
let timeout = tokio::time::timeout_at(deadline, socket.recv_from(&mut buf));
match timeout.await {
Ok(Ok((n, _addr))) => {
if !recorder.process_rtp(&buf[..n]) {
break; // Max duration reached.
}
}
Ok(Err(_)) => break, // Socket error (closed).
Err(_) => break, // Timeout (max duration + grace).
}
}
recorder.stop()
}

View File

@@ -0,0 +1,389 @@
//! WebRTC engine — manages browser PeerConnections with SIP audio bridging.
//!
//! Browser Opus audio → Rust PeerConnection → transcode via codec-lib → SIP RTP
//! SIP RTP → transcode via codec-lib → Rust PeerConnection → Browser Opus
use crate::ipc::{emit_event, OutTx};
use crate::rtp::{build_rtp_header, rtp_clock_increment};
use codec_lib::{TranscodeState, PT_G722, PT_OPUS};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::Mutex;
use webrtc::api::media_engine::MediaEngine;
use webrtc::api::APIBuilder;
use webrtc::ice_transport::ice_candidate::RTCIceCandidateInit;
use webrtc::peer_connection::configuration::RTCConfiguration;
use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
use webrtc::peer_connection::RTCPeerConnection;
use webrtc::rtp_transceiver::rtp_codec::RTCRtpCodecCapability;
use webrtc::track::track_local::track_local_static_rtp::TrackLocalStaticRTP;
use webrtc::track::track_local::{TrackLocal, TrackLocalWriter};
/// SIP-side bridge info for a WebRTC session.
#[derive(Clone)]
pub struct SipBridgeInfo {
/// Provider's media endpoint (RTP destination).
pub provider_media: SocketAddr,
/// Provider's codec payload type (e.g. 9 for G.722).
pub sip_pt: u8,
/// The SIP UDP socket for sending RTP to the provider.
pub sip_socket: Arc<UdpSocket>,
}
/// A managed WebRTC session.
struct WebRtcSession {
pc: Arc<RTCPeerConnection>,
local_track: Arc<TrackLocalStaticRTP>,
call_id: Option<String>,
/// SIP bridge — set when the session is linked to a call.
sip_bridge: Arc<Mutex<Option<SipBridgeInfo>>>,
}
/// Manages all WebRTC sessions.
pub struct WebRtcEngine {
sessions: HashMap<String, WebRtcSession>,
out_tx: OutTx,
}
impl WebRtcEngine {
pub fn new(out_tx: OutTx) -> Self {
Self {
sessions: HashMap::new(),
out_tx,
}
}
/// Handle a WebRTC offer from a browser.
pub async fn handle_offer(
&mut self,
session_id: &str,
offer_sdp: &str,
) -> Result<String, String> {
let mut media_engine = MediaEngine::default();
media_engine
.register_default_codecs()
.map_err(|e| format!("register codecs: {e}"))?;
let api = APIBuilder::new()
.with_media_engine(media_engine)
.build();
let config = RTCConfiguration {
ice_servers: vec![],
..Default::default()
};
let pc = api
.new_peer_connection(config)
.await
.map_err(|e| format!("create peer connection: {e}"))?;
let pc = Arc::new(pc);
// Local audio track for sending audio to browser (Opus).
let local_track = Arc::new(TrackLocalStaticRTP::new(
RTCRtpCodecCapability {
mime_type: "audio/opus".to_string(),
clock_rate: 48000,
channels: 1,
..Default::default()
},
"audio".to_string(),
"siprouter".to_string(),
));
let _sender = pc
.add_track(local_track.clone() as Arc<dyn TrackLocal + Send + Sync>)
.await
.map_err(|e| format!("add track: {e}"))?;
// Shared SIP bridge info (populated when linked to a call).
let sip_bridge: Arc<Mutex<Option<SipBridgeInfo>>> = Arc::new(Mutex::new(None));
// ICE candidate handler.
let out_tx_ice = self.out_tx.clone();
let sid_ice = session_id.to_string();
pc.on_ice_candidate(Box::new(move |candidate| {
let out_tx = out_tx_ice.clone();
let sid = sid_ice.clone();
Box::pin(async move {
if let Some(c) = candidate {
if let Ok(json) = c.to_json() {
emit_event(
&out_tx,
"webrtc_ice_candidate",
serde_json::json!({
"session_id": sid,
"candidate": json.candidate,
"sdp_mid": json.sdp_mid,
"sdp_mline_index": json.sdp_mline_index,
}),
);
}
}
})
}));
// Connection state handler.
let out_tx_state = self.out_tx.clone();
let sid_state = session_id.to_string();
pc.on_peer_connection_state_change(Box::new(move |state| {
let out_tx = out_tx_state.clone();
let sid = sid_state.clone();
Box::pin(async move {
let state_str = match state {
RTCPeerConnectionState::Connected => "connected",
RTCPeerConnectionState::Disconnected => "disconnected",
RTCPeerConnectionState::Failed => "failed",
RTCPeerConnectionState::Closed => "closed",
RTCPeerConnectionState::New => "new",
RTCPeerConnectionState::Connecting => "connecting",
_ => "unknown",
};
emit_event(
&out_tx,
"webrtc_state",
serde_json::json!({ "session_id": sid, "state": state_str }),
);
})
}));
// Track handler — receives Opus audio from the browser.
// When SIP bridge is set, transcodes and forwards to provider.
let out_tx_track = self.out_tx.clone();
let sid_track = session_id.to_string();
let sip_bridge_for_track = sip_bridge.clone();
pc.on_track(Box::new(move |track, _receiver, _transceiver| {
let out_tx = out_tx_track.clone();
let sid = sid_track.clone();
let bridge = sip_bridge_for_track.clone();
Box::pin(async move {
let codec_info = track.codec();
emit_event(
&out_tx,
"webrtc_track",
serde_json::json!({
"session_id": sid,
"kind": track.kind().to_string(),
"codec": codec_info.capability.mime_type,
}),
);
// Spawn the browser→SIP audio forwarding task.
tokio::spawn(browser_to_sip_loop(track, bridge, out_tx, sid));
})
}));
// Set remote offer.
let offer = RTCSessionDescription::offer(offer_sdp.to_string())
.map_err(|e| format!("parse offer: {e}"))?;
pc.set_remote_description(offer)
.await
.map_err(|e| format!("set remote description: {e}"))?;
// Create answer.
let answer = pc
.create_answer(None)
.await
.map_err(|e| format!("create answer: {e}"))?;
let answer_sdp = answer.sdp.clone();
pc.set_local_description(answer)
.await
.map_err(|e| format!("set local description: {e}"))?;
self.sessions.insert(
session_id.to_string(),
WebRtcSession {
pc,
local_track,
call_id: None,
sip_bridge,
},
);
Ok(answer_sdp)
}
/// Link a WebRTC session to a SIP call — sets up the audio bridge.
pub async fn link_to_sip(
&mut self,
session_id: &str,
call_id: &str,
bridge_info: SipBridgeInfo,
) -> bool {
if let Some(session) = self.sessions.get_mut(session_id) {
session.call_id = Some(call_id.to_string());
let mut bridge = session.sip_bridge.lock().await;
*bridge = Some(bridge_info);
true
} else {
false
}
}
/// Send transcoded audio from the SIP side to the browser.
/// Called by the RTP relay when it receives a packet from the provider.
pub async fn forward_sip_to_browser(
&self,
session_id: &str,
sip_rtp_payload: &[u8],
sip_pt: u8,
) -> Result<(), String> {
let session = self
.sessions
.get(session_id)
.ok_or_else(|| format!("session {session_id} not found"))?;
// Transcode SIP codec → Opus.
// We create a temporary TranscodeState per packet for simplicity.
// TODO: Use a per-session persistent state for proper codec continuity.
let mut transcoder = TranscodeState::new().map_err(|e| format!("codec: {e}"))?;
let opus_payload = transcoder
.transcode(sip_rtp_payload, sip_pt, PT_OPUS, Some("to_browser"))
.map_err(|e| format!("transcode: {e}"))?;
if opus_payload.is_empty() {
return Ok(());
}
// Build RTP header for Opus.
// TODO: Track seq/ts/ssrc per session for proper continuity.
let header = build_rtp_header(PT_OPUS, 0, 0, 0);
let mut packet = header.to_vec();
packet.extend_from_slice(&opus_payload);
session
.local_track
.write(&packet)
.await
.map(|_| ())
.map_err(|e| format!("write: {e}"))
}
pub async fn add_ice_candidate(
&self,
session_id: &str,
candidate: &str,
sdp_mid: Option<&str>,
sdp_mline_index: Option<u16>,
) -> Result<(), String> {
let session = self
.sessions
.get(session_id)
.ok_or_else(|| format!("session {session_id} not found"))?;
let init = RTCIceCandidateInit {
candidate: candidate.to_string(),
sdp_mid: sdp_mid.map(|s| s.to_string()),
sdp_mline_index,
..Default::default()
};
session
.pc
.add_ice_candidate(init)
.await
.map_err(|e| format!("add ICE: {e}"))
}
pub async fn close_session(&mut self, session_id: &str) -> Result<(), String> {
if let Some(session) = self.sessions.remove(session_id) {
session.pc.close().await.map_err(|e| format!("close: {e}"))?;
}
Ok(())
}
pub fn has_session(&self, session_id: &str) -> bool {
self.sessions.contains_key(session_id)
}
}
/// Browser → SIP audio forwarding loop.
/// Reads Opus RTP from the browser, transcodes to the SIP codec, sends to provider.
async fn browser_to_sip_loop(
track: Arc<webrtc::track::track_remote::TrackRemote>,
sip_bridge: Arc<Mutex<Option<SipBridgeInfo>>>,
out_tx: OutTx,
session_id: String,
) {
// Create a persistent codec state for this direction.
let mut transcoder = match TranscodeState::new() {
Ok(t) => t,
Err(e) => {
emit_event(
&out_tx,
"webrtc_error",
serde_json::json!({ "session_id": session_id, "error": format!("codec init: {e}") }),
);
return;
}
};
let mut buf = vec![0u8; 1500];
let mut count = 0u64;
let mut to_sip_seq: u16 = 0;
let mut to_sip_ts: u32 = 0;
let to_sip_ssrc: u32 = rand::random();
loop {
match track.read(&mut buf).await {
Ok((rtp_packet, _attributes)) => {
count += 1;
// Get the SIP bridge info (may not be set yet if call isn't linked).
let bridge = sip_bridge.lock().await;
let bridge_info = match bridge.as_ref() {
Some(b) => b.clone(),
None => continue, // Not linked to a SIP call yet — drop the packet.
};
drop(bridge); // Release lock before doing I/O.
// Extract Opus payload from the RTP packet (skip 12-byte header).
let payload = &rtp_packet.payload;
if payload.is_empty() {
continue;
}
// Transcode Opus → SIP codec (e.g. G.722).
let sip_payload = match transcoder.transcode(
payload,
PT_OPUS,
bridge_info.sip_pt,
Some("to_sip"),
) {
Ok(p) if !p.is_empty() => p,
_ => continue,
};
// Build SIP RTP packet.
let header = build_rtp_header(bridge_info.sip_pt, to_sip_seq, to_sip_ts, to_sip_ssrc);
let mut sip_rtp = header.to_vec();
sip_rtp.extend_from_slice(&sip_payload);
to_sip_seq = to_sip_seq.wrapping_add(1);
to_sip_ts = to_sip_ts.wrapping_add(rtp_clock_increment(bridge_info.sip_pt));
// Send to provider.
let _ = bridge_info
.sip_socket
.send_to(&sip_rtp, bridge_info.provider_media)
.await;
if count == 1 || count == 50 || count % 500 == 0 {
emit_event(
&out_tx,
"webrtc_audio_tx",
serde_json::json!({
"session_id": session_id,
"direction": "browser_to_sip",
"packet_count": count,
}),
);
}
}
Err(_) => break, // Track ended.
}
}
}

View File

@@ -0,0 +1,8 @@
[package]
name = "sip-proto"
version = "0.1.0"
edition = "2021"
[dependencies]
md-5 = "0.10"
rand = "0.8"

View File

@@ -0,0 +1,408 @@
//! SIP dialog state machine (RFC 3261 §12).
//!
//! Tracks local/remote tags, CSeq counters, route set, and remote target.
//! Provides methods to build in-dialog requests (BYE, re-INVITE, ACK, CANCEL).
//!
//! Ported from ts/sip/dialog.ts.
use crate::helpers::{generate_branch, generate_tag};
use crate::message::SipMessage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogState {
Early,
Confirmed,
Terminated,
}
/// SIP dialog state per RFC 3261 §12.
#[derive(Debug, Clone)]
pub struct SipDialog {
pub call_id: String,
pub local_tag: String,
pub remote_tag: Option<String>,
pub local_uri: String,
pub remote_uri: String,
pub local_cseq: u32,
pub remote_cseq: u32,
pub route_set: Vec<String>,
pub remote_target: String,
pub state: DialogState,
pub local_host: String,
pub local_port: u16,
}
impl SipDialog {
/// Create a dialog from an INVITE we are sending (UAC side).
/// The dialog enters Early state; call `process_response()` when responses arrive.
pub fn from_uac_invite(invite: &SipMessage, local_host: &str, local_port: u16) -> Self {
let from = invite.get_header("From").unwrap_or("");
let to = invite.get_header("To").unwrap_or("");
let local_cseq = invite
.get_header("CSeq")
.and_then(|c| c.split_whitespace().next())
.and_then(|s| s.parse().ok())
.unwrap_or(1);
Self {
call_id: invite.call_id().to_string(),
local_tag: SipMessage::extract_tag(from)
.map(|s| s.to_string())
.unwrap_or_else(generate_tag),
remote_tag: None,
local_uri: SipMessage::extract_uri(from)
.unwrap_or("")
.to_string(),
remote_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(),
local_cseq,
remote_cseq: 0,
route_set: Vec::new(),
remote_target: invite
.request_uri()
.or_else(|| SipMessage::extract_uri(to))
.unwrap_or("")
.to_string(),
state: DialogState::Early,
local_host: local_host.to_string(),
local_port,
}
}
/// Create a dialog from an INVITE we received (UAS side).
pub fn from_uas_invite(
invite: &SipMessage,
local_tag: &str,
local_host: &str,
local_port: u16,
) -> Self {
let from = invite.get_header("From").unwrap_or("");
let to = invite.get_header("To").unwrap_or("");
let contact = invite.get_header("Contact");
let remote_target = contact
.and_then(SipMessage::extract_uri)
.or_else(|| SipMessage::extract_uri(from))
.unwrap_or("")
.to_string();
Self {
call_id: invite.call_id().to_string(),
local_tag: local_tag.to_string(),
remote_tag: SipMessage::extract_tag(from).map(|s| s.to_string()),
local_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(),
remote_uri: SipMessage::extract_uri(from).unwrap_or("").to_string(),
local_cseq: 0,
remote_cseq: 0,
route_set: Vec::new(),
remote_target,
state: DialogState::Early,
local_host: local_host.to_string(),
local_port,
}
}
/// Update dialog state from a received response.
pub fn process_response(&mut self, response: &SipMessage) {
let to = response.get_header("To").unwrap_or("");
let tag = SipMessage::extract_tag(to).map(|s| s.to_string());
let code = response.status_code().unwrap_or(0);
// Always update remoteTag from 2xx (RFC 3261 §12.1.2).
if let Some(ref t) = tag {
if code >= 200 && code < 300 {
self.remote_tag = Some(t.clone());
} else if self.remote_tag.is_none() {
self.remote_tag = Some(t.clone());
}
}
// Update remote target from Contact.
if let Some(contact) = response.get_header("Contact") {
if let Some(uri) = SipMessage::extract_uri(contact) {
self.remote_target = uri.to_string();
}
}
// Record-Route → route set (in reverse for UAC).
if self.state == DialogState::Early {
let rr: Vec<String> = response
.headers
.iter()
.filter(|(n, _)| n.to_ascii_lowercase() == "record-route")
.map(|(_, v)| v.clone())
.collect();
if !rr.is_empty() {
let mut reversed = rr;
reversed.reverse();
self.route_set = reversed;
}
}
if code >= 200 && code < 300 {
self.state = DialogState::Confirmed;
} else if code >= 300 {
self.state = DialogState::Terminated;
}
}
/// Build an in-dialog request (BYE, re-INVITE, INFO, ...).
/// Automatically increments the local CSeq.
pub fn create_request(
&mut self,
method: &str,
body: Option<&str>,
content_type: Option<&str>,
extra_headers: Option<Vec<(String, String)>>,
) -> SipMessage {
self.local_cseq += 1;
let branch = generate_branch();
let remote_tag_str = self
.remote_tag
.as_ref()
.map(|t| format!(";tag={t}"))
.unwrap_or_default();
let mut headers = vec![
(
"Via".to_string(),
format!(
"SIP/2.0/UDP {}:{};branch={branch};rport",
self.local_host, self.local_port
),
),
(
"From".to_string(),
format!("<{}>;tag={}", self.local_uri, self.local_tag),
),
(
"To".to_string(),
format!("<{}>{remote_tag_str}", self.remote_uri),
),
("Call-ID".to_string(), self.call_id.clone()),
(
"CSeq".to_string(),
format!("{} {method}", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()),
];
for route in &self.route_set {
headers.push(("Route".to_string(), route.clone()));
}
headers.push((
"Contact".to_string(),
format!("<sip:{}:{}>", self.local_host, self.local_port),
));
if let Some(extra) = extra_headers {
headers.extend(extra);
}
let body_str = body.unwrap_or("");
if !body_str.is_empty() {
if let Some(ct) = content_type {
headers.push(("Content-Type".to_string(), ct.to_string()));
}
}
headers.push(("Content-Length".to_string(), body_str.len().to_string()));
let ruri = self.resolve_ruri();
SipMessage::new(
format!("{method} {ruri} SIP/2.0"),
headers,
body_str.to_string(),
)
}
/// Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4).
pub fn create_ack(&self) -> SipMessage {
let branch = generate_branch();
let remote_tag_str = self
.remote_tag
.as_ref()
.map(|t| format!(";tag={t}"))
.unwrap_or_default();
let mut headers = vec![
(
"Via".to_string(),
format!(
"SIP/2.0/UDP {}:{};branch={branch};rport",
self.local_host, self.local_port
),
),
(
"From".to_string(),
format!("<{}>;tag={}", self.local_uri, self.local_tag),
),
(
"To".to_string(),
format!("<{}>{remote_tag_str}", self.remote_uri),
),
("Call-ID".to_string(), self.call_id.clone()),
(
"CSeq".to_string(),
format!("{} ACK", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()),
];
for route in &self.route_set {
headers.push(("Route".to_string(), route.clone()));
}
headers.push(("Content-Length".to_string(), "0".to_string()));
let ruri = self.resolve_ruri();
SipMessage::new(format!("ACK {ruri} SIP/2.0"), headers, String::new())
}
/// Build a CANCEL for the original INVITE (same branch, CSeq).
pub fn create_cancel(&self, original_invite: &SipMessage) -> SipMessage {
let via = original_invite.get_header("Via").unwrap_or("").to_string();
let from = original_invite.get_header("From").unwrap_or("").to_string();
let to = original_invite.get_header("To").unwrap_or("").to_string();
let headers = vec![
("Via".to_string(), via),
("From".to_string(), from),
("To".to_string(), to),
("Call-ID".to_string(), self.call_id.clone()),
(
"CSeq".to_string(),
format!("{} CANCEL", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()),
("Content-Length".to_string(), "0".to_string()),
];
let ruri = original_invite
.request_uri()
.unwrap_or(&self.remote_target)
.to_string();
SipMessage::new(
format!("CANCEL {ruri} SIP/2.0"),
headers,
String::new(),
)
}
/// Transition the dialog to terminated state.
pub fn terminate(&mut self) {
self.state = DialogState::Terminated;
}
/// Resolve Request-URI from route set or remote target.
fn resolve_ruri(&self) -> &str {
if !self.route_set.is_empty() {
if let Some(top_route) = SipMessage::extract_uri(&self.route_set[0]) {
if top_route.contains(";lr") {
return &self.remote_target; // loose routing
}
return top_route; // strict routing
}
}
&self.remote_target
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::RequestOptions;
fn make_invite() -> SipMessage {
SipMessage::create_request(
"INVITE",
"sip:callee@host",
RequestOptions {
via_host: "192.168.1.1".to_string(),
via_port: 5070,
via_transport: None,
via_branch: Some("z9hG4bK-test".to_string()),
from_uri: "sip:caller@proxy".to_string(),
from_display_name: None,
from_tag: Some("from-tag".to_string()),
to_uri: "sip:callee@host".to_string(),
to_display_name: None,
to_tag: None,
call_id: Some("test-dialog-call".to_string()),
cseq: Some(1),
contact: Some("<sip:caller@192.168.1.1:5070>".to_string()),
max_forwards: None,
body: None,
content_type: None,
extra_headers: None,
},
)
}
#[test]
fn uac_dialog_lifecycle() {
let invite = make_invite();
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
assert_eq!(dialog.state, DialogState::Early);
assert_eq!(dialog.call_id, "test-dialog-call");
assert_eq!(dialog.local_tag, "from-tag");
assert!(dialog.remote_tag.is_none());
// Simulate 200 OK
let response = SipMessage::create_response(
200,
"OK",
&invite,
Some(crate::message::ResponseOptions {
to_tag: Some("remote-tag".to_string()),
contact: Some("<sip:callee@10.0.0.1:5060>".to_string()),
..Default::default()
}),
);
dialog.process_response(&response);
assert_eq!(dialog.state, DialogState::Confirmed);
assert_eq!(dialog.remote_tag.as_deref(), Some("remote-tag"));
assert_eq!(dialog.remote_target, "sip:callee@10.0.0.1:5060");
}
#[test]
fn create_bye() {
let invite = make_invite();
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
dialog.remote_tag = Some("remote-tag".to_string());
dialog.state = DialogState::Confirmed;
let bye = dialog.create_request("BYE", None, None, None);
assert_eq!(bye.method(), Some("BYE"));
assert_eq!(bye.call_id(), "test-dialog-call");
assert_eq!(dialog.local_cseq, 2);
let to = bye.get_header("To").unwrap();
assert!(to.contains("tag=remote-tag"));
}
#[test]
fn create_ack() {
let invite = make_invite();
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
dialog.remote_tag = Some("remote-tag".to_string());
let ack = dialog.create_ack();
assert_eq!(ack.method(), Some("ACK"));
assert!(ack.get_header("CSeq").unwrap().contains("ACK"));
}
#[test]
fn create_cancel() {
let invite = make_invite();
let dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
let cancel = dialog.create_cancel(&invite);
assert_eq!(cancel.method(), Some("CANCEL"));
assert!(cancel.get_header("CSeq").unwrap().contains("CANCEL"));
assert!(cancel.start_line.contains("sip:callee@host"));
}
}

View File

@@ -0,0 +1,331 @@
//! SIP helper utilities — ID generation, codec registry, SDP builder,
//! Digest authentication, SDP parser, and MWI body builder.
use md5::{Digest, Md5};
use rand::Rng;
// ---- ID generators ---------------------------------------------------------
/// Generate a random SIP Call-ID (32 hex chars).
pub fn generate_call_id(domain: Option<&str>) -> String {
let id = random_hex(16);
match domain {
Some(d) => format!("{id}@{d}"),
None => id,
}
}
/// Generate a random SIP From/To tag (16 hex chars).
pub fn generate_tag() -> String {
random_hex(8)
}
/// Generate an RFC 3261 compliant Via branch (starts with `z9hG4bK` magic cookie).
pub fn generate_branch() -> String {
format!("z9hG4bK-{}", random_hex(8))
}
fn random_hex(bytes: usize) -> String {
let mut rng = rand::thread_rng();
(0..bytes).map(|_| format!("{:02x}", rng.gen::<u8>())).collect()
}
// ---- Codec registry --------------------------------------------------------
/// Look up the rtpmap name for a static payload type.
pub fn codec_name(pt: u8) -> &'static str {
match pt {
0 => "PCMU/8000",
3 => "GSM/8000",
4 => "G723/8000",
8 => "PCMA/8000",
9 => "G722/8000",
18 => "G729/8000",
101 => "telephone-event/8000",
_ => "unknown",
}
}
// ---- SDP builder -----------------------------------------------------------
/// Options for building an SDP body.
pub struct SdpOptions<'a> {
pub ip: &'a str,
pub port: u16,
pub payload_types: &'a [u8],
pub session_id: Option<&'a str>,
pub session_name: Option<&'a str>,
pub direction: Option<&'a str>,
pub attributes: &'a [&'a str],
}
impl<'a> Default for SdpOptions<'a> {
fn default() -> Self {
Self {
ip: "0.0.0.0",
port: 0,
payload_types: &[9, 0, 8, 101],
session_id: None,
session_name: None,
direction: None,
attributes: &[],
}
}
}
/// Build a minimal SDP body suitable for SIP INVITE offers/answers.
pub fn build_sdp(opts: &SdpOptions) -> String {
let session_id = opts
.session_id
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64)));
let session_name = opts.session_name.unwrap_or("-");
let direction = opts.direction.unwrap_or("sendrecv");
let pts: Vec<String> = opts.payload_types.iter().map(|pt| pt.to_string()).collect();
let mut lines = vec![
"v=0".to_string(),
format!("o=- {session_id} {session_id} IN IP4 {}", opts.ip),
format!("s={session_name}"),
format!("c=IN IP4 {}", opts.ip),
"t=0 0".to_string(),
format!("m=audio {} RTP/AVP {}", opts.port, pts.join(" ")),
];
for &pt in opts.payload_types {
let name = codec_name(pt);
if name != "unknown" {
lines.push(format!("a=rtpmap:{pt} {name}"));
}
if pt == 101 {
lines.push("a=fmtp:101 0-16".to_string());
}
}
lines.push(format!("a={direction}"));
for attr in opts.attributes {
lines.push(format!("a={attr}"));
}
lines.push(String::new()); // trailing CRLF
lines.join("\r\n")
}
// ---- SIP Digest authentication (RFC 2617) ----------------------------------
/// Parsed fields from a Proxy-Authenticate or WWW-Authenticate header.
#[derive(Debug, Clone)]
pub struct DigestChallenge {
pub realm: String,
pub nonce: String,
pub algorithm: Option<String>,
pub opaque: Option<String>,
pub qop: Option<String>,
}
/// Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value.
pub fn parse_digest_challenge(header: &str) -> Option<DigestChallenge> {
let lower = header.to_ascii_lowercase();
if !lower.starts_with("digest ") {
return None;
}
let params = &header[7..];
let get = |key: &str| -> Option<String> {
// Try quoted value first.
let pat = format!("{}=", key);
if let Some(pos) = params.to_ascii_lowercase().find(&pat) {
let after = &params[pos + pat.len()..];
let after = after.trim_start();
if after.starts_with('"') {
let end = after[1..].find('"')?;
return Some(after[1..1 + end].to_string());
}
// Unquoted value.
let end = after.find(|c: char| c == ',' || c.is_whitespace()).unwrap_or(after.len());
return Some(after[..end].to_string());
}
None
};
let realm = get("realm")?;
let nonce = get("nonce")?;
Some(DigestChallenge {
realm,
nonce,
algorithm: get("algorithm"),
opaque: get("opaque"),
qop: get("qop"),
})
}
fn md5_hex(s: &str) -> String {
let mut hasher = Md5::new();
hasher.update(s.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Compute a SIP Digest Authorization header value.
pub fn compute_digest_auth(
username: &str,
password: &str,
realm: &str,
nonce: &str,
method: &str,
uri: &str,
algorithm: Option<&str>,
opaque: Option<&str>,
) -> String {
let ha1 = md5_hex(&format!("{username}:{realm}:{password}"));
let ha2 = md5_hex(&format!("{method}:{uri}"));
let response = md5_hex(&format!("{ha1}:{nonce}:{ha2}"));
let alg = algorithm.unwrap_or("MD5");
let mut header = format!(
"Digest username=\"{username}\", realm=\"{realm}\", \
nonce=\"{nonce}\", uri=\"{uri}\", response=\"{response}\", \
algorithm={alg}"
);
if let Some(op) = opaque {
header.push_str(&format!(", opaque=\"{op}\""));
}
header
}
// ---- SDP parser ------------------------------------------------------------
use crate::Endpoint;
/// Parse the audio media port and connection address from an SDP body.
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
let mut addr: Option<&str> = None;
let mut port: Option<u16> = None;
let normalized = sdp.replace("\r\n", "\n");
for raw in normalized.split('\n') {
let line = raw.trim();
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
addr = Some(rest.trim());
} else if let Some(rest) = line.strip_prefix("m=audio ") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if !parts.is_empty() {
port = parts[0].parse().ok();
}
}
}
match (addr, port) {
(Some(a), Some(p)) => Some(Endpoint {
address: a.to_string(),
port: p,
}),
_ => None,
}
}
// ---- MWI (RFC 3842) --------------------------------------------------------
/// Build the body and extra headers for an MWI NOTIFY (RFC 3842 message-summary).
pub struct MwiResult {
pub body: String,
pub content_type: &'static str,
pub extra_headers: Vec<(String, String)>,
}
pub fn build_mwi_body(
new_messages: u32,
old_messages: u32,
account_uri: &str,
) -> MwiResult {
let waiting = if new_messages > 0 { "yes" } else { "no" };
let body = format!(
"Messages-Waiting: {waiting}\r\n\
Message-Account: {account_uri}\r\n\
Voice-Message: {new_messages}/{old_messages}\r\n"
);
MwiResult {
body,
content_type: "application/simple-message-summary",
extra_headers: vec![
("Event".to_string(), "message-summary".to_string()),
(
"Subscription-State".to_string(),
"terminated;reason=noresource".to_string(),
),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_branch_has_magic_cookie() {
let branch = generate_branch();
assert!(branch.starts_with("z9hG4bK-"));
assert!(branch.len() > 8);
}
#[test]
fn test_codec_name() {
assert_eq!(codec_name(0), "PCMU/8000");
assert_eq!(codec_name(9), "G722/8000");
assert_eq!(codec_name(101), "telephone-event/8000");
assert_eq!(codec_name(255), "unknown");
}
#[test]
fn test_build_sdp() {
let sdp = build_sdp(&SdpOptions {
ip: "192.168.1.1",
port: 20000,
payload_types: &[9, 0, 101],
..Default::default()
});
assert!(sdp.contains("m=audio 20000 RTP/AVP 9 0 101"));
assert!(sdp.contains("c=IN IP4 192.168.1.1"));
assert!(sdp.contains("a=rtpmap:9 G722/8000"));
assert!(sdp.contains("a=fmtp:101 0-16"));
assert!(sdp.contains("a=sendrecv"));
}
#[test]
fn test_parse_digest_challenge() {
let header = r#"Digest realm="asterisk", nonce="abc123", algorithm=MD5, opaque="xyz""#;
let ch = parse_digest_challenge(header).unwrap();
assert_eq!(ch.realm, "asterisk");
assert_eq!(ch.nonce, "abc123");
assert_eq!(ch.algorithm.as_deref(), Some("MD5"));
assert_eq!(ch.opaque.as_deref(), Some("xyz"));
}
#[test]
fn test_compute_digest_auth() {
let auth = compute_digest_auth(
"user", "pass", "realm", "nonce", "REGISTER", "sip:host", None, None,
);
assert!(auth.starts_with("Digest "));
assert!(auth.contains("username=\"user\""));
assert!(auth.contains("realm=\"realm\""));
assert!(auth.contains("response=\""));
}
#[test]
fn test_parse_sdp_endpoint() {
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5060 RTP/AVP 0\r\n";
let ep = parse_sdp_endpoint(sdp).unwrap();
assert_eq!(ep.address, "10.0.0.1");
assert_eq!(ep.port, 5060);
}
#[test]
fn test_build_mwi_body() {
let mwi = build_mwi_body(3, 5, "sip:user@host");
assert!(mwi.body.contains("Messages-Waiting: yes"));
assert!(mwi.body.contains("Voice-Message: 3/5"));
assert_eq!(mwi.content_type, "application/simple-message-summary");
}
}

View File

@@ -0,0 +1,17 @@
//! SIP protocol library for the proxy engine.
//!
//! Provides SIP message parsing/serialization, dialog state management,
//! SDP handling, Digest authentication, and URI rewriting.
//! Ported from the TypeScript `ts/sip/` library.
pub mod message;
pub mod dialog;
pub mod helpers;
pub mod rewrite;
/// Network endpoint (address + port).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
pub address: String,
pub port: u16,
}

View File

@@ -0,0 +1,563 @@
//! SIP message parsing, serialization, inspection, mutation, and factory methods.
//!
//! Ported from ts/sip/message.ts.
use crate::helpers::{generate_branch, generate_call_id, generate_tag};
/// A parsed SIP message (request or response).
#[derive(Debug, Clone)]
pub struct SipMessage {
pub start_line: String,
pub headers: Vec<(String, String)>,
pub body: String,
}
impl SipMessage {
pub fn new(start_line: String, headers: Vec<(String, String)>, body: String) -> Self {
Self { start_line, headers, body }
}
// ---- Parsing -----------------------------------------------------------
/// Parse a raw buffer into a SipMessage. Returns None for invalid data.
pub fn parse(buf: &[u8]) -> Option<Self> {
if buf.is_empty() {
return None;
}
// First byte must be ASCII A-z.
if buf[0] < 0x41 || buf[0] > 0x7a {
return None;
}
let text = std::str::from_utf8(buf).ok()?;
let (head, body) = if let Some(sep) = text.find("\r\n\r\n") {
(&text[..sep], &text[sep + 4..])
} else if let Some(sep) = text.find("\n\n") {
(&text[..sep], &text[sep + 2..])
} else {
(text, "")
};
let normalized = head.replace("\r\n", "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
if lines.is_empty() || lines[0].is_empty() {
return None;
}
let start_line = lines[0];
// Validate: must be a SIP request or response start line.
if !is_sip_first_line(start_line) {
return None;
}
let mut headers = Vec::new();
for &line in &lines[1..] {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(colon) = line.find(':') {
let name = line[..colon].trim().to_string();
let value = line[colon + 1..].trim().to_string();
headers.push((name, value));
}
}
Some(SipMessage {
start_line: start_line.to_string(),
headers,
body: body.to_string(),
})
}
// ---- Serialization -----------------------------------------------------
/// Serialize the message to a byte buffer suitable for UDP transmission.
pub fn serialize(&self) -> Vec<u8> {
let mut head = self.start_line.clone();
for (name, value) in &self.headers {
head.push_str("\r\n");
head.push_str(name);
head.push_str(": ");
head.push_str(value);
}
head.push_str("\r\n\r\n");
let mut buf = head.into_bytes();
if !self.body.is_empty() {
buf.extend_from_slice(self.body.as_bytes());
}
buf
}
// ---- Inspectors --------------------------------------------------------
pub fn is_request(&self) -> bool {
!self.start_line.starts_with("SIP/")
}
pub fn is_response(&self) -> bool {
self.start_line.starts_with("SIP/")
}
/// Request method (INVITE, REGISTER, ...) or None for responses.
pub fn method(&self) -> Option<&str> {
if !self.is_request() {
return None;
}
self.start_line.split_whitespace().next()
}
/// Response status code or None for requests.
pub fn status_code(&self) -> Option<u16> {
if !self.is_response() {
return None;
}
self.start_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
}
pub fn call_id(&self) -> &str {
self.get_header("Call-ID").unwrap_or("noid")
}
/// Method from the CSeq header (e.g. "INVITE").
pub fn cseq_method(&self) -> Option<&str> {
let cseq = self.get_header("CSeq")?;
cseq.split_whitespace().nth(1)
}
/// True for INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE.
pub fn is_dialog_establishing(&self) -> bool {
matches!(
self.method(),
Some("INVITE" | "SUBSCRIBE" | "REFER" | "NOTIFY" | "UPDATE")
)
}
/// True when the body carries an SDP payload.
pub fn has_sdp_body(&self) -> bool {
if self.body.is_empty() {
return false;
}
let ct = self.get_header("Content-Type").unwrap_or("");
ct.to_ascii_lowercase().starts_with("application/sdp")
}
// ---- Header accessors --------------------------------------------------
/// Get the first header value matching `name` (case-insensitive).
pub fn get_header(&self, name: &str) -> Option<&str> {
let nl = name.to_ascii_lowercase();
for (n, v) in &self.headers {
if n.to_ascii_lowercase() == nl {
return Some(v.as_str());
}
}
None
}
/// Overwrites the first header with the given name, or appends it.
pub fn set_header(&mut self, name: &str, value: &str) -> &mut Self {
let nl = name.to_ascii_lowercase();
for h in &mut self.headers {
if h.0.to_ascii_lowercase() == nl {
h.1 = value.to_string();
return self;
}
}
self.headers.push((name.to_string(), value.to_string()));
self
}
/// Inserts a header at the top of the header list.
pub fn prepend_header(&mut self, name: &str, value: &str) -> &mut Self {
self.headers.insert(0, (name.to_string(), value.to_string()));
self
}
/// Removes all headers with the given name.
pub fn remove_header(&mut self, name: &str) -> &mut Self {
let nl = name.to_ascii_lowercase();
self.headers.retain(|(n, _)| n.to_ascii_lowercase() != nl);
self
}
/// Recalculates Content-Length to match the current body.
pub fn update_content_length(&mut self) -> &mut Self {
let len = self.body.len();
self.set_header("Content-Length", &len.to_string())
}
// ---- Start-line mutation -----------------------------------------------
/// Replace the Request-URI (second token) of a request start line.
pub fn set_request_uri(&mut self, uri: &str) -> &mut Self {
if !self.is_request() {
return self;
}
let parts: Vec<&str> = self.start_line.splitn(3, ' ').collect();
if parts.len() >= 3 {
self.start_line = format!("{} {} {}", parts[0], uri, parts[2]);
}
self
}
/// Returns the Request-URI (second token) of a request start line.
pub fn request_uri(&self) -> Option<&str> {
if !self.is_request() {
return None;
}
self.start_line.split_whitespace().nth(1)
}
// ---- Factory methods ---------------------------------------------------
/// Build a new SIP request.
pub fn create_request(method: &str, request_uri: &str, opts: RequestOptions) -> Self {
let branch = opts.via_branch.unwrap_or_else(|| generate_branch());
let transport = opts.via_transport.unwrap_or_else(|| "UDP".to_string());
let from_tag = opts.from_tag.unwrap_or_else(|| generate_tag());
let call_id = opts.call_id.unwrap_or_else(|| generate_call_id(None));
let cseq = opts.cseq.unwrap_or(1);
let max_forwards = opts.max_forwards.unwrap_or(70);
let from_display = opts
.from_display_name
.map(|d| format!("\"{d}\" "))
.unwrap_or_default();
let to_display = opts
.to_display_name
.map(|d| format!("\"{d}\" "))
.unwrap_or_default();
let to_tag_str = opts
.to_tag
.map(|t| format!(";tag={t}"))
.unwrap_or_default();
let mut headers = vec![
(
"Via".to_string(),
format!(
"SIP/2.0/{transport} {}:{};branch={branch};rport",
opts.via_host, opts.via_port
),
),
(
"From".to_string(),
format!("{from_display}<{}>;tag={from_tag}", opts.from_uri),
),
(
"To".to_string(),
format!("{to_display}<{}>{to_tag_str}", opts.to_uri),
),
("Call-ID".to_string(), call_id),
("CSeq".to_string(), format!("{cseq} {method}")),
("Max-Forwards".to_string(), max_forwards.to_string()),
];
if let Some(contact) = &opts.contact {
headers.push(("Contact".to_string(), contact.clone()));
}
if let Some(extra) = opts.extra_headers {
headers.extend(extra);
}
let body = opts.body.unwrap_or_default();
if !body.is_empty() {
if let Some(ct) = &opts.content_type {
headers.push(("Content-Type".to_string(), ct.clone()));
}
}
headers.push(("Content-Length".to_string(), body.len().to_string()));
SipMessage {
start_line: format!("{method} {request_uri} 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.
pub fn create_response(
status_code: u16,
reason_phrase: &str,
request: &SipMessage,
opts: Option<ResponseOptions>,
) -> Self {
let opts = opts.unwrap_or_default();
let mut headers: Vec<(String, String)> = Vec::new();
// Copy all Via headers (order matters).
for (n, v) in &request.headers {
if n.to_ascii_lowercase() == "via" {
headers.push(("Via".to_string(), v.clone()));
}
}
// From — copied verbatim.
if let Some(from) = request.get_header("From") {
headers.push(("From".to_string(), from.to_string()));
}
// To — add tag if provided and not already present.
let mut to = request.get_header("To").unwrap_or("").to_string();
if let Some(tag) = &opts.to_tag {
if !to.contains("tag=") {
to.push_str(&format!(";tag={tag}"));
}
}
headers.push(("To".to_string(), to));
headers.push(("Call-ID".to_string(), request.call_id().to_string()));
if let Some(cseq) = request.get_header("CSeq") {
headers.push(("CSeq".to_string(), cseq.to_string()));
}
if let Some(contact) = &opts.contact {
headers.push(("Contact".to_string(), contact.clone()));
}
if let Some(extra) = opts.extra_headers {
headers.extend(extra);
}
let body = opts.body.unwrap_or_default();
if !body.is_empty() {
if let Some(ct) = &opts.content_type {
headers.push(("Content-Type".to_string(), ct.clone()));
}
}
headers.push(("Content-Length".to_string(), body.len().to_string()));
SipMessage {
start_line: format!("SIP/2.0 {status_code} {reason_phrase}"),
headers,
body,
}
}
/// Extract the tag from a From or To header value.
pub fn extract_tag(header_value: &str) -> Option<&str> {
let idx = header_value.find(";tag=")?;
let rest = &header_value[idx + 5..];
let end = rest
.find(|c: char| c.is_whitespace() || c == ';' || c == '>')
.unwrap_or(rest.len());
Some(&rest[..end])
}
/// Extract the URI from an addr-spec or name-addr (From/To/Contact).
pub fn extract_uri(header_value: &str) -> Option<&str> {
if let Some(start) = header_value.find('<') {
let end = header_value[start..].find('>')?;
Some(&header_value[start + 1..start + end])
} else {
let trimmed = header_value.trim();
let end = trimmed
.find(|c: char| c == ';' || c == '>')
.unwrap_or(trimmed.len());
let result = &trimmed[..end];
if result.is_empty() { None } else { Some(result) }
}
}
}
/// Options for `SipMessage::create_request`.
pub struct RequestOptions {
pub via_host: String,
pub via_port: u16,
pub via_transport: Option<String>,
pub via_branch: Option<String>,
pub from_uri: String,
pub from_display_name: Option<String>,
pub from_tag: Option<String>,
pub to_uri: String,
pub to_display_name: Option<String>,
pub to_tag: Option<String>,
pub call_id: Option<String>,
pub cseq: Option<u32>,
pub contact: Option<String>,
pub max_forwards: Option<u16>,
pub body: Option<String>,
pub content_type: Option<String>,
pub extra_headers: Option<Vec<(String, String)>>,
}
/// Options for `SipMessage::create_response`.
#[derive(Default)]
pub struct ResponseOptions {
pub to_tag: Option<String>,
pub contact: Option<String>,
pub body: Option<String>,
pub content_type: Option<String>,
pub extra_headers: Option<Vec<(String, String)>>,
}
/// Check if a string matches the SIP first-line pattern.
fn is_sip_first_line(line: &str) -> bool {
// Request: METHOD SP URI SP SIP/X.Y
// Response: SIP/X.Y SP STATUS SP REASON
if line.starts_with("SIP/") {
// Response: SIP/2.0 200 OK
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() >= 2 {
return parts[1].chars().all(|c| c.is_ascii_digit());
}
} else {
// Request: INVITE sip:user@host SIP/2.0
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() >= 3 {
return parts[0].chars().all(|c| c.is_ascii_uppercase())
&& parts[2].starts_with("SIP/");
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const INVITE_RAW: &str = "INVITE sip:user@host SIP/2.0\r\n\
Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK-test\r\n\
From: <sip:caller@host>;tag=abc\r\n\
To: <sip:user@host>\r\n\
Call-ID: test-call-id\r\n\
CSeq: 1 INVITE\r\n\
Content-Length: 0\r\n\r\n";
#[test]
fn parse_invite() {
let msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
assert!(msg.is_request());
assert!(!msg.is_response());
assert_eq!(msg.method(), Some("INVITE"));
assert_eq!(msg.call_id(), "test-call-id");
assert_eq!(msg.cseq_method(), Some("INVITE"));
assert!(msg.is_dialog_establishing());
assert_eq!(msg.request_uri(), Some("sip:user@host"));
}
#[test]
fn parse_response() {
let raw = "SIP/2.0 200 OK\r\n\
Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK-test\r\n\
From: <sip:caller@host>;tag=abc\r\n\
To: <sip:user@host>;tag=def\r\n\
Call-ID: test-call-id\r\n\
CSeq: 1 INVITE\r\n\
Content-Length: 0\r\n\r\n";
let msg = SipMessage::parse(raw.as_bytes()).unwrap();
assert!(msg.is_response());
assert_eq!(msg.status_code(), Some(200));
assert_eq!(msg.cseq_method(), Some("INVITE"));
}
#[test]
fn serialize_roundtrip() {
let msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
let serialized = msg.serialize();
let reparsed = SipMessage::parse(&serialized).unwrap();
assert_eq!(reparsed.call_id(), "test-call-id");
assert_eq!(reparsed.method(), Some("INVITE"));
assert_eq!(reparsed.headers.len(), msg.headers.len());
}
#[test]
fn header_mutation() {
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
msg.set_header("X-Custom", "value1");
assert_eq!(msg.get_header("X-Custom"), Some("value1"));
msg.set_header("X-Custom", "value2");
assert_eq!(msg.get_header("X-Custom"), Some("value2"));
msg.prepend_header("X-First", "first");
assert_eq!(msg.headers[0].0, "X-First");
msg.remove_header("X-Custom");
assert_eq!(msg.get_header("X-Custom"), None);
}
#[test]
fn set_request_uri() {
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
msg.set_request_uri("sip:new@host");
assert_eq!(msg.request_uri(), Some("sip:new@host"));
assert!(msg.start_line.starts_with("INVITE sip:new@host SIP/2.0"));
}
#[test]
fn extract_tag_and_uri() {
assert_eq!(
SipMessage::extract_tag("<sip:user@host>;tag=abc123"),
Some("abc123")
);
assert_eq!(SipMessage::extract_tag("<sip:user@host>"), None);
assert_eq!(
SipMessage::extract_uri("<sip:user@host>"),
Some("sip:user@host")
);
assert_eq!(
SipMessage::extract_uri("\"Name\" <sip:user@host>;tag=abc"),
Some("sip:user@host")
);
}
#[test]
fn create_request_and_response() {
let invite = SipMessage::create_request(
"INVITE",
"sip:user@host",
RequestOptions {
via_host: "192.168.1.1".to_string(),
via_port: 5070,
via_transport: None,
via_branch: None,
from_uri: "sip:caller@proxy".to_string(),
from_display_name: None,
from_tag: Some("mytag".to_string()),
to_uri: "sip:user@host".to_string(),
to_display_name: None,
to_tag: None,
call_id: Some("test-123".to_string()),
cseq: Some(1),
contact: Some("<sip:caller@192.168.1.1:5070>".to_string()),
max_forwards: None,
body: None,
content_type: None,
extra_headers: None,
},
);
assert_eq!(invite.method(), Some("INVITE"));
assert_eq!(invite.call_id(), "test-123");
assert!(invite.get_header("Via").unwrap().contains("192.168.1.1:5070"));
let response = SipMessage::create_response(
200,
"OK",
&invite,
Some(ResponseOptions {
to_tag: Some("remotetag".to_string()),
..Default::default()
}),
);
assert!(response.is_response());
assert_eq!(response.status_code(), Some(200));
let to = response.get_header("To").unwrap();
assert!(to.contains("tag=remotetag"));
}
#[test]
fn has_sdp_body() {
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
assert!(!msg.has_sdp_body());
msg.body = "v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\n".to_string();
msg.set_header("Content-Type", "application/sdp");
assert!(msg.has_sdp_body());
}
}

View File

@@ -0,0 +1,130 @@
//! SIP URI and SDP body rewriting helpers.
//!
//! Ported from ts/sip/rewrite.ts.
use crate::Endpoint;
/// Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
let mut result = String::with_capacity(value.len());
let mut i = 0;
let bytes = value.as_bytes();
while i < bytes.len() {
// Look for "sip:" or "sips:"
let scheme_len = if i + 4 <= bytes.len()
&& (bytes[i..].starts_with(b"sip:") || bytes[i..].starts_with(b"SIP:"))
{
4
} else if i + 5 <= bytes.len()
&& (bytes[i..].starts_with(b"sips:") || bytes[i..].starts_with(b"SIPS:"))
{
5
} else {
result.push(value[i..].chars().next().unwrap());
i += value[i..].chars().next().unwrap().len_utf8();
continue;
};
let scheme = &value[i..i + scheme_len];
let rest = &value[i + scheme_len..];
// Check for userpart (contains '@')
let (userpart, host_start) = if let Some(at) = rest.find('@') {
// Make sure @ comes before any delimiters
let delim = rest.find(|c: char| c == '>' || c == ';' || c == ',' || c.is_whitespace());
if delim.is_none() || at < delim.unwrap() {
(&rest[..=at], at + 1)
} else {
("", 0)
}
} else {
("", 0)
};
// Find the end of the host:port portion
let host_rest = &rest[host_start..];
let end = host_rest
.find(|c: char| c == '>' || c == ';' || c == ',' || c.is_whitespace())
.unwrap_or(host_rest.len());
result.push_str(scheme);
result.push_str(userpart);
result.push_str(&format!("{host}:{port}"));
i += scheme_len + host_start + end;
}
result
}
/// 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).
pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>) {
let mut orig_addr: Option<String> = None;
let mut orig_port: Option<u16> = None;
let lines: Vec<String> = body
.replace("\r\n", "\n")
.split('\n')
.map(|line| {
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
orig_addr = Some(rest.trim().to_string());
format!("c=IN IP4 {ip}")
} else if line.starts_with("m=audio ") {
let parts: Vec<&str> = line.split(' ').collect();
if parts.len() >= 2 {
orig_port = parts[1].parse().ok();
let mut rebuilt = parts[0].to_string();
rebuilt.push(' ');
rebuilt.push_str(&port.to_string());
for part in &parts[2..] {
rebuilt.push(' ');
rebuilt.push_str(part);
}
return rebuilt;
}
line.to_string()
} else {
line.to_string()
}
})
.collect();
let original = match (orig_addr, orig_port) {
(Some(a), Some(p)) => Some(Endpoint { address: a, port: p }),
_ => None,
};
(lines.join("\r\n"), original)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rewrite_sip_uri() {
let input = "<sip:user@10.0.0.1:5060>";
let result = rewrite_sip_uri(input, "192.168.1.1", 5070);
assert_eq!(result, "<sip:user@192.168.1.1:5070>");
}
#[test]
fn test_rewrite_sip_uri_no_port() {
let input = "sip:user@10.0.0.1";
let result = rewrite_sip_uri(input, "192.168.1.1", 5070);
assert_eq!(result, "sip:user@192.168.1.1:5070");
}
#[test]
fn test_rewrite_sdp() {
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5060 RTP/AVP 0 9\r\na=sendrecv\r\n";
let (rewritten, orig) = rewrite_sdp(sdp, "192.168.1.1", 20000);
assert!(rewritten.contains("c=IN IP4 192.168.1.1"));
assert!(rewritten.contains("m=audio 20000 RTP/AVP 0 9"));
let ep = orig.unwrap();
assert_eq!(ep.address, "10.0.0.1");
assert_eq!(ep.port, 5060);
}
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: 'siprouter',
version: '1.10.0',
version: '1.12.0',
description: 'undefined'
}

View File

@@ -14,9 +14,26 @@ import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { buildRtpHeader, rtpClockIncrement } from './call/leg.ts';
import { encodePcm, isCodecReady } from './opusbridge.ts';
/** RTP clock increment per 20ms frame for each codec. */
function rtpClockIncrement(pt: number): number {
if (pt === 111) return 960;
if (pt === 9) return 160;
return 160;
}
/** Build a fresh RTP header. */
function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
const hdr = Buffer.alloc(12);
hdr[0] = 0x80;
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
hdr.writeUInt16BE(seq & 0xffff, 2);
hdr.writeUInt32BE(ts >>> 0, 4);
hdr.writeUInt32BE(ssrc >>> 0, 8);
return hdr;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

View File

@@ -1,323 +0,0 @@
/**
* Audio recorder — captures RTP packets from a single direction,
* decodes them to PCM, and writes a WAV file.
*
* Uses the Rust codec bridge to transcode incoming audio (G.722, Opus,
* PCMU, PCMA) to PCMU, then decodes mu-law to 16-bit PCM in TypeScript.
* Output: 8kHz 16-bit mono WAV (standard telephony quality).
*
* Supports:
* - Max recording duration limit
* - Silence detection (stop after N seconds of silence)
* - Manual stop
* - DTMF packets (PT 101) are automatically skipped
*/
import { Buffer } from 'node:buffer';
import fs from 'node:fs';
import path from 'node:path';
import { WavWriter } from './wav-writer.ts';
import type { IWavWriterResult } from './wav-writer.ts';
import { transcode, createSession, destroySession } from '../opusbridge.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface IRecordingOptions {
/** Output directory for WAV files. */
outputDir: string;
/** Target sample rate for the WAV output (default 8000). */
sampleRate?: number;
/** Maximum recording duration in seconds. 0 = unlimited. Default 120. */
maxDurationSec?: number;
/** Stop after this many consecutive seconds of silence. 0 = disabled. Default 5. */
silenceTimeoutSec?: number;
/** Silence threshold: max PCM amplitude below this is "silent". Default 200. */
silenceThreshold?: number;
/** Logging function. */
log: (msg: string) => void;
}
export interface IRecordingResult {
/** Full path to the WAV file. */
filePath: string;
/** Duration in milliseconds. */
durationMs: number;
/** Sample rate of the WAV. */
sampleRate: number;
/** Size of the WAV file in bytes. */
fileSize: number;
/** Why the recording was stopped. */
stopReason: TRecordingStopReason;
}
export type TRecordingStopReason = 'manual' | 'max-duration' | 'silence' | 'cancelled';
// ---------------------------------------------------------------------------
// Mu-law decode table (ITU-T G.711)
// ---------------------------------------------------------------------------
/** Pre-computed mu-law → 16-bit linear PCM lookup table (256 entries). */
const MULAW_DECODE: Int16Array = buildMulawDecodeTable();
function buildMulawDecodeTable(): Int16Array {
const table = new Int16Array(256);
for (let i = 0; i < 256; i++) {
// Invert all bits per mu-law standard.
let mu = ~i & 0xff;
const sign = mu & 0x80;
const exponent = (mu >> 4) & 0x07;
const mantissa = mu & 0x0f;
let magnitude = ((mantissa << 1) + 33) << (exponent + 2);
magnitude -= 0x84; // Bias adjustment
table[i] = sign ? -magnitude : magnitude;
}
return table;
}
/** Decode a PCMU payload to 16-bit LE PCM. */
function decodeMulaw(mulaw: Buffer): Buffer {
const pcm = Buffer.alloc(mulaw.length * 2);
for (let i = 0; i < mulaw.length; i++) {
pcm.writeInt16LE(MULAW_DECODE[mulaw[i]], i * 2);
}
return pcm;
}
// ---------------------------------------------------------------------------
// AudioRecorder
// ---------------------------------------------------------------------------
export class AudioRecorder {
/** Current state. */
state: 'idle' | 'recording' | 'stopped' = 'idle';
/** Called when recording stops automatically (silence or max duration). */
onStopped: ((result: IRecordingResult) => void) | null = null;
private outputDir: string;
private sampleRate: number;
private maxDurationSec: number;
private silenceTimeoutSec: number;
private silenceThreshold: number;
private log: (msg: string) => void;
private wavWriter: WavWriter | null = null;
private filePath: string = '';
private codecSessionId: string | null = null;
private stopReason: TRecordingStopReason = 'manual';
// Silence detection.
private consecutiveSilentFrames = 0;
/** Number of 20ms frames that constitute silence timeout. */
private silenceFrameThreshold = 0;
// Max duration timer.
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
// Processing queue to avoid concurrent transcodes.
private processQueue: Promise<void> = Promise.resolve();
constructor(options: IRecordingOptions) {
this.outputDir = options.outputDir;
this.sampleRate = options.sampleRate ?? 8000;
this.maxDurationSec = options.maxDurationSec ?? 120;
this.silenceTimeoutSec = options.silenceTimeoutSec ?? 5;
this.silenceThreshold = options.silenceThreshold ?? 200;
this.log = options.log;
}
/**
* Start recording. Creates the output directory, WAV file, and codec session.
* @param fileId - unique ID for the recording file name
*/
async start(fileId?: string): Promise<void> {
if (this.state !== 'idle') return;
// Ensure output directory exists.
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
}
// Generate file path.
const id = fileId ?? `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this.filePath = path.join(this.outputDir, `${id}.wav`);
// Create a codec session for isolated decoding.
this.codecSessionId = `recorder-${id}`;
await createSession(this.codecSessionId);
// Open WAV writer.
this.wavWriter = new WavWriter({
filePath: this.filePath,
sampleRate: this.sampleRate,
});
this.wavWriter.open();
// Silence detection threshold: frames in timeout period.
this.silenceFrameThreshold = this.silenceTimeoutSec > 0
? Math.ceil((this.silenceTimeoutSec * 1000) / 20)
: 0;
this.consecutiveSilentFrames = 0;
// Max duration timer.
if (this.maxDurationSec > 0) {
this.maxDurationTimer = setTimeout(() => {
if (this.state === 'recording') {
this.stopReason = 'max-duration';
this.log(`[recorder] max duration reached (${this.maxDurationSec}s)`);
this.stop().then((result) => this.onStopped?.(result));
}
}, this.maxDurationSec * 1000);
}
this.state = 'recording';
this.stopReason = 'manual';
this.log(`[recorder] started → ${this.filePath}`);
}
/**
* Feed an RTP packet. Strips the 12-byte header, transcodes the payload
* to PCMU via the Rust bridge, decodes to PCM, and writes to WAV.
* Skips telephone-event (DTMF) and comfort noise packets.
*/
processRtp(data: Buffer): void {
if (this.state !== 'recording') return;
if (data.length < 13) return; // too short
const pt = data[1] & 0x7f;
// Skip DTMF (telephone-event) and comfort noise.
if (pt === 101 || pt === 13) return;
const payload = data.subarray(12);
if (payload.length === 0) return;
// Queue processing to avoid concurrent transcodes corrupting codec state.
this.processQueue = this.processQueue.then(() => this.decodeAndWrite(payload, pt));
}
/** Decode a single RTP payload to PCM and write to WAV. */
private async decodeAndWrite(payload: Buffer, pt: number): Promise<void> {
if (this.state !== 'recording' || !this.wavWriter) return;
let pcm: Buffer;
if (pt === 0) {
// PCMU: decode directly in TypeScript (no Rust round-trip needed).
pcm = decodeMulaw(payload);
} else {
// All other codecs: transcode to PCMU via Rust, then decode mu-law.
const mulaw = await transcode(payload, pt, 0, this.codecSessionId ?? undefined);
if (!mulaw) return;
pcm = decodeMulaw(mulaw);
}
// Silence detection.
if (this.silenceFrameThreshold > 0) {
if (isSilent(pcm, this.silenceThreshold)) {
this.consecutiveSilentFrames++;
if (this.consecutiveSilentFrames >= this.silenceFrameThreshold) {
this.stopReason = 'silence';
this.log(`[recorder] silence detected (${this.silenceTimeoutSec}s)`);
this.stop().then((result) => this.onStopped?.(result));
return;
}
} else {
this.consecutiveSilentFrames = 0;
}
}
this.wavWriter.write(pcm);
}
/**
* Stop recording and finalize the WAV file.
*/
async stop(): Promise<IRecordingResult> {
if (this.state === 'stopped' || this.state === 'idle') {
return {
filePath: this.filePath,
durationMs: 0,
sampleRate: this.sampleRate,
fileSize: 0,
stopReason: this.stopReason,
};
}
this.state = 'stopped';
// Wait for pending decode operations to finish.
await this.processQueue;
// Clear timers.
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
// Finalize WAV.
let wavResult: IWavWriterResult | null = null;
if (this.wavWriter) {
wavResult = this.wavWriter.close();
this.wavWriter = null;
}
// Destroy codec session.
if (this.codecSessionId) {
await destroySession(this.codecSessionId);
this.codecSessionId = null;
}
const result: IRecordingResult = {
filePath: this.filePath,
durationMs: wavResult?.durationMs ?? 0,
sampleRate: this.sampleRate,
fileSize: wavResult?.fileSize ?? 0,
stopReason: this.stopReason,
};
this.log(`[recorder] stopped (${result.stopReason}): ${result.durationMs}ms → ${this.filePath}`);
return result;
}
/** Cancel recording — stops and deletes the WAV file. */
async cancel(): Promise<void> {
this.stopReason = 'cancelled';
await this.stop();
// Delete the incomplete file.
try {
if (fs.existsSync(this.filePath)) {
fs.unlinkSync(this.filePath);
this.log(`[recorder] cancelled — deleted ${this.filePath}`);
}
} catch { /* best effort */ }
}
/** Clean up all resources. */
destroy(): void {
if (this.state === 'recording') {
this.cancel();
}
this.onStopped = null;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Check if a PCM buffer is "silent" (max amplitude below threshold). */
function isSilent(pcm: Buffer, threshold: number): boolean {
let maxAmp = 0;
for (let i = 0; i < pcm.length - 1; i += 2) {
const sample = pcm.readInt16LE(i);
const abs = sample < 0 ? -sample : sample;
if (abs > maxAmp) maxAmp = abs;
// Early exit: already above threshold.
if (maxAmp >= threshold) return false;
}
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,265 +0,0 @@
/**
* Call — the hub entity in the hub model.
*
* A Call owns N legs and bridges their media. For 2-party calls, RTP packets
* from leg A are forwarded to leg B and vice versa. For N>2 party calls,
* packets from each leg are forwarded to all other legs (fan-out).
*
* Transcoding is applied per-leg when codecs differ.
*/
import { Buffer } from 'node:buffer';
import type { ILeg } from './leg.ts';
import type { TCallState, TCallDirection, ICallStatus } from './types.ts';
import { RtpPortPool } from './rtp-port-pool.ts';
import type { SipLeg } from './sip-leg.ts';
export class Call {
readonly id: string;
state: TCallState = 'setting-up';
direction: TCallDirection;
readonly createdAt: number;
callerNumber: string | null = null;
calleeNumber: string | null = null;
providerUsed: string | null = null;
/** All legs in this call. */
private legs = new Map<string, ILeg>();
/** Codec payload type for the "native" audio in the call (usually the first SIP leg's codec). */
private nativeCodec: number | null = null;
/** Port pool reference for cleanup. */
private portPool: RtpPortPool;
private log: (msg: string) => void;
private onChange: ((call: Call) => void) | null = null;
constructor(options: {
id: string;
direction: TCallDirection;
portPool: RtpPortPool;
log: (msg: string) => void;
onChange?: (call: Call) => void;
}) {
this.id = options.id;
this.direction = options.direction;
this.createdAt = Date.now();
this.portPool = options.portPool;
this.log = options.log;
this.onChange = options.onChange ?? null;
}
// -------------------------------------------------------------------------
// Leg management
// -------------------------------------------------------------------------
/** Add a leg to this call and wire up media forwarding. */
addLeg(leg: ILeg): void {
this.legs.set(leg.id, leg);
// Wire up RTP forwarding: when this leg receives a packet, forward to all other legs.
leg.onRtpReceived = (data: Buffer) => {
this.forwardRtp(leg.id, data);
};
this.log(`[call:${this.id}] added leg ${leg.id} (${leg.type}), total=${this.legs.size}`);
this.updateState();
}
/** Remove a leg from this call, tear it down, and release its port. */
removeLeg(legId: string): void {
const leg = this.legs.get(legId);
if (!leg) return;
leg.onRtpReceived = null;
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
this.legs.delete(legId);
this.log(`[call:${this.id}] removed leg ${legId}, total=${this.legs.size}`);
this.updateState();
}
getLeg(legId: string): ILeg | null {
return this.legs.get(legId) ?? null;
}
getLegs(): ILeg[] {
return [...this.legs.values()];
}
getLegByType(type: string): ILeg | null {
for (const leg of this.legs.values()) {
if (leg.type === type) return leg;
}
return null;
}
getLegBySipCallId(sipCallId: string): ILeg | null {
for (const leg of this.legs.values()) {
if (leg.sipCallId === sipCallId) return leg;
}
return null;
}
get legCount(): number {
return this.legs.size;
}
// -------------------------------------------------------------------------
// Media forwarding (the hub)
// -------------------------------------------------------------------------
private forwardRtp(fromLegId: string, data: Buffer): void {
for (const [id, leg] of this.legs) {
if (id === fromLegId) continue;
if (leg.state !== 'connected') continue;
// For WebRTC legs, sendRtp calls forwardToBrowser which handles transcoding internally.
// For SIP legs, forward the raw packet (same codec path) or let the leg handle it.
// The Call hub does NOT transcode — that's the leg's responsibility.
leg.sendRtp(data);
}
}
// -------------------------------------------------------------------------
// State management
// -------------------------------------------------------------------------
private updateState(): void {
if (this.state === 'terminated' || this.state === 'terminating') return;
const legs = [...this.legs.values()];
if (legs.length === 0) {
this.state = 'terminated';
} else if (legs.every((l) => l.state === 'terminated')) {
this.state = 'terminated';
} else if (legs.some((l) => l.state === 'connected') && legs.filter((l) => l.state !== 'terminated').length >= 2) {
// If a system leg is connected, report voicemail/ivr state for the dashboard.
const systemLeg = legs.find((l) => l.type === 'system');
if (systemLeg) {
// Keep voicemail/ivr state if already set; otherwise set connected.
if (this.state !== 'voicemail' && this.state !== 'ivr') {
this.state = 'connected';
}
} else {
this.state = 'connected';
}
} else if (legs.some((l) => l.state === 'ringing')) {
this.state = 'ringing';
} else {
this.state = 'setting-up';
}
this.onChange?.(this);
}
/** Notify the call that a leg's state has changed. */
notifyLegStateChange(_leg: ILeg): void {
this.updateState();
}
// -------------------------------------------------------------------------
// Hangup
// -------------------------------------------------------------------------
/** Tear down all legs and terminate the call. */
hangup(): void {
if (this.state === 'terminated' || this.state === 'terminating') return;
this.state = 'terminating';
this.log(`[call:${this.id}] hanging up (${this.legs.size} legs)`);
for (const [id, leg] of this.legs) {
// Send BYE/CANCEL for SIP legs (system legs have no SIP signaling).
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
(leg as SipLeg).sendHangup();
}
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
}
this.legs.clear();
this.state = 'terminated';
this.onChange?.(this);
}
/**
* Handle a BYE from one leg — tear down the other legs.
* Called by CallManager when a SipLeg receives a BYE.
*/
handleLegTerminated(terminatedLegId: string): void {
const terminatedLeg = this.legs.get(terminatedLegId);
if (!terminatedLeg) return;
// Remove the terminated leg.
terminatedLeg.onRtpReceived = null;
if (terminatedLeg.rtpPort) {
this.portPool.release(terminatedLeg.rtpPort);
}
this.legs.delete(terminatedLegId);
// If this is a 2-party call, hang up the other leg too.
if (this.legs.size <= 1) {
for (const [id, leg] of this.legs) {
// Send BYE/CANCEL for SIP legs (system legs just get torn down).
if (leg.type === 'sip-device' || leg.type === 'sip-provider') {
(leg as SipLeg).sendHangup();
}
leg.teardown();
if (leg.rtpPort) {
this.portPool.release(leg.rtpPort);
}
}
this.legs.clear();
this.state = 'terminated';
this.log(`[call:${this.id}] terminated`);
this.onChange?.(this);
} else {
this.log(`[call:${this.id}] leg ${terminatedLegId} removed, ${this.legs.size} remaining`);
this.updateState();
}
}
// -------------------------------------------------------------------------
// Transfer
// -------------------------------------------------------------------------
/**
* Detach a leg from this call (without tearing it down).
* The leg can then be added to another call.
*/
detachLeg(legId: string): ILeg | null {
const leg = this.legs.get(legId);
if (!leg) return null;
leg.onRtpReceived = null;
this.legs.delete(legId);
this.log(`[call:${this.id}] detached leg ${legId}`);
this.updateState();
return leg;
}
// -------------------------------------------------------------------------
// Status
// -------------------------------------------------------------------------
getStatus(): ICallStatus {
return {
id: this.id,
state: this.state,
direction: this.direction,
callerNumber: this.callerNumber,
calleeNumber: this.calleeNumber,
providerUsed: this.providerUsed,
createdAt: this.createdAt,
duration: Math.floor((Date.now() - this.createdAt) / 1000),
legs: [...this.legs.values()].map((l) => l.getStatus()),
};
}
}

View File

@@ -1,272 +0,0 @@
/**
* DTMF detection — parses RFC 2833 telephone-event RTP packets
* and SIP INFO (application/dtmf-relay) messages.
*
* Designed to be attached to any leg or RTP stream. The detector
* deduplicates repeated telephone-event packets (same digit is sent
* multiple times with increasing duration) and fires a callback
* once per detected digit.
*/
import { Buffer } from 'node:buffer';
import type { SipMessage } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A single detected DTMF digit. */
export interface IDtmfDigit {
/** The digit character: '0'-'9', '*', '#', 'A'-'D'. */
digit: string;
/** Duration in milliseconds. */
durationMs: number;
/** Detection source. */
source: 'rfc2833' | 'sip-info';
/** Wall-clock timestamp when the digit was detected. */
timestamp: number;
}
/** Callback fired once per detected DTMF digit. */
export type TDtmfCallback = (digit: IDtmfDigit) => void;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** RFC 2833 event ID → character mapping. */
const EVENT_CHARS: string[] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'*', '#', 'A', 'B', 'C', 'D',
];
/** Safety timeout: report digit if no End packet arrives within this many ms. */
const SAFETY_TIMEOUT_MS = 200;
// ---------------------------------------------------------------------------
// DtmfDetector
// ---------------------------------------------------------------------------
/**
* Detects DTMF digits from RFC 2833 RTP packets and SIP INFO messages.
*
* Usage:
* ```
* const detector = new DtmfDetector(log);
* detector.onDigit = (d) => console.log('DTMF:', d.digit);
* // Feed every RTP packet (detector checks PT internally):
* detector.processRtp(rtpPacket);
* // Or feed a SIP INFO message:
* detector.processSipInfo(sipMsg);
* ```
*/
export class DtmfDetector {
/** Callback fired once per detected digit. */
onDigit: TDtmfCallback | null = null;
/** Negotiated telephone-event payload type (default 101). */
private telephoneEventPt: number;
/** Clock rate for duration calculation (default 8000 Hz). */
private clockRate: number;
// -- Deduplication state for RFC 2833 --
/** Event ID of the digit currently being received. */
private currentEventId: number | null = null;
/** RTP timestamp of the first packet for the current event. */
private currentEventTs: number | null = null;
/** Whether the current event has already been reported. */
private currentEventReported = false;
/** Latest duration value seen (in clock ticks). */
private currentEventDuration = 0;
/** Latest volume value seen (dBm0, 0 = loudest). */
private currentEventVolume = 0;
/** Safety timer: fires if no End packet arrives. */
private safetyTimer: ReturnType<typeof setTimeout> | null = null;
private log: (msg: string) => void;
constructor(
log: (msg: string) => void,
telephoneEventPt = 101,
clockRate = 8000,
) {
this.log = log;
this.telephoneEventPt = telephoneEventPt;
this.clockRate = clockRate;
}
// -------------------------------------------------------------------------
// RFC 2833 RTP processing
// -------------------------------------------------------------------------
/**
* Feed an RTP packet. Checks PT; ignores non-DTMF packets.
* Expects the full RTP packet (12-byte header + payload).
*/
processRtp(data: Buffer): void {
if (data.length < 16) return; // 12-byte header + 4-byte telephone-event payload minimum
const pt = data[1] & 0x7f;
if (pt !== this.telephoneEventPt) return;
// Parse RTP header fields we need.
const marker = (data[1] & 0x80) !== 0;
const rtpTimestamp = data.readUInt32BE(4);
// Parse telephone-event payload (4 bytes starting at offset 12).
const eventId = data[12];
const endBit = (data[13] & 0x80) !== 0;
const volume = data[13] & 0x3f;
const duration = data.readUInt16BE(14);
// Validate event ID.
if (eventId >= EVENT_CHARS.length) return;
// Detect new event: marker bit, different event ID, or different RTP timestamp.
const isNewEvent =
marker ||
eventId !== this.currentEventId ||
rtpTimestamp !== this.currentEventTs;
if (isNewEvent) {
// If there was an unreported previous event, report it now (fallback).
this.reportPendingEvent();
// Start tracking the new event.
this.currentEventId = eventId;
this.currentEventTs = rtpTimestamp;
this.currentEventReported = false;
this.currentEventDuration = duration;
this.currentEventVolume = volume;
// Start safety timer.
this.clearSafetyTimer();
this.safetyTimer = setTimeout(() => {
this.reportPendingEvent();
}, SAFETY_TIMEOUT_MS);
}
// Update duration (it increases with each retransmission).
if (duration > this.currentEventDuration) {
this.currentEventDuration = duration;
}
// Report on End bit (first time only).
if (endBit && !this.currentEventReported) {
this.currentEventReported = true;
this.clearSafetyTimer();
const digit = EVENT_CHARS[eventId];
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms)`);
this.onDigit?.({
digit,
durationMs,
source: 'rfc2833',
timestamp: Date.now(),
});
}
}
/** Report a pending (unreported) event — called by safety timer or on new event start. */
private reportPendingEvent(): void {
if (
this.currentEventId !== null &&
!this.currentEventReported &&
this.currentEventId < EVENT_CHARS.length
) {
this.currentEventReported = true;
this.clearSafetyTimer();
const digit = EVENT_CHARS[this.currentEventId];
const durationMs = (this.currentEventDuration / this.clockRate) * 1000;
this.log(`[dtmf] RFC 2833 digit '${digit}' (${Math.round(durationMs)}ms, safety timeout)`);
this.onDigit?.({
digit,
durationMs,
source: 'rfc2833',
timestamp: Date.now(),
});
}
}
private clearSafetyTimer(): void {
if (this.safetyTimer) {
clearTimeout(this.safetyTimer);
this.safetyTimer = null;
}
}
// -------------------------------------------------------------------------
// SIP INFO processing
// -------------------------------------------------------------------------
/**
* Parse a SIP INFO message carrying DTMF.
* Supports Content-Type: application/dtmf-relay (Signal=X / Duration=Y).
*/
processSipInfo(msg: SipMessage): void {
const ct = (msg.getHeader('Content-Type') || '').toLowerCase();
if (!ct.includes('application/dtmf-relay') && !ct.includes('application/dtmf')) return;
const body = msg.body || '';
if (ct.includes('application/dtmf-relay')) {
// Format: "Signal= 5\r\nDuration= 160\r\n"
const signalMatch = body.match(/Signal\s*=\s*(\S+)/i);
const durationMatch = body.match(/Duration\s*=\s*(\d+)/i);
if (!signalMatch) return;
const signal = signalMatch[1];
const durationTicks = durationMatch ? parseInt(durationMatch[1], 10) : 160;
// Validate digit.
if (signal.length !== 1 || !/[0-9*#A-Da-d]/.test(signal)) return;
const digit = signal.toUpperCase();
const durationMs = (durationTicks / this.clockRate) * 1000;
this.log(`[dtmf] SIP INFO digit '${digit}' (${Math.round(durationMs)}ms)`);
this.onDigit?.({
digit,
durationMs,
source: 'sip-info',
timestamp: Date.now(),
});
} else if (ct.includes('application/dtmf')) {
// Simple format: just the digit character in the body.
const digit = body.trim().toUpperCase();
if (digit.length !== 1 || !/[0-9*#A-D]/.test(digit)) return;
this.log(`[dtmf] SIP INFO digit '${digit}' (application/dtmf)`);
this.onDigit?.({
digit,
durationMs: 250, // default duration
source: 'sip-info',
timestamp: Date.now(),
});
}
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/** Reset detection state (e.g., between calls). */
reset(): void {
this.currentEventId = null;
this.currentEventTs = null;
this.currentEventReported = false;
this.currentEventDuration = 0;
this.currentEventVolume = 0;
this.clearSafetyTimer();
}
/** Clean up timers and references. */
destroy(): void {
this.clearSafetyTimer();
this.onDigit = null;
}
}

View File

@@ -1,12 +0,0 @@
export type { TCallState, TLegState, TLegType, TCallDirection, ICallStatus, ILegStatus, ICallHistoryEntry } from './types.ts';
export type { ILeg } from './leg.ts';
export { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts';
export { RtpPortPool } from './rtp-port-pool.ts';
export type { IRtpAllocation } from './rtp-port-pool.ts';
export { SipLeg } from './sip-leg.ts';
export type { ISipLegConfig } from './sip-leg.ts';
export { WebRtcLeg } from './webrtc-leg.ts';
export type { IWebRtcLegConfig } from './webrtc-leg.ts';
export { Call } from './call.ts';
export { CallManager } from './call-manager.ts';
export type { ICallManagerConfig } from './call-manager.ts';

View File

@@ -1,104 +0,0 @@
/**
* ILeg interface — abstract connection from a Call hub to an endpoint.
*
* Concrete implementations: SipLeg (SIP devices + providers) and WebRtcLeg (browsers).
* Shared RTP utilities (header building, clock rates) are also defined here.
*/
import { Buffer } from 'node:buffer';
import type dgram from 'node:dgram';
import type { IEndpoint } from '../sip/index.ts';
import type { TLegState, TLegType, ILegStatus } from './types.ts';
import type { IRtpTranscoder } from '../codec.ts';
import type { SipDialog } from '../sip/index.ts';
import type { SipMessage } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// ILeg interface
// ---------------------------------------------------------------------------
export interface ILeg {
readonly id: string;
readonly type: TLegType;
state: TLegState;
/** The SIP Call-ID used by this leg (for CallManager routing). */
readonly sipCallId: string;
/** Where this leg sends/receives RTP. */
readonly rtpPort: number | null;
readonly rtpSock: dgram.Socket | null;
remoteMedia: IEndpoint | null;
/** Negotiated codec payload type (e.g. 9 = G.722, 111 = Opus). */
codec: number | null;
/** Transcoder for converting to this leg's codec (set by Call when codecs differ). */
transcoder: IRtpTranscoder | null;
/** Packet counters. */
pktSent: number;
pktReceived: number;
/** SIP dialog (SipLegs only, null for WebRtcLegs). */
readonly dialog: SipDialog | null;
/**
* Send an RTP packet toward this leg's remote endpoint.
* If a transcoder is set, the Call should transcode before calling this.
*/
sendRtp(data: Buffer): void;
/**
* Callback set by the owning Call — invoked when this leg receives an RTP packet.
* The Call uses this to forward to other legs.
*/
onRtpReceived: ((data: Buffer) => void) | null;
/**
* Handle an incoming SIP message routed to this leg (SipLegs only).
* Returns a SipMessage response if one needs to be sent, or null.
*/
handleSipMessage(msg: SipMessage, rinfo: IEndpoint): void;
/** Release all resources (sockets, peer connections, etc.). */
teardown(): void;
/** Status snapshot for the dashboard. */
getStatus(): ILegStatus;
}
// ---------------------------------------------------------------------------
// Shared RTP utilities
// ---------------------------------------------------------------------------
/** RTP clock increment per 20ms frame for each codec. */
export function rtpClockIncrement(pt: number): number {
if (pt === 111) return 960; // Opus: 48000 Hz x 0.02s
if (pt === 9) return 160; // G.722: 8000 Hz x 0.02s (SDP clock rate quirk)
return 160; // PCMU/PCMA: 8000 Hz x 0.02s
}
/** Build a fresh RTP header with correct PT, timestamp, seq, SSRC. */
export function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
const hdr = Buffer.alloc(12);
hdr[0] = 0x80; // V=2
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
hdr.writeUInt16BE(seq & 0xffff, 2);
hdr.writeUInt32BE(ts >>> 0, 4);
hdr.writeUInt32BE(ssrc >>> 0, 8);
return hdr;
}
/** Codec name for status display. */
export function codecDisplayName(pt: number | null): string | null {
if (pt === null) return null;
switch (pt) {
case 0: return 'PCMU';
case 8: return 'PCMA';
case 9: return 'G.722';
case 111: return 'Opus';
case 101: return 'telephone-event';
default: return `PT${pt}`;
}
}

View File

@@ -17,9 +17,26 @@ import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { buildRtpHeader, rtpClockIncrement } from './leg.ts';
import { encodePcm, isCodecReady } from '../opusbridge.ts';
/** RTP clock increment per 20ms frame for each codec. */
function rtpClockIncrement(pt: number): number {
if (pt === 111) return 960;
if (pt === 9) return 160;
return 160;
}
/** Build a fresh RTP header. */
function buildRtpHeader(pt: number, seq: number, ts: number, ssrc: number, marker: boolean): Buffer {
const hdr = Buffer.alloc(12);
hdr[0] = 0x80;
hdr[1] = (marker ? 0x80 : 0) | (pt & 0x7f);
hdr.writeUInt16BE(seq & 0xffff, 2);
hdr.writeUInt32BE(ts >>> 0, 4);
hdr.writeUInt32BE(ssrc >>> 0, 8);
return hdr;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

View File

@@ -1,71 +0,0 @@
/**
* Unified RTP port pool — replaces the three separate allocators
* in sipproxy.ts, calloriginator.ts, and webrtcbridge.ts.
*
* Allocates even-numbered UDP ports from a configured range.
* Each allocation binds a dgram socket and returns it ready to use.
*/
import dgram from 'node:dgram';
export interface IRtpAllocation {
port: number;
sock: dgram.Socket;
}
export class RtpPortPool {
private min: number;
private max: number;
private allocated = new Map<number, dgram.Socket>();
private log: (msg: string) => void;
constructor(min: number, max: number, log: (msg: string) => void) {
this.min = min % 2 === 0 ? min : min + 1; // ensure even start
this.max = max;
this.log = log;
}
/**
* Allocate an even-numbered port and bind a UDP socket to it.
* Returns null if the pool is exhausted.
*/
allocate(): IRtpAllocation | null {
for (let port = this.min; port < this.max; port += 2) {
if (this.allocated.has(port)) continue;
const sock = dgram.createSocket('udp4');
try {
sock.bind(port, '0.0.0.0');
} catch {
try { sock.close(); } catch { /* ignore */ }
continue;
}
this.allocated.set(port, sock);
this.log(`[rtp-pool] allocated port ${port} (${this.allocated.size} in use)`);
return { port, sock };
}
this.log('[rtp-pool] WARN: port pool exhausted');
return null;
}
/**
* Release a port back to the pool and close its socket.
*/
release(port: number): void {
const sock = this.allocated.get(port);
if (!sock) return;
try { sock.close(); } catch { /* ignore */ }
this.allocated.delete(port);
this.log(`[rtp-pool] released port ${port} (${this.allocated.size} in use)`);
}
/** Number of currently allocated ports. */
get size(): number {
return this.allocated.size;
}
/** Total capacity (number of even ports in range). */
get capacity(): number {
return Math.floor((this.max - this.min) / 2);
}
}

View File

@@ -1,633 +0,0 @@
/**
* 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<typeof setInterval> | 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: `<sip:${ip}:${this.config.lanPort}>`,
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', `<sip:${this.config.lanIp}:${this.config.lanPort};lr>`);
}
// 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: `<sip:${ip}:${this.config.lanPort}>`,
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'],
],
'',
);
}

View File

@@ -1,336 +0,0 @@
/**
* SystemLeg — virtual ILeg for IVR menus and voicemail.
*
* Plugs into the Call hub exactly like SipLeg or WebRtcLeg:
* - Receives caller audio via sendRtp() (called by Call.forwardRtp)
* - Plays prompts by firing onRtpReceived (picked up by Call.forwardRtp → caller's leg)
* - Detects DTMF from caller's audio (RFC 2833 telephone-event)
* - Records caller's audio to WAV files (for voicemail)
*
* No UDP socket or SIP dialog needed — purely virtual.
*/
import { Buffer } from 'node:buffer';
import type dgram from 'node:dgram';
import type { IEndpoint } from '../sip/index.ts';
import type { SipMessage } from '../sip/index.ts';
import type { SipDialog } from '../sip/index.ts';
import type { IRtpTranscoder } from '../codec.ts';
import type { ILeg } from './leg.ts';
import type { TLegState, TLegType, ILegStatus } from './types.ts';
import { DtmfDetector } from './dtmf-detector.ts';
import type { IDtmfDigit } from './dtmf-detector.ts';
import { AudioRecorder } from './audio-recorder.ts';
import type { IRecordingResult } from './audio-recorder.ts';
import { PromptCache, playPromptG722, playPromptOpus } from './prompt-cache.ts';
import type { ICachedPrompt } from './prompt-cache.ts';
import { buildRtpHeader, rtpClockIncrement } from './leg.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TSystemLegMode = 'ivr' | 'voicemail-greeting' | 'voicemail-recording' | 'idle';
export interface ISystemLegConfig {
/** Logging function. */
log: (msg: string) => void;
/** The prompt cache for TTS playback. */
promptCache: PromptCache;
/**
* Codec payload type used by the caller's leg.
* Determines whether G.722 (9) or Opus (111) frames are played.
* Default: 9 (G.722, typical for SIP callers).
*/
callerCodecPt?: number;
/** Called when a DTMF digit is detected. */
onDtmfDigit?: (digit: IDtmfDigit) => void;
/** Called when a voicemail recording is complete. */
onRecordingComplete?: (result: IRecordingResult) => void;
/** Called when the SystemLeg wants to signal an IVR action. */
onAction?: (action: string, data?: any) => void;
}
// ---------------------------------------------------------------------------
// SystemLeg
// ---------------------------------------------------------------------------
export class SystemLeg implements ILeg {
readonly id: string;
readonly type: TLegType = 'system';
state: TLegState = 'connected'; // Immediately "connected" — no setup phase.
/** Current operating mode. */
mode: TSystemLegMode = 'idle';
// --- ILeg required fields (virtual — no real network resources) ---
readonly sipCallId: string;
readonly rtpPort: number | null = null;
readonly rtpSock: dgram.Socket | null = null;
remoteMedia: IEndpoint | null = null;
codec: number | null = null;
transcoder: IRtpTranscoder | null = null;
pktSent = 0;
pktReceived = 0;
readonly dialog: SipDialog | null = null;
/**
* Set by Call.addLeg() — firing this injects audio into the Call hub,
* which forwards it to the caller's leg.
*/
onRtpReceived: ((data: Buffer) => void) | null = null;
// --- Internal components ---
private dtmfDetector: DtmfDetector;
private recorder: AudioRecorder | null = null;
private promptCache: PromptCache;
private promptCancel: (() => void) | null = null;
private callerCodecPt: number;
private log: (msg: string) => void;
readonly config: ISystemLegConfig;
/** Stable SSRC for all prompt playback (random, stays constant for the leg's lifetime). */
private ssrc: number;
/** Sequence/timestamp counters for Opus prompt playback (shared for seamless transitions). */
private opusCounters = { seq: 0, ts: 0 };
constructor(id: string, config: ISystemLegConfig) {
this.id = id;
this.sipCallId = `system-${id}`; // Virtual Call-ID — not a real SIP dialog.
this.config = config;
this.log = config.log;
this.promptCache = config.promptCache;
this.callerCodecPt = config.callerCodecPt ?? 9; // Default G.722
this.ssrc = (Math.random() * 0xffffffff) >>> 0;
this.opusCounters.seq = Math.floor(Math.random() * 0xffff);
this.opusCounters.ts = Math.floor(Math.random() * 0xffffffff);
// Initialize DTMF detector.
this.dtmfDetector = new DtmfDetector(this.log);
this.dtmfDetector.onDigit = (digit) => {
this.log(`[system-leg:${this.id}] DTMF '${digit.digit}' (${digit.source})`);
this.config.onDtmfDigit?.(digit);
};
}
// -------------------------------------------------------------------------
// ILeg: sendRtp — receives caller's audio from the Call hub
// -------------------------------------------------------------------------
/**
* Called by the Call hub (via forwardRtp) to deliver the caller's audio
* to this leg. We use this for DTMF detection and recording.
*/
sendRtp(data: Buffer): void {
this.pktReceived++;
// Feed DTMF detector (it checks PT internally, ignores non-101 packets).
this.dtmfDetector.processRtp(data);
// Feed recorder if active.
if (this.mode === 'voicemail-recording' && this.recorder) {
this.recorder.processRtp(data);
}
}
// -------------------------------------------------------------------------
// ILeg: handleSipMessage — handles SIP INFO for DTMF
// -------------------------------------------------------------------------
/**
* Handle a SIP message routed to this leg. Only SIP INFO (DTMF) is relevant.
*/
handleSipMessage(msg: SipMessage, _rinfo: IEndpoint): void {
if (msg.method === 'INFO') {
this.dtmfDetector.processSipInfo(msg);
}
}
// -------------------------------------------------------------------------
// Prompt playback
// -------------------------------------------------------------------------
/**
* Play a cached prompt by ID.
* The audio is injected into the Call hub via onRtpReceived.
*
* @param promptId - ID of the prompt in the PromptCache
* @param onDone - called when playback completes (not on cancel)
* @returns true if playback started, false if prompt not found
*/
playPrompt(promptId: string, onDone?: () => void): boolean {
const prompt = this.promptCache.get(promptId);
if (!prompt) {
this.log(`[system-leg:${this.id}] prompt "${promptId}" not found`);
onDone?.();
return false;
}
// Cancel any in-progress playback.
this.cancelPrompt();
this.log(`[system-leg:${this.id}] playing prompt "${promptId}" (${prompt.durationMs}ms)`);
// Select G.722 or Opus frames based on caller codec.
if (this.callerCodecPt === 111) {
// WebRTC caller: play Opus frames.
this.promptCancel = playPromptOpus(
prompt,
(pkt) => this.injectPacket(pkt),
this.ssrc,
this.opusCounters,
() => {
this.promptCancel = null;
onDone?.();
},
);
} else {
// SIP caller: play G.722 frames (works for all SIP codecs since the
// SipLeg's RTP socket sends whatever we give it — the provider's
// media endpoint accepts the codec negotiated in the SDP).
this.promptCancel = playPromptG722(
prompt,
(pkt) => this.injectPacket(pkt),
this.ssrc,
() => {
this.promptCancel = null;
onDone?.();
},
);
}
return this.promptCancel !== null;
}
/**
* Play a sequence of prompts, one after another.
*/
playPromptSequence(promptIds: string[], onDone?: () => void): void {
let index = 0;
const playNext = () => {
if (index >= promptIds.length) {
onDone?.();
return;
}
const id = promptIds[index++];
if (!this.playPrompt(id, playNext)) {
// Prompt not found — skip and play next.
playNext();
}
};
playNext();
}
/** Cancel any in-progress prompt playback. */
cancelPrompt(): void {
if (this.promptCancel) {
this.promptCancel();
this.promptCancel = null;
}
}
/** Whether a prompt is currently playing. */
get isPlaying(): boolean {
return this.promptCancel !== null;
}
/**
* Inject an RTP packet into the Call hub.
* This simulates "receiving" audio on this leg — the hub
* will forward it to the caller's leg.
*/
private injectPacket(pkt: Buffer): void {
this.pktSent++;
this.onRtpReceived?.(pkt);
}
// -------------------------------------------------------------------------
// Recording
// -------------------------------------------------------------------------
/**
* Start recording the caller's audio.
* @param outputDir - directory to write the WAV file
* @param fileId - unique ID for the file name
*/
async startRecording(outputDir: string, fileId?: string): Promise<void> {
if (this.recorder) {
await this.recorder.stop();
}
this.recorder = new AudioRecorder({
outputDir,
log: this.log,
maxDurationSec: 120,
silenceTimeoutSec: 5,
});
this.recorder.onStopped = (result) => {
this.log(`[system-leg:${this.id}] recording auto-stopped (${result.stopReason})`);
this.config.onRecordingComplete?.(result);
};
this.mode = 'voicemail-recording';
await this.recorder.start(fileId);
}
/**
* Stop recording and finalize the WAV file.
*/
async stopRecording(): Promise<IRecordingResult | null> {
if (!this.recorder) return null;
const result = await this.recorder.stop();
this.recorder = null;
return result;
}
/** Cancel recording — stops and deletes the file. */
async cancelRecording(): Promise<void> {
if (this.recorder) {
await this.recorder.cancel();
this.recorder = null;
}
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/** Release all resources. */
teardown(): void {
this.cancelPrompt();
// Stop recording gracefully.
if (this.recorder && this.recorder.state === 'recording') {
this.recorder.stop().then((result) => {
this.config.onRecordingComplete?.(result);
});
this.recorder = null;
}
this.dtmfDetector.destroy();
this.state = 'terminated';
this.mode = 'idle';
this.onRtpReceived = null;
this.log(`[system-leg:${this.id}] torn down`);
}
/** Status snapshot for the dashboard. */
getStatus(): ILegStatus {
return {
id: this.id,
type: this.type,
state: this.state,
remoteMedia: null,
rtpPort: null,
pktSent: this.pktSent,
pktReceived: this.pktReceived,
codec: this.callerCodecPt === 111 ? 'Opus' : 'G.722',
transcoding: false,
};
}
}

View File

@@ -1,70 +0,0 @@
/**
* Hub model type definitions — Call, Leg, and status types.
*/
import type { IEndpoint } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// State types
// ---------------------------------------------------------------------------
export type TCallState =
| 'setting-up'
| 'ringing'
| 'connected'
| 'on-hold'
| 'voicemail'
| 'ivr'
| 'transferring'
| 'terminating'
| 'terminated';
export type TLegState =
| 'inviting'
| 'ringing'
| 'connected'
| 'on-hold'
| 'terminating'
| 'terminated';
export type TLegType = 'sip-device' | 'sip-provider' | 'webrtc' | 'system';
export type TCallDirection = 'inbound' | 'outbound' | 'internal';
// ---------------------------------------------------------------------------
// Status interfaces (for frontend dashboard)
// ---------------------------------------------------------------------------
export interface ILegStatus {
id: string;
type: TLegType;
state: TLegState;
remoteMedia: IEndpoint | null;
rtpPort: number | null;
pktSent: number;
pktReceived: number;
codec: string | null;
transcoding: boolean;
}
export interface ICallStatus {
id: string;
state: TCallState;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
createdAt: number;
duration: number;
legs: ILegStatus[];
}
export interface ICallHistoryEntry {
id: string;
direction: TCallDirection;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
startedAt: number;
duration: number;
}

View File

@@ -1,163 +0,0 @@
/**
* Streaming WAV file writer — opens a file, writes a placeholder header,
* appends raw PCM data in chunks, and finalizes (patches sizes) on close.
*
* Produces standard RIFF/WAVE format compatible with the WAV parser
* in announcement.ts (extractPcmFromWav).
*/
import fs from 'node:fs';
import { Buffer } from 'node:buffer';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface IWavWriterOptions {
/** Full path to the output WAV file. */
filePath: string;
/** Sample rate in Hz (e.g. 16000). */
sampleRate: number;
/** Number of channels (default 1 = mono). */
channels?: number;
/** Bits per sample (default 16). */
bitsPerSample?: number;
}
export interface IWavWriterResult {
/** Full path to the WAV file. */
filePath: string;
/** Total duration in milliseconds. */
durationMs: number;
/** Sample rate of the output file. */
sampleRate: number;
/** Total number of audio samples written. */
totalSamples: number;
/** File size in bytes. */
fileSize: number;
}
// ---------------------------------------------------------------------------
// WAV header constants
// ---------------------------------------------------------------------------
/** Standard WAV header size: RIFF(12) + fmt(24) + data-header(8) = 44 bytes. */
const HEADER_SIZE = 44;
// ---------------------------------------------------------------------------
// WavWriter
// ---------------------------------------------------------------------------
export class WavWriter {
private fd: number | null = null;
private totalDataBytes = 0;
private closed = false;
private filePath: string;
private sampleRate: number;
private channels: number;
private bitsPerSample: number;
constructor(options: IWavWriterOptions) {
this.filePath = options.filePath;
this.sampleRate = options.sampleRate;
this.channels = options.channels ?? 1;
this.bitsPerSample = options.bitsPerSample ?? 16;
}
/** Open the file and write a placeholder 44-byte WAV header. */
open(): void {
if (this.fd !== null) throw new Error('WavWriter already open');
this.fd = fs.openSync(this.filePath, 'w');
this.totalDataBytes = 0;
this.closed = false;
// Write 44 bytes of zeros as placeholder — patched in close().
const placeholder = Buffer.alloc(HEADER_SIZE);
fs.writeSync(this.fd, placeholder, 0, HEADER_SIZE, 0);
}
/** Append raw 16-bit little-endian PCM samples. */
write(pcm: Buffer): void {
if (this.fd === null || this.closed) return;
if (pcm.length === 0) return;
fs.writeSync(this.fd, pcm, 0, pcm.length);
this.totalDataBytes += pcm.length;
}
/**
* Finalize: rewrite the RIFF and data chunk sizes in the header, close the file.
* Returns metadata about the written WAV.
*/
close(): IWavWriterResult {
if (this.fd === null || this.closed) {
return {
filePath: this.filePath,
durationMs: 0,
sampleRate: this.sampleRate,
totalSamples: 0,
fileSize: HEADER_SIZE,
};
}
this.closed = true;
const blockAlign = this.channels * (this.bitsPerSample / 8);
const byteRate = this.sampleRate * blockAlign;
const fileSize = HEADER_SIZE + this.totalDataBytes;
// Build the complete 44-byte header.
const hdr = Buffer.alloc(HEADER_SIZE);
let offset = 0;
// RIFF chunk descriptor.
hdr.write('RIFF', offset); offset += 4;
hdr.writeUInt32LE(fileSize - 8, offset); offset += 4; // ChunkSize = fileSize - 8
hdr.write('WAVE', offset); offset += 4;
// fmt sub-chunk.
hdr.write('fmt ', offset); offset += 4;
hdr.writeUInt32LE(16, offset); offset += 4; // Subchunk1Size (PCM = 16)
hdr.writeUInt16LE(1, offset); offset += 2; // AudioFormat (1 = PCM)
hdr.writeUInt16LE(this.channels, offset); offset += 2;
hdr.writeUInt32LE(this.sampleRate, offset); offset += 4;
hdr.writeUInt32LE(byteRate, offset); offset += 4;
hdr.writeUInt16LE(blockAlign, offset); offset += 2;
hdr.writeUInt16LE(this.bitsPerSample, offset); offset += 2;
// data sub-chunk.
hdr.write('data', offset); offset += 4;
hdr.writeUInt32LE(this.totalDataBytes, offset); offset += 4;
// Patch the header at the beginning of the file.
fs.writeSync(this.fd, hdr, 0, HEADER_SIZE, 0);
fs.closeSync(this.fd);
this.fd = null;
const bytesPerSample = this.bitsPerSample / 8;
const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels));
const durationMs = (totalSamples / this.sampleRate) * 1000;
return {
filePath: this.filePath,
durationMs: Math.round(durationMs),
sampleRate: this.sampleRate,
totalSamples,
fileSize,
};
}
/** Current recording duration in milliseconds. */
get durationMs(): number {
const bytesPerSample = this.bitsPerSample / 8;
const totalSamples = Math.floor(this.totalDataBytes / (bytesPerSample * this.channels));
return (totalSamples / this.sampleRate) * 1000;
}
/** Whether the writer is still open and accepting data. */
get isOpen(): boolean {
return this.fd !== null && !this.closed;
}
}

View File

@@ -1,417 +0,0 @@
/**
* WebRtcLeg — a WebRTC connection from the Call hub to a browser client.
*
* Wraps a werift RTCPeerConnection and handles:
* - WebRTC offer/answer/ICE negotiation
* - Opus <-> G.722/PCMU/PCMA transcoding via Rust IPC
* - RTP header rebuilding with correct PT, timestamp, SSRC
*/
import dgram from 'node:dgram';
import { Buffer } from 'node:buffer';
import { WebSocket } from 'ws';
import type { IEndpoint } from '../sip/index.ts';
import type { TLegState, ILegStatus } from './types.ts';
import type { ILeg } from './leg.ts';
import { rtpClockIncrement, buildRtpHeader, codecDisplayName } from './leg.ts';
import { createTranscoder, OPUS_PT } from '../codec.ts';
import type { IRtpTranscoder } from '../codec.ts';
import { createSession, destroySession } from '../opusbridge.ts';
import type { SipDialog } from '../sip/index.ts';
import type { SipMessage } from '../sip/index.ts';
// ---------------------------------------------------------------------------
// WebRtcLeg config
// ---------------------------------------------------------------------------
export interface IWebRtcLegConfig {
/** The browser's WebSocket connection. */
ws: WebSocket;
/** The browser's session ID. */
sessionId: string;
/** RTP port and socket (pre-allocated from the pool). */
rtpPort: number;
rtpSock: dgram.Socket;
/** Logging function. */
log: (msg: string) => void;
}
// ---------------------------------------------------------------------------
// WebRtcLeg
// ---------------------------------------------------------------------------
export class WebRtcLeg implements ILeg {
readonly id: string;
readonly type = 'webrtc' as const;
state: TLegState = 'inviting';
readonly sessionId: string;
/** The werift RTCPeerConnection instance. */
private pc: any = null;
/** RTP socket for bridging to SIP. */
readonly rtpSock: dgram.Socket;
readonly rtpPort: number;
/** Remote media endpoint (the other side of the bridge, set by Call). */
remoteMedia: IEndpoint | null = null;
/** Negotiated WebRTC codec payload type. */
codec: number | null = null;
/** Transcoders for WebRTC <-> SIP conversion. */
transcoder: IRtpTranscoder | null = null; // used by Call for fan-out
private toSipTranscoder: IRtpTranscoder | null = null;
private fromSipTranscoder: IRtpTranscoder | null = null;
/** RTP counters for outgoing (to SIP) direction. */
private toSipSeq = 0;
private toSipTs = 0;
private toSipSsrc = (Math.random() * 0xffffffff) >>> 0;
/** RTP counters for incoming (from SIP) direction.
* Initialized to random values so announcements and provider audio share
* a continuous sequence — prevents the browser jitter buffer from discarding
* packets after the announcement→provider transition. */
readonly fromSipCounters = {
seq: Math.floor(Math.random() * 0xffff),
ts: Math.floor(Math.random() * 0xffffffff),
};
fromSipSsrc = (Math.random() * 0xffffffff) >>> 0;
/** Packet counters. */
pktSent = 0;
pktReceived = 0;
/** Callback set by Call. */
onRtpReceived: ((data: Buffer) => void) | null = null;
/** Callback to send transcoded RTP to the provider via the SipLeg's socket.
* Set by CallManager when the bridge is established. If null, falls back to own rtpSock. */
onSendToProvider: ((data: Buffer, dest: IEndpoint) => void) | null = null;
/** Lifecycle callbacks. */
onConnected: ((leg: WebRtcLeg) => void) | null = null;
onTerminated: ((leg: WebRtcLeg) => void) | null = null;
/** Cancel handle for an in-progress announcement. */
announcementCancel: (() => void) | null = null;
private ws: WebSocket;
private config: IWebRtcLegConfig;
private pendingIceCandidates: any[] = [];
// SipDialog is not applicable for WebRTC legs.
readonly dialog: SipDialog | null = null;
readonly sipCallId: string;
constructor(id: string, config: IWebRtcLegConfig) {
this.id = id;
this.sessionId = config.sessionId;
this.ws = config.ws;
this.rtpSock = config.rtpSock;
this.rtpPort = config.rtpPort;
this.config = config;
this.sipCallId = `webrtc-${id}`;
// Log RTP arriving on this socket (symmetric RTP from provider).
// Audio forwarding is handled by the Call hub: SipLeg → forwardRtp → WebRtcLeg.sendRtp.
// We do NOT transcode here to avoid double-processing (the SipLeg also receives these packets).
let sipRxCount = 0;
this.rtpSock.on('message', (data: Buffer) => {
sipRxCount++;
if (sipRxCount === 1 || sipRxCount === 50 || sipRxCount % 500 === 0) {
this.config.log(`[webrtc-leg:${this.id}] SIP->browser rtp #${sipRxCount} (${data.length}b) [symmetric, ignored]`);
}
});
}
// -------------------------------------------------------------------------
// WebRTC offer/answer
// -------------------------------------------------------------------------
/**
* Handle a WebRTC offer from the browser. Creates the PeerConnection,
* sets remote offer, creates answer, and sends it back.
*/
async handleOffer(offerSdp: string): Promise<void> {
this.config.log(`[webrtc-leg:${this.id}] received offer`);
try {
const werift = await import('werift');
this.pc = new werift.RTCPeerConnection({ iceServers: [] });
// Add sendrecv transceiver before setRemoteDescription.
this.pc.addTransceiver('audio', { direction: 'sendrecv' });
// Handle incoming audio from browser.
this.pc.ontrack = (event: any) => {
const track = event.track;
this.config.log(`[webrtc-leg:${this.id}] got track: ${track.kind}`);
let rxCount = 0;
track.onReceiveRtp.subscribe((rtp: any) => {
if (!this.remoteMedia) return;
rxCount++;
if (rxCount === 1 || rxCount === 50 || rxCount % 500 === 0) {
this.config.log(`[webrtc-leg:${this.id}] browser->SIP rtp #${rxCount}`);
}
this.forwardToSip(rtp, rxCount);
});
};
// ICE candidate handling.
this.pc.onicecandidate = (candidate: any) => {
if (candidate) {
const json = candidate.toJSON?.() || candidate;
this.wsSend({ type: 'webrtc-ice', sessionId: this.sessionId, candidate: json });
}
};
this.pc.onconnectionstatechange = () => {
this.config.log(`[webrtc-leg:${this.id}] connection state: ${this.pc.connectionState}`);
if (this.pc.connectionState === 'connected') {
this.state = 'connected';
this.onConnected?.(this);
} else if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
this.state = 'terminated';
this.onTerminated?.(this);
}
};
if (this.pc.oniceconnectionstatechange !== undefined) {
this.pc.oniceconnectionstatechange = () => {
this.config.log(`[webrtc-leg:${this.id}] ICE state: ${this.pc.iceConnectionState}`);
};
}
// Set remote offer and create answer.
await this.pc.setRemoteDescription({ type: 'offer', sdp: offerSdp });
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
const sdp: string = this.pc.localDescription!.sdp;
// Detect negotiated codec.
const mAudio = sdp.match(/m=audio\s+\d+\s+\S+\s+(\d+)/);
if (mAudio) {
this.codec = parseInt(mAudio[1], 10);
this.config.log(`[webrtc-leg:${this.id}] negotiated audio PT=${this.codec}`);
}
// Extract sender SSRC from SDP.
const ssrcMatch = sdp.match(/a=ssrc:(\d+)\s/);
if (ssrcMatch) {
this.fromSipSsrc = parseInt(ssrcMatch[1], 10);
}
// Also try from sender object.
const senders = this.pc.getSenders();
if (senders[0]) {
const senderSsrc = (senders[0] as any).ssrc ?? (senders[0] as any)._ssrc;
if (senderSsrc) this.fromSipSsrc = senderSsrc;
}
// Send answer to browser.
this.wsSend({ type: 'webrtc-answer', sessionId: this.sessionId, sdp });
this.config.log(`[webrtc-leg:${this.id}] sent answer, rtp port=${this.rtpPort}`);
// Process buffered ICE candidates.
for (const c of this.pendingIceCandidates) {
try { await this.pc.addIceCandidate(c); } catch { /* ignore */ }
}
this.pendingIceCandidates = [];
} catch (err: any) {
this.config.log(`[webrtc-leg:${this.id}] offer error: ${err.message}`);
this.wsSend({ type: 'webrtc-error', sessionId: this.sessionId, error: err.message });
this.state = 'terminated';
this.onTerminated?.(this);
}
}
/** Add an ICE candidate from the browser. */
async addIceCandidate(candidate: any): Promise<void> {
if (!this.pc) {
this.pendingIceCandidates.push(candidate);
return;
}
try {
if (candidate) await this.pc.addIceCandidate(candidate);
} catch (err: any) {
this.config.log(`[webrtc-leg:${this.id}] ICE error: ${err.message}`);
}
}
// -------------------------------------------------------------------------
// Transcoding setup
// -------------------------------------------------------------------------
/** Codec session ID for isolated Rust codec state (unique per leg). */
private codecSessionId = `webrtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
/**
* Set up transcoders for bridging between WebRTC and SIP codecs.
* Called by the Call when the remote media endpoint is known.
* Creates an isolated Rust codec session so concurrent calls don't
* corrupt each other's stateful codec state (Opus/G.722 ADPCM).
*/
async setupTranscoders(sipPT: number): Promise<void> {
const webrtcPT = this.codec ?? OPUS_PT;
// Create isolated codec session for this leg.
await createSession(this.codecSessionId);
this.toSipTranscoder = createTranscoder(webrtcPT, sipPT, this.codecSessionId, 'to_sip');
this.fromSipTranscoder = createTranscoder(sipPT, webrtcPT, this.codecSessionId, 'to_browser');
const mode = this.toSipTranscoder ? `transcoding PT ${webrtcPT}<->${sipPT}` : `pass-through PT ${webrtcPT}`;
this.config.log(`[webrtc-leg:${this.id}] ${mode} (session: ${this.codecSessionId})`);
}
// -------------------------------------------------------------------------
// RTP forwarding
// -------------------------------------------------------------------------
/** Forward RTP from SIP side to browser via WebRTC. */
private forwardToBrowser(data: Buffer, count: number): void {
const sender = this.pc?.getSenders()[0];
if (!sender) return;
if (this.fromSipTranscoder && data.length > 12) {
const payload = Buffer.from(data.subarray(12));
// Stop announcement if still playing — provider audio takes over.
if (this.announcementCancel) {
this.announcementCancel();
this.announcementCancel = null;
}
// Capture seq/ts BEFORE async transcode to preserve ordering.
const toPT = this.fromSipTranscoder.toPT;
const seq = this.fromSipCounters.seq++;
const ts = this.fromSipCounters.ts;
this.fromSipCounters.ts += rtpClockIncrement(toPT);
const result = this.fromSipTranscoder.payload(payload);
const sendTranscoded = (transcoded: Buffer) => {
if (transcoded.length === 0) return; // transcoding failed
try {
const hdr = buildRtpHeader(toPT, seq, ts, this.fromSipSsrc, false);
const out = Buffer.concat([hdr, transcoded]);
const r = sender.sendRtp(out);
if (r instanceof Promise) r.catch(() => {});
} catch { /* ignore */ }
};
if (result instanceof Promise) result.then(sendTranscoded).catch(() => {});
else sendTranscoded(result);
} else if (!this.fromSipTranscoder) {
// No transcoder — either same codec or not set up yet.
// Only forward if we don't expect transcoding.
if (this.codec === null) {
try { sender.sendRtp(data); } catch { /* ignore */ }
}
}
}
/** Forward RTP from browser to SIP side. */
private forwardToSip(rtp: any, count: number): void {
if (!this.remoteMedia) return;
if (this.toSipTranscoder) {
const payload: Buffer = rtp.payload;
if (!payload || payload.length === 0) return;
// Capture seq/ts BEFORE async transcode to preserve ordering.
const toPT = this.toSipTranscoder.toPT;
const seq = this.toSipSeq++;
const ts = this.toSipTs;
this.toSipTs += rtpClockIncrement(toPT);
const result = this.toSipTranscoder.payload(payload);
const sendTranscoded = (transcoded: Buffer) => {
if (transcoded.length === 0) return; // transcoding failed
const hdr = buildRtpHeader(toPT, seq, ts, this.toSipSsrc, false);
const out = Buffer.concat([hdr, transcoded]);
if (this.onSendToProvider) {
this.onSendToProvider(out, this.remoteMedia!);
} else {
this.rtpSock.send(out, this.remoteMedia!.port, this.remoteMedia!.address);
}
this.pktSent++;
};
if (result instanceof Promise) result.then(sendTranscoded).catch(() => {});
else sendTranscoded(result);
} else if (this.codec === null) {
// Same codec (no transcoding needed) — pass through.
const raw = rtp.serialize();
if (this.onSendToProvider) {
this.onSendToProvider(raw, this.remoteMedia);
} else {
this.rtpSock.send(raw, this.remoteMedia.port, this.remoteMedia.address);
}
this.pktSent++;
}
// If codec is set but transcoder is null, drop the packet — transcoder not ready yet.
// This prevents raw Opus from being sent to a G.722 endpoint.
}
/**
* Send RTP to the browser via WebRTC (used by Call hub for fan-out).
* This transcodes and sends through the PeerConnection, NOT to a UDP address.
*/
sendRtp(data: Buffer): void {
this.forwardToBrowser(data, this.pktSent);
this.pktSent++;
}
/**
* Send a pre-encoded RTP packet directly to the browser via PeerConnection.
* Used for announcements — the packet must already be in the correct codec (Opus).
*/
sendDirectToBrowser(pkt: Buffer): void {
const sender = this.pc?.getSenders()[0];
if (!sender) return;
try {
const r = sender.sendRtp(pkt);
if (r instanceof Promise) r.catch(() => {});
} catch { /* ignore */ }
}
/** No-op: WebRTC legs don't process SIP messages. */
handleSipMessage(_msg: SipMessage, _rinfo: IEndpoint): void {
// WebRTC legs don't handle SIP messages.
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
teardown(): void {
this.state = 'terminated';
try { this.pc?.close(); } catch { /* ignore */ }
this.pc = null;
// Destroy the isolated Rust codec session for this leg.
destroySession(this.codecSessionId).catch(() => {});
// 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.toSipTranscoder !== null || this.fromSipTranscoder !== null,
};
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private wsSend(data: unknown): void {
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
} catch { /* ignore */ }
}
}

View File

@@ -1,40 +0,0 @@
/**
* Audio codec translation layer for bridging between WebRTC and SIP.
*
* All actual codec work (Opus, G.722, PCMU, PCMA) is done in Rust via
* the smartrust bridge. This module provides the RTP-level transcoding
* interface used by the webrtcbridge.
*/
import { Buffer } from 'node:buffer';
import { transcode, isCodecReady } from './opusbridge.ts';
/** Opus dynamic payload type (standard WebRTC assignment). */
export const OPUS_PT = 111;
export interface IRtpTranscoder {
/** Transcode an RTP payload. Always async (Rust IPC). */
payload(data: Buffer): Promise<Buffer>;
fromPT: number;
toPT: number;
}
/**
* Create a transcoder that converts RTP payloads between two codecs.
* Returns null if the codecs are the same or the Rust bridge isn't ready.
*
* @param sessionId - optional Rust codec session for isolated state per call
*/
export function createTranscoder(fromPT: number, toPT: number, sessionId?: string, direction?: string): IRtpTranscoder | null {
if (fromPT === toPT) return null;
if (!isCodecReady()) return null;
return {
fromPT,
toPT,
async payload(data: Buffer): Promise<Buffer> {
const result = await transcode(data, fromPT, toPT, sessionId, direction);
return result || Buffer.alloc(0); // return empty on failure — never pass raw codec bytes
},
};
}

View File

@@ -8,7 +8,15 @@
import fs from 'node:fs';
import path from 'node:path';
import type { IEndpoint } from './sip/index.ts';
// ---------------------------------------------------------------------------
// Shared types (previously in ts/sip/types.ts, now inlined)
// ---------------------------------------------------------------------------
export interface IEndpoint {
address: string;
port: number;
}
// ---------------------------------------------------------------------------
// Config interfaces
@@ -319,175 +327,5 @@ export function loadConfig(): IAppConfig {
return cfg;
}
// ---------------------------------------------------------------------------
// Pattern matching
// ---------------------------------------------------------------------------
/**
* Test a value against a pattern string.
* - undefined/empty pattern: matches everything (wildcard)
* - Prefix: "pattern*" matches values starting with "pattern"
* - Regex: "/pattern/" or "/pattern/i" compiles as RegExp
* - Otherwise: exact match
*/
export function matchesPattern(pattern: string | undefined, value: string): boolean {
if (!pattern) return true;
// Prefix match: "+49*"
if (pattern.endsWith('*')) {
return value.startsWith(pattern.slice(0, -1));
}
// Regex match: "/^\\+49/" or "/pattern/i"
if (pattern.startsWith('/')) {
const lastSlash = pattern.lastIndexOf('/');
if (lastSlash > 0) {
const re = new RegExp(pattern.slice(1, lastSlash), pattern.slice(lastSlash + 1));
return re.test(value);
}
}
// Exact match.
return value === pattern;
}
// ---------------------------------------------------------------------------
// Route resolution
// ---------------------------------------------------------------------------
/** Result of resolving an outbound route. */
export interface IOutboundRouteResult {
provider: IProviderConfig;
transformedNumber: string;
}
/** Result of resolving an inbound route. */
export interface IInboundRouteResult {
/** Device IDs to ring (empty = all devices). */
deviceIds: string[];
ringBrowsers: boolean;
/** If set, route directly to this voicemail box (skip ringing). */
voicemailBox?: string;
/** If set, route to this IVR menu (skip ringing). */
ivrMenuId?: string;
/** Override for no-answer timeout in seconds. */
noAnswerTimeout?: number;
}
/**
* Resolve which provider to use for an outbound call, and transform the number.
*
* @param cfg - app config
* @param dialedNumber - the number being dialed
* @param sourceDeviceId - optional device originating the call
* @param isProviderRegistered - callback to check if a provider is currently registered
*/
export function resolveOutboundRoute(
cfg: IAppConfig,
dialedNumber: string,
sourceDeviceId?: string,
isProviderRegistered?: (providerId: string) => boolean,
): IOutboundRouteResult | null {
const routes = cfg.routing.routes
.filter((r) => r.enabled && r.match.direction === 'outbound')
.sort((a, b) => b.priority - a.priority);
for (const route of routes) {
const m = route.match;
if (!matchesPattern(m.numberPattern, dialedNumber)) continue;
if (m.sourceDevice && m.sourceDevice !== sourceDeviceId) continue;
// Find a registered provider (primary + failovers).
const candidates = [route.action.provider, ...(route.action.failoverProviders || [])].filter(Boolean) as string[];
for (const pid of candidates) {
const provider = getProvider(cfg, pid);
if (!provider) continue;
if (isProviderRegistered && !isProviderRegistered(pid)) continue;
// Apply number transformation.
let num = dialedNumber;
if (route.action.stripPrefix && num.startsWith(route.action.stripPrefix)) {
num = num.slice(route.action.stripPrefix.length);
}
if (route.action.prependPrefix) {
num = route.action.prependPrefix + num;
}
return { provider, transformedNumber: num };
}
// Route matched but no provider is available — continue to next route.
}
// Fallback: first available provider.
const fallback = cfg.providers[0];
return fallback ? { provider: fallback, transformedNumber: dialedNumber } : null;
}
/**
* Resolve which devices/browsers to ring for an inbound call.
*
* @param cfg - app config
* @param providerId - the provider the call is coming from
* @param calledNumber - the DID / called number (from Request-URI)
* @param callerNumber - the caller ID (from From header)
*/
export function resolveInboundRoute(
cfg: IAppConfig,
providerId: string,
calledNumber: string,
callerNumber: string,
): IInboundRouteResult {
const routes = cfg.routing.routes
.filter((r) => r.enabled && r.match.direction === 'inbound')
.sort((a, b) => b.priority - a.priority);
for (const route of routes) {
const m = route.match;
if (m.sourceProvider && m.sourceProvider !== providerId) continue;
if (!matchesPattern(m.numberPattern, calledNumber)) continue;
if (!matchesPattern(m.callerPattern, callerNumber)) continue;
return {
deviceIds: route.action.targets || [],
ringBrowsers: route.action.ringBrowsers ?? false,
voicemailBox: route.action.voicemailBox,
ivrMenuId: route.action.ivrMenuId,
noAnswerTimeout: route.action.noAnswerTimeout,
};
}
// Fallback: ring all devices + browsers.
return { deviceIds: [], ringBrowsers: true };
}
// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------
export function getProvider(cfg: IAppConfig, id: string): IProviderConfig | null {
return cfg.providers.find((p) => p.id === id) ?? null;
}
export function getDevice(cfg: IAppConfig, id: string): IDeviceConfig | null {
return cfg.devices.find((d) => d.id === id) ?? null;
}
/**
* @deprecated Use resolveOutboundRoute() instead. Kept for backward compat.
*/
export function getProviderForOutbound(cfg: IAppConfig): IProviderConfig | null {
const result = resolveOutboundRoute(cfg, '');
return result?.provider ?? null;
}
/**
* @deprecated Use resolveInboundRoute() instead. Kept for backward compat.
*/
export function getDevicesForInbound(cfg: IAppConfig, providerId: string): IDeviceConfig[] {
const result = resolveInboundRoute(cfg, providerId, '', '');
if (!result.deviceIds.length) return cfg.devices;
return result.deviceIds.map((id) => getDevice(cfg, id)).filter(Boolean) as IDeviceConfig[];
}
// Route resolution, pattern matching, and provider/device lookup
// are now handled entirely by the Rust proxy-engine.

View File

@@ -11,10 +11,13 @@ import path from 'node:path';
import http from 'node:http';
import https from 'node:https';
import { WebSocketServer, WebSocket } from 'ws';
import type { CallManager } from './call/index.ts';
import { handleWebRtcSignaling } from './webrtcbridge.ts';
import type { VoiceboxManager } from './voicebox.ts';
// CallManager was previously used for WebRTC call handling. Now replaced by Rust proxy-engine.
// Kept as `any` type for backward compat with the function signature until full WebRTC port.
type CallManager = any;
const CONFIG_PATH = path.join(process.cwd(), '.nogit', 'config.json');
// ---------------------------------------------------------------------------
@@ -336,6 +339,10 @@ export function initWebUi(
onHangupCall: (callId: string) => boolean,
onConfigSaved?: () => void,
callManager?: CallManager,
/** WebRTC signaling handlers — forwarded to Rust proxy-engine. */
onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>,
onWebRtcIce?: (sessionId: string, candidate: any) => Promise<void>,
onWebRtcClose?: (sessionId: string) => Promise<void>,
voiceboxManager?: VoiceboxManager,
): void {
const WEB_PORT = 3060;
@@ -372,17 +379,23 @@ export function initWebUi(
socket.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === 'webrtc-accept' && msg.callId) {
log(`[webrtc] browser accepted call ${msg.callId} session=${msg.sessionId || 'none'}`);
const ok = callManager?.acceptBrowserCall(msg.callId, msg.sessionId) ?? false;
log(`[webrtc] acceptBrowserCall result: ${ok}`);
} else if (msg.type === 'webrtc-offer' && msg.sessionId) {
callManager?.handleWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
log(`[webrtc] offer error: ${e.message}`));
if (msg.type === 'webrtc-offer' && msg.sessionId) {
// Forward to Rust proxy-engine for WebRTC handling.
if (onWebRtcOffer) {
onWebRtcOffer(msg.sessionId, msg.sdp, socket as any).catch((e: any) =>
log(`[webrtc] offer error: ${e.message}`));
}
} else if (msg.type === 'webrtc-ice' && msg.sessionId) {
callManager?.handleWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
if (onWebRtcIce) {
onWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
}
} else if (msg.type === 'webrtc-hangup' && msg.sessionId) {
callManager?.handleWebRtcHangup(msg.sessionId);
if (onWebRtcClose) {
onWebRtcClose(msg.sessionId).catch(() => {});
}
} else if (msg.type === 'webrtc-accept' && msg.callId) {
// TODO: Wire to Rust call linking.
log(`[webrtc] accept: call=${msg.callId} session=${msg.sessionId || 'none'}`);
} else if (msg.type?.startsWith('webrtc-')) {
msg._remoteIp = remoteIp;
handleWebRtcSignaling(socket as any, msg);

209
ts/ivr.ts
View File

@@ -1,209 +0,0 @@
/**
* IVR engine — state machine that navigates callers through menus
* based on DTMF digit input.
*
* The IvrEngine is instantiated per-call and drives a SystemLeg:
* - Plays menu prompts via the SystemLeg's prompt playback
* - Receives DTMF digits and resolves them to actions
* - Fires an onAction callback for the CallManager to execute
* (route to extension, voicemail, transfer, etc.)
*/
import type { IIvrConfig, IIvrMenu, TIvrAction } from './config.ts';
import type { SystemLeg } from './call/system-leg.ts';
// ---------------------------------------------------------------------------
// IVR Engine
// ---------------------------------------------------------------------------
export class IvrEngine {
private config: IIvrConfig;
private systemLeg: SystemLeg;
private onAction: (action: TIvrAction) => void;
private log: (msg: string) => void;
/** The currently active menu. */
private currentMenu: IIvrMenu | null = null;
/** How many times the current menu has been replayed (for retry limit). */
private retryCount = 0;
/** Timer for digit input timeout. */
private digitTimeout: ReturnType<typeof setTimeout> | null = null;
/** Whether the engine is waiting for a digit (prompt finished playing). */
private waitingForDigit = false;
/** Whether the engine has been destroyed. */
private destroyed = false;
constructor(
config: IIvrConfig,
systemLeg: SystemLeg,
onAction: (action: TIvrAction) => void,
log: (msg: string) => void,
) {
this.config = config;
this.systemLeg = systemLeg;
this.onAction = onAction;
this.log = log;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Start the IVR — navigates to the entry menu and plays its prompt.
*/
start(): void {
const entryMenu = this.getMenu(this.config.entryMenuId);
if (!entryMenu) {
this.log(`[ivr] entry menu "${this.config.entryMenuId}" not found — hanging up`);
this.onAction({ type: 'hangup' });
return;
}
this.navigateToMenu(entryMenu);
}
/**
* Handle a DTMF digit from the caller.
*/
handleDigit(digit: string): void {
if (this.destroyed || !this.currentMenu) return;
// Clear the timeout — caller pressed something.
this.clearDigitTimeout();
// Cancel any playing prompt (caller interrupted it).
this.systemLeg.cancelPrompt();
this.waitingForDigit = false;
this.log(`[ivr] digit '${digit}' in menu "${this.currentMenu.id}"`);
// Look up the digit in the current menu.
const entry = this.currentMenu.entries.find((e) => e.digit === digit);
if (entry) {
this.executeAction(entry.action);
} else {
this.log(`[ivr] invalid digit '${digit}' in menu "${this.currentMenu.id}"`);
this.executeAction(this.currentMenu.invalidAction);
}
}
/**
* Clean up timers and state.
*/
destroy(): void {
this.destroyed = true;
this.clearDigitTimeout();
this.currentMenu = null;
}
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
/** Navigate to a menu: play its prompt, then wait for digit. */
private navigateToMenu(menu: IIvrMenu): void {
if (this.destroyed) return;
this.currentMenu = menu;
this.waitingForDigit = false;
this.clearDigitTimeout();
const promptId = `ivr-menu-${menu.id}`;
this.log(`[ivr] playing menu "${menu.id}" prompt`);
this.systemLeg.playPrompt(promptId, () => {
if (this.destroyed) return;
// Prompt finished — start digit timeout.
this.waitingForDigit = true;
this.startDigitTimeout();
});
}
/** Start the timeout timer for digit input. */
private startDigitTimeout(): void {
const timeoutSec = this.currentMenu?.timeoutSec ?? 5;
this.digitTimeout = setTimeout(() => {
if (this.destroyed || !this.currentMenu) return;
this.log(`[ivr] digit timeout in menu "${this.currentMenu.id}"`);
this.handleTimeout();
}, timeoutSec * 1000);
}
/** Handle timeout (no digit pressed). */
private handleTimeout(): void {
if (!this.currentMenu) return;
this.retryCount++;
const maxRetries = this.currentMenu.maxRetries ?? 3;
if (this.retryCount >= maxRetries) {
this.log(`[ivr] max retries (${maxRetries}) reached in menu "${this.currentMenu.id}"`);
this.executeAction(this.currentMenu.timeoutAction);
} else {
this.log(`[ivr] retry ${this.retryCount}/${maxRetries} in menu "${this.currentMenu.id}"`);
// Replay the current menu.
this.navigateToMenu(this.currentMenu);
}
}
/** Execute an IVR action. */
private executeAction(action: TIvrAction): void {
if (this.destroyed) return;
switch (action.type) {
case 'submenu': {
const submenu = this.getMenu(action.menuId);
if (submenu) {
this.retryCount = 0;
this.navigateToMenu(submenu);
} else {
this.log(`[ivr] submenu "${action.menuId}" not found — hanging up`);
this.onAction({ type: 'hangup' });
}
break;
}
case 'repeat': {
if (this.currentMenu) {
this.navigateToMenu(this.currentMenu);
}
break;
}
case 'play-message': {
// Play a message prompt, then return to the current menu.
this.systemLeg.playPrompt(action.promptId, () => {
if (this.destroyed || !this.currentMenu) return;
this.navigateToMenu(this.currentMenu);
});
break;
}
default:
// All other actions (route-extension, route-voicemail, transfer, hangup)
// are handled by the CallManager via the onAction callback.
this.log(`[ivr] action: ${action.type}`);
this.onAction(action);
break;
}
}
/** Look up a menu by ID. */
private getMenu(menuId: string): IIvrMenu | null {
return this.config.menus.find((m) => m.id === menuId) ?? null;
}
/** Clear the digit timeout timer. */
private clearDigitTimeout(): void {
if (this.digitTimeout) {
clearTimeout(this.digitTimeout);
this.digitTimeout = null;
}
}
}

View File

@@ -1,306 +0,0 @@
/**
* Per-provider runtime state and upstream registration.
*
* Each configured provider gets its own ProviderState instance tracking
* registration status, public IP, and the periodic REGISTER cycle.
*/
import { Buffer } from 'node:buffer';
import {
SipMessage,
generateCallId,
generateTag,
generateBranch,
parseDigestChallenge,
computeDigestAuth,
} from './sip/index.ts';
import type { IEndpoint } from './sip/index.ts';
import type { IProviderConfig } from './config.ts';
// ---------------------------------------------------------------------------
// Provider state
// ---------------------------------------------------------------------------
export class ProviderState {
readonly config: IProviderConfig;
publicIp: string | null;
isRegistered = false;
registeredAor: string;
// Registration transaction state.
private regCallId: string;
private regCSeq = 0;
private regFromTag: string;
private regTimer: ReturnType<typeof setInterval> | null = null;
private sendSip: ((buf: Buffer, dest: IEndpoint) => void) | null = null;
private logFn: ((msg: string) => void) | null = null;
private onRegistrationChange: ((provider: ProviderState) => void) | null = null;
constructor(config: IProviderConfig, publicIpSeed: string | null) {
this.config = config;
this.publicIp = publicIpSeed;
this.registeredAor = `sip:${config.username}@${config.domain}`;
this.regCallId = generateCallId();
this.regFromTag = generateTag();
}
private log(msg: string): void {
this.logFn?.(`[provider:${this.config.id}] ${msg}`);
}
// -------------------------------------------------------------------------
// Upstream registration
// -------------------------------------------------------------------------
/**
* Start the periodic REGISTER cycle with this provider.
*/
startRegistration(
lanIp: string,
lanPort: number,
sendSip: (buf: Buffer, dest: IEndpoint) => void,
log: (msg: string) => void,
onRegistrationChange: (provider: ProviderState) => void,
): void {
this.sendSip = sendSip;
this.logFn = log;
this.onRegistrationChange = onRegistrationChange;
// Initial registration.
this.sendRegister(lanIp, lanPort);
// Re-register periodically.
const intervalMs = (this.config.registerIntervalSec * 0.85) * 1000;
this.regTimer = setInterval(() => this.sendRegister(lanIp, lanPort), intervalMs);
}
stopRegistration(): void {
if (this.regTimer) {
clearInterval(this.regTimer);
this.regTimer = null;
}
}
private sendRegister(lanIp: string, lanPort: number): void {
this.regCSeq++;
const pub = this.publicIp || lanIp;
const { config } = this;
const register = SipMessage.createRequest('REGISTER', `sip:${config.domain}`, {
via: { host: pub, port: lanPort },
from: { uri: this.registeredAor, tag: this.regFromTag },
to: { uri: this.registeredAor },
callId: this.regCallId,
cseq: this.regCSeq,
contact: `<sip:${config.username}@${pub}:${lanPort}>`,
maxForwards: 70,
extraHeaders: [
['Expires', String(config.registerIntervalSec)],
['User-Agent', 'SipRouter/1.0'],
['Allow', 'INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE'],
],
});
this.log(`REGISTER -> ${config.outboundProxy.address}:${config.outboundProxy.port} (CSeq ${this.regCSeq})`);
this.sendSip!(register.serialize(), config.outboundProxy);
}
/**
* Handle an incoming SIP response that belongs to this provider's registration.
* Returns true if the message was consumed.
*/
handleRegistrationResponse(msg: SipMessage): boolean {
if (!msg.isResponse) return false;
if (msg.callId !== this.regCallId) return false;
if (msg.cseqMethod?.toUpperCase() !== 'REGISTER') return false;
const code = msg.statusCode ?? 0;
this.log(`REGISTER <- ${code}`);
if (code === 200) {
const wasRegistered = this.isRegistered;
this.isRegistered = true;
if (!wasRegistered) {
this.log('registered');
this.onRegistrationChange?.(this);
}
return true;
}
if (code === 401 || code === 407) {
const challengeHeader = code === 401
? msg.getHeader('WWW-Authenticate')
: msg.getHeader('Proxy-Authenticate');
if (!challengeHeader) {
this.log(`${code} but no challenge header`);
return true;
}
const challenge = parseDigestChallenge(challengeHeader);
if (!challenge) {
this.log(`${code} could not parse digest challenge`);
return true;
}
const authValue = computeDigestAuth({
username: this.config.username,
password: this.config.password,
realm: challenge.realm,
nonce: challenge.nonce,
method: 'REGISTER',
uri: `sip:${this.config.domain}`,
algorithm: challenge.algorithm,
opaque: challenge.opaque,
});
// Resend REGISTER with auth.
this.regCSeq++;
const pub = this.publicIp || 'unknown';
// We need lanIp/lanPort but don't have them here — reconstruct from Via.
const via = msg.getHeader('Via') || '';
const viaHost = via.match(/SIP\/2\.0\/UDP\s+([^;:]+)/)?.[1] || pub;
const viaPort = parseInt(via.match(/:(\d+)/)?.[1] || '5070', 10);
const register = SipMessage.createRequest('REGISTER', `sip:${this.config.domain}`, {
via: { host: viaHost, port: viaPort },
from: { uri: this.registeredAor, tag: this.regFromTag },
to: { uri: this.registeredAor },
callId: this.regCallId,
cseq: this.regCSeq,
contact: `<sip:${this.config.username}@${viaHost}:${viaPort}>`,
maxForwards: 70,
extraHeaders: [
[code === 401 ? 'Authorization' : 'Proxy-Authorization', authValue],
['Expires', String(this.config.registerIntervalSec)],
['User-Agent', 'SipRouter/1.0'],
['Allow', 'INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE'],
],
});
this.log(`REGISTER -> (with auth, CSeq ${this.regCSeq})`);
this.sendSip!(register.serialize(), this.config.outboundProxy);
return true;
}
if (code >= 400) {
const wasRegistered = this.isRegistered;
this.isRegistered = false;
if (wasRegistered) {
this.log(`registration lost (${code})`);
this.onRegistrationChange?.(this);
}
return true;
}
return true; // consume 1xx etc.
}
/**
* Update public IP from Via received= parameter.
*/
detectPublicIp(via: string): void {
const m = via.match(/received=([\d.]+)/);
if (m && m[1] !== this.publicIp) {
this.log(`publicIp = ${m[1]}`);
this.publicIp = m[1];
}
}
}
// ---------------------------------------------------------------------------
// Provider state management
// ---------------------------------------------------------------------------
let providerStates: Map<string, ProviderState>;
export function initProviderStates(
providers: IProviderConfig[],
publicIpSeed: string | null,
): Map<string, ProviderState> {
providerStates = new Map();
for (const p of providers) {
providerStates.set(p.id, new ProviderState(p, publicIpSeed));
}
return providerStates;
}
export function getProviderState(id: string): ProviderState | null {
return providerStates?.get(id) ?? null;
}
/**
* Sync running provider states with updated config.
* - New providers: create state + start registration.
* - Removed providers: stop registration + delete state.
* - Changed providers: stop old, create new, start registration (preserves detected publicIp).
*/
export function syncProviderStates(
newProviders: IProviderConfig[],
publicIpSeed: string | null,
lanIp: string,
lanPort: number,
sendSip: (buf: Buffer, dest: IEndpoint) => void,
log: (msg: string) => void,
onRegistrationChange: (provider: ProviderState) => void,
): void {
if (!providerStates) return;
const newIds = new Set(newProviders.map(p => p.id));
const oldIds = new Set(providerStates.keys());
// Remove providers no longer in config.
for (const id of oldIds) {
if (!newIds.has(id)) {
const ps = providerStates.get(id)!;
ps.stopRegistration();
providerStates.delete(id);
log(`[provider:${id}] removed`);
}
}
for (const p of newProviders) {
if (!oldIds.has(p.id)) {
// New provider.
const ps = new ProviderState(p, publicIpSeed);
providerStates.set(p.id, ps);
ps.startRegistration(lanIp, lanPort, sendSip, log, onRegistrationChange);
log(`[provider:${p.id}] added — registration started`);
} else {
// Existing provider — check if config changed.
const existing = providerStates.get(p.id)!;
if (JSON.stringify(existing.config) !== JSON.stringify(p)) {
existing.stopRegistration();
const ps = new ProviderState(p, existing.publicIp || publicIpSeed);
providerStates.set(p.id, ps);
ps.startRegistration(lanIp, lanPort, sendSip, log, onRegistrationChange);
log(`[provider:${p.id}] config changed — re-registering`);
}
}
}
}
/**
* Find which provider sent a packet, by matching the source address
* against all providers' outbound proxy addresses.
*/
export function getProviderByUpstreamAddress(address: string, port: number): ProviderState | null {
if (!providerStates) return null;
for (const ps of providerStates.values()) {
if (ps.config.outboundProxy.address === address && ps.config.outboundProxy.port === port) {
return ps;
}
}
return null;
}
/**
* Check whether a response belongs to any provider's registration transaction.
*/
export function handleProviderRegistrationResponse(msg: SipMessage): boolean {
if (!providerStates || !msg.isResponse) return false;
for (const ps of providerStates.values()) {
if (ps.handleRegistrationResponse(msg)) return true;
}
return false;
}

274
ts/proxybridge.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* Proxy engine bridge — manages the Rust proxy-engine subprocess.
*
* The proxy-engine handles ALL SIP protocol mechanics. TypeScript only:
* - Sends configuration
* - Receives high-level events (incoming_call, call_ended, etc.)
* - Sends high-level commands (hangup, make_call, play_audio)
*
* No raw SIP ever touches TypeScript.
*/
import path from 'node:path';
import { RustBridge } from '@push.rocks/smartrust';
// ---------------------------------------------------------------------------
// Command type map for smartrust
// ---------------------------------------------------------------------------
type TProxyCommands = {
configure: {
params: Record<string, unknown>;
result: { bound: string };
};
hangup: {
params: { call_id: string };
result: Record<string, never>;
};
make_call: {
params: { number: string; device_id?: string; provider_id?: string };
result: { call_id: string };
};
play_audio: {
params: { call_id: string; leg_id?: string; file_path: string; codec?: number };
result: Record<string, never>;
};
start_recording: {
params: { call_id: string; file_path: string; max_duration_ms?: number };
result: Record<string, never>;
};
stop_recording: {
params: { call_id: string };
result: { file_path: string; duration_ms: number };
};
};
// ---------------------------------------------------------------------------
// Event types from Rust
// ---------------------------------------------------------------------------
export interface IIncomingCallEvent {
call_id: string;
from_uri: string;
to_number: string;
provider_id: string;
}
export interface IOutboundCallEvent {
call_id: string;
from_device: string | null;
to_number: string;
}
export interface ICallEndedEvent {
call_id: string;
reason: string;
duration: number;
from_side?: string;
}
export interface IProviderRegisteredEvent {
provider_id: string;
registered: boolean;
public_ip: string | null;
}
export interface IDeviceRegisteredEvent {
device_id: string;
display_name: string;
address: string;
port: number;
aor: string;
expires: number;
}
// ---------------------------------------------------------------------------
// Bridge singleton
// ---------------------------------------------------------------------------
let bridge: RustBridge<TProxyCommands> | null = null;
let initialized = false;
let logFn: ((msg: string) => void) | undefined;
function buildLocalPaths(): string[] {
const root = process.cwd();
return [
path.join(root, 'dist_rust', 'proxy-engine'),
path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
];
}
/**
* Initialize the proxy engine — spawn the Rust binary.
* Call configure() separately to push config and start SIP.
*/
export async function initProxyEngine(log?: (msg: string) => void): Promise<boolean> {
if (initialized && bridge) return true;
logFn = log;
try {
bridge = new RustBridge<TProxyCommands>({
binaryName: 'proxy-engine',
localPaths: buildLocalPaths(),
});
const spawned = await bridge.spawn();
if (!spawned) {
log?.('[proxy-engine] failed to spawn binary');
bridge = null;
return false;
}
bridge.on('exit', () => {
logFn?.('[proxy-engine] process exited — will need re-init');
bridge = null;
initialized = false;
});
// Forward stderr for debugging.
bridge.on('stderr', (line: string) => {
logFn?.(`[proxy-engine:stderr] ${line}`);
});
initialized = true;
log?.('[proxy-engine] spawned and ready');
return true;
} catch (e: any) {
log?.(`[proxy-engine] init error: ${e.message}`);
bridge = null;
return false;
}
}
/**
* Send the full app config to the proxy engine.
* This binds the SIP socket, starts provider registrations, etc.
*/
export async function configureProxyEngine(config: Record<string, unknown>): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
const result = await bridge.sendCommand('configure', config as any);
logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`);
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] configure error: ${e.message}`);
return false;
}
}
/**
* Initiate an outbound call via Rust. Returns the call ID or null on failure.
*/
export async function makeCall(number: string, deviceId?: string, providerId?: string): Promise<string | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('make_call', {
number,
device_id: deviceId,
provider_id: providerId,
} as any);
return (result as any)?.call_id || null;
} catch (e: any) {
logFn?.(`[proxy-engine] make_call error: ${e?.message || e}`);
return null;
}
}
/**
* Send a hangup command.
*/
export async function hangupCall(callId: string): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('hangup', { call_id: callId } as any);
return true;
} catch {
return false;
}
}
/**
* Send a WebRTC offer to the proxy engine. Returns the SDP answer.
*/
export async function webrtcOffer(sessionId: string, sdp: string): Promise<{ sdp: string } | null> {
if (!bridge || !initialized) return null;
try {
const result = await bridge.sendCommand('webrtc_offer', { session_id: sessionId, sdp } as any);
return result as any;
} catch (e: any) {
logFn?.(`[proxy-engine] webrtc_offer error: ${e?.message || e}`);
return null;
}
}
/**
* Forward an ICE candidate to the proxy engine.
*/
export async function webrtcIce(sessionId: string, candidate: any): Promise<void> {
if (!bridge || !initialized) return;
try {
await bridge.sendCommand('webrtc_ice', {
session_id: sessionId,
candidate: candidate?.candidate || candidate,
sdp_mid: candidate?.sdpMid,
sdp_mline_index: candidate?.sdpMLineIndex,
} as any);
} catch { /* ignore */ }
}
/**
* Link a WebRTC session to a SIP call — enables audio bridging.
* The browser's Opus audio will be transcoded and sent to the provider.
*/
export async function webrtcLink(sessionId: string, callId: string, providerMediaAddr: string, providerMediaPort: number, sipPt: number = 9): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('webrtc_link', {
session_id: sessionId,
call_id: callId,
provider_media_addr: providerMediaAddr,
provider_media_port: providerMediaPort,
sip_pt: sipPt,
} as any);
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] webrtc_link error: ${e?.message || e}`);
return false;
}
}
/**
* Close a WebRTC session.
*/
export async function webrtcClose(sessionId: string): Promise<void> {
if (!bridge || !initialized) return;
try {
await bridge.sendCommand('webrtc_close', { session_id: sessionId } as any);
} catch { /* ignore */ }
}
/**
* Subscribe to an event from the proxy engine.
* Event names: incoming_call, outbound_device_call, call_ringing,
* call_answered, call_ended, provider_registered, device_registered,
* dtmf_digit, recording_done, sip_unhandled
*/
export function onProxyEvent(event: string, handler: (data: any) => void): void {
if (!bridge) throw new Error('proxy engine not initialized');
bridge.on(`management:${event}`, handler);
}
/** Check if the proxy engine is ready. */
export function isProxyReady(): boolean {
return initialized && bridge !== null;
}
/** Shut down the proxy engine. */
export function shutdownProxyEngine(): void {
if (bridge) {
try { bridge.kill(); } catch { /* ignore */ }
bridge = null;
initialized = false;
}
}

View File

@@ -1,118 +1,27 @@
/**
* Local SIP registrar — accepts REGISTER from devices and browser clients.
* Browser device registration.
*
* Devices point their SIP registration at the proxy instead of the upstream
* provider. The registrar responds with 200 OK and stores the device's
* current contact (source IP:port). Browser softphones register via
* WebSocket signaling.
* SIP device registration is now handled entirely by the Rust proxy-engine.
* This module only handles browser softphone registration via WebSocket.
*/
import { createHash } from 'node:crypto';
import {
SipMessage,
generateTag,
} from './sip/index.ts';
/** Hash a string to a 6-char hex ID. */
export function shortHash(input: string): string {
return createHash('sha256').update(input).digest('hex').slice(0, 6);
}
import type { IEndpoint } from './sip/index.ts';
import type { IDeviceConfig } from './config.ts';
// ---------------------------------------------------------------------------
// Types
// Browser device registration
// ---------------------------------------------------------------------------
export interface IRegisteredDevice {
deviceConfig: IDeviceConfig;
contact: IEndpoint | null;
registeredAt: number;
expiresAt: number;
aor: string;
connected: boolean;
isBrowser: boolean;
}
export interface IDeviceStatusEntry {
id: string;
displayName: string;
contact: IEndpoint | null;
aor: string;
connected: boolean;
isBrowser: boolean;
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const registeredDevices = new Map<string, IRegisteredDevice>();
const browserDevices = new Map<string, IRegisteredDevice>();
let knownDevices: IDeviceConfig[] = [];
let logFn: (msg: string) => void = () => {};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function initRegistrar(
devices: IDeviceConfig[],
log: (msg: string) => void,
): void {
knownDevices = devices;
logFn = log;
}
/**
* Process a REGISTER from a SIP device. Returns a 200 OK response to send back,
* or null if this REGISTER should not be handled by the local registrar.
*/
export function handleDeviceRegister(
msg: SipMessage,
rinfo: IEndpoint,
): SipMessage | null {
if (msg.method !== 'REGISTER') return null;
const device = knownDevices.find((d) => d.expectedAddress === rinfo.address);
if (!device) return null;
const from = msg.getHeader('From');
const aor = from ? SipMessage.extractUri(from) || `sip:${device.extension}@${rinfo.address}` : `sip:${device.extension}@${rinfo.address}`;
const MAX_EXPIRES = 300;
const expiresHeader = msg.getHeader('Expires');
const requested = expiresHeader ? parseInt(expiresHeader, 10) : 3600;
const expires = Math.min(requested, MAX_EXPIRES);
const entry: IRegisteredDevice = {
deviceConfig: device,
contact: { address: rinfo.address, port: rinfo.port },
registeredAt: Date.now(),
expiresAt: Date.now() + expires * 1000,
aor,
connected: true,
isBrowser: false,
};
registeredDevices.set(device.id, entry);
logFn(`[registrar] ${device.displayName} (${device.id}) registered from ${rinfo.address}:${rinfo.port} expires=${expires}`);
const contact = msg.getHeader('Contact') || `<sip:${rinfo.address}:${rinfo.port}>`;
const response = SipMessage.createResponse(200, 'OK', msg, {
toTag: generateTag(),
contact,
extraHeaders: [['Expires', String(expires)]],
});
return response;
}
const browserDevices = new Map<string, { deviceId: string; displayName: string; remoteIp: string }>();
/**
* Register a browser softphone as a device.
*/
export function registerBrowserDevice(sessionId: string, userAgent?: string, remoteIp?: string): void {
// Extract a short browser name from the UA string.
let browserName = 'Browser';
if (userAgent) {
if (userAgent.includes('Firefox/')) browserName = 'Firefox';
@@ -121,21 +30,11 @@ export function registerBrowserDevice(sessionId: string, userAgent?: string, rem
else if (userAgent.includes('Safari/') && !userAgent.includes('Chrome/')) browserName = 'Safari';
}
const entry: IRegisteredDevice = {
deviceConfig: {
id: `browser-${shortHash(sessionId)}`,
displayName: browserName,
expectedAddress: remoteIp || '127.0.0.1',
extension: 'webrtc',
},
contact: null,
registeredAt: Date.now(),
expiresAt: Date.now() + 60 * 1000, // 60s — browser must re-register to stay alive
aor: `sip:webrtc@browser`,
connected: true,
isBrowser: true,
};
browserDevices.set(sessionId, entry);
browserDevices.set(sessionId, {
deviceId: `browser-${shortHash(sessionId)}`,
displayName: browserName,
remoteIp: remoteIp || '127.0.0.1',
});
}
/**
@@ -144,96 +43,3 @@ export function registerBrowserDevice(sessionId: string, userAgent?: string, rem
export function unregisterBrowserDevice(sessionId: string): void {
browserDevices.delete(sessionId);
}
/**
* Get a registered device by its config ID.
*/
export function getRegisteredDevice(deviceId: string): IRegisteredDevice | null {
const entry = registeredDevices.get(deviceId);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
registeredDevices.delete(deviceId);
return null;
}
return entry;
}
/**
* Get a registered device by source IP address.
*/
export function getRegisteredDeviceByAddress(address: string): IRegisteredDevice | null {
for (const entry of registeredDevices.values()) {
if (entry.contact?.address === address && Date.now() <= entry.expiresAt) {
return entry;
}
}
return null;
}
/**
* Check whether an address belongs to a known device (by config expectedAddress).
*/
export function isKnownDeviceAddress(address: string): boolean {
return knownDevices.some((d) => d.expectedAddress === address);
}
/**
* Get all devices for the dashboard.
* - Configured devices always show (connected or not).
* - Browser devices only show while connected.
*/
export function getAllDeviceStatuses(): IDeviceStatusEntry[] {
const now = Date.now();
const result: IDeviceStatusEntry[] = [];
// Configured devices — always show.
for (const dc of knownDevices) {
const reg = registeredDevices.get(dc.id);
const connected = reg ? now <= reg.expiresAt : false;
if (reg && now > reg.expiresAt) {
registeredDevices.delete(dc.id);
}
result.push({
id: dc.id,
displayName: dc.displayName,
contact: connected && reg ? reg.contact : null,
aor: reg?.aor || `sip:${dc.extension}@${dc.expectedAddress}`,
connected,
isBrowser: false,
});
}
// Browser devices — only while connected.
for (const [, entry] of browserDevices) {
const ip = entry.deviceConfig.expectedAddress;
result.push({
id: entry.deviceConfig.id,
displayName: entry.deviceConfig.displayName,
contact: ip && ip !== '127.0.0.1' ? { address: ip, port: 0 } : null,
aor: entry.aor,
connected: true,
isBrowser: true,
});
}
return result;
}
/**
* Get all currently registered (connected) SIP devices.
*/
export function getAllRegisteredDevices(): IRegisteredDevice[] {
const now = Date.now();
const result: IRegisteredDevice[] = [];
for (const [id, entry] of registeredDevices) {
if (now > entry.expiresAt) {
registeredDevices.delete(id);
} else {
result.push(entry);
}
}
for (const [, entry] of browserDevices) {
result.push(entry);
}
return result;
}

View File

@@ -1,280 +0,0 @@
/**
* 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
* - 3xx6xx → 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';
}
}

View File

@@ -1,241 +0,0 @@
/**
* 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;
}
// ---------------------------------------------------------------------------
// MWI (Message Waiting Indicator) — RFC 3842
// ---------------------------------------------------------------------------
/**
* Build a SIP NOTIFY request for Message Waiting Indicator.
*
* Sent out-of-dialog to notify a device about voicemail message counts.
* Uses the message-summary event package per RFC 3842.
*/
export interface IMwiOptions {
/** Proxy LAN IP and port (Via / From / Contact). */
proxyHost: string;
proxyPort: number;
/** Target device URI (e.g. "sip:user@192.168.5.100:5060"). */
targetUri: string;
/** Account URI for the voicebox (used in the From header). */
accountUri: string;
/** Number of new (unheard) voice messages. */
newMessages: number;
/** Number of old (heard) voice messages. */
oldMessages: number;
}
/**
* Build the body and headers for an MWI NOTIFY (RFC 3842 message-summary).
*
* Returns the body string and extra headers needed. The caller builds
* the SipMessage via SipMessage.createRequest('NOTIFY', ...).
*/
export function buildMwiBody(newMessages: number, oldMessages: number, accountUri: string): {
body: string;
contentType: string;
extraHeaders: [string, string][];
} {
const hasNew = newMessages > 0;
const body =
`Messages-Waiting: ${hasNew ? 'yes' : 'no'}\r\n` +
`Message-Account: ${accountUri}\r\n` +
`Voice-Message: ${newMessages}/${oldMessages}\r\n`;
return {
body,
contentType: 'application/simple-message-summary',
extraHeaders: [
['Event', 'message-summary'],
['Subscription-State', 'terminated;reason=noresource'],
],
};
}

View File

@@ -1,17 +0,0 @@
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,
buildMwiBody,
} from './helpers.ts';
export type { ISdpOptions, IDigestChallenge, IMwiOptions } from './helpers.ts';
export type { IEndpoint } from './types.ts';

View File

@@ -1,316 +0,0 @@
/**
* 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;
}
}

View File

@@ -1,228 +0,0 @@
# 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.

View File

@@ -1,54 +0,0 @@
/**
* 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,
};
}

View File

@@ -1,8 +0,0 @@
/**
* Shared SIP types.
*/
export interface IEndpoint {
address: string;
port: number;
}

View File

@@ -1,39 +1,22 @@
/**
* SIP proxy — hub model entry point.
* SIP proxy — entry point.
*
* Thin bootstrap that wires together:
* - UDP socket for all SIP signaling
* - CallManager (the hub model core)
* - Provider registration
* - Local device registrar
* - WebRTC signaling
* - Web dashboard
* - Rust codec bridge
* Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics.
* TypeScript is the control plane:
* - Loads config and pushes it to Rust
* - Receives high-level events (incoming calls, registration, etc.)
* - Drives the web dashboard
* - Manages IVR, voicemail, announcements
* - Handles WebRTC browser signaling (forwarded to Rust in Phase 2)
*
* All call/media logic lives in ts/call/.
* No raw SIP ever touches TypeScript.
*/
import dgram from 'node:dgram';
import fs from 'node:fs';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { SipMessage } from './sip/index.ts';
import type { IEndpoint } from './sip/index.ts';
import { loadConfig, resolveOutboundRoute } from './config.ts';
import type { IAppConfig, IProviderConfig } from './config.ts';
import {
initProviderStates,
syncProviderStates,
getProviderByUpstreamAddress,
handleProviderRegistrationResponse,
} from './providerstate.ts';
import {
initRegistrar,
handleDeviceRegister,
isKnownDeviceAddress,
getAllDeviceStatuses,
} from './registrar.ts';
import { loadConfig } from './config.ts';
import type { IAppConfig } from './config.ts';
import { broadcastWs, initWebUi } from './frontend.ts';
import {
initWebRtcSignaling,
@@ -43,19 +26,32 @@ import {
} from './webrtcbridge.ts';
import { initCodecBridge } from './opusbridge.ts';
import { initAnnouncement } from './announcement.ts';
import { CallManager } from './call/index.ts';
import { PromptCache } from './call/prompt-cache.ts';
import { VoiceboxManager } from './voicebox.ts';
import {
initProxyEngine,
configureProxyEngine,
onProxyEvent,
hangupCall,
makeCall,
shutdownProxyEngine,
webrtcOffer,
webrtcIce,
webrtcClose,
} from './proxybridge.ts';
import type {
IIncomingCallEvent,
IOutboundCallEvent,
ICallEndedEvent,
IProviderRegisteredEvent,
IDeviceRegisteredEvent,
} from './proxybridge.ts';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
let appConfig: IAppConfig = loadConfig();
const { proxy } = appConfig;
const LAN_IP = proxy.lanIp;
const LAN_PORT = proxy.lanPort;
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
@@ -77,42 +73,82 @@ function log(msg: string): void {
broadcastWs('log', { message: msg });
}
function logPacket(label: string, data: Buffer): void {
const head = `\n========== ${now()} ${label} (${data.length}b) ==========\n`;
const looksText = data.length > 0 && data[0] >= 0x41 && data[0] <= 0x7a;
const body = looksText
? data.toString('utf8')
: `[${data.length} bytes binary] ${data.toString('hex').slice(0, 80)}`;
fs.appendFileSync(LOG_PATH, head + body + '\n');
// ---------------------------------------------------------------------------
// Shadow state — maintained from Rust events for the dashboard
// ---------------------------------------------------------------------------
interface IProviderStatus {
id: string;
displayName: string;
registered: boolean;
publicIp: string | null;
}
interface IDeviceStatus {
id: string;
displayName: string;
address: string | null;
port: number;
connected: boolean;
isBrowser: boolean;
}
interface IActiveCall {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
}
interface ICallHistoryEntry {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
startedAt: number;
duration: number;
}
const providerStatuses = new Map<string, IProviderStatus>();
const deviceStatuses = new Map<string, IDeviceStatus>();
const activeCalls = new Map<string, IActiveCall>();
const callHistory: ICallHistoryEntry[] = [];
const MAX_HISTORY = 100;
// Initialize provider statuses from config (all start as unregistered).
for (const p of appConfig.providers) {
providerStatuses.set(p.id, {
id: p.id,
displayName: p.displayName,
registered: false,
publicIp: null,
});
}
// Initialize device statuses from config.
for (const d of appConfig.devices) {
deviceStatuses.set(d.id, {
id: d.id,
displayName: d.displayName,
address: null,
port: 0,
connected: false,
isBrowser: false,
});
}
// ---------------------------------------------------------------------------
// Initialize subsystems
// ---------------------------------------------------------------------------
const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSeed);
initRegistrar(appConfig.devices, log);
// Initialize voicemail and IVR subsystems.
const promptCache = new PromptCache(log);
const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []);
const callManager = new CallManager({
appConfig,
sendSip: (buf, dest) => sock.send(buf, dest.port, dest.address),
log,
broadcastWs,
getProviderState: (id) => providerStates.get(id),
getAllBrowserDeviceIds,
sendToBrowserDevice,
getBrowserDeviceWs,
promptCache,
voiceboxManager,
});
// Initialize WebRTC signaling (browser device registration only).
// WebRTC signaling (browser device registration).
initWebRtcSignaling({ log });
// ---------------------------------------------------------------------------
@@ -120,177 +156,245 @@ initWebRtcSignaling({ log });
// ---------------------------------------------------------------------------
function getStatus() {
const providers: unknown[] = [];
for (const ps of providerStates.values()) {
providers.push({
id: ps.config.id,
displayName: ps.config.displayName,
registered: ps.isRegistered,
publicIp: ps.publicIp,
// Merge SIP devices (from Rust) + browser devices (from TS WebSocket).
const devices = [...deviceStatuses.values()];
for (const bid of getAllBrowserDeviceIds()) {
devices.push({
id: bid,
displayName: 'Browser',
address: null,
port: 0,
connected: true,
isBrowser: true,
});
}
return {
instanceId,
uptime: Math.floor((Date.now() - startTime) / 1000),
lanIp: LAN_IP,
providers,
devices: getAllDeviceStatuses(),
calls: callManager.getStatus(),
callHistory: callManager.getHistory(),
lanIp: appConfig.proxy.lanIp,
providers: [...providerStatuses.values()],
devices,
calls: [...activeCalls.values()].map((c) => ({
...c,
duration: Math.floor((Date.now() - c.startedAt) / 1000),
legs: [],
})),
callHistory,
contacts: appConfig.contacts || [],
voicemailCounts: voiceboxManager.getAllUnheardCounts(),
};
}
// ---------------------------------------------------------------------------
// Main UDP socket
// Start Rust proxy engine
// ---------------------------------------------------------------------------
const sock = dgram.createSocket('udp4');
sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
try {
const ps = getProviderByUpstreamAddress(rinfo.address, rinfo.port);
const msg = SipMessage.parse(data);
if (!msg) {
// Non-SIP data — forward raw based on direction.
if (ps) {
// From provider, forward to... nowhere useful without a call context.
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data);
} else {
// From device, forward to default provider.
const dp = resolveOutboundRoute(appConfig, '');
if (dp) sock.send(data, dp.provider.outboundProxy.port, dp.provider.outboundProxy.address);
}
return;
}
// 1. Provider registration responses — consumed by providerstate.
if (handleProviderRegistrationResponse(msg)) return;
// 2. Device REGISTER — handled by local registrar.
if (!ps && msg.method === 'REGISTER') {
const response = handleDeviceRegister(msg, { address: rinfo.address, port: rinfo.port });
if (response) {
sock.send(response.serialize(), rinfo.port, rinfo.address);
return;
}
}
// 3. Route to existing call by SIP Call-ID.
if (callManager.routeSipMessage(msg, rinfo)) {
return;
}
// 4. New inbound call from provider.
if (ps && msg.isRequest && msg.method === 'INVITE') {
logPacket(`[new inbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
// Detect public IP from Via.
const via = msg.getHeader('Via');
if (via) ps.detectPublicIp(via);
callManager.createInboundCall(ps, msg, { address: rinfo.address, port: rinfo.port });
return;
}
// 5. New outbound call from device (passthrough).
if (!ps && msg.isRequest && msg.method === 'INVITE') {
logPacket(`[new outbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
const dialedNumber = SipMessage.extractUri(msg.requestUri || '') || '';
const routeResult = resolveOutboundRoute(
appConfig,
dialedNumber,
undefined,
(pid) => !!providerStates.get(pid)?.registeredAor,
);
if (routeResult) {
const provState = providerStates.get(routeResult.provider.id);
if (provState) {
// Apply number transformation to the INVITE if needed.
if (routeResult.transformedNumber !== dialedNumber) {
const newUri = msg.requestUri?.replace(dialedNumber, routeResult.transformedNumber);
if (newUri) msg.setRequestUri(newUri);
}
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, routeResult.provider, provState);
}
}
return;
}
// 6. Fallback: forward based on direction (for mid-dialog messages
// that don't match any tracked call, e.g. OPTIONS, NOTIFY).
if (ps) {
// From provider -> forward to device.
logPacket(`[fallback inbound] from ${rinfo.address}:${rinfo.port}`, data);
const via = msg.getHeader('Via');
if (via) ps.detectPublicIp(via);
// Try to figure out where to send it...
// For now, just log. These should become rare once all calls are tracked.
log(`[fallback] unrouted inbound ${msg.isRequest ? msg.method : msg.statusCode} Call-ID=${msg.callId.slice(0, 30)}`);
} else {
// From device -> forward to provider.
logPacket(`[fallback outbound] from ${rinfo.address}:${rinfo.port}`, data);
const fallback = resolveOutboundRoute(appConfig, '');
if (fallback) sock.send(msg.serialize(), fallback.provider.outboundProxy.port, fallback.provider.outboundProxy.address);
}
} catch (e: any) {
log(`[err] ${e?.stack || e}`);
async function startProxyEngine(): Promise<void> {
const ok = await initProxyEngine(log);
if (!ok) {
log('[FATAL] failed to start proxy engine');
process.exit(1);
}
});
sock.on('error', (err: Error) => log(`[main] sock err: ${err.message}`));
// Subscribe to events from Rust BEFORE sending configure.
onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => {
const ps = providerStatuses.get(data.provider_id);
if (ps) {
const wasRegistered = ps.registered;
ps.registered = data.registered;
ps.publicIp = data.public_ip;
if (data.registered && !wasRegistered) {
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
} else if (!data.registered && wasRegistered) {
log(`[provider:${data.provider_id}] registration lost`);
}
broadcastWs('registration', { providerId: data.provider_id, registered: data.registered });
}
});
onProxyEvent('device_registered', (data: IDeviceRegisteredEvent) => {
const ds = deviceStatuses.get(data.device_id);
if (ds) {
ds.address = data.address;
ds.port = data.port;
ds.connected = true;
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
}
});
onProxyEvent('incoming_call', (data: IIncomingCallEvent) => {
log(`[call] incoming: ${data.from_uri}${data.to_number} via ${data.provider_id} (${data.call_id})`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'inbound',
callerNumber: data.from_uri,
calleeNumber: data.to_number,
providerUsed: data.provider_id,
state: 'ringing',
startedAt: Date.now(),
});
// Notify browsers of incoming call.
const browserIds = getAllBrowserDeviceIds();
for (const bid of browserIds) {
sendToBrowserDevice(bid, {
type: 'webrtc-incoming',
callId: data.call_id,
from: data.from_uri,
deviceId: bid,
});
}
});
onProxyEvent('outbound_device_call', (data: IOutboundCallEvent) => {
log(`[call] outbound: device ${data.from_device}${data.to_number} (${data.call_id})`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: data.from_device,
calleeNumber: data.to_number,
providerUsed: null,
state: 'setting-up',
startedAt: Date.now(),
});
});
onProxyEvent('outbound_call_started', (data: any) => {
log(`[call] outbound started: ${data.call_id}${data.number} via ${data.provider_id}`);
activeCalls.set(data.call_id, {
id: data.call_id,
direction: 'outbound',
callerNumber: null,
calleeNumber: data.number,
providerUsed: data.provider_id,
state: 'setting-up',
startedAt: Date.now(),
});
});
onProxyEvent('call_ringing', (data: { call_id: string }) => {
const call = activeCalls.get(data.call_id);
if (call) call.state = 'ringing';
});
onProxyEvent('call_answered', (data: { call_id: string }) => {
const call = activeCalls.get(data.call_id);
if (call) {
call.state = 'connected';
log(`[call] ${data.call_id} connected`);
}
});
onProxyEvent('call_ended', (data: ICallEndedEvent) => {
const call = activeCalls.get(data.call_id);
if (call) {
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
// Move to history.
callHistory.unshift({
id: call.id,
direction: call.direction,
callerNumber: call.callerNumber,
calleeNumber: call.calleeNumber,
startedAt: call.startedAt,
duration: data.duration,
});
if (callHistory.length > MAX_HISTORY) callHistory.pop();
activeCalls.delete(data.call_id);
}
});
onProxyEvent('sip_unhandled', (data: any) => {
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
});
// WebRTC events from Rust — forward ICE candidates to browser via WebSocket.
onProxyEvent('webrtc_ice_candidate', (data: any) => {
// Find the browser's WebSocket by session ID and send the ICE candidate.
broadcastWs('webrtc-ice', {
sessionId: data.session_id,
candidate: { candidate: data.candidate, sdpMid: data.sdp_mid, sdpMLineIndex: data.sdp_mline_index },
});
});
onProxyEvent('webrtc_state', (data: any) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} state=${data.state}`);
});
onProxyEvent('webrtc_track', (data: any) => {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} track=${data.kind} codec=${data.codec}`);
});
onProxyEvent('webrtc_audio_rx', (data: any) => {
if (data.packet_count === 1 || data.packet_count === 50) {
log(`[webrtc] session=${data.session_id?.slice(0, 8)} browser audio rx #${data.packet_count}`);
}
});
// Voicemail events.
onProxyEvent('voicemail_started', (data: any) => {
log(`[voicemail] started for call ${data.call_id} caller=${data.caller_number}`);
});
onProxyEvent('recording_done', (data: any) => {
log(`[voicemail] recording done: ${data.file_path} (${data.duration_ms}ms) caller=${data.caller_number}`);
// Save voicemail metadata via VoiceboxManager.
voiceboxManager.addMessage?.('default', {
callerNumber: data.caller_number || 'Unknown',
callerName: null,
fileName: data.file_path,
durationMs: data.duration_ms,
});
});
onProxyEvent('voicemail_error', (data: any) => {
log(`[voicemail] error: ${data.error} call=${data.call_id}`);
});
// Send full config to Rust — this binds the SIP socket and starts registrations.
const configured = await configureProxyEngine({
proxy: appConfig.proxy,
providers: appConfig.providers,
devices: appConfig.devices,
routing: appConfig.routing,
});
if (!configured) {
log('[FATAL] failed to configure proxy engine');
process.exit(1);
}
sock.bind(LAN_PORT, '0.0.0.0', () => {
const providerList = appConfig.providers.map((p) => p.displayName).join(', ');
const deviceList = appConfig.devices.map((d) => d.displayName).join(', ');
log(`sip proxy bound 0.0.0.0:${LAN_PORT} | providers: ${providerList} | devices: ${deviceList}`);
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
// Start upstream provider registrations.
for (const ps of providerStates.values()) {
ps.startRegistration(
LAN_IP,
LAN_PORT,
(buf, dest) => sock.send(buf, dest.port, dest.address),
log,
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
);
// Initialize audio codec bridge (still needed for WebRTC transcoding).
try {
await initCodecBridge(log);
await initAnnouncement(log);
// Pre-generate prompts.
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
for (const vb of appConfig.voiceboxes ?? []) {
if (!vb.enabled) continue;
const promptId = `voicemail-greeting-${vb.id}`;
if (vb.greetingWavPath) {
await promptCache.loadWavPrompt(promptId, vb.greetingWavPath);
} else {
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
}
}
if (appConfig.ivr?.enabled) {
for (const menu of appConfig.ivr.menus) {
await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella');
}
}
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
} catch (e) {
log(`[codec] init failed: ${e}`);
}
// Initialize audio codec bridge (Rust binary via smartrust).
initCodecBridge(log)
.then(() => initAnnouncement(log))
.then(async () => {
// Pre-generate voicemail beep tone.
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
// Pre-generate voicemail greetings for all configured voiceboxes.
for (const vb of appConfig.voiceboxes ?? []) {
if (!vb.enabled) continue;
const promptId = `voicemail-greeting-${vb.id}`;
const wavPath = vb.greetingWavPath;
if (wavPath) {
await promptCache.loadWavPrompt(promptId, wavPath);
} else {
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
}
}
// Pre-generate IVR menu prompts.
if (appConfig.ivr?.enabled) {
for (const menu of appConfig.ivr.menus) {
const promptId = `ivr-menu-${menu.id}`;
await promptCache.generatePrompt(promptId, menu.promptText, menu.promptVoice || 'af_bella');
}
}
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
})
.catch((e) => log(`[codec] init failed: ${e}`));
});
}
// ---------------------------------------------------------------------------
// Web UI
@@ -300,33 +404,92 @@ initWebUi(
getStatus,
log,
(number, deviceId, providerId) => {
const call = callManager.createOutboundCall(number, deviceId, providerId);
return call ? { id: call.id } : null;
// Outbound calls from dashboard — send make_call command to Rust.
log(`[dashboard] start call: ${number} device=${deviceId || 'any'} provider=${providerId || 'auto'}`);
// Fire-and-forget — the async result comes via events.
makeCall(number, deviceId, providerId).then((callId) => {
if (callId) {
log(`[dashboard] call started: ${callId}`);
activeCalls.set(callId, {
id: callId,
direction: 'outbound',
callerNumber: null,
calleeNumber: number,
providerUsed: providerId || null,
state: 'setting-up',
startedAt: Date.now(),
});
} else {
log(`[dashboard] call failed for ${number}`);
}
});
// Return a temporary ID so the frontend doesn't show "failed" immediately.
return { id: `pending-${Date.now()}` };
},
(callId) => {
hangupCall(callId);
return true;
},
(callId) => callManager.hangup(callId),
() => {
// Reload config after UI save.
// Config saved — reconfigure Rust engine.
try {
const fresh = loadConfig();
Object.assign(appConfig, fresh);
// Sync provider registrations: add new, remove deleted, re-register changed.
syncProviderStates(
fresh.providers,
proxy.publicIpSeed,
LAN_IP,
LAN_PORT,
(buf, dest) => sock.send(buf, dest.port, dest.address),
log,
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
);
log('[config] reloaded config after save');
// Update shadow state.
for (const p of fresh.providers) {
if (!providerStatuses.has(p.id)) {
providerStatuses.set(p.id, {
id: p.id, displayName: p.displayName, registered: false, publicIp: null,
});
}
}
for (const d of fresh.devices) {
if (!deviceStatuses.has(d.id)) {
deviceStatuses.set(d.id, {
id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false,
});
}
}
// Re-send config to Rust.
configureProxyEngine({
proxy: fresh.proxy,
providers: fresh.providers,
devices: fresh.devices,
routing: fresh.routing,
}).then((ok) => {
if (ok) log('[config] reloaded — proxy engine reconfigured');
else log('[config] reload failed — proxy engine rejected config');
});
} catch (e: any) {
log(`[config] reload failed: ${e.message}`);
}
},
callManager,
undefined, // callManager — legacy, replaced by Rust proxy-engine
voiceboxManager,
// WebRTC signaling → forwarded to Rust proxy-engine.
async (sessionId, sdp, ws) => {
log(`[webrtc] offer from browser session=${sessionId.slice(0, 8)}`);
const result = await webrtcOffer(sessionId, sdp);
if (result?.sdp) {
ws.send(JSON.stringify({ type: 'webrtc-answer', sessionId, sdp: result.sdp }));
log(`[webrtc] answer sent to browser session=${sessionId.slice(0, 8)}`);
}
},
async (sessionId, candidate) => {
await webrtcIce(sessionId, candidate);
},
async (sessionId) => {
await webrtcClose(sessionId);
},
);
process.on('SIGINT', () => { log('SIGINT, exiting'); process.exit(0); });
process.on('SIGTERM', () => { log('SIGTERM, exiting'); process.exit(0); });
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
startProxyEngine();
process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); });
process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: 'siprouter',
version: '1.10.0',
version: '1.12.0',
description: 'undefined'
}