active_call/
config.rs

1use crate::media::{ambiance::AmbianceOption, recorder::RecorderFormat};
2use crate::useragent::RegisterOption;
3use anyhow::{Error, Result};
4use clap::Parser;
5use rustrtc::IceServer;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Parser, Debug)]
10#[command(version)]
11pub struct Cli {
12    #[clap(long)]
13    pub conf: Option<String>,
14
15    #[clap(long)]
16    pub http: Option<String>,
17
18    #[clap(long)]
19    pub sip: Option<String>,
20
21    /// SIP invitation handler: URL for webhook (http://...) or playbook file (.md)
22    #[clap(long)]
23    pub handler: Option<String>,
24}
25
26pub(crate) fn default_config_recorder_path() -> String {
27    #[cfg(target_os = "windows")]
28    return "./config/recorders".to_string();
29    #[cfg(not(target_os = "windows"))]
30    return "./config/recorders".to_string();
31}
32
33fn default_config_media_cache_path() -> String {
34    #[cfg(target_os = "windows")]
35    return "./config/mediacache".to_string();
36    #[cfg(not(target_os = "windows"))]
37    return "./config/mediacache".to_string();
38}
39
40fn default_config_http_addr() -> String {
41    "0.0.0.0:8080".to_string()
42}
43
44fn default_sip_addr() -> String {
45    "0.0.0.0".to_string()
46}
47
48fn default_sip_port() -> u16 {
49    25060
50}
51
52fn default_config_rtp_start_port() -> Option<u16> {
53    Some(12000)
54}
55
56fn default_config_rtp_end_port() -> Option<u16> {
57    Some(42000)
58}
59
60fn default_codecs() -> Option<Vec<String>> {
61    let mut codecs = vec![
62        "pcmu".to_string(),
63        "pcma".to_string(),
64        "g722".to_string(),
65        "g729".to_string(),
66        "telephone_event".to_string(),
67    ];
68
69    #[cfg(feature = "opus")]
70    {
71        codecs.push("opus".to_string());
72    }
73
74    Some(codecs)
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize, Default)]
78#[serde(rename_all = "snake_case")]
79pub struct RecordingPolicy {
80    #[serde(default)]
81    pub enabled: bool,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub auto_start: Option<bool>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub filename_pattern: Option<String>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub samplerate: Option<u32>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub ptime: Option<u32>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub path: Option<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub format: Option<RecorderFormat>,
94}
95
96impl RecordingPolicy {
97    pub fn recorder_path(&self) -> String {
98        self.path
99            .as_ref()
100            .map(|p| p.trim())
101            .filter(|p| !p.is_empty())
102            .map(|p| p.to_string())
103            .unwrap_or_else(default_config_recorder_path)
104    }
105
106    pub fn recorder_format(&self) -> RecorderFormat {
107        self.format.unwrap_or_default()
108    }
109
110    pub fn ensure_defaults(&mut self) -> bool {
111        if self
112            .path
113            .as_ref()
114            .map(|p| p.trim().is_empty())
115            .unwrap_or(true)
116        {
117            self.path = Some(default_config_recorder_path());
118        }
119
120        false
121    }
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct RewriteRule {
126    pub r#match: String,
127    pub rewrite: String,
128}
129
130#[derive(Debug, Deserialize, Serialize)]
131pub struct Config {
132    #[serde(default = "default_config_http_addr")]
133    pub http_addr: String,
134    pub addr: String,
135    pub udp_port: u16,
136
137    pub log_level: Option<String>,
138    pub log_file: Option<String>,
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub http_access_skip_paths: Vec<String>,
141
142    pub useragent: Option<String>,
143    pub register_users: Option<Vec<RegisterOption>>,
144    pub graceful_shutdown: Option<bool>,
145    pub handler: Option<InviteHandlerConfig>,
146    pub accept_timeout: Option<String>,
147    #[serde(default = "default_codecs")]
148    pub codecs: Option<Vec<String>>,
149    pub external_ip: Option<String>,
150    #[serde(default = "default_config_rtp_start_port")]
151    pub rtp_start_port: Option<u16>,
152    #[serde(default = "default_config_rtp_end_port")]
153    pub rtp_end_port: Option<u16>,
154
155    pub callrecord: Option<CallRecordConfig>,
156    #[serde(default = "default_config_media_cache_path")]
157    pub media_cache_path: String,
158    pub ambiance: Option<AmbianceOption>,
159    pub ice_servers: Option<Vec<IceServer>>,
160    #[serde(default)]
161    pub recording: Option<RecordingPolicy>,
162    pub rewrites: Option<Vec<RewriteRule>>,
163}
164
165#[derive(Debug, Deserialize, Clone, Serialize)]
166#[serde(rename_all = "snake_case")]
167#[serde(tag = "type")]
168pub enum InviteHandlerConfig {
169    Webhook {
170        url: Option<String>,
171        urls: Option<Vec<String>>,
172        method: Option<String>,
173        headers: Option<Vec<(String, String)>>,
174    },
175    Playbook {
176        rules: Option<Vec<PlaybookRule>>,
177        default: Option<String>,
178    },
179}
180
181#[derive(Debug, Deserialize, Clone, Serialize)]
182#[serde(rename_all = "snake_case")]
183pub struct PlaybookRule {
184    pub caller: Option<String>,
185    pub callee: Option<String>,
186    pub playbook: String,
187}
188
189#[derive(Debug, Deserialize, Clone, Serialize)]
190#[serde(rename_all = "snake_case")]
191pub enum S3Vendor {
192    Aliyun,
193    Tencent,
194    Minio,
195    AWS,
196    GCP,
197    Azure,
198    DigitalOcean,
199}
200
201#[derive(Debug, Deserialize, Clone, Serialize)]
202#[serde(tag = "type")]
203#[serde(rename_all = "snake_case")]
204pub enum CallRecordConfig {
205    Local {
206        root: String,
207    },
208    S3 {
209        vendor: S3Vendor,
210        bucket: String,
211        region: String,
212        access_key: String,
213        secret_key: String,
214        endpoint: String,
215        root: String,
216        with_media: Option<bool>,
217        keep_media_copy: Option<bool>,
218    },
219    Http {
220        url: String,
221        headers: Option<HashMap<String, String>>,
222        with_media: Option<bool>,
223        keep_media_copy: Option<bool>,
224    },
225}
226
227impl Default for CallRecordConfig {
228    fn default() -> Self {
229        Self::Local {
230            #[cfg(target_os = "windows")]
231            root: "./config/cdr".to_string(),
232            #[cfg(not(target_os = "windows"))]
233            root: "./config/cdr".to_string(),
234        }
235    }
236}
237
238impl Default for Config {
239    fn default() -> Self {
240        Self {
241            http_addr: default_config_http_addr(),
242            log_level: None,
243            log_file: None,
244            http_access_skip_paths: Vec::new(),
245            addr: default_sip_addr(),
246            udp_port: default_sip_port(),
247            useragent: None,
248            register_users: None,
249            graceful_shutdown: Some(true),
250            handler: None,
251            accept_timeout: Some("50s".to_string()),
252            media_cache_path: default_config_media_cache_path(),
253            ambiance: None,
254            callrecord: None,
255            ice_servers: None,
256            codecs: None,
257            external_ip: None,
258            rtp_start_port: default_config_rtp_start_port(),
259            rtp_end_port: default_config_rtp_end_port(),
260            recording: None,
261            rewrites: None,
262        }
263    }
264}
265
266impl Clone for Config {
267    fn clone(&self) -> Self {
268        // This is a bit expensive but Config is not cloned often in hot paths
269        // and implementing Clone manually for all nested structs is tedious
270        let s = toml::to_string(self).unwrap();
271        toml::from_str(&s).unwrap()
272    }
273}
274
275impl Config {
276    pub fn load(path: &str) -> Result<Self, Error> {
277        let config: Self = toml::from_str(
278            &std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("{}: {}", e, path))?,
279        )?;
280        Ok(config)
281    }
282
283    pub fn recorder_path(&self) -> String {
284        self.recording
285            .as_ref()
286            .map(|policy| policy.recorder_path())
287            .unwrap_or_else(default_config_recorder_path)
288    }
289
290    pub fn recorder_format(&self) -> RecorderFormat {
291        self.recording
292            .as_ref()
293            .map(|policy| policy.recorder_format())
294            .unwrap_or_default()
295    }
296
297    pub fn ensure_recording_defaults(&mut self) -> bool {
298        let mut fallback = false;
299
300        if let Some(policy) = self.recording.as_mut() {
301            fallback |= policy.ensure_defaults();
302        }
303
304        fallback
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_playbook_handler_config_parsing() {
314        let toml_config = r#"
315http_addr = "0.0.0.0:8080"
316addr = "0.0.0.0"
317udp_port = 25060
318
319[handler]
320type = "playbook"
321default = "default.md"
322
323[[handler.rules]]
324caller = "^\\+1\\d{10}$"
325callee = "^sip:support@.*"
326playbook = "support.md"
327
328[[handler.rules]]
329caller = "^\\+86\\d+"
330playbook = "chinese.md"
331
332[[handler.rules]]
333callee = "^sip:sales@.*"
334playbook = "sales.md"
335"#;
336
337        let config: Config = toml::from_str(toml_config).unwrap();
338
339        assert!(config.handler.is_some());
340        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
341            assert_eq!(default, Some("default.md".to_string()));
342            let rules = rules.unwrap();
343            assert_eq!(rules.len(), 3);
344
345            assert_eq!(rules[0].caller, Some(r"^\+1\d{10}$".to_string()));
346            assert_eq!(rules[0].callee, Some("^sip:support@.*".to_string()));
347            assert_eq!(rules[0].playbook, "support.md");
348
349            assert_eq!(rules[1].caller, Some(r"^\+86\d+".to_string()));
350            assert_eq!(rules[1].callee, None);
351            assert_eq!(rules[1].playbook, "chinese.md");
352
353            assert_eq!(rules[2].caller, None);
354            assert_eq!(rules[2].callee, Some("^sip:sales@.*".to_string()));
355            assert_eq!(rules[2].playbook, "sales.md");
356        } else {
357            panic!("Expected Playbook handler config");
358        }
359    }
360
361    #[test]
362    fn test_playbook_handler_config_without_default() {
363        let toml_config = r#"
364http_addr = "0.0.0.0:8080"
365addr = "0.0.0.0"
366udp_port = 25060
367
368[handler]
369type = "playbook"
370
371[[handler.rules]]
372caller = "^\\+1.*"
373playbook = "us.md"
374"#;
375
376        let config: Config = toml::from_str(toml_config).unwrap();
377
378        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
379            assert_eq!(default, None);
380            let rules = rules.unwrap();
381            assert_eq!(rules.len(), 1);
382        } else {
383            panic!("Expected Playbook handler config");
384        }
385    }
386
387    #[test]
388    fn test_webhook_handler_config_still_works() {
389        let toml_config = r#"
390http_addr = "0.0.0.0:8080"
391addr = "0.0.0.0"
392udp_port = 25060
393
394[handler]
395type = "webhook"
396url = "http://example.com/webhook"
397"#;
398
399        let config: Config = toml::from_str(toml_config).unwrap();
400
401        if let Some(InviteHandlerConfig::Webhook { url, .. }) = config.handler {
402            assert_eq!(url, Some("http://example.com/webhook".to_string()));
403        } else {
404            panic!("Expected Webhook handler config");
405        }
406    }
407}