tunmux is a multi-provider VPN CLI written in Rust.
Supported platforms:
- Linux: direct mode,
--proxymode, and--local-proxymode - macOS: direct mode and
--local-proxymode (--proxyis not available)
It supports Proton VPN, AirVPN, Mullvad, IVPN, and local WireGuard config profiles (wgconf) with WireGuard connectivity in:
- direct mode (system-wide routing)
- Linux namespace proxy mode (
--proxy, isolated per-connection namespace) - local-proxy mode (
--local-proxy, userspace SOCKS5/HTTP proxy without root)
- Connect/disconnect VPN sessions across multiple providers
- Run multiple VPN exits side-by-side in proxy mode
- In proxy mode, keep host traffic unchanged unless an app explicitly uses a proxy
- Run rootless userspace local-proxy mode with
--local-proxy(nosudorequired) - Manage provider-specific account and utility commands from one CLI
- Support multiple WireGuard backends:
wg-quick,userspace,kernel
- Rust (stable, edition 2021)
- Linux for full feature set (kernel backend +
--proxynamespace isolation) - macOS for direct mode and
--local-proxymode (--proxyis Linux-only) sudoaccess for privileged operations (tunmux privileged --serve) used by direct mode and--proxy
userspace mode uses the embedded gotatun library through a built-in helper; no separate gotatun CLI install is required.
--local-proxy uses that userspace engine to run a SOCKS5/HTTP proxy without root.
Optional:
- systemd socket activation via
systemd/tunmux-privileged.socket - keyring storage via
cargo build --features keyring
cargo build
cargo build --features keyringPushing a v* tag (for example v1.2.3) triggers .github/workflows/release.yml to:
- run
cargo test --locked - build release binaries for Linux, macOS, and Android targets via
.github/workflows/manual-build.yml - build an Android app APK from
android/via.github/workflows/manual-build-android.yml - upload tarballs and SHA256 files to a GitHub Release for that tag
Publishing that GitHub Release then triggers .github/workflows/docker.yml (which calls .github/workflows/docker-publish.yml) to build and publish a multi-arch Docker image to GHCR.
Binary version output follows the tag in CI builds:
tunmux --versionContainer tags:
ghcr.io/<owner>/tunmux:<tag>ghcr.io/<owner>/tunmux:latest(only for non-prerelease tags)
tunmux proton login <username>
tunmux connect proton --country CH --backend wg-quick
tunmux status
tunmux disconnect --provider proton# First proxy instance (typically SOCKS5 1080, HTTP 8118)
tunmux connect proton --proxy --country US
# Second proxy instance (next available ports)
tunmux connect proton --proxy --country CH
# Use a specific proxy
curl --socks5 127.0.0.1:1080 https://api.ipify.org
# Host traffic remains unchanged unless using proxy
curl https://api.ipify.org# No sudo required; app traffic goes through SOCKS5/HTTP proxy
tunmux connect proton --local-proxy --country US
# Use the proxy from an app/tool
curl --socks5 127.0.0.1:1080 https://api.ipify.org
# Stop local-proxy instance(s)
tunmux disconnect --provider protonTop-level commands:
tunmux status
tunmux connect <provider> [provider connect flags]
tunmux disconnect [instance] [--provider <provider>] [--all]
tunmux hook run <connectivity|external-ip|dns-detection>
tunmux hook debug [instance] [--provider <provider>] [--event ifup|ifdown]
tunmux proton <...>
tunmux airvpn <...>
tunmux mullvad <...>
tunmux ivpn <...>
tunmux wgconf <...>Common provider flows:
tunmux <provider> login ...
tunmux <provider> info
tunmux <provider> servers [--country XX] [--tag ...] [--sort ...]
tunmux connect <provider> [server] [--country XX] [--sort ...] [--backend ...] [--mtu N] [--proxy|--local-proxy]
tunmux disconnect [instance] [--provider <provider>] [--all]
tunmux <provider> logoutwgconf provider flow:
tunmux connect wgconf --file ./my-tunnel.conf
tunmux connect wgconf --file ./my-tunnel.conf --save-as work
tunmux connect wgconf --profile work
tunmux wgconf list
tunmux wgconf remove workLegacy provider-prefixed forms (tunmux <provider> connect ..., tunmux <provider> disconnect ...) remain supported for compatibility.
Disconnect semantics:
tunmux disconnect --all # all providers
tunmux disconnect --provider proton --all # proton only
tunmux disconnect --provider wgconf --all # wgconf only
tunmux disconnect <instance> # exact instance
tunmux disconnect --provider proton # provider-scoped single/list behavior
# short forms
tunmux disconnect -a
tunmux disconnect -p proton -aCommon short forms:
connect:-c(country),-s(sort),-b(backend)disconnect:-p(provider),-a(all)servers:-c(country),-t(tag),-s(sort)
Use verbose logs when needed:
tunmux -v connect proton --country CH
RUST_LOG=debug tunmux disconnect --alltunmux proton login <username>
tunmux proton info
tunmux proton servers --country US --free
tunmux proton servers --country CH --tag p2p --sort latency
tunmux connect proton US#1
tunmux connect proton --country CH --p2p
tunmux connect proton --country CH --p2p --port-forwarding
tunmux connect proton --country CH --sort latency
tunmux connect proton FR#183 --mtu 1280
tunmux proton ports request --protocol both
tunmux proton ports request --protocol both --no-daemon
tunmux proton ports list
tunmux proton ports list --current
tunmux proton ports list --current --json
tunmux proton ports renew --lifetime 60
tunmux proton ports daemon --protocol both --lifetime 60 --renew-every 45
tunmux proton ports release
tunmux disconnect --provider proton --all
tunmux proton logoutProton NAT-PMP notes:
tunmux proton ports request ...now starts the background renew daemon by default.- Use
--no-daemonfor a one-shot mapping request. - Default daemon renew interval is
lifetime - 15s(minimum1s). tunmux proton ports listshows saved mappings (including expired ones).tunmux proton ports list --currentshows only active mappings for the current direct Proton connection.tunmux proton ports list --current --jsonreturns the same filtered view as JSON.tunmux proton ports releasestops the renew daemon and sends NAT-PMP unmap (lifetime0) for saved mappings on the active direct connection.- Daemon state files:
- PID:
~/.config/tunmux/proton/port_forward_daemon.pid - Log:
~/.config/tunmux/proton/port_forward_daemon.log
- PID:
Typical lifecycle:
tunmux connect proton --country DE --port-forwarding
tunmux proton ports request --protocol both # request + auto-start renew daemon
tunmux proton ports list --current --json # inspect currently active mapping(s)
tunmux proton ports release # stop daemon + release mapping(s)tunmux airvpn login <username>
tunmux airvpn info
tunmux airvpn servers --country NL --tag nl
tunmux airvpn servers --sort latency
tunmux connect airvpn Castor
tunmux connect airvpn --country DE --key "my device"
tunmux connect airvpn --country DE --sort latency
tunmux airvpn sessions
tunmux airvpn generate -s nl -s be -p wg-1637 -o config.conf
tunmux airvpn ports list
tunmux airvpn ports add 8080 --protocol tcp --ddns myhost
tunmux airvpn devices list
tunmux airvpn api list
tunmux disconnect --provider airvpn --all
tunmux airvpn logouttunmux mullvad login <account_number>
tunmux mullvad create-account
tunmux mullvad payment monero --json
tunmux mullvad info
tunmux mullvad servers --country US --tag us-nyc
tunmux mullvad servers --sort latency
tunmux connect mullvad us-nyc-wg-401
tunmux connect mullvad --country SE --sort latency
tunmux disconnect --provider mullvad
tunmux mullvad logouttunmux ivpn create-account
tunmux ivpn create-account --product pro
tunmux ivpn payment monero --duration 1m
tunmux ivpn login <account_id>
tunmux ivpn info
tunmux ivpn servers --country US --tag us1
tunmux ivpn servers --sort latency
tunmux connect ivpn us-ny4.wg.ivpn.net
tunmux connect ivpn --country US --sort latency
tunmux disconnect --provider ivpn
tunmux ivpn logouttunmux connect wgconf --file ./my-tunnel.conf --backend wg-quick
tunmux connect wgconf --file ./my-tunnel.conf --save-as office
tunmux connect wgconf --profile office --local-proxy
tunmux connect wgconf --file ./ipv4-only.conf --backend kernel --disable-ipv6
tunmux connect wgconf --file ./my-tunnel.conf --backend kernel --mtu 1280
tunmux wgconf save --file ./my-tunnel.conf --name backup
tunmux wgconf list
tunmux wgconf remove backup
tunmux disconnect --provider wgconf--disable-ipv6 is supported by connect for proton, airvpn, mullvad,
ivpn, and wgconf. It is accepted only for direct kernel mode (no
--proxy/--local-proxy) and only when the selected WireGuard config has no
IPv6 interface address.
--mtu is supported by provider connect commands. For most providers it
applies to direct and proxy kernel tunnels, as well as generated wg-quick and
userspace configs. For wgconf, --mtu is supported in kernel mode (direct
or --proxy) and is not supported with --local-proxy.
Each --proxy connection creates:
- a dedicated Linux network namespace
- a dedicated WireGuard interface in that namespace
- a local SOCKS5 and HTTP proxy bound on localhost
Multiple instances can run at once, each with different exits and ports.
--local-proxy starts a userspace WireGuard tunnel and local SOCKS5/HTTP proxy without sudo.
- no root/privileged daemon required
- no host routing changes (only apps configured to use the proxy are tunneled)
- available on Linux and macOS
- supports multiple instances with the same auto-port behavior as
--proxy - hostname resolution for proxy requests prefers the VPN-pushed DNS servers
- set
TUNMUX_LOCAL_PROXY_DNS_SERVERS(orTUNMUX_DNS_SERVERS) to override local-proxy DNS resolver servers (comma or whitespace separated)
Port behavior:
- default scan starts at
1080(SOCKS5) and8118(HTTP) - each new instance picks the next available localhost ports
- override with
--socks-portand--http-port
Instance naming is derived from the selected server and used in status/disconnect commands.
Direct mode is the default when neither --proxy nor --local-proxy is used.
- one direct connection is active at a time
- host traffic is routed through that WireGuard tunnel
- stored internally as
_directconnection state
Direct, --proxy, and --local-proxy sessions can coexist.
If multiple instances exist for a provider, running disconnect without an instance will prompt selection:
tunmux disconnect --provider protonDisconnect all for one provider:
tunmux disconnect --provider proton --allDisconnect all active connections across all providers:
tunmux disconnect --alltunmux reads optional defaults from:
~/.config/tunmux/config.toml
Example:
[general]
backend = "kernel" # default: kernel on unix (except macOS), wg-quick on macOS
credential_store = "keyring" # keyring or file
proxy_access_log = false
hooks = { ifup = ["builtin:connectivity", "builtin:external-ip"], ifdown = [] }
privileged_transport = "socket" # socket or stdio
privileged_autostart = true
privileged_autostart_timeout_ms = 5000
privileged_authorized_group = "tunmux"
privileged_autostop_mode = "never" # never, command, timeout
privileged_autostop_timeout_ms = 30000
[proton]
default_country = "CH"
hooks = { ifup = ["/usr/local/bin/proton-ifup.sh"], ifdown = ["/usr/local/bin/proton-ifdown.sh"] }
[airvpn]
default_country = "NL"
default_device = "laptop"
hooks = { ifup = [], ifdown = [] }
[mullvad]
default_country = "SE"
hooks = { ifup = [], ifdown = [] }
[ivpn]
default_country = "CH"
hooks = { ifup = [], ifdown = [] }
[wgconf]
hooks = { ifup = [], ifdown = [] }CLI flags override config values.
Hook behavior:
general.hooksruns for every provider, then provider-specific hooks run after it.ifupruns after successful connect;ifdownruns after successful disconnect.- Built-ins are opt-in via hook entries:
builtin:connectivity: ping IPv4 (1.1.1.1) and IPv6 (2606:4700:4700::1111)builtin:external-ip: fetch external IP viahttps://ipinfo.ioandhttps://v6.ipinfo.iobuiltin:dns-detection: queryhttps://<random>-<n>.ipleak.net/dnsdetection/with a 40-char random host label and incrementing probe number (-1to-10), then run reverse DNS lookup for each recovered resolver IP
- Hook commands run with env vars such as
TUNMUX_HOOK_EVENT,TUNMUX_PROVIDER,TUNMUX_INSTANCE,TUNMUX_BACKEND,TUNMUX_INTERFACE,TUNMUX_SERVER,TUNMUX_ENDPOINT, plus optional proxy fields (TUNMUX_NAMESPACE,TUNMUX_SOCKS_PORT,TUNMUX_HTTP_PORT,TUNMUX_PROXY_PID). - Hook env also includes
TUNMUX_DNS_SERVERSwhen VPN DNS servers are known. - When proxy ports are present, hook commands also receive standard proxy vars:
HTTP_PROXY/HTTPS_PROXYandALL_PROXY(plus lowercase variants). - Builtin HTTP checks (
external-ip,dns-detection, and proxy-modeconnectivity) use the active connection proxy automatically when available. dns-detectionreverse DNS lookup prefers VPN-configured DNS servers first.- Manual builtin checks:
tunmux hook run connectivity,tunmux hook run external-ip, ortunmux hook run dns-detection. - Debug helper:
tunmux hook debug <instance>prints the exact env payload used for hooks (--event ifup|ifdown, defaultifup).
Privileged operations are handled by:
sudo tunmux privileged --serve --authorized-group <group>Supported transports:
socket(default): Unix socket control channel (/var/run/tunmux/ctl.sock, typically/run/tunmux/ctl.sock)stdio: one-shot helper process over stdin/stdout
Autostart can launch the privileged service when needed (if enabled in config).
Example sudoers entries (adjust binary path for your install):
<user-or-group> ALL=(root) NOPASSWD: /usr/bin/tunmux privileged --serve --authorized-group tunmux
<user-or-group> ALL=(root) NOPASSWD: /usr/bin/tunmux privileged --serve --autostarted --authorized-group tunmux
<user-or-group> ALL=(root) NOPASSWD: /usr/bin/tunmux privileged --serve --autostarted --authorized-group tunmux --idle-timeout-ms *For stdio mode:
<user-or-group> ALL=(root) NOPASSWD: /usr/bin/tunmux privileged --serve --stdio --autostarted --authorized-group tunmuxUser data under ~/.config/tunmux/:
~/.config/tunmux/
config.toml
connections/
_direct.json
<instance>.json
proton/
session.json
manifest.json
airvpn/
session.json
manifest.json
web_session.json
mullvad/
account_id.json
session.json
manifest.json
ivpn/
account_id.json
session.json
manifest.json
wgconf/
profiles/
<name>.conf
Runtime state (Linux):
/var/run/tunmux/
ctl.sock
managed-pids/
<pid>.start
/var/lib/tunmux/
proxy/
<instance>.pid
<instance>.log
wg/
<provider>/<iface>.conf
Runtime state (macOS):
/var/db/tunmux/
proxy/
wg/
MIT
Copyright (c) 2026 Contributors to tunmux