//! 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 { /// 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. #[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, #[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, 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)] 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 { 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, #[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, } #[derive(Debug, Clone, Deserialize)] pub struct RoutingConfig { pub routes: Vec, } // --------------------------------------------------------------------------- // 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, 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, ) -> 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, } } }