diff --git a/Cargo.lock b/Cargo.lock index a506ac2..d660200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2995,7 +2995,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vmate-cli" -version = "1.2.1" +version = "1.2.2" dependencies = [ "anyhow", "arboard", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "vmate-core" -version = "1.2.1" +version = "1.2.2" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index b0dd167..840e521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ [workspace.package] edition = "2024" rust-version = "1.85" -version = "1.2.1" +version = "1.2.2" license = "MIT" authors = ["codewiththiha"] diff --git a/README.md b/README.md index a88aba5..db1f4bc 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,10 @@ from a keyboard-driven terminal UI backed by SQLite. - Rust **1.85+** (edition 2024) - [OpenVPN](https://openvpn.net/) — `openvpn` on `PATH`, or pass `--openvpn-bin` -- Root/sudo for `connect` and `all`, which configure routes and the tunnel - interface. `scan` **never asks for a password** — it only probes configs — and - picks up root silently when your sudo credentials are already cached. Set - `VMATE_NO_ELEVATE=1` to skip elevation entirely (OpenVPN will likely fail) +- Root/sudo for `scan`, `connect` and `all` (OpenVPN needs root/`CAP_NET_ADMIN` + to open `/dev/net/tun` or `utun`; vmate-cli re-executes under `sudo` + automatically on an interactive terminal, preserving `$HOME` so storage stays + under your user account. Set `VMATE_NO_ELEVATE=1` to run without elevation) - `killall` (optional) only if you pass `--killall` for the global OpenVPN sweep ## Build & Test diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e92fb2f..0d4a8b0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,32 +1,25 @@ -# vmate-cli v1.2.1 +# vmate-cli v1.2.2 Prebuilt binaries for macOS (Apple Silicon and Intel) and Linux (x86_64 and arm64). Each zip contains the `vmate-cli` binary and `install.sh`, which copies the binary onto your PATH. -## Linux: the database works again - -- **Fixed: scan results disappeared on Linux.** `sudo` on Linux resets `HOME` - to `/root`, so an elevated `vmate-cli scan` or `connect` stored its history in - `/root/.config/vmate-cli/vmate.db` while `vmate-cli recent` — which never - elevates — read `~/.config/vmate-cli/vmate.db` and reported nothing. macOS - `sudo` keeps `HOME`, which is why the same build behaved differently per - platform. vmate now re-applies your home, `XDG_*` and `VMATE_*` environment - across the `sudo` re-exec and hands every root-created config directory, - database, WAL sidecar and settings file back to your user. -- `vmate-cli doctor` reports the platform, your home, the resolved config - directory and whether the database is really writable, and prints the - `chown` that repairs a database left root-owned by an older release. -- The database is opened by path instead of through a `sqlite://` URL, so a - database path containing `?` can no longer be parsed as query parameters. - -## `scan` no longer asks for a password - -- Scanning only probes configs — it never rewrites routes or interfaces — so it - never blocks on a sudo prompt. When your sudo credentials are already cached - the run is elevated for free; otherwise it runs with the privileges it has - and hints at `sudo vmate-cli scan ...` only if every probe failed. -- `--save-defaults` is a pure write, so it never elevates on any command. +## Linux: database fix retained & scan elevation restored + +- **Linux database fix:** `sudo` on Linux resets `HOME` to `/root`, which caused + elevated operations to write to `/root/.config/vmate-cli/vmate.db` instead of the + invoking user's database. vmate carries your user environment (`HOME`, `USER`, + `XDG_*`, `VMATE_*`) across the `sudo` re-exec and restores file ownership for + any root-created database, WAL sidecar, config directory, or settings file, so + `scan`, `connect`, and `recent` all share the exact same user history. +- **Root elevation restored for `scan`:** OpenVPN fundamentally requires root + privileges (`CAP_NET_ADMIN`) to allocate the TUN device (`/dev/net/tun` on Linux + or `utun` on macOS). An unprivileged `scan` is unable to test any configurations. + `vmate-cli scan` now re-executes under `sudo` transparently as needed, while + ensuring the resulting database belongs to your normal user account. +- **Diagnostics in `doctor`:** `vmate-cli doctor` checks platform, home, resolved + config directory, and database write access, giving actionable repair commands + if older runs left behind root-owned files. ## Snappier switching and quitting diff --git a/crates/vmate-cli/src/commands/all.rs b/crates/vmate-cli/src/commands/all.rs index fd113e0..09d665f 100644 --- a/crates/vmate-cli/src/commands/all.rs +++ b/crates/vmate-cli/src/commands/all.rs @@ -12,18 +12,17 @@ use vmate_core::settings::UserSettings; use vmate_core::system::{ProcessKiller, RealProcessKiller, require_root_for}; pub async fn run(settings: &Settings, args: &AllArgs, verbose: &Verbosity) -> Result<()> { - // --save-defaults persists and exits without scanning or connecting: it - // writes one file, so it must not block on a password prompt. + // Elevate like a real run: the config dir may need sudo (e.g. left over + // from a previous elevated run), so --save-defaults must not skip it. + require_root_for("run OpenVPN tests and connections", settings.no_elevate)?; + + // --save-defaults persists and exits without scanning or connecting. if settings.save_defaults { crate::commands::scan::persist_scan_defaults(&args.scan)?; persist_connect_defaults(&args.connect)?; return Ok(()); } - // `all` connects at the end, and connecting reconfigures the system, so it - // needs root: elevate up front rather than re-running the scan afterwards. - require_root_for("run OpenVPN tests and connections", settings.no_elevate)?; - let us = UserSettings::load(); let connect = resolve_connect(&us, &args.connect); diff --git a/crates/vmate-cli/src/commands/connect.rs b/crates/vmate-cli/src/commands/connect.rs index 3f00a72..25c5c79 100644 --- a/crates/vmate-cli/src/commands/connect.rs +++ b/crates/vmate-cli/src/commands/connect.rs @@ -53,17 +53,16 @@ pub(crate) fn persist_connect_defaults(args: &ConnectArgs) -> Result<()> { } pub async fn run(settings: &Settings, args: &ConnectArgs, verbose: &Verbosity) -> Result<()> { - // --save-defaults persists and exits without connecting: it writes a single - // file, so it needs no elevation and must not block on a password prompt. + // Elevate like a real run: the config dir may need sudo (e.g. left over + // from a previous elevated run), so --save-defaults must not skip it. + require_root_for("run OpenVPN connections", settings.no_elevate)?; + + // --save-defaults persists and exits without connecting (no OpenVPN, no DB). if settings.save_defaults { persist_connect_defaults(args)?; return Ok(()); } - // Connecting really does reconfigure the system (routes and the tunnel - // interface), so it needs root: elevate like a normal run. - require_root_for("run OpenVPN connections", settings.no_elevate)?; - let us = UserSettings::load(); let resolved = resolve_connect(&us, args); diff --git a/crates/vmate-cli/src/commands/scan.rs b/crates/vmate-cli/src/commands/scan.rs index 2a0ef18..8b2f336 100644 --- a/crates/vmate-cli/src/commands/scan.rs +++ b/crates/vmate-cli/src/commands/scan.rs @@ -15,16 +15,14 @@ use vmate_core::ovpn::process::{RealVpnTester, VpnTester}; use vmate_core::scan::{ScanOptions, ScanProgress, ScanReport, ScanService}; use vmate_core::settings::UserSettings; use vmate_core::system::{ - CleanupGuard, ProcessKiller, RealProcessKiller, elevate_without_prompt, is_root, - shutdown_signal, + CleanupGuard, ProcessKiller, RealProcessKiller, require_root_for, shutdown_signal, }; pub async fn run(settings: &Settings, args: &ScanArgs, verbose: &Verbosity) -> Result<()> { - // Scanning only *probes* configs — it never rewrites the system's routes or - // interfaces — so it must never block on a password prompt. When sudo - // credentials are already cached the run is elevated for free; otherwise it - // simply runs with the privileges it has. - elevate_without_prompt(settings.no_elevate); + // Elevate like a real run: OpenVPN requires root/CAP_NET_ADMIN to allocate + // the tun/tap device even for test probes, and the config dir may need + // sudo (e.g. left over from a previous elevated run). + require_root_for("run OpenVPN tests", settings.no_elevate)?; // --save-defaults persists and exits without scanning (no OpenVPN, no DB). if settings.save_defaults { @@ -32,16 +30,7 @@ pub async fn run(settings: &Settings, args: &ScanArgs, verbose: &Verbosity) -> R return Ok(()); } - let (report, _repo) = scan_pipeline(settings, args, verbose).await?; - - // Every probe failed and we were not root: on Linux OpenVPN needs root to - // open a tun device, so say so instead of leaving a silent wall of misses. - if !is_root() && !settings.no_elevate && report.scanned > 0 && report.success == 0 { - eprintln!( - "note: no config connected and vmate is not running as root\n\ - hint: OpenVPN needs root to open a tun device — try `sudo vmate-cli scan ...`" - ); - } + scan_pipeline(settings, args, verbose).await?; Ok(()) } diff --git a/crates/vmate-cli/tests/scan.rs b/crates/vmate-cli/tests/scan.rs index c906f5c..771ac76 100644 --- a/crates/vmate-cli/tests/scan.rs +++ b/crates/vmate-cli/tests/scan.rs @@ -25,32 +25,6 @@ fn scan_empty_dir_succeeds() { .stdout(predicate::str::contains("Found matched: 0")); } -/// Scanning only probes configs, so it must not demand a password. The absence -/// of `VMATE_NO_ELEVATE` here is the point: an older release refused to run at -/// all without it unless the process was already root. -#[test] -fn scan_does_not_require_root() { - let (dir, db) = tmp_db(); - let mut cmd = Command::cargo_bin("vmate-cli").unwrap(); - cmd.args(["scan", dir.path().to_str().unwrap(), "--no-save"]) - .env("VMATE_DB", &db) - .assert() - .success() - .stdout(predicate::str::contains("Found matched: 0")); -} - -/// `--save-defaults` writes one file: it must not elevate on any command. -#[test] -fn scan_save_defaults_does_not_require_root() { - let home = tempfile::tempdir().expect("tempdir"); - let mut cmd = Command::cargo_bin("vmate-cli").unwrap(); - cmd.args(["scan", "--save-defaults", "--max", "42"]) - .env("HOME", home.path()) - .assert() - .success() - .stdout(predicate::str::contains("Saved scan defaults")); -} - #[test] fn scan_missing_dir_fails_cleanly() { let (_, db) = tmp_db(); diff --git a/crates/vmate-core/src/system/mod.rs b/crates/vmate-core/src/system/mod.rs index 31c8f6d..0825e0e 100644 --- a/crates/vmate-core/src/system/mod.rs +++ b/crates/vmate-core/src/system/mod.rs @@ -10,8 +10,7 @@ pub use killer::{ kill_process_tree_with_grace, killall_openvpn, }; pub use root::{ - carried_environment, elevate_with_sudo, elevate_without_prompt, interactive, is_elevated, - is_root, repair_ownership, require_root_for, root_summary, sudo_can_elevate_without_prompt, - sudo_uid_gid, sudo_user, + carried_environment, elevate_with_sudo, interactive, is_elevated, is_root, repair_ownership, + require_root_for, root_summary, sudo_uid_gid, sudo_user, }; pub use signal::{ShutdownReason, shutdown_signal}; diff --git a/crates/vmate-core/src/system/root.rs b/crates/vmate-core/src/system/root.rs index fd600af..fbb4356 100644 --- a/crates/vmate-core/src/system/root.rs +++ b/crates/vmate-core/src/system/root.rs @@ -4,7 +4,6 @@ use anyhow::{Result, bail}; use nix::unistd::getuid; use std::io::IsTerminal; use std::path::{Path, PathBuf}; -use std::process::Stdio; /// Environment carried across a `sudo` re-exec so the elevated half resolves /// the same home, config directory, database and settings as the invoking @@ -103,26 +102,6 @@ pub fn carried_environment() -> Vec { assignments } -/// Whether `sudo` can elevate without asking for a password — either the -/// credential cache is warm or the user has a `NOPASSWD` rule. -/// -/// `sudo -n` never prompts: it succeeds from the cache or fails outright. -/// Commands that work unprivileged use this to pick up root for free instead -/// of blocking the terminal on a password prompt. -pub fn sudo_can_elevate_without_prompt() -> bool { - if is_root() { - return false; - } - std::process::Command::new("sudo") - .args(["-n", "-v"]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) -} - /// Ensure the process has root privileges for `context`. /// /// * Already root → returns. @@ -159,24 +138,6 @@ pub fn require_root_for(context: &str, no_elevate: bool) -> Result<()> { elevate_with_sudo() } -/// Elevate only when it costs the user nothing. -/// -/// Used by commands that do not need root to do their job (`scan`): when sudo -/// credentials are already cached the run silently becomes root, and when they -/// are not the command simply runs unprivileged. It never prompts. -pub fn elevate_without_prompt(no_elevate: bool) { - if is_root() || no_elevate || is_elevated() { - return; - } - if std::env::var_os("VMATE_NO_ELEVATE").is_some() { - return; - } - if !interactive() || !sudo_can_elevate_without_prompt() { - return; - } - elevate_with_sudo() -} - /// Re-execute the current binary with `sudo`, preserving arguments. /// /// This function never returns: the child runs under sudo and this process