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
33 changes: 26 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
#
# The README documents `cargo fmt --check`, `cargo clippy --all-targets
# -- -D warnings`, and `cargo test` as the quality bar; this job enforces it
# so nothing lands that doesn't pass its own checks. Releases are handled
# separately by `release.yml` (tag-triggered).
# so nothing lands that doesn't pass its own checks. Warnings are errors two
# ways: clippy runs with `-D warnings` (so a rustc warning fails too), and the
# workspace lints table promotes the rustc `unused` group to `deny`, so even a
# plain `cargo check` — the step below — fails on dead code and unused imports.
# Releases are handled separately by `release.yml` (tag-triggered).

name: ci

Expand All @@ -17,8 +20,16 @@ permissions:

jobs:
check:
name: fmt + clippy + test
runs-on: ubuntu-latest
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Linux and macOS both: vmate-cli ships for both, and they differ
# exactly where it hurts (sudo resets HOME on Linux, process groups and
# `killall` behave differently), so a green Linux alone proves little.
os: [ubuntu-latest, macos-latest]

steps:
- uses: actions/checkout@v4

Expand All @@ -31,8 +42,16 @@ jobs:
- name: Format
run: cargo fmt --check

- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Clippy (warnings as errors)
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings

- name: Warnings (workspace lints deny unused code and unsafe)
run: cargo check --workspace --all-targets --locked

- name: Docs (broken links as errors)
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps --locked

- name: Test
run: cargo test --workspace
run: cargo test --workspace --locked
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.

10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@ members = [
[workspace.package]
edition = "2024"
rust-version = "1.85"
version = "1.2.0"
version = "1.2.1"
license = "MIT"
authors = ["codewiththiha"]

# Warnings are errors for everything in this workspace (never for
# dependencies). `unused` covers unused imports/variables and dead code, so a
# plain `cargo check` — not just clippy — fails on them, and `unsafe_code`
# keeps the process/signal code honest.
[workspace.lints.rust]
unused = "deny"
unsafe_code = "deny"

[workspace.dependencies]
# Async runtime. Deliberately NOT `full`: vmate only needs process spawning,
# timers, signals, mpsc, DNS lookup, and the runtime itself — trimming the
Expand Down
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +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 `scan`, `connect` and `all` (vmate-cli re-executes under `sudo`
automatically on an interactive terminal; set `VMATE_NO_ELEVATE=1` to run
without elevation — OpenVPN will likely fail)
- 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)
- `killall` (optional) only if you pass `--killall` for the global OpenVPN sweep

## Build & Test
Expand Down Expand Up @@ -244,7 +245,9 @@ instead.
vmate-cli doctor
```

Checks for OpenVPN, root access, and database health.
Checks OpenVPN, `killall`, the database (journal mode and counts), storage
ownership, and the platform/sudo environment — including whether an elevated
run resolves the same config directory as a normal one.

#### `completions` — shell completion scripts

Expand Down Expand Up @@ -338,9 +341,16 @@ failures, a `removed <file> from recent list` notice is shown briefly, and the
Config line shows the `.ovpn` file name (not the full path).

Pressing `n` gracefully kills the current OpenVPN process group (SIGTERM, then
SIGKILL after the grace period), runs `killall -9 openvpn` too when `--killall`
is enabled, marks the config as skipped (it is **not** deleted from history),
and moves it to the end of a shuffled deferred queue.
SIGKILL after a short switch grace period), runs `killall -9 openvpn` too when
`--killall` is enabled, marks the config as skipped (it is **not** deleted from
history), and moves it to the end of a shuffled deferred queue. A switch or a
quit never waits out the full teardown grace period, so `n` and Ctrl+C hand the
terminal back immediately.

Keys are polled while a connection is being established as well as while it is
up, so `n`, `r`, `c` and `q` respond during the handshake. Ctrl+C (and a real
SIGINT/SIGTERM when the TUI is not in raw mode) restores the terminal and kills
only the OpenVPN processes vmate spawned.

## Export

Expand All @@ -367,6 +377,14 @@ migrations run automatically on startup:
sqlite3 ~/.config/vmate-cli/vmate.db "PRAGMA journal_mode;" # → wal
```

