ringo-core 0.10.1

Shared baresip backend, ctrl_tcp protocol and call engine for the ringo tools
Documentation
use anyhow::{Context, Result};
use std::{
    fs,
    io::Write,
    net::{TcpListener, TcpStream},
    path::PathBuf,
    process::{Child, Command, Stdio},
    time::{Duration, Instant},
};

// ─── Account & backend options ─────────────────────────────────────────────────

/// A SIP account to register, independent of any ringo profile/config. Callers
/// (the softphone or the scenario runner) build this from their own source.
#[derive(Debug, Clone, Default)]
pub struct Account {
    pub username: String,
    pub domain: String,
    pub password: String,
    pub display_name: Option<String>,
    pub transport: Option<String>,
    pub auth_user: Option<String>,
    pub outbound: Option<String>,
    pub stun_server: Option<String>,
    pub media_enc: Option<String>,
    pub regint: Option<u32>,
    pub mwi: bool,
    /// DTMF transmission mode (`rtpevent` / `info` / `auto`). `info` sends DTMF as
    /// SIP INFO, independent of the RTP audio stream — needed where the audio TX
    /// may be idle (e.g. headless with no clocked source). `None` keeps baresip's
    /// default.
    pub dtmf_mode: Option<String>,
}

/// Overrides for auto-detected baresip backend settings. Any `None`/empty field
/// is auto-detected at spawn time.
#[derive(Debug, Clone, Default)]
pub struct BaresipOptions {
    pub module_path: Option<String>,
    pub audio_driver: Option<String>,
    pub audio_player_device: Option<String>,
    pub audio_source_device: Option<String>,
    pub audio_alert_device: Option<String>,
    pub sip_cafile: Option<String>,
    /// `None` = auto-detect; `Some("")` = explicitly disable.
    pub sip_capath: Option<String>,
    /// Arbitrary extra config lines appended at the end (key, value).
    pub extra: Vec<(String, String)>,
    /// Enable baresip's SIP trace (`-s`, color disabled via `-c`) so full SIP
    /// messages — including inbound custom headers, which the ctrl_tcp event API
    /// does not expose — land in `baresip.log` for parsing (see `siptrace`).
    pub sip_trace: bool,
    /// Load the `sndfile` module so every call's decoded audio is recorded to a
    /// `dump-…-dec.wav` next to the config (the spawn sets the working dir to the
    /// instance's temp dir). Used by the scenario runner's audio assertions; the
    /// softphone leaves this off.
    pub record_audio: bool,
}

// ─── Instance ────────────────────────────────────────────────────────────────

pub struct Instance {
    pub port: u16,
    pub log_path: PathBuf,
    child: Child,
    tmp_dir: PathBuf,
}

impl Instance {
    pub fn spawn(name: &str, account: &Account, options: &BaresipOptions) -> Result<Self> {
        let port = pick_free_port()?;

        let ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)?
            .as_secs();
        let tmp_dir = PathBuf::from(format!("/tmp/ringo-{}-{}", name, ts));
        fs::create_dir_all(&tmp_dir)?;

        fs::write(
            tmp_dir.join("config"),
            generate_config_content(account, options, port)?,
        )
        .context("Failed to write config into temp dir")?;

        fs::write(
            tmp_dir.join("accounts"),
            format!(
                "# Generated by ringo — do not edit manually\n{}\n",
                accounts_line(account)
            ),
        )
        .context("Failed to write accounts into temp dir")?;

        let log_path = tmp_dir.join("baresip.log");
        let log_file = fs::File::create(&log_path).context("Failed to create baresip.log")?;
        let log_file2 = log_file.try_clone()?;

        let mut cmd = Command::new("baresip");
        cmd.arg("-f").arg(&tmp_dir);
        if options.sip_trace {
            // -s: trace SIP messages to the log; -c: no ANSI colour, so the
            // trace parses cleanly.
            cmd.arg("-s").arg("-c");
        }
        if options.record_audio {
            // sndfile records into the working dir; point it at the temp dir so
            // the `dump-…-dec.wav` files are isolated and cleaned up with it.
            cmd.current_dir(&tmp_dir);
        }
        let child = cmd
            // No TTY: baresip otherwise prompts on stdin (e.g. for a missing
            // account password) and blocks startup. /dev/null makes any prompt
            // fail fast so it can't wedge a headless/CI run.
            .stdin(Stdio::null())
            .stdout(Stdio::from(log_file))
            .stderr(Stdio::from(log_file2))
            .spawn()
            .context("Failed to start baresip. Is it installed and in PATH?")?;

