Skip to main content

active_call/
config.rs

1use crate::media::{ambiance::AmbianceOption, recorder::RecorderFormat};
2use crate::useragent::RegisterOption;
3use anyhow::{Error, Result};
4use clap::Parser;
5use rsipstack::dialog::invitation::InviteOption;
6use rsipstack::rsip::uri::{Auth, HostWithPort, Uri};
7use rustrtc::IceServer;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Parser, Debug)]
12#[command(version)]
13pub struct Cli {
14    /// Path to configuration file
15    #[clap(long)]
16    pub conf: Option<String>,
17    /// HTTP listening address
18    #[clap(long)]
19    pub http: Option<String>,
20
21    /// SIP listening port
22    #[clap(long)]
23    pub sip: Option<String>,
24
25    /// SIP invitation handler: URL for webhook (http://...) or playbook file (.md)
26    #[clap(long)]
27    pub handler: Option<String>,
28
29    /// Call a SIP address immediately and use the handler for the call
30    #[clap(long)]
31    pub call: Option<String>,
32
33    /// External IP address for SIP/RTP
34    #[clap(long)]
35    pub external_ip: Option<String>,
36
37    /// Supported codecs (e.g., pcmu,pcma,g722,g729,opus)
38    #[clap(long, value_delimiter = ',')]
39    pub codecs: Option<Vec<String>>,
40
41    /// Download models (sensevoice, supertonic, or all)
42    #[cfg(feature = "offline")]
43    #[clap(long)]
44    pub download_models: Option<String>,
45
46    /// Models directory for offline inference
47    #[cfg(feature = "offline")]
48    #[clap(long, default_value = "./models")]
49    pub models_dir: String,
50
51    /// Exit after downloading models
52    #[cfg(feature = "offline")]
53    #[clap(long)]
54    pub exit_after_download: bool,
55}
56
57pub(crate) fn default_config_recorder_path() -> String {
58    #[cfg(target_os = "windows")]
59    return "./config/recorders".to_string();
60    #[cfg(not(target_os = "windows"))]
61    return "./config/recorders".to_string();
62}
63
64fn default_config_media_cache_path() -> String {
65    #[cfg(target_os = "windows")]
66    return "./config/mediacache".to_string();
67    #[cfg(not(target_os = "windows"))]
68    return "./config/mediacache".to_string();
69}
70
71fn default_config_http_addr() -> String {
72    "0.0.0.0:8080".to_string()
73}
74
75fn default_sip_addr() -> String {
76    "0.0.0.0".to_string()
77}
78
79fn default_sip_port() -> u16 {
80    25060
81}
82
83fn default_config_rtp_start_port() -> Option<u16> {
84    Some(12000)
85}
86
87fn default_config_rtp_end_port() -> Option<u16> {
88    Some(42000)
89}
90
91fn default_config_rtp_latching() -> Option<bool> {
92    Some(true)
93}
94
95fn default_graceful_shutdown() -> Option<bool> {
96    Some(true)
97}
98
99fn default_graceful_shutdown_timeout() -> Option<u64> {
100    Some(30)
101}
102
103fn default_config_useragent() -> Option<String> {
104    Some(format!(
105        "active-call({} miuda.ai)",
106        env!("CARGO_PKG_VERSION")
107    ))
108}
109
110fn default_enable_options_response() -> Option<bool> {
111    Some(true)
112}
113
114fn default_options_allow_registered_servers() -> Option<bool> {
115    Some(true)
116}
117
118fn default_options_auto_learn() -> Option<bool> {
119    Some(true)
120}
121
122fn default_options_learn_ttl() -> Option<String> {
123    Some("7d".to_string())
124}
125
126fn default_codecs() -> Option<Vec<String>> {
127    let codecs = vec![
128        "pcmu".to_string(),
129        "pcma".to_string(),
130        "g722".to_string(),
131        "g729".to_string(),
132        "opus".to_string(),
133        "telephone_event".to_string(),
134    ];
135    Some(codecs)
136}
137
138#[derive(Debug, Clone, Deserialize, Serialize, Default)]
139#[serde(rename_all = "snake_case")]
140pub struct RecordingPolicy {
141    #[serde(default)]
142    pub enabled: bool,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub auto_start: Option<bool>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub filename_pattern: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub samplerate: Option<u32>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub ptime: Option<u32>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub path: Option<String>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub format: Option<RecorderFormat>,
155    /// Record at the source's native sample rate instead of resampling to
156    /// 16 kHz. The actual rate is detected from the caller leg's codec.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub native_samplerate: Option<bool>,
159}
160
161impl RecordingPolicy {
162    pub fn recorder_path(&self) -> String {
163        self.path
164            .as_ref()
165            .map(|p| p.trim())
166            .filter(|p| !p.is_empty())
167            .map(|p| p.to_string())
168            .unwrap_or_else(default_config_recorder_path)
169    }
170
171    pub fn recorder_format(&self) -> RecorderFormat {
172        self.format.unwrap_or_default()
173    }
174
175    pub fn ensure_defaults(&mut self) -> bool {
176        if self
177            .path
178            .as_ref()
179            .map(|p| p.trim().is_empty())
180            .unwrap_or(true)
181        {
182            self.path = Some(default_config_recorder_path());
183        }
184
185        false
186    }
187}
188
189#[derive(Debug, Clone, Deserialize, Serialize)]
190pub struct RewriteRule {
191    pub r#match: String,
192    pub rewrite: String,
193}
194
195/// Trunk-like SIP INVITE/REFER rewriting rule.
196///
197/// Rules are evaluated in declaration order and the first rule whose `match`
198/// conditions all hold (AND) is applied via its `rewrite` actions
199/// (first-match-wins). A rule with an empty `match` section always matches and
200/// therefore acts as a catch-all default.
201///
202/// ```toml
203/// [[trunk_rules]]
204/// rule.match.to.host = "^172\\.25\\."
205/// rule.rewrite.contact.host = "172.25.225.2"
206/// ```
207#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
208#[serde(rename_all = "snake_case")]
209pub struct TrunkRule {
210    pub rule: TrunkRuleDef,
211}
212
213#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
214#[serde(rename_all = "snake_case")]
215pub struct TrunkRuleDef {
216    #[serde(default, rename = "match")]
217    pub r#match: TrunkMatch,
218    pub rewrite: TrunkRewrite,
219}
220
221/// Match conditions for a trunk rule. All non-None fields must match (AND).
222/// `from` matches the SIP From header (caller), `to` matches the SIP To header
223/// and Request-URI (callee) of the outgoing INVITE/REFER.
224#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
225#[serde(rename_all = "snake_case")]
226pub struct TrunkMatch {
227    pub from: Option<UriMatch>,
228    pub to: Option<UriMatch>,
229}
230
231/// Matches a URI against optional regex patterns on its user and host parts.
232/// `user` matches the username before `@`; `host` matches the host after `@`.
233#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
234#[serde(rename_all = "snake_case")]
235pub struct UriMatch {
236    /// Regex matched against the SIP URI user part (before `@`).
237    pub user: Option<String>,
238    /// Regex matched against the SIP URI host part (after `@`).
239    pub host: Option<String>,
240}
241
242/// Rewrite actions applied once a rule matches. All non-None fields are applied.
243#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
244#[serde(rename_all = "snake_case")]
245pub struct TrunkRewrite {
246    pub from: Option<UriRewrite>,
247    pub to: Option<UriRewrite>,
248    pub contact: Option<UriRewrite>,
249}
250
251/// Rewrites the user and/or host of a SIP URI. When a `host` value carries no
252/// port (e.g. `"172.25.225.2"`), the original URI port is preserved; include a
253/// port (e.g. `"172.25.225.2:15060"`) to also change it.
254#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
255#[serde(rename_all = "snake_case")]
256pub struct UriRewrite {
257    pub user: Option<String>,
258    pub host: Option<String>,
259}
260
261impl TrunkRule {
262    fn matches(&self, invite: &InviteOption) -> bool {
263        self.rule.r#match.matches(invite)
264    }
265
266    fn apply(&self, invite: &mut InviteOption) {
267        self.rule.rewrite.apply(invite);
268    }
269}
270
271impl TrunkMatch {
272    fn matches(&self, invite: &InviteOption) -> bool {
273        let from_ok = self
274            .from
275            .as_ref()
276            .map(|from| from.matches(&invite.caller))
277            .unwrap_or(true);
278        let to_ok = self
279            .to
280            .as_ref()
281            .map(|to| to.matches(&invite.callee))
282            .unwrap_or(true);
283        from_ok && to_ok
284    }
285}
286
287impl UriMatch {
288    fn matches(&self, uri: &Uri) -> bool {
289        let user_ok = self
290            .user
291            .as_ref()
292            .map(|user| {
293                uri.auth
294                    .as_ref()
295                    .map(|auth| regex_is_match(user, &auth.user))
296                    .unwrap_or(false)
297            })
298            .unwrap_or(true);
299        let host_ok = self
300            .host
301            .as_ref()
302            .map(|host| regex_is_match(host, &uri.host_with_port.host.to_string()))
303            .unwrap_or(true);
304        user_ok && host_ok
305    }
306}
307
308impl TrunkRewrite {
309    fn apply(&self, invite: &mut InviteOption) {
310        if let Some(from) = &self.from {
311            from.apply(&mut invite.caller);
312        }
313        if let Some(to) = &self.to {
314            to.apply(&mut invite.callee);
315        }
316        if let Some(contact) = &self.contact {
317            contact.apply(&mut invite.contact);
318        }
319    }
320}
321
322impl UriRewrite {
323    fn apply(&self, uri: &mut Uri) {
324        if let Some(user) = &self.user {
325            let auth = uri.auth.get_or_insert_with(|| Auth {
326                user: String::new(),
327                password: None,
328            });
329            auth.user = user.clone();
330        }
331        if let Some(host) = &self.host
332            && let Ok(mut host_with_port) = HostWithPort::try_from(host.as_str())
333        {
334            // Preserve the original port when the rewrite value omits one.
335            if host_with_port.port.is_none() {
336                host_with_port.port = uri.host_with_port.port;
337            }
338            uri.host_with_port = host_with_port;
339        }
340    }
341}
342
343fn regex_is_match(pattern: &str, value: &str) -> bool {
344    regex::Regex::new(pattern)
345        .map(|re| re.is_match(value))
346        .unwrap_or(false)
347}
348
349/// Extracts the host part of a `register_users[].server` value, which may be
350/// `host`, `host:port`, or `[ipv6]:port`.
351fn host_of_server(server: &str) -> Option<String> {
352    let server = server.trim();
353    if server.is_empty() {
354        return None;
355    }
356    if let Some(rest) = server.strip_prefix('[') {
357        // [ipv6]:port or [ipv6]
358        return rest
359            .split(']')
360            .next()
361            .map(|h| h.to_ascii_lowercase());
362    }
363    // A bare ipv6 literal without brackets contains multiple colons; keep it whole.
364    if server.matches(':').count() > 1 {
365        return Some(server.to_ascii_lowercase());
366    }
367    server.split(':').next().map(|h| h.to_ascii_lowercase())
368}
369
370/// One parsed `[options_response].allow` entry: an exact IP, a CIDR block, or
371/// a hostname.
372#[derive(Debug, Clone, PartialEq)]
373pub enum OptionsAclEntry {
374    Ip(std::net::IpAddr),
375    Cidr(ipnet::IpNet),
376    Host(String),
377}
378
379impl OptionsAclEntry {
380    fn parse(raw: &str) -> Option<Self> {
381        let raw = raw.trim();
382        if raw.is_empty() {
383            return None;
384        }
385        if let Ok(ip) = raw.parse::<std::net::IpAddr>() {
386            return Some(Self::Ip(ip));
387        }
388        if let Ok(cidr) = raw.parse::<ipnet::IpNet>() {
389            return Some(Self::Cidr(cidr));
390        }
391        Some(Self::Host(raw.to_ascii_lowercase()))
392    }
393
394    pub fn matches(&self, source_ip: Option<std::net::IpAddr>, source_host: &str) -> bool {
395        match self {
396            Self::Ip(ip) => source_ip == Some(*ip),
397            Self::Cidr(net) => source_ip.map(|ip| net.contains(&ip)).unwrap_or(false),
398            Self::Host(host) => host.eq_ignore_ascii_case(source_host),
399        }
400    }
401}
402
403/// `[options_response]` — customizes the 200 OK answers to out-of-dialog
404/// OPTIONS keep-alive probes and restricts which sources are answered.
405///
406/// A probe is answered only when its source (top Via header) matches the
407/// static `allow` ACL, a `register_users[].server` host, or a peer address
408/// learned from call traffic (`auto_learn`). Everything else is dropped
409/// silently.
410#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
411#[serde(rename_all = "snake_case")]
412pub struct OptionsResponseConfig {
413    /// Answer out-of-dialog OPTIONS probes; falls back to the legacy
414    /// `enable_options_response` field, defaulting to `true`.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub enabled: Option<bool>,
417    /// Static ACL entries: exact IP (`139.224.72.64`), CIDR (`10.0.0.0/8`),
418    /// or hostname (`sip.ccc.aliyuncs.com`).
419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
420    pub allow: Vec<String>,
421    /// Also answer probes coming from the hosts of `register_users[].server`.
422    #[serde(default = "default_options_allow_registered_servers")]
423    pub allow_registered_servers: Option<bool>,
424    /// Learn peer addresses from call traffic — inbound INVITEs and responses
425    /// to our outbound INVITEs — and answer probes from those sources within
426    /// `learn_ttl` (strict mode: until the first peer is learned, only the
427    /// static ACL and registered hosts apply).
428    #[serde(default = "default_options_auto_learn")]
429    pub auto_learn: Option<bool>,
430    /// How long a learned peer address stays valid, e.g. `"7d"` (default).
431    #[serde(default = "default_options_learn_ttl")]
432    pub learn_ttl: Option<String>,
433    /// Extra headers appended to the OPTIONS 200 OK response.
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub extra_headers: Vec<(String, String)>,
436}
437
438#[derive(Debug, Deserialize, Serialize)]
439pub struct Config {
440    #[serde(default = "default_config_http_addr")]
441    pub http_addr: String,
442    pub addr: String,
443    pub udp_port: u16,
444    pub auto_learn_public_address: Option<bool>,
445
446    pub log_level: Option<String>,
447    pub log_file: Option<String>,
448    #[serde(default, skip_serializing_if = "Vec::is_empty")]
449    pub http_access_skip_paths: Vec<String>,
450
451    /// Peer active-call nodes ("ip:port" of their HTTP/WS endpoint).
452    ///
453    /// When a websocket client connects with a session id that is not hosted on
454    /// this node, the originator (empty `forward`) polls each peer once
455    /// (`forward=true`) and tunnels the websocket to the peer that hosts the
456    /// call. Any request whose `forward` is set must not hop further.
457    #[serde(default, skip_serializing_if = "Vec::is_empty")]
458    pub peers: Vec<String>,
459
460    #[serde(default = "default_config_useragent")]
461    pub useragent: Option<String>,
462    pub register_users: Option<Vec<RegisterOption>>,
463    #[serde(default = "default_graceful_shutdown")]
464    pub graceful_shutdown: Option<bool>,
465    /// Total seconds to wait during graceful shutdown before forcing exit.
466    /// SIP de-registration and active call draining run in parallel, both with this timeout.
467    #[serde(default = "default_graceful_shutdown_timeout")]
468    pub graceful_shutdown_timeout: Option<u64>,
469    pub handler: Option<InviteHandlerConfig>,
470    pub accept_timeout: Option<String>,
471    #[serde(default = "default_codecs")]
472    pub codecs: Option<Vec<String>>,
473    pub external_ip: Option<String>,
474    #[serde(default = "default_config_rtp_start_port")]
475    pub rtp_start_port: Option<u16>,
476    #[serde(default = "default_config_rtp_end_port")]
477    pub rtp_end_port: Option<u16>,
478    #[serde(default = "default_config_rtp_latching")]
479    pub enable_rtp_latching: Option<bool>,
480    pub enable_ice_lite: Option<bool>,
481    pub rtp_bind_ip: Option<String>,
482    pub tls_port: Option<u16>,
483    pub tls_cert_file: Option<String>,
484    pub tls_key_file: Option<String>,
485
486    pub enable_srtp: Option<bool>,
487
488    pub callrecord: Option<CallRecordConfig>,
489    #[serde(default = "default_config_media_cache_path")]
490    pub media_cache_path: String,
491    pub ambiance: Option<AmbianceOption>,
492    pub ice_servers: Option<Vec<IceServer>>,
493    #[serde(default)]
494    pub recording: Option<RecordingPolicy>,
495    pub rewrites: Option<Vec<RewriteRule>>,
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub trunk_rules: Option<Vec<TrunkRule>>,
498    #[serde(default = "default_enable_options_response")]
499    pub enable_options_response: Option<bool>,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub options_response: Option<OptionsResponseConfig>,
502}
503
504#[derive(Debug, Deserialize, Clone, Serialize)]
505#[serde(rename_all = "snake_case")]
506#[serde(tag = "type")]
507pub enum InviteHandlerConfig {
508    Webhook {
509        url: Option<String>,
510        urls: Option<Vec<String>>,
511        method: Option<String>,
512        headers: Option<Vec<(String, String)>>,
513    },
514    Playbook {
515        rules: Option<Vec<PlaybookRule>>,
516        default: Option<String>,
517    },
518}
519
520#[derive(Debug, Deserialize, Clone, Serialize)]
521#[serde(rename_all = "snake_case")]
522pub struct PlaybookRule {
523    pub caller: Option<String>,
524    pub callee: Option<String>,
525    pub playbook: String,
526}
527
528#[derive(Debug, Deserialize, Clone, Serialize)]
529#[serde(rename_all = "snake_case")]
530pub enum S3Vendor {
531    Aliyun,
532    Tencent,
533    Minio,
534    AWS,
535    GCP,
536    Azure,
537    DigitalOcean,
538}
539
540#[derive(Debug, Deserialize, Clone, Serialize)]
541#[serde(tag = "type")]
542#[serde(rename_all = "snake_case")]
543pub enum CallRecordConfig {
544    Local {
545        root: String,
546    },
547    S3 {
548        vendor: S3Vendor,
549        bucket: String,
550        region: String,
551        access_key: String,
552        secret_key: String,
553        endpoint: String,
554        root: String,
555        with_media: Option<bool>,
556        keep_media_copy: Option<bool>,
557    },
558    Http {
559        url: String,
560        headers: Option<HashMap<String, String>>,
561        with_media: Option<bool>,
562        keep_media_copy: Option<bool>,
563    },
564}
565
566impl Default for CallRecordConfig {
567    fn default() -> Self {
568        Self::Local {
569            #[cfg(target_os = "windows")]
570            root: "./config/cdr".to_string(),
571            #[cfg(not(target_os = "windows"))]
572            root: "./config/cdr".to_string(),
573        }
574    }
575}
576
577impl Default for Config {
578    fn default() -> Self {
579        Self {
580            http_addr: default_config_http_addr(),
581            log_level: None,
582            log_file: None,
583            http_access_skip_paths: Vec::new(),
584            peers: Vec::new(),
585            addr: default_sip_addr(),
586            udp_port: default_sip_port(),
587            auto_learn_public_address: None,
588            useragent: None,
589            register_users: None,
590            graceful_shutdown: Some(true),
591            graceful_shutdown_timeout: default_graceful_shutdown_timeout(),
592            handler: None,
593            accept_timeout: Some("50s".to_string()),
594            media_cache_path: default_config_media_cache_path(),
595            ambiance: None,
596            callrecord: None,
597            ice_servers: None,
598            codecs: None,
599            external_ip: None,
600            rtp_start_port: default_config_rtp_start_port(),
601            rtp_end_port: default_config_rtp_end_port(),
602            enable_rtp_latching: Some(true),
603            rtp_bind_ip: None,
604            enable_ice_lite: None,
605            tls_port: None,
606            tls_cert_file: None,
607            tls_key_file: None,
608            enable_srtp: None,
609            recording: None,
610            rewrites: None,
611            trunk_rules: None,
612            enable_options_response: default_enable_options_response(),
613            options_response: None,
614        }
615    }
616}
617
618impl Clone for Config {
619    fn clone(&self) -> Self {
620        // This is a bit expensive but Config is not cloned often in hot paths
621        // and implementing Clone manually for all nested structs is tedious
622        let s = toml::to_string(self).unwrap();
623        toml::from_str(&s).unwrap()
624    }
625}
626
627impl Config {
628    pub fn load(path: &str) -> Result<Self, Error> {
629        let config: Self = toml::from_str(
630            &std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("{}: {}", e, path))?,
631        )?;
632        Ok(config)
633    }
634
635    /// Whether out-of-dialog OPTIONS probes should be answered at all.
636    pub fn options_response_enabled(&self) -> bool {
637        self.options_response
638            .as_ref()
639            .and_then(|o| o.enabled)
640            .or(self.enable_options_response)
641            .unwrap_or(true)
642    }
643
644    /// Whether peer addresses should be learned from call traffic.
645    pub fn options_auto_learn(&self) -> bool {
646        self.options_response
647            .as_ref()
648            .and_then(|o| o.auto_learn)
649            .unwrap_or(true)
650    }
651
652    /// TTL for learned peer addresses; unparsable/missing values default to 7 days.
653    pub fn options_learn_ttl(&self) -> std::time::Duration {
654        const DEFAULT: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 3600);
655        self.options_response
656            .as_ref()
657            .and_then(|o| o.learn_ttl.as_deref())
658            .map(|raw| {
659                humantime::parse_duration(raw).unwrap_or(DEFAULT)
660            })
661            .unwrap_or(DEFAULT)
662    }
663
664    /// Parsed static ACL entries for OPTIONS probing.
665    pub fn options_acl_entries(&self) -> Vec<OptionsAclEntry> {
666        self.options_response
667            .as_ref()
668            .map(|o| o.allow.iter().filter_map(|raw| OptionsAclEntry::parse(raw)).collect())
669            .unwrap_or_default()
670    }
671
672    /// Hosts of `register_users[].server` when `allow_registered_servers` is
673    /// on (the default); empty otherwise.
674    pub fn options_registered_hosts(&self) -> Vec<String> {
675        let enabled = self
676            .options_response
677            .as_ref()
678            .and_then(|o| o.allow_registered_servers)
679            .unwrap_or(true);
680        if !enabled {
681            return vec![];
682        }
683        self.register_users
684            .as_ref()
685            .map(|users| {
686                users
687                    .iter()
688                    .filter_map(|user| host_of_server(&user.server))
689                    .collect()
690            })
691            .unwrap_or_default()
692    }
693
694    /// Extra headers appended to OPTIONS 200 OK responses.
695    pub fn options_extra_headers(&self) -> Vec<(String, String)> {
696        self.options_response
697            .as_ref()
698            .map(|o| o.extra_headers.clone())
699            .unwrap_or_default()
700    }
701
702    /// Whether `source_ip`/`source_host` matches the static ACL or the
703    /// registered-server hosts (the configured, non-learned allow sets).
704    pub fn options_matches_static(&self, source_ip: Option<std::net::IpAddr>, source_host: &str) -> bool {
705        self.options_acl_entries()
706            .iter()
707            .any(|entry| entry.matches(source_ip, source_host))
708            || self
709                .options_registered_hosts()
710                .iter()
711                .any(|host| host.eq_ignore_ascii_case(source_host))
712    }
713
714    pub fn recorder_path(&self) -> String {
715        self.recording
716            .as_ref()
717            .map(|policy| policy.recorder_path())
718            .unwrap_or_else(default_config_recorder_path)
719    }
720
721    pub fn recorder_format(&self) -> RecorderFormat {
722        self.recording
723            .as_ref()
724            .map(|policy| policy.recorder_format())
725            .unwrap_or_default()
726    }
727
728    pub fn recorder_native_samplerate(&self) -> bool {
729        self.recording
730            .as_ref()
731            .and_then(|policy| policy.native_samplerate)
732            .unwrap_or(false)
733    }
734
735    pub fn ensure_recording_defaults(&mut self) -> bool {
736        let mut fallback = false;
737
738        if let Some(policy) = self.recording.as_mut() {
739            fallback |= policy.ensure_defaults();
740        }
741
742        fallback
743    }
744
745    /// Apply the first matching trunk rule to an outgoing SIP INVITE/REFER.
746    ///
747    /// Rules are evaluated in declaration order; the first rule whose `match`
748    /// conditions (all AND) hold is applied and later rules are skipped
749    /// (first-match-wins). A rule with an empty `match` section always matches
750    /// and acts as a catch-all default. If no rule matches, the invite is left
751    /// untouched. Does nothing when [`Config::trunk_rules`] is not configured.
752    pub fn apply_trunk_rules(&self, invite: &mut InviteOption) {
753        if let Some(rules) = &self.trunk_rules {
754            for rule in rules {
755                if rule.matches(invite) {
756                    rule.apply(invite);
757                    break;
758                }
759            }
760        }
761    }
762
763    /// Normalize a configured peer to a `ws://` (or `wss://`) base URL.
764    ///
765    /// Accepts bare `ip:port`, or an explicit `ws://`, `wss://`, `http://` or
766    /// `https://` scheme. Returns `None` when the value is empty or unusable.
767    pub fn peer_ws_endpoint(peer: &str) -> Option<String> {
768        let peer = peer.trim();
769        if peer.is_empty() {
770            return None;
771        }
772        if peer.starts_with("wss://") {
773            return Some(peer.to_string());
774        }
775        if peer.starts_with("ws://") {
776            return Some(peer.to_string());
777        }
778        if peer.starts_with("https://") {
779            return Some(peer.replacen("https://", "wss://", 1));
780        }
781        if peer.starts_with("http://") {
782            return Some(peer.replacen("http://", "ws://", 1));
783        }
784        Some(format!("ws://{}", peer))
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    #[test]
793    fn test_playbook_handler_config_parsing() {
794        let toml_config = r#"
795http_addr = "0.0.0.0:8080"
796addr = "0.0.0.0"
797udp_port = 25060
798
799[handler]
800type = "playbook"
801default = "default.md"
802
803[[handler.rules]]
804caller = "^\\+1\\d{10}$"
805callee = "^sip:support@.*"
806playbook = "support.md"
807
808[[handler.rules]]
809caller = "^\\+86\\d+"
810playbook = "chinese.md"
811
812[[handler.rules]]
813callee = "^sip:sales@.*"
814playbook = "sales.md"
815"#;
816
817        let config: Config = toml::from_str(toml_config).unwrap();
818
819        assert!(config.handler.is_some());
820        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
821            assert_eq!(default, Some("default.md".to_string()));
822            let rules = rules.unwrap();
823            assert_eq!(rules.len(), 3);
824
825            assert_eq!(rules[0].caller, Some(r"^\+1\d{10}$".to_string()));
826            assert_eq!(rules[0].callee, Some("^sip:support@.*".to_string()));
827            assert_eq!(rules[0].playbook, "support.md");
828
829            assert_eq!(rules[1].caller, Some(r"^\+86\d+".to_string()));
830            assert_eq!(rules[1].callee, None);
831            assert_eq!(rules[1].playbook, "chinese.md");
832
833            assert_eq!(rules[2].caller, None);
834            assert_eq!(rules[2].callee, Some("^sip:sales@.*".to_string()));
835            assert_eq!(rules[2].playbook, "sales.md");
836        } else {
837            panic!("Expected Playbook handler config");
838        }
839    }
840
841    #[test]
842    fn test_playbook_handler_config_without_default() {
843        let toml_config = r#"
844http_addr = "0.0.0.0:8080"
845addr = "0.0.0.0"
846udp_port = 25060
847
848[handler]
849type = "playbook"
850
851[[handler.rules]]
852caller = "^\\+1.*"
853playbook = "us.md"
854"#;
855
856        let config: Config = toml::from_str(toml_config).unwrap();
857
858        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
859            assert_eq!(default, None);
860            let rules = rules.unwrap();
861            assert_eq!(rules.len(), 1);
862        } else {
863            panic!("Expected Playbook handler config");
864        }
865    }
866
867    #[test]
868    fn test_webhook_handler_config_still_works() {
869        let toml_config = r#"
870http_addr = "0.0.0.0:8080"
871addr = "0.0.0.0"
872udp_port = 25060
873
874[handler]
875type = "webhook"
876url = "http://example.com/webhook"
877"#;
878
879        let config: Config = toml::from_str(toml_config).unwrap();
880
881        if let Some(InviteHandlerConfig::Webhook { url, .. }) = config.handler {
882            assert_eq!(url, Some("http://example.com/webhook".to_string()));
883        } else {
884            panic!("Expected Webhook handler config");
885        }
886    }
887
888    // ---------------------------------------------------------------------------
889    // options_response: config parsing + accessors
890    // ---------------------------------------------------------------------------
891
892    #[test]
893    fn test_options_response_defaults() {
894        let config: Config = toml::from_str(
895            r#"
896http_addr = "0.0.0.0:8080"
897addr = "0.0.0.0"
898udp_port = 25060
899"#,
900        )
901        .unwrap();
902
903        assert!(config.options_response.is_none());
904        // Section absent: enabled via the legacy field/default, auto-learn on,
905        // 7d TTL, no static entries, no extra headers.
906        assert!(config.options_response_enabled());
907        assert!(config.options_auto_learn());
908        assert_eq!(
909            config.options_learn_ttl(),
910            std::time::Duration::from_secs(7 * 24 * 3600)
911        );
912        assert!(config.options_acl_entries().is_empty());
913        assert!(config.options_registered_hosts().is_empty());
914        assert!(config.options_extra_headers().is_empty());
915    }
916
917    #[test]
918    fn test_options_response_config_parsing() {
919        let toml_config = r#"
920http_addr = "0.0.0.0:8080"
921addr = "0.0.0.0"
922udp_port = 25060
923
924[options_response]
925enabled = true
926allow = ["139.224.72.64", "10.0.0.0/8", "SIP.CCC.AliyunCS.com"]
927allow_registered_servers = false
928auto_learn = false
929learn_ttl = "1h"
930extra_headers = [["X-Node-Id", "node-1"], ["User-Agent", "ActiveCall-Test"]]
931"#;
932
933        let config: Config = toml::from_str(toml_config).unwrap();
934        let options = config.options_response.as_ref().unwrap();
935        assert_eq!(options.enabled, Some(true));
936        assert_eq!(options.allow_registered_servers, Some(false));
937        assert_eq!(options.auto_learn, Some(false));
938
939        assert!(!config.options_auto_learn());
940        assert_eq!(
941            config.options_learn_ttl(),
942            std::time::Duration::from_secs(3600)
943        );
944
945        let entries = config.options_acl_entries();
946        assert_eq!(entries.len(), 3);
947        assert_eq!(
948            entries[0],
949            OptionsAclEntry::Ip("139.224.72.64".parse().unwrap())
950        );
951        assert!(matches!(entries[1], OptionsAclEntry::Cidr(_)));
952        assert_eq!(
953            entries[2],
954            OptionsAclEntry::Host("sip.ccc.aliyuncs.com".to_string())
955        );
956
957        let headers = config.options_extra_headers();
958        assert_eq!(headers.len(), 2);
959        assert_eq!(headers[0], ("X-Node-Id".to_string(), "node-1".to_string()));
960    }
961
962    #[test]
963    fn test_options_response_enabled_fallback_chain() {
964        // Legacy field only.
965        let config: Config = toml::from_str(
966            r#"
967addr = "0.0.0.0"
968udp_port = 25060
969enable_options_response = false
970"#,
971        )
972        .unwrap();
973        assert!(!config.options_response_enabled());
974
975        // New section wins over the legacy field.
976        let config: Config = toml::from_str(
977            r#"
978addr = "0.0.0.0"
979udp_port = 25060
980enable_options_response = false
981
982[options_response]
983enabled = true
984"#,
985        )
986        .unwrap();
987        assert!(config.options_response_enabled());
988
989        // New section with enabled unset falls back to the legacy field.
990        let config: Config = toml::from_str(
991            r#"
992addr = "0.0.0.0"
993udp_port = 25060
994enable_options_response = false
995
996[options_response]
997extra_headers = [["X-A", "1"]]
998"#,
999        )
1000        .unwrap();
1001        assert!(!config.options_response_enabled());
1002    }
1003
1004    #[test]
1005    fn test_options_acl_matching() {
1006        let toml_config = r#"
1007addr = "0.0.0.0"
1008udp_port = 25060
1009
1010[options_response]
1011allow = ["139.224.72.64", "10.0.0.0/8", "sip.ccc.aliyuncs.com"]
1012"#;
1013        let config: Config = toml::from_str(toml_config).unwrap();
1014        let ip = |s: &str| Some(s.parse::<std::net::IpAddr>().unwrap());
1015
1016        // Exact IP.
1017        assert!(config.options_matches_static(ip("139.224.72.64"), "139.224.72.64"));
1018        assert!(!config.options_matches_static(ip("139.224.72.65"), "139.224.72.65"));
1019        // CIDR.
1020        assert!(config.options_matches_static(ip("10.1.2.3"), "10.1.2.3"));
1021        assert!(!config.options_matches_static(ip("192.168.1.1"), "192.168.1.1"));
1022        // Hostname, case-insensitive.
1023        assert!(config
1024            .options_matches_static(None, "SIP.CCC.ALIYUNCS.COM"));
1025        assert!(!config.options_matches_static(None, "sip.example.com"));
1026        // An IP source does not match the hostname entry and vice versa.
1027        assert!(!config.options_matches_static(None, "139.224.72.64"));
1028    }
1029
1030    #[test]
1031    fn test_options_registered_hosts() {
1032        let toml_config = r#"
1033addr = "0.0.0.0"
1034udp_port = 25060
1035
1036[[register_users]]
1037server = "sip.example.com:5060"
1038username = "1001"
1039
1040[[register_users]]
1041server = "10.0.0.5"
1042username = "1002"
1043"#;
1044        let config: Config = toml::from_str(toml_config).unwrap();
1045        // Default: registered servers are allowed.
1046        assert_eq!(
1047            config.options_registered_hosts(),
1048            vec![
1049                "sip.example.com".to_string(),
1050                "10.0.0.5".to_string()
1051            ]
1052        );
1053        assert!(config.options_matches_static(None, "SIP.EXAMPLE.COM"));
1054        assert!(config.options_matches_static(
1055            Some("10.0.0.5".parse().unwrap()),
1056            "10.0.0.5"
1057        ));
1058
1059        // Opt out via allow_registered_servers = false.
1060        let toml_config_off = r#"
1061addr = "0.0.0.0"
1062udp_port = 25060
1063
1064[[register_users]]
1065server = "sip.example.com"
1066username = "1001"
1067
1068[options_response]
1069allow_registered_servers = false
1070"#;
1071        let config: Config = toml::from_str(toml_config_off).unwrap();
1072        assert!(config.options_registered_hosts().is_empty());
1073        assert!(!config.options_matches_static(None, "sip.example.com"));
1074    }
1075
1076    #[test]
1077    fn test_host_of_server() {
1078        assert_eq!(
1079            host_of_server("sip.example.com:5060"),
1080            Some("sip.example.com".to_string())
1081        );
1082        assert_eq!(
1083            host_of_server("SIP.Example.COM"),
1084            Some("sip.example.com".to_string())
1085        );
1086        assert_eq!(
1087            host_of_server("[2001:db8::1]:5060"),
1088            Some("2001:db8::1".to_string())
1089        );
1090        assert_eq!(
1091            host_of_server("2001:db8::1"),
1092            Some("2001:db8::1".to_string())
1093        );
1094        assert_eq!(host_of_server(""), None);
1095    }
1096
1097    // ---------------------------------------------------------------------------
1098    // trunk_rules: config parsing
1099    // ---------------------------------------------------------------------------
1100
1101    #[test]
1102    fn test_trunk_rules_config_parsing() {
1103        let toml_config = r#"
1104http_addr = "0.0.0.0:8080"
1105addr = "0.0.0.0"
1106udp_port = 25060
1107
1108[[trunk_rules]]
1109rule.match.to.host = "^172\\.25\\."
1110rule.rewrite.contact.host = "172.25.225.2"
1111
1112[[trunk_rules]]
1113rule.match.from.user = "^\\+86.*"
1114rule.match.to.host = "^10\\."
1115rule.rewrite.from.user = "10086"
1116rule.rewrite.to.host = "10.0.0.1"
1117rule.rewrite.contact.user = "active-call"
1118rule.rewrite.contact.host = "10.0.0.1:25060"
1119
1120[[trunk_rules]]
1121rule.rewrite.contact.host = "116.62.75.161"
1122"#;
1123
1124        let config: Config = toml::from_str(toml_config).unwrap();
1125        let rules = config.trunk_rules.expect("trunk_rules should be parsed");
1126
1127        assert_eq!(rules.len(), 3);
1128
1129        // Rule 1: match to.host, rewrite contact.host
1130        let m = &rules[0].rule.r#match;
1131        assert_eq!(m.to.as_ref().unwrap().host.as_deref(), Some("^172\\.25\\."));
1132        assert_eq!(m.to.as_ref().unwrap().user, None);
1133        assert_eq!(m.from, None);
1134        let w = &rules[0].rule.rewrite;
1135        assert_eq!(
1136            w.contact.as_ref().unwrap().host.as_deref(),
1137            Some("172.25.225.2")
1138        );
1139        assert_eq!(w.from, None);
1140        assert_eq!(w.to, None);
1141
1142        // Rule 2: combined match + all rewrites
1143        let m = &rules[1].rule.r#match;
1144        assert_eq!(m.from.as_ref().unwrap().user.as_deref(), Some("^\\+86.*"));
1145        assert_eq!(m.to.as_ref().unwrap().host.as_deref(), Some("^10\\."));
1146        let w = &rules[1].rule.rewrite;
1147        assert_eq!(w.from.as_ref().unwrap().user.as_deref(), Some("10086"));
1148        assert_eq!(w.to.as_ref().unwrap().host.as_deref(), Some("10.0.0.1"));
1149        assert_eq!(
1150            w.contact.as_ref().unwrap().user.as_deref(),
1151            Some("active-call")
1152        );
1153        assert_eq!(
1154            w.contact.as_ref().unwrap().host.as_deref(),
1155            Some("10.0.0.1:25060")
1156        );
1157
1158        // Rule 3: catch-all (empty match)
1159        assert_eq!(rules[2].rule.r#match.from, None);
1160        assert_eq!(rules[2].rule.r#match.to, None);
1161        assert_eq!(
1162            rules[2]
1163                .rule
1164                .rewrite
1165                .contact
1166                .as_ref()
1167                .unwrap()
1168                .host
1169                .as_deref(),
1170            Some("116.62.75.161")
1171        );
1172    }
1173
1174    #[test]
1175    fn test_trunk_rules_absent_by_default() {
1176        let config = Config::default();
1177        assert!(config.trunk_rules.is_none());
1178    }
1179
1180    // ---------------------------------------------------------------------------
1181    // trunk_rules: match + rewrite behaviour
1182    // ---------------------------------------------------------------------------
1183
1184    fn invite_option(caller: &str, callee: &str, contact: &str) -> InviteOption {
1185        InviteOption {
1186            caller: caller.try_into().unwrap(),
1187            callee: callee.try_into().unwrap(),
1188            contact: contact.try_into().unwrap(),
1189            ..Default::default()
1190        }
1191    }
1192
1193    #[test]
1194    fn test_trunk_rule_matches_on_to_host() {
1195        let config: Config = toml::from_str(
1196            r#"
1197addr = "0.0.0.0"
1198udp_port = 25060
1199
1200[[trunk_rules]]
1201rule.match.to.host = "^172\\.25\\."
1202rule.rewrite.contact.host = "172.25.225.2"
1203"#,
1204        )
1205        .unwrap();
1206
1207        // Internal target -> matched, contact host rewritten.
1208        let mut invite = invite_option(
1209            "sip:ai@116.62.75.161:13050",
1210            "sip:agent1@172.25.225.3:15060",
1211            "sip:ai@127.0.0.1:13050",
1212        );
1213        config.apply_trunk_rules(&mut invite);
1214        assert_eq!(
1215            invite.contact.host_with_port.host.to_string(),
1216            "172.25.225.2"
1217        );
1218
1219        // External target -> no match, contact untouched.
1220        let mut invite = invite_option(
1221            "sip:ai@116.62.75.161:13050",
1222            "sip:+8613800138000@sbc.example.com:5060",
1223            "sip:ai@127.0.0.1:13050",
1224        );
1225        config.apply_trunk_rules(&mut invite);
1226        assert_eq!(invite.contact.host_with_port.host.to_string(), "127.0.0.1");
1227    }
1228
1229    #[test]
1230    fn test_trunk_rule_matches_on_from_user_and_to_host() {
1231        let config: Config = toml::from_str(
1232            r#"
1233addr = "0.0.0.0"
1234udp_port = 25060
1235
1236[[trunk_rules]]
1237rule.match.from.user = "^anonymous$"
1238rule.match.to.host = "^sbc\\."
1239rule.rewrite.contact.host = "116.62.75.161"
1240"#,
1241        )
1242        .unwrap();
1243
1244        // from.user + to.host both match -> rewritten.
1245        let mut invite = invite_option(
1246            "sip:anonymous@116.62.75.161:13050",
1247            "sip:+8613800138000@sbc.example.com:5060",
1248            "sip:ai@127.0.0.1:13050",
1249        );
1250        config.apply_trunk_rules(&mut invite);
1251        assert_eq!(
1252            invite.contact.host_with_port.host.to_string(),
1253            "116.62.75.161"
1254        );
1255
1256        // from.user does not match -> no rewrite.
1257        let mut invite = invite_option(
1258            "sip:alice@116.62.75.161:13050",
1259            "sip:+8613800138000@sbc.example.com:5060",
1260            "sip:ai@127.0.0.1:13050",
1261        );
1262        config.apply_trunk_rules(&mut invite);
1263        assert_eq!(invite.contact.host_with_port.host.to_string(), "127.0.0.1");
1264    }
1265
1266    #[test]
1267    fn test_trunk_rule_rewrites_from_to_contact() {
1268        let config: Config = toml::from_str(
1269            r#"
1270addr = "0.0.0.0"
1271udp_port = 25060
1272
1273[[trunk_rules]]
1274rule.match.to.host = "^10\\."
1275rule.rewrite.from.user = "10086"
1276rule.rewrite.from.host = "116.62.75.161"
1277rule.rewrite.to.user = "30000"
1278rule.rewrite.to.host = "10.0.0.1:25060"
1279rule.rewrite.contact.user = "active-call"
1280rule.rewrite.contact.host = "10.0.0.1"
1281"#,
1282        )
1283        .unwrap();
1284
1285        let mut invite = invite_option(
1286            "sip:ai@127.0.0.1:13050",
1287            "sip:agent1@10.0.0.2:25060",
1288            "sip:ai@127.0.0.1:13050",
1289        );
1290        config.apply_trunk_rules(&mut invite);
1291
1292        // caller (from)
1293        assert_eq!(invite.caller.auth.as_ref().unwrap().user, "10086");
1294        assert_eq!(
1295            invite.caller.host_with_port.host.to_string(),
1296            "116.62.75.161"
1297        );
1298
1299        // callee (to)
1300        assert_eq!(invite.callee.auth.as_ref().unwrap().user, "30000");
1301        assert_eq!(invite.callee.host_with_port.host.to_string(), "10.0.0.1");
1302        assert_eq!(invite.callee.host_with_port.port.unwrap().0, 25060);
1303
1304        // contact: host rewritten without port -> original port preserved
1305        assert_eq!(invite.contact.auth.as_ref().unwrap().user, "active-call");
1306        assert_eq!(invite.contact.host_with_port.host.to_string(), "10.0.0.1");
1307        assert_eq!(invite.contact.host_with_port.port.unwrap().0, 13050);
1308    }
1309
1310    #[test]
1311    fn test_trunk_rule_catch_all_default() {
1312        let config: Config = toml::from_str(
1313            r#"
1314addr = "0.0.0.0"
1315udp_port = 25060
1316
1317[[trunk_rules]]
1318rule.match.to.host = "^172\\.25\\."
1319rule.rewrite.contact.host = "172.25.225.2"
1320
1321[[trunk_rules]]
1322rule.rewrite.contact.host = "116.62.75.161"
1323"#,
1324        )
1325        .unwrap();
1326
1327        // Not matched by rule 1 -> falls through to catch-all rule 2.
1328        let mut invite = invite_option(
1329            "sip:ai@116.62.75.161:13050",
1330            "sip:+8613800138000@sbc.example.com:5060",
1331            "sip:ai@127.0.0.1:13050",
1332        );
1333        config.apply_trunk_rules(&mut invite);
1334        assert_eq!(
1335            invite.contact.host_with_port.host.to_string(),
1336            "116.62.75.161"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_trunk_rule_first_match_wins() {
1342        let config: Config = toml::from_str(
1343            r#"
1344addr = "0.0.0.0"
1345udp_port = 25060
1346
1347[[trunk_rules]]
1348rule.match.to.host = ".*"
1349rule.rewrite.contact.host = "1.1.1.1"
1350
1351[[trunk_rules]]
1352rule.match.to.host = ".*"
1353rule.rewrite.contact.host = "2.2.2.2"
1354"#,
1355        )
1356        .unwrap();
1357
1358        let mut invite = invite_option(
1359            "sip:ai@116.62.75.161:13050",
1360            "sip:any@example.com:5060",
1361            "sip:ai@127.0.0.1:13050",
1362        );
1363        config.apply_trunk_rules(&mut invite);
1364        // Only the first rule applies.
1365        assert_eq!(invite.contact.host_with_port.host.to_string(), "1.1.1.1");
1366    }
1367
1368    #[test]
1369    fn test_trunk_rule_no_config_noop() {
1370        let config = Config::default();
1371        let mut invite = invite_option(
1372            "sip:ai@116.62.75.161:13050",
1373            "sip:agent1@172.25.225.3:15060",
1374            "sip:ai@127.0.0.1:13050",
1375        );
1376        let before = invite.contact.to_string();
1377        config.apply_trunk_rules(&mut invite);
1378        assert_eq!(invite.contact.to_string(), before);
1379    }
1380
1381    #[test]
1382    fn test_trunk_rule_rewrite_missing_auth_creates_user() {
1383        let config: Config = toml::from_str(
1384            r#"
1385addr = "0.0.0.0"
1386udp_port = 25060
1387
1388[[trunk_rules]]
1389rule.match.to.host = ".*"
1390rule.rewrite.contact.user = "active-call"
1391"#,
1392        )
1393        .unwrap();
1394
1395        // A contact without a user part (e.g. "sip:127.0.0.1:13050").
1396        let mut invite = InviteOption {
1397            caller: "sip:ai@127.0.0.1:13050".try_into().unwrap(),
1398            callee: "sip:agent1@172.25.225.3:15060".try_into().unwrap(),
1399            contact: "sip:127.0.0.1:13050".try_into().unwrap(),
1400            ..Default::default()
1401        };
1402        config.apply_trunk_rules(&mut invite);
1403        assert_eq!(invite.contact.auth.as_ref().unwrap().user, "active-call");
1404    }
1405}