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    /// Peer active-call nodes ("ip:port" of their HTTP/WS endpoint).
347    ///
348    /// When a websocket client connects with a session id that is not hosted on
349    /// this node, the originator (empty `forward`) polls each peer once
350    /// (`forward=true`) and tunnels the websocket to the peer that hosts the
351    /// call. Any request whose `forward` is set must not hop further.
352    #[serde(default, skip_serializing_if = "Vec::is_empty")]
353    pub peers: Vec<String>,
354
355    #[serde(default = "default_config_useragent")]
356    pub useragent: Option<String>,
357    pub register_users: Option<Vec<RegisterOption>>,
358    #[serde(default = "default_graceful_shutdown")]
359    pub graceful_shutdown: Option<bool>,
360    /// Total seconds to wait during graceful shutdown before forcing exit.
361    /// SIP de-registration and active call draining run in parallel, both with this timeout.
362    #[serde(default = "default_graceful_shutdown_timeout")]
363    pub graceful_shutdown_timeout: Option<u64>,
364    pub handler: Option<InviteHandlerConfig>,
365    pub accept_timeout: Option<String>,
366    #[serde(default = "default_codecs")]
367    pub codecs: Option<Vec<String>>,
368    pub external_ip: Option<String>,
369    #[serde(default = "default_config_rtp_start_port")]
370    pub rtp_start_port: Option<u16>,
371    #[serde(default = "default_config_rtp_end_port")]
372    pub rtp_end_port: Option<u16>,
373    #[serde(default = "default_config_rtp_latching")]
374    pub enable_rtp_latching: Option<bool>,
375    pub enable_ice_lite: Option<bool>,
376    pub rtp_bind_ip: Option<String>,
377    pub tls_port: Option<u16>,
378    pub tls_cert_file: Option<String>,
379    pub tls_key_file: Option<String>,
380
381    pub enable_srtp: Option<bool>,
382
383    pub callrecord: Option<CallRecordConfig>,
384    #[serde(default = "default_config_media_cache_path")]
385    pub media_cache_path: String,
386    pub ambiance: Option<AmbianceOption>,
387    pub ice_servers: Option<Vec<IceServer>>,
388    #[serde(default)]
389    pub recording: Option<RecordingPolicy>,
390    pub rewrites: Option<Vec<RewriteRule>>,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub trunk_rules: Option<Vec<TrunkRule>>,
393    #[serde(default = "default_enable_options_response")]
394    pub enable_options_response: Option<bool>,
395}
396
397#[derive(Debug, Deserialize, Clone, Serialize)]
398#[serde(rename_all = "snake_case")]
399#[serde(tag = "type")]
400pub enum InviteHandlerConfig {
401    Webhook {
402        url: Option<String>,
403        urls: Option<Vec<String>>,
404        method: Option<String>,
405        headers: Option<Vec<(String, String)>>,
406    },
407    Playbook {
408        rules: Option<Vec<PlaybookRule>>,
409        default: Option<String>,
410    },
411}
412
413#[derive(Debug, Deserialize, Clone, Serialize)]
414#[serde(rename_all = "snake_case")]
415pub struct PlaybookRule {
416    pub caller: Option<String>,
417    pub callee: Option<String>,
418    pub playbook: String,
419}
420
421#[derive(Debug, Deserialize, Clone, Serialize)]
422#[serde(rename_all = "snake_case")]
423pub enum S3Vendor {
424    Aliyun,
425    Tencent,
426    Minio,
427    AWS,
428    GCP,
429    Azure,
430    DigitalOcean,
431}
432
433#[derive(Debug, Deserialize, Clone, Serialize)]
434#[serde(tag = "type")]
435#[serde(rename_all = "snake_case")]
436pub enum CallRecordConfig {
437    Local {
438        root: String,
439    },
440    S3 {
441        vendor: S3Vendor,
442        bucket: String,
443        region: String,
444        access_key: String,
445        secret_key: String,
446        endpoint: String,
447        root: String,
448        with_media: Option<bool>,
449        keep_media_copy: Option<bool>,
450    },
451    Http {
452        url: String,
453        headers: Option<HashMap<String, String>>,
454        with_media: Option<bool>,
455        keep_media_copy: Option<bool>,
456    },
457}
458
459impl Default for CallRecordConfig {
460    fn default() -> Self {
461        Self::Local {
462            #[cfg(target_os = "windows")]
463            root: "./config/cdr".to_string(),
464            #[cfg(not(target_os = "windows"))]
465            root: "./config/cdr".to_string(),
466        }
467    }
468}
469
470impl Default for Config {
471    fn default() -> Self {
472        Self {
473            http_addr: default_config_http_addr(),
474            log_level: None,
475            log_file: None,
476            http_access_skip_paths: Vec::new(),
477            peers: Vec::new(),
478            addr: default_sip_addr(),
479            udp_port: default_sip_port(),
480            auto_learn_public_address: None,
481            useragent: None,
482            register_users: None,
483            graceful_shutdown: Some(true),
484            graceful_shutdown_timeout: default_graceful_shutdown_timeout(),
485            handler: None,
486            accept_timeout: Some("50s".to_string()),
487            media_cache_path: default_config_media_cache_path(),
488            ambiance: None,
489            callrecord: None,
490            ice_servers: None,
491            codecs: None,
492            external_ip: None,
493            rtp_start_port: default_config_rtp_start_port(),
494            rtp_end_port: default_config_rtp_end_port(),
495            enable_rtp_latching: Some(true),
496            rtp_bind_ip: None,
497            enable_ice_lite: None,
498            tls_port: None,
499            tls_cert_file: None,
500            tls_key_file: None,
501            enable_srtp: None,
502            recording: None,
503            rewrites: None,
504            trunk_rules: None,
505            enable_options_response: default_enable_options_response(),
506        }
507    }
508}
509
510impl Clone for Config {
511    fn clone(&self) -> Self {
512        // This is a bit expensive but Config is not cloned often in hot paths
513        // and implementing Clone manually for all nested structs is tedious
514        let s = toml::to_string(self).unwrap();
515        toml::from_str(&s).unwrap()
516    }
517}
518
519impl Config {
520    pub fn load(path: &str) -> Result<Self, Error> {
521        let config: Self = toml::from_str(
522            &std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("{}: {}", e, path))?,
523        )?;
524        Ok(config)
525    }
526
527    pub fn recorder_path(&self) -> String {
528        self.recording
529            .as_ref()
530            .map(|policy| policy.recorder_path())
531            .unwrap_or_else(default_config_recorder_path)
532    }
533
534    pub fn recorder_format(&self) -> RecorderFormat {
535        self.recording
536            .as_ref()
537            .map(|policy| policy.recorder_format())
538            .unwrap_or_default()
539    }
540
541    pub fn ensure_recording_defaults(&mut self) -> bool {
542        let mut fallback = false;
543
544        if let Some(policy) = self.recording.as_mut() {
545            fallback |= policy.ensure_defaults();
546        }
547
548        fallback
549    }
550
551    /// Apply the first matching trunk rule to an outgoing SIP INVITE/REFER.
552    ///
553    /// Rules are evaluated in declaration order; the first rule whose `match`
554    /// conditions (all AND) hold is applied and later rules are skipped
555    /// (first-match-wins). A rule with an empty `match` section always matches
556    /// and acts as a catch-all default. If no rule matches, the invite is left
557    /// untouched. Does nothing when [`Config::trunk_rules`] is not configured.
558    pub fn apply_trunk_rules(&self, invite: &mut InviteOption) {
559        if let Some(rules) = &self.trunk_rules {
560            for rule in rules {
561                if rule.matches(invite) {
562                    rule.apply(invite);
563                    break;
564                }
565            }
566        }
567    }
568
569    /// Normalize a configured peer to a `ws://` (or `wss://`) base URL.
570    ///
571    /// Accepts bare `ip:port`, or an explicit `ws://`, `wss://`, `http://` or
572    /// `https://` scheme. Returns `None` when the value is empty or unusable.
573    pub fn peer_ws_endpoint(peer: &str) -> Option<String> {
574        let peer = peer.trim();
575        if peer.is_empty() {
576            return None;
577        }
578        if peer.starts_with("wss://") {
579            return Some(peer.to_string());
580        }
581        if peer.starts_with("ws://") {
582            return Some(peer.to_string());
583        }
584        if peer.starts_with("https://") {
585            return Some(peer.replacen("https://", "wss://", 1));
586        }
587        if peer.starts_with("http://") {
588            return Some(peer.replacen("http://", "ws://", 1));
589        }
590        Some(format!("ws://{}", peer))
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_playbook_handler_config_parsing() {
600        let toml_config = r#"
601http_addr = "0.0.0.0:8080"
602addr = "0.0.0.0"
603udp_port = 25060
604
605[handler]
606type = "playbook"
607default = "default.md"
608
609[[handler.rules]]
610caller = "^\\+1\\d{10}$"
611callee = "^sip:support@.*"
612playbook = "support.md"
613
614[[handler.rules]]
615caller = "^\\+86\\d+"
616playbook = "chinese.md"
617
618[[handler.rules]]
619callee = "^sip:sales@.*"
620playbook = "sales.md"
621"#;
622
623        let config: Config = toml::from_str(toml_config).unwrap();
624
625        assert!(config.handler.is_some());
626        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
627            assert_eq!(default, Some("default.md".to_string()));
628            let rules = rules.unwrap();
629            assert_eq!(rules.len(), 3);
630
631            assert_eq!(rules[0].caller, Some(r"^\+1\d{10}$".to_string()));
632            assert_eq!(rules[0].callee, Some("^sip:support@.*".to_string()));
633            assert_eq!(rules[0].playbook, "support.md");
634
635            assert_eq!(rules[1].caller, Some(r"^\+86\d+".to_string()));
636            assert_eq!(rules[1].callee, None);
637            assert_eq!(rules[1].playbook, "chinese.md");
638
639            assert_eq!(rules[2].caller, None);
640            assert_eq!(rules[2].callee, Some("^sip:sales@.*".to_string()));
641            assert_eq!(rules[2].playbook, "sales.md");
642        } else {
643            panic!("Expected Playbook handler config");
644        }
645    }
646
647    #[test]
648    fn test_playbook_handler_config_without_default() {
649        let toml_config = r#"
650http_addr = "0.0.0.0:8080"
651addr = "0.0.0.0"
652udp_port = 25060
653
654[handler]
655type = "playbook"
656
657[[handler.rules]]
658caller = "^\\+1.*"
659playbook = "us.md"
660"#;
661
662        let config: Config = toml::from_str(toml_config).unwrap();
663
664        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
665            assert_eq!(default, None);
666            let rules = rules.unwrap();
667            assert_eq!(rules.len(), 1);
668        } else {
669            panic!("Expected Playbook handler config");
670        }
671    }
672
673    #[test]
674    fn test_webhook_handler_config_still_works() {
675        let toml_config = r#"
676http_addr = "0.0.0.0:8080"
677addr = "0.0.0.0"
678udp_port = 25060
679
680[handler]
681type = "webhook"
682url = "http://example.com/webhook"
683"#;
684
685        let config: Config = toml::from_str(toml_config).unwrap();
686
687        if let Some(InviteHandlerConfig::Webhook { url, .. }) = config.handler {
688            assert_eq!(url, Some("http://example.com/webhook".to_string()));
689        } else {
690            panic!("Expected Webhook handler config");
691        }
692    }
693
694    // ---------------------------------------------------------------------------
695    // trunk_rules: config parsing
696    // ---------------------------------------------------------------------------
697
698    #[test]
699    fn test_trunk_rules_config_parsing() {
700        let toml_config = r#"
701http_addr = "0.0.0.0:8080"
702addr = "0.0.0.0"
703udp_port = 25060
704
705[[trunk_rules]]
706rule.match.to.host = "^172\\.25\\."
707rule.rewrite.contact.host = "172.25.225.2"
708
709[[trunk_rules]]
710rule.match.from.user = "^\\+86.*"
711rule.match.to.host = "^10\\."
712rule.rewrite.from.user = "10086"
713rule.rewrite.to.host = "10.0.0.1"
714rule.rewrite.contact.user = "active-call"
715rule.rewrite.contact.host = "10.0.0.1:25060"
716
717[[trunk_rules]]
718rule.rewrite.contact.host = "116.62.75.161"
719"#;
720
721        let config: Config = toml::from_str(toml_config).unwrap();
722        let rules = config.trunk_rules.expect("trunk_rules should be parsed");
723
724        assert_eq!(rules.len(), 3);
725
726        // Rule 1: match to.host, rewrite contact.host
727        let m = &rules[0].rule.r#match;
728        assert_eq!(m.to.as_ref().unwrap().host.as_deref(), Some("^172\\.25\\."));
729        assert_eq!(m.to.as_ref().unwrap().user, None);
730        assert_eq!(m.from, None);
731        let w = &rules[0].rule.rewrite;
732        assert_eq!(
733            w.contact.as_ref().unwrap().host.as_deref(),
734            Some("172.25.225.2")
735        );
736        assert_eq!(w.from, None);
737        assert_eq!(w.to, None);
738
739        // Rule 2: combined match + all rewrites
740        let m = &rules[1].rule.r#match;
741        assert_eq!(m.from.as_ref().unwrap().user.as_deref(), Some("^\\+86.*"));
742        assert_eq!(m.to.as_ref().unwrap().host.as_deref(), Some("^10\\."));
743        let w = &rules[1].rule.rewrite;
744        assert_eq!(w.from.as_ref().unwrap().user.as_deref(), Some("10086"));
745        assert_eq!(w.to.as_ref().unwrap().host.as_deref(), Some("10.0.0.1"));
746        assert_eq!(
747            w.contact.as_ref().unwrap().user.as_deref(),
748            Some("active-call")
749        );
750        assert_eq!(
751            w.contact.as_ref().unwrap().host.as_deref(),
752            Some("10.0.0.1:25060")
753        );
754
755        // Rule 3: catch-all (empty match)
756        assert_eq!(rules[2].rule.r#match.from, None);
757        assert_eq!(rules[2].rule.r#match.to, None);
758        assert_eq!(
759            rules[2]
760                .rule
761                .rewrite
762                .contact
763                .as_ref()
764                .unwrap()
765                .host
766                .as_deref(),
767            Some("116.62.75.161")
768        );
769    }
770
771    #[test]
772    fn test_trunk_rules_absent_by_default() {
773        let config = Config::default();
774        assert!(config.trunk_rules.is_none());
775    }
776
777    // ---------------------------------------------------------------------------
778    // trunk_rules: match + rewrite behaviour
779    // ---------------------------------------------------------------------------
780
781    fn invite_option(caller: &str, callee: &str, contact: &str) -> InviteOption {
782        InviteOption {
783            caller: caller.try_into().unwrap(),
784            callee: callee.try_into().unwrap(),
785            contact: contact.try_into().unwrap(),
786            ..Default::default()
787        }
788    }
789
790    #[test]
791    fn test_trunk_rule_matches_on_to_host() {
792        let config: Config = toml::from_str(
793            r#"
794addr = "0.0.0.0"
795udp_port = 25060
796
797[[trunk_rules]]
798rule.match.to.host = "^172\\.25\\."
799rule.rewrite.contact.host = "172.25.225.2"
800"#,
801        )
802        .unwrap();
803
804        // Internal target -> matched, contact host rewritten.
805        let mut invite = invite_option(
806            "sip:ai@116.62.75.161:13050",
807            "sip:agent1@172.25.225.3:15060",
808            "sip:ai@127.0.0.1:13050",
809        );
810        config.apply_trunk_rules(&mut invite);
811        assert_eq!(
812            invite.contact.host_with_port.host.to_string(),
813            "172.25.225.2"
814        );
815
816        // External target -> no match, contact untouched.
817        let mut invite = invite_option(
818            "sip:ai@116.62.75.161:13050",
819            "sip:+8613800138000@sbc.example.com:5060",
820            "sip:ai@127.0.0.1:13050",
821        );
822        config.apply_trunk_rules(&mut invite);
823        assert_eq!(invite.contact.host_with_port.host.to_string(), "127.0.0.1");
824    }
825
826    #[test]
827    fn test_trunk_rule_matches_on_from_user_and_to_host() {
828        let config: Config = toml::from_str(
829            r#"
830addr = "0.0.0.0"
831udp_port = 25060
832
833[[trunk_rules]]
834rule.match.from.user = "^anonymous$"
835rule.match.to.host = "^sbc\\."
836rule.rewrite.contact.host = "116.62.75.161"
837"#,
838        )
839        .unwrap();
840
841        // from.user + to.host both match -> rewritten.
842        let mut invite = invite_option(
843            "sip:anonymous@116.62.75.161:13050",
844            "sip:+8613800138000@sbc.example.com:5060",
845            "sip:ai@127.0.0.1:13050",
846        );
847        config.apply_trunk_rules(&mut invite);
848        assert_eq!(
849            invite.contact.host_with_port.host.to_string(),
850            "116.62.75.161"
851        );
852
853        // from.user does not match -> no rewrite.
854        let mut invite = invite_option(
855            "sip:alice@116.62.75.161:13050",
856            "sip:+8613800138000@sbc.example.com:5060",
857            "sip:ai@127.0.0.1:13050",
858        );
859        config.apply_trunk_rules(&mut invite);
860        assert_eq!(invite.contact.host_with_port.host.to_string(), "127.0.0.1");
861    }
862
863    #[test]
864    fn test_trunk_rule_rewrites_from_to_contact() {
865        let config: Config = toml::from_str(
866            r#"
867addr = "0.0.0.0"
868udp_port = 25060
869
870[[trunk_rules]]
871rule.match.to.host = "^10\\."
872rule.rewrite.from.user = "10086"
873rule.rewrite.from.host = "116.62.75.161"
874rule.rewrite.to.user = "30000"
875rule.rewrite.to.host = "10.0.0.1:25060"
876rule.rewrite.contact.user = "active-call"
877rule.rewrite.contact.host = "10.0.0.1"
878"#,
879        )
880        .unwrap();
881
882        let mut invite = invite_option(
883            "sip:ai@127.0.0.1:13050",
884            "sip:agent1@10.0.0.2:25060",
885            "sip:ai@127.0.0.1:13050",
886        );
887        config.apply_trunk_rules(&mut invite);
888
889        // caller (from)
890        assert_eq!(invite.caller.auth.as_ref().unwrap().user, "10086");
891        assert_eq!(
892            invite.caller.host_with_port.host.to_string(),
893            "116.62.75.161"
894        );
895
896        // callee (to)
897        assert_eq!(invite.callee.auth.as_ref().unwrap().user, "30000");
898        assert_eq!(invite.callee.host_with_port.host.to_string(), "10.0.0.1");
899        assert_eq!(invite.callee.host_with_port.port.unwrap().0, 25060);
900
901        // contact: host rewritten without port -> original port preserved
902        assert_eq!(invite.contact.auth.as_ref().unwrap().user, "active-call");
903        assert_eq!(invite.contact.host_with_port.host.to_string(), "10.0.0.1");
904        assert_eq!(invite.contact.host_with_port.port.unwrap().0, 13050);
905    }
906
907    #[test]
908    fn test_trunk_rule_catch_all_default() {
909        let config: Config = toml::from_str(
910            r#"
911addr = "0.0.0.0"
912udp_port = 25060
913
914[[trunk_rules]]
915rule.match.to.host = "^172\\.25\\."
916rule.rewrite.contact.host = "172.25.225.2"
917
918[[trunk_rules]]
919rule.rewrite.contact.host = "116.62.75.161"
920"#,
921        )
922        .unwrap();
923
924        // Not matched by rule 1 -> falls through to catch-all rule 2.
925        let mut invite = invite_option(
926            "sip:ai@116.62.75.161:13050",
927            "sip:+8613800138000@sbc.example.com:5060",
928            "sip:ai@127.0.0.1:13050",
929        );
930        config.apply_trunk_rules(&mut invite);
931        assert_eq!(
932            invite.contact.host_with_port.host.to_string(),
933            "116.62.75.161"
934        );
935    }
936
937    #[test]
938    fn test_trunk_rule_first_match_wins() {
939        let config: Config = toml::from_str(
940            r#"
941addr = "0.0.0.0"
942udp_port = 25060
943
944[[trunk_rules]]
945rule.match.to.host = ".*"
946rule.rewrite.contact.host = "1.1.1.1"
947
948[[trunk_rules]]
949rule.match.to.host = ".*"
950rule.rewrite.contact.host = "2.2.2.2"
951"#,
952        )
953        .unwrap();
954
955        let mut invite = invite_option(
956            "sip:ai@116.62.75.161:13050",
957            "sip:any@example.com:5060",
958            "sip:ai@127.0.0.1:13050",
959        );
960        config.apply_trunk_rules(&mut invite);
961        // Only the first rule applies.
962        assert_eq!(invite.contact.host_with_port.host.to_string(), "1.1.1.1");
963    }
964
965    #[test]
966    fn test_trunk_rule_no_config_noop() {
967        let config = Config::default();
968        let mut invite = invite_option(
969            "sip:ai@116.62.75.161:13050",
970            "sip:agent1@172.25.225.3:15060",
971            "sip:ai@127.0.0.1:13050",
972        );
973        let before = invite.contact.to_string();
974        config.apply_trunk_rules(&mut invite);
975        assert_eq!(invite.contact.to_string(), before);
976    }
977
978    #[test]
979    fn test_trunk_rule_rewrite_missing_auth_creates_user() {
980        let config: Config = toml::from_str(
981            r#"
982addr = "0.0.0.0"
983udp_port = 25060
984
985[[trunk_rules]]
986rule.match.to.host = ".*"
987rule.rewrite.contact.user = "active-call"
988"#,
989        )
990        .unwrap();
991
992        // A contact without a user part (e.g. "sip:127.0.0.1:13050").
993        let mut invite = InviteOption {
994            caller: "sip:ai@127.0.0.1:13050".try_into().unwrap(),
995            callee: "sip:agent1@172.25.225.3:15060".try_into().unwrap(),
996            contact: "sip:127.0.0.1:13050".try_into().unwrap(),
997            ..Default::default()
998        };
999        config.apply_trunk_rules(&mut invite);
1000        assert_eq!(invite.contact.auth.as_ref().unwrap().user, "active-call");
1001    }
1002}