1use anyhow::{Context, Result};
2use std::{
3 fs,
4 net::TcpListener,
5 path::PathBuf,
6 process::{Child, Command, Stdio},
7};
8
9#[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#[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 pub sip_capath: Option<String>,
40 pub extra: Vec<(String, String)>,
42}
43
44pub 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, 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 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 let candidates = [
245 "/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", ];
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", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", ];
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
311fn 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
322fn 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}