326 lines
9.9 KiB
Rust
326 lines
9.9 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 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.
|
|
#[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,
|
|
#[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>,
|
|
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)]
|
|
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 {
|
|
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<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,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct RoutingConfig {
|
|
pub routes: Vec<Route>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<String>,
|
|
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,
|
|
) -> 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,
|
|
}
|
|
}
|
|
}
|