Files
siprouter/rust/crates/proxy-engine/src/config.rs

659 lines
21 KiB
Rust

//! 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<SocketAddr> {
// 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<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,
// 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<u8>,
// 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<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)]
// 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<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 {
// `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<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,
#[serde(default)]
pub voiceboxes: Vec<VoiceboxConfig>,
#[serde(default)]
pub ivr: Option<IvrConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RoutingConfig {
pub routes: Vec<Route>,
}
// ---------------------------------------------------------------------------
// 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>,
}
// ---------------------------------------------------------------------------
// 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`, `ring_all_devices`, 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<String>,
pub ring_all_devices: bool,
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,
) -> Option<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;
}
let explicit_targets = route.action.targets.clone();
return Some(InboundRouteResult {
device_ids: explicit_targets.clone().unwrap_or_default(),
ring_all_devices: explicit_targets.is_none(),
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<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,
}
}
#[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);
}
#[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"
));
}
}