feat(routing): require explicit inbound DID routes and normalize SIP identities for provider-based number matching
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
//! 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.
|
||||
@@ -227,6 +228,51 @@ pub struct IvrMenuEntry {
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Test a value against a pattern string.
|
||||
/// - None/empty: matches everything (wildcard)
|
||||
/// - Trailing '*': prefix match
|
||||
@@ -363,7 +409,7 @@ impl AppConfig {
|
||||
provider_id: &str,
|
||||
called_number: &str,
|
||||
caller_number: &str,
|
||||
) -> InboundRouteResult {
|
||||
) -> Option<InboundRouteResult> {
|
||||
let mut routes: Vec<&Route> = self
|
||||
.routing
|
||||
.routes
|
||||
@@ -387,22 +433,150 @@ impl AppConfig {
|
||||
continue;
|
||||
}
|
||||
|
||||
return InboundRouteResult {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: ring all devices + browsers.
|
||||
InboundRouteResult {
|
||||
device_ids: vec![],
|
||||
ring_browsers: true,
|
||||
voicemail_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: None,
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user