//! 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. // // 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, } #[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, // 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, ) -> 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, } } }