A fast, interactive OpenVPN config scanner, tester and connector for
the command line. It finds .ovpn files on disk, tests them concurrently,
remembers which ones work, and lets you connect, switch and copy configs — all
from a keyboard-driven terminal UI backed by SQLite.
⚠️ Note: by default vmate-cli cleans up only the OpenVPN processes it spawned: each process group is sent SIGTERM, given a grace period, then SIGKILLed if needed. Pass--killallto also run thekillall -9 openvpnsweep (the behavior of the original Go tool) during connection switching and shutdown.
- 🔍 Scan — recursively discover
.ovpnfiles and test them concurrently. - ⚡ Connect — intelligent retry, manual skip, deferred reshuffling.
- 🗂️ Recent — browse previously successful configs in a clickable TUI.
- 🎬 All — scan, store, then connect using only the filtered matches.
- 📦 Export — copy successful configs with sanitized, country-prefixed names.
- 🌍 Filter — filter by country code, case-insensitive, across every command.
- 🖱️ Click-to-copy — copy config paths from the recent TUI.
- 💾 SQLite (WAL mode) — persistent history with automatic migrations.
- 🩺 Doctor — environment and dependency checks.
- ⚙️ Completions — shell completions for bash/zsh/fish.
- 🔍 Country detection — filename heuristics, an IP cache, and a geo IP API.
- Rust 1.85+ (edition 2024)
- OpenVPN —
openvpnonPATH, or pass--openvpn-bin - Root/sudo for
scan,connectandall(OpenVPN needs root/CAP_NET_ADMINto open/dev/net/tunorutun; vmate-cli re-executes undersudoautomatically on an interactive terminal, preserving$HOMEso storage stays under your user account. SetVMATE_NO_ELEVATE=1to run without elevation) killall(optional) only if you pass--killallfor the global OpenVPN sweep
cargo build --release # optimized, stripped binary → target/release/vmate-cli
cargo test # unit + integration tests
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --checkReleases are built automatically when a v* tag is pushed. Each release
attaches a zip per platform (vmate-cli-<version>-<target>.zip):
| Target | Binary | Notes |
|---|---|---|
aarch64-apple-darwin |
Apple Silicon Macs | arm64 |
x86_64-apple-darwin |
Intel Macs | x86_64 |
x86_64-unknown-linux-gnu |
Linux x86_64 | most servers / desktops |
aarch64-unknown-linux-gnu |
Linux arm64 | Pi, AWS Graviton, etc. |
Windows is not shipped — vmate-cli is Unix-only (process groups, root checks,
killall when --killall is used).
Download and extract a zip, then install the binary to your PATH:
tar -xzf vmate-cli-1.0.1-aarch64-apple-darwin.zip
cd vmate-cli-1.0.1-aarch64-apple-darwin
sudo ./install.sh # copies vmate-cli → /usr/local/bin
vmate-cli --helpinstall.sh can also replace an existing install, install to a different
directory by editing the DEST/OPERATION variables at the top, and remove
itself with sudo ./install.sh --uninstall.
vmate-cli <COMMAND> [OPTIONS]
Run vmate-cli --help for all options and vmate-cli <COMMAND> --help for
per-command help.
These apply to every subcommand:
| Option | Description |
|---|---|
-f, --filter <COUNTRY> |
Filter by country code, e.g. jp,kr. Repeatable. |
--db <PATH> |
Path to the SQLite database (default ~/.config/vmate-cli/vmate.db). |
--openvpn-bin <BIN> |
OpenVPN binary to use (default openvpn). |
--killall |
Also run killall -9 openvpn on shutdown/switch. Default is per-process cleanup of only the openvpn processes vmate spawned. |
--no-elevate |
Don't re-execute under sudo; run with current privileges (OpenVPN will likely fail). |
--ipinfo-token <TOKEN> |
ipinfo.io API token (defaults to a bundled free token). |
--save-defaults |
Persist explicitly-passed default flags (e.g. --max, --timeout, --retry-count) to the config file for future sessions. |
-v, -vv, -q |
Verbosity / quiet logging. |
-h, --help |
Print help. |
The scan/connect tunables (--max, --limit, --timeout, --connect-timeout,
--cooldown, --retry-count, --stability-grace) have built-in defaults. Pass
--save-defaults alongside the value flags to persist them for future sessions:
vmate-cli scan --save-defaults --max 500 --timeout 20
vmate-cli connect --save-defaults --retry-count 5 --connect-timeout 10 --cooldown 60 --stability-grace 8--save-defaults only writes the new defaults and exits — it does not scan
or connect (no root/OpenVPN needed). Run the command again without
--save-defaults to actually scan/connect, resolving each value as
explicit flag → persisted → built-in default. scan/all persist their
workers (--max), --limit, and --timeout defaults; connect/all persist
--connect-timeout, --cooldown, --retry-count, and --stability-grace.
--retry-count controls how many times a failing config is retried before it is
dropped from history; --connect-timeout is the handshake threshold,
--cooldown the delay before retrying a recently-failed config, and
--stability-grace how long a connected session must last before its crash
resets the retry budget.
Only the flags you explicitly pass are saved; unmentioned tunables keep
their existing persisted or built-in default. Persisted settings live in
vmate-cli/settings.json inside your config directory (printed when you save)
and can be edited by hand. Each tunable is resolved as:
explicit CLI flag → persisted setting → built-in default
A missing or corrupt settings.json is ignored and falls back to the built-in
defaults.
# Scan ~/configs, keep testing until 20 Japan/Korea configs succeed.
# --max/-m controls concurrency, --timeout/-t is per-test seconds.
vmate-cli scan ~/configs --filter jp,kr --limit 20 --max 64 --timeout 15 -v
# Scan the built-in vpn-gate remotes over UDP (no directory argument)
vmate-cli scan --filter jp
# Try the same built-in remotes over TCP instead
vmate-cli scan --proto tcp
# Do not write results to the database
vmate-cli scan ~/configs --no-save
# Also copy this scan's filtered matches into ./out
vmate-cli scan ~/configs --filter jp --export ./outscan tests every .ovpn file it finds, stores the successful ones in the
database (so they show up in vmate-cli recent later), and reports the configs
that match the current --filter.
With no directory argument, scan materializes the built-in configs for the
chosen provider and protocol into ~/.config/vmate-cli/builtin/<provider>/<proto>/
and scans those. --provider selects the built-in provider (default vpn-gate)
and --proto selects the transport protocol (udp or tcp, default udp);
re-scan with --proto tcp to try the other protocol.
# Connect using stored JP candidates only
vmate-cli connect --filter jp
# Connect to an explicit config (fallbacks still respect the filter)
vmate-cli connect ./some.ovpn --filter jp
# Reject an explicit config that does not match the filter
vmate-cli connect ./us.ovpn --filter jp --strict-filterconnect picks candidates from the stored history, tries each one, retries a
failed handshake once, and drops a config from history after repeated failures.
Use the interactive keys below while connected.
# Show the last 50 successful configs in a TUI
vmate-cli recent
# Plain table output (no TUI)
vmate-cli recent --no-tui
# Show everything
vmate-cli recent --all
# Copy the newest config path immediately
vmate-cli recent --copy-first
# Also copy the listed configs into ./out
vmate-cli recent --filter jp --export ./outIn the TUI: press Enter or c to copy a config path, / to filter the list,
arrow keys / j k to move, and q / Ctrl+C to quit. Clicking a row copies
its path too.
# Scan, then connect using only the filtered matches
vmate-cli all ~/configs --filter jp,kr
# Scan the built-in vpn-gate remotes and connect (no directory argument)
vmate-cli all --filter jp,kr
# Scan and report only (do not connect)
vmate-cli all ~/configs --no-connect
# Scan, connect, and also export this scan's matches
vmate-cli all ~/configs --filter jp --export ./outall runs a full scan (storing successes as usual), reports the matches, then
hands them to the connect flow. --no-connect stops after the scan report. As
with scan, omitting the directory scans the built-in remotes for
--provider/--proto.
# Export JP configs to ./exported
vmate-cli export --filter jp --out ./exportedExports configs from the database, naming each file COUNTRY_<original name>
and avoiding collisions with _1, _2, ... suffixes. For exporting from a
fresh scan or from the recent list, use scan --export or recent --export
instead.
vmate-cli doctorChecks 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.
vmate-cli installs completion scripts for bash, zsh and fish into the
standard location for each shell, then prints how to activate them:
vmate-cli completions bash
vmate-cli completions zsh
vmate-cli completions fishFor zsh it prefers a Homebrew zsh-completions dir already on $fpath
(falling back to ~/.zfunc). After installing, restart your shell — or just
run compinit in zsh to pick it up immediately.
If you need the raw script (for example to capture it into a dotfiles repo),
use --print:
vmate-cli completions zsh --print > _vmate-cliManual one-liners, if you prefer to place the script yourself:
# bash
echo 'source <(vmate-cli completions bash --print)' >> ~/.bashrc
# zsh (with fpath + compinit)
mkdir -p ~/.zfunc && vmate-cli completions zsh --print > ~/.zfunc/_vmate-cli
echo 'fpath=(~/.zfunc $fpath); autoload -Uz compinit && compinit' >> ~/.zshrc
# fish
mkdir -p ~/.config/fish/completions
vmate-cli completions fish --print > ~/.config/fish/completions/vmate-cli.fish--filter is a global flag, case-insensitive, and can be repeated or comma
separated:
vmate-cli scan ./configs --filter JP,KR
vmate-cli scan ./configs --filter jp,kr
vmate-cli scan ./configs -f jp -f krUNKNOWN is an allowed value. An empty filter matches everything.
During a scan, the filter limits what is reported and exported, not what is
tested — unfiltered successes are still stored so they show up in
vmate-cli recent later.
Each config is tagged with a country using, in order:
- Filename heuristic — a two-letter code embedded in the file name, e.g.
vpngate_20260801_jp_vpn-gate.ovpn→JP. Fast, no network. - IP cache — the remote host is resolved and looked up in the SQLite cache.
- Geo IP API — a lookup against ipinfo.io, persisted to the cache.
By default vmate sends client IPs to ipinfo.io under a shared free-tier token,
so country detection works with no configuration. That shared token is
rate-limited and shared across all vmate-cli users — for privacy-sensitive
setups, provide your own token with --ipinfo-token or the IPINFO_TOKEN
environment variable (a warning is emitted when the shared token is in use).
Failures degrade to UNKNOWN — geo lookup never aborts a scan.
While connected:
n Next config (kill current, skip, defer)
r Reconnect to the same config
c Copy current config path
v Toggle live OpenVPN output log
? Show help
q Quit
Ctrl+C Quit and cleanup
v toggles a panel showing the OpenVPN process's output — the connection
handshake as well as live lines. c shows a Copied: ... confirmation that
fades after a few seconds. Keys respond immediately, even while a connection
is being established or switched. When a config is removed after repeated
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 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.
Two ways to copy configs out of vmate-cli:
vmate-cli scan <dir> --filter jp --export ./out— copy this scan's fresh matches. The scan still stores its successes, sovmate-cli recentis updated as usual.vmate-cli recent --filter jp --export ./out— copy previously scanned stored configs matching the filter.vmate-cli export --filter jp --out ./out— copy stored configs to an output directory.
Exported files are named COUNTRY_<sanitized-name>.ovpn; collisions get _1,
_2, ... suffixes.
The database lives at ~/.config/vmate-cli/vmate.db by default (override with
--db or the VMATE_DB environment variable). WAL mode is enabled and
migrations run automatically on startup:
sqlite3 ~/.config/vmate-cli/vmate.db "PRAGMA journal_mode;" # → walOne 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.
.
├── Cargo.toml # workspace
├── migrations/ # SQLite schema
├── crates/
│ ├── vmate-core/ # domain logic (UI-agnostic)
│ │ ├── country.rs / filter.rs # --filter parsing & matching
│ │ ├── db/ # SQLite pool, models, repository (WAL)
│ │ ├── ovpn/ # parser, cipher repair, process runner, monitor
│ │ ├── geo/ # country detection (filename/IP cache/geo API)
│ │ ├── scan/ # concurrent test orchestration
│ │ ├── connect/ # candidate queue + connect session
│ │ ├── export/ # sanitized config export
│ │ └── system/ # process killer, root, signals
│ └── vmate-cli/ # clap CLI, commands, TUIs, progress, clipboard
│ └── tests/ # integration tests (assert_cmd)
Design principles:
- No global mutable state. Everything is constructed per run and passed in.
- Traits for external effects.
VpnTester,OpenVpnRunner,ProcessKiller,GeoLocatorandConnectHostkeep the core testable. - RAII cleanup.
CleanupGuardandTuiGuardrestore the terminal and kill stale OpenVPN processes even on panic/error paths. - Structured concurrency.
JoinSet+Semaphore+CancellationTokenfor scans;tokio::select!for the interactive connect loop.