2026-04-10 09:57:27 +00:00
|
|
|
//! 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;
|
2026-04-14 16:35:54 +00:00
|
|
|
use sip_proto::message::SipMessage;
|
2026-04-10 09:57:27 +00:00
|
|
|
use std::net::SocketAddr;
|
|
|
|
|
|
|
|
|
|
/// Network endpoint.
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
|
|
pub struct Endpoint {
|
|
|
|
|
pub address: String,
|
|
|
|
|
pub port: u16,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Endpoint {
|
2026-04-10 11:36:18 +00:00
|
|
|
/// Resolve to a SocketAddr. Handles both IP addresses and hostnames.
|
2026-04-10 09:57:27 +00:00
|
|
|
pub fn to_socket_addr(&self) -> Option<SocketAddr> {
|
2026-04-10 11:36:18 +00:00
|
|
|
// Try direct parse first (IP address).
|
|
|
|
|
if let Ok(addr) = format!("{}:{}", self.address, self.port).parse() {
|
|
|
|
|
return Some(addr);
|
|
|
|
|
}
|
|
|
|
|
// DNS resolution for hostnames.
|
|
|
|
|
use std::net::ToSocketAddrs;
|
|
|
|
|
format!("{}:{}", self.address, self.port)
|
|
|
|
|
.to_socket_addrs()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|mut addrs| addrs.next())
|
2026-04-10 09:57:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Provider quirks for codec/protocol workarounds.
|
2026-04-11 18:40:56 +00:00
|
|
|
//
|
|
|
|
|
// Deserialized from provider config for TS parity. Early-media silence
|
|
|
|
|
// injection and related workarounds are not yet ported to the Rust engine,
|
|
|
|
|
// so every field is populated by serde but not yet consumed.
|
|
|
|
|
#[allow(dead_code)]
|
2026-04-10 09:57:27 +00:00
|
|
|
#[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,
|
2026-04-11 18:40:56 +00:00
|
|
|
// UI label — populated by serde for parity with the TS config, not
|
|
|
|
|
// consumed at runtime.
|
|
|
|
|
#[allow(dead_code)]
|
2026-04-10 09:57:27 +00:00
|
|
|
#[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>,
|
2026-04-11 18:40:56 +00:00
|
|
|
// Workaround knobs populated by serde but not yet acted upon — see Quirks.
|
|
|
|
|
#[allow(dead_code)]
|
2026-04-10 09:57:27 +00:00
|
|
|
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)]
|
2026-04-11 18:40:56 +00:00
|
|
|
// Several fields (voicemail_box, ivr_menu_id, no_answer_timeout) are read
|
|
|
|
|
// by resolve_inbound_route but not yet honored downstream — see the
|
|
|
|
|
// multi-target TODO in CallManager::create_inbound_call.
|
|
|
|
|
#[allow(dead_code)]
|
2026-04-10 09:57:27 +00:00
|
|
|
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 {
|
2026-04-11 18:40:56 +00:00
|
|
|
// `id` and `name` are UI identifiers, populated by serde but not
|
|
|
|
|
// consumed by the resolvers.
|
|
|
|
|
#[allow(dead_code)]
|
2026-04-10 09:57:27 +00:00
|
|
|
pub id: String,
|
2026-04-11 18:40:56 +00:00
|
|
|
#[allow(dead_code)]
|
2026-04-10 09:57:27 +00:00
|
|
|
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,
|
2026-04-12 20:45:08 +00:00
|
|
|
#[serde(default)]
|
|
|
|
|
pub voiceboxes: Vec<VoiceboxConfig>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub ivr: Option<IvrConfig>,
|
2026-04-10 09:57:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
|
|
pub struct RoutingConfig {
|
|
|
|
|
pub routes: Vec<Route>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-12 20:45:08 +00:00
|
|
|
// Voicebox config
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
|
|
pub struct VoiceboxConfig {
|
|
|
|
|
pub id: String,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub enabled: bool,
|
|
|
|
|
#[serde(rename = "greetingText")]
|
|
|
|
|
pub greeting_text: Option<String>,
|
|
|
|
|
#[serde(rename = "greetingVoice")]
|
|
|
|
|
pub greeting_voice: Option<String>,
|
|
|
|
|
#[serde(rename = "greetingWavPath")]
|
|
|
|
|
pub greeting_wav_path: Option<String>,
|
|
|
|
|
#[serde(rename = "maxRecordingSec")]
|
|
|
|
|
pub max_recording_sec: Option<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// IVR config
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
|
|
pub struct IvrConfig {
|
|
|
|
|
pub enabled: bool,
|
|
|
|
|
pub menus: Vec<IvrMenuConfig>,
|
|
|
|
|
#[serde(rename = "entryMenuId")]
|
|
|
|
|
pub entry_menu_id: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
|
|
pub struct IvrMenuConfig {
|
|
|
|
|
pub id: String,
|
|
|
|
|
#[serde(rename = "promptText")]
|
|
|
|
|
pub prompt_text: String,
|
|
|
|
|
#[serde(rename = "promptVoice")]
|
|
|
|
|
pub prompt_voice: Option<String>,
|
|
|
|
|
pub entries: Vec<IvrMenuEntry>,
|
|
|
|
|
#[serde(rename = "timeoutSec")]
|
|
|
|
|
pub timeout_sec: Option<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
|
|
|
pub struct IvrMenuEntry {
|
|
|
|
|
pub digit: String,
|
|
|
|
|
pub action: String,
|
|
|
|
|
pub target: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-04-10 09:57:27 +00:00
|
|
|
// Pattern matching (ported from ts/config.ts)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-14 16:35:54 +00:00
|
|
|
/// Extract the URI user part and normalize phone-like identities for routing.
|
|
|
|
|
///
|
|
|
|
|
/// This keeps inbound route matching stable across provider-specific URI shapes,
|
|
|
|
|
/// e.g. `sip:+49 421 219694@trunk.example` and `sip:0049421219694@trunk.example`
|
|
|
|
|
/// both normalize to `+49421219694`.
|
|
|
|
|
pub fn normalize_routing_identity(value: &str) -> String {
|
|
|
|
|
let extracted = SipMessage::extract_uri_user(value).unwrap_or(value).trim();
|
|
|
|
|
if extracted.is_empty() {
|
|
|
|
|
return String::new();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut digits = String::new();
|
|
|
|
|
let mut saw_plus = false;
|
|
|
|
|
|
|
|
|
|
for (idx, ch) in extracted.chars().enumerate() {
|
|
|
|
|
if ch.is_ascii_digit() {
|
|
|
|
|
digits.push(ch);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ch == '+' && idx == 0 {
|
|
|
|
|
saw_plus = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if matches!(ch, ' ' | '\t' | '-' | '.' | '/' | '(' | ')') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return extracted.to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if digits.is_empty() {
|
|
|
|
|
return extracted.to_string();
|
|
|
|
|
}
|
|
|
|
|
if saw_plus {
|
|
|
|
|
return format!("+{digits}");
|
|
|
|
|
}
|
|
|
|
|
if digits.starts_with("00") && digits.len() > 2 {
|
|
|
|
|
return format!("+{}", &digits[2..]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
digits
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 18:52:13 +00:00
|
|
|
fn parse_numeric_range_value(value: &str) -> Option<(bool, &str)> {
|
|
|
|
|
let trimmed = value.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (has_plus, digits) = if let Some(rest) = trimmed.strip_prefix('+') {
|
|
|
|
|
(true, rest)
|
|
|
|
|
} else {
|
|
|
|
|
(false, trimmed)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some((has_plus, digits))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn matches_numeric_range_pattern(pattern: &str, value: &str) -> bool {
|
|
|
|
|
let Some((start, end)) = pattern.split_once("..") else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let Some((start_plus, start_digits)) = parse_numeric_range_value(start) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let Some((end_plus, end_digits)) = parse_numeric_range_value(end) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let Some((value_plus, value_digits)) = parse_numeric_range_value(value) else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if start_plus != end_plus || value_plus != start_plus {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if start_digits.len() != end_digits.len() || value_digits.len() != start_digits.len() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if start_digits > end_digits {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value_digits >= start_digits && value_digits <= end_digits
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 09:57:27 +00:00
|
|
|
/// Test a value against a pattern string.
|
|
|
|
|
/// - None/empty: matches everything (wildcard)
|
2026-04-14 18:52:13 +00:00
|
|
|
/// - `start..end`: numeric range match
|
2026-04-10 09:57:27 +00:00
|
|
|
/// - 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]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 18:52:13 +00:00
|
|
|
if matches_numeric_range_pattern(pattern, value) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 09:57:27 +00:00
|
|
|
// 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,
|
2026-04-11 18:40:56 +00:00
|
|
|
// TODO: prefix rewriting is unfinished — this is computed but the
|
|
|
|
|
// caller ignores it and uses the raw dialed number.
|
|
|
|
|
#[allow(dead_code)]
|
2026-04-10 09:57:27 +00:00
|
|
|
pub transformed_number: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Result of resolving an inbound route.
|
2026-04-11 18:40:56 +00:00
|
|
|
//
|
2026-04-14 18:58:48 +00:00
|
|
|
// `device_ids`, `ring_all_devices`, and `ring_browsers` are consumed by
|
|
|
|
|
// create_inbound_call.
|
2026-04-11 18:40:56 +00:00
|
|
|
// The remaining fields (voicemail_box, ivr_menu_id, no_answer_timeout)
|
|
|
|
|
// are resolved but not yet acted upon — see the multi-target TODO.
|
|
|
|
|
#[allow(dead_code)]
|
2026-04-10 09:57:27 +00:00
|
|
|
pub struct InboundRouteResult {
|
|
|
|
|
pub device_ids: Vec<String>,
|
2026-04-14 18:58:48 +00:00
|
|
|
pub ring_all_devices: bool,
|
2026-04-10 09:57:27 +00:00
|
|
|
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,
|
2026-04-14 16:35:54 +00:00
|
|
|
) -> Option<InboundRouteResult> {
|
2026-04-10 09:57:27 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 18:58:48 +00:00
|
|
|
let explicit_targets = route.action.targets.clone();
|
2026-04-14 16:35:54 +00:00
|
|
|
return Some(InboundRouteResult {
|
2026-04-14 18:58:48 +00:00
|
|
|
device_ids: explicit_targets.clone().unwrap_or_default(),
|
|
|
|
|
ring_all_devices: explicit_targets.is_none(),
|
2026-04-10 09:57:27 +00:00
|
|
|
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,
|
2026-04-14 16:35:54 +00:00
|
|
|
});
|
2026-04-10 09:57:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 16:35:54 +00:00
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn test_app_config(routes: Vec<Route>) -> AppConfig {
|
|
|
|
|
AppConfig {
|
|
|
|
|
proxy: ProxyConfig {
|
|
|
|
|
lan_ip: "127.0.0.1".to_string(),
|
|
|
|
|
lan_port: 5070,
|
|
|
|
|
public_ip_seed: None,
|
|
|
|
|
rtp_port_range: RtpPortRange {
|
|
|
|
|
min: 20_000,
|
|
|
|
|
max: 20_100,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
providers: vec![ProviderConfig {
|
|
|
|
|
id: "provider-a".to_string(),
|
|
|
|
|
display_name: "Provider A".to_string(),
|
|
|
|
|
domain: "example.com".to_string(),
|
|
|
|
|
outbound_proxy: Endpoint {
|
|
|
|
|
address: "example.com".to_string(),
|
|
|
|
|
port: 5060,
|
|
|
|
|
},
|
|
|
|
|
username: "user".to_string(),
|
|
|
|
|
password: "pass".to_string(),
|
|
|
|
|
register_interval_sec: 300,
|
|
|
|
|
codecs: vec![9],
|
|
|
|
|
quirks: Quirks {
|
|
|
|
|
early_media_silence: false,
|
|
|
|
|
silence_payload_type: None,
|
|
|
|
|
silence_max_packets: None,
|
|
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
devices: vec![DeviceConfig {
|
|
|
|
|
id: "desk".to_string(),
|
|
|
|
|
display_name: "Desk".to_string(),
|
|
|
|
|
expected_address: "127.0.0.1".to_string(),
|
|
|
|
|
extension: "100".to_string(),
|
|
|
|
|
}],
|
|
|
|
|
routing: RoutingConfig { routes },
|
|
|
|
|
voiceboxes: vec![],
|
|
|
|
|
ivr: None,
|
2026-04-10 09:57:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-14 16:35:54 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn normalize_routing_identity_extracts_uri_user_and_phone_number() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
normalize_routing_identity("sip:0049 421 219694@voip.easybell.de"),
|
|
|
|
|
"+49421219694"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
normalize_routing_identity("<tel:+49 (421) 219694>"),
|
|
|
|
|
"+49421219694"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(normalize_routing_identity("sip:100@pbx.local"), "100");
|
|
|
|
|
assert_eq!(normalize_routing_identity("sip:alice@pbx.local"), "alice");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_inbound_route_requires_explicit_match() {
|
|
|
|
|
let cfg = test_app_config(vec![]);
|
|
|
|
|
assert!(cfg
|
|
|
|
|
.resolve_inbound_route("provider-a", "+49421219694", "+491701234567")
|
|
|
|
|
.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_inbound_route_matches_per_number_on_shared_provider() {
|
|
|
|
|
let cfg = test_app_config(vec![
|
|
|
|
|
Route {
|
|
|
|
|
id: "main".to_string(),
|
|
|
|
|
name: "Main DID".to_string(),
|
|
|
|
|
priority: 200,
|
|
|
|
|
enabled: true,
|
|
|
|
|
match_criteria: RouteMatch {
|
|
|
|
|
direction: "inbound".to_string(),
|
|
|
|
|
number_pattern: Some("+49421219694".to_string()),
|
|
|
|
|
caller_pattern: None,
|
|
|
|
|
source_provider: Some("provider-a".to_string()),
|
|
|
|
|
source_device: None,
|
|
|
|
|
},
|
|
|
|
|
action: RouteAction {
|
|
|
|
|
targets: Some(vec!["desk".to_string()]),
|
|
|
|
|
ring_browsers: Some(true),
|
|
|
|
|
voicemail_box: None,
|
|
|
|
|
ivr_menu_id: None,
|
|
|
|
|
no_answer_timeout: None,
|
|
|
|
|
provider: None,
|
|
|
|
|
failover_providers: None,
|
|
|
|
|
strip_prefix: None,
|
|
|
|
|
prepend_prefix: None,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Route {
|
|
|
|
|
id: "support".to_string(),
|
|
|
|
|
name: "Support DID".to_string(),
|
|
|
|
|
priority: 100,
|
|
|
|
|
enabled: true,
|
|
|
|
|
match_criteria: RouteMatch {
|
|
|
|
|
direction: "inbound".to_string(),
|
|
|
|
|
number_pattern: Some("+49421219695".to_string()),
|
|
|
|
|
caller_pattern: None,
|
|
|
|
|
source_provider: Some("provider-a".to_string()),
|
|
|
|
|
source_device: None,
|
|
|
|
|
},
|
|
|
|
|
action: RouteAction {
|
|
|
|
|
targets: None,
|
|
|
|
|
ring_browsers: Some(false),
|
|
|
|
|
voicemail_box: Some("support-box".to_string()),
|
|
|
|
|
ivr_menu_id: None,
|
|
|
|
|
no_answer_timeout: Some(20),
|
|
|
|
|
provider: None,
|
|
|
|
|
failover_providers: None,
|
|
|
|
|
strip_prefix: None,
|
|
|
|
|
prepend_prefix: None,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
let main = cfg
|
|
|
|
|
.resolve_inbound_route("provider-a", "+49421219694", "+491701234567")
|
|
|
|
|
.expect("main DID should match");
|
|
|
|
|
assert_eq!(main.device_ids, vec!["desk".to_string()]);
|
|
|
|
|
assert!(main.ring_browsers);
|
|
|
|
|
|
|
|
|
|
let support = cfg
|
|
|
|
|
.resolve_inbound_route("provider-a", "+49421219695", "+491701234567")
|
|
|
|
|
.expect("support DID should match");
|
|
|
|
|
assert_eq!(support.voicemail_box.as_deref(), Some("support-box"));
|
|
|
|
|
assert_eq!(support.no_answer_timeout, Some(20));
|
|
|
|
|
assert!(!support.ring_browsers);
|
|
|
|
|
}
|
2026-04-14 18:52:13 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn matches_pattern_supports_numeric_ranges() {
|
|
|
|
|
assert!(matches_pattern(
|
|
|
|
|
Some("042116767546..042116767548"),
|
|
|
|
|
"042116767547"
|
|
|
|
|
));
|
|
|
|
|
assert!(!matches_pattern(
|
|
|
|
|
Some("042116767546..042116767548"),
|
|
|
|
|
"042116767549"
|
|
|
|
|
));
|
|
|
|
|
assert!(matches_pattern(
|
|
|
|
|
Some("+4942116767546..+4942116767548"),
|
|
|
|
|
"+4942116767547"
|
|
|
|
|
));
|
|
|
|
|
assert!(!matches_pattern(
|
|
|
|
|
Some("+4942116767546..+4942116767548"),
|
|
|
|
|
"042116767547"
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-04-10 09:57:27 +00:00
|
|
|
}
|