use crate::api::MyceliumClient; use crate::elevation; use crate::error::{AppError, AppResult}; use parking_lot::Mutex; use std::collections::VecDeque; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter, Manager}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Child; use tracing::{info, warn}; const HEALTH_CHECK_TIMEOUT_SECS: u64 = 20; const HEALTH_CHECK_INTERVAL_MS: u64 = 400; const LOG_RING_CAPACITY: usize = 500; #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SidecarConfig { pub peers: Vec, pub tun_name: Option, pub no_tun: bool, } impl Default for SidecarConfig { fn default() -> Self { Self { // A small set of well-known public peers from the mycelium README, // used as bootstrap when the user hasn't configured their own. peers: vec![ "tcp://188.40.132.242:9651".into(), "quic://[2a01:4f8:212:fa6::2]:9651".into(), ], tun_name: None, no_tun: false, } } } /// Holds the running mycelium child process plus a small in-memory log /// buffer so the Settings page can show recent stderr/stdout without /// reading from disk. pub struct SidecarHandle { child: Mutex>, api_url: Mutex>, logs: Mutex>, config_path: Mutex>, key_path: Mutex>, /// Full overlay IPv6, parsed out of mycelium's `Node overlay IP: ...` /// startup log line. The daemon's HTTP API only exposes the subnet, /// so we extract the host part from the binary's stderr. overlay_ip: Mutex>, } impl SidecarHandle { pub fn new() -> Arc { Arc::new(Self { child: Mutex::new(None), api_url: Mutex::new(None), logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)), config_path: Mutex::new(None), key_path: Mutex::new(None), overlay_ip: Mutex::new(None), }) } pub fn is_running(&self) -> bool { self.api_url.lock().is_some() } pub fn client(&self) -> Option { self.api_url .lock() .as_ref() .map(|u| MyceliumClient::new(u.clone())) } pub fn logs_snapshot(&self) -> Vec { self.logs.lock().iter().cloned().collect() } pub fn key_path(&self) -> Option { self.key_path.lock().clone() } pub fn config_path(&self) -> Option { self.config_path.lock().clone() } pub fn overlay_ip(&self) -> Option { self.overlay_ip.lock().clone() } fn push_log(&self, line: String) { // mycelium prefixes log lines with ANSI colour escapes; strip them // before scanning for the overlay IP marker so the regex stays // resilient to cosmetic changes in upstream's log format. if self.overlay_ip.lock().is_none() { let stripped = strip_ansi(&line); if let Some(ip) = extract_overlay_ip(&stripped) { tracing::info!(overlay_ip = %ip, "node overlay IP captured"); *self.overlay_ip.lock() = Some(ip); } } let mut buf = self.logs.lock(); if buf.len() >= LOG_RING_CAPACITY { buf.pop_front(); } buf.push_back(line); } pub async fn start( self: &Arc, app: &AppHandle, config: &SidecarConfig, ) -> AppResult { if self.is_running() { return Err(AppError::DaemonAlreadyRunning); } let bin = locate_sidecar(app)?; // Three ports: HTTP API (loopback), TCP listen, QUIC (UDP) listen. // mycelium defaults to 9651 for both peer-listen ports, which // collides if another instance (or a leftover from a previous test) // is already up. Always picking ephemeral ports avoids that at the // cost of inbound peers needing the actual port number. let api_port = pick_port()?; let tcp_port = pick_port_skip(&[api_port])?; let quic_port = pick_port_skip(&[api_port, tcp_port])?; // mycelium also opens an internal JSON-RPC / metrics endpoint on // 127.0.0.1:8990 by default; if 8990 is already taken (e.g. by an // orphan from a previous run we couldn't SIGKILL because it ran as // root) the new instance dies a few seconds after start. Pin this // to a fresh ephemeral port too. let metrics_port = pick_port_skip(&[api_port, tcp_port, quic_port])?; let data_dir = app .path() .app_data_dir() .map_err(|e| AppError::TauriPath(e.to_string()))?; std::fs::create_dir_all(&data_dir)?; let key_path = data_dir.join("priv_key.bin"); let config_path = data_dir.join("mycelium.toml"); let mut args = vec![ "--api-addr".to_string(), format!("127.0.0.1:{api_port}"), "--tcp-listen-port".to_string(), tcp_port.to_string(), "--quic-listen-port".to_string(), quic_port.to_string(), "--metrics-api-address".to_string(), format!("127.0.0.1:{metrics_port}"), "--key-file".to_string(), key_path.display().to_string(), ]; if config_path.exists() { args.push("--config-file".to_string()); args.push(config_path.display().to_string()); } if config.no_tun { args.push("--no-tun".to_string()); } if let Some(name) = &config.tun_name { args.push("--tun-name".to_string()); args.push(name.clone()); } if !config.peers.is_empty() { args.push("--peers".to_string()); for p in &config.peers { args.push(p.clone()); } } info!( ?bin, api_port, tcp_port, quic_port, metrics_port, "spawning mycelium sidecar via pkexec" ); let mut cmd = elevation::elevated(&bin, &args); cmd.stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); let mut child = cmd.spawn()?; let stdout = child.stdout.take(); let stderr = child.stderr.take(); // Stash before we await the health check, so a slow daemon // doesn't leave us with a zombie process if anything panics. let api_url = format!("http://127.0.0.1:{api_port}"); *self.child.lock() = Some(child); *self.api_url.lock() = Some(api_url.clone()); *self.config_path.lock() = Some(config_path); *self.key_path.lock() = Some(key_path); if let Some(out) = stdout { let me = Arc::clone(self); tokio::spawn(async move { let mut lines = BufReader::new(out).lines(); while let Ok(Some(line)) = lines.next_line().await { me.push_log(format!("[stdout] {line}")); } }); } if let Some(err) = stderr { let me = Arc::clone(self); tokio::spawn(async move { let mut lines = BufReader::new(err).lines(); while let Ok(Some(line)) = lines.next_line().await { me.push_log(format!("[stderr] {line}")); } }); } // Background watcher: polls every 2s and emits `sidecar://exited` // when the child dies after the start sequence has succeeded. { let me = Arc::clone(self); let app = app.clone(); tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_secs(2)).await; if !me.is_running() { break; } if let Some(code) = me.child_exit_status() { me.handle_exit(&app, code); break; } } }); } // Health-check loop. The pkexec dialog can take several seconds, so // give the daemon a generous window to come up before failing. let client = MyceliumClient::new(&api_url); let started = Instant::now(); loop { // Bail early if the child died (auth cancel, missing TUN cap, etc.). if let Some(code) = self.child_exit_status() { self.cleanup(); if elevation::is_auth_failure(code) { return Err(AppError::ElevationCancelled); } return Err(AppError::SidecarExited(format!("exit code {code}"))); } if client.is_alive().await { info!(api_url = %api_url, "mycelium sidecar healthy"); let _ = app.emit("sidecar://ready", &api_url); return Ok(api_url); } if started.elapsed() > Duration::from_secs(HEALTH_CHECK_TIMEOUT_SECS) { self.stop().await; return Err(AppError::HealthCheckTimeout(HEALTH_CHECK_TIMEOUT_SECS)); } tokio::time::sleep(Duration::from_millis(HEALTH_CHECK_INTERVAL_MS)).await; } } fn child_exit_status(&self) -> Option { let mut lock = self.child.lock(); let child = lock.as_mut()?; match child.try_wait() { Ok(Some(s)) => Some(s.code().unwrap_or(-1)), _ => None, } } fn handle_exit(&self, app: &AppHandle, code: i32) { warn!(code, "mycelium sidecar exited"); self.cleanup(); let _ = app.emit("sidecar://exited", code); } fn cleanup(&self) { *self.api_url.lock() = None; *self.overlay_ip.lock() = None; let _ = self.child.lock().take(); } pub async fn stop(&self) { // Take the child by value so the parking_lot guard isn't held across // the await on `wait()`. `kill_on_drop(true)` is set, but pkexec runs // mycelium as root, so we still ask politely first; the polkit agent // reaps the elevated child. let child_opt = self.child.lock().take(); if let Some(mut child) = child_opt { let _ = child.start_kill(); let _ = child.wait().await; } self.cleanup(); } } /// Mycelium logs the assigned overlay IPv6 once at startup like: /// `INFO mycelium: Node overlay IP: 43d:956e:7877:d933:eecc:b305:21ff:77f9` /// We don't pull a regex crate just for this — a hand-rolled parser is /// resilient enough. fn extract_overlay_ip(line: &str) -> Option { const NEEDLE: &str = "Node overlay IP:"; let idx = line.find(NEEDLE)?; let rest = line[idx + NEEDLE.len()..].trim(); // The IP is the first whitespace-bounded token; rejection criteria // (must contain `:`, must not contain `/`) keep us from accidentally // capturing the subnet. let token = rest.split_whitespace().next()?; if !token.contains(':') || token.contains('/') { return None; } Some(token.to_string()) } /// Strip ANSI SGR escape sequences (`ESC [ ... m`) from a log line so the /// overlay-IP scanner doesn't choke on tracing's coloured output. fn strip_ansi(s: &str) -> String { let bytes = s.as_bytes(); let mut out = String::with_capacity(s.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'[' { // Skip until 'm' or any letter terminator. i += 2; while i < bytes.len() && !bytes[i].is_ascii_alphabetic() { i += 1; } if i < bytes.len() { i += 1; } } else { out.push(bytes[i] as char); i += 1; } } out } fn pick_port() -> AppResult { portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into())) } fn pick_port_skip(taken: &[u16]) -> AppResult { for _ in 0..16 { let p = pick_port()?; if !taken.contains(&p) { return Ok(p); } } Err(AppError::Other( "could not find a unique free port".into(), )) } /// Resolve the bundled `mycelium` sidecar across our two build modes: /// • `tauri dev` keeps the file under `src-tauri/binaries/` with the /// `-` suffix Tauri's externalBin convention requires. /// • `tauri build` for a `.deb` strips the suffix and places the /// binary at `/usr/bin/mycelium` next to the app launcher. /// We probe the bundled path first, then walk back to the dev location. fn locate_sidecar(app: &AppHandle) -> AppResult { let triple = std::env::var("TAURI_ENV_TARGET_TRIPLE") .ok() .or_else(|| option_env!("TARGET").map(|s| s.to_string())) .unwrap_or_else(|| "x86_64-unknown-linux-gnu".to_string()); let suffixed = format!("mycelium-{triple}"); let plain = "mycelium".to_string(); let mut tried: Vec = Vec::new(); // Bundled .deb / AppImage: the launcher lives next to the sidecar // under /usr/bin/. Resolve relative to the running executable. if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { for name in [&plain, &suffixed] { let p = dir.join(name); if p.exists() { return Ok(p); } tried.push(p); } } } // Tauri's resource_dir() — used when externalBin is treated as a // resource (older bundles, or when the user moves things around). if let Ok(resource) = app.path().resource_dir() { for name in [&plain, &suffixed] { let p = resource.join(name); if p.exists() { return Ok(p); } tried.push(p); } } // Dev mode: `pnpm tauri dev` runs the binary out of target/debug/ so // current_exe() is far from src-tauri/binaries/. Use the manifest dir. let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); for name in [&suffixed, &plain] { let p = manifest_dir.join("binaries").join(name); if p.exists() { return Ok(p); } tried.push(p); } // Final fallback: trust $PATH if a system-installed mycelium is around. if let Ok(path) = std::env::var("PATH") { for entry in path.split(':') { let p = PathBuf::from(entry).join(&plain); if p.exists() { return Ok(p); } } } Err(AppError::SidecarNotFound(tried)) }