feat(rust-proxy-engine): add a Rust SIP proxy engine with shared SIP and codec libraries

This commit is contained in:
2026-04-10 09:57:27 +00:00
parent f3b18a7170
commit 3132ba8cbb
28 changed files with 5042 additions and 548 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # 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) ## 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 add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management

135
rust/Cargo.lock generated
View File

@@ -111,6 +111,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 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]] [[package]]
name = "byte-slice-cast" name = "byte-slice-cast"
version = "1.2.3" version = "1.2.3"
@@ -203,6 +212,16 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "codec-lib"
version = "0.1.0"
dependencies = [
"audiopus",
"ezk-g722",
"nnnoiseless",
"rubato",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -246,6 +265,16 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "dary_heap" name = "dary_heap"
version = "0.3.8" version = "0.3.8"
@@ -271,6 +300,16 @@ dependencies = [
"powerfmt", "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]] [[package]]
name = "downcast-rs" name = "downcast-rs"
version = "1.2.1" version = "1.2.1"
@@ -505,6 +544,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "generic_singleton" name = "generic_singleton"
version = "0.5.3" version = "0.5.3"
@@ -796,12 +845,33 @@ dependencies = [
"rawpointer", "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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 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]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.18" version = "0.2.18"
@@ -952,11 +1022,8 @@ dependencies = [
name = "opus-codec" name = "opus-codec"
version = "0.2.0" version = "0.2.0"
dependencies = [ dependencies = [
"audiopus",
"base64", "base64",
"ezk-g722", "codec-lib",
"nnnoiseless",
"rubato",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -1180,6 +1247,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "proxy-engine"
version = "0.1.0"
dependencies = [
"base64",
"codec-lib",
"regex-lite",
"serde",
"serde_json",
"sip-proto",
"tokio",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -1295,6 +1375,12 @@ dependencies = [
"regex-syntax", "regex-syntax",
] ]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.10" version = "0.8.10"
@@ -1479,6 +1565,24 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 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]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.2" version = "1.0.2"
@@ -1506,6 +1610,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 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]] [[package]]
name = "socks" name = "socks"
version = "0.3.4" version = "0.3.4"
@@ -1592,8 +1706,15 @@ version = "1.51.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
dependencies = [ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros", "tokio-macros",
"windows-sys",
] ]
[[package]] [[package]]
@@ -1646,6 +1767,12 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"

View File

@@ -1,5 +1,11 @@
[workspace] [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" resolver = "2"
[profile.release] [profile.release]

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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"

View File

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

View File

@@ -0,0 +1,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<String, PassthroughCall>,
/// Call ID counter.
next_call_num: u64,
/// Output channel for events.
out_tx: OutTx,
}
impl CallManager {
pub fn new(out_tx: OutTx) -> Self {
Self {
calls: HashMap::new(),
next_call_num: 0,
out_tx,
}
}
/// Generate a unique call ID.
fn next_call_id(&mut self) -> String {
let id = format!(
"call-{}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
self.next_call_num,
);
self.next_call_num += 1;
id
}
/// Try to route a SIP message to an existing call.
/// Returns true if handled.
pub async fn route_sip_message(
&mut self,
msg: &SipMessage,
from_addr: SocketAddr,
socket: &UdpSocket,
config: &AppConfig,
_registrar: &Registrar,
) -> bool {
let sip_call_id = msg.call_id().to_string();
// Check if this Call-ID belongs to an active call.
if !self.calls.contains_key(&sip_call_id) {
return false;
}
// Extract needed data from the call to avoid borrow conflicts.
let (call_id, provider_addr, device_addr, rtp_port, from_provider) = {
let call = self.calls.get(&sip_call_id).unwrap();
let from_provider = from_addr.ip().to_string() == call.provider_addr.ip().to_string();
(
call.id.clone(),
call.provider_addr,
call.device_addr,
call.rtp_port,
from_provider,
)
};
let lan_ip = config.proxy.lan_ip.clone();
let lan_port = config.proxy.lan_port;
if msg.is_request() {
let method = msg.method().unwrap_or("");
let forward_to = if from_provider { device_addr } else { provider_addr };
// Handle BYE.
if method == "BYE" {
let ok = SipMessage::create_response(200, "OK", msg, None);
let _ = socket.send_to(&ok.serialize(), from_addr).await;
let _ = socket.send_to(&msg.serialize(), forward_to).await;
let duration = self.calls.get(&sip_call_id).unwrap().duration_secs();
emit_event(
&self.out_tx,
"call_ended",
serde_json::json!({
"call_id": call_id,
"reason": "bye",
"duration": duration,
"from_side": if from_provider { "provider" } else { "device" },
}),
);
self.calls.get_mut(&sip_call_id).unwrap().state = CallState::Terminated;
return true;
}
// Handle CANCEL.
if method == "CANCEL" {
let ok = SipMessage::create_response(200, "OK", msg, None);
let _ = socket.send_to(&ok.serialize(), from_addr).await;
let _ = socket.send_to(&msg.serialize(), forward_to).await;
let duration = self.calls.get(&sip_call_id).unwrap().duration_secs();
emit_event(
&self.out_tx,
"call_ended",
serde_json::json!({
"call_id": call_id, "reason": "cancel", "duration": duration,
}),
);
self.calls.get_mut(&sip_call_id).unwrap().state = CallState::Terminated;
return true;
}
// Handle INFO (DTMF relay).
if method == "INFO" {
let ok = SipMessage::create_response(200, "OK", msg, None);
let _ = socket.send_to(&ok.serialize(), from_addr).await;
// Detect DTMF from INFO body.
if let Some(ct) = msg.get_header("Content-Type") {
let mut detector = DtmfDetector::new(call_id.clone(), self.out_tx.clone());
detector.process_sip_info(ct, &msg.body);
}
return true;
}
// Forward other requests with SDP rewriting.
let mut fwd = msg.clone();
if from_provider {
rewrite_sdp_for_device(&mut fwd, &lan_ip, rtp_port);
if let Some(ruri) = fwd.request_uri().map(|s| s.to_string()) {
let new_ruri = rewrite_sip_uri(&ruri, &device_addr.ip().to_string(), device_addr.port());
fwd.set_request_uri(&new_ruri);
}
} else {
rewrite_sdp_for_provider(&mut fwd, &lan_ip, rtp_port);
}
if fwd.is_dialog_establishing() {
fwd.prepend_header("Record-Route", &format!("<sip:{lan_ip}:{lan_port};lr>"));
}
let _ = socket.send_to(&fwd.serialize(), forward_to).await;
return true;
}
// --- Responses ---
if msg.is_response() {
let code = msg.status_code().unwrap_or(0);
let cseq_method = msg.cseq_method().unwrap_or("").to_uppercase();
let forward_to = if from_provider { device_addr } else { provider_addr };
let mut fwd = msg.clone();
if from_provider {
rewrite_sdp_for_device(&mut fwd, &lan_ip, rtp_port);
} else {
rewrite_sdp_for_provider(&mut fwd, &lan_ip, rtp_port);
if let Some(contact) = fwd.get_header("Contact").map(|s| s.to_string()) {
let new_contact = rewrite_sip_uri(&contact, &lan_ip, lan_port);
if new_contact != contact {
fwd.set_header("Contact", &new_contact);
}
}
}
// State transitions.
if cseq_method == "INVITE" {
let call = self.calls.get_mut(&sip_call_id).unwrap();
if (code == 180 || code == 183) && call.state == CallState::SettingUp {
call.state = CallState::Ringing;
emit_event(&self.out_tx, "call_ringing", serde_json::json!({ "call_id": call_id }));
} else if code >= 200 && code < 300 {
call.state = CallState::Connected;
emit_event(&self.out_tx, "call_answered", serde_json::json!({ "call_id": call_id }));
} else if code >= 300 {
let duration = call.duration_secs();
call.state = CallState::Terminated;
emit_event(
&self.out_tx,
"call_ended",
serde_json::json!({
"call_id": call_id,
"reason": format!("rejected_{code}"),
"duration": duration,
}),
);
}
}
let _ = socket.send_to(&fwd.serialize(), forward_to).await;
return true;
}
false
}
/// Create an inbound passthrough call (provider → device).
pub async fn create_inbound_call(
&mut self,
invite: &SipMessage,
from_addr: SocketAddr,
provider_id: &str,
provider_config: &ProviderConfig,
config: &AppConfig,
registrar: &Registrar,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
) -> Option<String> {
let call_id = self.next_call_id();
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
// Extract caller/callee info.
let from_header = invite.get_header("From").unwrap_or("");
let caller_number = SipMessage::extract_uri(from_header)
.unwrap_or("Unknown")
.to_string();
let called_number = invite
.request_uri()
.and_then(|uri| SipMessage::extract_uri(uri))
.unwrap_or("")
.to_string();
// Resolve target device (first registered device 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!("<sip:{lan_ip}:{lan_port};lr>"));
if fwd_invite.has_sdp_body() {
let (new_body, original) = rewrite_sdp(&fwd_invite.body, lan_ip, rtp_alloc.port);
fwd_invite.body = new_body;
fwd_invite.update_content_length();
if let Some(ep) = original {
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
call.provider_media = Some(addr);
}
}
}
let _ = socket.send_to(&fwd_invite.serialize(), device_addr).await;
// Store the call.
self.calls.insert(call.sip_call_id.clone(), call);
Some(call_id)
}
/// Create an outbound passthrough call (device → provider).
pub async fn create_outbound_passthrough(
&mut self,
invite: &SipMessage,
from_addr: SocketAddr,
provider_config: &ProviderConfig,
config: &AppConfig,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
) -> Option<String> {
let call_id = self.next_call_id();
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
let callee = invite.request_uri().unwrap_or("").to_string();
// Allocate RTP port.
let rtp_alloc = match rtp_pool.allocate().await {
Some(a) => a,
None => return None,
};
let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() {
Some(a) => a,
None => return None,
};
let mut call = PassthroughCall {
id: call_id.clone(),
sip_call_id: invite.call_id().to_string(),
state: CallState::SettingUp,
direction: CallDirection::Outbound,
created_at: Instant::now(),
caller_number: None,
callee_number: Some(callee),
provider_id: provider_config.id.clone(),
provider_addr: provider_dest,
provider_media: None,
device_addr: from_addr,
device_media: None,
rtp_port: rtp_alloc.port,
rtp_socket: rtp_alloc.socket.clone(),
pkt_from_device: 0,
pkt_from_provider: 0,
};
// Start RTP relay.
let rtp_socket = rtp_alloc.socket.clone();
let device_addr_for_relay = from_addr;
let provider_addr_for_relay = provider_dest;
tokio::spawn(async move {
rtp_relay_loop(rtp_socket, device_addr_for_relay, provider_addr_for_relay).await;
});
// Rewrite and forward INVITE to provider.
let mut fwd_invite = invite.clone();
fwd_invite.prepend_header("Record-Route", &format!("<sip:{lan_ip}:{lan_port};lr>"));
// Rewrite Contact to public IP.
if let Some(contact) = fwd_invite.get_header("Contact").map(|s| s.to_string()) {
let new_contact = rewrite_sip_uri(&contact, pub_ip, lan_port);
if new_contact != contact {
fwd_invite.set_header("Contact", &new_contact);
}
}
// Rewrite SDP.
if fwd_invite.has_sdp_body() {
let (new_body, original) = rewrite_sdp(&fwd_invite.body, pub_ip, rtp_alloc.port);
fwd_invite.body = new_body;
fwd_invite.update_content_length();
if let Some(ep) = original {
if let Ok(addr) = format!("{}:{}", ep.address, ep.port).parse() {
call.device_media = Some(addr);
}
}
}
let _ = socket.send_to(&fwd_invite.serialize(), provider_dest).await;
self.calls.insert(call.sip_call_id.clone(), call);
Some(call_id)
}
/// Hangup a call by call ID (from TypeScript command).
pub async fn hangup(&mut self, call_id: &str, socket: &UdpSocket) -> bool {
// Find the call by our internal call ID.
let sip_call_id = self
.calls
.iter()
.find(|(_, c)| c.id == call_id)
.map(|(k, _)| k.clone());
let sip_call_id = match sip_call_id {
Some(id) => id,
None => return false,
};
let call = match self.calls.get_mut(&sip_call_id) {
Some(c) => c,
None => return false,
};
if call.state == CallState::Terminated {
return false;
}
// Build and send BYE to both sides.
// For passthrough, we build a simple BYE using the SIP Call-ID.
let bye_msg = format!(
"BYE sip:hangup SIP/2.0\r\n\
Via: SIP/2.0/UDP 0.0.0.0:0;branch=z9hG4bK-hangup\r\n\
Call-ID: {}\r\n\
CSeq: 99 BYE\r\n\
Max-Forwards: 70\r\n\
Content-Length: 0\r\n\r\n",
sip_call_id
);
let bye_bytes = bye_msg.as_bytes();
let _ = socket.send_to(bye_bytes, call.provider_addr).await;
let _ = socket.send_to(bye_bytes, call.device_addr).await;
call.state = CallState::Terminated;
emit_event(
&self.out_tx,
"call_ended",
serde_json::json!({
"call_id": call.id,
"reason": "hangup_command",
"duration": call.duration_secs(),
}),
);
true
}
/// Get all active call statuses.
pub fn get_all_statuses(&self) -> Vec<serde_json::Value> {
self.calls
.values()
.filter(|c| c.state != CallState::Terminated)
.map(|c| c.to_status_json())
.collect()
}
/// Clean up terminated calls.
pub fn cleanup_terminated(&mut self) {
self.calls.retain(|_, c| c.state != CallState::Terminated);
}
/// Check if a SIP Call-ID belongs to any active call.
pub fn has_call(&self, sip_call_id: &str) -> bool {
self.calls.contains_key(sip_call_id)
}
// --- Internal helpers ---
fn resolve_first_device(&self, config: &AppConfig, registrar: &Registrar) -> Option<SocketAddr> {
for device in &config.devices {
if let Some(addr) = registrar.get_device_contact(&device.id) {
return Some(addr);
}
}
None
}
}
/// Rewrite SDP for provider→device direction (use LAN IP).
fn rewrite_sdp_for_device(msg: &mut SipMessage, lan_ip: &str, rtp_port: u16) {
if msg.has_sdp_body() {
let (new_body, _original) = rewrite_sdp(&msg.body, lan_ip, rtp_port);
msg.body = new_body;
msg.update_content_length();
}
}
/// Rewrite SDP for device→provider direction (use public IP).
fn rewrite_sdp_for_provider(msg: &mut SipMessage, pub_ip: &str, rtp_port: u16) {
if msg.has_sdp_body() {
let (new_body, _original) = rewrite_sdp(&msg.body, pub_ip, rtp_port);
msg.body = new_body;
msg.update_content_length();
}
}
/// Bidirectional RTP relay loop.
/// Receives packets on the relay socket and forwards based on source address.
async fn rtp_relay_loop(
socket: Arc<UdpSocket>,
device_addr: SocketAddr,
provider_addr: SocketAddr,
) {
let mut buf = vec![0u8; 65535];
let device_ip = device_addr.ip().to_string();
let provider_ip = provider_addr.ip().to_string();
// Track learned media endpoints (may differ from signaling addresses).
let mut learned_device: Option<SocketAddr> = None;
let mut learned_provider: Option<SocketAddr> = None;
loop {
match socket.recv_from(&mut buf).await {
Ok((n, from)) => {
let data = &buf[..n];
let from_ip = from.ip().to_string();
if from_ip == device_ip || learned_device.map(|d| d == from).unwrap_or(false) {
// From device → forward to provider.
if learned_device.is_none() {
learned_device = Some(from);
}
if let Some(target) = learned_provider {
let _ = socket.send_to(data, target).await;
} else {
// Provider media not yet learned; try signaling address.
let _ = socket.send_to(data, provider_addr).await;
}
} else if from_ip == provider_ip
|| learned_provider.map(|p| p == from).unwrap_or(false)
{
// From provider → forward to device.
if learned_provider.is_none() {
learned_provider = Some(from);
}
if let Some(target) = learned_device {
let _ = socket.send_to(data, target).await;
} else {
let _ = socket.send_to(data, device_addr).await;
}
} else {
// Unknown source — try to identify by known device addresses.
// For now, assume it's the device if not from provider IP range.
if learned_device.is_none() {
learned_device = Some(from);
}
}
}
Err(_) => {
// Socket closed or error — exit relay.
break;
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<AppConfig>,
transport: Option<SipTransport>,
provider_mgr: ProviderManager,
registrar: Registrar,
call_mgr: CallManager,
rtp_pool: Option<RtpPortPool>,
out_tx: OutTx,
}
impl ProxyEngine {
fn new(out_tx: OutTx) -> Self {
Self {
config: None,
transport: None,
provider_mgr: ProviderManager::new(out_tx.clone()),
registrar: Registrar::new(out_tx.clone()),
call_mgr: CallManager::new(out_tx.clone()),
rtp_pool: None,
out_tx,
}
}
}
#[tokio::main]
async fn main() {
// Output channel: all stdout writes go through here for serialization.
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
// Stdout writer task.
tokio::spawn(async move {
let mut stdout = tokio::io::stdout();
while let Some(line) = out_rx.recv().await {
let mut output = line.into_bytes();
output.push(b'\n');
if stdout.write_all(&output).await.is_err() {
break;
}
let _ = stdout.flush().await;
}
});
// Emit ready event.
emit_event(&out_tx, "ready", serde_json::json!({}));
// Shared engine state.
let engine = Arc::new(Mutex::new(ProxyEngine::new(out_tx.clone())));
// Read commands from stdin.
let stdin = tokio::io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
let cmd: Command = match serde_json::from_str(&line) {
Ok(c) => c,
Err(e) => {
respond_err(&out_tx, "", &format!("parse: {e}"));
continue;
}
};
let engine = engine.clone();
let out_tx = out_tx.clone();
// Handle commands — some are async, so we spawn.
tokio::spawn(async move {
handle_command(engine, &out_tx, cmd).await;
});
}
}
async fn handle_command(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: Command) {
match cmd.method.as_str() {
"configure" => handle_configure(engine, out_tx, &cmd).await,
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
"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<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let app_config: AppConfig = match serde_json::from_value(cmd.params.clone()) {
Ok(c) => c,
Err(e) => {
respond_err(out_tx, &cmd.id, &format!("bad config: {e}"));
return;
}
};
let mut eng = engine.lock().await;
let is_reconfigure = eng.transport.is_some();
let socket = if is_reconfigure {
// Reconfigure — socket already bound, just update subsystems.
eng.transport.as_ref().unwrap().socket()
} else {
// First configure — bind SIP transport.
let bind_addr = format!("0.0.0.0:{}", app_config.proxy.lan_port);
let transport = match SipTransport::bind(&bind_addr).await {
Ok(t) => t,
Err(e) => {
respond_err(out_tx, &cmd.id, &format!("SIP bind failed: {e}"));
return;
}
};
let socket = transport.socket();
// Start UDP receiver.
let engine_for_recv = engine.clone();
let socket_for_recv = socket.clone();
transport.spawn_receiver(move |data: &[u8], addr: SocketAddr| {
let engine = engine_for_recv.clone();
let socket = socket_for_recv.clone();
let data = data.to_vec();
tokio::spawn(async move {
handle_sip_packet(engine, &socket, &data, addr).await;
});
});
eng.transport = Some(transport);
// Initialize RTP port pool (only on first configure).
eng.rtp_pool = Some(RtpPortPool::new(
app_config.proxy.rtp_port_range.min,
app_config.proxy.rtp_port_range.max,
));
socket
};
// (Re)configure registrar.
eng.registrar.configure(&app_config.devices);
// (Re)configure provider registrations.
eng.provider_mgr
.configure(
&app_config.providers,
app_config.proxy.public_ip_seed.as_deref(),
&app_config.proxy.lan_ip,
app_config.proxy.lan_port,
socket,
)
.await;
let bind_info = format!("0.0.0.0:{}", app_config.proxy.lan_port);
eng.config = Some(app_config);
respond_ok(
out_tx,
&cmd.id,
serde_json::json!({
"bound": bind_info,
"reconfigure": is_reconfigure,
}),
);
}
/// Handle incoming SIP packets from the UDP socket.
/// This is the core routing pipeline — entirely in Rust.
async fn handle_sip_packet(
engine: Arc<Mutex<ProxyEngine>>,
socket: &UdpSocket,
data: &[u8],
from_addr: SocketAddr,
) {
let msg = match SipMessage::parse(data) {
Some(m) => m,
None => return, // Not a valid SIP message, ignore.
};
let mut eng = engine.lock().await;
// 1. Provider registration responses — consumed internally.
if msg.is_response() {
if eng.provider_mgr.handle_response(&msg, socket).await {
return;
}
}
// 2. Device REGISTER — handled by registrar.
let is_from_provider = eng
.provider_mgr
.find_by_address(&from_addr)
.await
.is_some();
if !is_from_provider && msg.method() == Some("REGISTER") {
if let Some(response_buf) = eng.registrar.handle_register(&msg, from_addr) {
let _ = socket.send_to(&response_buf, from_addr).await;
return;
}
}
// 3. Route to existing call by SIP Call-ID.
// Check if this Call-ID belongs to an active call (avoids borrow conflict).
if eng.call_mgr.has_call(msg.call_id()) {
let config_ref = eng.config.as_ref().unwrap().clone();
// Temporarily take registrar to avoid overlapping borrows.
let registrar_dummy = Registrar::new(eng.out_tx.clone());
if eng
.call_mgr
.route_sip_message(&msg, from_addr, socket, &config_ref, &registrar_dummy)
.await
{
return;
}
}
let config_ref = eng.config.as_ref().unwrap().clone();
// 4. New inbound INVITE from provider.
if is_from_provider && msg.is_request() && msg.method() == Some("INVITE") {
// Detect public IP from Via.
if let Some(via) = msg.get_header("Via") {
if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
let mut ps = ps_arc.lock().await;
ps.detect_public_ip(via);
}
}
// Send 100 Trying immediately.
let trying = SipMessage::create_response(100, "Trying", &msg, None);
let _ = socket.send_to(&trying.serialize(), from_addr).await;
// Determine provider info.
let (provider_id, provider_config, public_ip) =
if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
let ps = ps_arc.lock().await;
(
ps.config.id.clone(),
ps.config.clone(),
ps.public_ip.clone(),
)
} else {
return;
};
// Create the inbound call — Rust handles everything.
// Split borrows via destructuring to satisfy the borrow checker.
let ProxyEngine {
ref registrar,
ref mut call_mgr,
ref mut rtp_pool,
..
} = *eng;
let rtp_pool = rtp_pool.as_mut().unwrap();
let call_id = call_mgr
.create_inbound_call(
&msg,
from_addr,
&provider_id,
&provider_config,
&config_ref,
registrar,
rtp_pool,
socket,
public_ip.as_deref(),
)
.await;
if let Some(call_id) = call_id {
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
let from_header = msg.get_header("From").unwrap_or("");
let from_uri = SipMessage::extract_uri(from_header).unwrap_or("Unknown");
let called_number = msg
.request_uri()
.and_then(|uri| SipMessage::extract_uri(uri))
.unwrap_or("");
emit_event(
&eng.out_tx,
"incoming_call",
serde_json::json!({
"call_id": call_id,
"from_uri": from_uri,
"to_number": called_number,
"provider_id": provider_id,
}),
);
}
return;
}
// 5. New outbound INVITE from device.
if !is_from_provider && msg.is_request() && msg.method() == Some("INVITE") {
// Resolve outbound route.
let dialed_number = msg
.request_uri()
.and_then(|uri| SipMessage::extract_uri(uri))
.unwrap_or(msg.request_uri().unwrap_or(""))
.to_string();
let device = eng.registrar.find_by_address(&from_addr);
let device_id = device.map(|d| d.device_id.clone());
// Find provider via routing rules.
let route_result = config_ref.resolve_outbound_route(
&dialed_number,
device_id.as_deref(),
&|pid: &str| {
// Can't call async here — use a sync check.
// For now, assume all configured providers are available.
true
},
);
if let Some(route) = route_result {
let public_ip = if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
let ps = ps_arc.lock().await;
ps.public_ip.clone()
} else {
None
};
let ProxyEngine {
ref mut call_mgr,
ref mut rtp_pool,
..
} = *eng;
let rtp_pool = rtp_pool.as_mut().unwrap();
let call_id = call_mgr
.create_outbound_passthrough(
&msg,
from_addr,
&route.provider,
&config_ref,
rtp_pool,
socket,
public_ip.as_deref(),
)
.await;
if let Some(call_id) = call_id {
emit_event(
&eng.out_tx,
"outbound_device_call",
serde_json::json!({
"call_id": call_id,
"from_device": device_id,
"to_number": dialed_number,
}),
);
}
}
return;
}
// 6. Other messages — log for debugging.
let label = if msg.is_request() {
msg.method().unwrap_or("?").to_string()
} else {
msg.status_code().map(|c| c.to_string()).unwrap_or_default()
};
emit_event(
&eng.out_tx,
"sip_unhandled",
serde_json::json!({
"method_or_status": label,
"call_id": msg.call_id(),
"from_addr": from_addr.ip().to_string(),
"from_port": from_addr.port(),
"is_from_provider": is_from_provider,
}),
);
}
/// Handle `get_status` — return active call statuses from Rust.
async fn handle_get_status(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let eng = engine.lock().await;
let calls = eng.call_mgr.get_all_statuses();
respond_ok(out_tx, &cmd.id, serde_json::json!({ "calls": calls }));
}
/// Handle the `hangup` command.
async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) {
Some(id) => id.to_string(),
None => {
respond_err(out_tx, &cmd.id, "missing call_id");
return;
}
};
let mut eng = engine.lock().await;
let socket = match &eng.transport {
Some(t) => t.socket(),
None => {
respond_err(out_tx, &cmd.id, "not initialized");
return;
}
};
if eng.call_mgr.hangup(&call_id, &socket).await {
respond_ok(out_tx, &cmd.id, serde_json::json!({}));
} else {
respond_err(out_tx, &cmd.id, &format!("call {call_id} not found"));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

196
ts/proxybridge.ts Normal file
View File

@@ -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<string, unknown>;
result: { bound: string };
};
hangup: {
params: { call_id: string };
result: Record<string, never>;
};
make_call: {
params: { number: string; device_id?: string; provider_id?: string };
result: { call_id: string };
};
play_audio: {
params: { call_id: string; leg_id?: string; file_path: string; codec?: number };
result: Record<string, never>;
};
start_recording: {
params: { call_id: string; file_path: string; max_duration_ms?: number };
result: Record<string, never>;
};
stop_recording: {
params: { call_id: string };
result: { file_path: string; duration_ms: number };
};
};
// ---------------------------------------------------------------------------
// Event types from Rust
// ---------------------------------------------------------------------------
export interface IIncomingCallEvent {
call_id: string;
from_uri: string;
to_number: string;
provider_id: string;
}
export interface IOutboundCallEvent {
call_id: string;
from_device: string | null;
to_number: string;
}
export interface ICallEndedEvent {
call_id: string;
reason: string;
duration: number;
from_side?: string;
}
export interface IProviderRegisteredEvent {
provider_id: string;
registered: boolean;
public_ip: string | null;
}
export interface IDeviceRegisteredEvent {
device_id: string;
display_name: string;
address: string;
port: number;
aor: string;
expires: number;
}
// ---------------------------------------------------------------------------
// Bridge singleton
// ---------------------------------------------------------------------------
let bridge: RustBridge<TProxyCommands> | null = null;
let initialized = false;
let logFn: ((msg: string) => void) | undefined;
function buildLocalPaths(): string[] {
const root = process.cwd();
return [
path.join(root, 'dist_rust', 'proxy-engine'),
path.join(root, 'rust', 'target', 'release', 'proxy-engine'),
path.join(root, 'rust', 'target', 'debug', 'proxy-engine'),
];
}
/**
* Initialize the proxy engine — spawn the Rust binary.
* Call configure() separately to push config and start SIP.
*/
export async function initProxyEngine(log?: (msg: string) => void): Promise<boolean> {
if (initialized && bridge) return true;
logFn = log;
try {
bridge = new RustBridge<TProxyCommands>({
binaryName: 'proxy-engine',
localPaths: buildLocalPaths(),
});
const spawned = await bridge.spawn();
if (!spawned) {
log?.('[proxy-engine] failed to spawn binary');
bridge = null;
return false;
}
bridge.on('exit', () => {
logFn?.('[proxy-engine] process exited — will need re-init');
bridge = null;
initialized = false;
});
// Forward stderr for debugging.
bridge.on('stderr', (line: string) => {
logFn?.(`[proxy-engine:stderr] ${line}`);
});
initialized = true;
log?.('[proxy-engine] spawned and ready');
return true;
} catch (e: any) {
log?.(`[proxy-engine] init error: ${e.message}`);
bridge = null;
return false;
}
}
/**
* Send the full app config to the proxy engine.
* This binds the SIP socket, starts provider registrations, etc.
*/
export async function configureProxyEngine(config: Record<string, unknown>): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
const result = await bridge.sendCommand('configure', config as any);
logFn?.(`[proxy-engine] configured, SIP bound on ${(result as any)?.bound || '?'}`);
return true;
} catch (e: any) {
logFn?.(`[proxy-engine] configure error: ${e.message}`);
return false;
}
}
/**
* Send a hangup command.
*/
export async function hangupCall(callId: string): Promise<boolean> {
if (!bridge || !initialized) return false;
try {
await bridge.sendCommand('hangup', { call_id: callId } as any);
return true;
} catch {
return false;
}
}
/**
* Subscribe to an event from the proxy engine.
* Event names: incoming_call, outbound_device_call, call_ringing,
* call_answered, call_ended, provider_registered, device_registered,
* dtmf_digit, recording_done, sip_unhandled
*/
export function onProxyEvent(event: string, handler: (data: any) => void): void {
if (!bridge) throw new Error('proxy engine not initialized');
bridge.on(`management:${event}`, handler);
}
/** Check if the proxy engine is ready. */
export function isProxyReady(): boolean {
return initialized && bridge !== null;
}
/** Shut down the proxy engine. */
export function shutdownProxyEngine(): void {
if (bridge) {
try { bridge.kill(); } catch { /* ignore */ }
bridge = null;
initialized = false;
}
}

