Multi-tunnel VPN orchestrator for macOS, Linux & Windows
OpenVPN FortiVPN OpenConnect sing-box xray-core WireGuard IPsec/IKEv2 Tailscale SSH tunnel + your plugin
Quick Start · How It Works · Configuration · CLI · Plugins
git clone https://github.com/enyonee/tunnelvault.git
cd tunnelvault
cp config.toml.example config.toml # edit with your infrastructure
./setup.sh # creates venv, installs deps
sudo ./tvpn # interactive wizard on first runTip
On first launch, the wizard collects missing parameters and saves them. Subsequent runs are automatic.
Parameters are resolved through: config.toml ──▸ ENV ──▸ wizard (results saved back to config.toml)
1. Cleanup — pkill stale VPN processes from previous runs, flush orphaned routes.
2. IPv6 — Disable on all interfaces to prevent leaks.
- macOS:
networksetup -setv6offper service - Linux:
sysctl net.ipv6.conf.all.disable_ipv6=1
3. Server routes — Pin VPN server IPs to current default gateway so VPN traffic itself doesn't get rerouted after tunnels come up.
- Hostnames from
[global.vpn_server_routes].resolveresolved via system DNS - Static IPs from
[global.vpn_server_routes].hostsadded directly - macOS:
route add -host <ip> <gw> - Linux:
ip route add <ip> via <gw>
4. Bypass routes — Same mechanism for IPs/domains from [global.bypass_routes] that must always skip VPN. Domain suffix bypass starts a DNS proxy (see below).
5. Connect — For each tunnel in order:
- Spawn daemon (
openvpn/openfortivpn/sing-box/wg-quick/swanctl/tailscale/ssh) with config - Wait for interface (
tun0,ppp0,utun99) to appear - Add routes from
[tunnels.<name>.routes]:- macOS:
route add -net <cidr> -interface <iface>/route add -host <ip> <gw> - Linux:
ip route add <cidr> dev <iface>/ip route add <ip> via <gw>
- macOS:
- Create DNS resolver entries from
[tunnels.<name>.dns]:- macOS: write
/etc/resolver/<domain>withnameserver <ip> - Linux:
resolvectl dns <iface> <ip>,resolvectl domain <iface> <domain>
- macOS: write
6. Health checks — Per tunnel, run checks defined in [tunnels.<name>.checks]:
ports— TCP connect tohost:portping— ICMP, withfallback = "port:53"if ICMP blockeddns—dig @<server> <name>, verify non-empty answerhttp—curlendpoint, check HTTP 200external_ip_url— fetch external IP, display in summary
7. Summary — Print pass/fail table and log paths.
Two formats for defining per-tunnel routes:
targets (auto-parsed):
targets = ["*.example.local", "10.0.0.0/8", "192.168.1.1", "git.internal.com"]| Pattern | Parsed as | System effect |
|---|---|---|
*.example.local |
DNS domain | /etc/resolver/example.local → tunnel nameservers |
10.0.0.0/8 |
Network CIDR | route add -net 10.0.0.0/8 -interface ppp0 |
192.168.1.1 |
Host IP | route add -host 192.168.1.1 <tunnel_gw> |
git.internal.com |
Hostname | Resolve → IP → host route |
Explicit (when you need full control):
[tunnels.fortivpn.routes]
networks = ["10.0.0.0/8"]
hosts = ["10.0.1.1"]
[tunnels.fortivpn.dns]
nameservers = ["10.0.1.1"]
domains = ["example.local"]For domain suffixes that must not go through VPN (configured in [global.bypass_routes].domain_suffix):
App resolves *.ru
│
▼
/etc/resolver/ru → nameserver 127.0.0.1
│
▼
TunnelVault DNS proxy (127.0.0.1:53)
│
├─ Matches suffix? ──▸ Forward to upstream_dns (e.g. 8.8.8.8)
│ Route 8.8.8.8 pinned to default GW
│ Add host route for resolved IP → default GW
│
└─ No match ──▸ Forward to system default DNS
All injected routes are cleaned up on disconnect.
--disconnect — reverse order:
- Kill VPN daemon by PID (fallback:
pkill -f <pattern>) - Delete routes:
route delete -net <cidr>,route delete -host <ip> - Remove
/etc/resolver/<domain>files (macOS) or resetresolvectl(Linux) - Stop DNS proxy, delete injected routes
- Restore IPv6
--reset — emergency mode: pkill all known process names (openvpn, openfortivpn, sing-box, xray, wg-quick, charon, tailscaled, ssh, sshuttle) without config context.
Ctrl+C / SIGTERM triggers graceful disconnect. Broken state falls back to emergency kill.
Each tunnel is a [tunnels.<name>] section in config.toml:
Full example
[global.vpn_server_routes]
hosts = ["203.0.113.10"]
resolve = ["vpn.example.com"]
[global.bypass_routes]
domains = ["external-service.com"]
domain_suffix = [".ru"]
upstream_dns = "8.8.8.8"
# ─── OpenVPN ──────────────────────────────────────────────────
[tunnels.openvpn]
type = "openvpn"
order = 1
config_file = "client.ovpn"
[tunnels.openvpn.checks]
http = ["https://google.com"]
external_ip_url = "https://ifconfig.me"
# ─── FortiVPN ─────────────────────────────────────────────────
[tunnels.fortivpn]
type = "fortivpn"
order = 2
[tunnels.fortivpn.auth]
host = "vpn.example.com"
port = "44333"
cert_mode = "auto"
[tunnels.fortivpn.routes]
targets = ["*.example.local", "10.0.0.0/8", "192.168.100.0/24"]
[tunnels.fortivpn.dns]
nameservers = ["10.0.1.1", "10.0.1.2"]
[tunnels.fortivpn.checks]
ports = [{ host = "192.168.100.1", port = 8080 }]
ping = [{ host = "10.0.1.1", label = "DNS-1", fallback = "port:53" }]
dns = [{ name = "app.example.local", server = "10.0.1.1" }]
# ─── sing-box ─────────────────────────────────────────────────
[tunnels.singbox]
type = "singbox"
order = 3
config_file = "singbox.json"
interface = "utun99"
[tunnels.singbox.routes]
networks = ["172.18.0.0/16"]
[tunnels.singbox.checks]
ports = [{ host = "203.0.113.30", port = 443 }]
# ─── xray-core (TUN mode) ─────────────────────────────────────
[tunnels.xray]
type = "xray"
order = 4
config_file = "xray.json"
interface = "utun98"
# mode = "tun" # default; requires tun inbound in xray.json + sudo
[tunnels.xray.routes]
networks = ["172.19.0.0/16"]
[tunnels.xray.checks]
ports = [{ host = "203.0.113.40", port = 443 }]
# ─── xray-core (SOCKS proxy mode, no sudo) ────────────────────
[tunnels.xray-proxy]
type = "xray"
order = 5
config_file = "xray-proxy.json"
mode = "proxy" # xray.json must declare socks/http inbound on 127.0.0.1:socks_port
socks_port = 10808| File | Purpose |
|---|---|
config.toml.example |
Template - copy and edit |
config.toml |
Your config (gitignored, wizard saves here too) |
Environment Variables
export VPN_FORTI_HOST="vpn.company.com"
export VPN_FORTI_LOGIN="user"
export VPN_FORTI_PASS="secret"
export VPN_FORTI_PORT="44333"
export VPN_CERT_MODE="auto"
export VPN_TRUSTED_CERT="sha256hash..."
export VPN_OVPN_CONFIG="client.ovpn"
export VPN_SINGBOX_CONFIG="singbox.json"
export VPN_XRAY_CONFIG="xray.json"IPv6 (experimental opt-in)
By default TunnelVault disables IPv6 on the system to prevent leaks through the dual stack while only IPv4 traffic is routed via the VPN. This is intentional defense, not a bug.
You can opt in to keep IPv6 enabled:
[global]
ipv6 = trueWhat this does NOT do: routes IPv6 through the VPN tunnel. Your real IPv6 address stays visible to external sites - traffic goes out via your ISP's default gateway. Full IPv6 support (routes, kill switch, DNS) is tracked in #5.
FortiVPN limitation: openfortivpn is IPv4-only, so the flag is ignored and IPv6 is force-disabled whenever a FortiVPN tunnel is present. Remove the FortiVPN tunnel or set ipv6 = false explicitly if you see the warning.
When to enable: you have a custom IPv6 configuration (static, DHCPv6, link-local-only) on macOS that you do not want TunnelVault to touch. On macOS restore_ipv6 (called on disconnect) runs networksetup -setv6automatic, which overwrites custom IPv6 settings.
| Connect |
sudo ./tvpn # all tunnels (wizard on first run)
sudo ./tvpn --setup # force wizard
sudo ./tvpn --clear # kill stale sessions first
sudo ./tvpn --only fortivpn # specific tunnel |
| Disconnect |
sudo ./tvpn --disconnect # all tunnels
sudo ./tvpn --disconnect --only fortivpn # specific tunnel
sudo ./tvpn --reset # emergency kill |
| Monitor |
sudo ./tvpn --check # health checks on running tunnels
sudo ./tvpn --status # interfaces, routes, DNS, processes
sudo ./tvpn --watch # live dashboard |
| Tools |
sudo ./tvpn --validate # validate config without connecting
sudo ./tvpn --logs # list log paths
sudo ./tvpn --logs fortivpn # tail specific log |
Each VPN type extends TunnelPlugin and registers via @register:
from tv.vpn.base import TunnelPlugin, VPNResult, ConfigParam
from tv.vpn.registry import register
@register("myvpn")
class MyVPNPlugin(TunnelPlugin):
type_display_name = "MyVPN"
process_names = ["myvpn-daemon"]
@classmethod
def config_schema(cls) -> list[ConfigParam]:
return [
ConfigParam(key="host", label="param.host", required=True, env_var="VPN_MY_HOST"),
ConfigParam(key="token", label="param.token", required=True, secret=True),
]
@property
def process_name(self) -> str:
return "myvpn-daemon"
def connect(self) -> VPNResult:
...
return VPNResult(ok=True, pid=pid, detail="connected")Tip
Drop your plugin into tv/vpn/, import in tunnelvault.py, add [tunnels.myname] with type = "myvpn".
| Plugin | Process | Interface | Highlights |
|---|---|---|---|
| OpenVPN | openvpn |
tun0 |
Tunnelblick detection, .ovpn config, external IP |
| FortiVPN | openfortivpn |
ppp0 |
Auto cert trust, PPP gateway discovery, split routing |
| OpenConnect | openconnect |
tun1 |
FortiGate via TUN (replaces PPP), SAML/cert auth |
| sing-box | sing-box |
utun99 |
JSON config, custom interface |
| xray-core (XTLS/Xray-core) | xray |
utun98 |
VMess/VLESS/Trojan/Shadowsocks, REALITY, XTLS, TUN + SOCKS proxy modes |
| WireGuard | wg-quick |
wg0 |
Client mode, config via wg0.conf |
| IPsec/IKEv2 | swanctl |
xfrm | strongSwan, PSK/cert auth, corporate VPN |
| Tailscale | tailscale |
tailscale0 |
Mesh VPN, Headscale support, auth-key, exit nodes |
| SSH tunnel | ssh |
- | SOCKS proxy (ssh -D) or transparent routing (sshuttle) |
sing-box: multiple outbounds with auto-failover
Use urltest outbound to auto-select the fastest working server from multiple outbounds.
[!IMPORTANT] Add all outbound server IPs to
[global.vpn_server_routes].hostsinconfig.toml- otherwise tunnel traffic loops through itself.
xray-core: TUN vs SOCKS proxy mode
TUN mode (default, mode = "tun"): requires sudo, xray config.json must declare a tun inbound (Xray 1.8.x+), routes/DNS applied through interface.
SOCKS proxy mode (mode = "proxy"): no sudo, xray config.json must declare a socks (or http) inbound on 127.0.0.1:<socks_port>. Plugin does not patch config - it only waits for the port to open and reports success. No routes/DNS are set; clients must opt in via SOCKS5://127.0.0.1:<port> or all_proxy=socks5://....
[!TIP] Proxy mode is easier to set up (no TUN inbound, no sudo) but requires per-app proxy configuration. TUN mode is transparent for all traffic routed through the interface.
Cross-platform implementation
| Function | macOS | Linux | Windows |
|---|---|---|---|
| Routing | route add/delete |
ip route add/del |
route ADD/DELETE |
| DNS | /etc/resolver/* |
resolvectl |
NRPT rules (PowerShell) |
| Interfaces | ifconfig -a |
ip -br addr |
ipconfig |
| IPv6 | networksetup |
sysctl |
Disable-NetAdapterBinding |
| Gateway | route -n get default |
ip route show default |
route PRINT |
| Processes | pgrep/pkill |
pgrep/pkill |
wmic/taskkill |
| Port check | nc -z |
nc -z |
socket.connect |
- WireGuard plugin - client mode via
wg-quick - OpenConnect plugin - FortiGate via TUN interface
- IPsec/IKEv2 plugin - strongSwan
swanctl, PSK/cert auth - Tailscale plugin - mesh VPN, Headscale support, auth-key
- SSH tunnel plugin - SOCKS proxy + sshuttle transparent routing
- IPC daemon - unix socket between daemon and CLI
- Auto-reconnect - network wait with debounce after sleep/wake
- Single
config.tomlfor all settings -
--checkrerun - re-run health checks with retry/loop until all pass - Kill switch - block non-VPN traffic
- Plugin-defined checks - each VPN plugin declares default checks in code, no manual TOML needed
- Configurable check list - override/extend plugin checks via external file
- Windows support - routing, DNS, process management for Windows
- Windows VPN plugins - adapt openvpn/sing-box plugins for Windows paths and adapters
- Windows daemon - Task Scheduler integration for keepalive mode
- IPv6 support - foundation opt-in flag in place (#5), routing/kill-switch/DNS pending
Python 3.10+ · macOS, Linux, or Windows · sudo / Run as Administrator · VPN tools you need (openvpn, openfortivpn, sing-box, xray, wg-quick, swanctl, tailscale, ssh)
Warning
TunnelVault modifies routing tables and DNS configuration. Review your config.toml before running. Use --validate to dry-run.