Skip to content

enyonee/TunnelVault

Repository files navigation

TunnelVault

Multi-tunnel VPN orchestrator for macOS, Linux & Windows

Python Platform License Version Tests

OpenVPN   FortiVPN   OpenConnect   sing-box   xray-core   WireGuard   IPsec/IKEv2   Tailscale   SSH tunnel   + your plugin

Quick Start · How It Works · Configuration · CLI · Plugins


Quick Start

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 run

Tip

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)

How It Works

Startup sequence

1. Cleanuppkill stale VPN processes from previous runs, flush orphaned routes.

2. IPv6 — Disable on all interfaces to prevent leaks.

  • macOS: networksetup -setv6off per 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].resolve resolved via system DNS
  • Static IPs from [global.vpn_server_routes].hosts added 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>
  • Create DNS resolver entries from [tunnels.<name>.dns]:
    • macOS: write /etc/resolver/<domain> with nameserver <ip>
    • Linux: resolvectl dns <iface> <ip>, resolvectl domain <iface> <domain>

6. Health checks — Per tunnel, run checks defined in [tunnels.<name>.checks]:

  • ports — TCP connect to host:port
  • ping — ICMP, with fallback = "port:53" if ICMP blocked
  • dnsdig @<server> <name>, verify non-empty answer
  • httpcurl endpoint, check HTTP 200
  • external_ip_url — fetch external IP, display in summary

7. Summary — Print pass/fail table and log paths.

Routing

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"]

DNS bypass proxy

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

--disconnect — reverse order:

  1. Kill VPN daemon by PID (fallback: pkill -f <pattern>)
  2. Delete routes: route delete -net <cidr>, route delete -host <ip>
  3. Remove /etc/resolver/<domain> files (macOS) or reset resolvectl (Linux)
  4. Stop DNS proxy, delete injected routes
  5. 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.

Configuration

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 = true

What 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.

CLI

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

Plugin System

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].hosts in config.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

Roadmap

  • 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.toml for all settings
  • --check rerun - 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

Requirements

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.


Built for teams that run multiple VPNs and hate doing it manually ⚡

About

Multi-tunnel VPN orchestrator for macOS, Linux & Windows - OpenVPN, FortiVPN, sing-box with split routing, DNS bypass proxy, and plugin system

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages