//! Child-process management for `aura client`. //! //! We spawn the binary with the profile's `client.toml`, point it at a per-profile admin socket //! (so multiple GUIs / installations don't collide), and stream stderr into an in-memory ring //! buffer so the UI can show recent log lines. //! //! ## Privilege escalation //! //! `aura client` creates a TUN device, which requires root on Unix and Administrator on Windows. //! Tauri apps launched from `/Applications/` run as the desktop user, so spawning the binary //! directly would fail with `EPERM` and the child would die before the UI's 1.5 s status poller //! noticed. To make the GUI usable as a real always-on VPN we prepend `sudo -n` on Unix; for //! this to work without an interactive password prompt the user has to install a one-time //! sudoers entry (see `install_sudoers` in `lib.rs`). When `sudo -n` itself fails because no //! sudoers entry exists, the child exits immediately and the connect error surfaces in the UI. use std::path::Path; use std::process::{Child, Command, Stdio}; use std::sync::Arc; use std::thread; use std::time::Duration; use anyhow::{anyhow, Context, Result}; use parking_lot::Mutex; /// Bounded ring buffer of recent log lines. const LOG_RING_CAP: usize = 200; /// Handle to a running `aura client` child. pub struct ClientHandle { child: Mutex, profile_id: String, admin_socket: String, logs: Arc>>, } impl ClientHandle { pub fn profile_id(&self) -> &str { &self.profile_id } pub fn admin_socket_path(&self) -> &str { &self.admin_socket } pub fn is_alive(&self) -> bool { // try_wait returns Ok(None) while running. We don't reap a finished child here — the kill // path / Drop does that. let mut guard = self.child.lock(); match guard.try_wait() { Ok(None) => true, Ok(Some(_status)) => false, Err(_) => false, } } pub fn recent_logs(&self) -> Vec { self.logs.lock().clone() } /// Kill the child and reap it. Idempotent. /// /// Because we spawned via `sudo -n aura …`, our direct child is `sudo` (running as us; we /// own it). The real aura process is sudo's child, running as root, so we can't signal it /// directly. SIGTERM to the sudo PID is forwarded to aura by sudo's signal handler, which /// lets aura's `OsRouteGuard::Drop` and TUN cleanup run before exit. After a 2 s grace /// period we fall back to SIGKILL via `Child::kill`, which kills sudo immediately (aura /// becomes orphaned, but the kernel reaps it via PID 1 — TUN may linger). pub fn kill(self) -> Result<()> { let pid = { self.child.lock().id() }; // SIGTERM to sudo — sudo forwards to aura. We own sudo so plain `kill` works. let _ = Command::new("kill") .arg("-TERM") .arg(pid.to_string()) .output(); let mut guard = self.child.lock(); for _ in 0..20 { match guard.try_wait() { Ok(Some(_)) => return Ok(()), _ => thread::sleep(Duration::from_millis(100)), } } // Grace period elapsed — fall back to SIGKILL. let _ = guard.kill(); let _ = guard.wait(); Ok(()) } } /// Spawn `aura client --config /client.toml --admin-socket `. /// /// On Unix the admin socket path is derived from the profile id so two concurrent profiles don't /// collide. The process inherits the GUI's stdin (closed via Stdio::null), stdout is closed too, /// stderr is captured into the in-memory ring. pub fn spawn_client(aura_bin: &Path, profile_dir: &Path, profile_id: &str) -> Result { let config = profile_dir.join("client.toml"); if !config.exists() { return Err(anyhow!( "profile is missing client.toml at {}", config.display() )); } let admin_socket = derive_admin_socket(profile_id); // On Unix prepend `sudo -n` so the aura child runs as root (required for the TUN device). // The user installs a one-time NOPASSWD sudoers entry — see lib.rs `install_sudoers_admin`. // If sudo refuses (no entry), the child exits within milliseconds and the post-spawn check // below surfaces the error to the UI. #[cfg(unix)] let mut cmd = { let mut c = Command::new("/usr/bin/sudo"); c.arg("-n").arg(aura_bin); c }; #[cfg(windows)] let mut cmd = Command::new(aura_bin); cmd.arg("client") .arg("--config") .arg(&config) .arg("--admin-socket") .arg(&admin_socket) .current_dir(profile_dir) // so relative paths in client.toml (ca.crt, ...) resolve .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); // Provide a verbose default if the operator didn't override RUST_LOG. if std::env::var_os("RUST_LOG").is_none() { cmd.env( "RUST_LOG", "info,aura_cli=info,aura_transport=info,aura_proto=info,aura_tunnel=info", ); } let mut child = cmd .spawn() .with_context(|| format!("spawning {}", aura_bin.display()))?; let logs: Arc>> = Arc::new(Mutex::new(Vec::with_capacity(LOG_RING_CAP))); if let Some(stderr) = child.stderr.take() { let logs_clone = Arc::clone(&logs); thread::spawn(move || { use std::io::{BufRead, BufReader}; let reader = BufReader::new(stderr); for line in reader.lines().map_while(|l| l.ok()) { let mut buf = logs_clone.lock(); if buf.len() == LOG_RING_CAP { buf.remove(0); } buf.push(line); } }); } // Brief wait so quick failures (no sudoers, TUN permission denied, port collision) surface // as a connect-time error rather than silently flipping the UI's "connected" pill back to // disconnected on the next status poll. 1.5 s is enough for `sudo -n` to refuse or aura to // print its first diagnostic; longer would block the Connect button noticeably. thread::sleep(Duration::from_millis(1500)); if let Ok(Some(status)) = child.try_wait() { // Give the stderr reader thread a moment to drain any final bytes. thread::sleep(Duration::from_millis(150)); let tail = { let buf = logs.lock(); if buf.is_empty() { "(no stderr captured — the child died before printing anything; most likely \ `sudo -n` was refused because the NOPASSWD entry is missing)" .to_string() } else { buf.iter() .rev() .take(20) .rev() .cloned() .collect::>() .join("\n") } }; let _ = child.wait(); return Err(anyhow!( "aura client exited immediately (status {status:?}).\n\ \n\ Most likely causes:\n\ • the one-time NOPASSWD sudoers entry is missing — click `Install admin access` \ in the GUI (or run the command from MIGRATION.md §6.3)\n\ • another `aura client` is already running — kill it first\n\ • client.toml is misconfigured (bad port / cert / pool ip)\n\ \n\ Recent stderr:\n{tail}" )); } Ok(ClientHandle { child: Mutex::new(child), profile_id: profile_id.to_string(), admin_socket, logs, }) } #[cfg(unix)] fn derive_admin_socket(profile_id: &str) -> String { // /tmp is world-writable and persists across the GUI's lifetime. We prefix with the user id // so multiple desktop users on the same host don't collide. let uid = unsafe { libc_uid() }; format!("/tmp/aura-admin-{}-{}.sock", uid, sanitize(profile_id)) } #[cfg(windows)] fn derive_admin_socket(profile_id: &str) -> String { format!(r"\\.\pipe\aura-admin-{}", sanitize(profile_id)) } fn sanitize(s: &str) -> String { s.chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' } }) .collect() } #[cfg(unix)] unsafe fn libc_uid() -> u32 { // libc isn't a dependency; use the geteuid syscall via std. // Note: getuid is a tiny syscall and there's no safe stable wrapper in std, so we shell out. // For a desktop GUI the cost is negligible. match std::process::Command::new("id").arg("-u").output() { Ok(o) => String::from_utf8_lossy(&o.stdout) .trim() .parse::() .unwrap_or(0), Err(_) => 0, } }