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#[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 pub dtmf_mode: Option<String>,
33}
34
35#[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 pub sip_capath: Option<String>,
47 pub extra: Vec<(String, String)>,
49 pub sip_trace: bool,
53 pub record_audio: bool,
58}
59
60pub 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 cmd.arg("-s").arg("-c");
104 }
105 if options.record_audio {
106 cmd.current_dir(&tmp_dir);
109 }
110 let child = cmd
111 .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 fn graceful_stop(&mut self) {
148 if let Ok(mut stream) = TcpStream::connect(("127.0.0.1", self.port)) {
149 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 let deadline = Instant::now() + Duration::from_secs(2);
158 while Instant::now() < deadline {
159 match self.child.try_wait() {
160 Ok(Some(_)) => return, 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, 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 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 let candidates = [
308 "/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", ];
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", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", ];
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
374fn 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
385fn 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}