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