fix(tunnel,aura-gui): macOS TUN auto-assign + admin-access check

Two bugs found in the GUI's first end-to-end test:

## #41 was incomplete — `Some("")` is not the same as `None` for tun-rs

The agent's earlier #41 fix passed `""` to `Configuration::tun_name()`
expecting the tun crate to treat empty as "let the kernel auto-assign". It
doesn't. Looking at tun-0.8.9/src/platform/macos/device.rs:

    if !tun_name.starts_with("utun") {
        return Err(Error::InvalidName);
    }

An empty string fails `starts_with("utun")` so the create errors out before
the kernel is ever consulted. The auto-assign branch ONLY triggers when
`config.tun_name` is `None` — which requires us to skip the `.tun_name()`
call entirely, not pass a sentinel value.

Fix: split the builder chain so `.tun_name()` is only called when the
sanitized name is non-empty. The kernel now correctly auto-picks the next
free `utunN` for the standard provisioned `tun_name = "aura0"` config.

User-visible symptom this resolves: the GUI's Connect button consistently
died with `failed to create TUN device 'aura0'` followed by an InvalidName
chain, even though aura was running as root.

## check_admin_access tested the wrong command shape

`check_admin_access` ran `sudo -n <aura> --help` and inferred the sudoers
entry was installed iff that succeeded. But our sudoers entry is scoped to
`<aura> client *` — `<aura> --help` does NOT match, so even when the
entry was correctly installed and Connect was already working, the yellow
"One-time setup needed" banner stayed up forever.

Switched to `sudo -n -l <aura>` which lists matching sudoers entries for
the binary path itself. Returns 0 iff ANY entry covers it without a
password — works regardless of the per-command scope.

## Verification

- `cargo test -p aura-tunnel --lib tun` — all 3 sanitize / create tests pass
- Rebuilt `target/release/aura` and `/Applications/Aura.app` against the
  fixes
- Confirmed via `sudo -n -l /Users/xah30/AuraVPN/target/release/aura` that
  the installed sudoers entry is detectable by the new check

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-29 19:45:59 +03:00
parent dbee9d8b93
commit f68a61f760
2 changed files with 32 additions and 14 deletions
+16 -10
View File
@@ -221,24 +221,30 @@ fn get_aura_binary_path(state: tauri::State<'_, Arc<AppState>>) -> String {
state.aura_binary.lock().display().to_string()
}
/// `true` if `sudo -n <aura> --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.
/// `true` if the NOPASSWD sudoers entry for `<aura> client *` is installed and works.
///
/// We use `sudo -n -l <aura>` which lists the sudoers entries matching the binary path and
/// returns 0 iff at least one entry covers it without a password. This is correct even when our
/// sudoers fragment is scoped to `<aura> client *` (and so wouldn't match `<aura> --help` —
/// that's why the earlier `sudo -n <aura> --help` check kept saying "not installed" while in
/// reality the entry was there and Connect was working).
#[tauri::command]
fn check_admin_access(state: tauri::State<'_, Arc<AppState>>) -> bool {
let bin = state.aura_binary.lock().clone();
#[cfg(unix)]
{
match std::process::Command::new("/usr/bin/sudo")
let output = std::process::Command::new("/usr/bin/sudo")
.arg("-n")
.arg("-l")
.arg(bin)
.arg("--help")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(s) => s.success(),
.output();
match output {
Ok(out) => {
// sudo -n -l <cmd> prints the matching entry's command path on stdout when allowed
// (e.g. "/usr/local/bin/aura"); on refusal it exits non-zero and prints to stderr.
out.status.success()
}
Err(_) => false,
}
}
+16 -4
View File
@@ -95,9 +95,16 @@ impl AuraTun {
.mask();
// macOS: the kernel utun driver enforces `^utun[0-9]+$` and rejects anything else with
// `invalid device tun name`. Pass the requested name through `sanitize_macos_tun_name`
// which returns `""` for non-conforming names; the tun crate treats `""` as
// "let the kernel pick the next free utunN".
// `invalid device tun name`. Earlier v3.4 attempt passed `""` to `.tun_name()` thinking
// tun-rs would treat empty as "kernel auto-assign" — it does NOT. Looking at
// tun-0.8.9/src/platform/macos/device.rs:
//
// if !tun_name.starts_with("utun") { return Err(Error::InvalidName); }
//
// An empty string fails the `starts_with` check and the create errors out. The fix is
// to skip the `.tun_name()` call ENTIRELY for non-conforming names — that leaves
// `Configuration::tun_name` as `None`, which the tun crate handles by passing id=0 to
// the kernel (auto-assign next free utunN).
#[cfg(target_os = "macos")]
let requested_name = sanitize_macos_tun_name(name);
#[cfg(not(target_os = "macos"))]
@@ -105,12 +112,17 @@ impl AuraTun {
let mut config = tun::Configuration::default();
config
.tun_name(requested_name)
.address(ip)
.netmask(netmask)
.mtu(mtu)
.layer(tun::Layer::L3)
.up();
// Only set tun_name when it's a value the kernel will accept. On macOS that means a
// valid `utunN` string; otherwise we leave it unset (None) so the tun crate's auto-
// assign branch kicks in. On Linux/Windows the requested name is always honoured.
if !requested_name.is_empty() {
config.tun_name(requested_name);
}
let inner = tun::create_as_async(&config)
.with_context(|| format!("failed to create TUN device '{name}'"))?;