use anyhow::{Context, Result};
use std::{
fs,
net::TcpListener,
path::PathBuf,
process::{Child, Command, Stdio},
};
#[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,
}
#[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>,
pub sip_capath: Option<String>,
pub extra: Vec<(String, String)>,
}
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 child = Command::new("baresip")
.arg("-f")
.arg(&tmp_dir)
.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 Drop for Instance {
fn drop(&mut self) {
crate::rlog!(Info, "baresip cleanup, removing {}", self.tmp_dir.display());
if let Err(e) = self.child.kill() {
crate::rlog!(Warn, "baresip kill failed: {}", e);
}
if let Err(e) = self.child.wait() {
crate::rlog!(Warn, "baresip wait failed: {}", e);
}
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));
}
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, 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);
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 {
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;
}
}
}
let candidates = [
"/opt/homebrew/lib/baresip/modules", "/usr/local/lib/baresip/modules", "/usr/lib/x86_64-linux-gnu/baresip/modules", "/usr/lib/aarch64-linux-gnu/baresip/modules", "/usr/lib/baresip/modules", "/usr/lib64/baresip/modules", ];
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", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", ];
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
}
}
}
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()
}
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())
}