diff --git a/aura-gui/src-tauri/src/cli_proc.rs b/aura-gui/src-tauri/src/cli_proc.rs index c7c024f..a0c2d9f 100644 --- a/aura-gui/src-tauri/src/cli_proc.rs +++ b/aura-gui/src-tauri/src/cli_proc.rs @@ -3,11 +3,22 @@ //! 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; @@ -48,11 +59,28 @@ impl ClientHandle { } /// 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(); - // Best-effort send SIGTERM-equivalent first; std::process::Child::kill on Unix is SIGKILL, - // which is fine for our use (the client doesn't have any state we care about persisting - // beyond what its OsRouteGuard's Drop reverts — and Drop runs on graceful shutdown only). + 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(()) @@ -74,7 +102,19 @@ pub fn spawn_client(aura_bin: &Path, profile_dir: &Path, profile_id: &str) -> Re } 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) @@ -113,6 +153,44 @@ pub fn spawn_client(aura_bin: &Path, profile_dir: &Path, profile_id: &str) -> Re }); } + // 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(), diff --git a/aura-gui/src-tauri/src/lib.rs b/aura-gui/src-tauri/src/lib.rs index 7778f79..738fa3e 100644 --- a/aura-gui/src-tauri/src/lib.rs +++ b/aura-gui/src-tauri/src/lib.rs @@ -221,6 +221,102 @@ fn get_aura_binary_path(state: tauri::State<'_, Arc>) -> String { state.aura_binary.lock().display().to_string() } +/// `true` if `sudo -n --help` runs without prompting (i.e. the NOPASSWD sudoers entry is +/// installed). The UI uses this to gate the "Install admin access" button so the user only sees +/// it when it's actually needed. +#[tauri::command] +fn check_admin_access(state: tauri::State<'_, Arc>) -> bool { + let bin = state.aura_binary.lock().clone(); + #[cfg(unix)] + { + match std::process::Command::new("/usr/bin/sudo") + .arg("-n") + .arg(bin) + .arg("--help") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + { + Ok(s) => s.success(), + Err(_) => false, + } + } + #[cfg(windows)] + { + let _ = bin; + true // Windows GUI users elevate via UAC at launch; nothing to pre-check. + } +} + +/// One-time setup: install a NOPASSWD sudoers entry for `aura client` so the GUI can spawn the +/// privileged child without prompting on every Connect. Uses `osascript`'s +/// `with administrator privileges` to surface the native macOS authentication dialog, then writes +/// a hardened sudoers fragment to `/etc/sudoers.d/aura-gui`. +/// +/// The entry is scoped to **exactly** `/usr/local/bin/aura client *` (not arbitrary `aura` +/// invocations) and only for members of the `admin` group, which keeps the elevation surface +/// minimal. +#[tauri::command] +fn install_sudoers_admin(state: tauri::State<'_, Arc>) -> Result { + let bin = state.aura_binary.lock().clone(); + let bin_path = bin.display().to_string(); + if !bin.exists() { + return Err(format!("aura binary not found at {bin_path}")); + } + + #[cfg(unix)] + { + // Sudoers fragment. `%admin` matches the macOS admin group (which the desktop user is + // always a member of on a single-user Mac). `setenv:RUST_LOG` lets us forward verbose + // logging from the GUI to the child without `sudo -E`. + let fragment = format!( + "# Installed by aura-gui — NOPASSWD for `aura client` only.\n\ + %admin ALL=(root) NOPASSWD: setenv: {bin_path} client *\n" + ); + + // The shell script is run inside `osascript do shell script … with administrator + // privileges`, which prompts via the native auth dialog and runs as root. + let escaped = fragment.replace('"', "\\\"").replace('$', "\\$"); + let shell_cmd = format!( + "umask 077 && \ + cat > /etc/sudoers.d/aura-gui <<'AURA_GUI_EOF'\n{escaped}AURA_GUI_EOF\n\ + chown root:wheel /etc/sudoers.d/aura-gui && \ + chmod 0440 /etc/sudoers.d/aura-gui && \ + visudo -c -f /etc/sudoers.d/aura-gui" + ); + let osa = format!( + "do shell script \"{}\" with administrator privileges", + shell_cmd + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + ); + + let out = std::process::Command::new("/usr/bin/osascript") + .arg("-e") + .arg(&osa) + .output() + .map_err(|e| format!("running osascript: {e}"))?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + return Err(format!( + "osascript refused or `visudo -c` rejected the fragment:\n{stderr}" + )); + } + Ok(format!( + "✓ /etc/sudoers.d/aura-gui installed. The Connect button now spawns aura without \ + a password prompt. To revert later: `sudo rm /etc/sudoers.d/aura-gui`." + )) + } + #[cfg(windows)] + { + let _ = bin; + Err("Windows uses UAC at launch; this command is not applicable.".into()) + } +} + // ---- App entry point ------------------------------------------------------------------------ #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -240,6 +336,8 @@ pub fn run() { get_status, set_aura_binary_path, get_aura_binary_path, + check_admin_access, + install_sudoers_admin, ]) .setup(|app| { let connect_item = diff --git a/aura-gui/src/App.css b/aura-gui/src/App.css index db4615f..9c8c78e 100644 --- a/aura-gui/src/App.css +++ b/aura-gui/src/App.css @@ -263,12 +263,46 @@ button.danger:hover:not(:disabled) { background: rgba(239, 90, 90, 0.12); border: 1px solid rgba(239, 90, 90, 0.4); border-radius: 8px; - padding: 10px 14px; + padding: 12px 14px; color: #ffb1b1; display: flex; - justify-content: space-between; + flex-direction: column; + gap: 10px; +} + +.error-body { + margin: 0; + font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace; + font-size: 11px; + color: #ffc8c8; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow: auto; +} + +.error button { + align-self: flex-end; +} + +.admin-banner { + background: rgba(247, 185, 85, 0.12); + border: 1px solid rgba(247, 185, 85, 0.4); + border-radius: 8px; + padding: 14px 16px; + color: #f7b955; + display: flex; align-items: center; gap: 16px; + font-size: 13px; +} + +.admin-banner > div { + flex: 1; +} + +.admin-banner button { + flex-shrink: 0; } .aura-bin code { diff --git a/aura-gui/src/App.tsx b/aura-gui/src/App.tsx index 356e3ea..b235f6d 100644 --- a/aura-gui/src/App.tsx +++ b/aura-gui/src/App.tsx @@ -38,6 +38,17 @@ function App() { const [auraBin, setAuraBin] = useState(""); const [error, setError] = useState(null); const [showLogs, setShowLogs] = useState(false); + const [adminReady, setAdminReady] = useState(null); + const [connecting, setConnecting] = useState(false); + + const refreshAdmin = useCallback(async () => { + try { + const ok = await invoke("check_admin_access"); + setAdminReady(ok); + } catch { + setAdminReady(false); + } + }, []); const refreshProfiles = useCallback(async () => { try { @@ -64,7 +75,8 @@ function App() { } catch {} })(); refreshProfiles(); - }, [refreshProfiles]); + refreshAdmin(); + }, [refreshProfiles, refreshAdmin]); // Poll status every 1.5s. useEffect(() => { @@ -91,10 +103,26 @@ function App() { }; const onConnect = async (profileId: string) => { + setConnecting(true); try { await invoke("connect", { profileId }); setError(null); await refreshStatus(); + } catch (e: any) { + // The backend's spawn_client now waits 1.5 s and surfaces the stderr tail if the child + // exited early — that error string is what we render here. + setError(String(e)); + } finally { + setConnecting(false); + } + }; + + const onInstallAdmin = async () => { + try { + const msg = await invoke("install_sudoers_admin"); + setError(null); + await refreshAdmin(); + alert(msg); // intentionally a native alert — visible confirmation matters. } catch (e: any) { setError(String(e)); } @@ -150,11 +178,26 @@ function App() { {error && (
- error: {error}{" "} + error: +
{error}
)} + {adminReady === false && ( +
+
+ One-time setup needed. The Aura tunnel needs root + to create a TUN device. Click below to install a NOPASSWD sudoers + entry — the native macOS password prompt will appear. After that, + Connect works without prompting on every click. +
+ +
+ )} +

Profiles

@@ -184,10 +227,10 @@ function App() { ) : ( )}