From 16351907974572e4662ae2f9308f754ced08207f Mon Sep 17 00:00:00 2001 From: xah30 Date: Fri, 29 May 2026 19:32:38 +0300 Subject: [PATCH] feat(aura-gui): privilege escalation via sudo + one-click NOPASSWD installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.1 GUI's Connect button was broken in practice: the Tauri app launched from /Applications runs as the desktop user, so `Command::new(aura).spawn()` started aura without root. aura died in ms with EPERM at TUN creation, faster than the 1.5 s status poller could catch — the UI just silently flipped back to "disconnected" with no clue. ## Fix * `cli_proc::spawn_client` now prepends `sudo -n` on Unix. After spawn it blocks for 1.5 s and checks `try_wait`; if the child already exited, it reads the stderr ring's last 20 lines and returns an anyhow Error with that tail + a hint list of common causes. The Tauri command surfaces it to the frontend's `error` state where the UI renders it as a multi-line `
` block instead of the previous single-line text.
* `ClientHandle::kill` no longer uses `Child::kill` (SIGKILL) on its sudo
  parent — that would have left aura orphaned with the TUN lingering.
  Sends SIGTERM to sudo, which sudo forwards to aura, giving the inner
  `OsRouteGuard::Drop` 2 s to run cleanup. Falls back to SIGKILL only after
  the grace period.

## One-click NOPASSWD installer

Two new Tauri commands plus a UI banner:

* `check_admin_access` — runs `sudo -n aura --help` and returns whether the
  sudoers entry is in place. Used by the React side to decide whether to
  show the banner.
* `install_sudoers_admin` — runs `osascript ... with administrator
  privileges` which surfaces the native macOS auth dialog, then writes
  `/etc/sudoers.d/aura-gui` scoped to ` client *` only (not arbitrary
  aura invocations), runs `visudo -c` for syntax validation, and reports
  success or the syntax error.

The frontend shows a yellow "One-time setup needed" banner above the
profile list whenever `adminReady === false`. Clicking the button pops the
Mac password dialog once; from then on Connect is a single click with no
prompt.

## UI feedback

* "Connecting…" disabled state on the Connect button while spawn_client's
  1.5 s wait is in progress
* Errors render as monospace `
` so the multi-line stderr tail is
  readable
* `.error` and `.admin-banner` CSS classes added to App.css

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 
---
 aura-gui/src-tauri/src/cli_proc.rs | 84 ++++++++++++++++++++++++-
 aura-gui/src-tauri/src/lib.rs      | 98 ++++++++++++++++++++++++++++++
 aura-gui/src/App.css               | 38 +++++++++++-
 aura-gui/src/App.tsx               | 51 ++++++++++++++--
 4 files changed, 262 insertions(+), 9 deletions(-)

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() { ) : ( )}