        crate::rlog!(
            Info,
            "baresip spawned pid={} port={} tmpdir={}",
            child.id(),
            port,
            tmp_dir.display()
        );

        Ok(Self {
            port,
            log_path,
            child,
            tmp_dir,
        })
    }
}

impl Instance {
    /// Stop baresip so it cleans up after itself at the registrar. Sending the
    /// `quit` command over a short-lived ctrl_tcp connection makes baresip run
    /// `ua_stop_all(forced=0)` — de-REGISTER (expires=0) for its binding and BYE
    /// any calls — before exiting. A plain SIGKILL (the fallback) skips that and
    /// leaves a stale binding at the registrar on every run, which makes inbound
    /// calls flaky as the registrar forks to dead contacts.
    ///
    /// Only removes *this* instance's binding; bindings left by earlier
    /// hard-killed runs still expire on their own.
    fn graceful_stop(&mut self) {
        if let Ok(mut stream) = TcpStream::connect(("127.0.0.1", self.port)) {
            // netstring-framed `{"command":"quit"}` (see `client::write_command`)
            let payload = br#"{"command":"quit"}"#;
            let _ = write!(stream, "{}:", payload.len());
            let _ = stream.write_all(payload);
            let _ = stream.write_all(b",");
            let _ = stream.flush();
        }
        // Wait for baresip to de-register and exit; SIGKILL if it overstays.
        let deadline = Instant::now() + Duration::from_secs(2);
        while Instant::now() < deadline {
            match self.child.try_wait() {
                Ok(Some(_)) => return, // exited cleanly (de-registered)
                Ok(None) => std::thread::sleep(Duration::from_millis(50)),
                Err(_) => break,
            }
        }
        crate::rlog!(Warn, "baresip did not quit gracefully, killing");
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

impl Drop for Instance {
    fn drop(&mut self) {
        crate::rlog!(Info, "baresip cleanup, removing {}", self.tmp_dir.display());
        self.graceful_stop();
        if let Err(e) = fs::remove_dir_all(&self.tmp_dir) {
            crate::rlog!(
                Warn,
                "tmpdir cleanup failed ({}): {}",
                self.tmp_dir.display(),
                e
            );
        }
    }
}

const CONFIG_TEMPLATE: &str = include_str!("../assets/config.tera");

fn accounts_line(account: &Account) -> String {
    let display = account
        .display_name
        .as_deref()
        .filter(|s| !s.is_empty())
        .map(|s| format!("{} ", s))
        .unwrap_or_default();

    let transport = account
        .transport
        .as_deref()
        .filter(|s| !s.is_empty())
        .map(|s| format!(";transport={}", s))
        .unwrap_or_default();

    let auth_user = account
        .auth_user
        .as_deref()
        .filter(|s| !s.is_empty())
        .unwrap_or(&account.username);

    let mut line = format!(
        "{}<sip:{}@{}{}>; auth_user={};auth_pass={}",
        display, account.username, account.domain, transport, auth_user, account.password
    );

    if let Some(v) = account.outbound.as_deref().filter(|s| !s.is_empty()) {
        line.push_str(&format!(";outbound={}", v));
    }
    if let Some(v) = account.stun_server.as_deref().filter(|s| !s.is_empty()) {
        line.push_str(&format!(";stunserver={}", v));
    }
    if let Some(v) = account.media_enc.as_deref().filter(|s| !s.is_empty()) {
        line.push_str(&format!(";mediaenc={}", v));
    }
    if let Some(v) = account.regint {
        line.push_str(&format!(";regint={}", v));
    }
    if let Some(v) = account.dtmf_mode.as_deref().filter(|s| !s.is_empty()) {
        line.push_str(&format!(";dtmfmode={}", v));
    }

    line
}

fn generate_config_content(
    account: &Account,
    overrides: &BaresipOptions,
    port: u16,
) -> Result<String> {
    let module_path = overrides
        .module_path
        .clone()
        .unwrap_or_else(detect_module_path);
    let audio_driver = overrides
        .audio_driver
        .as_deref()
        .unwrap_or_else(|| detect_audio_driver(&module_path));
    let audio_player_device = overrides
        .audio_player_device
        .as_deref()
        .unwrap_or("default");
    let audio_source_device = overrides
        .audio_source_device
        .as_deref()
        .unwrap_or("default");
    let audio_alert_device = overrides.audio_alert_device.as_deref().unwrap_or("default");
    let sip_cafile = overrides
        .sip_cafile
        .clone()
        .unwrap_or_else(detect_sip_cafile);
    let sip_capath: Option<String> = match &overrides.sip_capath {
        Some(s) if s.is_empty() => None, // explicit disable via ""
        Some(s) => Some(s.clone()),
        None => detect_sip_capath(),
    };

    let extra_codecs = detect_codecs(&module_path);

    let mut ctx = tera::Context::new();
    ctx.insert("module_path", &module_path);
    ctx.insert("audio_driver", &audio_driver);
    ctx.insert("audio_player_device", &audio_player_device);
    ctx.insert("audio_source_device", &audio_source_device);
    ctx.insert("audio_alert_device", &audio_alert_device);
    ctx.insert("extra_codecs", &extra_codecs);
    ctx.insert("port", &port);
    ctx.insert("sip_cafile", &sip_cafile);
    ctx.insert("sip_capath", &sip_capath);
    ctx.insert("mwi", &account.mwi);
    ctx.insert("record_audio", &overrides.record_audio);

    let mut extra_lines: Vec<String> = overrides
        .extra
        .iter()
        .map(|(k, v)| format!("{:<20}{}", k, v))
        .collect();
    extra_lines.sort();
    ctx.insert("extra_config", &extra_lines);

    tera::Tera::one_off(CONFIG_TEMPLATE, &ctx, false)
        .context("Failed to render baresip config template")
}

fn detect_module_path() -> String {
    // Try pkg-config first
    if let Ok(out) = Command::new("pkg-config")
        .args(["--variable=moduledir", "baresip"])
        .output()
    {
        if out.status.success() {
            let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if !path.is_empty() && std::path::Path::new(&path).exists() {
                return path;
            }
        }
    }

    // Known paths ordered by likelihood
    let candidates = [
        "/opt/homebrew/lib/baresip/modules", // macOS ARM (Homebrew)
        "/usr/local/lib/baresip/modules",    // macOS Intel (Homebrew)
        "/usr/lib/x86_64-linux-gnu/baresip/modules", // Debian/Ubuntu x86_64
        "/usr/lib/aarch64-linux-gnu/baresip/modules", // Debian/Ubuntu ARM64
        "/usr/lib/baresip/modules",          // Arch Linux / generic
        "/usr/lib64/baresip/modules",        // Fedora/RHEL
    ];

    for path in &candidates {
        if std::path::Path::new(path).exists() {
            return path.to_string();
        }
    }

    "/usr/lib/baresip/modules".to_string()
}

fn detect_audio_driver(
    #[cfg_attr(target_os = "macos", allow(unused_variables))] module_path: &str,
) -> &'static str {
    #[cfg(target_os = "macos")]
    return "coreaudio";

    #[cfg(not(target_os = "macos"))]
    {
        let base = std::path::Path::new(module_path);
        for driver in &["pipewire", "pulse", "alsa"] {
            if base.join(format!("{}.so", driver)).exists() {
                return driver;
            }
        }
        "alsa"
    }
}

fn detect_sip_cafile() -> String {
    let candidates = [
        "/etc/ssl/cert.pem",                  // macOS
        "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Arch
        "/etc/pki/tls/certs/ca-bundle.crt",   // Fedora/RHEL
    ];

    for path in &candidates {
        if std::path::Path::new(path).exists() {
            return path.to_string();
        }
    }

    "/etc/ssl/certs/ca-certificates.crt".to_string()
}

fn detect_sip_capath() -> Option<String> {
    #[cfg(target_os = "macos")]
    return None;

    #[cfg(not(target_os = "macos"))]
    {
        let path = "/etc/ssl/certs";
        if std::path::Path::new(path).exists() {
            Some(path.to_string())
        } else {
            None
        }
    }
}

/// Detect optional codec modules available in the module path.
fn detect_codecs(module_path: &str) -> Vec<String> {
    let base = std::path::Path::new(module_path);
    let candidates = ["opus", "g722", "g726", "gsm", "l16"];
    candidates
        .iter()
        .filter(|c| base.join(format!("{}.so", c)).exists())
        .map(|c| c.to_string())
        .collect()
}

/// Ask the OS for a free ephemeral port by binding to port 0.
fn pick_free_port() -> Result<u16> {
    let listener = TcpListener::bind("127.0.0.1:0").context("Failed to bind for port discovery")?;
    Ok(listener.local_addr()?.port())
}