View File

@@ -1,39 +1,22 @@
/** /**
* SIP proxy — hub model entry point. * SIP proxy — entry point.
* *
* Thin bootstrap that wires together: * Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics.
* - UDP socket for all SIP signaling * TypeScript is the control plane:
* - CallManager (the hub model core) * - Loads config and pushes it to Rust
* - Provider registration * - Receives high-level events (incoming calls, registration, etc.)
* - Local device registrar * - Drives the web dashboard
* - WebRTC signaling * - Manages IVR, voicemail, announcements
* - Web dashboard * - Handles WebRTC browser signaling (forwarded to Rust in Phase 2)
* - Rust codec bridge
* *
* 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { Buffer } from 'node:buffer';
import { SipMessage } from './sip/index.ts'; import { loadConfig } from './config.ts';
import type { IEndpoint } from './sip/index.ts'; import type { IAppConfig } from './config.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 { broadcastWs, initWebUi } from './frontend.ts'; import { broadcastWs, initWebUi } from './frontend.ts';
import { import {
initWebRtcSignaling, initWebRtcSignaling,
@@ -43,19 +26,28 @@ import {
} from './webrtcbridge.ts'; } from './webrtcbridge.ts';
import { initCodecBridge } from './opusbridge.ts'; import { initCodecBridge } from './opusbridge.ts';
import { initAnnouncement } from './announcement.ts'; import { initAnnouncement } from './announcement.ts';
import { CallManager } from './call/index.ts';
import { PromptCache } from './call/prompt-cache.ts'; import { PromptCache } from './call/prompt-cache.ts';
import { VoiceboxManager } from './voicebox.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 // Config
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let appConfig: IAppConfig = loadConfig(); 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'); const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
@@ -77,42 +69,82 @@ function log(msg: string): void {
broadcastWs('log', { message: msg }); broadcastWs('log', { message: msg });
} }
function logPacket(label: string, data: Buffer): void { // ---------------------------------------------------------------------------
const head = `\n========== ${now()} ${label} (${data.length}b) ==========\n`; // Shadow state — maintained from Rust events for the dashboard
const looksText = data.length > 0 && data[0] >= 0x41 && data[0] <= 0x7a; // ---------------------------------------------------------------------------
const body = looksText
? data.toString('utf8') interface IProviderStatus {
: `[${data.length} bytes binary] ${data.toString('hex').slice(0, 80)}`; id: string;
fs.appendFileSync(LOG_PATH, head + body + '\n'); displayName: string;
registered: boolean;
publicIp: string | null;
}
interface IDeviceStatus {
id: string;
displayName: string;
address: string | null;
port: number;
connected: boolean;
isBrowser: boolean;
}
interface IActiveCall {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
providerUsed: string | null;
state: string;
startedAt: number;
}
interface ICallHistoryEntry {
id: string;
direction: string;
callerNumber: string | null;
calleeNumber: string | null;
startedAt: number;
duration: number;
}
const providerStatuses = new Map<string, IProviderStatus>();
const deviceStatuses = new Map<string, IDeviceStatus>();
const activeCalls = new Map<string, IActiveCall>();
const callHistory: ICallHistoryEntry[] = [];
const MAX_HISTORY = 100;
// Initialize provider statuses from config (all start as unregistered).
for (const p of appConfig.providers) {
providerStatuses.set(p.id, {
id: p.id,
displayName: p.displayName,
registered: false,
publicIp: null,
});
}
// Initialize device statuses from config.
for (const d of appConfig.devices) {
deviceStatuses.set(d.id, {
id: d.id,
displayName: d.displayName,
address: null,
port: 0,
connected: false,
isBrowser: false,
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Initialize subsystems // Initialize subsystems
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSeed);
initRegistrar(appConfig.devices, log);
// Initialize voicemail and IVR subsystems.
const promptCache = new PromptCache(log); const promptCache = new PromptCache(log);
const voiceboxManager = new VoiceboxManager(log); const voiceboxManager = new VoiceboxManager(log);
voiceboxManager.init(appConfig.voiceboxes ?? []); voiceboxManager.init(appConfig.voiceboxes ?? []);
const callManager = new CallManager({ // WebRTC signaling (browser device registration).
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).
initWebRtcSignaling({ log }); initWebRtcSignaling({ log });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -120,177 +152,176 @@ initWebRtcSignaling({ log });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getStatus() { 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 { return {
instanceId, instanceId,
uptime: Math.floor((Date.now() - startTime) / 1000), uptime: Math.floor((Date.now() - startTime) / 1000),
lanIp: LAN_IP, lanIp: appConfig.proxy.lanIp,
providers, providers: [...providerStatuses.values()],
devices: getAllDeviceStatuses(), devices: [...deviceStatuses.values()],
calls: callManager.getStatus(), calls: [...activeCalls.values()].map((c) => ({
callHistory: callManager.getHistory(), ...c,
duration: Math.floor((Date.now() - c.startedAt) / 1000),
legs: [],
})),
callHistory,
contacts: appConfig.contacts || [], contacts: appConfig.contacts || [],
voicemailCounts: voiceboxManager.getAllUnheardCounts(), voicemailCounts: voiceboxManager.getAllUnheardCounts(),
}; };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main UDP socket // Start Rust proxy engine
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const sock = dgram.createSocket('udp4'); async function startProxyEngine(): Promise<void> {
const ok = await initProxyEngine(log);
if (!ok) {
log('[FATAL] failed to start proxy engine');
process.exit(1);
}
sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => { // Subscribe to events from Rust BEFORE sending configure.
try { onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => {
const ps = getProviderByUpstreamAddress(rinfo.address, rinfo.port); const ps = providerStatuses.get(data.provider_id);
const msg = SipMessage.parse(data);
if (!msg) {
// Non-SIP data — forward raw based on direction.
if (ps) { if (ps) {
// From provider, forward to... nowhere useful without a call context. const wasRegistered = ps.registered;
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data); ps.registered = data.registered;
} else { ps.publicIp = data.public_ip;
// From device, forward to default provider. if (data.registered && !wasRegistered) {
const dp = resolveOutboundRoute(appConfig, ''); log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
if (dp) sock.send(data, dp.provider.outboundProxy.port, dp.provider.outboundProxy.address); } else if (!data.registered && wasRegistered) {
log(`[provider:${data.provider_id}] registration lost`);
} }
return; 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);
} }
// 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}`);
}
});
sock.on('error', (err: Error) => log(`[main] sock err: ${err.message}`));
sock.bind(LAN_PORT, '0.0.0.0', () => {
const providerList = appConfig.providers.map((p) => p.displayName).join(', '); const providerList = appConfig.providers.map((p) => p.displayName).join(', ');
const deviceList = appConfig.devices.map((d) => d.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. // Initialize audio codec bridge (still needed for WebRTC transcoding).
for (const ps of providerStates.values()) { try {
ps.startRegistration( await initCodecBridge(log);
LAN_IP, await initAnnouncement(log);
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 (Rust binary via smartrust). // Pre-generate prompts.
initCodecBridge(log)
.then(() => initAnnouncement(log))
.then(async () => {
// Pre-generate voicemail beep tone.
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000); await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
// Pre-generate voicemail greetings for all configured voiceboxes.
for (const vb of appConfig.voiceboxes ?? []) { for (const vb of appConfig.voiceboxes ?? []) {
if (!vb.enabled) continue; if (!vb.enabled) continue;
const promptId = `voicemail-greeting-${vb.id}`; const promptId = `voicemail-greeting-${vb.id}`;
const wavPath = vb.greetingWavPath; if (vb.greetingWavPath) {
if (wavPath) { await promptCache.loadWavPrompt(promptId, vb.greetingWavPath);
await promptCache.loadWavPrompt(promptId, wavPath);
} else { } else {
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.'; 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'); await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
} }
} }
// Pre-generate IVR menu prompts.
if (appConfig.ivr?.enabled) { if (appConfig.ivr?.enabled) {
for (const menu of appConfig.ivr.menus) { for (const menu of appConfig.ivr.menus) {
const promptId = `ivr-menu-${menu.id}`; await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella');
await promptCache.generatePrompt(promptId, menu.promptText, menu.promptVoice || 'af_bella');
} }
} }
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`); log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
}) } catch (e) {
.catch((e) => log(`[codec] init failed: ${e}`)); log(`[codec] init failed: ${e}`);
}); }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Web UI // Web UI
@@ -299,34 +330,62 @@ sock.bind(LAN_PORT, '0.0.0.0', () => {
initWebUi( initWebUi(
getStatus, getStatus,
log, log,
(number, deviceId, providerId) => { (number, _deviceId, _providerId) => {
const call = callManager.createOutboundCall(number, deviceId, providerId); // Outbound calls from dashboard — send make_call command to Rust.
return call ? { id: call.id } : null; // 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 { try {
const fresh = loadConfig(); const fresh = loadConfig();
Object.assign(appConfig, fresh); Object.assign(appConfig, fresh);
// Sync provider registrations: add new, remove deleted, re-register changed.
syncProviderStates( // Update shadow state.
fresh.providers, for (const p of fresh.providers) {
proxy.publicIpSeed, if (!providerStatuses.has(p.id)) {
LAN_IP, providerStatuses.set(p.id, {
LAN_PORT, id: p.id, displayName: p.displayName, registered: false, publicIp: null,
(buf, dest) => sock.send(buf, dest.port, dest.address), });
log, }
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }), }
); for (const d of fresh.devices) {
log('[config] reloaded config after save'); 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) { } catch (e: any) {
log(`[config] reload failed: ${e.message}`); log(`[config] reload failed: ${e.message}`);
} }
}, },
callManager, undefined, // callManager — WebRTC calls handled separately in Phase 2
voiceboxManager, 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); });

View File

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