diff --git a/changelog.md b/changelog.md index 07e15b8..001c68c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 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 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 012a289..ab8bc33 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -111,6 +111,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "byte-slice-cast" version = "1.2.3" @@ -203,6 +212,16 @@ dependencies = [ "cc", ] +[[package]] +name = "codec-lib" +version = "0.1.0" +dependencies = [ + "audiopus", + "ezk-g722", + "nnnoiseless", + "rubato", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -246,6 +265,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dary_heap" version = "0.3.8" @@ -271,6 +300,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -505,6 +544,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "generic_singleton" version = "0.5.3" @@ -796,12 +845,33 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -952,11 +1022,8 @@ dependencies = [ name = "opus-codec" version = "0.2.0" dependencies = [ - "audiopus", "base64", - "ezk-g722", - "nnnoiseless", - "rubato", + "codec-lib", "serde", "serde_json", ] @@ -1180,6 +1247,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proxy-engine" +version = "0.1.0" +dependencies = [ + "base64", + "codec-lib", + "regex-lite", + "serde", + "serde_json", + "sip-proto", + "tokio", +] + [[package]] name = "quote" version = "1.0.45" @@ -1295,6 +1375,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -1479,6 +1565,24 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "sip-proto" +version = "0.1.0" +dependencies = [ + "md-5", + "rand 0.8.5", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -1506,6 +1610,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "socks" version = "0.3.4" @@ -1592,8 +1706,15 @@ version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys", ] [[package]] @@ -1646,6 +1767,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index c68797b..c32663c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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] diff --git a/rust/crates/codec-lib/Cargo.toml b/rust/crates/codec-lib/Cargo.toml new file mode 100644 index 0000000..10ba784 --- /dev/null +++ b/rust/crates/codec-lib/Cargo.toml @@ -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 } diff --git a/rust/crates/codec-lib/src/lib.rs b/rust/crates/codec-lib/src/lib.rs new file mode 100644 index 0000000..dfa4f0a --- /dev/null +++ b/rust/crates/codec-lib/src/lib.rs @@ -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>, + /// ML noise suppression for the SIP-bound direction. + denoiser_to_sip: Box>, + /// ML noise suppression for the browser-bound direction. + denoiser_to_browser: Box>, +} + +impl TranscodeState { + /// Create a new transcoding session with fresh codec state. + pub fn new() -> Result { + 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, 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::::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 = 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 { + 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 = 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, 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, 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 = data.iter().map(|&b| mulaw_decode(b)).collect(); + Ok((pcm, 8000)) + } + PT_PCMA => { + let pcm: Vec = 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, 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 = (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); + } +} diff --git a/rust/crates/opus-codec/Cargo.toml b/rust/crates/opus-codec/Cargo.toml index 79cecd5..bbbb543 100644 --- a/rust/crates/opus-codec/Cargo.toml +++ b/rust/crates/opus-codec/Cargo.toml @@ -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 } diff --git a/rust/crates/opus-codec/src/main.rs b/rust/crates/opus-codec/src/main.rs index 2f21d3b..a9b2be9 100644 --- a/rust/crates/opus-codec/src/main.rs +++ b/rust/crates/opus-codec/src/main.rs @@ -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, error: Option<&str>) { +fn respond( + out: &mut impl Write, + id: &str, + success: bool, + result: Option, + 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>, - // 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>, - denoiser_to_browser: Box>, -} - -impl TranscodeState { - fn new() -> Result { - 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, 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::::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 = 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 { - 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 = 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, 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, 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 = data.iter().map(|&b| mulaw_decode(b)).collect(); - Ok((pcm, 8000)) - } - PT_PCMA => { - let pcm: Vec = 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, 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 = None; - // Per-session codec state for concurrent call isolation. let mut sessions: HashMap = 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 = data.chunks_exact(2) + let pcm: Vec = 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)), + ), } } } diff --git a/rust/crates/proxy-engine/Cargo.toml b/rust/crates/proxy-engine/Cargo.toml new file mode 100644 index 0000000..c48afc4 --- /dev/null +++ b/rust/crates/proxy-engine/Cargo.toml @@ -0,0 +1,17 @@ +[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" diff --git a/rust/crates/proxy-engine/src/call.rs b/rust/crates/proxy-engine/src/call.rs new file mode 100644 index 0000000..686bcdf --- /dev/null +++ b/rust/crates/proxy-engine/src/call.rs @@ -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, + pub callee_number: Option, + pub provider_id: String, + + // Provider side. + pub provider_addr: SocketAddr, + pub provider_media: Option, + + // Device side. + pub device_addr: SocketAddr, + pub device_media: Option, + + // RTP relay. + pub rtp_port: u16, + pub rtp_socket: Arc, + + // 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, + }) + } +} diff --git a/rust/crates/proxy-engine/src/call_manager.rs b/rust/crates/proxy-engine/src/call_manager.rs new file mode 100644 index 0000000..db33685 --- /dev/null +++ b/rust/crates/proxy-engine/src/call_manager.rs @@ -0,0 +1,578 @@ +//! 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, + /// 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!("")); + } + 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 { + 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 for now). + let device_addr = match self.resolve_first_device(config, registrar) { + Some(addr) => addr, + None => { + // No device available — could route to voicemail + // For now, send 480 Temporarily Unavailable. + let resp = SipMessage::create_response(480, "Temporarily Unavailable", invite, None); + let _ = socket.send_to(&resp.serialize(), from_addr).await; + return None; + } + }; + + // 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!("")); + + 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 { + 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!("")); + + // 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 { + 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) + } + + // --- Internal helpers --- + + fn resolve_first_device(&self, config: &AppConfig, registrar: &Registrar) -> Option { + for device in &config.devices { + if let Some(addr) = registrar.get_device_contact(&device.id) { + return Some(addr); + } + } + None + } +} + +/// 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, + 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 = None; + let mut learned_provider: Option = 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; + } + } + } +} diff --git a/rust/crates/proxy-engine/src/config.rs b/rust/crates/proxy-engine/src/config.rs new file mode 100644 index 0000000..6de94c8 --- /dev/null +++ b/rust/crates/proxy-engine/src/config.rs @@ -0,0 +1,315 @@ +//! 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 { + pub fn to_socket_addr(&self) -> Option { + format!("{}:{}", self.address, self.port).parse().ok() + } +} + +/// 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, + #[serde(rename = "silenceMaxPackets")] + pub silence_max_packets: Option, +} + +/// 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, + 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, + #[serde(rename = "callerPattern")] + pub caller_pattern: Option, + #[serde(rename = "sourceProvider")] + pub source_provider: Option, + #[serde(rename = "sourceDevice")] + pub source_device: Option, +} + +/// Route action. +#[derive(Debug, Clone, Deserialize)] +pub struct RouteAction { + pub targets: Option>, + #[serde(rename = "ringBrowsers")] + pub ring_browsers: Option, + #[serde(rename = "voicemailBox")] + pub voicemail_box: Option, + #[serde(rename = "ivrMenuId")] + pub ivr_menu_id: Option, + #[serde(rename = "noAnswerTimeout")] + pub no_answer_timeout: Option, + pub provider: Option, + #[serde(rename = "failoverProviders")] + pub failover_providers: Option>, + #[serde(rename = "stripPrefix")] + pub strip_prefix: Option, + #[serde(rename = "prependPrefix")] + pub prepend_prefix: Option, +} + +/// 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, + #[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, + pub devices: Vec, + pub routing: RoutingConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RoutingConfig { + pub routes: Vec, +} + +// --------------------------------------------------------------------------- +// 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, + pub ring_browsers: bool, + pub voicemail_box: Option, + pub ivr_menu_id: Option, + pub no_answer_timeout: Option, +} + +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 { + 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, + } + } +} diff --git a/rust/crates/proxy-engine/src/dtmf.rs b/rust/crates/proxy-engine/src/dtmf.rs new file mode 100644 index 0000000..b874149 --- /dev/null +++ b/rust/crates/proxy-engine/src/dtmf.rs @@ -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, + current_event_ts: Option, + 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 { + 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 { + 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 { + 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 + } +} diff --git a/rust/crates/proxy-engine/src/ipc.rs b/rust/crates/proxy-engine/src/ipc.rs new file mode 100644 index 0000000..720e5cd --- /dev/null +++ b/rust/crates/proxy-engine/src/ipc.rs @@ -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; + +/// 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, 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()); +} diff --git a/rust/crates/proxy-engine/src/main.rs b/rust/crates/proxy-engine/src/main.rs new file mode 100644 index 0000000..51b5a6f --- /dev/null +++ b/rust/crates/proxy-engine/src/main.rs @@ -0,0 +1,440 @@ +/// 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 call; +mod call_manager; +mod config; +mod dtmf; +mod ipc; +mod provider; +mod registrar; +mod rtp; +mod sip_transport; + +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 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, + transport: Option, + provider_mgr: ProviderManager, + registrar: Registrar, + call_mgr: CallManager, + rtp_pool: Option, + 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()), + 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::(); + + // 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>, 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, + "get_status" => handle_get_status(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>, 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>, + 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, ®istrar_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>, 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 the `hangup` command. +async fn handle_hangup(engine: Arc>, 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")); + } +} diff --git a/rust/crates/proxy-engine/src/provider.rs b/rust/crates/proxy-engine/src/provider.rs new file mode 100644 index 0000000..b34c184 --- /dev/null +++ b/rust/crates/proxy-engine/src/provider.rs @@ -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, + 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 { + 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!( + "", + 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> { + 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!( + "", + 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>>, + 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, + ) { + 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>> { + 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::() { + 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>, + socket: Arc, + _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(®ister_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(®ister_buf, dest).await; + } + } +} diff --git a/rust/crates/proxy-engine/src/registrar.rs b/rust/crates/proxy-engine/src/registrar.rs new file mode 100644 index 0000000..2f6e5ac --- /dev/null +++ b/rust/crates/proxy-engine/src/registrar.rs @@ -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, + /// Currently registered devices, keyed by device ID. + registered: HashMap, + 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> { + 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!("", 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 { + 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 { + 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 + } +} diff --git a/rust/crates/proxy-engine/src/rtp.rs b/rust/crates/proxy-engine/src/rtp.rs new file mode 100644 index 0000000..30cf69b --- /dev/null +++ b/rust/crates/proxy-engine/src/rtp.rs @@ -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, +} + +/// RTP port pool — allocates even-numbered UDP ports. +pub struct RtpPortPool { + min: u16, + max: u16, + allocated: HashMap>, +} + +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 { + 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, + pub remote_addr: Option, + /// If set, transcode packets using this codec session before forwarding. + pub transcode: Option, + /// 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) -> 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 { + // 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 { + // 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(×tamp.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, + } +} diff --git a/rust/crates/proxy-engine/src/sip_transport.rs b/rust/crates/proxy-engine/src/sip_transport.rs new file mode 100644 index 0000000..cf5036a --- /dev/null +++ b/rust/crates/proxy-engine/src/sip_transport.rs @@ -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, +} + +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 { + 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 { + self.socket.clone() + } + + /// Send a raw SIP message to a destination. + pub async fn send_to(&self, data: &[u8], dest: SocketAddr) -> Result { + 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 { + 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( + &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; + } + } + } + }); + } +} diff --git a/rust/crates/sip-proto/Cargo.toml b/rust/crates/sip-proto/Cargo.toml new file mode 100644 index 0000000..8febbb6 --- /dev/null +++ b/rust/crates/sip-proto/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "sip-proto" +version = "0.1.0" +edition = "2021" + +[dependencies] +md-5 = "0.10" +rand = "0.8" diff --git a/rust/crates/sip-proto/src/dialog.rs b/rust/crates/sip-proto/src/dialog.rs new file mode 100644 index 0000000..9f9bb44 --- /dev/null +++ b/rust/crates/sip-proto/src/dialog.rs @@ -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, + pub local_uri: String, + pub remote_uri: String, + pub local_cseq: u32, + pub remote_cseq: u32, + pub route_set: Vec, + 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 = 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>, + ) -> 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!("", 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("".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("".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")); + } +} diff --git a/rust/crates/sip-proto/src/helpers.rs b/rust/crates/sip-proto/src/helpers.rs new file mode 100644 index 0000000..225ae41 --- /dev/null +++ b/rust/crates/sip-proto/src/helpers.rs @@ -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::())).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 = 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, + pub opaque: Option, + pub qop: Option, +} + +/// Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value. +pub fn parse_digest_challenge(header: &str) -> Option { + let lower = header.to_ascii_lowercase(); + if !lower.starts_with("digest ") { + return None; + } + let params = &header[7..]; + + let get = |key: &str| -> Option { + // Try quoted value first. + let pat = format!("{}=", key); + if let Some(pos) = params.to_ascii_lowercase().find(&pat) { + let after = ¶ms[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 { + let mut addr: Option<&str> = None; + let mut port: Option = 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"); + } +} diff --git a/rust/crates/sip-proto/src/lib.rs b/rust/crates/sip-proto/src/lib.rs new file mode 100644 index 0000000..0b71bc3 --- /dev/null +++ b/rust/crates/sip-proto/src/lib.rs @@ -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, +} diff --git a/rust/crates/sip-proto/src/message.rs b/rust/crates/sip-proto/src/message.rs new file mode 100644 index 0000000..14a00c7 --- /dev/null +++ b/rust/crates/sip-proto/src/message.rs @@ -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 { + 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 { + 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 { + 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, + ) -> 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, + pub via_branch: Option, + pub from_uri: String, + pub from_display_name: Option, + pub from_tag: Option, + pub to_uri: String, + pub to_display_name: Option, + pub to_tag: Option, + pub call_id: Option, + pub cseq: Option, + pub contact: Option, + pub max_forwards: Option, + pub body: Option, + pub content_type: Option, + pub extra_headers: Option>, +} + +/// Options for `SipMessage::create_response`. +#[derive(Default)] +pub struct ResponseOptions { + pub to_tag: Option, + pub contact: Option, + pub body: Option, + pub content_type: Option, + pub extra_headers: Option>, +} + +/// 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: ;tag=abc\r\n\ + To: \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: ;tag=abc\r\n\ + To: ;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(";tag=abc123"), + Some("abc123") + ); + assert_eq!(SipMessage::extract_tag(""), None); + assert_eq!( + SipMessage::extract_uri(""), + Some("sip:user@host") + ); + assert_eq!( + SipMessage::extract_uri("\"Name\" ;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("".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()); + } +} diff --git a/rust/crates/sip-proto/src/rewrite.rs b/rust/crates/sip-proto/src/rewrite.rs new file mode 100644 index 0000000..f890dcc --- /dev/null +++ b/rust/crates/sip-proto/src/rewrite.rs @@ -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) { + let mut orig_addr: Option = None; + let mut orig_port: Option = None; + + let lines: Vec = 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 = ""; + let result = rewrite_sip_uri(input, "192.168.1.1", 5070); + assert_eq!(result, ""); + } + + #[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); + } +} diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 04ca5e2..414db0b 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.10.0', + version: '1.11.0', description: 'undefined' } diff --git a/ts/proxybridge.ts b/ts/proxybridge.ts new file mode 100644 index 0000000..64591e5 --- /dev/null +++ b/ts/proxybridge.ts @@ -0,0 +1,196 @@ +/** + * 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; + result: { bound: string }; + }; + hangup: { + params: { call_id: string }; + result: Record; + }; + 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; + }; + start_recording: { + params: { call_id: string; file_path: string; max_duration_ms?: number }; + result: Record; + }; + 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 | 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 { + if (initialized && bridge) return true; + logFn = log; + + try { + bridge = new RustBridge({ + 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): Promise { + 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; + } +} + +/** + * Send a hangup command. + */ +export async function hangupCall(callId: string): Promise { + if (!bridge || !initialized) return false; + try { + await bridge.sendCommand('hangup', { call_id: callId } as any); + return true; + } catch { + return false; + } +} + +/** + * 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; + } +} diff --git a/ts/sipproxy.ts b/ts/sipproxy.ts index 9e25bbf..c2fe010 100644 --- a/ts/sipproxy.ts +++ b/ts/sipproxy.ts @@ -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,28 @@ 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, + shutdownProxyEngine, +} 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 +69,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(); +const deviceStatuses = new Map(); +const activeCalls = new Map(); +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 +152,176 @@ 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, - }); - } - 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: [...deviceStatuses.values()], + 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 { + 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('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}`); + }); + + // 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 @@ -299,34 +330,62 @@ sock.bind(LAN_PORT, '0.0.0.0', () => { initWebUi( getStatus, log, - (number, deviceId, providerId) => { - const call = callManager.createOutboundCall(number, deviceId, providerId); - return call ? { id: call.id } : null; + (number, _deviceId, _providerId) => { + // Outbound calls from dashboard — send make_call command to Rust. + // For now, log only. Full implementation needs make_call in Rust. + log(`[dashboard] start call requested: ${number}`); + // TODO: send make_call command when implemented in Rust + return null; + }, + (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 — WebRTC calls handled separately in Phase 2 voiceboxManager, ); -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); }); diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 04ca5e2..414db0b 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.10.0', + version: '1.11.0', description: 'undefined' }