One history, elevated or not: `sudo` on Linux resets `HOME` to `/root`, which
would otherwise give `sudo vmate-cli scan` and a plain `vmate-cli recent` two
different databases. vmate re-applies your home and config environment across
the `sudo` re-exec and hands any root-created database files back to you, so
both halves of a session — and both platforms — see the same history. Any file
that is already root-owned from an older release is reported by
`vmate-cli doctor` together with the `chown` that fixes it.

## Architecture

```
Expand Down
75 changes: 43 additions & 32 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,48 @@
# vmate-cli v1.2.0
# vmate-cli v1.2.1

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.

## Built-in VPN configs

- `vmate-cli scan` with no directory scans the built-in vpn-gate remotes — the
shared key is embedded once and each config is built on the fly, so there are
no files to download. `--provider` selects the provider (default `vpn-gate`)
and `--proto udp|tcp` picks the transport (default `udp`); re-scan with
`--proto tcp` to try the other.
- Built-in configs appear in `recent` by their remote (`host-port`), cache
their country like normal configs, and export as
`provider_host-port_COUNTRY.ovpn`.

## Persistent defaults

- `vmate-cli scan --save-defaults --max 500 --timeout 20s` saves those as the
defaults for future sessions; `vmate-cli connect --save-defaults
--retry-count 5 --connect-timeout 10s --cooldown 60s --stability-grace 8s`
does the same for the connect tunables. `--save-defaults` writes the new
defaults and exits — it does not scan or connect.
- Each value resolves as `explicit flag → persisted setting → built-in
default`, so a plain `vmate-cli scan` uses what you saved and a one-off
`--max 200` still overrides for that run. Settings live in
`vmate-cli/settings.json` inside your config directory and are
human-editable.

## Reliability

- A CI gate now runs `cargo fmt --check`, `cargo clippy`, and `cargo test` on
every push, so the documented quality bar is enforced.
- Internal architecture pass: the retry/drop policy, built-in identity, and
process-teardown logic were consolidated, and the process-registry isolation
that had made the test suite flaky was fixed.
## 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.

## Snappier switching and quitting

- `n` (next config) and Ctrl+C no longer wait out the 3s teardown grace period:
a user-initiated switch escalates after 400ms, and shutdown cleanup polls for
the processes to exit instead of sleeping a fixed second.
- Keys are polled every 50ms, so `n`, `r`, `c` and `q` land immediately and the
uptime clock keeps ticking smoothly.
- A real SIGINT/SIGTERM — raw mode turns an interactive Ctrl+C into a key event
— now kills only the OpenVPN processes vmate spawned and restores the
terminal instead of leaving it in raw mode.

## Housekeeping

- CI runs on Linux *and* macOS, treats warnings as errors twice over (clippy
with `-D warnings`, plus a workspace lints table that promotes `unused` and
`unsafe_code` to errors) and fails on broken doc links.
- Result blocks are separated by a consistent blank line, so a scan report
followed by an export summary no longer runs together.
3 changes: 3 additions & 0 deletions crates/vmate-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ vmate-core = { path = "../vmate-core" }
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true

[lints]
workspace = true
16 changes: 11 additions & 5 deletions crates/vmate-cli/src/commands/all.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,18 @@ 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<()> {
// 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.
// --save-defaults persists and exits without scanning or connecting: it
// writes one file, so it must not block on a password prompt.
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 All @@ -36,6 +37,8 @@ pub async fn run(settings: &Settings, args: &AllArgs, verbose: &Verbosity) -> Re
}

if report.matched_configs.is_empty() {
// Blank line so this reads as its own block after the scan report.
println!();
println!("No successful configs matched filter: {}", settings.filter);
return Ok(());
}
Expand All @@ -57,6 +60,9 @@ pub async fn run(settings: &Settings, args: &AllArgs, verbose: &Verbosity) -> Re
let queue = ConnectQueue::new(candidates);

let registry = Arc::new(vmate_core::system::ProcessRegistry::new());
// Catches a real signal (raw mode turns Ctrl+C into a key event) so an
// interrupted session still cleans up and restores the terminal.
let _signal_guard = crate::ui::term::SignalGuard::install(registry.clone());
let runner = Arc::new(RealOpenVpnRunner {
bin: settings.openvpn_bin.clone(),
registry: registry.clone(),
Expand Down
16 changes: 11 additions & 5 deletions crates/vmate-cli/src/commands/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,17 @@ pub(crate) fn persist_connect_defaults(args: &ConnectArgs) -> Result<()> {
}

pub async fn run(settings: &Settings, args: &ConnectArgs, verbose: &Verbosity) -> Result<()> {
// 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).
// --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.
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 Expand Up @@ -91,6 +92,11 @@ pub async fn run(settings: &Settings, args: &ConnectArgs, verbose: &Verbosity) -
killall_enabled: settings.killall_enabled,
});
let registry = Arc::new(vmate_core::system::ProcessRegistry::new());
// Raw mode turns Ctrl+C into a key event, so this only catches a real
// signal (--no-interactive, or one delivered before the TUI came up): it
// kills what we spawned and restores the terminal instead of leaving both
// behind.
let _signal_guard = crate::ui::term::SignalGuard::install(registry.clone());
let runner = Arc::new(RealOpenVpnRunner {
bin: settings.openvpn_bin.clone(),
registry: registry.clone(),
Expand Down
45 changes: 42 additions & 3 deletions crates/vmate-cli/src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ use crate::settings::Settings;
use anyhow::Result;
use comfy_table::Table;
use std::io::IsTerminal;
use std::path::Path;
use vmate_core::db::ConfigRepo;
use vmate_core::db::models::ConfigStatus;
use vmate_core::db::pool::init_pool;
use vmate_core::system::is_root;

pub async fn run(settings: &Settings) -> Result<()> {
let mut table = Table::new();
Expand Down Expand Up @@ -54,10 +54,20 @@ pub async fn run(settings: &Settings) -> Result<()> {
}
}

table.add_row(["Root".to_string(), vmate_core::system::root_summary()]);
table.add_row([
"Root".to_string(),
if is_root() { "yes" } else { "no" }.to_string(),
"Platform".to_string(),
format!("{} / {}", std::env::consts::OS, std::env::consts::ARCH),
]);
table.add_row([
"Home".to_string(),
std::env::var("HOME").unwrap_or_else(|_| "-".to_string()),
]);
match vmate_core::paths::config_dir() {
Ok(dir) => table.add_row(["Config dir".to_string(), dir.display().to_string()]),
Err(err) => table.add_row(["Config dir".to_string(), format!("error: {err}")]),
};
table.add_row(["DB access".to_string(), storage_status(&settings.db_path)]);
table.add_row([
"Terminal".to_string(),
if std::io::stdout().is_terminal() {
Expand Down Expand Up @@ -96,6 +106,35 @@ pub async fn run(settings: &Settings) -> Result<()> {
Ok(())
}

/// Whether the database — and the directory holding it — can be used by the
/// current user.
///
/// This is the check that makes the classic Linux failure visible: `sudo`
/// resets `HOME` there, so an older elevated run could create (and later leave
/// behind) a root-owned database that every normal run failed to open.
fn storage_status(db_path: &Path) -> String {
let dir = match db_path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => Path::new("."),
};
if !vmate_core::paths::writable_by_current_user(dir) {
return format!("blocked ({} is not writable)", dir.display());
}
if !db_path.exists() {
return "ok (not created yet)".to_string();
}
if vmate_core::paths::writable_by_current_user(db_path) {
return "ok".to_string();
}
let owner = vmate_core::paths::owner_uid(db_path)
.map(|uid| uid.to_string())
.unwrap_or_else(|| "unknown".to_string());
format!(
"read-only (owned by uid {owner}) — fix with: sudo chown -R \"$(id -u):$(id -g)\" {}",
dir.display()
)
}

fn status_text(ok: bool) -> String {
if ok {
"ok".to_string()
Expand Down
2 changes: 2 additions & 0 deletions crates/vmate-cli/src/commands/recent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub async fn run(settings: &Settings, args: &RecentArgs) -> Result<()> {
if let Some(export_dir) = &args.export {
let dest = vmate_core::paths::expand_path(export_dir);
let result = vmate_core::export::export_configs(&repo, &settings.filter, &dest).await?;
// Blank line so the summary reads as its own block after the listing.
println!();
println!(
"Exported {} of {} configs to {}",
result.exported,
Expand Down
Loading
Loading