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_codecs() -> Option<Vec<String>> {
115    let codecs = vec![
116        "pcmu".to_string(),
117        "pcma".to_string(),
118        "g722".to_string(),
119        "g729".to_string(),
120        "opus".to_string(),
121        "telephone_event".to_string(),
122    ];
123    Some(codecs)
124}
125
126#[derive(Debug, Clone, Deserialize, Serialize, Default)]
127#[serde(rename_all = "snake_case")]
128pub struct RecordingPolicy {
129    #[serde(default)]
130    pub enabled: bool,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub auto_start: Option<bool>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub filename_pattern: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub samplerate: Option<u32>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub ptime: Option<u32>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub path: Option<String>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub format: Option<RecorderFormat>,
143}
144
145impl RecordingPolicy {
146    pub fn recorder_path(&self) -> String {
147        self.path
148            .as_ref()
149            .map(|p| p.trim())
150            .filter(|p| !p.is_empty())
151            .map(|p| p.to_string())
152            .unwrap_or_else(default_config_recorder_path)
153    }
154
155    pub fn recorder_format(&self) -> RecorderFormat {
156        self.format.unwrap_or_default()
157    }
158
159    pub fn ensure_defaults(&mut self) -> bool {
160        if self
161            .path
162            .as_ref()
163            .map(|p| p.trim().is_empty())
164            .unwrap_or(true)
165        {
166            self.path = Some(default_config_recorder_path());
167        }
168
169        false
170    }
171}
172
173#[derive(Debug, Clone, Deserialize, Serialize)]
174pub struct RewriteRule {
175    pub r#match: String,
176    pub rewrite: String,
177}
178
179/// Trunk-like SIP INVITE/REFER rewriting rule.
180///
181/// Rules are evaluated in declaration order and the first rule whose `match`
182/// conditions all hold (AND) is applied via its `rewrite` actions
183/// (first-match-wins). A rule with an empty `match` section always matches and
184/// therefore acts as a catch-all default.
185///
186/// ```toml
187/// [[trunk_rules]]
188/// rule.match.to.host = "^172\\.25\\."
189/// rule.rewrite.contact.host = "172.25.225.2"
190/// ```
191#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
192#[serde(rename_all = "snake_case")]
193pub struct TrunkRule {
194    pub rule: TrunkRuleDef,
195}
196
197#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
198#[serde(rename_all = "snake_case")]
199pub struct TrunkRuleDef {
200    #[serde(default, rename = "match")]
201    pub r#match: TrunkMatch,
202    pub rewrite: TrunkRewrite,
203}
204
205/// Match conditions for a trunk rule. All non-None fields must match (AND).
206/// `from` matches the SIP From header (caller), `to` matches the SIP To header
207/// and Request-URI (callee) of the outgoing INVITE/REFER.
208#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
209#[serde(rename_all = "snake_case")]
210pub struct TrunkMatch {
211    pub from: Option<UriMatch>,
212    pub to: Option<UriMatch>,
213}
214
215/// Matches a URI against optional regex patterns on its user and host parts.
216/// `user` matches the username before `@`; `host` matches the host after `@`.
217#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
218#[serde(rename_all = "snake_case")]
219pub struct UriMatch {
220    /// Regex matched against the SIP URI user part (before `@`).
221    pub user: Option<String>,
222    /// Regex matched against the SIP URI host part (after `@`).
223    pub host: Option<String>,
224}
225
226/// Rewrite actions applied once a rule matches. All non-None fields are applied.
227#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
228#[serde(rename_all = "snake_case")]
229pub struct TrunkRewrite {
230    pub from: Option<UriRewrite>,
231    pub to: Option<UriRewrite>,
232    pub contact: Option<UriRewrite>,
233}
234
235/// Rewrites the user and/or host of a SIP URI. When a `host` value carries no
236/// port (e.g. `"172.25.225.2"`), the original URI port is preserved; include a
237/// port (e.g. `"172.25.225.2:15060"`) to also change it.
238#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)]
239#[serde(rename_all = "snake_case")]
240pub struct UriRewrite {
241    pub user: Option<String>,
242    pub host: Option<String>,
243}
244
245impl TrunkRule {
246    fn matches(&self, invite: &InviteOption) -> bool {
247        self.rule.r#match.matches(invite)
248    }
249
250    fn apply(&self, invite: &mut InviteOption) {
251        self.rule.rewrite.apply(invite);
252    }
253}
254
255impl TrunkMatch {
256    fn matches(&self, invite: &InviteOption) -> bool {
257        let from_ok = self
258            .from
259            .as_ref()
260            .map(|from| from.matches(&invite.caller))
261            .unwrap_or(true);
262        let to_ok = self
263            .to
264            .as_ref()
265            .map(|to| to.matches(&invite.callee))
266            .unwrap_or(true);
267        from_ok && to_ok
268    }
269}
270
271impl UriMatch {
272    fn matches(&self, uri: &Uri) -> bool {
273        let user_ok = self
274            .user
275            .as_ref()
276            .map(|user| {
277                uri.auth
278                    .as_ref()
279                    .map(|auth| regex_is_match(user, &auth.user))
280                    .unwrap_or(false)
281            })
282            .unwrap_or(true);
283        let host_ok = self
284            .host
285            .as_ref()
286            .map(|host| regex_is_match(host, &uri.host_with_port.host.to_string()))
287            .unwrap_or(true);
288        user_ok && host_ok
289    }
290}
291
292impl TrunkRewrite {
293    fn apply(&self, invite: &mut InviteOption) {
294        if let Some(from) = &self.from {
295            from.apply(&mut invite.caller);
296        }
297        if let Some(to) = &self.to {
298            to.apply(&mut invite.callee);
299        }
300        if let Some(contact) = &self.contact {
301            contact.apply(&mut invite.contact);
302        }
303    }
304}
305
306impl UriRewrite {
307    fn apply(&self, uri: &mut Uri) {
308        if let Some(user) = &self.user {
309            let auth = uri.auth.get_or_insert_with(|| Auth {
310                user: String::new(),
311                password: None,
312            });
313            auth.user = user.clone();
314        }
315        if let Some(host) = &self.host
316            && let Ok(mut host_with_port) = HostWithPort::try_from(host.as_str())
317        {
318            // Preserve the original port when the rewrite value omits one.
319            if host_with_port.port.is_none() {
320                host_with_port.port = uri.host_with_port.port;
321            }
322            uri.host_with_port = host_with_port;
323        }
324    }
325}
326
327fn regex_is_match(pattern: &str, value: &str) -> bool {
328    regex::Regex::new(pattern)
329        .map(|re| re.is_match(value))
330        .unwrap_or(false)
331}
332
333#[derive(Debug, Deserialize, Serialize)]
334pub struct Config {
335    #[serde(default = "default_config_http_addr")]
336    pub http_addr: String,
337    pub addr: String,
338    pub udp_port: u16,
339    pub auto_learn_public_address: Option<bool>,
340
341    pub log_level: Option<String>,
342    pub log_file: Option<String>,
343    #[serde(default, skip_serializing_if = "Vec::is_empty")]
344    pub http_access_skip_paths: Vec<String>,
345
346    #[serde(default = "default_config_useragent")]
347    pub useragent: Option<String>,
348    pub register_users: Option<Vec<RegisterOption>>,
349    #[serde(default = "default_graceful_shutdown")]
350    pub graceful_shutdown: Option<bool>,
351    /// Total seconds to wait during graceful shutdown before forcing exit.
352    /// SIP de-registration and active call draining run in parallel, both with this timeout.
353    #[serde(default = "default_graceful_shutdown_timeout")]
354    pub graceful_shutdown_timeout: Option<u64>,
355    pub handler: Option<InviteHandlerConfig>,
356    pub accept_timeout: Option<String>,
357    #[serde(default = "default_codecs")]
358    pub codecs: Option<Vec<String>>,
359    pub external_ip: Option<String>,
360    #[serde(default = "default_config_rtp_start_port")]
361    pub rtp_start_port: Option<u16>,
362    #[serde(default = "default_config_rtp_end_port")]
363    pub rtp_end_port: Option<u16>,
364    #[serde(default = "default_config_rtp_latching")]
365    pub enable_rtp_latching: Option<bool>,
366    pub enable_ice_lite: Option<bool>,
367    pub rtp_bind_ip: Option<String>,
368    pub tls_port: Option<u16>,
369    pub tls_cert_file: Option<String>,
370    pub tls_key_file: Option<String>,
371
372    pub enable_srtp: Option<bool>,
373
374    pub callrecord: Option<CallRecordConfig>,
375    #[serde(default = "default_config_media_cache_path")]
376    pub media_cache_path: String,
377    pub ambiance: Option<AmbianceOption>,
378    pub ice_servers: Option<Vec<IceServer>>,
379    #[serde(default)]
380    pub recording: Option<RecordingPolicy>,
381    pub rewrites: Option<Vec<RewriteRule>>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub trunk_rules: Option<Vec<TrunkRule>>,
384    #[serde(default = "default_enable_options_response")]
385    pub enable_options_response: Option<bool>,
386}
387
388#[derive(Debug, Deserialize, Clone, Serialize)]
389#[serde(rename_all = "snake_case")]
390#[serde(tag = "type")]
391pub enum InviteHandlerConfig {
392    Webhook {
393        url: Option<String>,
394        urls: Option<Vec<String>>,
395        method: Option<String>,
396        headers: Option<Vec<(String, String)>>,
397    },
398    Playbook {
399        rules: Option<Vec<PlaybookRule>>,
400        default: Option<String>,
401    },
402}
403
404#[derive(Debug, Deserialize, Clone, Serialize)]
405#[serde(rename_all = "snake_case")]
406pub struct PlaybookRule {
407    pub caller: Option<String>,
408    pub callee: Option<String>,
409    pub playbook: String,
410}
411
412#[derive(Debug, Deserialize, Clone, Serialize)]
413#[serde(rename_all = "snake_case")]
414pub enum S3Vendor {
415    Aliyun,
416    Tencent,
417    Minio,
418    AWS,
419    GCP,
420    Azure,
421    DigitalOcean,
422}
423
424#[derive(Debug, Deserialize, Clone, Serialize)]
425#[serde(tag = "type")]
426#[serde(rename_all = "snake_case")]
427pub enum CallRecordConfig {
428    Local {
429        root: String,
430    },
431    S3 {
432        vendor: S3Vendor,
433        bucket: String,
434        region: String,
435        access_key: String,
436        secret_key: String,
437        endpoint: String,
438        root: String,
439        with_media: Option<bool>,
440        keep_media_copy: Option<bool>,
441    },
442    Http {
443        url: String,
444        headers: Option<HashMap<String, String>>,
445        with_media: Option<bool>,
446        keep_media_copy: Option<bool>,
447    },
448}
449
450impl Default for CallRecordConfig {
451    fn default() -> Self {
452        Self::Local {
453            #[cfg(target_os = "windows")]
454            root: "./config/cdr".to_string(),
455            #[cfg(not(target_os = "windows"))]
456            root: "./config/cdr".to_string(),
457        }
458    }
459}
460
461impl Default for Config {
462    fn default() -> Self {
463        Self {
464            http_addr: default_config_http_addr(),
465            log_level: None,
466            log_file: None,
467            http_access_skip_paths: Vec::new(),
468            addr: default_sip_addr(),
469            udp_port: default_sip_port(),
470            auto_learn_public_address: None,
471            useragent: None,
472            register_users: None,
473            graceful_shutdown: Some(true),
474            graceful_shutdown_timeout: default_graceful_shutdown_timeout(),
475            handler: None,
476            accept_timeout: Some("50s".to_string()),
477            media_cache_path: default_config_media_cache_path(),
478            ambiance: None,
479            callrecord: None,
480            ice_servers: None,
481            codecs: None,
482            external_ip: None,
483            rtp_start_port: default_config_rtp_start_port(),
484            rtp_end_port: default_config_rtp_end_port(),
485            enable_rtp_latching: Some(true),
486            rtp_bind_ip: None,
487            enable_ice_lite: None,
488            tls_port: None,
489            tls_cert_file: None,
490            tls_key_file: None,
491            enable_srtp: None,
492            recording: None,
493            rewrites: None,
494            trunk_rules: None,
495            enable_options_response: default_enable_options_response(),
496        }
497    }
498}
499
500impl Clone for Config {
501    fn clone(&self) -> Self {
502        // This is a bit expensive but Config is not cloned often in hot paths
503        // and implementing Clone manually for all nested structs is tedious
504        let s = toml::to_string(self).unwrap();
505        toml::from_str(&s).unwrap()
506    }
507}
508
509impl Config {
510    pub fn load(path: &str) -> Result<Self, Error> {
511        let config: Self = toml::from_str(
512            &std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("{}: {}", e, path))?,
513        )?;
514        Ok(config)
515    }
516
517    pub fn recorder_path(&self) -> String {
518        self.recording
519            .as_ref()
520            .map(|policy| policy.recorder_path())
521            .unwrap_or_else(default_config_recorder_path)
522    }
523
524    pub fn recorder_format(&self) -> RecorderFormat {
525        self.recording
526            .as_ref()
527            .map(|policy| policy.recorder_format())
528            .unwrap_or_default()
529    }
530
531    pub fn ensure_recording_defaults(&mut self) -> bool {
532        let mut fallback = false;
533
534        if let Some(policy) = self.recording.as_mut() {
535            fallback |= policy.ensure_defaults();
536        }
537
538        fallback
539    }
540
541    /// Apply the first matching trunk rule to an outgoing SIP INVITE/REFER.
542    ///
543    /// Rules are evaluated in declaration order; the first rule whose `match`
544    /// conditions (all AND) hold is applied and later rules are skipped
545    /// (first-match-wins). A rule with an empty `match` section always matches
546    /// and acts as a catch-all default. If no rule matches, the invite is left
547    /// untouched. Does nothing when [`Config::trunk_rules`] is not configured.
548    pub fn apply_trunk_rules(&self, invite: &mut InviteOption) {
549        if let Some(rules) = &self.trunk_rules {
550            for rule in rules {
551                if rule.matches(invite) {
552                    rule.apply(invite);
553                    break;
554                }
555            }
556        }
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn test_playbook_handler_config_parsing() {
566        let toml_config = r#"
567http_addr = "0.0.0.0:8080"
568addr = "0.0.0.0"
569udp_port = 25060
570
571[handler]
572type = "playbook"
573default = "default.md"
574
575[[handler.rules]]
576caller = "^\\+1\\d{10}$"
577callee = "^sip:support@.*"
578playbook = "support.md"
579
580[[handler.rules]]
581caller = "^\\+86\\d+"
582playbook = "chinese.md"
583
584[[handler.rules]]
585callee = "^sip:sales@.*"
586playbook = "sales.md"
587"#;
588
589        let config: Config = toml::from_str(toml_config).unwrap();
590
591        assert!(config.handler.is_some());
592        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
593            assert_eq!(default, Some("default.md".to_string()));
594            let rules = rules.unwrap();
595            assert_eq!(rules.len(), 3);
596
597            assert_eq!(rules[0].caller, Some(r"^\+1\d{10}$".to_string()));
598            assert_eq!(rules[0].callee, Some("^sip:support@.*".to_string()));
599            assert_eq!(rules[0].playbook, "support.md");
600
601            assert_eq!(rules[1].caller, Some(r"^\+86\d+".to_string()));
602            assert_eq!(rules[1].callee, None);
603            assert_eq!(rules[1].playbook, "chinese.md");
604
605            assert_eq!(rules[2].caller, None);
606            assert_eq!(rules[2].callee, Some("^sip:sales@.*".to_string()));
607            assert_eq!(rules[2].playbook, "sales.md");
608        } else {
609            panic!("Expected Playbook handler config");
610        }
611    }
612
613    #[test]
614    fn test_playbook_handler_config_without_default() {
615        let toml_config = r#"
616http_addr = "0.0.0.0:8080"
617addr = "0.0.0.0"
618udp_port = 25060
619
620[handler]
621type = "playbook"
622
623[[handler.rules]]
624caller = "^\\+1.*"
625playbook = "us.md"
626"#;
627
628        let config: Config = toml::from_str(toml_config).unwrap();
629
630        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
631            assert_eq!(default, None);
632            let rules = rules.unwrap();
633            assert_eq!(rules.len(), 1);
634        } else {
635            panic!("Expected Playbook handler config");
636        }
637    }
638
639    #[test]
640    fn test_webhook_handler_config_still_works() {
641        let toml_config = r#"
642http_addr = "0.0.0.0:8080"
643addr = "0.0.0.0"
644udp_port = 25060
645
646[handler]
647type = "webhook"
648url = "http://example.com/webhook"
649"#;
650
651        let config: Config = toml::from_str(toml_config).unwrap();
652
653        if let Some(InviteHandlerConfig::Webhook { url, .. }) = config.handler {
654            assert_eq!(url, Some("http://example.com/webhook".to_string()));
655        } else {
656            panic!("Expected Webhook handler config");
657        }
658    }
659
660    // ---------------------------------------------------------------------------
661    // trunk_rules: config parsing
662    // ---------------------------------------------------------------------------
663
664    #[test]
665    fn test_trunk_rules_config_parsing() {
666        let toml_config = r#"
667http_addr = "0.0.0.0:8080"
668addr = "0.0.0.0"
669udp_port = 25060
670
671[[trunk_rules]]
672rule.match.to.host = "^172\\.25\\."
673rule.rewrite.contact.host = "172.25.225.2"
674
675[[trunk_rules]]
676rule.match.from.user = "^\\+86.*"
677rule.match.to.host = "^10\\."
678rule.rewrite.from.user = "10086"
679rule.rewrite.to.host = "10.0.0.1"
680rule.rewrite.contact.user = "active-call"
681rule.rewrite.contact.host = "10.0.0.1:25060"
682
683[[trunk_rules]]
684rule.rewrite.contact.host = "116.62.75.161"
685"#;
686
687        let config: Config = toml::from_str(toml_config).unwrap();
688        let rules = config.trunk_rules.expect("trunk_rules should be parsed");
689
690        assert_eq!(rules.len(), 3);
691
692        // Rule 1: match to.host, rewrite contact.host
693        let m = &rules[0].rule.r#match;
694        assert_eq!(m.to.as_ref().unwrap().host.as_deref(), Some("^172\\.25\\."));
695        assert_eq!(m.to.as_ref().unwrap().user, None);
696        assert_eq!(m.from, None);
697        let w = &rules[0].rule.rewrite;
698        assert_eq!(
699            w.contact.as_ref().unwrap().host.as_deref(),
700            Some("172.25.225.2")
701        );
702        assert_eq!(w.from, None);
703        assert_eq!(w.to, None);
704
705        // Rule 2: combined match + all rewrites
706        let m = &rules[1].rule.r#match;
707        assert_eq!(m.from.as_ref().unwrap().user.as_deref(), Some("^\\+86.*"));
708        assert_eq!(m.to.as_ref().unwrap().host.as_deref(), Some("^10\\."));
709        let w = &rules[1].rule.rewrite;
710        assert_eq!(w.from.as_ref().unwrap().user.as_deref(), Some("10086"));
711        assert_eq!(w.to.as_ref().unwrap().host.as_deref(), Some("10.0.0.1"));
712        assert_eq!(
713            w.contact.as_ref().unwrap().user.as_deref(),
714            Some("active-call")
715        );
716        assert_eq!(
717            w.contact.as_ref().unwrap().host.as_deref(),
718            Some("10.0.0.1:25060")
719        );
720
721        // Rule 3: catch-all (empty match)
722        assert_eq!(rules[2].rule.r#match.from, None);
723        assert_eq!(rules[2].rule.r#match.to, None);
724        assert_eq!(
725            rules[2]
726                .rule
727                .rewrite
728                .contact
729                .as_ref()
730                .unwrap()
731                .host
732                .as_deref(),
733            Some("116.62.75.161")
734        );
735    }
736
737    #[test]
738    fn test_trunk_rules_absent_by_default() {
739        let config = Config::default();
740        assert!(config.trunk_rules.is_none());
741    }
742
743    // ---------------------------------------------------------------------------
744    // trunk_rules: match + rewrite behaviour
745    // ---------------------------------------------------------------------------
746
747    fn invite_option(caller: &str, callee: &str, contact: &str) -> InviteOption {
748        InviteOption {
749            caller: caller.try_into().unwrap(),
750            callee: callee.try_into().unwrap(),
751            contact: contact.try_into().unwrap(),
752            ..Default::default()
753        }
754    }
755
756    #[test]
757    fn test_trunk_rule_matches_on_to_host() {
758        let config: Config = toml::from_str(
759            r#"
760addr = "0.0.0.0"
761udp_port = 25060
762
763[[trunk_rules]]
764rule.match.to.host = "^172\\.25\\."
765rule.rewrite.contact.host = "172.25.225.2"
766"#,
767        )
768        .unwrap();
769
770        // Internal target -> matched, contact host rewritten.
771        let mut invite = invite_option(
772            "sip:ai@116.62.75.161:13050",
773            "sip:agent1@172.25.225.3:15060",
774            "sip:ai@127.0.0.1:13050",
775        );
776        config.apply_trunk_rules(&mut invite);
777        assert_eq!(
778            invite.contact.host_with_port.host.to_string(),
779            "172.25.225.2"
780        );
781
782        // External target -> no match, contact untouched.
783        let mut invite = invite_option(
784            "sip:ai@116.62.75.161:13050",
785            "sip:+8613800138000@sbc.example.com:5060",
786            "sip:ai@127.0.0.1:13050",
787        );
788        config.apply_trunk_rules(&mut invite);
789        assert_eq!(invite.contact.host_with_port.host.to_string(), "127.0.0.1");
790    }
791
792    #[test]
793    fn test_trunk_rule_matches_on_from_user_and_to_host() {
794        let config: Config = toml::from_str(
795            r#"
796addr = "0.0.0.0"
797udp_port = 25060
798
799[[trunk_rules]]
800rule.match.from.user = "^anonymous$"
801rule.match.to.host = "^sbc\\."
802rule.rewrite.contact.host = "116.62.75.161"
803"#,
804        )
805        .unwrap();
806
807        // from.user + to.host both match -> rewritten.
808        let mut invite = invite_option(
809            "sip:anonymous@116.62.75.161:13050",
810            "sip:+8613800138000@sbc.example.com:5060",
811            "sip:ai@127.0.0.1:13050",
812        );
813        config.apply_trunk_rules(&mut invite);
814        assert_eq!(
815            invite.contact.host_with_port.host.to_string(),
816            "116.62.75.161"
817        );
818
819        // from.user does not match -> no rewrite.
820        let mut invite = invite_option(
821            "sip:alice@116.62.75.161:13050",
822            "sip:+8613800138000@sbc.example.com:5060",
823            "sip:ai@127.0.0.1:13050",
824        );
825        config.apply_trunk_rules(&mut invite);
826        assert_eq!(invite.contact.host_with_port.host.to_string(), "127.0.0.1");
827    }
828
829    #[test]
830    fn test_trunk_rule_rewrites_from_to_contact() {
831        let config: Config = toml::from_str(
832            r#"
833addr = "0.0.0.0"
834udp_port = 25060
835
836[[trunk_rules]]
837rule.match.to.host = "^10\\."
838rule.rewrite.from.user = "10086"
839rule.rewrite.from.host = "116.62.75.161"
840rule.rewrite.to.user = "30000"
841rule.rewrite.to.host = "10.0.0.1:25060"
842rule.rewrite.contact.user = "active-call"
843rule.rewrite.contact.host = "10.0.0.1"
844"#,
845        )
846        .unwrap();
847
848        let mut invite = invite_option(
849            "sip:ai@127.0.0.1:13050",
850            "sip:agent1@10.0.0.2:25060",
851            "sip:ai@127.0.0.1:13050",
852        );
853        config.apply_trunk_rules(&mut invite);
854
855        // caller (from)
856        assert_eq!(invite.caller.auth.as_ref().unwrap().user, "10086");
857        assert_eq!(
858            invite.caller.host_with_port.host.to_string(),
859            "116.62.75.161"
860        );
861
862        // callee (to)
863        assert_eq!(invite.callee.auth.as_ref().unwrap().user, "30000");
864        assert_eq!(invite.callee.host_with_port.host.to_string(), "10.0.0.1");
865        assert_eq!(invite.callee.host_with_port.port.unwrap().0, 25060);
866
867        // contact: host rewritten without port -> original port preserved
868        assert_eq!(invite.contact.auth.as_ref().unwrap().user, "active-call");
869        assert_eq!(invite.contact.host_with_port.host.to_string(), "10.0.0.1");
870        assert_eq!(invite.contact.host_with_port.port.unwrap().0, 13050);
871    }
872
873    #[test]
874    fn test_trunk_rule_catch_all_default() {
875        let config: Config = toml::from_str(
876            r#"
877addr = "0.0.0.0"
878udp_port = 25060
879
880[[trunk_rules]]
881rule.match.to.host = "^172\\.25\\."
882rule.rewrite.contact.host = "172.25.225.2"
883
884[[trunk_rules]]
885rule.rewrite.contact.host = "116.62.75.161"
886"#,
887        )
888        .unwrap();
889
890        // Not matched by rule 1 -> falls through to catch-all rule 2.
891        let mut invite = invite_option(
892            "sip:ai@116.62.75.161:13050",
893            "sip:+8613800138000@sbc.example.com:5060",
894            "sip:ai@127.0.0.1:13050",
895        );
896        config.apply_trunk_rules(&mut invite);
897        assert_eq!(
898            invite.contact.host_with_port.host.to_string(),
899            "116.62.75.161"
900        );
901    }
902
903    #[test]
904    fn test_trunk_rule_first_match_wins() {
905        let config: Config = toml::from_str(
906            r#"
907addr = "0.0.0.0"
908udp_port = 25060
909
910[[trunk_rules]]
911rule.match.to.host = ".*"
912rule.rewrite.contact.host = "1.1.1.1"
913
914[[trunk_rules]]
915rule.match.to.host = ".*"
916rule.rewrite.contact.host = "2.2.2.2"
917"#,
918        )
919        .unwrap();
920
921        let mut invite = invite_option(
922            "sip:ai@116.62.75.161:13050",
923            "sip:any@example.com:5060",
924            "sip:ai@127.0.0.1:13050",
925        );
926        config.apply_trunk_rules(&mut invite);
927        // Only the first rule applies.
928        assert_eq!(invite.contact.host_with_port.host.to_string(), "1.1.1.1");
929    }
930
931    #[test]
932    fn test_trunk_rule_no_config_noop() {
933        let config = Config::default();
934        let mut invite = invite_option(
935            "sip:ai@116.62.75.161:13050",
936            "sip:agent1@172.25.225.3:15060",
937            "sip:ai@127.0.0.1:13050",
938        );
939        let before = invite.contact.to_string();
940        config.apply_trunk_rules(&mut invite);
941        assert_eq!(invite.contact.to_string(), before);
942    }
943
944    #[test]
945    fn test_trunk_rule_rewrite_missing_auth_creates_user() {
946        let config: Config = toml::from_str(
947            r#"
948addr = "0.0.0.0"
949udp_port = 25060
950
951[[trunk_rules]]
952rule.match.to.host = ".*"
953rule.rewrite.contact.user = "active-call"
954"#,
955        )
956        .unwrap();
957
958        // A contact without a user part (e.g. "sip:127.0.0.1:13050").
959        let mut invite = InviteOption {
960            caller: "sip:ai@127.0.0.1:13050".try_into().unwrap(),
961            callee: "sip:agent1@172.25.225.3:15060".try_into().unwrap(),
962            contact: "sip:127.0.0.1:13050".try_into().unwrap(),
963            ..Default::default()
964        };
965        config.apply_trunk_rules(&mut invite);
966        assert_eq!(invite.contact.auth.as_ref().unwrap().user, "active-call");
967    }
968}