feat(rust-proxy-engine): add a Rust SIP proxy engine with shared SIP and codec libraries
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-10 - 1.11.0 - feat(rust-proxy-engine)
|
||||
add a Rust SIP proxy engine with shared SIP and codec libraries
|
||||
|
||||
- add new Rust workspace crates for proxy-engine, sip-proto, and codec-lib
|
||||
- move transcoding logic out of opus-codec into reusable codec-lib and keep opus-codec as a thin CLI wrapper
|
||||
- implement SIP message parsing, dialog handling, SDP/URI rewriting, provider registration, device registration, call management, RTP relay, and DTMF detection in Rust
|
||||
- add a TypeScript proxy bridge and update the SIP proxy entrypoint to spawn and configure the Rust engine as the SIP data plane
|
||||
|
||||
## 2026-04-10 - 1.10.0 - feat(call, voicemail, ivr)
|
||||
add voicemail and IVR call flows with DTMF handling, prompt playback, recording, and dashboard management
|
||||
|
||||
|
||||
135
rust/Cargo.lock
generated
135
rust/Cargo.lock
generated
@@ -111,6 +111,15 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byte-slice-cast"
|
||||
version = "1.2.3"
|
||||
@@ -203,6 +212,16 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codec-lib"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"audiopus",
|
||||
"ezk-g722",
|
||||
"nnnoiseless",
|
||||
"rubato",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -246,6 +265,16 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dary_heap"
|
||||
version = "0.3.8"
|
||||
@@ -271,6 +300,16 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
@@ -505,6 +544,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic_singleton"
|
||||
version = "0.5.3"
|
||||
@@ -796,12 +845,33 @@ dependencies = [
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -952,11 +1022,8 @@ dependencies = [
|
||||
name = "opus-codec"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"audiopus",
|
||||
"base64",
|
||||
"ezk-g722",
|
||||
"nnnoiseless",
|
||||
"rubato",
|
||||
"codec-lib",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1180,6 +1247,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proxy-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"codec-lib",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sip-proto",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -1295,6 +1375,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
@@ -1479,6 +1565,24 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sip-proto"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"md-5",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
@@ -1506,6 +1610,16 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socks"
|
||||
version = "0.3.4"
|
||||
@@ -1592,8 +1706,15 @@ version = "1.51.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1646,6 +1767,12 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
[workspace]
|
||||
members = ["crates/opus-codec", "crates/tts-engine"]
|
||||
members = [
|
||||
"crates/codec-lib",
|
||||
"crates/opus-codec",
|
||||
"crates/tts-engine",
|
||||
"crates/sip-proto",
|
||||
"crates/proxy-engine",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
|
||||
10
rust/crates/codec-lib/Cargo.toml
Normal file
10
rust/crates/codec-lib/Cargo.toml
Normal 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 }
|
||||
349
rust/crates/codec-lib/src/lib.rs
Normal file
349
rust/crates/codec-lib/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,7 @@ name = "opus-codec"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
audiopus = "0.3.0-rc.0"
|
||||
ezk-g722 = "0.1"
|
||||
rubato = "0.14"
|
||||
codec-lib = { path = "../codec-lib" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
nnnoiseless = { version = "0.5", default-features = false }
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/// Audio transcoding bridge for smartrust.
|
||||
///
|
||||
/// Handles Opus ↔ G.722 ↔ PCMU transcoding for the SIP router.
|
||||
/// Uses audiopus (libopus) for Opus and ezk-g722 (SpanDSP port) for G.722.
|
||||
///
|
||||
/// Supports per-session codec state so concurrent calls don't corrupt each
|
||||
/// other's stateful codecs (Opus, G.722 ADPCM).
|
||||
/// Thin CLI wrapper around `codec-lib`. Handles Opus ↔ G.722 ↔ PCMU transcoding.
|
||||
///
|
||||
/// Protocol:
|
||||
/// -> {"id":"1","method":"init","params":{}}
|
||||
@@ -16,24 +12,13 @@
|
||||
/// -> {"id":"4","method":"destroy_session","params":{"session_id":"call-abc"}}
|
||||
/// <- {"id":"4","success":true,"result":{}}
|
||||
|
||||
use audiopus::coder::{Decoder as OpusDecoder, Encoder as OpusEncoder};
|
||||
use audiopus::packet::Packet as OpusPacket;
|
||||
use audiopus::{Application, Bitrate as OpusBitrate, Channels, MutSignals, SampleRate};
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine as _;
|
||||
use ezk_g722::libg722::{self, Bitrate};
|
||||
use nnnoiseless::DenoiseState;
|
||||
use rubato::{FftFixedIn, Resampler};
|
||||
use codec_lib::{codec_sample_rate, TranscodeState};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
// Payload type constants.
|
||||
const PT_PCMU: u8 = 0;
|
||||
const PT_PCMA: u8 = 8;
|
||||
const PT_G722: u8 = 9;
|
||||
const PT_OPUS: u8 = 111;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Request {
|
||||
id: String,
|
||||
@@ -42,261 +27,24 @@ struct Request {
|
||||
params: serde_json::Value,
|
||||
}
|
||||
|
||||
fn respond(out: &mut impl Write, id: &str, success: bool, result: Option<serde_json::Value>, error: Option<&str>) {
|
||||
fn respond(
|
||||
out: &mut impl Write,
|
||||
id: &str,
|
||||
success: bool,
|
||||
result: Option<serde_json::Value>,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
let mut resp = serde_json::json!({ "id": id, "success": success });
|
||||
if let Some(r) = result { resp["result"] = r; }
|
||||
if let Some(e) = error { resp["error"] = serde_json::Value::String(e.to_string()); }
|
||||
if let Some(r) = result {
|
||||
resp["result"] = r;
|
||||
}
|
||||
if let Some(e) = error {
|
||||
resp["error"] = serde_json::Value::String(e.to_string());
|
||||
}
|
||||
let _ = writeln!(out, "{}", resp);
|
||||
let _ = out.flush();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codec state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TranscodeState {
|
||||
opus_enc: OpusEncoder,
|
||||
opus_dec: OpusDecoder,
|
||||
g722_enc: libg722::encoder::Encoder,
|
||||
g722_dec: libg722::decoder::Decoder,
|
||||
// Cached FFT resamplers keyed by (from_rate, to_rate, chunk_size).
|
||||
resamplers: HashMap<(u32, u32, usize), FftFixedIn<f64>>,
|
||||
// Per-direction ML noise suppression (RNNoise). Separate state per direction
|
||||
// prevents the RNN hidden state from being corrupted by interleaved audio streams.
|
||||
denoiser_to_sip: Box<DenoiseState<'static>>,
|
||||
denoiser_to_browser: Box<DenoiseState<'static>>,
|
||||
}
|
||||
|
||||
impl TranscodeState {
|
||||
fn new() -> Result<Self, String> {
|
||||
let mut opus_enc = OpusEncoder::new(SampleRate::Hz48000, Channels::Mono, Application::Voip)
|
||||
.map_err(|e| format!("opus encoder: {e}"))?;
|
||||
// Telephony-grade tuning: complexity 5 is sufficient for voice bridged to G.722.
|
||||
opus_enc.set_complexity(5).map_err(|e| format!("opus set_complexity: {e}"))?;
|
||||
opus_enc.set_bitrate(OpusBitrate::BitsPerSecond(24000)).map_err(|e| format!("opus set_bitrate: {e}"))?;
|
||||
let opus_dec = OpusDecoder::new(SampleRate::Hz48000, Channels::Mono)
|
||||
.map_err(|e| format!("opus decoder: {e}"))?;
|
||||
let g722_enc = libg722::encoder::Encoder::new(Bitrate::Mode1_64000, false, false);
|
||||
let g722_dec = libg722::decoder::Decoder::new(Bitrate::Mode1_64000, false, false);
|
||||
|
||||
Ok(Self {
|
||||
opus_enc, opus_dec, g722_enc, g722_dec,
|
||||
resamplers: HashMap::new(),
|
||||
denoiser_to_sip: DenoiseState::new(),
|
||||
denoiser_to_browser: DenoiseState::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// High-quality sample rate conversion using rubato FFT resampler.
|
||||
/// Resamplers are cached by (from_rate, to_rate, chunk_size) and reused,
|
||||
/// maintaining proper inter-frame state for continuous audio streams.
|
||||
fn resample(&mut self, pcm: &[i16], from_rate: u32, to_rate: u32) -> Result<Vec<i16>, String> {
|
||||
if from_rate == to_rate || pcm.is_empty() {
|
||||
return Ok(pcm.to_vec());
|
||||
}
|
||||
|
||||
let chunk = pcm.len();
|
||||
let key = (from_rate, to_rate, chunk);
|
||||
|
||||
// Get or create cached resampler for this rate pair + chunk size.
|
||||
if !self.resamplers.contains_key(&key) {
|
||||
let r = FftFixedIn::<f64>::new(from_rate as usize, to_rate as usize, chunk, 1, 1)
|
||||
.map_err(|e| format!("resampler {from_rate}->{to_rate}: {e}"))?;
|
||||
self.resamplers.insert(key, r);
|
||||
}
|
||||
let resampler = self.resamplers.get_mut(&key).unwrap();
|
||||
|
||||
// i16 → f64 normalized to [-1.0, 1.0]
|
||||
let float_in: Vec<f64> = pcm.iter().map(|&s| s as f64 / 32768.0).collect();
|
||||
let input = vec![float_in];
|
||||
|
||||
let result = resampler.process(&input, None)
|
||||
.map_err(|e| format!("resample {from_rate}->{to_rate}: {e}"))?;
|
||||
|
||||
// f64 → i16
|
||||
Ok(result[0].iter()
|
||||
.map(|&s| (s * 32767.0).round().clamp(-32768.0, 32767.0) as i16)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Apply RNNoise ML noise suppression to 48kHz PCM audio.
|
||||
/// Processes in 480-sample (10ms) frames. State persists across calls.
|
||||
fn denoise(denoiser: &mut DenoiseState, pcm: &[i16]) -> Vec<i16> {
|
||||
let frame_size = DenoiseState::FRAME_SIZE; // 480
|
||||
let total = pcm.len();
|
||||
// Round down to whole frames — don't process partial frames to avoid
|
||||
// injecting artificial silence into the RNN state.
|
||||
let whole = (total / frame_size) * frame_size;
|
||||
let mut output = Vec::with_capacity(total);
|
||||
let mut out_buf = [0.0f32; 480];
|
||||
|
||||
for offset in (0..whole).step_by(frame_size) {
|
||||
let input: Vec<f32> = pcm[offset..offset + frame_size]
|
||||
.iter().map(|&s| s as f32).collect();
|
||||
denoiser.process_frame(&mut out_buf, &input);
|
||||
output.extend(out_buf.iter()
|
||||
.map(|&s| s.round().clamp(-32768.0, 32767.0) as i16));
|
||||
}
|
||||
// Pass through any trailing partial-frame samples unmodified.
|
||||
if whole < total {
|
||||
output.extend_from_slice(&pcm[whole..]);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Transcode audio payload from one codec to another.
|
||||
/// `direction`: "to_sip" or "to_browser" — selects the per-direction denoiser.
|
||||
/// If None, denoising is skipped (backward compat).
|
||||
fn transcode(&mut self, data: &[u8], from_pt: u8, to_pt: u8, direction: Option<&str>) -> Result<Vec<u8>, String> {
|
||||
if from_pt == to_pt {
|
||||
return Ok(data.to_vec());
|
||||
}
|
||||
|
||||
// Decode to PCM (at source sample rate).
|
||||
let (pcm, rate) = self.decode_to_pcm(data, from_pt)?;
|
||||
|
||||
// Apply noise suppression if direction is specified.
|
||||
let processed = if let Some(dir) = direction {
|
||||
// Resample to 48kHz for denoising (no-op when already 48kHz).
|
||||
let pcm_48k = self.resample(&pcm, rate, 48000)?;
|
||||
let denoiser = match dir {
|
||||
"to_sip" => &mut self.denoiser_to_sip,
|
||||
_ => &mut self.denoiser_to_browser,
|
||||
};
|
||||
let denoised = Self::denoise(denoiser, &pcm_48k);
|
||||
// Resample to target rate (no-op when target is 48kHz).
|
||||
let target_rate = codec_sample_rate(to_pt);
|
||||
self.resample(&denoised, 48000, target_rate)?
|
||||
} else {
|
||||
// No denoising — direct resample.
|
||||
let target_rate = codec_sample_rate(to_pt);
|
||||
if rate == target_rate { pcm } else { self.resample(&pcm, rate, target_rate)? }
|
||||
};
|
||||
|
||||
// Encode from PCM.
|
||||
self.encode_from_pcm(&processed, to_pt)
|
||||
}
|
||||
|
||||
fn decode_to_pcm(&mut self, data: &[u8], pt: u8) -> Result<(Vec<i16>, u32), String> {
|
||||
match pt {
|
||||
PT_OPUS => {
|
||||
let mut pcm = vec![0i16; 5760]; // up to 120ms at 48kHz (RFC 6716 max)
|
||||
let packet = OpusPacket::try_from(data)
|
||||
.map_err(|e| format!("opus packet: {e}"))?;
|
||||
let out = MutSignals::try_from(&mut pcm[..])
|
||||
.map_err(|e| format!("opus signals: {e}"))?;
|
||||
let n: usize = self.opus_dec.decode(Some(packet), out, false)
|
||||
.map_err(|e| format!("opus decode: {e}"))?.into();
|
||||
pcm.truncate(n);
|
||||
Ok((pcm, 48000))
|
||||
}
|
||||
PT_G722 => {
|
||||
let pcm = self.g722_dec.decode(data);
|
||||
Ok((pcm, 16000))
|
||||
}
|
||||
PT_PCMU => {
|
||||
let pcm: Vec<i16> = data.iter().map(|&b| mulaw_decode(b)).collect();
|
||||
Ok((pcm, 8000))
|
||||
}
|
||||
PT_PCMA => {
|
||||
let pcm: Vec<i16> = data.iter().map(|&b| alaw_decode(b)).collect();
|
||||
Ok((pcm, 8000))
|
||||
}
|
||||
_ => Err(format!("unsupported source PT {pt}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_from_pcm(&mut self, pcm: &[i16], pt: u8) -> Result<Vec<u8>, String> {
|
||||
match pt {
|
||||
PT_OPUS => {
|
||||
let mut buf = vec![0u8; 4000];
|
||||
let n: usize = self.opus_enc.encode(pcm, &mut buf)
|
||||
.map_err(|e| format!("opus encode: {e}"))?.into();
|
||||
buf.truncate(n);
|
||||
Ok(buf)
|
||||
}
|
||||
PT_G722 => {
|
||||
Ok(self.g722_enc.encode(pcm))
|
||||
}
|
||||
PT_PCMU => {
|
||||
Ok(pcm.iter().map(|&s| mulaw_encode(s)).collect())
|
||||
}
|
||||
PT_PCMA => {
|
||||
Ok(pcm.iter().map(|&s| alaw_encode(s)).collect())
|
||||
}
|
||||
_ => Err(format!("unsupported target PT {pt}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn codec_sample_rate(pt: u8) -> u32 {
|
||||
match pt {
|
||||
PT_OPUS => 48000,
|
||||
PT_G722 => 16000,
|
||||
_ => 8000, // PCMU, PCMA
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G.711 µ-law (PCMU)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn mulaw_encode(sample: i16) -> u8 {
|
||||
const BIAS: i16 = 0x84;
|
||||
const CLIP: i16 = 32635;
|
||||
let sign = if sample < 0 { 0x80u8 } else { 0 };
|
||||
// Use i32 to avoid overflow when sample == i16::MIN (-32768).
|
||||
let mut s = (sample as i32).unsigned_abs().min(CLIP as u32) as i16;
|
||||
s += BIAS;
|
||||
let mut exp = 7u8;
|
||||
let mut mask = 0x4000i16;
|
||||
while exp > 0 && (s & mask) == 0 { exp -= 1; mask >>= 1; }
|
||||
let mantissa = ((s >> (exp + 3)) & 0x0f) as u8;
|
||||
!(sign | (exp << 4) | mantissa)
|
||||
}
|
||||
|
||||
fn mulaw_decode(mulaw: u8) -> i16 {
|
||||
let v = !mulaw;
|
||||
let sign = v & 0x80;
|
||||
let exp = (v >> 4) & 0x07;
|
||||
let mantissa = v & 0x0f;
|
||||
let mut sample = (((mantissa as i16) << 4) + 0x84) << exp;
|
||||
sample -= 0x84;
|
||||
if sign != 0 { -sample } else { sample }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G.711 A-law (PCMA)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn alaw_encode(sample: i16) -> u8 {
|
||||
let sign = if sample >= 0 { 0x80u8 } else { 0 };
|
||||
// Use i32 to avoid overflow when sample == i16::MIN (-32768).
|
||||
let s = (sample as i32).unsigned_abs().min(32767) as i16;
|
||||
let mut exp = 7u8;
|
||||
let mut mask = 0x4000i16;
|
||||
while exp > 0 && (s & mask) == 0 { exp -= 1; mask >>= 1; }
|
||||
let mantissa = if exp > 0 { ((s >> (exp + 3)) & 0x0f) as u8 } else { ((s >> 4) & 0x0f) as u8 };
|
||||
(sign | (exp << 4) | mantissa) ^ 0x55
|
||||
}
|
||||
|
||||
fn alaw_decode(alaw: u8) -> i16 {
|
||||
let v = alaw ^ 0x55;
|
||||
let sign = v & 0x80;
|
||||
let exp = (v >> 4) & 0x07;
|
||||
let mantissa = v & 0x0f;
|
||||
let sample = if exp == 0 {
|
||||
((mantissa as i16) << 4) + 8
|
||||
} else {
|
||||
(((mantissa as i16) << 4) + 0x108) << (exp - 1)
|
||||
};
|
||||
if sign != 0 { sample } else { -sample }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a session: if session_id is provided, look it up in the sessions map;
|
||||
/// otherwise fall back to the default state (backward compat with `init`).
|
||||
fn get_session<'a>(
|
||||
@@ -319,9 +67,7 @@ fn main() {
|
||||
let _ = writeln!(out, r#"{{"event":"ready","data":{{}}}}"#);
|
||||
let _ = out.flush();
|
||||
|
||||
// Default state for backward-compat `init` (no session_id).
|
||||
let mut default_state: Option<TranscodeState> = None;
|
||||
// Per-session codec state for concurrent call isolation.
|
||||
let mut sessions: HashMap<String, TranscodeState> = HashMap::new();
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
@@ -340,22 +86,21 @@ fn main() {
|
||||
};
|
||||
|
||||
match req.method.as_str() {
|
||||
// Backward-compat: init the default (shared) session.
|
||||
"init" => {
|
||||
match TranscodeState::new() {
|
||||
Ok(s) => {
|
||||
default_state = Some(s);
|
||||
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
|
||||
}
|
||||
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
|
||||
"init" => match TranscodeState::new() {
|
||||
Ok(s) => {
|
||||
default_state = Some(s);
|
||||
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
|
||||
}
|
||||
}
|
||||
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
|
||||
},
|
||||
|
||||
// Create an isolated session with its own codec state.
|
||||
"create_session" => {
|
||||
let session_id = match req.params.get("session_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => { respond(&mut out, &req.id, false, None, Some("missing session_id")); continue; }
|
||||
None => {
|
||||
respond(&mut out, &req.id, false, None, Some("missing session_id"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if sessions.contains_key(&session_id) {
|
||||
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
|
||||
@@ -370,95 +115,172 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy a session, freeing its codec state.
|
||||
"destroy_session" => {
|
||||
let session_id = match req.params.get("session_id").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => { respond(&mut out, &req.id, false, None, Some("missing session_id")); continue; }
|
||||
None => {
|
||||
respond(&mut out, &req.id, false, None, Some("missing session_id"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
sessions.remove(session_id);
|
||||
respond(&mut out, &req.id, true, Some(serde_json::json!({})), None);
|
||||
}
|
||||
|
||||
// Transcode: uses session_id if provided, else default state.
|
||||
"transcode" => {
|
||||
let st = match get_session(&mut sessions, &mut default_state, &req.params) {
|
||||
Some(s) => s,
|
||||
None => { respond(&mut out, &req.id, false, None, Some("not initialized (no session or default state)")); continue; }
|
||||
None => {
|
||||
respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
false,
|
||||
None,
|
||||
Some("not initialized (no session or default state)"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let data_b64 = match req.params.get("data_b64").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => { respond(&mut out, &req.id, false, None, Some("missing data_b64")); continue; }
|
||||
None => {
|
||||
respond(&mut out, &req.id, false, None, Some("missing data_b64"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let from_pt = req.params.get("from_pt").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||
let from_pt =
|
||||
req.params.get("from_pt").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||
let to_pt = req.params.get("to_pt").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||
let direction = req.params.get("direction").and_then(|v| v.as_str());
|
||||
|
||||
let data = match B64.decode(data_b64) {
|
||||
Ok(b) => b,
|
||||
Err(e) => { respond(&mut out, &req.id, false, None, Some(&format!("b64: {e}"))); continue; }
|
||||
Err(e) => {
|
||||
respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
false,
|
||||
None,
|
||||
Some(&format!("b64: {e}")),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match st.transcode(&data, from_pt, to_pt, direction) {
|
||||
Ok(result) => {
|
||||
respond(&mut out, &req.id, true, Some(serde_json::json!({ "data_b64": B64.encode(&result) })), None);
|
||||
respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
true,
|
||||
Some(serde_json::json!({ "data_b64": B64.encode(&result) })),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => respond(&mut out, &req.id, false, None, Some(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Encode raw 16-bit PCM to a target codec.
|
||||
// Params: data_b64 (raw PCM bytes, 16-bit LE), sample_rate (input Hz), to_pt
|
||||
// Optional: session_id for isolated codec state.
|
||||
"encode_pcm" => {
|
||||
let st = match get_session(&mut sessions, &mut default_state, &req.params) {
|
||||
Some(s) => s,
|
||||
None => { respond(&mut out, &req.id, false, None, Some("not initialized (no session or default state)")); continue; }
|
||||
None => {
|
||||
respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
false,
|
||||
None,
|
||||
Some("not initialized (no session or default state)"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let data_b64 = match req.params.get("data_b64").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => { respond(&mut out, &req.id, false, None, Some("missing data_b64")); continue; }
|
||||
None => {
|
||||
respond(&mut out, &req.id, false, None, Some("missing data_b64"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let sample_rate = req.params.get("sample_rate").and_then(|v| v.as_u64()).unwrap_or(22050) as u32;
|
||||
let sample_rate = req
|
||||
.params
|
||||
.get("sample_rate")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(22050) as u32;
|
||||
let to_pt = req.params.get("to_pt").and_then(|v| v.as_u64()).unwrap_or(9) as u8;
|
||||
|
||||
let data = match B64.decode(data_b64) {
|
||||
Ok(b) => b,
|
||||
Err(e) => { respond(&mut out, &req.id, false, None, Some(&format!("b64: {e}"))); continue; }
|
||||
Err(e) => {
|
||||
respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
false,
|
||||
None,
|
||||
Some(&format!("b64: {e}")),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if data.len() % 2 != 0 {
|
||||
respond(&mut out, &req.id, false, None, Some("PCM data has odd byte count (expected 16-bit LE samples)"));
|
||||
respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
false,
|
||||
None,
|
||||
Some("PCM data has odd byte count (expected 16-bit LE samples)"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert raw bytes to i16 samples.
|
||||
let pcm: Vec<i16> = data.chunks_exact(2)
|
||||
let pcm: Vec<i16> = data
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
|
||||
// Resample to target codec's sample rate.
|
||||
let target_rate = codec_sample_rate(to_pt);
|
||||
let resampled = match st.resample(&pcm, sample_rate, target_rate) {
|
||||
Ok(r) => r,
|
||||
Err(e) => { respond(&mut out, &req.id, false, None, Some(&e)); continue; }
|
||||
Err(e) => {
|
||||
respond(&mut out, &req.id, false, None, Some(&e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Encode to target codec (reuse encode_from_pcm).
|
||||
match st.encode_from_pcm(&resampled, to_pt) {
|
||||
Ok(encoded) => {
|
||||
respond(&mut out, &req.id, true, Some(serde_json::json!({ "data_b64": B64.encode(&encoded) })), None);
|
||||
respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
true,
|
||||
Some(serde_json::json!({ "data_b64": B64.encode(&encoded) })),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
respond(&mut out, &req.id, false, None, Some(&e));
|
||||
}
|
||||
Err(e) => { respond(&mut out, &req.id, false, None, Some(&e)); continue; }
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy commands (kept for backward compat).
|
||||
"encode" | "decode" => {
|
||||
respond(&mut out, &req.id, false, None, Some("use 'transcode' command instead"));
|
||||
respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
false,
|
||||
None,
|
||||
Some("use 'transcode' command instead"),
|
||||
);
|
||||
}
|
||||
|
||||
_ => respond(&mut out, &req.id, false, None, Some(&format!("unknown: {}", req.method))),
|
||||
_ => respond(
|
||||
&mut out,
|
||||
&req.id,
|
||||
false,
|
||||
None,
|
||||
Some(&format!("unknown: {}", req.method)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
rust/crates/proxy-engine/Cargo.toml
Normal file
17
rust/crates/proxy-engine/Cargo.toml
Normal 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"
|
||||
103
rust/crates/proxy-engine/src/call.rs
Normal file
103
rust/crates/proxy-engine/src/call.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
578
rust/crates/proxy-engine/src/call_manager.rs
Normal file
578
rust/crates/proxy-engine/src/call_manager.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
315
rust/crates/proxy-engine/src/config.rs
Normal file
315
rust/crates/proxy-engine/src/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
200
rust/crates/proxy-engine/src/dtmf.rs
Normal file
200
rust/crates/proxy-engine/src/dtmf.rs
Normal 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
|
||||
}
|
||||
}
|
||||
47
rust/crates/proxy-engine/src/ipc.rs
Normal file
47
rust/crates/proxy-engine/src/ipc.rs
Normal 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());
|
||||
}
|
||||
440
rust/crates/proxy-engine/src/main.rs
Normal file
440
rust/crates/proxy-engine/src/main.rs
Normal 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, ®istrar_dummy)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let config_ref = eng.config.as_ref().unwrap().clone();
|
||||
|
||||
// 4. New inbound INVITE from provider.
|
||||
if is_from_provider && msg.is_request() && msg.method() == Some("INVITE") {
|
||||
// Detect public IP from Via.
|
||||
if let Some(via) = msg.get_header("Via") {
|
||||
if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
|
||||
let mut ps = ps_arc.lock().await;
|
||||
ps.detect_public_ip(via);
|
||||
}
|
||||
}
|
||||
|
||||
// Send 100 Trying immediately.
|
||||
let trying = SipMessage::create_response(100, "Trying", &msg, None);
|
||||
let _ = socket.send_to(&trying.serialize(), from_addr).await;
|
||||
|
||||
// Determine provider info.
|
||||
let (provider_id, provider_config, public_ip) =
|
||||
if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
|
||||
let ps = ps_arc.lock().await;
|
||||
(
|
||||
ps.config.id.clone(),
|
||||
ps.config.clone(),
|
||||
ps.public_ip.clone(),
|
||||
)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create the inbound call — Rust handles everything.
|
||||
// Split borrows via destructuring to satisfy the borrow checker.
|
||||
let ProxyEngine {
|
||||
ref registrar,
|
||||
ref mut call_mgr,
|
||||
ref mut rtp_pool,
|
||||
..
|
||||
} = *eng;
|
||||
let rtp_pool = rtp_pool.as_mut().unwrap();
|
||||
let call_id = call_mgr
|
||||
.create_inbound_call(
|
||||
&msg,
|
||||
from_addr,
|
||||
&provider_id,
|
||||
&provider_config,
|
||||
&config_ref,
|
||||
registrar,
|
||||
rtp_pool,
|
||||
socket,
|
||||
public_ip.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(call_id) = call_id {
|
||||
// Emit event so TypeScript knows about the call (for dashboard, IVR routing, etc).
|
||||
let from_header = msg.get_header("From").unwrap_or("");
|
||||
let from_uri = SipMessage::extract_uri(from_header).unwrap_or("Unknown");
|
||||
let called_number = msg
|
||||
.request_uri()
|
||||
.and_then(|uri| SipMessage::extract_uri(uri))
|
||||
.unwrap_or("");
|
||||
|
||||
emit_event(
|
||||
&eng.out_tx,
|
||||
"incoming_call",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"from_uri": from_uri,
|
||||
"to_number": called_number,
|
||||
"provider_id": provider_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. New outbound INVITE from device.
|
||||
if !is_from_provider && msg.is_request() && msg.method() == Some("INVITE") {
|
||||
// Resolve outbound route.
|
||||
let dialed_number = msg
|
||||
.request_uri()
|
||||
.and_then(|uri| SipMessage::extract_uri(uri))
|
||||
.unwrap_or(msg.request_uri().unwrap_or(""))
|
||||
.to_string();
|
||||
|
||||
let device = eng.registrar.find_by_address(&from_addr);
|
||||
let device_id = device.map(|d| d.device_id.clone());
|
||||
|
||||
// Find provider via routing rules.
|
||||
let route_result = config_ref.resolve_outbound_route(
|
||||
&dialed_number,
|
||||
device_id.as_deref(),
|
||||
&|pid: &str| {
|
||||
// Can't call async here — use a sync check.
|
||||
// For now, assume all configured providers are available.
|
||||
true
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(route) = route_result {
|
||||
let public_ip = if let Some(ps_arc) = eng.provider_mgr.find_by_address(&from_addr).await {
|
||||
let ps = ps_arc.lock().await;
|
||||
ps.public_ip.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let ProxyEngine {
|
||||
ref mut call_mgr,
|
||||
ref mut rtp_pool,
|
||||
..
|
||||
} = *eng;
|
||||
let rtp_pool = rtp_pool.as_mut().unwrap();
|
||||
let call_id = call_mgr
|
||||
.create_outbound_passthrough(
|
||||
&msg,
|
||||
from_addr,
|
||||
&route.provider,
|
||||
&config_ref,
|
||||
rtp_pool,
|
||||
socket,
|
||||
public_ip.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(call_id) = call_id {
|
||||
emit_event(
|
||||
&eng.out_tx,
|
||||
"outbound_device_call",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"from_device": device_id,
|
||||
"to_number": dialed_number,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Other messages — log for debugging.
|
||||
let label = if msg.is_request() {
|
||||
msg.method().unwrap_or("?").to_string()
|
||||
} else {
|
||||
msg.status_code().map(|c| c.to_string()).unwrap_or_default()
|
||||
};
|
||||
emit_event(
|
||||
&eng.out_tx,
|
||||
"sip_unhandled",
|
||||
serde_json::json!({
|
||||
"method_or_status": label,
|
||||
"call_id": msg.call_id(),
|
||||
"from_addr": from_addr.ip().to_string(),
|
||||
"from_port": from_addr.port(),
|
||||
"is_from_provider": is_from_provider,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle `get_status` — return active call statuses from Rust.
|
||||
async fn handle_get_status(engine: Arc<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"));
|
||||
}
|
||||
}
|
||||
367
rust/crates/proxy-engine/src/provider.rs
Normal file
367
rust/crates/proxy-engine/src/provider.rs
Normal 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(®ister_buf, dest).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-register periodically (85% of the interval).
|
||||
let interval_sec = {
|
||||
let state = ps.lock().await;
|
||||
(state.config.register_interval_sec as f64 * 0.85) as u64
|
||||
};
|
||||
let mut interval = time::interval(Duration::from_secs(interval_sec.max(30)));
|
||||
interval.tick().await; // skip first immediate tick
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut state = ps.lock().await;
|
||||
let register_buf = state.build_register();
|
||||
if let Some(dest) = state.config.outbound_proxy.to_socket_addr() {
|
||||
let _ = socket.send_to(®ister_buf, dest).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
171
rust/crates/proxy-engine/src/registrar.rs
Normal file
171
rust/crates/proxy-engine/src/registrar.rs
Normal 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
|
||||
}
|
||||
}
|
||||
158
rust/crates/proxy-engine/src/rtp.rs
Normal file
158
rust/crates/proxy-engine/src/rtp.rs
Normal 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(×tamp.to_be_bytes());
|
||||
header[8..12].copy_from_slice(&ssrc.to_be_bytes());
|
||||
header
|
||||
}
|
||||
|
||||
/// Get the RTP clock increment per 20ms frame for a payload type.
|
||||
pub fn rtp_clock_increment(pt: u8) -> u32 {
|
||||
match pt {
|
||||
9 => 160, // G.722: 8000 Hz clock rate (despite 16kHz audio) × 0.02s
|
||||
0 | 8 => 160, // PCMU/PCMA: 8000 × 0.02
|
||||
111 => 960, // Opus: 48000 × 0.02
|
||||
_ => 160,
|
||||
}
|
||||
}
|
||||
67
rust/crates/proxy-engine/src/sip_transport.rs
Normal file
67
rust/crates/proxy-engine/src/sip_transport.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
8
rust/crates/sip-proto/Cargo.toml
Normal file
8
rust/crates/sip-proto/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "sip-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
md-5 = "0.10"
|
||||
rand = "0.8"
|
||||
408
rust/crates/sip-proto/src/dialog.rs
Normal file
408
rust/crates/sip-proto/src/dialog.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
331
rust/crates/sip-proto/src/helpers.rs
Normal file
331
rust/crates/sip-proto/src/helpers.rs
Normal 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 = ¶ms[pos + pat.len()..];
|
||||
let after = after.trim_start();
|
||||
if after.starts_with('"') {
|
||||
let end = after[1..].find('"')?;
|
||||
return Some(after[1..1 + end].to_string());
|
||||
}
|
||||
// Unquoted value.
|
||||
let end = after.find(|c: char| c == ',' || c.is_whitespace()).unwrap_or(after.len());
|
||||
return Some(after[..end].to_string());
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let realm = get("realm")?;
|
||||
let nonce = get("nonce")?;
|
||||
|
||||
Some(DigestChallenge {
|
||||
realm,
|
||||
nonce,
|
||||
algorithm: get("algorithm"),
|
||||
opaque: get("opaque"),
|
||||
qop: get("qop"),
|
||||
})
|
||||
}
|
||||
|
||||
fn md5_hex(s: &str) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(s.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Compute a SIP Digest Authorization header value.
|
||||
pub fn compute_digest_auth(
|
||||
username: &str,
|
||||
password: &str,
|
||||
realm: &str,
|
||||
nonce: &str,
|
||||
method: &str,
|
||||
uri: &str,
|
||||
algorithm: Option<&str>,
|
||||
opaque: Option<&str>,
|
||||
) -> String {
|
||||
let ha1 = md5_hex(&format!("{username}:{realm}:{password}"));
|
||||
let ha2 = md5_hex(&format!("{method}:{uri}"));
|
||||
let response = md5_hex(&format!("{ha1}:{nonce}:{ha2}"));
|
||||
let alg = algorithm.unwrap_or("MD5");
|
||||
|
||||
let mut header = format!(
|
||||
"Digest username=\"{username}\", realm=\"{realm}\", \
|
||||
nonce=\"{nonce}\", uri=\"{uri}\", response=\"{response}\", \
|
||||
algorithm={alg}"
|
||||
);
|
||||
if let Some(op) = opaque {
|
||||
header.push_str(&format!(", opaque=\"{op}\""));
|
||||
}
|
||||
header
|
||||
}
|
||||
|
||||
// ---- SDP parser ------------------------------------------------------------
|
||||
|
||||
use crate::Endpoint;
|
||||
|
||||
/// Parse the audio media port and connection address from an SDP body.
|
||||
pub fn parse_sdp_endpoint(sdp: &str) -> Option<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");
|
||||
}
|
||||
}
|
||||
17
rust/crates/sip-proto/src/lib.rs
Normal file
17
rust/crates/sip-proto/src/lib.rs
Normal 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,
|
||||
}
|
||||
563
rust/crates/sip-proto/src/message.rs
Normal file
563
rust/crates/sip-proto/src/message.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
130
rust/crates/sip-proto/src/rewrite.rs
Normal file
130
rust/crates/sip-proto/src/rewrite.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.10.0',
|
||||
version: '1.11.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
196
ts/proxybridge.ts
Normal file
196
ts/proxybridge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
521
ts/sipproxy.ts
521
ts/sipproxy.ts
@@ -1,39 +1,22 @@
|
||||
/**
|
||||
* SIP proxy — hub model entry point.
|
||||
* SIP proxy — entry point.
|
||||
*
|
||||
* Thin bootstrap that wires together:
|
||||
* - UDP socket for all SIP signaling
|
||||
* - CallManager (the hub model core)
|
||||
* - Provider registration
|
||||
* - Local device registrar
|
||||
* - WebRTC signaling
|
||||
* - Web dashboard
|
||||
* - Rust codec bridge
|
||||
* Spawns the Rust proxy-engine which handles ALL SIP protocol mechanics.
|
||||
* TypeScript is the control plane:
|
||||
* - Loads config and pushes it to Rust
|
||||
* - Receives high-level events (incoming calls, registration, etc.)
|
||||
* - Drives the web dashboard
|
||||
* - Manages IVR, voicemail, announcements
|
||||
* - Handles WebRTC browser signaling (forwarded to Rust in Phase 2)
|
||||
*
|
||||
* All call/media logic lives in ts/call/.
|
||||
* No raw SIP ever touches TypeScript.
|
||||
*/
|
||||
|
||||
import dgram from 'node:dgram';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import { SipMessage } from './sip/index.ts';
|
||||
import type { IEndpoint } from './sip/index.ts';
|
||||
import { loadConfig, resolveOutboundRoute } from './config.ts';
|
||||
import type { IAppConfig, IProviderConfig } from './config.ts';
|
||||
import {
|
||||
initProviderStates,
|
||||
syncProviderStates,
|
||||
getProviderByUpstreamAddress,
|
||||
handleProviderRegistrationResponse,
|
||||
} from './providerstate.ts';
|
||||
import {
|
||||
initRegistrar,
|
||||
handleDeviceRegister,
|
||||
isKnownDeviceAddress,
|
||||
getAllDeviceStatuses,
|
||||
} from './registrar.ts';
|
||||
import { loadConfig } from './config.ts';
|
||||
import type { IAppConfig } from './config.ts';
|
||||
import { broadcastWs, initWebUi } from './frontend.ts';
|
||||
import {
|
||||
initWebRtcSignaling,
|
||||
@@ -43,19 +26,28 @@ import {
|
||||
} from './webrtcbridge.ts';
|
||||
import { initCodecBridge } from './opusbridge.ts';
|
||||
import { initAnnouncement } from './announcement.ts';
|
||||
import { CallManager } from './call/index.ts';
|
||||
import { PromptCache } from './call/prompt-cache.ts';
|
||||
import { VoiceboxManager } from './voicebox.ts';
|
||||
import {
|
||||
initProxyEngine,
|
||||
configureProxyEngine,
|
||||
onProxyEvent,
|
||||
hangupCall,
|
||||
shutdownProxyEngine,
|
||||
} from './proxybridge.ts';
|
||||
import type {
|
||||
IIncomingCallEvent,
|
||||
IOutboundCallEvent,
|
||||
ICallEndedEvent,
|
||||
IProviderRegisteredEvent,
|
||||
IDeviceRegisteredEvent,
|
||||
} from './proxybridge.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let appConfig: IAppConfig = loadConfig();
|
||||
const { proxy } = appConfig;
|
||||
|
||||
const LAN_IP = proxy.lanIp;
|
||||
const LAN_PORT = proxy.lanPort;
|
||||
|
||||
const LOG_PATH = path.join(process.cwd(), 'sip_trace.log');
|
||||
|
||||
@@ -77,42 +69,82 @@ function log(msg: string): void {
|
||||
broadcastWs('log', { message: msg });
|
||||
}
|
||||
|
||||
function logPacket(label: string, data: Buffer): void {
|
||||
const head = `\n========== ${now()} ${label} (${data.length}b) ==========\n`;
|
||||
const looksText = data.length > 0 && data[0] >= 0x41 && data[0] <= 0x7a;
|
||||
const body = looksText
|
||||
? data.toString('utf8')
|
||||
: `[${data.length} bytes binary] ${data.toString('hex').slice(0, 80)}`;
|
||||
fs.appendFileSync(LOG_PATH, head + body + '\n');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shadow state — maintained from Rust events for the dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IProviderStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
registered: boolean;
|
||||
publicIp: string | null;
|
||||
}
|
||||
|
||||
interface IDeviceStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
address: string | null;
|
||||
port: number;
|
||||
connected: boolean;
|
||||
isBrowser: boolean;
|
||||
}
|
||||
|
||||
interface IActiveCall {
|
||||
id: string;
|
||||
direction: string;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
state: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
interface ICallHistoryEntry {
|
||||
id: string;
|
||||
direction: string;
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const providerStatuses = new Map<string, IProviderStatus>();
|
||||
const deviceStatuses = new Map<string, IDeviceStatus>();
|
||||
const activeCalls = new Map<string, IActiveCall>();
|
||||
const callHistory: ICallHistoryEntry[] = [];
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
// Initialize provider statuses from config (all start as unregistered).
|
||||
for (const p of appConfig.providers) {
|
||||
providerStatuses.set(p.id, {
|
||||
id: p.id,
|
||||
displayName: p.displayName,
|
||||
registered: false,
|
||||
publicIp: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize device statuses from config.
|
||||
for (const d of appConfig.devices) {
|
||||
deviceStatuses.set(d.id, {
|
||||
id: d.id,
|
||||
displayName: d.displayName,
|
||||
address: null,
|
||||
port: 0,
|
||||
connected: false,
|
||||
isBrowser: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize subsystems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const providerStates = initProviderStates(appConfig.providers, proxy.publicIpSeed);
|
||||
|
||||
initRegistrar(appConfig.devices, log);
|
||||
|
||||
// Initialize voicemail and IVR subsystems.
|
||||
const promptCache = new PromptCache(log);
|
||||
const voiceboxManager = new VoiceboxManager(log);
|
||||
voiceboxManager.init(appConfig.voiceboxes ?? []);
|
||||
|
||||
const callManager = new CallManager({
|
||||
appConfig,
|
||||
sendSip: (buf, dest) => sock.send(buf, dest.port, dest.address),
|
||||
log,
|
||||
broadcastWs,
|
||||
getProviderState: (id) => providerStates.get(id),
|
||||
getAllBrowserDeviceIds,
|
||||
sendToBrowserDevice,
|
||||
getBrowserDeviceWs,
|
||||
promptCache,
|
||||
voiceboxManager,
|
||||
});
|
||||
|
||||
// Initialize WebRTC signaling (browser device registration only).
|
||||
// WebRTC signaling (browser device registration).
|
||||
initWebRtcSignaling({ log });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,177 +152,176 @@ initWebRtcSignaling({ log });
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getStatus() {
|
||||
const providers: unknown[] = [];
|
||||
for (const ps of providerStates.values()) {
|
||||
providers.push({
|
||||
id: ps.config.id,
|
||||
displayName: ps.config.displayName,
|
||||
registered: ps.isRegistered,
|
||||
publicIp: ps.publicIp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
instanceId,
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
lanIp: LAN_IP,
|
||||
providers,
|
||||
devices: getAllDeviceStatuses(),
|
||||
calls: callManager.getStatus(),
|
||||
callHistory: callManager.getHistory(),
|
||||
lanIp: appConfig.proxy.lanIp,
|
||||
providers: [...providerStatuses.values()],
|
||||
devices: [...deviceStatuses.values()],
|
||||
calls: [...activeCalls.values()].map((c) => ({
|
||||
...c,
|
||||
duration: Math.floor((Date.now() - c.startedAt) / 1000),
|
||||
legs: [],
|
||||
})),
|
||||
callHistory,
|
||||
contacts: appConfig.contacts || [],
|
||||
voicemailCounts: voiceboxManager.getAllUnheardCounts(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main UDP socket
|
||||
// Start Rust proxy engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sock = dgram.createSocket('udp4');
|
||||
|
||||
sock.on('message', (data: Buffer, rinfo: dgram.RemoteInfo) => {
|
||||
try {
|
||||
const ps = getProviderByUpstreamAddress(rinfo.address, rinfo.port);
|
||||
|
||||
const msg = SipMessage.parse(data);
|
||||
if (!msg) {
|
||||
// Non-SIP data — forward raw based on direction.
|
||||
if (ps) {
|
||||
// From provider, forward to... nowhere useful without a call context.
|
||||
logPacket(`UP->DN RAW (unparsed) from ${rinfo.address}:${rinfo.port}`, data);
|
||||
} else {
|
||||
// From device, forward to default provider.
|
||||
const dp = resolveOutboundRoute(appConfig, '');
|
||||
if (dp) sock.send(data, dp.provider.outboundProxy.port, dp.provider.outboundProxy.address);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Provider registration responses — consumed by providerstate.
|
||||
if (handleProviderRegistrationResponse(msg)) return;
|
||||
|
||||
// 2. Device REGISTER — handled by local registrar.
|
||||
if (!ps && msg.method === 'REGISTER') {
|
||||
const response = handleDeviceRegister(msg, { address: rinfo.address, port: rinfo.port });
|
||||
if (response) {
|
||||
sock.send(response.serialize(), rinfo.port, rinfo.address);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Route to existing call by SIP Call-ID.
|
||||
if (callManager.routeSipMessage(msg, rinfo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. New inbound call from provider.
|
||||
if (ps && msg.isRequest && msg.method === 'INVITE') {
|
||||
logPacket(`[new inbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
|
||||
|
||||
// Detect public IP from Via.
|
||||
const via = msg.getHeader('Via');
|
||||
if (via) ps.detectPublicIp(via);
|
||||
|
||||
callManager.createInboundCall(ps, msg, { address: rinfo.address, port: rinfo.port });
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. New outbound call from device (passthrough).
|
||||
if (!ps && msg.isRequest && msg.method === 'INVITE') {
|
||||
logPacket(`[new outbound] INVITE from ${rinfo.address}:${rinfo.port}`, data);
|
||||
const dialedNumber = SipMessage.extractUri(msg.requestUri || '') || '';
|
||||
const routeResult = resolveOutboundRoute(
|
||||
appConfig,
|
||||
dialedNumber,
|
||||
undefined,
|
||||
(pid) => !!providerStates.get(pid)?.registeredAor,
|
||||
);
|
||||
if (routeResult) {
|
||||
const provState = providerStates.get(routeResult.provider.id);
|
||||
if (provState) {
|
||||
// Apply number transformation to the INVITE if needed.
|
||||
if (routeResult.transformedNumber !== dialedNumber) {
|
||||
const newUri = msg.requestUri?.replace(dialedNumber, routeResult.transformedNumber);
|
||||
if (newUri) msg.setRequestUri(newUri);
|
||||
}
|
||||
callManager.handlePassthroughOutbound(msg, { address: rinfo.address, port: rinfo.port }, routeResult.provider, provState);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Fallback: forward based on direction (for mid-dialog messages
|
||||
// that don't match any tracked call, e.g. OPTIONS, NOTIFY).
|
||||
if (ps) {
|
||||
// From provider -> forward to device.
|
||||
logPacket(`[fallback inbound] from ${rinfo.address}:${rinfo.port}`, data);
|
||||
const via = msg.getHeader('Via');
|
||||
if (via) ps.detectPublicIp(via);
|
||||
// Try to figure out where to send it...
|
||||
// For now, just log. These should become rare once all calls are tracked.
|
||||
log(`[fallback] unrouted inbound ${msg.isRequest ? msg.method : msg.statusCode} Call-ID=${msg.callId.slice(0, 30)}`);
|
||||
} else {
|
||||
// From device -> forward to provider.
|
||||
logPacket(`[fallback outbound] from ${rinfo.address}:${rinfo.port}`, data);
|
||||
const fallback = resolveOutboundRoute(appConfig, '');
|
||||
if (fallback) sock.send(msg.serialize(), fallback.provider.outboundProxy.port, fallback.provider.outboundProxy.address);
|
||||
}
|
||||
} catch (e: any) {
|
||||
log(`[err] ${e?.stack || e}`);
|
||||
async function startProxyEngine(): Promise<void> {
|
||||
const ok = await initProxyEngine(log);
|
||||
if (!ok) {
|
||||
log('[FATAL] failed to start proxy engine');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
sock.on('error', (err: Error) => log(`[main] sock err: ${err.message}`));
|
||||
// Subscribe to events from Rust BEFORE sending configure.
|
||||
onProxyEvent('provider_registered', (data: IProviderRegisteredEvent) => {
|
||||
const ps = providerStatuses.get(data.provider_id);
|
||||
if (ps) {
|
||||
const wasRegistered = ps.registered;
|
||||
ps.registered = data.registered;
|
||||
ps.publicIp = data.public_ip;
|
||||
if (data.registered && !wasRegistered) {
|
||||
log(`[provider:${data.provider_id}] registered (publicIp=${data.public_ip})`);
|
||||
} else if (!data.registered && wasRegistered) {
|
||||
log(`[provider:${data.provider_id}] registration lost`);
|
||||
}
|
||||
broadcastWs('registration', { providerId: data.provider_id, registered: data.registered });
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('device_registered', (data: IDeviceRegisteredEvent) => {
|
||||
const ds = deviceStatuses.get(data.device_id);
|
||||
if (ds) {
|
||||
ds.address = data.address;
|
||||
ds.port = data.port;
|
||||
ds.connected = true;
|
||||
log(`[registrar] ${data.display_name} registered from ${data.address}:${data.port}`);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('incoming_call', (data: IIncomingCallEvent) => {
|
||||
log(`[call] incoming: ${data.from_uri} → ${data.to_number} via ${data.provider_id} (${data.call_id})`);
|
||||
activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'inbound',
|
||||
callerNumber: data.from_uri,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: data.provider_id,
|
||||
state: 'ringing',
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Notify browsers of incoming call.
|
||||
const browserIds = getAllBrowserDeviceIds();
|
||||
for (const bid of browserIds) {
|
||||
sendToBrowserDevice(bid, {
|
||||
type: 'webrtc-incoming',
|
||||
callId: data.call_id,
|
||||
from: data.from_uri,
|
||||
deviceId: bid,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('outbound_device_call', (data: IOutboundCallEvent) => {
|
||||
log(`[call] outbound: device ${data.from_device} → ${data.to_number} (${data.call_id})`);
|
||||
activeCalls.set(data.call_id, {
|
||||
id: data.call_id,
|
||||
direction: 'outbound',
|
||||
callerNumber: data.from_device,
|
||||
calleeNumber: data.to_number,
|
||||
providerUsed: null,
|
||||
state: 'setting-up',
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
onProxyEvent('call_ringing', (data: { call_id: string }) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) call.state = 'ringing';
|
||||
});
|
||||
|
||||
onProxyEvent('call_answered', (data: { call_id: string }) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
call.state = 'connected';
|
||||
log(`[call] ${data.call_id} connected`);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('call_ended', (data: ICallEndedEvent) => {
|
||||
const call = activeCalls.get(data.call_id);
|
||||
if (call) {
|
||||
log(`[call] ${data.call_id} ended: ${data.reason} (${data.duration}s)`);
|
||||
// Move to history.
|
||||
callHistory.unshift({
|
||||
id: call.id,
|
||||
direction: call.direction,
|
||||
callerNumber: call.callerNumber,
|
||||
calleeNumber: call.calleeNumber,
|
||||
startedAt: call.startedAt,
|
||||
duration: data.duration,
|
||||
});
|
||||
if (callHistory.length > MAX_HISTORY) callHistory.pop();
|
||||
activeCalls.delete(data.call_id);
|
||||
}
|
||||
});
|
||||
|
||||
onProxyEvent('sip_unhandled', (data: any) => {
|
||||
log(`[sip] unhandled ${data.method_or_status} Call-ID=${data.call_id?.slice(0, 20)} from=${data.from_addr}:${data.from_port}`);
|
||||
});
|
||||
|
||||
// Send full config to Rust — this binds the SIP socket and starts registrations.
|
||||
const configured = await configureProxyEngine({
|
||||
proxy: appConfig.proxy,
|
||||
providers: appConfig.providers,
|
||||
devices: appConfig.devices,
|
||||
routing: appConfig.routing,
|
||||
});
|
||||
|
||||
if (!configured) {
|
||||
log('[FATAL] failed to configure proxy engine');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
sock.bind(LAN_PORT, '0.0.0.0', () => {
|
||||
const providerList = appConfig.providers.map((p) => p.displayName).join(', ');
|
||||
const deviceList = appConfig.devices.map((d) => d.displayName).join(', ');
|
||||
log(`sip proxy bound 0.0.0.0:${LAN_PORT} | providers: ${providerList} | devices: ${deviceList}`);
|
||||
log(`proxy engine started | LAN ${appConfig.proxy.lanIp}:${appConfig.proxy.lanPort} | providers: ${providerList} | devices: ${deviceList}`);
|
||||
|
||||
// Start upstream provider registrations.
|
||||
for (const ps of providerStates.values()) {
|
||||
ps.startRegistration(
|
||||
LAN_IP,
|
||||
LAN_PORT,
|
||||
(buf, dest) => sock.send(buf, dest.port, dest.address),
|
||||
log,
|
||||
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
|
||||
);
|
||||
// Initialize audio codec bridge (still needed for WebRTC transcoding).
|
||||
try {
|
||||
await initCodecBridge(log);
|
||||
await initAnnouncement(log);
|
||||
|
||||
// Pre-generate prompts.
|
||||
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
|
||||
for (const vb of appConfig.voiceboxes ?? []) {
|
||||
if (!vb.enabled) continue;
|
||||
const promptId = `voicemail-greeting-${vb.id}`;
|
||||
if (vb.greetingWavPath) {
|
||||
await promptCache.loadWavPrompt(promptId, vb.greetingWavPath);
|
||||
} else {
|
||||
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
if (appConfig.ivr?.enabled) {
|
||||
for (const menu of appConfig.ivr.menus) {
|
||||
await promptCache.generatePrompt(`ivr-menu-${menu.id}`, menu.promptText, menu.promptVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
|
||||
} catch (e) {
|
||||
log(`[codec] init failed: ${e}`);
|
||||
}
|
||||
|
||||
// Initialize audio codec bridge (Rust binary via smartrust).
|
||||
initCodecBridge(log)
|
||||
.then(() => initAnnouncement(log))
|
||||
.then(async () => {
|
||||
// Pre-generate voicemail beep tone.
|
||||
await promptCache.generateBeep('voicemail-beep', 1000, 500, 8000);
|
||||
|
||||
// Pre-generate voicemail greetings for all configured voiceboxes.
|
||||
for (const vb of appConfig.voiceboxes ?? []) {
|
||||
if (!vb.enabled) continue;
|
||||
const promptId = `voicemail-greeting-${vb.id}`;
|
||||
const wavPath = vb.greetingWavPath;
|
||||
if (wavPath) {
|
||||
await promptCache.loadWavPrompt(promptId, wavPath);
|
||||
} else {
|
||||
const text = vb.greetingText || 'The person you are trying to reach is not available. Please leave a message after the tone.';
|
||||
await promptCache.generatePrompt(promptId, text, vb.greetingVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-generate IVR menu prompts.
|
||||
if (appConfig.ivr?.enabled) {
|
||||
for (const menu of appConfig.ivr.menus) {
|
||||
const promptId = `ivr-menu-${menu.id}`;
|
||||
await promptCache.generatePrompt(promptId, menu.promptText, menu.promptVoice || 'af_bella');
|
||||
}
|
||||
}
|
||||
|
||||
log(`[startup] prompts cached: ${promptCache.listIds().join(', ') || 'none'}`);
|
||||
})
|
||||
.catch((e) => log(`[codec] init failed: ${e}`));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web UI
|
||||
@@ -299,34 +330,62 @@ sock.bind(LAN_PORT, '0.0.0.0', () => {
|
||||
initWebUi(
|
||||
getStatus,
|
||||
log,
|
||||
(number, deviceId, providerId) => {
|
||||
const call = callManager.createOutboundCall(number, deviceId, providerId);
|
||||
return call ? { id: call.id } : null;
|
||||
(number, _deviceId, _providerId) => {
|
||||
// Outbound calls from dashboard — send make_call command to Rust.
|
||||
// For now, log only. Full implementation needs make_call in Rust.
|
||||
log(`[dashboard] start call requested: ${number}`);
|
||||
// TODO: send make_call command when implemented in Rust
|
||||
return null;
|
||||
},
|
||||
(callId) => {
|
||||
hangupCall(callId);
|
||||
return true;
|
||||
},
|
||||
(callId) => callManager.hangup(callId),
|
||||
() => {
|
||||
// Reload config after UI save.
|
||||
// Config saved — reconfigure Rust engine.
|
||||
try {
|
||||
const fresh = loadConfig();
|
||||
Object.assign(appConfig, fresh);
|
||||
// Sync provider registrations: add new, remove deleted, re-register changed.
|
||||
syncProviderStates(
|
||||
fresh.providers,
|
||||
proxy.publicIpSeed,
|
||||
LAN_IP,
|
||||
LAN_PORT,
|
||||
(buf, dest) => sock.send(buf, dest.port, dest.address),
|
||||
log,
|
||||
(provider) => broadcastWs('registration', { providerId: provider.config.id, registered: provider.isRegistered }),
|
||||
);
|
||||
log('[config] reloaded config after save');
|
||||
|
||||
// Update shadow state.
|
||||
for (const p of fresh.providers) {
|
||||
if (!providerStatuses.has(p.id)) {
|
||||
providerStatuses.set(p.id, {
|
||||
id: p.id, displayName: p.displayName, registered: false, publicIp: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const d of fresh.devices) {
|
||||
if (!deviceStatuses.has(d.id)) {
|
||||
deviceStatuses.set(d.id, {
|
||||
id: d.id, displayName: d.displayName, address: null, port: 0, connected: false, isBrowser: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-send config to Rust.
|
||||
configureProxyEngine({
|
||||
proxy: fresh.proxy,
|
||||
providers: fresh.providers,
|
||||
devices: fresh.devices,
|
||||
routing: fresh.routing,
|
||||
}).then((ok) => {
|
||||
if (ok) log('[config] reloaded — proxy engine reconfigured');
|
||||
else log('[config] reload failed — proxy engine rejected config');
|
||||
});
|
||||
} catch (e: any) {
|
||||
log(`[config] reload failed: ${e.message}`);
|
||||
}
|
||||
},
|
||||
callManager,
|
||||
undefined, // callManager — WebRTC calls handled separately in Phase 2
|
||||
voiceboxManager,
|
||||
);
|
||||
|
||||
process.on('SIGINT', () => { log('SIGINT, exiting'); process.exit(0); });
|
||||
process.on('SIGTERM', () => { log('SIGTERM, exiting'); process.exit(0); });
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
startProxyEngine();
|
||||
|
||||
process.on('SIGINT', () => { log('SIGINT, exiting'); shutdownProxyEngine(); process.exit(0); });
|
||||
process.on('SIGTERM', () => { log('SIGTERM, exiting'); shutdownProxyEngine(); process.exit(0); });
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: 'siprouter',
|
||||
version: '1.10.0',
|
||||
version: '1.11.0',
|
||||
description: 'undefined'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user