Skip to main content

ringo_core/
baresip.rs

1use anyhow::{Context, Result};
2use std::{
3    fs,
4    net::TcpListener,
5    path::PathBuf,
6    process::{Child, Command, Stdio},
7};
8
9// ─── Account & backend options ─────────────────────────────────────────────────
10
11/// A SIP account to register, independent of any ringo profile/config. Callers
12/// (the softphone or the scenario runner) build this from their own source.
13#[derive(Debug, Clone, Default)]
14pub struct Account {
15    pub username: String,
16    pub domain: String,
17    pub password: String,
18    pub display_name: Option<String>,
19    pub transport: Option<String>,
20    pub auth_user: Option<String>,
21    pub outbound: Option<String>,
22    pub stun_server: Option<String>,
23    pub media_enc: Option<String>,
24    pub regint: Option<u32>,
25    pub mwi: bool,
26}
27
28/// Overrides for auto-detected baresip backend settings. Any `None`/empty field
29/// is auto-detected at spawn time.
30#[derive(Debug, Clone, Default)]
31pub struct BaresipOptions {
32    pub module_path: Option<String>,
33    pub audio_driver: Option<String>,
34    pub audio_player_device: Option<String>,
35    pub audio_source_device: Option<String>,
36    pub audio_alert_device: Option<String>,
37    pub sip_cafile: Option<String>,
38    /// `None` = auto-detect; `Some("")` = explicitly disable.
39    pub sip_capath: Option<String>,
40    /// Arbitrary extra config lines appended at the end (key, value).
41    pub extra: Vec<(String, String)>,
42}
43
44// ─── Instance ────────────────────────────────────────────────────────────────
45
46pub struct Instance {
47    pub port: u16,
48    pub log_path: PathBuf,
49    child: Child,
50    tmp_dir: PathBuf,
51}
52
53impl Instance {
54    pub fn spawn(name: &str, account: &Account, options: &BaresipOptions) -> Result<Self> {
55        let port = pick_free_port()?;
56
57        let ts = std::time::SystemTime::now()
58            .duration_since(std::time::UNIX_EPOCH)?
59            .as_secs();
60        let tmp_dir = PathBuf::from(format!("/tmp/ringo-{}-{}", name, ts));
61        fs::create_dir_all(&tmp_dir)?;
62
63        fs::write(
64            tmp_dir.join("config"),
65            generate_config_content(account, options, port)?,
66        )
67        .context("Failed to write config into temp dir")?;
68
69        fs::write(
70            tmp_dir.join("accounts"),
71            format!(
72                "# Generated by ringo — do not edit manually\n{}\n",
73                accounts_line(account)
74            ),
75        )
76        .context("Failed to write accounts into temp dir")?;
77
78        let log_path = tmp_dir.join("baresip.log");
79        let log_file = fs::File::create(&log_path).context("Failed to create baresip.log")?;
80        let log_file2 = log_file.try_clone()?;
81
82        let child = Command::new("baresip")
83            .arg("-f")
84            .arg(&tmp_dir)
85            .stdout(Stdio::from(log_file))
86            .stderr(Stdio::from(log_file2))
87            .spawn()
88            .context("Failed to start baresip. Is it installed and in PATH?")?;
89
90        crate::rlog!(
91            Info,
92            "baresip spawned pid={} port={} tmpdir={}",
93            child.id(),
94            port,
95            tmp_dir.display()
96        );
97
98        Ok(Self {
99            port,
100            log_path,
101            child,
102            tmp_dir,
103        })
104    }
105}
106
107impl Drop for Instance {
108    fn drop(&mut self) {
109        crate::rlog!(Info, "baresip cleanup, removing {}", self.tmp_dir.display());
110        if let Err(e) = self.child.kill() {
111            crate::rlog!(Warn, "baresip kill failed: {}", e);
112        }
113        if let Err(e) = self.child.wait() {
114            crate::rlog!(Warn, "baresip wait failed: {}", e);
115        }
116        if let Err(e) = fs::remove_dir_all(&self.tmp_dir) {
117            crate::rlog!(
118                Warn,
119                "tmpdir cleanup failed ({}): {}",
120                self.tmp_dir.display(),
121                e
122            );
123        }
124    }
125}
126
127const CONFIG_TEMPLATE: &str = include_str!("../assets/config.tera");
128
129fn accounts_line(account: &Account) -> String {
130    let display = account
131        .display_name
132        .as_deref()
133        .filter(|s| !s.is_empty())
134        .map(|s| format!("{} ", s))
135        .unwrap_or_default();
136
137    let transport = account
138        .transport
139        .as_deref()
140        .filter(|s| !s.is_empty())
141        .map(|s| format!(";transport={}", s))
142        .unwrap_or_default();
143
144    let auth_user = account
145        .auth_user
146        .as_deref()
147        .filter(|s| !s.is_empty())
148        .unwrap_or(&account.username);
149
150    let mut line = format!(
151        "{}<sip:{}@{}{}>; auth_user={};auth_pass={}",
152        display, account.username, account.domain, transport, auth_user, account.password
153    );
154
155    if let Some(v) = account.outbound.as_deref().filter(|s| !s.is_empty()) {
156        line.push_str(&format!(";outbound={}", v));
157    }
158    if let Some(v) = account.stun_server.as_deref().filter(|s| !s.is_empty()) {
159        line.push_str(&format!(";stunserver={}", v));
160    }
161    if let Some(v) = account.media_enc.as_deref().filter(|s| !s.is_empty()) {
162        line.push_str(&format!(";mediaenc={}", v));
163    }
164    if let Some(v) = account.regint {
165        line.push_str(&format!(";regint={}", v));
166    }
167
168    line
169}
170
171fn generate_config_content(
172    account: &Account,
173    overrides: &BaresipOptions,
174    port: u16,
175) -> Result<String> {
176    let module_path = overrides
177        .module_path
178        .clone()
179        .unwrap_or_else(detect_module_path);
180    let audio_driver = overrides
181        .audio_driver
182        .as_deref()
183        .unwrap_or_else(|| detect_audio_driver(&module_path));
184    let audio_player_device = overrides
185        .audio_player_device
186        .as_deref()
187        .unwrap_or("default");
188    let audio_source_device = overrides
189        .audio_source_device
190        .as_deref()
191        .unwrap_or("default");
192    let audio_alert_device = overrides.audio_alert_device.as_deref().unwrap_or("default");
193    let sip_cafile = overrides
194        .sip_cafile
195        .clone()
196        .unwrap_or_else(detect_sip_cafile);
197    let sip_capath: Option<String> = match &overrides.sip_capath {
198        Some(s) if s.is_empty() => None, // explicit disable via ""
199        Some(s) => Some(s.clone()),
200        None => detect_sip_capath(),
201    };
202
203    let extra_codecs = detect_codecs(&module_path);
204
205    let mut ctx = tera::Context::new();
206    ctx.insert("module_path", &module_path);
207    ctx.insert("audio_driver", &audio_driver);
208    ctx.insert("audio_player_device", &audio_player_device);
209    ctx.insert("audio_source_device", &audio_source_device);
210    ctx.insert("audio_alert_device", &audio_alert_device);
211    ctx.insert("extra_codecs", &extra_codecs);
212    ctx.insert("port", &port);
213    ctx.insert("sip_cafile", &sip_cafile);
214    ctx.insert("sip_capath", &sip_capath);
215    ctx.insert("mwi", &account.mwi);
216
217    let mut extra_lines: Vec<String> = overrides
218        .extra
219        .iter()
220        .map(|(k, v)| format!("{:<20}{}", k, v))
221        .collect();
222    extra_lines.sort();
223    ctx.insert("extra_config", &extra_lines);
224
225    tera::Tera::one_off(CONFIG_TEMPLATE, &ctx, false)
226        .context("Failed to render baresip config template")
227}
228
229fn detect_module_path() -> String {
230    // Try pkg-config first
231    if let Ok(out) = Command::new("pkg-config")
232        .args(["--variable=moduledir", "baresip"])
233        .output()
234    {
235        if out.status.success() {
236            let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
237            if !path.is_empty() && std::path::Path::new(&path).exists() {
238                return path;
239            }
240        }
241    }
242
243    // Known paths ordered by likelihood
244    let candidates = [
245        "/opt/homebrew/lib/baresip/modules", // macOS ARM (Homebrew)
246        "/usr/local/lib/baresip/modules",    // macOS Intel (Homebrew)
247        "/usr/lib/x86_64-linux-gnu/baresip/modules", // Debian/Ubuntu x86_64
248        "/usr/lib/aarch64-linux-gnu/baresip/modules", // Debian/Ubuntu ARM64
249        "/usr/lib/baresip/modules",          // Arch Linux / generic
250        "/usr/lib64/baresip/modules",        // Fedora/RHEL
251    ];
252
253    for path in &candidates {
254        if std::path::Path::new(path).exists() {
255            return path.to_string();
256        }
257    }
258
259    "/usr/lib/baresip/modules".to_string()
260}
261
262fn detect_audio_driver(
263    #[cfg_attr(target_os = "macos", allow(unused_variables))] module_path: &str,
264) -> &'static str {
265    #[cfg(target_os = "macos")]
266    return "coreaudio";
267
268    #[cfg(not(target_os = "macos"))]
269    {
270        let base = std::path::Path::new(module_path);
271        for driver in &["pipewire", "pulse", "alsa"] {
272            if base.join(format!("{}.so", driver)).exists() {
273                return driver;
274            }
275        }
276        "alsa"
277    }
278}
279
280fn detect_sip_cafile() -> String {
281    let candidates = [
282        "/etc/ssl/cert.pem",                  // macOS
283        "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Arch
284        "/etc/pki/tls/certs/ca-bundle.crt",   // Fedora/RHEL
285    ];
286
287    for path in &candidates {
288        if std::path::Path::new(path).exists() {
289            return path.to_string();
290        }
291    }
292
293    "/etc/ssl/certs/ca-certificates.crt".to_string()
294}
295
296fn detect_sip_capath() -> Option<String> {
297    #[cfg(target_os = "macos")]
298    return None;
299
300    #[cfg(not(target_os = "macos"))]
301    {
302        let path = "/etc/ssl/certs";
303        if std::path::Path::new(path).exists() {
304            Some(path.to_string())
305        } else {
306            None
307        }
308    }
309}
310
311/// Detect optional codec modules available in the module path.
312fn detect_codecs(module_path: &str) -> Vec<String> {
313    let base = std::path::Path::new(module_path);
314    let candidates = ["opus", "g722", "g726", "gsm", "l16"];
315    candidates
316        .iter()
317        .filter(|c| base.join(format!("{}.so", c)).exists())
318        .map(|c| c.to_string())
319        .collect()
320}
321
322/// Ask the OS for a free ephemeral port by binding to port 0.
323fn pick_free_port() -> Result<u16> {
324    let listener = TcpListener::bind("127.0.0.1:0").context("Failed to bind for port discovery")?;
325    Ok(listener.local_addr()?.port())
326}