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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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