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