//! 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 sip_proto::message::SipMessage; use std::net::SocketAddr; /// Network endpoint. #[derive(Debug, Clone, Deserialize)] pub struct Endpoint { pub address: String, pub port: u16, } impl Endpoint { /// Resolve to a SocketAddr. Handles both IP addresses and hostnames. pub fn to_socket_addr(&self) -> Option { // 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()) } } /// Provider quirks for codec/protocol workarounds. // // 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)] #[derive(Debug, Clone, Deserialize)] pub struct Quirks { #[serde(rename = "earlyMediaSilence")] pub early_media_silence: bool, #[serde(rename = "silencePayloadType")] pub silence_payload_type: Option, #[serde(rename = "silenceMaxPackets")] pub silence_max_packets: Option, } /// A SIP trunk provider configuration. #[derive(Debug, Clone, Deserialize)] pub struct ProviderConfig { pub id: String, // UI label — populated by serde for parity with the TS config, not // consumed at runtime. #[allow(dead_code)] #[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, // Workaround knobs populated by serde but not yet acted upon — see Quirks. #[allow(dead_code)] pub quirks: Quirks, } /// A SIP device (phone) configuration. #[derive(Debug, Clone, Deserialize)] pub struct DeviceConfig { pub id: String, #[serde(rename = "displayName")] pub display_name: String, #[serde(rename = "expectedAddress")] pub expected_address: String, pub extension: String, } /// Route match criteria. #[derive(Debug, Clone, Deserialize)] pub struct RouteMatch { pub direction: String, // "inbound" | "outbound" #[serde(rename = "numberPattern")] pub number_pattern: Option, #[serde(rename = "callerPattern")] pub caller_pattern: Option, #[serde(rename = "sourceProvider")] pub source_provider: Option, #[serde(rename = "sourceDevice")] pub source_device: Option, } /// Route action. #[derive(Debug, Clone, Deserialize)] // 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)] pub struct RouteAction { pub targets: Option>, #[serde(rename = "ringBrowsers")] pub ring_browsers: Option, #[serde(rename = "voicemailBox")] pub voicemail_box: Option, #[serde(rename = "ivrMenuId")] pub ivr_menu_id: Option, #[serde(rename = "noAnswerTimeout")] pub no_answer_timeout: Option, pub provider: Option, #[serde(rename = "failoverProviders")] pub failover_providers: Option>, #[serde(rename = "stripPrefix")] pub strip_prefix: Option, #[serde(rename = "prependPrefix")] pub prepend_prefix: Option, } /// A routing rule. #[derive(Debug, Clone, Deserialize)] pub struct Route { // `id` and `name` are UI identifiers, populated by serde but not // consumed by the resolvers. #[allow(dead_code)] pub id: String, #[allow(dead_code)] pub name: String, pub priority: i32, pub enabled: bool, #[serde(rename = "match")] pub match_criteria: RouteMatch, pub action: RouteAction, } /// Proxy network settings. #[derive(Debug, Clone, Deserialize)] pub struct ProxyConfig { #[serde(rename = "lanIp")] pub lan_ip: String, #[serde(rename = "lanPort")] pub lan_port: u16, #[serde(rename = "publicIpSeed")] pub public_ip_seed: Option, #[serde(rename = "rtpPortRange")] pub rtp_port_range: RtpPortRange, } #[derive(Debug, Clone, Deserialize)] pub struct RtpPortRange { pub min: u16, pub max: u16, } /// Full application config pushed from TypeScript. #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { pub proxy: ProxyConfig, pub providers: Vec, pub devices: Vec, pub routing: RoutingConfig, #[serde(default)] pub voiceboxes: Vec, #[serde(default)] pub ivr: Option, } #[derive(Debug, Clone, Deserialize)] pub struct RoutingConfig { pub routes: Vec, } // --------------------------------------------------------------------------- // 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, #[serde(rename = "greetingVoice")] pub greeting_voice: Option, #[serde(rename = "greetingWavPath")] pub greeting_wav_path: Option, #[serde(rename = "maxRecordingSec")] pub max_recording_sec: Option, } // --------------------------------------------------------------------------- // IVR config // --------------------------------------------------------------------------- #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct IvrConfig { pub enabled: bool, pub menus: Vec, #[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, pub entries: Vec, #[serde(rename = "timeoutSec")] pub timeout_sec: Option, } #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct IvrMenuEntry { pub digit: String, pub action: String, pub target: Option, } // --------------------------------------------------------------------------- // Pattern matching (ported from ts/config.ts) // --------------------------------------------------------------------------- /// 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 } 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 } /// Test a value against a pattern string. /// - None/empty: matches everything (wildcard) /// - `start..end`: numeric range match /// - 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]); } if matches_numeric_range_pattern(pattern, value) { return true; } // 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, // TODO: prefix rewriting is unfinished — this is computed but the // caller ignores it and uses the raw dialed number. #[allow(dead_code)] pub transformed_number: String, } /// Result of resolving an inbound route. // // `device_ids` and `ring_browsers` are consumed by create_inbound_call. // 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)] pub struct InboundRouteResult { pub device_ids: Vec, pub ring_browsers: bool, pub voicemail_box: Option, pub ivr_menu_id: Option, pub no_answer_timeout: Option, } impl AppConfig { /// Resolve which provider to use for an outbound call. pub fn resolve_outbound_route( &self, dialed_number: &str, source_device_id: Option<&str>, is_provider_registered: &dyn Fn(&str) -> bool, ) -> Option { let mut routes: Vec<&Route> = self .routing .routes .iter() .filter(|r| r.enabled && r.match_criteria.direction == "outbound") .collect(); routes.sort_by(|a, b| b.priority.cmp(&a.priority)); for route in &routes { let m = &route.match_criteria; if !matches_pattern(m.number_pattern.as_deref(), dialed_number) { continue; } if let Some(sd) = &m.source_device { if source_device_id != Some(sd.as_str()) { continue; } } // Find a registered provider. let mut candidates: Vec<&str> = Vec::new(); if let Some(p) = &route.action.provider { candidates.push(p); } if let Some(fps) = &route.action.failover_providers { candidates.extend(fps.iter().map(|s| s.as_str())); } for pid in candidates { let provider = match self.providers.iter().find(|p| p.id == pid) { Some(p) => p, None => continue, }; if !is_provider_registered(pid) { continue; } let mut num = dialed_number.to_string(); if let Some(strip) = &route.action.strip_prefix { if num.starts_with(strip.as_str()) { num = num[strip.len()..].to_string(); } } if let Some(prepend) = &route.action.prepend_prefix { num = format!("{prepend}{num}"); } return Some(OutboundRouteResult { provider: provider.clone(), transformed_number: num, }); } } // Fallback: first provider. self.providers.first().map(|p| OutboundRouteResult { provider: p.clone(), transformed_number: dialed_number.to_string(), }) } /// Resolve which devices to ring for an inbound call. pub fn resolve_inbound_route( &self, provider_id: &str, called_number: &str, caller_number: &str, ) -> Option { 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 Some(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, }); } None } } #[cfg(test)] mod tests { use super::*; fn test_app_config(routes: Vec) -> 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, } } #[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(""), "+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); } #[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" )); } }