Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 17 additions & 24 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
11 changes: 5 additions & 6 deletions crates/vmate-cli/src/commands/all.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
11 changes: 5 additions & 6 deletions crates/vmate-cli/src/commands/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
23 changes: 6 additions & 17 deletions crates/vmate-cli/src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,22 @@ 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 {
persist_scan_defaults(args)?;
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(())
}

Expand Down
26 changes: 0 additions & 26 deletions crates/vmate-cli/tests/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 2 additions & 3 deletions crates/vmate-core/src/system/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
39 changes: 0 additions & 39 deletions crates/vmate-core/src/system/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,26 +102,6 @@ pub fn carried_environment() -> Vec<String> {
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.
Expand Down Expand Up @@ -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
Expand Down
Loading