Skip to main content

ringo_core/
baresip.rs

1use anyhow::{Context, Result};
2use std::{
3    fs,
4    io::Write,
5    net::{TcpListener, TcpStream},
6    path::PathBuf,
7    process::{Child, Command, Stdio},
8    time::{Duration, Instant},
9};
10
11// ─── Account & backend options ─────────────────────────────────────────────────
12
13/// A SIP account to register, independent of any ringo profile/config. Callers
14/// (the softphone or the scenario runner) build this from their own source.
15#[derive(Debug, Clone, Default)]
16pub struct Account {
17    pub username: String,
18    pub domain: String,
19    pub password: String,
20    pub display_name: Option<String>,
21    pub transport: Option<String>,
22    pub auth_user: Option<String>,
23    pub outbound: Option<String>,
24    pub stun_server: Option<String>,
25    pub media_enc: Option<String>,
26    pub regint: Option<u32>,
27    pub mwi: bool,
28    /// DTMF transmission mode (`rtpevent` / `info` / `auto`). `info` sends DTMF as
29    /// SIP INFO, independent of the RTP audio stream — needed where the audio TX
30    /// may be idle (e.g. headless with no clocked source). `None` keeps baresip's
31    /// default.
32    pub dtmf_mode: Option<String>,
33}
34
35/// Overrides for auto-detected baresip backend settings. Any `None`/empty field
36/// is auto-detected at spawn time.
37#[derive(Debug, Clone, Default)]
38pub struct BaresipOptions {
39    pub module_path: Option<String>,
40    pub audio_driver: Option<String>,
41    pub audio_player_device: Option<String>,
42    pub audio_source_device: Option<String>,
43    pub audio_alert_device: Option<String>,
44    pub sip_cafile: Option<String>,
45    /// `None` = auto-detect; `Some("")` = explicitly disable.
46    pub sip_capath: Option<String>,
47    /// Arbitrary extra config lines appended at the end (key, value).
48    pub extra: Vec<(String, String)>,
49    /// Enable baresip's SIP trace (`-s`, color disabled via `-c`) so full SIP
50    /// messages — including inbound custom headers, which the ctrl_tcp event API
51    /// does not expose — land in `baresip.log` for parsing (see `siptrace`).
52    pub sip_trace: bool,
53    /// Load the `sndfile` module so every call's decoded audio is recorded to a
54    /// `dump-…-dec.wav` next to the config (the spawn sets the working dir to the
55    /// instance's temp dir). Used by the scenario runner's audio assertions; the
56    /// softphone leaves this off.
57    pub record_audio: bool,
58}
59
60// ─── Instance ────────────────────────────────────────────────────────────────
61
62pub struct Instance {
63    pub port: u16,
64    pub log_path: PathBuf,
65    child: Child,
66    tmp_dir: PathBuf,
67}
68
69impl Instance {
70    pub fn spawn(name: &str, account: &Account, options: &BaresipOptions) -> Result<Self> {
71        let port = pick_free_port()?;
72
73        let ts = std::time::SystemTime::now()
74            .duration_since(std::time::UNIX_EPOCH)?
75            .as_secs();
76        let tmp_dir = PathBuf::from(format!("/tmp/ringo-{}-{}", name, ts));
77        fs::create_dir_all(&tmp_dir)?;
78
79        fs::write(
80            tmp_dir.join("config"),
81            generate_config_content(account, options, port)?,
82        )
83        .context("Failed to write config into temp dir")?;
84
85        fs::write(
86            tmp_dir.join("accounts"),
87            format!(
88                "# Generated by ringo — do not edit manually\n{}\n",
89                accounts_line(account)
90            ),
91        )
92        .context("Failed to write accounts into temp dir")?;
93
94        let log_path = tmp_dir.join("baresip.log");
95        let log_file = fs::File::create(&log_path).context("Failed to create baresip.log")?;
96        let log_file2 = log_file.try_clone()?;
97
98        let mut cmd = Command::new("baresip");
99        cmd.arg("-f").arg(&tmp_dir);
100        if options.sip_trace {
101            // -s: trace SIP messages to the log; -c: no ANSI colour, so the
102            // trace parses cleanly.
103            cmd.arg("-s").arg("-c");
104        }
105        if options.record_audio {
106            // sndfile records into the working dir; point it at the temp dir so
107            // the `dump-…-dec.wav` files are isolated and cleaned up with it.
108            cmd.current_dir(&tmp_dir);
109        }
110        let child = cmd
111            // No TTY: baresip otherwise prompts on stdin (e.g. for a missing
112            // account password) and blocks startup. /dev/null makes any prompt
113            // fail fast so it can't wedge a headless/CI run.
114            .stdin(Stdio::null())
115            .stdout(Stdio::from(log_file))
116            .stderr(Stdio::from(log_file2))
117            .spawn()
118            .context("Failed to start baresip. Is it installed and in PATH?")?;
119
120        crate::rlog!(
121            Info,
122            "baresip spawned pid={} port={} tmpdir={}",
123            child.id(),
124            port,
125            tmp_dir.display()
126        );
127
128        Ok(Self {
129            port,
130            log_path,
131            child,
132            tmp_dir,
133        })
134    }
135}
136
137impl Instance {
138    /// Stop baresip so it cleans up after itself at the registrar. Sending the
139    /// `quit` command over a short-lived ctrl_tcp connection makes baresip run
140    /// `ua_stop_all(forced=0)` — de-REGISTER (expires=0) for its binding and BYE
141    /// any calls — before exiting. A plain SIGKILL (the fallback) skips that and
142    /// leaves a stale binding at the registrar on every run, which makes inbound
143    /// calls flaky as the registrar forks to dead contacts.
144    ///
145    /// Only removes *this* instance's binding; bindings left by earlier
146    /// hard-killed runs still expire on their own.
147    fn graceful_stop(&mut self) {
148        if let Ok(mut stream) = TcpStream::connect(("127.0.0.1", self.port)) {
149            // netstring-framed `{"command":"quit"}` (see `client::write_command`)
150            let payload = br#"{"command":"quit"}"#;
151            let _ = write!(stream, "{}:", payload.len());
152            let _ = stream.write_all(payload);
153            let _ = stream.write_all(b",");
154            let _ = stream.flush();
155        }
156        // Wait for baresip to de-register and exit; SIGKILL if it overstays.
157        let deadline = Instant::now() + Duration::from_secs(2);
158        while Instant::now() < deadline {
159            match self.child.try_wait() {
160                Ok(Some(_)) => return, // exited cleanly (de-registered)
161                Ok(None) => std::thread::sleep(Duration::from_millis(50)),
162                Err(_) => break,
163            }
164        }
165        crate::rlog!(Warn, "baresip did not quit gracefully, killing");
166        let _ = self.child.kill();
167        let _ = self.child.wait();
168    }
169}
170
171impl Drop for Instance {
172    fn drop(&mut self) {
173        crate::rlog!(Info, "baresip cleanup, removing {}", self.tmp_dir.display());
174        self.graceful_stop();
175        if let Err(e) = fs::remove_dir_all(&self.tmp_dir) {
176            crate::rlog!(
177                Warn,
178                "tmpdir cleanup failed ({}): {}",
179                self.tmp_dir.display(),
180                e
181            );
182        }
183    }
184}
185
186const CONFIG_TEMPLATE: &str = include_str!("../assets/config.tera");
187
188fn accounts_line(account: &Account) -> String {
189    let display = account
190        .display_name
191        .as_deref()
192        .filter(|s| !s.is_empty())
193        .map(|s| format!("{} ", s))
194        .unwrap_or_default();
195
196    let transport = account
197        .transport
198        .as_deref()
199        .filter(|s| !s.is_empty())
200        .map(|s| format!(";transport={}", s))
201        .unwrap_or_default();
202
203    let auth_user = account
204        .auth_user
205        .as_deref()
206        .filter(|s| !s.is_empty())
207        .unwrap_or(&account.username);
208
209    let mut line = format!(
210        "{}<sip:{}@{}{}>; auth_user={};auth_pass={}",
211        display, account.username, account.domain, transport, auth_user, account.password
212    );
213
214    if let Some(v) = account.outbound.as_deref().filter(|s| !s.is_empty()) {
215        line.push_str(&format!(";outbound={}", v));
216    }
217    if let Some(v) = account.stun_server.as_deref().filter(|s| !s.is_empty()) {
218        line.push_str(&format!(";stunserver={}", v));
219    }
220    if let Some(v) = account.media_enc.as_deref().filter(|s| !s.is_empty()) {
221        line.push_str(&format!(";mediaenc={}", v));
222    }
223    if let Some(v) = account.regint {
224        line.push_str(&format!(";regint={}", v));
225    }
226    if let Some(v) = account.dtmf_mode.as_deref().filter(|s| !s.is_empty()) {
227        line.push_str(&format!(";dtmfmode={}", v));
228    }
229
230    line
231}
232
233fn generate_config_content(
234    account: &Account,
235    overrides: &BaresipOptions,
236    port: u16,
237) -> Result<String> {
238    let module_path = overrides
239        .module_path
240        .clone()
241        .unwrap_or_else(detect_module_path);
242    let audio_driver = overrides
243        .audio_driver
244        .as_deref()
245        .unwrap_or_else(|| detect_audio_driver(&module_path));
246    let audio_player_device = overrides
247        .audio_player_device
248        .as_deref()
249        .unwrap_or("default");
250    let audio_source_device = overrides
251        .audio_source_device
252        .as_deref()
253        .unwrap_or("default");
254    let audio_alert_device = overrides.audio_alert_device.as_deref().unwrap_or("default");
255    let sip_cafile = overrides
256        .sip_cafile
257        .clone()
258        .unwrap_or_else(detect_sip_cafile);
259    let sip_capath: Option<String> = match &overrides.sip_capath {
260        Some(s) if s.is_empty() => None, // explicit disable via ""
261        Some(s) => Some(s.clone()),
262        None => detect_sip_capath(),
263    };
264
265    let extra_codecs = detect_codecs(&module_path);
266
267    let mut ctx = tera::Context::new();
268    ctx.insert("module_path", &module_path);
269    ctx.insert("audio_driver", &audio_driver);
270    ctx.insert("audio_player_device", &audio_player_device);
271    ctx.insert("audio_source_device", &audio_source_device);
272    ctx.insert("audio_alert_device", &audio_alert_device);
273    ctx.insert("extra_codecs", &extra_codecs);
274    ctx.insert("port", &port);
275    ctx.insert("sip_cafile", &sip_cafile);
276    ctx.insert("sip_capath", &sip_capath);
277    ctx.insert("mwi", &account.mwi);
278    ctx.insert("record_audio", &overrides.record_audio);
279
280    let mut extra_lines: Vec<String> = overrides
281        .extra
282        .iter()
283        .map(|(k, v)| format!("{:<20}{}", k, v))
284        .collect();
285    extra_lines.sort();
286    ctx.insert("extra_config", &extra_lines);
287
288    tera::Tera::one_off(CONFIG_TEMPLATE, &ctx, false)
289        .context("Failed to render baresip config template")
290}
291
292fn detect_module_path() -> String {
293    // Try pkg-config first
294    if let Ok(out) = Command::new("pkg-config")
295        .args(["--variable=moduledir", "baresip"])
296        .output()
297    {
298        if out.status.success() {
299            let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
300            if !path.is_empty() && std::path::Path::new(&path).exists() {
301                return path;
302            }
303        }
304    }
305
306    // Known paths ordered by likelihood
307    let candidates = [
308        "/opt/homebrew/lib/baresip/modules", // macOS ARM (Homebrew)
309        "/usr/local/lib/baresip/modules",    // macOS Intel (Homebrew)
310        "/usr/lib/x86_64-linux-gnu/baresip/modules", // Debian/Ubuntu x86_64
311        "/usr/lib/aarch64-linux-gnu/baresip/modules", // Debian/Ubuntu ARM64
312        "/usr/lib/baresip/modules",          // Arch Linux / generic
313        "/usr/lib64/baresip/modules",        // Fedora/RHEL
314    ];
315
316    for path in &candidates {
317        if std::path::Path::new(path).exists() {
318            return path.to_string();
319        }
320    }
321
322    "/usr/lib/baresip/modules".to_string()
323}
324
325fn detect_audio_driver(
326    #[cfg_attr(target_os = "macos", allow(unused_variables))] module_path: &str,
327) -> &'static str {
328    #[cfg(target_os = "macos")]
329    return "coreaudio";
330
331    #[cfg(not(target_os = "macos"))]
332    {
333        let base = std::path::Path::new(module_path);
334        for driver in &["pipewire", "pulse", "alsa"] {
335            if base.join(format!("{}.so", driver)).exists() {
336                return driver;
337            }
338        }
339        "alsa"
340    }
341}
342
343fn detect_sip_cafile() -> String {
344    let candidates = [
345        "/etc/ssl/cert.pem",                  // macOS
346        "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Arch
347        "/etc/pki/tls/certs/ca-bundle.crt",   // Fedora/RHEL
348    ];
349
350    for path in &candidates {
351        if std::path::Path::new(path).exists() {
352            return path.to_string();
353        }
354    }
355
356    "/etc/ssl/certs/ca-certificates.crt".to_string()
357}
358
359fn detect_sip_capath() -> Option<String> {
360    #[cfg(target_os = "macos")]
361    return None;
362
363    #[cfg(not(target_os = "macos"))]
364    {
365        let path = "/etc/ssl/certs";
366        if std::path::Path::new(path).exists() {
367            Some(path.to_string())
368        } else {
369            None
370        }
371    }
372}
373
374/// Detect optional codec modules available in the module path.
375fn detect_codecs(module_path: &str) -> Vec<String> {
376    let base = std::path::Path::new(module_path);
377    let candidates = ["opus", "g722", "g726", "gsm", "l16"];
378    candidates
379        .iter()
380        .filter(|c| base.join(format!("{}.so", c)).exists())
381        .map(|c| c.to_string())
382        .collect()
383}
384
385/// Ask the OS for a free ephemeral port by binding to port 0.
386fn pick_free_port() -> Result<u16> {
387    let listener = TcpListener::bind("127.0.0.1:0").context("Failed to bind for port discovery")?;
388    Ok(listener.local_addr()?.port())
389}